Compare commits

..

260 Commits

Author SHA1 Message Date
Deluan
e111c5832f feat: implement mock service instances for non-WASM builds using testify/mock
Signed-off-by: Deluan <deluan@navidrome.org>
2025-12-31 19:34:14 -05:00
Deluan
6b0ea10f4e fix: handle URL parsing errors and use atomic upsert in plugin repository
Added proper error handling for url.Parse calls in PublicURL and AbsoluteURL
functions. When parsing fails, PublicURL now falls back to AbsoluteURL, and
AbsoluteURL logs the error and returns an empty string, preventing malformed
URLs from being generated.

Replaced the non-atomic UPDATE-then-INSERT pattern in plugin repository Put
method with a single atomic INSERT ... ON CONFLICT statement. This eliminates
potential race conditions and improves consistency with the upsert pattern
already used in host_kvstore.go.
2025-12-31 17:06:33 -05:00
Deluan
aa207fb521 docs: update README to include Rust PDK crate information for plugin developers
Signed-off-by: Deluan <deluan@navidrome.org>
2025-12-31 17:06:33 -05:00
Deluan
e2f64880e6 docs: clarify artwork URL generation capabilities in service descriptions
Signed-off-by: Deluan <deluan@navidrome.org>
2025-12-31 17:06:33 -05:00
Deluan
69ded80806 docs: correct example link for library inspector in README
Signed-off-by: Deluan <deluan@navidrome.org>
2025-12-31 17:06:33 -05:00
Deluan
477cec93a9 docs: update Go and Rust plugin developer sections for clarity
Signed-off-by: Deluan <deluan@navidrome.org>
2025-12-31 17:06:33 -05:00
Deluan
7b0db4f8d6 fix(plugins): update response types in testMetadataAgent methods to use pointers
Signed-off-by: Deluan <deluan@navidrome.org>
2025-12-31 17:06:33 -05:00
Deluan
ad9cda9d57 feat(plugins): implement XTP JSONSchema validation for generated schemas
Signed-off-by: Deluan <deluan@navidrome.org>
2025-12-31 17:06:33 -05:00
Deluan
e3bfcff8c4 test(plugins): add unit tests for rustOutputType and isPrimitiveRustType functions
Signed-off-by: Deluan <deluan@navidrome.org>
2025-12-31 17:06:33 -05:00
Deluan
6698e94a9c fix(plugins): update IsAuthorized method to return boolean instead of response object
Signed-off-by: Deluan <deluan@navidrome.org>
2025-12-31 17:06:33 -05:00
Deluan
6fff476e93 fix(plugins): enhance type handling for Rust and XTP output in capability generation
Signed-off-by: Deluan <deluan@navidrome.org>
2025-12-31 17:06:33 -05:00
Deluan
e6b0af63ce fix(plugins): update return types in metadata interfaces to use pointers
Signed-off-by: Deluan <deluan@navidrome.org>
2025-12-31 17:06:33 -05:00
Deluan
d6b412acde refactor(plugins): update plugin instance creation to accept context for cancellation support
Signed-off-by: Deluan <deluan@navidrome.org>
2025-12-31 17:06:33 -05:00
Deluan
8d586f7425 docs: update README to reflect changes in plugin import paths
Signed-off-by: Deluan <deluan@navidrome.org>
2025-12-31 17:06:33 -05:00
Deluan
451475a7af test(plugins): conditionally run goleak checks based on CI environment
Signed-off-by: Deluan <deluan@navidrome.org>
2025-12-31 17:06:33 -05:00
Deluan
dc5100e56a fix(plugins): generate host wrappers
Signed-off-by: Deluan <deluan@navidrome.org>
2025-12-31 17:06:33 -05:00
Deluan
4678da1e5b docs: update plugin registration docs
Signed-off-by: Deluan <deluan@navidrome.org>
2025-12-31 17:06:33 -05:00
Deluan
1fc1a667f1 refactor(plugins): update export wrappers to use //go:wasmexport for WebAssembly compatibility
Signed-off-by: Deluan <deluan@navidrome.org>
2025-12-31 17:06:33 -05:00
Deluan
59085145f5 refactor(plugins): rename scheduler callback methods for consistency and clarity
Signed-off-by: Deluan <deluan@navidrome.org>
2025-12-31 17:06:33 -05:00
Deluan
e6e2582abf refactor(plugins): update macro names for websocket and metadata registration to improve clarity and consistency
Signed-off-by: Deluan <deluan@navidrome.org>
2025-12-31 17:06:33 -05:00
Deluan
06d75476f6 refactor(plugins): update Discord Rich Presence and Library Inspector plugins to use nd-pdk for service calls and implement lifecycle management
Signed-off-by: Deluan <deluan@navidrome.org>
2025-12-31 17:06:33 -05:00
Deluan
4f260c058d refactor(plugins): reorganize Rust output structure to follow standard conventions
Signed-off-by: Deluan <deluan@navidrome.org>
2025-12-31 17:06:33 -05:00
Deluan
67ab3dc81a feat: implement Rust PDK
Signed-off-by: Deluan <deluan@navidrome.org>
2025-12-31 17:06:33 -05:00
Deluan
ae41164c1f refactor(plugins): consolidate PDK module path and update Go version to 1.25
Signed-off-by: Deluan <deluan@navidrome.org>
2025-12-31 17:06:33 -05:00
Deluan
4ea54fe176 build: mark .wasm files as intermediate for cleanup after building .ndp
Signed-off-by: Deluan <deluan@navidrome.org>
2025-12-31 17:06:33 -05:00
Deluan
ca36c5df13 refactor(plugins): update request/response types to use private naming conventions
Signed-off-by: Deluan <deluan@navidrome.org>
2025-12-31 17:06:32 -05:00
Deluan
7cad08ad09 refactor(plugins): update function signatures to return values directly instead of response structs
Signed-off-by: Deluan <deluan@navidrome.org>
2025-12-31 17:06:32 -05:00
Deluan
11d99ef673 refactor(plugins): update error handling for methods to return errors directly
Signed-off-by: Deluan <deluan@navidrome.org>
2025-12-31 17:06:32 -05:00
Deluan
6ee64ceeec refactor(plugins): reorganize and sort type definitions for consistency
Signed-off-by: Deluan <deluan@navidrome.org>
2025-12-31 17:06:32 -05:00
Deluan
f221c01bd9 test: make all test plugins use the PDK
Signed-off-by: Deluan <deluan@navidrome.org>
2025-12-31 17:06:32 -05:00
Deluan
f1b85e6a19 refactor(plugins): update scrobbler interface to return errors directly instead of response structs
Signed-off-by: Deluan <deluan@navidrome.org>
2025-12-31 17:06:32 -05:00
Deluan
d43724c571 refactor(plugins): streamline plugin function signatures and error handling
Signed-off-by: Deluan <deluan@navidrome.org>
2025-12-31 17:06:32 -05:00
Deluan
301b2c850c refactor(plugins): consistent naming/types across PDK
Signed-off-by: Deluan <deluan@navidrome.org>
2025-12-31 17:06:32 -05:00
Deluan
547363eab7 feat(plugins): implementation of the Navidrome Plugin Development Kit with generated client wrappers and service interfaces - Phase 5
Signed-off-by: Deluan <deluan@navidrome.org>
2025-12-31 17:06:32 -05:00
Deluan
8a453cb22c feat(plugins): implementation of the Navidrome Plugin Development Kit with generated client wrappers and service interfaces - Phase 4
Signed-off-by: Deluan <deluan@navidrome.org>
2025-12-31 17:06:32 -05:00
Deluan
2e716ed780 feat(plugins): implementation of the Navidrome Plugin Development Kit with generated client wrappers and service interfaces - Phase 3
Signed-off-by: Deluan <deluan@navidrome.org>
2025-12-31 17:06:32 -05:00
Deluan
6d3c29912b feat(plugins): implementation of the Navidrome Plugin Development Kit with generated client wrappers and service interfaces - Phase 2 (2)
Signed-off-by: Deluan <deluan@navidrome.org>
2025-12-31 17:06:32 -05:00
Deluan
6fa9ef0dfe feat(plugins): implementation of the Navidrome Plugin Development Kit with generated client wrappers and service interfaces - Phase 2
Signed-off-by: Deluan <deluan@navidrome.org>
2025-12-31 17:06:32 -05:00
Deluan
ebba3a2c46 feat(plugins): add initial implementation of the Navidrome Plugin Development Kit code generator - Pahse 1
Signed-off-by: Deluan <deluan@navidrome.org>
2025-12-31 17:06:32 -05:00
Deluan
7b36fcbaa1 feat(plugins): update client template file names for consistency
Signed-off-by: Deluan <deluan@navidrome.org>
2025-12-31 17:06:32 -05:00
Deluan
cd3ee136f4 feat(plugins): generate Go client stubs for non-WASM platforms
Signed-off-by: Deluan <deluan@navidrome.org>
2025-12-31 17:06:32 -05:00
Deluan
3692a274b4 feat(plugins): add Go client library with host function wrappers and documentation
Signed-off-by: Deluan <deluan@navidrome.org>
2025-12-31 17:06:32 -05:00
Deluan
13ca6149a9 feat(plugins): enhance Rust code generation with typed struct support and improved type handling
Signed-off-by: Deluan <deluan@navidrome.org>
2025-12-31 17:06:31 -05:00
Deluan
10e5f44617 refactor: reduce cyclomatic complexity by refactoring main function
Signed-off-by: Deluan <deluan@navidrome.org>
2025-12-31 17:06:31 -05:00
Deluan
7fd996b600 refactor(plugins): update JSON field names to camelCase for consistency
Signed-off-by: Deluan <deluan@navidrome.org>
2025-12-31 17:06:31 -05:00
Deluan
68e97a49ee feat(plugins): generate Rust lib.rs file to expose host function wrappers
Signed-off-by: Deluan <deluan@navidrome.org>
2025-12-31 17:06:31 -05:00
Deluan
8dad4f4a9c feat(plugins): add Rust host function library and example implementation of Discord Rich Presence plugin in Rust
Signed-off-by: Deluan <deluan@navidrome.org>
2025-12-31 17:06:31 -05:00
Deluan
2ec972cdc8 feat(plugins): update config handling in PluginShow to track last record state
Signed-off-by: Deluan <deluan@navidrome.org>
2025-12-31 17:06:31 -05:00
Deluan
d5e88c1117 feat(plugins): integrate event broker into plugin manager
Signed-off-by: Deluan <deluan@navidrome.org>
2025-12-31 17:06:31 -05:00
Deluan
2c6eef168c docs: enhance README with Extism plugin development resources and recommendations
Signed-off-by: Deluan <deluan@navidrome.org>
2025-12-31 17:06:31 -05:00
Deluan
2b2bc5dcb2 feat(plugins): implement KVStore service for persistent key-value storage
Signed-off-by: Deluan <deluan@navidrome.org>
2025-12-31 17:06:31 -05:00
Deluan
4e392f7b07 docs: update README for .ndp plugin packaging and installation instructions
Signed-off-by: Deluan <deluan@navidrome.org>
2025-12-31 17:06:31 -05:00
Deluan
e52b757cd4 feat(plugins): add support for .ndp plugin packages and update build process
Signed-off-by: Deluan <deluan@navidrome.org>
2025-12-31 17:06:31 -05:00
Deluan
78445163bb feat(i18n): add Portuguese translations for plugin management and notifications
Signed-off-by: Deluan <deluan@navidrome.org>
2025-12-31 17:06:31 -05:00
Deluan
c7d37c4e8c feat(plugins): rename ErrorIndicator to EnabledOrErrorField and enhance error handling logic
Signed-off-by: Deluan <deluan@navidrome.org>
2025-12-31 17:06:30 -05:00
Deluan
9b9920402e feat(ui): adjust grid layout in InfoRow component for improved responsiveness
Signed-off-by: Deluan <deluan@navidrome.org>
2025-12-31 17:06:30 -05:00
Deluan
93482e814e feat(plugins): implement configuration management UI with key-value pairs support
Signed-off-by: Deluan <deluan@navidrome.org>
2025-12-31 17:06:30 -05:00
Deluan
b592b1f2fa fix(build): update target to wasm32-wasip1 for improved WASI support
Signed-off-by: Deluan <deluan@navidrome.org>
2025-12-31 17:06:30 -05:00
Deluan
095ab6becf docs: update README to reflect JSON configuration format for plugins
Signed-off-by: Deluan <deluan@navidrome.org>
2025-12-31 17:06:30 -05:00
Deluan
422c1617bc feat(plugins): add Library Inspector plugin for periodic library inspection and file size logging
Signed-off-by: Deluan <deluan@navidrome.org>
2025-12-31 17:06:30 -05:00
Deluan
1ad824c724 feat(library): add Library service for metadata access and filesystem integration
Signed-off-by: Deluan <deluan@navidrome.org>
2025-12-31 17:06:30 -05:00
Deluan
aa83d3af6c docs: update READMEs
Signed-off-by: Deluan <deluan@navidrome.org>
2025-12-31 17:06:30 -05:00
Deluan
505b3c529f refactor(plugins): introduce ToggleEnabledSwitch for managing plugin enable/disable state
Signed-off-by: Deluan <deluan@navidrome.org>
2025-12-31 17:06:30 -05:00
Deluan
5201f8a5eb refactor(plugins): update newWebSocketService to use WebSocketPermission for allowed hosts
Signed-off-by: Deluan <deluan@navidrome.org>
2025-12-31 17:06:30 -05:00
Deluan
17d0e48a01 refactor(plugins): streamline error handling and improve plugin retrieval logic
Signed-off-by: Deluan <deluan@navidrome.org>
2025-12-31 17:06:30 -05:00
Deluan
e769ddf76c refactor(plugins): break manager.go into smaller, focused files
Signed-off-by: Deluan <deluan@navidrome.org>
2025-12-31 17:06:30 -05:00
Deluan
bad9e1fb5e refactor(plugins): enhance debug logging for plugin actions and recompile logic
Signed-off-by: Deluan <deluan@navidrome.org>
2025-12-31 17:06:30 -05:00
Deluan
6321dc1622 feat(plugins): add subsonicRouter to Manager and refactor host service registration
Signed-off-by: Deluan <deluan@navidrome.org>
2025-12-31 17:06:30 -05:00
Deluan
7c6c49c7a1 refactor(plugins): update GetManager to accept DataStore parameter
Signed-off-by: Deluan <deluan@navidrome.org>
2025-12-31 17:06:30 -05:00
Deluan
3605d5bf08 refactor(plugins): inject PluginManager into native API
Signed-off-by: Deluan <deluan@navidrome.org>
2025-12-31 17:06:30 -05:00
Deluan
064e73f958 feat(plugins UI): add error handling for plugin enable/disable actions
Signed-off-by: Deluan <deluan@navidrome.org>
2025-12-31 17:06:30 -05:00
Deluan
dd6d18de0a feat(plugins UI): refactor to use MUI and RA components
Signed-off-by: Deluan <deluan@navidrome.org>
2025-12-31 17:06:30 -05:00
Deluan
78f5ffce99 feat(plugins UI): enhance PluginShow with author, website, and permissions display
Signed-off-by: Deluan <deluan@navidrome.org>
2025-12-31 17:06:30 -05:00
Deluan
0cd44d2960 refactor(plugins UI): improve PluginList structure
Signed-off-by: Deluan <deluan@navidrome.org>
2025-12-31 17:06:30 -05:00
Deluan
2cfa902123 feat(plugins): optimize plugin change detection
Signed-off-by: Deluan <deluan@navidrome.org>
2025-12-31 17:06:30 -05:00
Deluan
2cc29793a6 feat(plugins UI): add PluginList and PluginShow components with plugin management functionality
Signed-off-by: Deluan <deluan@navidrome.org>
2025-12-31 17:06:30 -05:00
Deluan
690785120a feat(plugins UI): implement plugin synchronization with database for add, update, and remove actions
Signed-off-by: Deluan <deluan@navidrome.org>
2025-12-31 17:06:30 -05:00
Deluan
9c626183d0 feat(plugins UI): add plugin management routes and middleware
Signed-off-by: Deluan <deluan@navidrome.org>
2025-12-31 17:06:30 -05:00
Deluan
52c3985508 feat(plugins UI): add plugin repository and database support
Signed-off-by: Deluan <deluan@navidrome.org>
2025-12-31 17:06:30 -05:00
Deluan
f66c888e09 test: move purgeCacheBySize unit tests
Signed-off-by: Deluan <deluan@navidrome.org>
2025-12-31 17:06:30 -05:00
Deluan
8e9737ab95 feat: implement plugin cache purging functionality
Signed-off-by: Deluan <deluan@navidrome.org>
2025-12-31 17:06:30 -05:00
Deluan
a05fddbf7d add trace message for plugin recompiles
Signed-off-by: Deluan <deluan@navidrome.org>
2025-12-31 17:06:30 -05:00
Deluan
18723c6aa8 feat: extend plugin watcher with improved logging and debounce duration adjustment
Signed-off-by: Deluan <deluan@navidrome.org>
2025-12-31 17:06:30 -05:00
Deluan
97a10e8728 docs: update README to include WebSocket callback schema in plugin documentation
Signed-off-by: Deluan <deluan@navidrome.org>
2025-12-31 17:06:30 -05:00
Deluan
6107063517 feat: enable parallel loading of plugins during startup
Signed-off-by: Deluan <deluan@navidrome.org>
2025-12-31 17:06:30 -05:00
Deluan
980df67445 feat: add Webhook Scrobbler plugin in Rust to send HTTP notifications on scrobble events
Signed-off-by: Deluan <deluan@navidrome.org>
2025-12-31 17:06:30 -05:00
Deluan
9fbcf6ceb3 feat: update Python plugin documentation and usage instructions for host function wrappers
Signed-off-by: Deluan <deluan@navidrome.org>
2025-12-31 17:06:30 -05:00
Deluan
cbd74a3a96 feat: add generated host function wrappers for Scheduler and SubsonicAPI services
Signed-off-by: Deluan <deluan@navidrome.org>
2025-12-31 17:06:30 -05:00
Deluan
08b952ef50 feat: generate Python client wrappers for various host services
Signed-off-by: Deluan <deluan@navidrome.org>
2025-12-31 17:06:30 -05:00
Deluan
37f3b838d2 feat: add Now Playing Logger plugin to showcase calling host functions from Python plugins
Signed-off-by: Deluan <deluan@navidrome.org>
2025-12-31 17:06:30 -05:00
Deluan
3b9d426c5c feat: add trace logging for plugin availability and error handling in agents
Signed-off-by: Deluan <deluan@navidrome.org>
2025-12-31 17:06:30 -05:00
Deluan
38d80a07de feat: include plugin capabilities in loading log message
Signed-off-by: Deluan <deluan@navidrome.org>
2025-12-31 17:06:29 -05:00
Deluan
83eaad7292 feat: update Makefile and README to clarify Go plugin usage
Signed-off-by: Deluan <deluan@navidrome.org>
2025-12-31 17:06:29 -05:00
Deluan
870cd49307 feat: add Cover Art Archive plugin as an example of Python plugin
Signed-off-by: Deluan <deluan@navidrome.org>
2025-12-31 17:06:29 -05:00
Deluan
513c969c40 feat: add help target to Makefile for plugin usage instructions
Signed-off-by: Deluan <deluan@navidrome.org>
2025-12-31 17:06:29 -05:00
Deluan
dd238e74fb refactor: rename fake plugins to test plugins for clarity in integration tests
Signed-off-by: Deluan <deluan@navidrome.org>
2025-12-31 17:06:29 -05:00
Deluan
06e6c09882 refactor: error handling in various plugins to convert response.Error to Go errors
- Updated error handling in `nd_host_scheduler.go`, `nd_host_websocket.go`, `nd_host_artwork.go`, `nd_host_cache.go`, and `nd_host_subsonicapi.go` to convert string errors from responses into Go errors.
- Removed redundant error checks in test data plugins for cleaner code.
- Ensured consistent error handling across all plugins to improve reliability and maintainability.
2025-12-31 17:06:29 -05:00
Deluan
cab656dbe5 refactor: host function wrappers to use structured request and response types
- Updated the host function signatures in `nd_host_artwork.go`, `nd_host_scheduler.go`, `nd_host_subsonicapi.go`, and `nd_host_websocket.go` to accept a single parameter for JSON requests.
- Introduced structured request and response types for various cache operations in `nd_host_cache.go`.
- Modified cache functions to marshal requests to JSON and unmarshal responses, improving error handling and code clarity.
- Removed redundant memory allocation for string parameters in favor of JSON marshaling.
- Enhanced error handling in WebSocket and cache operations to return structured error responses.
2025-12-31 17:06:29 -05:00
Deluan
b9fceac12c feat: add Discord Rich Presence example plugin for Navidrome integration
Signed-off-by: Deluan <deluan@navidrome.org>
2025-12-31 17:06:29 -05:00
Deluan
f0d6fd4bc8 feat: add Cache service for in-memory TTL-based caching in plugins
Signed-off-by: Deluan <deluan@navidrome.org>
2025-12-31 17:06:29 -05:00
Deluan
66c396413c refactor: moved public URL builders to avoid import cycles
Signed-off-by: Deluan <deluan@navidrome.org>
2025-12-31 17:06:29 -05:00
Deluan
a78bbca741 feat: implement Artwork service for generating artwork URLs in Navidrome plugins - WIP
Signed-off-by: Deluan <deluan@navidrome.org>
2025-12-31 17:06:29 -05:00
Deluan
e60efde4d4 refactor: simplify schedule cloning in Close method and enhance plugin cleanup error handling
Signed-off-by: Deluan <deluan@navidrome.org>
2025-12-31 17:06:29 -05:00
Deluan
e200b70ea6 refactor: rename pluginInstance to plugin for consistency across the codebase
Signed-off-by: Deluan <deluan@navidrome.org>
2025-12-31 17:06:29 -05:00
Deluan
8bfb14814e refactor: rename plugin.create() to plugin.instance()
Signed-off-by: Deluan <deluan@navidrome.org>
2025-12-31 17:06:29 -05:00
Deluan
20c7e6c915 fix: use context.Background() in invokeCallback for scheduled tasks
Signed-off-by: Deluan <deluan@navidrome.org>
2025-12-31 17:06:29 -05:00
Deluan
9da40af6fb feat: add Crypto Ticker example plugin for real-time cryptocurrency price updates via Coinbase WebSocket API
Also add the lifecycle capability

Signed-off-by: Deluan <deluan@navidrome.org>
2025-12-31 17:06:29 -05:00
Deluan
d1225b7828 feat: implement WebSocket service for plugin integration and connection management
Signed-off-by: Deluan <deluan@navidrome.org>
2025-12-31 17:06:29 -05:00
Deluan
57aebf5ee9 feat: add WebSocket service definitions for plugin communication
Signed-off-by: Deluan <deluan@navidrome.org>
2025-12-31 17:06:29 -05:00
Deluan
6d4b708a28 refactor: update plugin manager initialization and encapsulate logic
Signed-off-by: Deluan <deluan@navidrome.org>
2025-12-31 17:06:29 -05:00
Deluan
36927729a4 test: add scheduler service isolation test for plugin instances
Signed-off-by: Deluan <deluan@navidrome.org>
2025-12-31 17:06:29 -05:00
Deluan
e951a82265 refactor: capabilities registration
Signed-off-by: Deluan <deluan@navidrome.org>
2025-12-31 17:06:29 -05:00
Deluan
b2e1c216a0 refactor(scheduler): replace uuid with id.NewRandom for schedule ID generation
Signed-off-by: Deluan <deluan@navidrome.org>
2025-12-31 17:06:29 -05:00
Deluan
1a7ba7f293 feat: rewrite the wikimedia plugin using the XTP CLI
Signed-off-by: Deluan <deluan@navidrome.org>
2025-12-31 17:06:29 -05:00
Deluan
7a9a63b219 fix: update wasm build rule to include all Go files in the directory
Signed-off-by: Deluan <deluan@navidrome.org>
2025-12-31 17:06:29 -05:00
Deluan
a770783c6c docs(scheduler): clarify SchedulerCallback requirement for scheduling functions
Signed-off-by: Deluan <deluan@navidrome.org>
2025-12-31 17:06:29 -05:00
Deluan
5e2e37bca7 refactor(scheduler): add Close method for resource cleanup on plugin unload
Signed-off-by: Deluan <deluan@navidrome.org>
2025-12-31 17:06:29 -05:00
Deluan
b94a214c91 refactor(scheduler): streamline scheduling logic and remove unused callback tracking
Signed-off-by: Deluan <deluan@navidrome.org>
2025-12-31 17:06:29 -05:00
Deluan
005fc684ed feat(scheduler): add scheduler callback schema and implementation for plugins
Signed-off-by: Deluan <deluan@navidrome.org>
2025-12-31 17:06:29 -05:00
Deluan
3a6cdb3ed3 refactor(manifest): remove unused ConfigPermission from permissions schema
Signed-off-by: Deluan <deluan@navidrome.org>
2025-12-31 17:06:29 -05:00
Deluan
f4c6461c0a feat(scheduler): implement Scheduler service with one-time and recurring scheduling capabilities
Signed-off-by: Deluan <deluan@navidrome.org>
2025-12-31 17:06:29 -05:00
Deluan
b84089cea4 feat(plugins): add WASI build constraints to client wrapper templates, to avoid lint errors
Signed-off-by: Deluan <deluan@navidrome.org>
2025-12-31 17:06:29 -05:00
Deluan
44c69de525 feat(scheduler): add Scheduler service interface with host function wrappers for scheduling tasks 2025-12-31 17:06:29 -05:00
Deluan
c059db4c9c refactor(generator): remove error handling for response.Error in client templates
Signed-off-by: Deluan <deluan@navidrome.org>
2025-12-31 17:06:28 -05:00
Deluan
ba27a8ceef feat(plugins): generate client wrappers for host functions
Signed-off-by: Deluan <deluan@navidrome.org>
2025-12-31 17:06:28 -05:00
Deluan
a0a5168f5f fix(generator): error-only methods in response handling
Signed-off-by: Deluan <deluan@navidrome.org>
2025-12-31 17:06:28 -05:00
Deluan
097774f9c2 feat(plugins): implement SubsonicAPI host function integration with permissions
Signed-off-by: Deluan <deluan@navidrome.org>
2025-12-31 17:06:28 -05:00
Deluan
62612391da feat(subsonicapi): update Call method to return JSON string response
Signed-off-by: Deluan <deluan@navidrome.org>
2025-12-31 17:06:28 -05:00
Deluan
de90e191bb feat(hostgen): add hostgen tool for generating Extism host function wrappers
- Implemented hostgen tool to generate wrappers from annotated Go interfaces.
- Added command-line flags for input/output directories and package name.
- Introduced parsing and code generation logic for host services.
- Created test data for various service interfaces and expected generated code.
- Added documentation for host services and annotations for code generation.
- Implemented SubsonicAPI service with corresponding generated code.
2025-12-31 17:06:28 -05:00
Deluan
9481ba3662 feat(plugins): add metadata agent and scrobbler schemas for bootstrapping plugins
Signed-off-by: Deluan <deluan@navidrome.org>
2025-12-31 17:06:28 -05:00
Deluan
1733129537 refactor(plugins): clean up manifest struct and improve plugin loading logic
Signed-off-by: Deluan <deluan@navidrome.org>
2025-12-31 17:06:28 -05:00
Deluan
415eac5399 feat(plugins): integrate logs
Signed-off-by: Deluan <deluan@navidrome.org>
2025-12-31 17:06:28 -05:00
Deluan
905cd613f3 feat(plugins): implement scrobbler plugin with authorization and scrobbling capabilities
Signed-off-by: Deluan <deluan@navidrome.org>
2025-12-31 17:06:28 -05:00
Deluan
876ecb29c8 feat(plugins): enhance plugin logging and set User-Agent header
Signed-off-by: Deluan <deluan@navidrome.org>
2025-12-31 17:06:28 -05:00
Deluan
5ddc763bb4 feat(plugins): add Wikimedia plugin for Navidrome to fetch artist metadata
Signed-off-by: Deluan <deluan@navidrome.org>
2025-12-31 17:06:28 -05:00
Deluan
6ac3ce3511 test(plugins): ignore goroutine leaks from notify library in tests
Signed-off-by: Deluan <deluan@navidrome.org>
2025-12-31 17:06:28 -05:00
Deluan
fed00e1838 tests(plugins): more optimizations
Signed-off-by: Deluan <deluan@navidrome.org>
2025-12-31 17:06:28 -05:00
Deluan
f0f191266c tests(plugins): optimize tests
Signed-off-by: Deluan <deluan@navidrome.org>
2025-12-31 17:06:28 -05:00
Deluan
39be1878cb tests(plugins): change BeforeEach to BeforeAll in MetadataAgent tests
Signed-off-by: Deluan <deluan@navidrome.org>
2025-12-31 17:06:28 -05:00
Deluan
42d48300bb fix(plugins): race condition in plugin manager 2025-12-31 17:06:28 -05:00
Deluan
40ce71294e refactor(plugins): implement plugin function call helper and refactor MetadataAgent methods
Signed-off-by: Deluan <deluan@navidrome.org>
2025-12-31 17:06:28 -05:00
Deluan
8cd3785ac4 fix(plugins): improve error handling and logging in plugin manager
Signed-off-by: Deluan <deluan@navidrome.org>
2025-12-31 17:06:28 -05:00
Deluan
41bc04214f refactor(plugins): standardize variable names and remove superfluous wrapper functions
Signed-off-by: Deluan <deluan@navidrome.org>
2025-12-31 17:06:28 -05:00
Deluan
66bd5f7a55 feat(plugins): add auto-reload functionality for plugins with file watcher support
Signed-off-by: Deluan <deluan@navidrome.org>
2025-12-31 17:06:28 -05:00
Deluan
373f5fb3d9 feat(plugins): add auto-reload functionality for plugins with file watcher support
Signed-off-by: Deluan <deluan@navidrome.org>
2025-12-31 17:06:28 -05:00
Deluan
22561abadc feat(plugins): add capability detection for plugins based on exported functions
Signed-off-by: Deluan <deluan@navidrome.org>
2025-12-31 17:06:28 -05:00
Deluan
b3ec005fa2 feat(plugins): implement new plugin system with using Extism
Signed-off-by: Deluan <deluan@navidrome.org>
2025-12-31 17:06:28 -05:00
Deluan
c8887eac6b chore(plugins): remove the old plugins system implementation
Signed-off-by: Deluan <deluan@navidrome.org>
2025-12-31 17:06:28 -05:00
Deluan
735c0d9103 chore(deps): remove direct dependency on golang.org/x/exp
Signed-off-by: Deluan <deluan@navidrome.org>
2025-12-31 17:03:44 -05:00
Deluan
fc9817552d fix(subsonic): make getUser?username comparison case-insensitive
Signed-off-by: Deluan <deluan@navidrome.org>
2025-12-19 17:56:40 -05:00
Xabi
0c1b65d3e6 fix(ui): update Basque translation (#4815)
Added missing strings and a fix or two
2025-12-19 08:32:13 -05:00
Deluan
47b448c64f chore(deps): update action versions in pipeline configuration
Signed-off-by: Deluan <deluan@navidrome.org>
2025-12-19 08:30:18 -05:00
Deluan
834fa494e4 chore(deps): update golangci-lint to v2.7.2
Signed-off-by: Deluan <deluan@navidrome.org>
2025-12-19 08:25:51 -05:00
Deluan
5d34640065 chore(deps): update dependencies for maruel/natural to v1.3.0 and tetratelabs/wazero to v1.11.0
Signed-off-by: Deluan <deluan@navidrome.org>
2025-12-19 08:24:45 -05:00
Deluan
9ed309ac81 feat(scanner): implement file-based target passing for large target lists
Signed-off-by: Deluan <deluan@navidrome.org>
2025-12-16 16:08:32 -05:00
Deluan
8c80be56da fix(scanner): ensure FullScanInProgress reflects current scan request during interrupted scans
Signed-off-by: Deluan <deluan@navidrome.org>
2025-12-16 12:16:00 -05:00
Deluan
cde5992c46 fix(scanner): execute GetFolderUpdateInfo in batches to avoid "Expression tree is too large (maximum depth 1000)"
Signed-off-by: Deluan <deluan@navidrome.org>
2025-12-16 11:37:13 -05:00
Deluan
017676c457 fix(ui): export all missing files instead of first 1000
Fixes #4721
2025-12-16 06:43:02 -05:00
Deluan
2d7b716834 fix(scanner): remove stale role associations when artist role changes. Fix #4242
Signed-off-by: Deluan <deluan@navidrome.org>
2025-12-16 06:38:50 -05:00
Deluan
c7ac0e4414 chore(docker): update Alpine base image to version 3.20 and bump XX_VERSION to 1.9.0
Signed-off-by: Deluan <deluan@navidrome.org>
2025-12-15 14:10:34 -05:00
Deluan
c9409d306a chore(deps): update Go dependencies to latest versions
Signed-off-by: Deluan <deluan@navidrome.org>
2025-12-15 13:09:06 -05:00
Deluan
ebbe62bbbd fix(ui): update delete button color in AMusic theme
Signed-off-by: Deluan <deluan@navidrome.org>
2025-12-14 13:51:01 -05:00
dragonish
42c85a18e2 fix(ui) Improve player buttons in AMusic theme (#4797)
* fix(ui): improve the lyric button of the AMusic theme

* fix(amusic): update styles for music player panel SVG and disabled button states

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

---------

Signed-off-by: Deluan <deluan@navidrome.org>
Co-authored-by: Deluan <deluan@navidrome.org>
2025-12-13 13:04:29 -05:00
Deluan
7ccf44b8ed feat: rename HTTPSecurityHeaders.CustomFrameOptionsValue to HTTPHeaders.FrameOptions
Signed-off-by: Deluan <deluan@navidrome.org>
2025-12-13 12:38:43 -05:00
Deluan
603cccde11 fix(subsonic): always enable getNowPlaying endpoint regardless of configuration
Signed-off-by: Deluan <deluan@navidrome.org>
2025-12-11 15:44:21 -05:00
Deluan
6ed6524752 fix(subsonic): add username parameter validation for GetUser endpoint
Fixes #4794

Signed-off-by: Deluan <deluan@navidrome.org>
2025-12-10 18:30:26 -05:00
Deluan
a081569ed4 fix(deezer): add order parameter to artist search for improved ranking
Signed-off-by: Deluan <deluan@navidrome.org>
2025-12-10 13:31:24 -05:00
Deluan
e923c02c6a chore: enhance Deezer logging for artist search results
Signed-off-by: Deluan <deluan@navidrome.org>
2025-12-10 08:38:28 -05:00
Deluan
51ca2dee65 fix: log environment variable configuration loading when no config file is found
Signed-off-by: Deluan <deluan@navidrome.org>
2025-12-09 19:40:46 -05:00
Deluan
6b961bd99d fix: update default legacy clients to include SubMusic. See #4779
Signed-off-by: Deluan <deluan@navidrome.org>
2025-12-09 08:44:56 -05:00
Deluan
396eee48c6 fix: preserve user context in async NowPlaying dispatch
Fixed issue #4787 where plugin scrobblers received an empty username during NowPlaying events. The async worker was passing context.Background() which lost all user information.

Changed nowPlayingEntry to store the full context (with cancellation removed via context.WithoutCancel) and pass it to dispatchNowPlaying. This ensures plugin scrobblers can extract username from the context for authorization checks.

Updated tests to verify username is properly propagated through the async workflow, matching the actual plugin adapter behavior of checking both request.UsernameFrom and request.UserFrom.
2025-12-09 08:43:56 -05:00
Deluan Quintão
cc3cca6077 fix(scanner): handle cross-library relative paths in playlists (#4659)
* fix: handle cross-library relative paths in playlists

Playlists can now reference songs in other libraries using relative paths.
Previously, relative paths like '../Songs/abc.mp3' would not resolve correctly
when pointing to files in a different library than the playlist file.

The fix resolves relative paths to absolute paths first, then checks which
library they belong to using the library regex. This allows playlists to
reference files across library boundaries while maintaining backward
compatibility with existing single-library relative paths.

Fixes #4617

* fix: enhance playlist path normalization for cross-library support

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

* refactor: improve handling of relative paths in playlists for cross-library compatibility

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

* fix: ensure longest library path matches first to resolve prefix conflicts in playlists

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

* test: refactor tests isolation

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

* fix: enhance handling of library-qualified paths and improve cross-library playlist support

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

* refactor: simplify mocks

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

* fix: lint

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

* fix: improve path resolution for cross-library playlists and enhance error handling

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

* refactor

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

* refactor: remove unnecessary path validation fallback

Remove validatePathInLibrary function and its fallback logic in
resolveRelativePath. The library matcher should always find the correct
library, including the playlist's own library. If this fails, we now
return an invalid resolution instead of attempting a fallback validation.

This simplifies the code by removing redundant validation logic that
was masking test setup issues. Also fixes test mock configuration to
properly set up library paths that match folder LibraryPath values.

* refactor: consolidate path resolution logic

Collapse resolveRelativePath and resolveAbsolutePath into a unified
resolvePath function, extracting common library matching logic into a
new findInLibraries helper method.

This eliminates duplicate code (~20 lines) while maintaining clear
separation of concerns: resolvePath handles path normalization
(relative vs absolute), and findInLibraries handles library matching.

Update tests to call resolvePath directly with appropriate parameters,
maintaining full test coverage for both absolute and relative path
scenarios.

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

* docs: add FindByPaths comment

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

* fix: enhance Unicode normalization for path comparisons in playlists. Fixes 4663

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

---------

Signed-off-by: Deluan <deluan@navidrome.org>
2025-12-06 12:05:38 -05:00
Deluan Quintão
f6ac99e081 fix(ui): update Bulgarian, Finnish translations from POEditor (#4773)
Co-authored-by: navidrome-bot <navidrome-bot@navidrome.org>
2025-12-06 11:08:24 -05:00
Deluan Quintão
a521c74a59 feat(server): track scrobble/linstens history (#4770)
* feat(scrobble): implement scrobble repository and record scrobble history

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

* feat(scrobble): add configuration option to enable scrobble history

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

* test(scrobble): enhance scrobble history tests for repository recording

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

---------

Signed-off-by: Deluan <deluan@navidrome.org>
2025-12-06 11:07:18 -05:00
Deluan Quintão
bfd219e708 fix(ui): update Esperanto, Finnish, Galician, Dutch, Norwegian, Turkish translations from POEditor (#4760)
Co-authored-by: navidrome-bot <navidrome-bot@navidrome.org>
2025-12-05 19:36:06 -05:00
Kendall Garner
eaf7795716 feat(cli): add user administration (#4754)
* feat(cli): add user administration

* clean go.mod, address comments

* fix lint, I hope

* bump compilation timeoit in adapter_media_agent_test

* address initial comments

* feedback 2

* update user commands to use context to allow proper cancellation

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

* enforce admin user requirement in context for command execution

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

---------

Signed-off-by: Deluan <deluan@navidrome.org>
Co-authored-by: Deluan <deluan@navidrome.org>
2025-12-03 19:58:33 -05:00
Deluan Quintão
96392f3af0 ci: improve docker manifest push reliability and isolation (#4764)
* ci: improve docker manifest push reliability and isolation

Split Docker manifest push into separate GHCR and Docker Hub jobs to improve pipeline reliability and resilience:

- Separated push-manifest job into push-manifest-ghcr and push-manifest-dockerhub for independent execution
- Filter tags per registry using jq to prevent cross-registry push attempts
- Add automatic retry logic (3 attempts with 30s delay) for Docker Hub push using nick-fields/retry action
- Make Docker Hub job continue-on-error to prevent Docker Hub intermittent failures from failing the entire pipeline
- Add dedicated cleanup-digests job that only requires GHCR job success
- GHCR is now the critical path and will fail the pipeline if it fails, while Docker Hub failures are tolerated with retries

This addresses the recurring 400 Bad Request errors from Docker Hub registry that were causing pipeline failures even when ghcr.io push succeeded.

* fix(ci): use ghcr.io as source for docker hub manifest creation

The docker buildx imagetools create command needs to reference the source images from where they exist (ghcr.io) rather than from Docker Hub. The digests uploaded during the build step are stored on ghcr.io, so we need to pull from there and tag to Docker Hub.

* fix(ci): simplify Docker manifest push job names for clarity

* fix(ci): add permissions for Docker manifest push jobs

* fix(ci): update permissions for GHCR manifest push to write

* fix(ci): update Docker Hub image tagging in manifest creation

* fix(ci): update permissions for GHCR manifest push to read contents and write packages

* Revert "fix(ci): update Docker Hub image tagging in manifest creation"

This reverts commit b5f04d9c8b.
2025-12-03 18:24:11 -05:00
maya doshi
b7c4128b1b fix(server): Lastfm.ScrobbleFirstArtistOnly also only scrobbles the first artist of the album (#4762)
* feat(server): add option Lastfm.ScrobbleFirstAlbumArtistOnly to send only the first album artist

* fix: remove config parameter scrobbleFirstAlbumArtist

* test: add NowPlaying test for ScrobbleFirstArtistOnly

Add a test case for the NowPlaying function when ScrobbleFirstArtistOnly is enabled. This ensures that only the first artist from the Participants list is sent to Last.fm for both artist and album artist fields, matching the existing test coverage for the Scrobble function.

* refactor: consolidate getArtistForScrobble and getAlbumArtistForScrobble

Merge the separate getArtistForScrobble and getAlbumArtistForScrobble functions into a single parameterized function. This eliminates code duplication and makes the scrobble artist handling logic more maintainable. The function now accepts a role parameter and display name, allowing it to handle both artist and album artist extraction based on the ScrobbleFirstArtistOnly configuration.

---------

Co-authored-by: Deluan <deluan@navidrome.org>
2025-12-03 15:55:25 -05:00
Deluan
86f929499e fix(ui): improve playlist bulk action button contrast on dark themes
The bulk action buttons (Make Public, Make Private, Delete) on the playlists list were displaying with poor text contrast when using dark themes like AMusic. The buttons had pinkish text (theme's primary color) on a dark red background, making them difficult to read.

This fix applies the same styling pattern used for song bulk actions by adding a makeStyles hook that sets white text color for dark themes. This ensures proper contrast between the button text and background while maintaining correct styling on light themes.

Tested on AMusic (dark) and Light themes to verify contrast improvement and backward compatibility.

Signed-off-by: Deluan <deluan@navidrome.org>
2025-12-03 14:37:52 -05:00
dependabot[bot]
5bc26de0e7 chore(deps-dev): bump js-yaml from 4.1.0 to 4.1.1 in /ui (#4715)
Bumps [js-yaml](https://github.com/nodeca/js-yaml) from 4.1.0 to 4.1.1.
- [Changelog](https://github.com/nodeca/js-yaml/blob/master/CHANGELOG.md)
- [Commits](https://github.com/nodeca/js-yaml/compare/4.1.0...4.1.1)

---
updated-dependencies:
- dependency-name: js-yaml
  dependency-version: 4.1.1
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-12-02 20:45:08 -05:00
Deluan
1f1a174542 fix(insights): add Parallels Shared Folders filesystem type to fsTypeMap
Signed-off-by: Deluan <deluan@navidrome.org>
2025-12-02 17:00:13 -05:00
Deluan
9f0d3f3cf4 fix(ui): sync body background color with theme
Set document.body.style.backgroundColor to match the current theme's background
color whenever the theme changes. This fixes the white background that appeared
during pull-to-refresh gestures on mobile or overscroll on desktop, where the
browser reveals the area behind the app content.

The background color is determined by the theme's palette.background.default
value if defined, otherwise falls back to Material-UI defaults (#303030 for
dark themes, #fafafa for light themes).

Signed-off-by: Deluan <deluan@navidrome.org>
2025-12-02 16:14:32 -05:00
Deluan
142a3136d4 fix: log warning when no config file is found
Always log the configuration source at startup: shows an INFO message with the
config file path when found, or a WARN message explaining how to specify one
when not found. This helps users understand why CLI commands may fail when
run outside of systemd (where --configfile is typically specified).

Closes #4758
2025-12-02 14:24:15 -05:00
Deluan Quintão
13f6eb9a11 feat: make Unicode handling in external API calls configurable (#4277)
* feat: make Unicode handling in external API calls configurable

- Add DevPreserveUnicodeInExternalCalls config option (default: false)
- Refactor external provider to use NameForExternal() method on auxArtist
- Remove redundant Name field from auxArtist struct
- Update all external API calls (image, URL, biography, similar, top songs, MBID) to use configurable Unicode handling
- Add comprehensive tests for both Unicode-preserving and normalized behaviors
- Refactor tests to use constants and improved structure with BeforeEach blocks

Fixes issue where Spotify integration failed to find artist images for artists with Unicode characters (e.g., en dash) in their names.

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

* address comments

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

* avoid calling str.Clean multiple times

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

* refactor: apply Unicode handling pattern to auxAlbum

Extended the configurable Unicode handling to album names, matching the
pattern already implemented for artist names. This ensures consistent behavior
when DevPreserveUnicodeInExternalCalls is enabled for both artist and album
external API calls.

Changes:
- Removed Name field from auxAlbum struct, added Name() method with Unicode logic
- Updated getAlbum, UpdateAlbumInfo, populateAlbumInfo, and AlbumImage functions
- Added comprehensive tests for album Unicode handling (preserve and normalize)
- Fixed typo in artist image test description

---------

Signed-off-by: Deluan <deluan@navidrome.org>
2025-12-02 13:08:30 -05:00
crazygolem
917726c166 feat: rename "reverse proxy authentication" to "external authentication" (#4418)
* Rename external auth options

ReverseProxyWhitelist was regularly confusing users that enabled it for
non-authenticating reverse proxy setups.

The new option name makes it clear that it's related to authentication, not
just reverse proxies.

* small refactor

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

* add test

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

---------

Signed-off-by: Deluan <deluan@navidrome.org>
Co-authored-by: Deluan Quintão <deluan@navidrome.org>
2025-12-02 12:01:48 -05:00
Deluan Quintão
654607ea53 fix(ui): update Danish, German, Greek, Spanish, French, Japanese, Polish, Russian, Swedish, Thai, Ukrainian translations from POEditor (#4687)
Co-authored-by: navidrome-bot <navidrome-bot@navidrome.org>
2025-12-02 11:38:26 -05:00
Xabi
5c43025ce1 fix(ui): update Basque translation to include library related strings that were missing (#4670)
* Update eu.json

Added Library strings

* Update eu.json, now with missing comma

There was a comma missing.

* Update eu.json, typo

Fixes a typo.
2025-12-02 11:31:02 -05:00
ChekeredList71
ff5ebe1829 fix(ui): new Hungarian strings and updates (#4703)
added: "quickscan", "fullscan"
updated:
- "manageUsers": `access` translates to `hozzáférés` in this context, not `elérés` (~reachableness)
- "quickscan", "fullscan", "scantype": updated to match new strings
2025-12-02 11:27:12 -05:00
floatlesss
3ac2c6b6ed fix: upgrade TagLib in devcontainer (#4750)
* Signed-off-by: floatlesss <117862164+floatlesss@users.noreply.github.com>

fix(vscodedevcontainer): fix-taglib-build-issues - #4749

* Apply Gemini suggested changes

Signed-off-by: floatlesss <117862164+floatlesss@users.noreply.github.com>

* chore: install TagLib in devcontainer Dockerfile

Move TagLib installation from postCreateCommand script into the devcontainer Dockerfile to leverage Docker layer caching and simplify setup.\n\nChanges:\n- Install cross-taglib v2.1.1-1 directly in Dockerfile using TARGETARCH for multi-arch support (amd64/arm64).\n- Remove redundant libtag1-dev apt dependency; keep ffmpeg only.\n- Add CROSS_TAGLIB_VERSION as a build arg for consistency with CI/Makefile.\n- Remove postCreateCommand from devcontainer.json and delete install-taglib.sh script.\n\nWhy:\n- Avoid re-downloading TagLib on each container create; benefit from cached image layers.\n- Reduce redundancy and potential version mismatch between apt libtag and cross-taglib.\n- Keep devcontainer aligned with production build approach and CI settings.

---------

Signed-off-by: floatlesss <117862164+floatlesss@users.noreply.github.com>
Co-authored-by: Deluan <deluan@navidrome.org>
2025-12-02 08:39:36 -05:00
Deluan Quintão
0faf744e32 refactor: make NowPlaying dispatch asynchronous with worker pool (#4757)
* feat: make NowPlaying dispatch asynchronous with worker pool

Implemented asynchronous NowPlaying dispatch using a queue worker pattern similar to cacheWarmer. Instead of dispatching NowPlaying updates synchronously during the HTTP request, they are now queued and processed by background workers at controlled intervals.

Key changes:
- Added nowPlayingEntry struct to represent queued entries
- Added npQueue map (keyed by playerId), npMu mutex, and npSignal channel to playTracker
- Implemented enqueueNowPlaying() to add entries to the queue
- Implemented nowPlayingWorker() that polls every 100ms, drains queue, and processes entries
- Changed NowPlaying() to queue dispatch instead of calling synchronously
- Renamed dispatchNowPlaying() to dispatchNowPlayingAsync() and updated it to use background context

Benefits:
- HTTP handlers return immediately without waiting for scrobbler responses
- Deduplication by key: rapid calls (seeking) only dispatch latest state
- Fire-and-forget: one-shot attempts with logged failures
- Backpressure-free: worker processes at its own pace
- Tests updated to use Eventually() assertions for async dispatch

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

* fix(play_tracker): increase timeout duration for signal handling

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

* refactor(play_tracker): simplify queue processing by directly assigning entries

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

---------

Signed-off-by: Deluan <deluan@navidrome.org>
2025-12-01 22:21:54 -05:00
Deluan Quintão
33d9ce6ecc feat: add configurable transcoding cancellation (#4411)
* feat: add configurable transcoding cancellation

Implemented EnableTranscodingCancellation configuration option to control whether
FFmpeg transcoding processes can be interrupted when client requests are cancelled.
This addresses resource management issues on low-power hardware where transcoding
processes would accumulate and cause CPU spikes.

Key changes:
- Added EnableTranscodingCancellation bool to configuration (default: false)
- Added CLI flag --enabletranscodingcancellation and TOML/env support
- Modified FFmpeg package to always use exec.CommandContext for consistency
- Implemented conditional context handling in NewTranscodingCache function
- When enabled: uses request context directly (allows cancellation)
- When disabled: uses background context with request metadata preserved
- Added comprehensive tests for both FFmpeg and transcoding layers
- Maintained backward compatibility with existing behavior as default

The implementation follows proper layered architecture with policy decisions
at the media streaming layer and execution utilities remaining focused on
their core responsibilities.

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

* test: refactor FFmpeg context cancellation tests for improved clarity and reliability

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

* test: reset FFmpeg initialization

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

* test: improve FFmpeg context cancellation tests for cross-platform compatibility

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

---------

Signed-off-by: Deluan <deluan@navidrome.org>
2025-12-01 17:33:53 -05:00
Deluan
f14692c1f0 test: remove racy buffer length assertion in scrobbler test
Removed the buffer.Length() check that was causing intermittent test failures.
The background goroutine started by newBufferedScrobbler can process and
dequeue scrobble entries before the test assertion runs, leading to a race
condition where the observed length is 0 instead of 1. The Eventually block
that follows already verifies the scrobble was processed correctly.

Signed-off-by: Deluan <deluan@navidrome.org>
2025-11-30 21:59:11 -05:00
Deluan
75b253687a fix(insights): add missing filesystem types to fsTypeMap 2025-11-30 11:26:59 -05:00
floatlesss
64a9260174 fix(ui): allow scrolling in shareplayer queue by adding delay #4748
fix(shareplayer): allow-scrolling-in-shareplayer - #4747
2025-11-29 12:54:46 -05:00
Deluan
6a7381aa5a test: prevent environment variables from overriding config file values in tests
Added a loadEnvVars parameter to InitConfig to control whether environment
variables should be loaded via viper.AutomaticEnv(). In tests, environment
variables (like ND_MUSICFOLDER) were overriding values from config test files,
causing tests to fail when these variables were set in the developer's
environment. Now tests can pass loadEnvVars=false to isolate from the
environment while production code continues to use loadEnvVars=true.

Signed-off-by: Deluan <deluan@navidrome.org>
2025-11-29 11:45:07 -05:00
Deluan Quintão
e36fef8692 fix: retry insights collection when no admin user available (#4746)
Previously, the insights collector would only try to get an admin user once
at startup. If no admin user existed (e.g., fresh database before first user
registration), insights collection would silently fail forever.

This change moves the admin context creation inside the collection loop so it
retries on each interval. It also updates log messages in WithAdminUser to
remove the Scanner prefix since this function is now used by other components.

Signed-off-by: Deluan <deluan@navidrome.org>
2025-11-28 19:38:28 -05:00
Deluan Quintão
9913235542 fix(server): improve error message for encrypted TLS private keys (#4742)
Added TLS certificate validation that detects encrypted (password-protected)
private keys and provides a clear error message with instructions on how to
decrypt them using openssl. This addresses user confusion when Go's standard
library fails with the cryptic 'tls: failed to parse private key' error.

Changes:
- Added validateTLSCertificates function to validate certs before server start
- Added isEncryptedPEM helper to detect both PKCS#8 and legacy encrypted keys
- Added comprehensive tests for TLS validation including encrypted key detection
- Added integration test that starts server with TLS and verifies HTTPS works
- Added test certificates (valid for 100 years) with SAN for localhost

Signed-off-by: Deluan <deluan@navidrome.org>
2025-11-28 17:08:34 -05:00
Deluan
a87b6a50a6 test: use unique library name and path in tests
Avoid UNIQUE constraint conflicts on library.name and library.path when
running tests in parallel. Both playlist_repository_test.go and
tag_library_filtering_test.go now generate timestamp-based unique
suffixes for library names and paths to ensure test isolation.

Signed-off-by: Deluan <deluan@navidrome.org>
2025-11-28 16:11:13 -05:00
Stephan Wahlen
2b30ed1520 fix(ui): Amusic theme improvements (#4731)
* fix low contrast in "delete missing files" button

* make login screen a bit nicer

* style modal similar to rest of ui

* Add custom styles for Ra Pagination

* Refactor styles in amusic.js

Removed albumSubtitle color and updated styles for albumPlayButton and albumArtistName

* Add NDDeleteLibraryButton and NDDeleteUserButton styles

low contrast

* low contrast text on delete buttons

* playbutton color back to pink without background
2025-11-28 08:52:26 -05:00
Deluan Quintão
1024d61a5e fix: apply library filter to smart playlist track generation (#4739)
Smart playlists were including tracks from all libraries regardless of the
user's library access permissions. This resulted in ghost tracks that users
could not see or play, while the playlist showed incorrect song counts.

Added applyLibraryFilter to the refreshSmartPlaylist function to ensure only
tracks from libraries the user has access to are included when populating
smart playlist tracks. Added regression test to verify the fix.

Closes #4738
2025-11-27 07:58:39 -05:00
Deluan
ca83ebbb53 feat: add DevOptimizeDB flag to control SQLite optimization
Added a new DevOptimizeDB configuration flag (default true) that controls
whether SQLite PRAGMA OPTIMIZE and ANALYZE commands are executed. This allows
disabling database optimization operations for debugging or testing purposes.

The flag guards optimization commands in:
- db/db.go: Initial connection, post-migration, and shutdown optimization
- persistence/library_repository.go: Post-scan optimization
- db/migrations/migration.go: ANALYZE during forced full rescans

Set ND_DEVOPTIMIZEDB=false to disable all database optimization commands.
2025-11-25 19:49:03 -05:00
dependabot[bot]
dc07dc413d chore(deps): bump golangci/golangci-lint-action in /.github/workflows (#4673)
Bumps [golangci/golangci-lint-action](https://github.com/golangci/golangci-lint-action) from 8 to 9.
- [Release notes](https://github.com/golangci/golangci-lint-action/releases)
- [Commits](https://github.com/golangci/golangci-lint-action/compare/v8...v9)

---
updated-dependencies:
- dependency-name: golangci/golangci-lint-action
  dependency-version: '9'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-11-24 23:36:19 -05:00
zacaj
3294bcacfc feat: add Rated At field - #4653 (#4660)
* feat(model): add Rated At field - #4653

Signed-off-by: zacaj <zacaj@zacaj.com>

* fix(ui): ignore empty dates in rating/love tooltips - #4653

* refactor(ui): add isDateSet util function

Signed-off-by: zacaj <zacaj@zacaj.com>

* feat: add tests for isDateSet and rated_at sort mappings

Added comprehensive tests for isDateSet and urlValidate functions in
ui/src/utils/validations.test.js covering falsy values, Go zero date handling,
valid date strings, Date objects, and edge cases.

Added rated_at sort mapping to album, artist, and mediafile repositories,
following the same pattern as starred_at (sorting by rating first, then by
timestamp). This enables proper sorting by rating date in the UI.

---------

Signed-off-by: zacaj <zacaj@zacaj.com>
Co-authored-by: zacaj <zacaj@zacaj.com>
Co-authored-by: Deluan <deluan@navidrome.org>
2025-11-24 23:18:05 -05:00
Deluan
228211f925 test: add smart playlist tag criteria tests for issue #4728
Add integration tests verifying the workaround for checking if a tag has any
value in smart playlists. The tests confirm that using 'contains' with an empty
string generates SQL that matches any non-empty tag value (value LIKE '%%'),
which is the recommended workaround for issue #4728.

Tests added:
- Verify contains with empty string matches tracks with tag values
- Verify notContains with empty string excludes tracks with tag values

Also updated test context to use GinkgoT().Context() instead of context.TODO().
2025-11-24 21:16:28 -05:00
dependabot[bot]
a6a682b385 chore(deps): bump actions/checkout from 5 to 6 in /.github/workflows (#4730)
Bumps [actions/checkout](https://github.com/actions/checkout) from 5 to 6.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v5...v6)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-11-24 13:18:34 -05:00
Kendall Garner
c40f12e65b fix(scanner): Use repeated arg instead of comma split (#4727) 2025-11-23 22:16:10 -05:00
Deluan
12d0898585 chore(docker): remove GODEBUG=asyncpreemptoff=1 flag, as it should not be needed on Go 1.15+
Signed-off-by: Deluan <deluan@navidrome.org>
2025-11-22 21:36:44 -05:00
Deluan
c21aee7360 fix(config): enables quoted ; as values in ini files
Signed-off-by: Deluan <deluan@navidrome.org>
2025-11-22 20:14:44 -05:00
Xavier Araque
ee51bd9281 feat(ui): add SquiddiesGlass Theme (#4632)
* feat: Add SquiddiesGlass Theme

* feat: fix commnets by gemini-code-assist in PR

* feat: fix Prettier format

* feat: fix play button, and text mobile

* feat: fix play button, and text mobile, prettier

* feat: fix chip, title artist

* fix: loading albbun, play button color

* prettier

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

---------

Signed-off-by: Deluan <deluan@navidrome.org>
Co-authored-by: Xavier Araque <francisco.araque@toolfactory.net>
Co-authored-by: Deluan <deluan@navidrome.org>
2025-11-22 13:41:59 -05:00
Stephan Wahlen
2451e9e7ae feat(ui): add AMusic (Apple Music inspired) theme (#4723)
* first show at AMuisc Theme

* prettier

* fix Duplicate key 'MuiButton'

* fix file name

* Update amusic.js

* Add styles for NDAlbumGridView in amusic.js

* Fix MuiToolbar background property in amusic.js

* Fix syntax error in amusic.js background property

* run prettier

* fix banded table styling and more

* more styling to player

- fix some appearances of green in queue
- match queue styling to rest of theme
- round albumart in player and prevent rotation

* fix queue panel background and border

to make it stand out more against the background

* fix stray comma

and lint+prettier

* queue hover still green

and player preview image not rounded properly

* Update amusic.css.js

* more mobile color fixes

* artist page

* prettier

* rounded art in albumgridview

* small tweaks to colors and radiuses

* artist and album heading

* external links colors

* unify font colors + albumgrid corner radius

* get rid of queue hover green

* unify colors in player

same red shades as primary

* mobile player floating panel background shade of green

* unify border colors

and attempt to get album cover corner radius working

* final touches

* Update amusic.css.js

* fix invisible button color fir muibutton

Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>

* fix css syntax on player queue color overrides

Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>

* remove unused MuiTableHead

* sort theme list in index.js alphabetically

* remove unused properties

* Revert "fix css syntax on player queue color overrides"

This reverts commit 503bba321d.

---------

Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2025-11-22 11:23:02 -05:00
Deluan
f6b2ab5726 feat(ui): add loading state to artist action buttons for improved user experience
Signed-off-by: Deluan <deluan@navidrome.org>
2025-11-21 22:23:38 -05:00
Deluan
67c4e24957 fix(scanner): defer artwork PreCache calls until after transaction commits
The CacheWarmer was failing with data not found errors because PreCache was being called inside the database transaction before the data was committed. The CacheWarmer runs in a separate goroutine with its own database context and could not access the uncommitted data due to transaction isolation.

Changed the persistChanges method in phase_1_folders.go to collect artwork IDs during the transaction and only call PreCache after the transaction successfully commits. This ensures the artwork data is visible to the CacheWarmer when it attempts to retrieve and cache the images.

The fix eliminates the data not found errors and allows the cache warmer to properly pre-cache album and artist artwork during library scanning.

Signed-off-by: Deluan <deluan@navidrome.org>
2025-11-21 15:27:25 -05:00
Deluan Quintão
255ed1f8e2 feat(deezer): Add artist bio, top tracks, related artists and language support (#4720)
* feat(deezer): add functions to fetch related artists, biographies, and top tracks for an artist

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

* feat(deezer): add language support for Deezer API client

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

* fix(deezer): Use GraphQL API for translated biographies

The previous implementation scraped the __DZR_APP_STATE__ from HTML,
which only contained English content. The actual biography displayed
on Deezer's website comes from their GraphQL API at pipe.deezer.com,
which properly respects the Accept-Language header and returns
translated content.

This change:
- Switches from HTML scraping to the GraphQL API
- Uses Accept-Language header instead of URL path for language
- Updates tests to match the new implementation
- Removes unused HTML fixture file

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

* refactor(deezer): move JWT token handling to a separate file for better organization

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

* feat(deezer): enhance JWT token handling with expiration validation

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

* refactor(deezer): change log level for unknown agent warnings from Warn to Debug

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

* fix(deezer): reduce JWT token expiration buffer from 10 minutes to 1 minute

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

---------

Signed-off-by: Deluan <deluan@navidrome.org>
2025-11-21 15:09:24 -05:00
Deluan
152f57e642 chore(deps): update golangci-lint version to v2.6.2
Signed-off-by: Deluan <deluan@navidrome.org>
2025-11-20 10:38:54 -05:00
Deluan
5c16622501 chore(makefile): update golangci-lint version to v2.6.2
See comment 0c71842b12 (commitcomment-170969373)

Signed-off-by: Deluan <deluan@navidrome.org>
2025-11-20 10:38:40 -05:00
Deluan
36fa869329 feat(scanner): improve error messages for cleanup operations in annotations, bookmarks, and tags
Signed-off-by: Deluan <deluan@navidrome.org>
2025-11-20 09:27:42 -05:00
Deluan
0c3012bbbd chore(deps): update Go dependencies to latest versions
Signed-off-by: Deluan <deluan@navidrome.org>
2025-11-19 22:05:46 -05:00
Deluan
353aff2c88 fix(lastfm): ignore artist placeholder image.
Fix #4702

Signed-off-by: Deluan <deluan@navidrome.org>
2025-11-19 20:49:29 -05:00
Deluan
c873466e5b fix(scanner): reset watcher trigger timer for debounce on notification receipt
Signed-off-by: Deluan <deluan@navidrome.org>
2025-11-19 20:24:13 -05:00
Deluan Quintão
3d1946e31c fix(plugins): avoid Chi RouteContext pollution by using http.NewRequest (#4713)
Signed-off-by: Deluan <deluan@navidrome.org>
2025-11-19 20:17:01 -05:00
Dongeun
6fb228bc10 fix(ui): fix translation display for library list terms (#4712) 2025-11-19 13:42:33 -05:00
Kendall Garner
32e1313fc6 ci: bump plugin compilation timeout for regressions (#4690) 2025-11-16 13:46:32 -05:00
Deluan
489d5c7760 test: update make test-race target to use PKG variable for improved flexibility
Signed-off-by: Deluan <deluan@navidrome.org>
2025-11-16 13:41:22 -05:00
Kendall Garner
0f1ede2581 fix(scanner): specify exact table to use for missing mediafile filter (#4689)
In `getAffectedAlbumIDs`, when one or more IDs is added, it adds a filter `"id": ids`.
This filter is ambiguous though, because the `getAll` query joins with library table, which _also_ has an `id` field.
Clarify this by adding the table name to the filter.

Note that this was not caught in testing, as it only uses mock db.
2025-11-16 12:54:28 -05:00
Deluan Quintão
395a36e10f fix(ui): fix library selection state for single-library users (#4686)
* fix: validate library selection state for single-library users

Fixes issues where users with a single library see no content when
selectedLibraries in localStorage contains library IDs they no longer
have access to (e.g., after removing libraries or switching accounts).

Changes:
- libraryReducer: Validate selectedLibraries when SET_USER_LIBRARIES
  is dispatched, filtering out invalid IDs and resetting to empty for
  single-library users (empty means 'all accessible libraries')
- wrapperDataProvider: Add defensive validation in getSelectedLibraries
  to check against current user libraries before applying filters
- Add comprehensive test coverage for reducer validation logic

Fixes #4553, #4508, #4569

* style: format code with prettier
2025-11-15 17:42:28 -05:00
Deluan
0161a0958c fix(ui): add CreateButton back to LibraryListActions
Signed-off-by: Deluan <deluan@navidrome.org>
2025-11-15 17:31:37 -05:00
Deluan Quintão
28d5299ffc feat(scanner): implement selective folder scanning and file system watcher improvements (#4674)
* feat: Add selective folder scanning capability

Implement targeted scanning of specific library/folder pairs without
full recursion. This enables efficient rescanning of individual folders
when changes are detected, significantly reducing scan time for large
libraries.

Key changes:
- Add ScanTarget struct and ScanFolders API to Scanner interface
- Implement CLI flag --targets for specifying libraryID:folderPath pairs
- Add FolderRepository.GetByPaths() for batch folder info retrieval
- Create loadSpecificFolders() for non-recursive directory loading
- Scope GC operations to affected libraries only (with TODO for full impl)
- Add comprehensive tests for selective scanning behavior

The selective scan:
- Only processes specified folders (no subdirectory recursion)
- Maintains library isolation
- Runs full maintenance pipeline scoped to affected libraries
- Supports both full and quick scan modes

Examples:
  navidrome scan --targets "1:Music/Rock,1:Music/Jazz"
  navidrome scan --full --targets "2:Classical"

* feat(folder): replace GetByPaths with GetFolderUpdateInfo for improved folder updates retrieval

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

* test: update parseTargets test to handle folder names with spaces

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

* refactor(folder): remove unused LibraryPath struct and update GC logging message

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

* refactor(folder): enhance external scanner to support target-specific scanning

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

* refactor(scanner): simplify scanner methods

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

* feat(watcher): implement folder scanning notifications with deduplication

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

* refactor(watcher): add resolveFolderPath function for testability

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

* feat(watcher): implement path ignoring based on .ndignore patterns

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

* refactor(scanner): implement IgnoreChecker for managing .ndignore patterns

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

* refactor(ignore_checker): rename scanner to lineScanner for clarity

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

* refactor(scanner): enhance ScanTarget struct with String method for better target representation

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

* fix(scanner): validate library ID to prevent negative values

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

* refactor(scanner): simplify GC method by removing library ID parameter

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

* feat(scanner): update folder scanning to include all descendants of specified folders

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

* feat(subsonic): allow selective scan in the /startScan endpoint

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

* refactor(scanner): update CallScan to handle specific library/folder pairs

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

* refactor(scanner): streamline scanning logic by removing scanAll method

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

* test: enhance mockScanner for thread safety and improve test reliability

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

* refactor(scanner): move scanner.ScanTarget to model.ScanTarget

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

* refactor: move scanner types to model,implement MockScanner

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

* refactor(scanner): update scanner interface and implementations to use model.Scanner

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

* refactor(folder_repository): normalize target path handling by using filepath.Clean

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

* test(folder_repository): add comprehensive tests for folder retrieval and child exclusion

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

* refactor(scanner): simplify selective scan logic using slice.Filter

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

* refactor(scanner): streamline phase folder and album creation by removing unnecessary library parameter

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

* refactor(scanner): move initialization logic from phase_1 to the scanner itself

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

* refactor(tests): rename selective scan test file to scanner_selective_test.go

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

* feat(configuration): add DevSelectiveWatcher configuration option

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

* feat(watcher): enhance .ndignore handling for folder deletions and file changes

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

* docs(scanner): comments

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

* refactor(scanner): enhance walkDirTree to support target folder scanning

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

* fix(scanner, watcher): handle errors when pushing ignore patterns for folders

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

* Update scanner/phase_1_folders.go

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* refactor(scanner): replace parseTargets function with direct call to scanner.ParseTargets

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

* test(scanner): add tests for ScanBegin and ScanEnd functionality

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

* fix(library): update PRAGMA optimize to check table sizes without ANALYZE

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

* test(scanner): refactor tests

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

* feat(ui): add selective scan options and update translations

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

* feat(ui): add quick and full scan options for individual libraries

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

* feat(ui): add Scan buttonsto the LibraryList

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

* feat(scan): update scanning parameters from 'path' to 'target' for selective scans.

* refactor(scan): move ParseTargets function to model package

* test(scan): suppress unused return value from SetUserLibraries in tests

* feat(gc): enhance garbage collection to support selective library purging

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

* fix(scanner): prevent race condition when scanning deleted folders

When the watcher detects changes in a folder that gets deleted before
the scanner runs (due to the 10-second delay), the scanner was
prematurely removing these folders from the tracking map, preventing
them from being marked as missing.

The issue occurred because `newFolderEntry` was calling `popLastUpdate`
before verifying the folder actually exists on the filesystem.

Changes:
- Move fs.Stat check before newFolderEntry creation in loadDir to
  ensure deleted folders remain in lastUpdates for finalize() to handle
- Add early existence check in walkDirTree to skip non-existent target
  folders with a warning log
- Add unit test verifying non-existent folders aren't removed from
  lastUpdates prematurely
- Add integration test for deleted folder scenario with ScanFolders

Fixes the issue where deleting entire folders (e.g., /music/AC_DC)
wouldn't mark tracks as missing when using selective folder scanning.

* refactor(scan): streamline folder entry creation and update handling

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

* feat(scan): add '@Recycle' (QNAP) to ignored directories list

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

* fix(log): improve thread safety in logging level management

* test(scan): move unit tests for ParseTargets function

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

* review

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

---------

Signed-off-by: Deluan <deluan@navidrome.org>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: deluan <deluan.quintao@mechanical-orchard.com>
2025-11-14 22:15:43 -05:00
Deluan
bca76069c3 fix(server): prioritize artist base image filenames over numeric suffixes and add tests for sorting
Signed-off-by: Deluan <deluan@navidrome.org>
2025-11-14 13:15:50 -05:00
Deluan Quintão
a10f839221 fix(server): prefer cover.jpg over cover.1.jpg (#4684)
* fix(reader): prioritize cover art selection by base filename without numeric suffixes

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

* fix(reader): update image file comparison to use natural sorting and prioritize files without numeric suffixes

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

* refactor(reader): simplify comparison, add case-sensitivity test case

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

---------

Signed-off-by: Deluan <deluan@navidrome.org>
2025-11-14 12:19:10 -05:00
Deluan
2385c8a548 test: mock formatFullDate for consistent test results 2025-11-13 18:46:06 -05:00
Deluan
9b3bdc8a8b fix(ui): adjust margins for bulk actions buttons in Spotify-ish and Ligera
Signed-off-by: Deluan <deluan@navidrome.org>
2025-11-13 18:30:44 -05:00
Deluan
f939ad84f3 fix(ui): increase contrast of button text in the Dark theme
Signed-off-by: Deluan <deluan@navidrome.org>
2025-11-12 16:17:41 -05:00
Deluan
c3e8c67116 feat(ui): update totalSize formatting to display two decimal places
Signed-off-by: Deluan <deluan@navidrome.org>
2025-11-12 13:23:18 -05:00
Deluan
d57a8e6d84 refactor(scanner): refactor legacyReleaseDate logic and add tests for date mapping
Signed-off-by: Deluan <deluan@navidrome.org>
2025-11-12 13:11:33 -05:00
Deluan
73ec89e1af feat(ui): add SizeField to display total size in LibraryList
Signed-off-by: Deluan <deluan@navidrome.org>
2025-11-12 13:01:17 -05:00
Rob Emery
131c0c565c feat(insights): detecting packaging method (#3841)
* Adding environmental variable so that navidrome can detect
if its running as an MSI install for insights

* Renaming to be ND_PACKAGE_TYPE so we can reuse this for the
.deb/.rpm stats as well

* Packaged implies a bool, this is a description so it should
be packaging or just package imo

* wixl currently doesn't support <Environment> so I'm swapping out
to a file next-door to the configuration file, we should be
able to reuse this for deb/rpm as well

* Using a file we should be able to add support for linux like this
also

* MSI should copy the package into place for us, it's not a KeyPath
as older versions won't have it, so it's presence doesn't indicate
the installed status of the package

* OK this doesn't exist, need to find another way to do it

* package to .package and moving to the datadir

* fix(scanner): better log message when AutoImportPlaylists is disabled

Fix #3861

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

* fix(scanner): support ID3v2 embedded images in WAV files

Fix #3867

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

* feat(ui): show bitDepth in song info dialog

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

* fix(server): don't break if the ND_CONFIGFILE does not exist

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

* feat(docker): automatically loads a navidrome.toml file from /data, if available

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

* feat(server): custom ArtistJoiner config (#3873)

* feat(server): custom ArtistJoiner config

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

* refactor(ui): organize ArtistLinkField, add tests

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

* feat(ui): use display artist

* feat(ui): use display artist

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

---------

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

* chore: remove some BFR-related TODOs that are not valid anymore

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

* chore: remove more outdated TODOs

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

* fix(scanner): elapsed time for folder processing is wrong in the logs

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

* Should be able to reuse this mechanism with deb and rpm, I think
it would be nice to know which specific one it is without guessing
based on /etc/debian_version or something; but it doesn't look like
that is exposed by goreleaser into an env or anything :/

* Need to reference the installed file and I think Id's don't require []

* Need to add into the root directory for this to work

* That was not deliberately removed

* feat: add RPM and DEB package configuration files for Navidrome

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

* Don't need this as goreleaser will sort it out

---------

Signed-off-by: Deluan <deluan@navidrome.org>
Co-authored-by: Deluan Quintão <deluan@navidrome.org>
2025-11-09 12:57:55 -05:00
Kendall Garner
53ff33866d feat(subsonic): implement indexBasedQueue extension (#4244)
* redo this whole PR, but clearner now that better errata is in

* update play queue types
2025-11-09 12:52:05 -05:00
Deluan
508670ecfb Revert "feat(ui): add Vietnamese localization for the application"
This reverts commit 9621a40f29.
2025-11-09 12:41:25 -05:00
Deluan
c369224597 test: fix flaky CacheWarmer deduplication test
Fixed race condition in the 'deduplicates items in buffer' test where the
background worker goroutine could process and clear the buffer before the
test could verify its contents. Added fc.SetReady(false) to keep the cache
unavailable during the test, ensuring buffered items remain in memory for
verification. This matches the pattern already used in the 'adds multiple
items to buffer' test.

Signed-off-by: Deluan <deluan@navidrome.org>
2025-11-09 12:19:28 -05:00
Deluan
ff583970f0 chore(deps): update golang.org/x/sync to v0.18.0 and golang.org/x/sys to v0.38.0
Signed-off-by: Deluan <deluan@navidrome.org>
2025-11-08 21:05:29 -05:00
Deluan
38ca65726a chore(deps): update wazero to version 1.10.0 and clean up go.mod
Signed-off-by: Deluan <deluan@navidrome.org>
2025-11-08 21:04:20 -05:00
Deluan Quintão
5ce6e16d96 fix: album statistics not updating after deleting missing files (#4668)
* feat: add album refresh functionality after deleting missing files

Implemented RefreshAlbums method in AlbumRepository to recalculate album attributes (size, duration, song count) from their constituent media files. This method processes albums in batches to maintain efficiency with large datasets.

Added integration in deleteMissingFiles to automatically refresh affected albums in the background after deleting missing media files, ensuring album statistics remain accurate. Includes comprehensive test coverage for various scenarios including single/multiple albums, empty batches, and large batch processing.

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

* refactor: extract missing files deletion into reusable service layer

Extracted inline deletion logic from server/nativeapi/missing.go into a new core.MissingFiles service interface and implementation. This provides better separation of concerns and testability.

The MissingFiles service handles:
- Deletion of specific or all missing files via transaction
- Garbage collection after deletion
- Extraction of affected album IDs from missing files
- Background refresh of artist and album statistics

The deleteMissingFiles HTTP handler now simply delegates to the service, removing 70+ lines of inline logic. All deletion, transaction, and stat refresh logic is now centralized in core/missing_files.go.

Updated dependency injection to provide MissingFiles service to the native API router. Renamed receiver variable from 'n' to 'api' throughout native_api.go for consistency.

* refactor: consolidate maintenance operations into unified service

Consolidate MissingFiles and RefreshAlbums functionality into a new Maintenance service. This refactoring:
- Creates core.Maintenance interface combining DeleteMissingFiles, DeleteAllMissingFiles, and RefreshAlbums methods
- Moves RefreshAlbums logic from AlbumRepository persistence layer to core Maintenance service
- Removes MissingFiles interface and moves its implementation to maintenanceService
- Updates all references in wire providers, native API router, and handlers
- Removes RefreshAlbums interface method from AlbumRepository model
- Improves separation of concerns by centralizing maintenance operations in the core domain

This change provides a cleaner API and better organization of maintenance-related database operations.

* refactor: remove MissingFiles interface and update references

Remove obsolete MissingFiles interface and its references:
- Delete core/missing_files.go and core/missing_files_test.go
- Remove RefreshAlbums method from AlbumRepository interface and implementation
- Remove RefreshAlbums tests from AlbumRepository test suite
- Update wire providers to use NewMaintenance instead of NewMissingFiles
- Update native API router to use Maintenance service
- Update missing.go handler to use Maintenance interface

All functionality is now consolidated in the core.Maintenance service.

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

* refactor: rename RefreshAlbums to refreshAlbums and update related calls

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

* refactor: optimize album refresh logic and improve test coverage

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

* refactor: simplify logging setup in tests with reusable LogHook function

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

* refactor: add synchronization to logger and maintenance service for thread safety

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

---------

Signed-off-by: Deluan <deluan@navidrome.org>
2025-11-08 20:11:00 -05:00
Deluan Quintão
69527085db fix(ui): resolve transparent dropdown background in Ligera theme (#4665)
Fixed the multi-library selector dropdown background in the Ligera theme by changing the palette.background.paper value from 'inherit' to bLight['500'] ('#ffffff'). This ensures the dropdown has a solid white background that properly overlays content, making the library selection options clearly readable.

Closes #4502
2025-11-08 12:47:02 -05:00
Nagi
9bb933c0d6 fix(ui): fix Playlist Italian translation(#4642)
In Italian, we usually use "Playlist" rather than "Scalette/a". "Scalette/a" refers to other functions or objects.
2025-11-07 18:41:23 -05:00
Deluan Quintão
6f4fa76772 fix(ui): update Galician, Dutch, Thai translations from POEditor (#4416)
Co-authored-by: navidrome-bot <navidrome-bot@navidrome.org>
2025-11-07 18:20:39 -05:00
Deluan
9621a40f29 feat(ui): add Vietnamese localization for the application 2025-11-07 18:13:46 -05:00
DDinghoya
df95dffa74 fix(ui): update ko.json (#4443)
* Update ko.json

* Update ko.json

Removed remove one of the entrie as below

"shuffleAll": "모두 셔플"

* Update ko.json

* Update ko.json

* Update ko.json

* Update ko.json

* Update ko.json
2025-11-07 18:10:38 -05:00
York
a59b59192a fix(ui): update zh-Hant.json (#4454)
* Update zh-Hant.json

Updated and optimized Traditional Chinese translation.

* Update zh-Hant.json

Updated and optimized Traditional Chinese translation.

* Update zh-Hant.json

Updated and optimized Traditional Chinese translation.
2025-11-07 18:06:41 -05:00
Deluan Quintão
4f7dc105b0 fix(ui): correct track ordering when sorting playlists by album (#4657)
* fix(deps): update wazero dependencies to resolve issues

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

* fix(deps): update wazero dependency to latest version

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

* fix: correct track ordering when sorting playlists by album

Fixed issue #3177 where tracks within multi-disc albums were displayed out of order when sorting playlists by album. The playlist track repository was using an incomplete sort mapping that only sorted by album name and artist, missing the critical disc_number and track_number fields.

Changed the album sort mapping in playlist_track_repository from:
  order_album_name, order_album_artist_name
to:
  order_album_name, order_album_artist_name, disc_number, track_number, order_artist_name, title

This now matches the sorting used in the media file repository, ensuring tracks are sorted by:
1. Album name (groups by album)
2. Album artist (handles compilations)
3. Disc number (multi-disc album discs in order)
4. Track number (tracks within disc in order)
5. Artist name and title (edge cases with missing metadata)

Added comprehensive tests with a multi-disc test album to verify correct sorting behavior.

* chore: sync go.mod and go.sum with master

* chore: align playlist album sort order with mediafile_repository (use album_id)

* fix: clean up test playlist to prevent state leakage in randomized test runs

---------

Signed-off-by: Deluan <deluan@navidrome.org>
2025-11-06 16:50:54 -05:00
Deluan Quintão
e918e049e2 fix: update wazero dependency to resolve ARM64 SIGILL crash (#4655)
* fix(deps): update wazero dependencies to resolve issues

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

* fix(deps): update wazero dependency to latest version

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

* fix(deps): update wazero dependency to latest version for issue resolution

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

---------

Signed-off-by: Deluan <deluan@navidrome.org>
2025-11-06 15:07:09 -05:00
Deluan Quintão
1e8d28ff46 fix: qualify user id filter to avoid ambiguous column (#4511) 2025-11-06 14:54:01 -05:00
Kendall Garner
a128b3cf98 fix(db): make playqueue position field an integer (#4481) 2025-11-06 14:41:09 -05:00
Deluan Quintão
290a9fdeaa test: fix locale-dependent tests by making formatNumber locale-aware (#4619)
- Add optional locale parameter to formatNumber function
- Update tests to explicitly pass 'en-US' locale for deterministic results
- Maintains backward compatibility: defaults to system locale when no locale specified
- No need for cross-env or environment variable manipulation
- Tests now pass consistently regardless of system locale

Related to #4417
2025-11-06 14:34:00 -05:00
Deluan
58b5ed86df refactor: extract TruncateRunes function for safe string truncation with suffix
Signed-off-by: Deluan <deluan@navidrome.org>

# Conflicts:
#	core/share.go
#	core/share_test.go
2025-11-06 14:27:38 -05:00
beerpsi
fe1cee0159 fix(share): slice content label by utf-8 runes (#4634)
* fix(share): slice content label by utf-8 runes

* Apply suggestions about avoiding allocations

Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>

* lint: remove unused import

* test: add test cases for CJK truncation

* test: add tests for ASCII labels too

---------

Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2025-11-06 14:24:07 -05:00
Deluan
3dfaa8cca1 ci: go mod tidy
Signed-off-by: Deluan <deluan@navidrome.org>
2025-11-06 12:53:41 -05:00
Deluan
0a5abfc1b1 chore: update actions/upload-artifact and actions/download-artifact to latest versions
Signed-off-by: Deluan <deluan@navidrome.org>
2025-11-06 12:43:35 -05:00
Deluan
c501bc6996 chore(deps): update ginkgo to version 2.27.2
Signed-off-by: Deluan <deluan@navidrome.org>
2025-11-06 12:41:16 -05:00
Deluan
0c71842b12 chore: update Go version to 1.25.4
Signed-off-by: Deluan <deluan@navidrome.org>
2025-11-06 12:40:44 -05:00
pca006132
e86dc03619 fix(ui): allow scrolling in play queue by adding delay (#4562) 2025-11-01 20:47:03 -04:00
770 changed files with 59496 additions and 38440 deletions

View File

@@ -9,12 +9,19 @@ ARG INSTALL_NODE="true"
ARG NODE_VERSION="lts/*"
RUN if [ "${INSTALL_NODE}" = "true" ]; then su vscode -c "source /usr/local/share/nvm/nvm.sh && nvm install ${NODE_VERSION} 2>&1"; fi
# [Optional] Uncomment this section to install additional OS packages.
# Install additional OS packages
RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \
&& apt-get -y install --no-install-recommends libtag1-dev ffmpeg
&& apt-get -y install --no-install-recommends ffmpeg
# [Optional] Uncomment the next line to use go get to install anything else you need
# RUN go get -x <your-dependency-or-tool>
# Install TagLib from cross-taglib releases
ARG CROSS_TAGLIB_VERSION="2.1.1-1"
ARG TARGETARCH
RUN DOWNLOAD_ARCH="linux-${TARGETARCH}" \
&& wget -q "https://github.com/navidrome/cross-taglib/releases/download/v${CROSS_TAGLIB_VERSION}/taglib-${DOWNLOAD_ARCH}.tar.gz" -O /tmp/cross-taglib.tar.gz \
&& tar -xzf /tmp/cross-taglib.tar.gz -C /usr --strip-components=1 \
&& mv /usr/include/taglib/* /usr/include/ \
&& rmdir /usr/include/taglib \
&& rm /tmp/cross-taglib.tar.gz /usr/provenance.json
# [Optional] Uncomment this line to install global node packages.
# RUN su vscode -c "source /usr/local/share/nvm/nvm.sh && npm install -g <your-package-here>" 2>&1

View File

@@ -7,7 +7,8 @@
"VARIANT": "1.25",
// Options
"INSTALL_NODE": "true",
"NODE_VERSION": "v24"
"NODE_VERSION": "v24",
"CROSS_TAGLIB_VERSION": "2.1.1-1"
}
},
"workspaceMount": "",
@@ -54,12 +55,10 @@
4533,
4633
],
// Use 'postCreateCommand' to run commands after the container is created.
// "postCreateCommand": "make setup-dev",
// Comment out connect as root instead. More info: https://aka.ms/vscode-remote/containers/non-root.
"remoteUser": "vscode",
"remoteEnv": {
"ND_MUSICFOLDER": "./music",
"ND_DATAFOLDER": "./data"
}
}
}

View File

@@ -25,7 +25,7 @@ jobs:
git_tag: ${{ steps.git-version.outputs.GIT_TAG }}
git_sha: ${{ steps.git-version.outputs.GIT_SHA }}
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v6
with:
fetch-depth: 0
fetch-tags: true
@@ -63,7 +63,7 @@ jobs:
name: Lint Go code
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v6
- name: Download TagLib
uses: ./.github/actions/download-taglib
@@ -71,7 +71,7 @@ jobs:
version: ${{ env.CROSS_TAGLIB_VERSION }}
- name: golangci-lint
uses: golangci/golangci-lint-action@v8
uses: golangci/golangci-lint-action@v9
with:
version: latest
problem-matchers: true
@@ -88,12 +88,22 @@ jobs:
exit 1
fi
- name: Run go generate
run: go generate ./...
- name: Verify no changes from go generate
run: |
git status --porcelain
if [ -n "$(git status --porcelain)" ]; then
echo 'Generated code is out of date. Run "make gen" and commit the changes'
exit 1
fi
go:
name: Test Go code
runs-on: ubuntu-latest
steps:
- name: Check out code into the Go module directory
uses: actions/checkout@v5
uses: actions/checkout@v6
- name: Download TagLib
uses: ./.github/actions/download-taglib
@@ -114,7 +124,7 @@ jobs:
env:
NODE_OPTIONS: "--max_old_space_size=4096"
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: 24
@@ -145,7 +155,7 @@ jobs:
name: Lint i18n files
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v6
- run: |
set -e
for file in resources/i18n/*.json; do
@@ -191,7 +201,7 @@ jobs:
PLATFORM=$(echo ${{ matrix.platform }} | tr '/' '_')
echo "PLATFORM=$PLATFORM" >> $GITHUB_ENV
- uses: actions/checkout@v5
- uses: actions/checkout@v6
- name: Prepare Docker Buildx
uses: ./.github/actions/prepare-docker
@@ -217,7 +227,7 @@ jobs:
CROSS_TAGLIB_VERSION=${{ env.CROSS_TAGLIB_VERSION }}
- name: Upload Binaries
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v6
with:
name: navidrome-${{ env.PLATFORM }}
path: ./output
@@ -248,7 +258,7 @@ jobs:
touch "/tmp/digests/${digest#sha256:}"
- name: Upload digest
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v6
if: env.IS_LINUX == 'true' && env.IS_DOCKER_PUSH_CONFIGURED == 'true' && env.IS_ARMV5 == 'false'
with:
name: digests-${{ env.PLATFORM }}
@@ -256,18 +266,55 @@ jobs:
if-no-files-found: error
retention-days: 1
push-manifest:
name: Push Docker manifest
push-manifest-ghcr:
name: Push to GHCR
permissions:
contents: read
packages: write
runs-on: ubuntu-latest
needs: [build, check-push-enabled]
if: needs.check-push-enabled.outputs.is_enabled == 'true'
env:
REGISTRY_IMAGE: ghcr.io/${{ github.repository }}
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v6
- name: Download digests
uses: actions/download-artifact@v5
uses: actions/download-artifact@v7
with:
path: /tmp/digests
pattern: digests-*
merge-multiple: true
- name: Prepare Docker Buildx
uses: ./.github/actions/prepare-docker
id: docker
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
- name: Create manifest list and push to ghcr.io
working-directory: /tmp/digests
run: |
docker buildx imagetools create $(jq -cr '.tags | map(select(startswith("ghcr.io"))) | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
$(printf '${{ env.REGISTRY_IMAGE }}@sha256:%s ' *)
- name: Inspect image in ghcr.io
run: |
docker buildx imagetools inspect ${{ env.REGISTRY_IMAGE }}:${{ steps.docker.outputs.version }}
push-manifest-dockerhub:
name: Push to Docker Hub
runs-on: ubuntu-latest
permissions:
contents: read
needs: [build, check-push-enabled]
if: needs.check-push-enabled.outputs.is_enabled == 'true' && vars.DOCKER_HUB_REPO != ''
continue-on-error: true
steps:
- uses: actions/checkout@v6
- name: Download digests
uses: actions/download-artifact@v7
with:
path: /tmp/digests
pattern: digests-*
@@ -282,28 +329,27 @@ jobs:
hub_username: ${{ secrets.DOCKER_HUB_USERNAME }}
hub_password: ${{ secrets.DOCKER_HUB_PASSWORD }}
- name: Create manifest list and push to ghcr.io
working-directory: /tmp/digests
run: |
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
$(printf '${{ env.REGISTRY_IMAGE }}@sha256:%s ' *)
- name: Create manifest list and push to Docker Hub
working-directory: /tmp/digests
if: vars.DOCKER_HUB_REPO != ''
run: |
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
$(printf '${{ vars.DOCKER_HUB_REPO }}@sha256:%s ' *)
- name: Inspect image in ghcr.io
run: |
docker buildx imagetools inspect ${{ env.REGISTRY_IMAGE }}:${{ steps.docker.outputs.version }}
uses: nick-fields/retry@v3
with:
timeout_minutes: 5
max_attempts: 3
retry_wait_seconds: 30
command: |
cd /tmp/digests
docker buildx imagetools create $(jq -cr '.tags | map(select(startswith("ghcr.io") | not)) | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
$(printf 'ghcr.io/${{ github.repository }}@sha256:%s ' *)
- name: Inspect image in Docker Hub
if: vars.DOCKER_HUB_REPO != ''
run: |
docker buildx imagetools inspect ${{ vars.DOCKER_HUB_REPO }}:${{ steps.docker.outputs.version }}
cleanup-digests:
name: Cleanup digest artifacts
runs-on: ubuntu-latest
needs: [push-manifest-ghcr, push-manifest-dockerhub]
if: always() && needs.push-manifest-ghcr.result == 'success'
steps:
- name: Delete unnecessary digest artifacts
env:
GH_TOKEN: ${{ github.token }}
@@ -318,9 +364,9 @@ jobs:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v6
- uses: actions/download-artifact@v5
- uses: actions/download-artifact@v7
with:
path: ./binaries
pattern: navidrome-windows*
@@ -339,7 +385,7 @@ jobs:
du -h binaries/msi/*.msi
- name: Upload MSI files
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v6
with:
name: navidrome-windows-installers
path: binaries/msi/*.msi
@@ -352,12 +398,12 @@ jobs:
outputs:
package_list: ${{ steps.set-package-list.outputs.package_list }}
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v6
with:
fetch-depth: 0
fetch-tags: true
- uses: actions/download-artifact@v5
- uses: actions/download-artifact@v7
with:
path: ./binaries
pattern: navidrome-*
@@ -383,7 +429,7 @@ jobs:
rm ./dist/*.tar.gz ./dist/*.zip
- name: Upload all-packages artifact
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v6
with:
name: packages
path: dist/navidrome_0*
@@ -406,13 +452,13 @@ jobs:
item: ${{ fromJson(needs.release.outputs.package_list) }}
steps:
- name: Download all-packages artifact
uses: actions/download-artifact@v5
uses: actions/download-artifact@v7
with:
name: packages
path: ./dist
- name: Upload all-packages artifact
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v6
with:
name: navidrome_linux_${{ matrix.item }}
path: dist/navidrome_0*_linux_${{ matrix.item }}

View File

@@ -12,7 +12,7 @@ jobs:
pull-requests: write
runs-on: ubuntu-latest
steps:
- uses: dessant/lock-threads@v5
- uses: dessant/lock-threads@v6
with:
process-only: 'issues, prs'
issue-inactive-days: 120

View File

@@ -8,7 +8,7 @@ jobs:
runs-on: ubuntu-latest
if: ${{ github.repository_owner == 'navidrome' }}
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v6
- name: Get updated translations
id: poeditor
env:
@@ -24,7 +24,7 @@ jobs:
git status --porcelain
git diff
- name: Create Pull Request
uses: peter-evans/create-pull-request@v7
uses: peter-evans/create-pull-request@v8
with:
token: ${{ secrets.PAT }}
author: "navidrome-bot <navidrome-bot@navidrome.org>"

6
.gitignore vendored
View File

@@ -17,6 +17,7 @@ master.zip
testDB
cache/*
*.swp
coverage.out
dist
music
*.db*
@@ -25,10 +26,13 @@ docker-compose.yml
!contrib/docker-compose.yml
binaries
navidrome-*
/ndpgen
AGENTS.md
.github/prompts
.github/instructions
.github/git-commit-instructions.md
*.exe
*.test
*.wasm
*.wasm
*.ndp
openspec/

View File

@@ -2,10 +2,10 @@ FROM --platform=$BUILDPLATFORM ghcr.io/crazy-max/osxcross:14.5-debian AS osxcros
########################################################################################################################
### Build xx (original image: tonistiigi/xx)
FROM --platform=$BUILDPLATFORM public.ecr.aws/docker/library/alpine:3.19 AS xx-build
FROM --platform=$BUILDPLATFORM public.ecr.aws/docker/library/alpine:3.20 AS xx-build
# v1.5.0
ENV XX_VERSION=b4e4c451c778822e6742bfc9d9a91d7c7d885c8a
# v1.9.0
ENV XX_VERSION=a5592eab7a57895e8d385394ff12241bc65ecd50
RUN apk add -U --no-cache git
RUN git clone https://github.com/tonistiigi/xx && \
@@ -26,7 +26,7 @@ COPY --from=xx-build /out/ /usr/bin/
########################################################################################################################
### Get TagLib
FROM --platform=$BUILDPLATFORM public.ecr.aws/docker/library/alpine:3.19 AS taglib-build
FROM --platform=$BUILDPLATFORM public.ecr.aws/docker/library/alpine:3.20 AS taglib-build
ARG TARGETPLATFORM
ARG CROSS_TAGLIB_VERSION=2.1.1-1
ENV CROSS_TAGLIB_RELEASES_URL=https://github.com/navidrome/cross-taglib/releases/download/v${CROSS_TAGLIB_VERSION}/
@@ -122,7 +122,7 @@ COPY --from=build /out /
########################################################################################################################
### Build Final Image
FROM public.ecr.aws/docker/library/alpine:3.19 AS final
FROM public.ecr.aws/docker/library/alpine:3.20 AS final
LABEL maintainer="deluan@navidrome.org"
LABEL org.opencontainers.image.source="https://github.com/navidrome/navidrome"
@@ -137,7 +137,6 @@ ENV ND_MUSICFOLDER=/music
ENV ND_DATAFOLDER=/data
ENV ND_CONFIGFILE=/data/navidrome.toml
ENV ND_PORT=4533
ENV GODEBUG="asyncpreemptoff=1"
RUN touch /.nddockerenv
EXPOSE ${ND_PORT}

View File

@@ -16,7 +16,7 @@ DOCKER_TAG ?= deluan/navidrome:develop
# Taglib version to use in cross-compilation, from https://github.com/navidrome/cross-taglib
CROSS_TAGLIB_VERSION ?= 2.1.1-1
GOLANGCI_LINT_VERSION ?= v2.5.0
GOLANGCI_LINT_VERSION ?= v2.7.2
UI_SRC_FILES := $(shell find ui -type f -not -path "ui/build/*" -not -path "ui/node_modules/*")
@@ -50,11 +50,11 @@ test: ##@Development Run Go tests. Use PKG variable to specify packages to test,
go test -tags netgo $(PKG)
.PHONY: test
testall: test-race test-i18n test-js ##@Development Run Go and JS tests
testall: test test-i18n test-js ##@Development Run Go and JS tests
.PHONY: testall
test-race: ##@Development Run Go tests with race detector
go test -tags netgo -race -shuffle=on ./...
go test -tags netgo -race -shuffle=on $(PKG)
.PHONY: test-race
test-js: ##@Development Run JS tests
@@ -85,7 +85,7 @@ install-golangci-lint: ##@Development Install golangci-lint if not present
.PHONY: install-golangci-lint
lint: install-golangci-lint ##@Development Lint Go code
PATH=$$PATH:./bin golangci-lint run -v --timeout 5m
PATH=$$PATH:./bin golangci-lint run --timeout 5m
.PHONY: lint
lintall: lint ##@Development Lint Go and JS code
@@ -103,6 +103,15 @@ wire: check_go_env ##@Development Update Dependency Injection
go tool wire gen -tags=netgo ./...
.PHONY: wire
gen: check_go_env ##@Development Run go generate for code generation
go generate ./...
cd plugins/cmd/ndpgen && go run . -host-wrappers -input=../../host -package=host
cd plugins/cmd/ndpgen && go run . -input=../../host -output=../../pdk -go -python -rust
cd plugins/cmd/ndpgen && go run . -capability-only -input=../../capabilities -output=../../pdk -go -rust
cd plugins/cmd/ndpgen && go run . -schemas -input=../../capabilities
go mod tidy -C plugins/pdk/go
.PHONY: gen
snapshots: ##@Development Update (GoLang) Snapshot tests
UPDATE_SNAPSHOTS=true go tool ginkgo ./server/subsonic/responses/...
.PHONY: snapshots
@@ -266,24 +275,6 @@ deprecated:
@echo "WARNING: This target is deprecated and will be removed in future releases. Use 'make build' instead."
.PHONY: deprecated
# Generate Go code from plugins/api/api.proto
plugin-gen: check_go_env ##@Development Generate Go code from plugins protobuf files
go generate ./plugins/...
.PHONY: plugin-gen
plugin-examples: check_go_env ##@Development Build all example plugins
$(MAKE) -C plugins/examples clean all
.PHONY: plugin-examples
plugin-clean: check_go_env ##@Development Clean all plugins
$(MAKE) -C plugins/examples clean
$(MAKE) -C plugins/testdata clean
.PHONY: plugin-clean
plugin-tests: check_go_env ##@Development Build all test plugins
$(MAKE) -C plugins/testdata clean all
.PHONY: plugin-tests
.DEFAULT_GOAL := help
HELP_FUN = \

View File

@@ -1,187 +1,186 @@
package cmd
//
//import (
// "context"
// "fmt"
// "os"
// "strings"
// "time"
//
// "github.com/navidrome/navidrome/conf"
// "github.com/navidrome/navidrome/db"
// "github.com/navidrome/navidrome/log"
// "github.com/spf13/cobra"
//)
//
//var (
// backupCount int
// backupDir string
// force bool
// restorePath string
//)
//
//func init() {
// rootCmd.AddCommand(backupRoot)
//
// backupCmd.Flags().StringVarP(&backupDir, "backup-dir", "d", "", "directory to manually make backup")
// backupRoot.AddCommand(backupCmd)
//
// pruneCmd.Flags().StringVarP(&backupDir, "backup-dir", "d", "", "directory holding Navidrome backups")
// pruneCmd.Flags().IntVarP(&backupCount, "keep-count", "k", -1, "specify the number of backups to keep. 0 remove ALL backups, and negative values mean to use the default from configuration")
// pruneCmd.Flags().BoolVarP(&force, "force", "f", false, "bypass warning when backup count is zero")
// backupRoot.AddCommand(pruneCmd)
//
// restoreCommand.Flags().StringVarP(&restorePath, "backup-file", "b", "", "path of backup database to restore")
// restoreCommand.Flags().BoolVarP(&force, "force", "f", false, "bypass restore warning")
// _ = restoreCommand.MarkFlagRequired("backup-file")
// backupRoot.AddCommand(restoreCommand)
//}
//
//var (
// backupRoot = &cobra.Command{
// Use: "backup",
// Aliases: []string{"bkp"},
// Short: "Create, restore and prune database backups",
// Long: "Create, restore and prune database backups",
// }
//
// backupCmd = &cobra.Command{
// Use: "create",
// Short: "Create a backup database",
// Long: "Manually backup Navidrome database. This will ignore BackupCount",
// Run: func(cmd *cobra.Command, _ []string) {
// runBackup(cmd.Context())
// },
// }
//
// pruneCmd = &cobra.Command{
// Use: "prune",
// Short: "Prune database backups",
// Long: "Manually prune database backups according to backup rules",
// Run: func(cmd *cobra.Command, _ []string) {
// runPrune(cmd.Context())
// },
// }
//
// restoreCommand = &cobra.Command{
// Use: "restore",
// Short: "Restore Navidrome database",
// Long: "Restore Navidrome database from a backup. This must be done offline",
// Run: func(cmd *cobra.Command, _ []string) {
// runRestore(cmd.Context())
// },
// }
//)
//
//func runBackup(ctx context.Context) {
// if backupDir != "" {
// conf.Server.Backup.Path = backupDir
// }
//
// idx := strings.LastIndex(conf.Server.DbPath, "?")
// var path string
//
// if idx == -1 {
// path = conf.Server.DbPath
// } else {
// path = conf.Server.DbPath[:idx]
// }
//
// if _, err := os.Stat(path); os.IsNotExist(err) {
// log.Fatal("No existing database", "path", path)
// return
// }
//
// start := time.Now()
// path, err := db.Backup(ctx)
// if err != nil {
// log.Fatal("Error backing up database", "backup path", conf.Server.BasePath, err)
// }
//
// elapsed := time.Since(start)
// log.Info("Backup complete", "elapsed", elapsed, "path", path)
//}
//
//func runPrune(ctx context.Context) {
// if backupDir != "" {
// conf.Server.Backup.Path = backupDir
// }
//
// if backupCount != -1 {
// conf.Server.Backup.Count = backupCount
// }
//
// if conf.Server.Backup.Count == 0 && !force {
// fmt.Println("Warning: pruning ALL backups")
// fmt.Printf("Please enter YES (all caps) to continue: ")
// var input string
// _, err := fmt.Scanln(&input)
//
// if input != "YES" || err != nil {
// log.Warn("Prune cancelled")
// return
// }
// }
//
// idx := strings.LastIndex(conf.Server.DbPath, "?")
// var path string
//
// if idx == -1 {
// path = conf.Server.DbPath
// } else {
// path = conf.Server.DbPath[:idx]
// }
//
// if _, err := os.Stat(path); os.IsNotExist(err) {
// log.Fatal("No existing database", "path", path)
// return
// }
//
// start := time.Now()
// count, err := db.Prune(ctx)
// if err != nil {
// log.Fatal("Error pruning up database", "backup path", conf.Server.BasePath, err)
// }
//
// elapsed := time.Since(start)
//
// log.Info("Prune complete", "elapsed", elapsed, "successfully pruned", count)
//}
//
//func runRestore(ctx context.Context) {
// idx := strings.LastIndex(conf.Server.DbPath, "?")
// var path string
//
// if idx == -1 {
// path = conf.Server.DbPath
// } else {
// path = conf.Server.DbPath[:idx]
// }
//
// if _, err := os.Stat(path); os.IsNotExist(err) {
// log.Fatal("No existing database", "path", path)
// return
// }
//
// if !force {
// fmt.Println("Warning: restoring the Navidrome database should only be done offline, especially if your backup is very old.")
// fmt.Printf("Please enter YES (all caps) to continue: ")
// var input string
// _, err := fmt.Scanln(&input)
//
// if input != "YES" || err != nil {
// log.Warn("Restore cancelled")
// return
// }
// }
//
// start := time.Now()
// err := db.Restore(ctx, restorePath)
// if err != nil {
// log.Fatal("Error restoring database", "backup path", conf.Server.BasePath, err)
// }
//
// elapsed := time.Since(start)
// log.Info("Restore complete", "elapsed", elapsed)
//}
import (
"context"
"fmt"
"os"
"strings"
"time"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/db"
"github.com/navidrome/navidrome/log"
"github.com/spf13/cobra"
)
var (
backupCount int
backupDir string
force bool
restorePath string
)
func init() {
rootCmd.AddCommand(backupRoot)
backupCmd.Flags().StringVarP(&backupDir, "backup-dir", "d", "", "directory to manually make backup")
backupRoot.AddCommand(backupCmd)
pruneCmd.Flags().StringVarP(&backupDir, "backup-dir", "d", "", "directory holding Navidrome backups")
pruneCmd.Flags().IntVarP(&backupCount, "keep-count", "k", -1, "specify the number of backups to keep. 0 remove ALL backups, and negative values mean to use the default from configuration")
pruneCmd.Flags().BoolVarP(&force, "force", "f", false, "bypass warning when backup count is zero")
backupRoot.AddCommand(pruneCmd)
restoreCommand.Flags().StringVarP(&restorePath, "backup-file", "b", "", "path of backup database to restore")
restoreCommand.Flags().BoolVarP(&force, "force", "f", false, "bypass restore warning")
_ = restoreCommand.MarkFlagRequired("backup-file")
backupRoot.AddCommand(restoreCommand)
}
var (
backupRoot = &cobra.Command{
Use: "backup",
Aliases: []string{"bkp"},
Short: "Create, restore and prune database backups",
Long: "Create, restore and prune database backups",
}
backupCmd = &cobra.Command{
Use: "create",
Short: "Create a backup database",
Long: "Manually backup Navidrome database. This will ignore BackupCount",
Run: func(cmd *cobra.Command, _ []string) {
runBackup(cmd.Context())
},
}
pruneCmd = &cobra.Command{
Use: "prune",
Short: "Prune database backups",
Long: "Manually prune database backups according to backup rules",
Run: func(cmd *cobra.Command, _ []string) {
runPrune(cmd.Context())
},
}
restoreCommand = &cobra.Command{
Use: "restore",
Short: "Restore Navidrome database",
Long: "Restore Navidrome database from a backup. This must be done offline",
Run: func(cmd *cobra.Command, _ []string) {
runRestore(cmd.Context())
},
}
)
func runBackup(ctx context.Context) {
if backupDir != "" {
conf.Server.Backup.Path = backupDir
}
idx := strings.LastIndex(conf.Server.DbPath, "?")
var path string
if idx == -1 {
path = conf.Server.DbPath
} else {
path = conf.Server.DbPath[:idx]
}
if _, err := os.Stat(path); os.IsNotExist(err) {
log.Fatal("No existing database", "path", path)
return
}
start := time.Now()
path, err := db.Backup(ctx)
if err != nil {
log.Fatal("Error backing up database", "backup path", conf.Server.BasePath, err)
}
elapsed := time.Since(start)
log.Info("Backup complete", "elapsed", elapsed, "path", path)
}
func runPrune(ctx context.Context) {
if backupDir != "" {
conf.Server.Backup.Path = backupDir
}
if backupCount != -1 {
conf.Server.Backup.Count = backupCount
}
if conf.Server.Backup.Count == 0 && !force {
fmt.Println("Warning: pruning ALL backups")
fmt.Printf("Please enter YES (all caps) to continue: ")
var input string
_, err := fmt.Scanln(&input)
if input != "YES" || err != nil {
log.Warn("Prune cancelled")
return
}
}
idx := strings.LastIndex(conf.Server.DbPath, "?")
var path string
if idx == -1 {
path = conf.Server.DbPath
} else {
path = conf.Server.DbPath[:idx]
}
if _, err := os.Stat(path); os.IsNotExist(err) {
log.Fatal("No existing database", "path", path)
return
}
start := time.Now()
count, err := db.Prune(ctx)
if err != nil {
log.Fatal("Error pruning up database", "backup path", conf.Server.BasePath, err)
}
elapsed := time.Since(start)
log.Info("Prune complete", "elapsed", elapsed, "successfully pruned", count)
}
func runRestore(ctx context.Context) {
idx := strings.LastIndex(conf.Server.DbPath, "?")
var path string
if idx == -1 {
path = conf.Server.DbPath
} else {
path = conf.Server.DbPath[:idx]
}
if _, err := os.Stat(path); os.IsNotExist(err) {
log.Fatal("No existing database", "path", path)
return
}
if !force {
fmt.Println("Warning: restoring the Navidrome database should only be done offline, especially if your backup is very old.")
fmt.Printf("Please enter YES (all caps) to continue: ")
var input string
_, err := fmt.Scanln(&input)
if input != "YES" || err != nil {
log.Warn("Restore cancelled")
return
}
}
start := time.Now()
err := db.Restore(ctx, restorePath)
if err != nil {
log.Fatal("Error restoring database", "backup path", conf.Server.BasePath, err)
}
elapsed := time.Since(start)
log.Info("Restore complete", "elapsed", elapsed)
}

View File

@@ -10,11 +10,8 @@ import (
"strconv"
"github.com/Masterminds/squirrel"
"github.com/navidrome/navidrome/core/auth"
"github.com/navidrome/navidrome/db"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/persistence"
"github.com/spf13/cobra"
)
@@ -52,7 +49,7 @@ var (
Short: "Export playlists",
Long: "Export Navidrome playlists to M3U files",
Run: func(cmd *cobra.Command, args []string) {
runExporter()
runExporter(cmd.Context())
},
}
@@ -60,15 +57,13 @@ var (
Use: "list",
Short: "List playlists",
Run: func(cmd *cobra.Command, args []string) {
runList()
runList(cmd.Context())
},
}
)
func runExporter() {
sqlDB := db.Db()
ds := persistence.New(sqlDB)
ctx := auth.WithAdminUser(context.Background(), ds)
func runExporter(ctx context.Context) {
ds, ctx := getAdminContext(ctx)
playlist, err := ds.Playlist(ctx).GetWithTracks(playlistID, true, false)
if err != nil && !errors.Is(err, model.ErrNotFound) {
log.Fatal("Error retrieving playlist", "name", playlistID, err)
@@ -100,31 +95,19 @@ func runExporter() {
}
}
func runList() {
func runList(ctx context.Context) {
if outputFormat != "csv" && outputFormat != "json" {
log.Fatal("Invalid output format. Must be one of csv, json", "format", outputFormat)
}
sqlDB := db.Db()
ds := persistence.New(sqlDB)
ctx := auth.WithAdminUser(context.Background(), ds)
ds, ctx := getAdminContext(ctx)
options := model.QueryOptions{Sort: "owner_name"}
if userID != "" {
user, err := ds.User(ctx).FindByUsername(userID)
if err != nil && !errors.Is(err, model.ErrNotFound) {
log.Fatal("Error retrieving user by name", "name", userID, err)
user, err := getUser(ctx, userID, ds)
if err != nil {
log.Fatal(ctx, "Error retrieving user", "username or id", userID)
}
if errors.Is(err, model.ErrNotFound) {
user, err = ds.User(ctx).Get(userID)
if err != nil {
log.Fatal("Error retrieving user by id", "id", userID, err)
}
}
options.Filters = squirrel.Eq{"owner_id": user.ID}
}

View File

@@ -1,716 +0,0 @@
package cmd
import (
"cmp"
"crypto/sha256"
"encoding/hex"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"text/tabwriter"
"time"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/plugins"
"github.com/navidrome/navidrome/plugins/schema"
"github.com/navidrome/navidrome/utils"
"github.com/navidrome/navidrome/utils/slice"
"github.com/spf13/cobra"
)
const (
pluginPackageExtension = ".ndp"
pluginDirPermissions = 0700
pluginFilePermissions = 0600
)
func init() {
pluginCmd := &cobra.Command{
Use: "plugin",
Short: "Manage Navidrome plugins",
Long: "Commands for managing Navidrome plugins",
}
listCmd := &cobra.Command{
Use: "list",
Short: "List installed plugins",
Long: "List all installed plugins with their metadata",
Run: pluginList,
}
infoCmd := &cobra.Command{
Use: "info [pluginPackage|pluginName]",
Short: "Show details of a plugin",
Long: "Show detailed information about a plugin package (.ndp file) or an installed plugin",
Args: cobra.ExactArgs(1),
Run: pluginInfo,
}
installCmd := &cobra.Command{
Use: "install [pluginPackage]",
Short: "Install a plugin from a .ndp file",
Long: "Install a Navidrome Plugin Package (.ndp) file",
Args: cobra.ExactArgs(1),
Run: pluginInstall,
}
removeCmd := &cobra.Command{
Use: "remove [pluginName]",
Short: "Remove an installed plugin",
Long: "Remove a plugin by name",
Args: cobra.ExactArgs(1),
Run: pluginRemove,
}
updateCmd := &cobra.Command{
Use: "update [pluginPackage]",
Short: "Update an existing plugin",
Long: "Update an installed plugin with a new version from a .ndp file",
Args: cobra.ExactArgs(1),
Run: pluginUpdate,
}
refreshCmd := &cobra.Command{
Use: "refresh [pluginName]",
Short: "Reload a plugin without restarting Navidrome",
Long: "Reload and recompile a plugin without needing to restart Navidrome",
Args: cobra.ExactArgs(1),
Run: pluginRefresh,
}
devCmd := &cobra.Command{
Use: "dev [folder_path]",
Short: "Create symlink to development folder",
Long: "Create a symlink from a plugin development folder to the plugins directory for easier development",
Args: cobra.ExactArgs(1),
Run: pluginDev,
}
pluginCmd.AddCommand(listCmd, infoCmd, installCmd, removeCmd, updateCmd, refreshCmd, devCmd)
rootCmd.AddCommand(pluginCmd)
}
// Validation helpers
func validatePluginPackageFile(path string) error {
if !utils.FileExists(path) {
return fmt.Errorf("plugin package not found: %s", path)
}
if filepath.Ext(path) != pluginPackageExtension {
return fmt.Errorf("not a valid plugin package: %s (expected %s extension)", path, pluginPackageExtension)
}
return nil
}
func validatePluginDirectory(pluginsDir, pluginName string) (string, error) {
pluginDir := filepath.Join(pluginsDir, pluginName)
if !utils.FileExists(pluginDir) {
return "", fmt.Errorf("plugin not found: %s (path: %s)", pluginName, pluginDir)
}
return pluginDir, nil
}
func resolvePluginPath(pluginDir string) (resolvedPath string, isSymlink bool, err error) {
// Check if it's a directory or a symlink
lstat, err := os.Lstat(pluginDir)
if err != nil {
return "", false, fmt.Errorf("failed to stat plugin: %w", err)
}
isSymlink = lstat.Mode()&os.ModeSymlink != 0
if isSymlink {
// Resolve the symlink target
targetDir, err := os.Readlink(pluginDir)
if err != nil {
return "", true, fmt.Errorf("failed to resolve symlink: %w", err)
}
// If target is a relative path, make it absolute
if !filepath.IsAbs(targetDir) {
targetDir = filepath.Join(filepath.Dir(pluginDir), targetDir)
}
// Verify the target exists and is a directory
targetInfo, err := os.Stat(targetDir)
if err != nil {
return "", true, fmt.Errorf("failed to access symlink target %s: %w", targetDir, err)
}
if !targetInfo.IsDir() {
return "", true, fmt.Errorf("symlink target is not a directory: %s", targetDir)
}
return targetDir, true, nil
} else if !lstat.IsDir() {
return "", false, fmt.Errorf("not a valid plugin directory: %s", pluginDir)
}
return pluginDir, false, nil
}
// Package handling helpers
func loadAndValidatePackage(ndpPath string) (*plugins.PluginPackage, error) {
if err := validatePluginPackageFile(ndpPath); err != nil {
return nil, err
}
pkg, err := plugins.LoadPackage(ndpPath)
if err != nil {
return nil, fmt.Errorf("failed to load plugin package: %w", err)
}
return pkg, nil
}
func extractAndSetupPlugin(ndpPath, targetDir string) error {
if err := plugins.ExtractPackage(ndpPath, targetDir); err != nil {
return fmt.Errorf("failed to extract plugin package: %w", err)
}
ensurePluginDirPermissions(targetDir)
return nil
}
// Display helpers
func displayPluginTableRow(w *tabwriter.Writer, discovery plugins.PluginDiscoveryEntry) {
if discovery.Error != nil {
// Handle global errors (like directory read failure)
if discovery.ID == "" {
log.Error("Failed to read plugins directory", "folder", conf.Server.Plugins.Folder, discovery.Error)
return
}
// Handle individual plugin errors - show them in the table
fmt.Fprintf(w, "%s\tERROR\tERROR\tERROR\tERROR\t%v\n", discovery.ID, discovery.Error)
return
}
// Mark symlinks with an indicator
nameDisplay := discovery.Manifest.Name
if discovery.IsSymlink {
nameDisplay = nameDisplay + " (dev)"
}
// Convert capabilities to strings
capabilities := slice.Map(discovery.Manifest.Capabilities, func(cap schema.PluginManifestCapabilitiesElem) string {
return string(cap)
})
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\n",
discovery.ID,
nameDisplay,
cmp.Or(discovery.Manifest.Author, "-"),
cmp.Or(discovery.Manifest.Version, "-"),
strings.Join(capabilities, ", "),
cmp.Or(discovery.Manifest.Description, "-"))
}
func displayTypedPermissions(permissions schema.PluginManifestPermissions, indent string) {
if permissions.Http != nil {
fmt.Printf("%shttp:\n", indent)
fmt.Printf("%s Reason: %s\n", indent, permissions.Http.Reason)
fmt.Printf("%s Allow Local Network: %t\n", indent, permissions.Http.AllowLocalNetwork)
fmt.Printf("%s Allowed URLs:\n", indent)
for urlPattern, methodEnums := range permissions.Http.AllowedUrls {
methods := make([]string, len(methodEnums))
for i, methodEnum := range methodEnums {
methods[i] = string(methodEnum)
}
fmt.Printf("%s %s: [%s]\n", indent, urlPattern, strings.Join(methods, ", "))
}
fmt.Println()
}
if permissions.Config != nil {
fmt.Printf("%sconfig:\n", indent)
fmt.Printf("%s Reason: %s\n", indent, permissions.Config.Reason)
fmt.Println()
}
if permissions.Scheduler != nil {
fmt.Printf("%sscheduler:\n", indent)
fmt.Printf("%s Reason: %s\n", indent, permissions.Scheduler.Reason)
fmt.Println()
}
if permissions.Websocket != nil {
fmt.Printf("%swebsocket:\n", indent)
fmt.Printf("%s Reason: %s\n", indent, permissions.Websocket.Reason)
fmt.Printf("%s Allow Local Network: %t\n", indent, permissions.Websocket.AllowLocalNetwork)
fmt.Printf("%s Allowed URLs: [%s]\n", indent, strings.Join(permissions.Websocket.AllowedUrls, ", "))
fmt.Println()
}
if permissions.Cache != nil {
fmt.Printf("%scache:\n", indent)
fmt.Printf("%s Reason: %s\n", indent, permissions.Cache.Reason)
fmt.Println()
}
if permissions.Artwork != nil {
fmt.Printf("%sartwork:\n", indent)
fmt.Printf("%s Reason: %s\n", indent, permissions.Artwork.Reason)
fmt.Println()
}
if permissions.Subsonicapi != nil {
allowedUsers := "All Users"
if len(permissions.Subsonicapi.AllowedUsernames) > 0 {
allowedUsers = strings.Join(permissions.Subsonicapi.AllowedUsernames, ", ")
}
fmt.Printf("%ssubsonicapi:\n", indent)
fmt.Printf("%s Reason: %s\n", indent, permissions.Subsonicapi.Reason)
fmt.Printf("%s Allow Admins: %t\n", indent, permissions.Subsonicapi.AllowAdmins)
fmt.Printf("%s Allowed Usernames: [%s]\n", indent, allowedUsers)
fmt.Println()
}
}
func displayPluginDetails(manifest *schema.PluginManifest, fileInfo *pluginFileInfo, permInfo *pluginPermissionInfo) {
fmt.Println("\nPlugin Information:")
fmt.Printf(" Name: %s\n", manifest.Name)
fmt.Printf(" Author: %s\n", manifest.Author)
fmt.Printf(" Version: %s\n", manifest.Version)
fmt.Printf(" Description: %s\n", manifest.Description)
fmt.Print(" Capabilities: ")
capabilities := make([]string, len(manifest.Capabilities))
for i, cap := range manifest.Capabilities {
capabilities[i] = string(cap)
}
fmt.Print(strings.Join(capabilities, ", "))
fmt.Println()
// Display manifest permissions using the typed permissions
fmt.Println(" Required Permissions:")
displayTypedPermissions(manifest.Permissions, " ")
// Print file information if available
if fileInfo != nil {
fmt.Println("Package Information:")
fmt.Printf(" File: %s\n", fileInfo.path)
fmt.Printf(" Size: %d bytes (%.2f KB)\n", fileInfo.size, float64(fileInfo.size)/1024)
fmt.Printf(" SHA-256: %s\n", fileInfo.hash)
fmt.Printf(" Modified: %s\n", fileInfo.modTime.Format(time.RFC3339))
}
// Print file permissions information if available
if permInfo != nil {
fmt.Println("File Permissions:")
fmt.Printf(" Plugin Directory: %s (%s)\n", permInfo.dirPath, permInfo.dirMode)
if permInfo.isSymlink {
fmt.Printf(" Symlink Target: %s (%s)\n", permInfo.targetPath, permInfo.targetMode)
}
fmt.Printf(" Manifest File: %s\n", permInfo.manifestMode)
if permInfo.wasmMode != "" {
fmt.Printf(" WASM File: %s\n", permInfo.wasmMode)
}
}
}
type pluginFileInfo struct {
path string
size int64
hash string
modTime time.Time
}
type pluginPermissionInfo struct {
dirPath string
dirMode string
isSymlink bool
targetPath string
targetMode string
manifestMode string
wasmMode string
}
func getFileInfo(path string) *pluginFileInfo {
fileInfo, err := os.Stat(path)
if err != nil {
log.Error("Failed to get file information", err)
return nil
}
return &pluginFileInfo{
path: path,
size: fileInfo.Size(),
hash: calculateSHA256(path),
modTime: fileInfo.ModTime(),
}
}
func getPermissionInfo(pluginDir string) *pluginPermissionInfo {
// Get plugin directory permissions
dirInfo, err := os.Lstat(pluginDir)
if err != nil {
log.Error("Failed to get plugin directory permissions", err)
return nil
}
permInfo := &pluginPermissionInfo{
dirPath: pluginDir,
dirMode: dirInfo.Mode().String(),
}
// Check if it's a symlink
if dirInfo.Mode()&os.ModeSymlink != 0 {
permInfo.isSymlink = true
// Get target path and permissions
targetPath, err := os.Readlink(pluginDir)
if err == nil {
if !filepath.IsAbs(targetPath) {
targetPath = filepath.Join(filepath.Dir(pluginDir), targetPath)
}
permInfo.targetPath = targetPath
if targetInfo, err := os.Stat(targetPath); err == nil {
permInfo.targetMode = targetInfo.Mode().String()
}
}
}
// Get manifest file permissions
manifestPath := filepath.Join(pluginDir, "manifest.json")
if manifestInfo, err := os.Stat(manifestPath); err == nil {
permInfo.manifestMode = manifestInfo.Mode().String()
}
// Get WASM file permissions (look for .wasm files)
entries, err := os.ReadDir(pluginDir)
if err == nil {
for _, entry := range entries {
if filepath.Ext(entry.Name()) == ".wasm" {
wasmPath := filepath.Join(pluginDir, entry.Name())
if wasmInfo, err := os.Stat(wasmPath); err == nil {
permInfo.wasmMode = wasmInfo.Mode().String()
break // Just show the first WASM file found
}
}
}
}
return permInfo
}
// Command implementations
func pluginList(cmd *cobra.Command, args []string) {
discoveries := plugins.DiscoverPlugins(conf.Server.Plugins.Folder)
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
fmt.Fprintln(w, "ID\tNAME\tAUTHOR\tVERSION\tCAPABILITIES\tDESCRIPTION")
for _, discovery := range discoveries {
displayPluginTableRow(w, discovery)
}
w.Flush()
}
func pluginInfo(cmd *cobra.Command, args []string) {
path := args[0]
pluginsDir := conf.Server.Plugins.Folder
var manifest *schema.PluginManifest
var fileInfo *pluginFileInfo
var permInfo *pluginPermissionInfo
if filepath.Ext(path) == pluginPackageExtension {
// It's a package file
pkg, err := loadAndValidatePackage(path)
if err != nil {
log.Fatal("Failed to load plugin package", err)
}
manifest = pkg.Manifest
fileInfo = getFileInfo(path)
// No permission info for package files
} else {
// It's a plugin name
pluginDir, err := validatePluginDirectory(pluginsDir, path)
if err != nil {
log.Fatal("Plugin validation failed", err)
}
manifest, err = plugins.LoadManifest(pluginDir)
if err != nil {
log.Fatal("Failed to load plugin manifest", err)
}
// Get permission info for installed plugins
permInfo = getPermissionInfo(pluginDir)
}
displayPluginDetails(manifest, fileInfo, permInfo)
}
func pluginInstall(cmd *cobra.Command, args []string) {
ndpPath := args[0]
pluginsDir := conf.Server.Plugins.Folder
pkg, err := loadAndValidatePackage(ndpPath)
if err != nil {
log.Fatal("Package validation failed", err)
}
// Create target directory based on plugin name
targetDir := filepath.Join(pluginsDir, pkg.Manifest.Name)
// Check if plugin already exists
if utils.FileExists(targetDir) {
log.Fatal("Plugin already installed", "name", pkg.Manifest.Name, "path", targetDir,
"use", "navidrome plugin update")
}
if err := extractAndSetupPlugin(ndpPath, targetDir); err != nil {
log.Fatal("Plugin installation failed", err)
}
fmt.Printf("Plugin '%s' v%s installed successfully\n", pkg.Manifest.Name, pkg.Manifest.Version)
}
func pluginRemove(cmd *cobra.Command, args []string) {
pluginName := args[0]
pluginsDir := conf.Server.Plugins.Folder
pluginDir, err := validatePluginDirectory(pluginsDir, pluginName)
if err != nil {
log.Fatal("Plugin validation failed", err)
}
_, isSymlink, err := resolvePluginPath(pluginDir)
if err != nil {
log.Fatal("Failed to resolve plugin path", err)
}
if isSymlink {
// For symlinked plugins (dev mode), just remove the symlink
if err := os.Remove(pluginDir); err != nil {
log.Fatal("Failed to remove plugin symlink", "name", pluginName, err)
}
fmt.Printf("Development plugin symlink '%s' removed successfully (target directory preserved)\n", pluginName)
} else {
// For regular plugins, remove the entire directory
if err := os.RemoveAll(pluginDir); err != nil {
log.Fatal("Failed to remove plugin directory", "name", pluginName, err)
}
fmt.Printf("Plugin '%s' removed successfully\n", pluginName)
}
}
func pluginUpdate(cmd *cobra.Command, args []string) {
ndpPath := args[0]
pluginsDir := conf.Server.Plugins.Folder
pkg, err := loadAndValidatePackage(ndpPath)
if err != nil {
log.Fatal("Package validation failed", err)
}
// Check if plugin exists
targetDir := filepath.Join(pluginsDir, pkg.Manifest.Name)
if !utils.FileExists(targetDir) {
log.Fatal("Plugin not found", "name", pkg.Manifest.Name, "path", targetDir,
"use", "navidrome plugin install")
}
// Create a backup of the existing plugin
backupDir := targetDir + ".bak." + time.Now().Format("20060102150405")
if err := os.Rename(targetDir, backupDir); err != nil {
log.Fatal("Failed to backup existing plugin", err)
}
// Extract the new package
if err := extractAndSetupPlugin(ndpPath, targetDir); err != nil {
// Restore backup if extraction failed
os.RemoveAll(targetDir)
_ = os.Rename(backupDir, targetDir) // Ignore error as we're already in a fatal path
log.Fatal("Plugin update failed", err)
}
// Remove the backup
os.RemoveAll(backupDir)
fmt.Printf("Plugin '%s' updated to v%s successfully\n", pkg.Manifest.Name, pkg.Manifest.Version)
}
func pluginRefresh(cmd *cobra.Command, args []string) {
pluginName := args[0]
pluginsDir := conf.Server.Plugins.Folder
pluginDir, err := validatePluginDirectory(pluginsDir, pluginName)
if err != nil {
log.Fatal("Plugin validation failed", err)
}
resolvedPath, isSymlink, err := resolvePluginPath(pluginDir)
if err != nil {
log.Fatal("Failed to resolve plugin path", err)
}
if isSymlink {
log.Debug("Processing symlinked plugin", "name", pluginName, "link", pluginDir, "target", resolvedPath)
}
fmt.Printf("Refreshing plugin '%s'...\n", pluginName)
// Get the plugin manager and refresh
mgr := GetPluginManager(cmd.Context())
log.Debug("Scanning plugins directory", "path", pluginsDir)
mgr.ScanPlugins()
log.Info("Waiting for plugin compilation to complete", "name", pluginName)
// Wait for compilation to complete
if err := mgr.EnsureCompiled(pluginName); err != nil {
log.Fatal("Failed to compile refreshed plugin", "name", pluginName, err)
}
log.Info("Plugin compilation completed successfully", "name", pluginName)
fmt.Printf("Plugin '%s' refreshed successfully\n", pluginName)
}
func pluginDev(cmd *cobra.Command, args []string) {
sourcePath, err := filepath.Abs(args[0])
if err != nil {
log.Fatal("Invalid path", "path", args[0], err)
}
pluginsDir := conf.Server.Plugins.Folder
// Validate source directory and manifest
if err := validateDevSource(sourcePath); err != nil {
log.Fatal("Source validation failed", err)
}
// Load manifest to get plugin name
manifest, err := plugins.LoadManifest(sourcePath)
if err != nil {
log.Fatal("Failed to load plugin manifest", "path", filepath.Join(sourcePath, "manifest.json"), err)
}
pluginName := cmp.Or(manifest.Name, filepath.Base(sourcePath))
targetPath := filepath.Join(pluginsDir, pluginName)
// Handle existing target
if err := handleExistingTarget(targetPath, sourcePath); err != nil {
log.Fatal("Failed to handle existing target", err)
}
// Create target directory if needed
if err := os.MkdirAll(filepath.Dir(targetPath), 0755); err != nil {
log.Fatal("Failed to create plugins directory", "path", filepath.Dir(targetPath), err)
}
// Create the symlink
if err := os.Symlink(sourcePath, targetPath); err != nil {
log.Fatal("Failed to create symlink", "source", sourcePath, "target", targetPath, err)
}
fmt.Printf("Development symlink created: '%s' -> '%s'\n", targetPath, sourcePath)
fmt.Println("Plugin can be refreshed with: navidrome plugin refresh", pluginName)
}
// Utility functions
func validateDevSource(sourcePath string) error {
sourceInfo, err := os.Stat(sourcePath)
if err != nil {
return fmt.Errorf("source folder not found: %s (%w)", sourcePath, err)
}
if !sourceInfo.IsDir() {
return fmt.Errorf("source path is not a directory: %s", sourcePath)
}
manifestPath := filepath.Join(sourcePath, "manifest.json")
if !utils.FileExists(manifestPath) {
return fmt.Errorf("source folder missing manifest.json: %s", sourcePath)
}
return nil
}
func handleExistingTarget(targetPath, sourcePath string) error {
if !utils.FileExists(targetPath) {
return nil // Nothing to handle
}
// Check if it's already a symlink to our source
existingLink, err := os.Readlink(targetPath)
if err == nil && existingLink == sourcePath {
fmt.Printf("Symlink already exists and points to the correct source\n")
return fmt.Errorf("symlink already exists") // This will cause early return in caller
}
// Handle case where target exists but is not a symlink to our source
fmt.Printf("Target path '%s' already exists.\n", targetPath)
fmt.Print("Do you want to replace it? (y/N): ")
var response string
_, err = fmt.Scanln(&response)
if err != nil || strings.ToLower(response) != "y" {
if err != nil {
log.Debug("Error reading input, assuming 'no'", err)
}
return fmt.Errorf("operation canceled")
}
// Remove existing target
if err := os.RemoveAll(targetPath); err != nil {
return fmt.Errorf("failed to remove existing target %s: %w", targetPath, err)
}
return nil
}
func ensurePluginDirPermissions(dir string) {
if err := os.Chmod(dir, pluginDirPermissions); err != nil {
log.Error("Failed to set plugin directory permissions", "dir", dir, err)
}
// Apply permissions to all files in the directory
entries, err := os.ReadDir(dir)
if err != nil {
log.Error("Failed to read plugin directory", "dir", dir, err)
return
}
for _, entry := range entries {
path := filepath.Join(dir, entry.Name())
info, err := os.Stat(path)
if err != nil {
log.Error("Failed to stat file", "path", path, err)
continue
}
mode := os.FileMode(pluginFilePermissions) // Files
if info.IsDir() {
mode = os.FileMode(pluginDirPermissions) // Directories
ensurePluginDirPermissions(path) // Recursive
}
if err := os.Chmod(path, mode); err != nil {
log.Error("Failed to set file permissions", "path", path, err)
}
}
}
func calculateSHA256(filePath string) string {
file, err := os.Open(filePath)
if err != nil {
log.Error("Failed to open file for hashing", err)
return "N/A"
}
defer file.Close()
hasher := sha256.New()
if _, err := io.Copy(hasher, file); err != nil {
log.Error("Failed to calculate hash", err)
return "N/A"
}
return hex.EncodeToString(hasher.Sum(nil))
}

View File

@@ -1,193 +0,0 @@
package cmd
import (
"io"
"os"
"path/filepath"
"strings"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/conf/configtest"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
"github.com/spf13/cobra"
)
var _ = Describe("Plugin CLI Commands", func() {
var tempDir string
var cmd *cobra.Command
var stdOut *os.File
var origStdout *os.File
var outReader *os.File
// Helper to create a test plugin with the given name and details
createTestPlugin := func(name, author, version string, capabilities []string) string {
pluginDir := filepath.Join(tempDir, name)
Expect(os.MkdirAll(pluginDir, 0755)).To(Succeed())
// Create a properly formatted capabilities JSON array
capabilitiesJSON := `"` + strings.Join(capabilities, `", "`) + `"`
manifest := `{
"name": "` + name + `",
"author": "` + author + `",
"version": "` + version + `",
"description": "Plugin for testing",
"website": "https://test.navidrome.org/` + name + `",
"capabilities": [` + capabilitiesJSON + `],
"permissions": {}
}`
Expect(os.WriteFile(filepath.Join(pluginDir, "manifest.json"), []byte(manifest), 0600)).To(Succeed())
// Create a dummy WASM file
wasmContent := []byte("dummy wasm content for testing")
Expect(os.WriteFile(filepath.Join(pluginDir, "plugin.wasm"), wasmContent, 0600)).To(Succeed())
return pluginDir
}
// Helper to execute a command and return captured output
captureOutput := func(reader io.Reader) string {
stdOut.Close()
outputBytes, err := io.ReadAll(reader)
Expect(err).NotTo(HaveOccurred())
return string(outputBytes)
}
BeforeEach(func() {
DeferCleanup(configtest.SetupConfig())
tempDir = GinkgoT().TempDir()
// Setup config
conf.Server.Plugins.Enabled = true
conf.Server.Plugins.Folder = tempDir
// Create a command for testing
cmd = &cobra.Command{Use: "test"}
// Setup stdout capture
origStdout = os.Stdout
var err error
outReader, stdOut, err = os.Pipe()
Expect(err).NotTo(HaveOccurred())
os.Stdout = stdOut
DeferCleanup(func() {
os.Stdout = origStdout
})
})
AfterEach(func() {
os.Stdout = origStdout
if stdOut != nil {
stdOut.Close()
}
if outReader != nil {
outReader.Close()
}
})
Describe("Plugin list command", func() {
It("should list installed plugins", func() {
// Create test plugins
createTestPlugin("plugin1", "Test Author", "1.0.0", []string{"MetadataAgent"})
createTestPlugin("plugin2", "Another Author", "2.1.0", []string{"Scrobbler"})
// Execute command
pluginList(cmd, []string{})
// Verify output
output := captureOutput(outReader)
Expect(output).To(ContainSubstring("plugin1"))
Expect(output).To(ContainSubstring("Test Author"))
Expect(output).To(ContainSubstring("1.0.0"))
Expect(output).To(ContainSubstring("MetadataAgent"))
Expect(output).To(ContainSubstring("plugin2"))
Expect(output).To(ContainSubstring("Another Author"))
Expect(output).To(ContainSubstring("2.1.0"))
Expect(output).To(ContainSubstring("Scrobbler"))
})
})
Describe("Plugin info command", func() {
It("should display information about an installed plugin", func() {
// Create test plugin with multiple capabilities
createTestPlugin("test-plugin", "Test Author", "1.0.0",
[]string{"MetadataAgent", "Scrobbler"})
// Execute command
pluginInfo(cmd, []string{"test-plugin"})
// Verify output
output := captureOutput(outReader)
Expect(output).To(ContainSubstring("Name: test-plugin"))
Expect(output).To(ContainSubstring("Author: Test Author"))
Expect(output).To(ContainSubstring("Version: 1.0.0"))
Expect(output).To(ContainSubstring("Description: Plugin for testing"))
Expect(output).To(ContainSubstring("Capabilities: MetadataAgent, Scrobbler"))
})
})
Describe("Plugin remove command", func() {
It("should remove a regular plugin directory", func() {
// Create test plugin
pluginDir := createTestPlugin("regular-plugin", "Test Author", "1.0.0",
[]string{"MetadataAgent"})
// Execute command
pluginRemove(cmd, []string{"regular-plugin"})
// Verify output
output := captureOutput(outReader)
Expect(output).To(ContainSubstring("Plugin 'regular-plugin' removed successfully"))
// Verify directory is actually removed
_, err := os.Stat(pluginDir)
Expect(os.IsNotExist(err)).To(BeTrue())
})
It("should remove only the symlink for a development plugin", func() {
// Create a real source directory
sourceDir := filepath.Join(GinkgoT().TempDir(), "dev-plugin-source")
Expect(os.MkdirAll(sourceDir, 0755)).To(Succeed())
manifest := `{
"name": "dev-plugin",
"author": "Dev Author",
"version": "0.1.0",
"description": "Development plugin for testing",
"website": "https://test.navidrome.org/dev-plugin",
"capabilities": ["Scrobbler"],
"permissions": {}
}`
Expect(os.WriteFile(filepath.Join(sourceDir, "manifest.json"), []byte(manifest), 0600)).To(Succeed())
// Create a dummy WASM file
wasmContent := []byte("dummy wasm content for testing")
Expect(os.WriteFile(filepath.Join(sourceDir, "plugin.wasm"), wasmContent, 0600)).To(Succeed())
// Create a symlink in the plugins directory
symlinkPath := filepath.Join(tempDir, "dev-plugin")
Expect(os.Symlink(sourceDir, symlinkPath)).To(Succeed())
// Execute command
pluginRemove(cmd, []string{"dev-plugin"})
// Verify output
output := captureOutput(outReader)
Expect(output).To(ContainSubstring("Development plugin symlink 'dev-plugin' removed successfully"))
Expect(output).To(ContainSubstring("target directory preserved"))
// Verify the symlink is removed but source directory exists
_, err := os.Lstat(symlinkPath)
Expect(os.IsNotExist(err)).To(BeTrue())
_, err = os.Stat(sourceDir)
Expect(err).NotTo(HaveOccurred())
})
})
})

View File

@@ -16,6 +16,7 @@ import (
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/resources"
"github.com/navidrome/navidrome/scanner"
"github.com/navidrome/navidrome/scheduler"
"github.com/navidrome/navidrome/server/backgrounds"
"github.com/spf13/cobra"
@@ -80,6 +81,7 @@ func runNavidrome(ctx context.Context) {
g.Go(startPlaybackServer(ctx))
g.Go(schedulePeriodicBackup(ctx))
g.Go(startInsightsCollector(ctx))
g.Go(scheduleDBOptimizer(ctx))
g.Go(startPluginManager(ctx))
g.Go(runInitialScan(ctx))
if conf.Server.Scanner.Enabled {
@@ -234,37 +236,51 @@ func startScanWatcher(ctx context.Context) func() error {
func schedulePeriodicBackup(ctx context.Context) func() error {
return func() error {
//schedule := conf.Server.Backup.Schedule
//if schedule == "" {
// log.Info(ctx, "Periodic backup is DISABLED")
// return nil
//}
//
//schedulerInstance := scheduler.GetInstance()
//
//log.Info("Scheduling periodic backup", "schedule", schedule)
//_, err := schedulerInstance.Add(schedule, func() {
// start := time.Now()
// path, err := db.Backup(ctx)
// elapsed := time.Since(start)
// if err != nil {
// log.Error(ctx, "Error backing up database", "elapsed", elapsed, err)
// return
// }
// log.Info(ctx, "Backup complete", "elapsed", elapsed, "path", path)
//
// count, err := db.Prune(ctx)
// if err != nil {
// log.Error(ctx, "Error pruning database", "error", err)
// } else if count > 0 {
// log.Info(ctx, "Successfully pruned old files", "count", count)
// } else {
// log.Info(ctx, "No backups pruned")
// }
//})
//
//return err
return nil
schedule := conf.Server.Backup.Schedule
if schedule == "" {
log.Info(ctx, "Periodic backup is DISABLED")
return nil
}
schedulerInstance := scheduler.GetInstance()
log.Info("Scheduling periodic backup", "schedule", schedule)
_, err := schedulerInstance.Add(schedule, func() {
start := time.Now()
path, err := db.Backup(ctx)
elapsed := time.Since(start)
if err != nil {
log.Error(ctx, "Error backing up database", "elapsed", elapsed, err)
return
}
log.Info(ctx, "Backup complete", "elapsed", elapsed, "path", path)
count, err := db.Prune(ctx)
if err != nil {
log.Error(ctx, "Error pruning database", "error", err)
} else if count > 0 {
log.Info(ctx, "Successfully pruned old files", "count", count)
} else {
log.Info(ctx, "No backups pruned")
}
})
return err
}
}
func scheduleDBOptimizer(ctx context.Context) func() error {
return func() error {
log.Info(ctx, "Scheduling DB optimizer", "schedule", consts.OptimizeDBSchedule)
schedulerInstance := scheduler.GetInstance()
_, err := schedulerInstance.Add(consts.OptimizeDBSchedule, func() {
if scanner.IsScanning() {
log.Debug(ctx, "Skipping DB optimization because a scan is in progress")
return
}
db.Optimize(ctx)
})
return err
}
}
@@ -314,23 +330,20 @@ func startPlaybackServer(ctx context.Context) func() error {
// startPluginManager starts the plugin manager, if configured.
func startPluginManager(ctx context.Context) func() error {
return func() error {
manager := GetPluginManager(ctx)
if !conf.Server.Plugins.Enabled {
log.Debug("Plugins are DISABLED")
log.Debug("Plugin system is DISABLED")
return nil
}
log.Info(ctx, "Starting plugin manager")
// Get the manager instance and scan for plugins
manager := GetPluginManager(ctx)
manager.ScanPlugins()
return nil
return manager.Start(ctx)
}
}
// TODO: Implement some struct tags to map flags to viper
func init() {
cobra.OnInitialize(func() {
conf.InitConfig(cfgFile)
conf.InitConfig(cfgFile, true)
})
rootCmd.PersistentFlags().StringVarP(&cfgFile, "configfile", "c", "", `config file (default "./navidrome.toml")`)
@@ -358,6 +371,7 @@ func init() {
rootCmd.Flags().Duration("scaninterval", viper.GetDuration("scaninterval"), "how frequently to scan for changes in your music library")
rootCmd.Flags().String("uiloginbackgroundurl", viper.GetString("uiloginbackgroundurl"), "URL to a backaground image used in the Login page")
rootCmd.Flags().Bool("enabletranscodingconfig", viper.GetBool("enabletranscodingconfig"), "enables transcoding configuration in the UI")
rootCmd.Flags().Bool("enabletranscodingcancellation", viper.GetBool("enabletranscodingcancellation"), "enables transcoding context cancellation")
rootCmd.Flags().String("transcodingcachesize", viper.GetString("transcodingcachesize"), "size of transcoding cache")
rootCmd.Flags().String("imagecachesize", viper.GetString("imagecachesize"), "size of image (art work) cache. set to 0 to disable cache")
rootCmd.Flags().String("albumplaycountmode", viper.GetString("albumplaycountmode"), "how to compute playcount for albums. absolute (default) or normalized")
@@ -381,6 +395,7 @@ func init() {
_ = viper.BindPFlag("prometheus.metricspath", rootCmd.Flags().Lookup("prometheus.metricspath"))
_ = viper.BindPFlag("enabletranscodingconfig", rootCmd.Flags().Lookup("enabletranscodingconfig"))
_ = viper.BindPFlag("enabletranscodingcancellation", rootCmd.Flags().Lookup("enabletranscodingcancellation"))
_ = viper.BindPFlag("transcodingcachesize", rootCmd.Flags().Lookup("transcodingcachesize"))
_ = viper.BindPFlag("imagecachesize", rootCmd.Flags().Lookup("imagecachesize"))
}

View File

@@ -1,13 +1,17 @@
package cmd
import (
"bufio"
"context"
"encoding/gob"
"fmt"
"os"
"strings"
"github.com/navidrome/navidrome/core"
"github.com/navidrome/navidrome/db"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/persistence"
"github.com/navidrome/navidrome/scanner"
"github.com/navidrome/navidrome/utils/pl"
@@ -17,11 +21,15 @@ import (
var (
fullScan bool
subprocess bool
targets []string
targetFile string
)
func init() {
scanCmd.Flags().BoolVarP(&fullScan, "full", "f", false, "check all subfolders, ignoring timestamps")
scanCmd.Flags().BoolVarP(&subprocess, "subprocess", "", false, "run as subprocess (internal use)")
scanCmd.Flags().StringArrayVarP(&targets, "target", "t", []string{}, "list of libraryID:folderPath pairs, can be repeated (e.g., \"-t 1:Music/Rock -t 1:Music/Jazz -t 2:Classical\")")
scanCmd.Flags().StringVar(&targetFile, "target-file", "", "path to file containing targets (one libraryID:folderPath per line)")
rootCmd.AddCommand(scanCmd)
}
@@ -63,14 +71,30 @@ func trackScanAsSubprocess(ctx context.Context, progress <-chan *scanner.Progres
}
func runScanner(ctx context.Context) {
defer db.Init(ctx)()
sqlDB := db.Db()
defer db.Db().Close()
ds := persistence.New(sqlDB)
pls := core.NewPlaylists(ds)
progress, err := scanner.CallScan(ctx, ds, pls, fullScan)
// Parse targets from command line or file
var scanTargets []model.ScanTarget
var err error
if targetFile != "" {
scanTargets, err = readTargetsFromFile(targetFile)
if err != nil {
log.Fatal(ctx, "Failed to read targets from file", err)
}
log.Info(ctx, "Scanning specific folders from file", "numTargets", len(scanTargets))
} else if len(targets) > 0 {
scanTargets, err = model.ParseTargets(targets)
if err != nil {
log.Fatal(ctx, "Failed to parse targets", err)
}
log.Info(ctx, "Scanning specific folders", "numTargets", len(scanTargets))
}
progress, err := scanner.CallScan(ctx, ds, pls, fullScan, scanTargets)
if err != nil {
log.Fatal(ctx, "Failed to scan", err)
}
@@ -82,3 +106,31 @@ func runScanner(ctx context.Context) {
trackScanInteractively(ctx, progress)
}
}
// readTargetsFromFile reads scan targets from a file, one per line.
// Each line should be in the format "libraryID:folderPath".
// Empty lines and lines starting with # are ignored.
func readTargetsFromFile(filePath string) ([]model.ScanTarget, error) {
file, err := os.Open(filePath)
if err != nil {
return nil, fmt.Errorf("failed to open target file: %w", err)
}
defer file.Close()
var targetStrings []string
scanner := bufio.NewScanner(file)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
// Skip empty lines and comments
if line == "" {
continue
}
targetStrings = append(targetStrings, line)
}
if err := scanner.Err(); err != nil {
return nil, fmt.Errorf("failed to read target file: %w", err)
}
return model.ParseTargets(targetStrings)
}

89
cmd/scan_test.go Normal file
View File

@@ -0,0 +1,89 @@
package cmd
import (
"os"
"path/filepath"
"github.com/navidrome/navidrome/model"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("readTargetsFromFile", func() {
var tempDir string
BeforeEach(func() {
var err error
tempDir, err = os.MkdirTemp("", "navidrome-test-")
Expect(err).ToNot(HaveOccurred())
})
AfterEach(func() {
os.RemoveAll(tempDir)
})
It("reads valid targets from file", func() {
filePath := filepath.Join(tempDir, "targets.txt")
content := "1:Music/Rock\n2:Music/Jazz\n3:Classical\n"
err := os.WriteFile(filePath, []byte(content), 0600)
Expect(err).ToNot(HaveOccurred())
targets, err := readTargetsFromFile(filePath)
Expect(err).ToNot(HaveOccurred())
Expect(targets).To(HaveLen(3))
Expect(targets[0]).To(Equal(model.ScanTarget{LibraryID: 1, FolderPath: "Music/Rock"}))
Expect(targets[1]).To(Equal(model.ScanTarget{LibraryID: 2, FolderPath: "Music/Jazz"}))
Expect(targets[2]).To(Equal(model.ScanTarget{LibraryID: 3, FolderPath: "Classical"}))
})
It("skips empty lines", func() {
filePath := filepath.Join(tempDir, "targets.txt")
content := "1:Music/Rock\n\n2:Music/Jazz\n\n"
err := os.WriteFile(filePath, []byte(content), 0600)
Expect(err).ToNot(HaveOccurred())
targets, err := readTargetsFromFile(filePath)
Expect(err).ToNot(HaveOccurred())
Expect(targets).To(HaveLen(2))
})
It("trims whitespace", func() {
filePath := filepath.Join(tempDir, "targets.txt")
content := " 1:Music/Rock \n\t2:Music/Jazz\t\n"
err := os.WriteFile(filePath, []byte(content), 0600)
Expect(err).ToNot(HaveOccurred())
targets, err := readTargetsFromFile(filePath)
Expect(err).ToNot(HaveOccurred())
Expect(targets).To(HaveLen(2))
Expect(targets[0].FolderPath).To(Equal("Music/Rock"))
Expect(targets[1].FolderPath).To(Equal("Music/Jazz"))
})
It("returns error for non-existent file", func() {
_, err := readTargetsFromFile("/nonexistent/file.txt")
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("failed to open target file"))
})
It("returns error for invalid target format", func() {
filePath := filepath.Join(tempDir, "targets.txt")
content := "invalid-format\n"
err := os.WriteFile(filePath, []byte(content), 0600)
Expect(err).ToNot(HaveOccurred())
_, err = readTargetsFromFile(filePath)
Expect(err).To(HaveOccurred())
})
It("handles mixed valid and empty lines", func() {
filePath := filepath.Join(tempDir, "targets.txt")
content := "\n1:Music/Rock\n\n\n2:Music/Jazz\n\n"
err := os.WriteFile(filePath, []byte(content), 0600)
Expect(err).ToNot(HaveOccurred())
targets, err := readTargetsFromFile(filePath)
Expect(err).ToNot(HaveOccurred())
Expect(targets).To(HaveLen(2))
})
})

477
cmd/user.go Normal file
View File

@@ -0,0 +1,477 @@
package cmd
import (
"context"
"encoding/csv"
"encoding/json"
"errors"
"fmt"
"os"
"strconv"
"strings"
"syscall"
"time"
"github.com/Masterminds/squirrel"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/spf13/cobra"
"golang.org/x/term"
)
var (
email string
libraryIds []int
name string
removeEmail bool
removeName bool
setAdmin bool
setPassword bool
setRegularUser bool
)
func init() {
rootCmd.AddCommand(userRoot)
userCreateCommand.Flags().StringVarP(&userID, "username", "u", "", "username")
userCreateCommand.Flags().StringVarP(&email, "email", "e", "", "New user email")
userCreateCommand.Flags().IntSliceVarP(&libraryIds, "library-ids", "i", []int{}, "Comma-separated list of library IDs. Set the user's accessible libraries. If empty, the user can access all libraries. This is incompatible with admin, as admin can always access all libraries")
userCreateCommand.Flags().BoolVarP(&setAdmin, "admin", "a", false, "If set, make the user an admin. This user will have access to every library")
userCreateCommand.Flags().StringVar(&name, "name", "", "New user's name (this is separate from username used to log in)")
_ = userCreateCommand.MarkFlagRequired("username")
userRoot.AddCommand(userCreateCommand)
userDeleteCommand.Flags().StringVarP(&userID, "user", "u", "", "username or id")
_ = userDeleteCommand.MarkFlagRequired("user")
userRoot.AddCommand(userDeleteCommand)
userEditCommand.Flags().StringVarP(&userID, "user", "u", "", "username or id")
userEditCommand.Flags().BoolVar(&setAdmin, "set-admin", false, "If set, make the user an admin")
userEditCommand.Flags().BoolVar(&setRegularUser, "set-regular", false, "If set, make the user a non-admin")
userEditCommand.MarkFlagsMutuallyExclusive("set-admin", "set-regular")
userEditCommand.Flags().BoolVar(&removeEmail, "remove-email", false, "If set, clear the user's email")
userEditCommand.Flags().StringVarP(&email, "email", "e", "", "New user email")
userEditCommand.MarkFlagsMutuallyExclusive("email", "remove-email")
userEditCommand.Flags().BoolVar(&removeName, "remove-name", false, "If set, clear the user's name")
userEditCommand.Flags().StringVar(&name, "name", "", "New user name (this is separate from username used to log in)")
userEditCommand.MarkFlagsMutuallyExclusive("name", "remove-name")
userEditCommand.Flags().BoolVar(&setPassword, "set-password", false, "If set, the user's new password will be prompted on the CLI")
userEditCommand.Flags().IntSliceVarP(&libraryIds, "library-ids", "i", []int{}, "Comma-separated list of library IDs. Set the user's accessible libraries by id")
_ = userEditCommand.MarkFlagRequired("user")
userRoot.AddCommand(userEditCommand)
userListCommand.Flags().StringVarP(&outputFormat, "format", "f", "csv", "output format [supported values: csv, json]")
userRoot.AddCommand(userListCommand)
}
var (
userRoot = &cobra.Command{
Use: "user",
Short: "Administer users",
Long: "Create, delete, list, or update users",
}
userCreateCommand = &cobra.Command{
Use: "create",
Aliases: []string{"c"},
Short: "Create a new user",
Run: func(cmd *cobra.Command, args []string) {
runCreateUser(cmd.Context())
},
}
userDeleteCommand = &cobra.Command{
Use: "delete",
Aliases: []string{"d"},
Short: "Deletes an existing user",
Run: func(cmd *cobra.Command, args []string) {
runDeleteUser(cmd.Context())
},
}
userEditCommand = &cobra.Command{
Use: "edit",
Aliases: []string{"e"},
Short: "Edit a user",
Long: "Edit the password, admin status, and/or library access",
Run: func(cmd *cobra.Command, args []string) {
runUserEdit(cmd.Context())
},
}
userListCommand = &cobra.Command{
Use: "list",
Short: "List users",
Run: func(cmd *cobra.Command, args []string) {
runUserList(cmd.Context())
},
}
)
func promptPassword() string {
for {
fmt.Print("Enter new password (press enter with no password to cancel): ")
// This cast is necessary for some platforms
password, err := term.ReadPassword(int(syscall.Stdin)) //nolint:unconvert
if err != nil {
log.Fatal("Error getting password", err)
}
fmt.Print("\nConfirm new password (press enter with no password to cancel): ")
confirmation, err := term.ReadPassword(int(syscall.Stdin)) //nolint:unconvert
if err != nil {
log.Fatal("Error getting password confirmation", err)
}
// clear the line.
fmt.Println()
pass := string(password)
confirm := string(confirmation)
if pass == "" {
return ""
}
if pass == confirm {
return pass
}
fmt.Println("Password and password confirmation do not match")
}
}
func libraryError(libraries model.Libraries) error {
ids := make([]int, len(libraries))
for idx, library := range libraries {
ids[idx] = library.ID
}
return fmt.Errorf("not all available libraries found. Requested ids: %v, Found libraries: %v", libraryIds, ids)
}
func runCreateUser(ctx context.Context) {
password := promptPassword()
if password == "" {
log.Fatal("Empty password provided, user creation cancelled")
}
user := model.User{
UserName: userID,
Email: email,
Name: name,
IsAdmin: setAdmin,
NewPassword: password,
}
if user.Name == "" {
user.Name = userID
}
ds, ctx := getAdminContext(ctx)
err := ds.WithTx(func(tx model.DataStore) error {
existingUser, err := tx.User(ctx).FindByUsername(userID)
if existingUser != nil {
return fmt.Errorf("existing user '%s'", userID)
}
if err != nil && !errors.Is(err, model.ErrNotFound) {
return fmt.Errorf("failed to check existing username: %w", err)
}
if len(libraryIds) > 0 && !setAdmin {
user.Libraries, err = tx.Library(ctx).GetAll(model.QueryOptions{Filters: squirrel.Eq{"id": libraryIds}})
if err != nil {
return err
}
if len(user.Libraries) != len(libraryIds) {
return libraryError(user.Libraries)
}
} else {
user.Libraries, err = tx.Library(ctx).GetAll()
if err != nil {
return err
}
}
err = tx.User(ctx).Put(&user)
if err != nil {
return err
}
updatedIds := make([]int, len(user.Libraries))
for idx, lib := range user.Libraries {
updatedIds[idx] = lib.ID
}
err = tx.User(ctx).SetUserLibraries(user.ID, updatedIds)
return err
})
if err != nil {
log.Fatal(ctx, err)
}
log.Info(ctx, "Successfully created user", "id", user.ID, "username", user.UserName)
}
func runDeleteUser(ctx context.Context) {
ds, ctx := getAdminContext(ctx)
var err error
var user *model.User
err = ds.WithTx(func(tx model.DataStore) error {
count, err := tx.User(ctx).CountAll()
if err != nil {
return err
}
if count == 1 {
return errors.New("refusing to delete the last user")
}
user, err = getUser(ctx, userID, tx)
if err != nil {
return err
}
return tx.User(ctx).Delete(user.ID)
})
if err != nil {
log.Fatal(ctx, "Failed to delete user", err)
}
log.Info(ctx, "Deleted user", "username", user.UserName)
}
func runUserEdit(ctx context.Context) {
ds, ctx := getAdminContext(ctx)
var err error
var user *model.User
changes := []string{}
err = ds.WithTx(func(tx model.DataStore) error {
var newLibraries model.Libraries
user, err = getUser(ctx, userID, tx)
if err != nil {
return err
}
if len(libraryIds) > 0 && !setAdmin {
libraries, err := tx.Library(ctx).GetAll(model.QueryOptions{Filters: squirrel.Eq{"id": libraryIds}})
if err != nil {
return err
}
if len(libraries) != len(libraryIds) {
return libraryError(libraries)
}
newLibraries = libraries
changes = append(changes, "updated library ids")
}
if setAdmin && !user.IsAdmin {
libraries, err := tx.Library(ctx).GetAll()
if err != nil {
return err
}
user.IsAdmin = true
user.Libraries = libraries
changes = append(changes, "set admin")
newLibraries = libraries
}
if setRegularUser && user.IsAdmin {
user.IsAdmin = false
changes = append(changes, "set regular user")
}
if setPassword {
password := promptPassword()
if password != "" {
user.NewPassword = password
changes = append(changes, "updated password")
}
}
if email != "" && email != user.Email {
user.Email = email
changes = append(changes, "updated email")
} else if removeEmail && user.Email != "" {
user.Email = ""
changes = append(changes, "removed email")
}
if name != "" && name != user.Name {
user.Name = name
changes = append(changes, "updated name")
} else if removeName && user.Name != "" {
user.Name = ""
changes = append(changes, "removed name")
}
if len(changes) == 0 {
return nil
}
err := tx.User(ctx).Put(user)
if err != nil {
return err
}
if len(newLibraries) > 0 {
updatedIds := make([]int, len(newLibraries))
for idx, lib := range newLibraries {
updatedIds[idx] = lib.ID
}
err := tx.User(ctx).SetUserLibraries(user.ID, updatedIds)
if err != nil {
return err
}
}
return nil
})
if err != nil {
log.Fatal(ctx, "Failed to update user", err)
}
if len(changes) == 0 {
log.Info(ctx, "No changes for user", "user", user.UserName)
} else {
log.Info(ctx, "Updated user", "user", user.UserName, "changes", strings.Join(changes, ", "))
}
}
type displayLibrary struct {
ID int `json:"id"`
Path string `json:"path"`
}
type displayUser struct {
Id string `json:"id"`
Username string `json:"username"`
Name string `json:"name"`
Email string `json:"email"`
Admin bool `json:"admin"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
LastAccess *time.Time `json:"lastAccess"`
LastLogin *time.Time `json:"lastLogin"`
Libraries []displayLibrary `json:"libraries"`
}
func runUserList(ctx context.Context) {
if outputFormat != "csv" && outputFormat != "json" {
log.Fatal("Invalid output format. Must be one of csv, json", "format", outputFormat)
}
ds, ctx := getAdminContext(ctx)
users, err := ds.User(ctx).ReadAll()
if err != nil {
log.Fatal(ctx, "Failed to retrieve users", err)
}
userList := users.(model.Users)
if outputFormat == "csv" {
w := csv.NewWriter(os.Stdout)
_ = w.Write([]string{
"user id",
"username",
"user's name",
"user email",
"admin",
"created at",
"updated at",
"last access",
"last login",
"libraries",
})
for _, user := range userList {
paths := make([]string, len(user.Libraries))
for idx, library := range user.Libraries {
paths[idx] = fmt.Sprintf("%d:%s", library.ID, library.Path)
}
var lastAccess, lastLogin string
if user.LastAccessAt != nil {
lastAccess = user.LastAccessAt.Format(time.RFC3339Nano)
} else {
lastAccess = "never"
}
if user.LastLoginAt != nil {
lastLogin = user.LastLoginAt.Format(time.RFC3339Nano)
} else {
lastLogin = "never"
}
_ = w.Write([]string{
user.ID,
user.UserName,
user.Name,
user.Email,
strconv.FormatBool(user.IsAdmin),
user.CreatedAt.Format(time.RFC3339Nano),
user.UpdatedAt.Format(time.RFC3339Nano),
lastAccess,
lastLogin,
fmt.Sprintf("'%s'", strings.Join(paths, "|")),
})
}
w.Flush()
} else {
users := make([]displayUser, len(userList))
for idx, user := range userList {
paths := make([]displayLibrary, len(user.Libraries))
for idx, library := range user.Libraries {
paths[idx].ID = library.ID
paths[idx].Path = library.Path
}
users[idx].Id = user.ID
users[idx].Username = user.UserName
users[idx].Name = user.Name
users[idx].Email = user.Email
users[idx].Admin = user.IsAdmin
users[idx].CreatedAt = user.CreatedAt
users[idx].UpdatedAt = user.UpdatedAt
users[idx].LastAccess = user.LastAccessAt
users[idx].LastLogin = user.LastLoginAt
users[idx].Libraries = paths
}
j, _ := json.Marshal(users)
fmt.Printf("%s\n", j)
}
}

42
cmd/utils.go Normal file
View File

@@ -0,0 +1,42 @@
package cmd
import (
"context"
"errors"
"fmt"
"github.com/navidrome/navidrome/core/auth"
"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"
)
func getAdminContext(ctx context.Context) (model.DataStore, context.Context) {
sqlDB := db.Db()
ds := persistence.New(sqlDB)
ctx = auth.WithAdminUser(ctx, ds)
u, _ := request.UserFrom(ctx)
if !u.IsAdmin {
log.Fatal(ctx, "There must be at least one admin user to run this command.")
}
return ds, ctx
}
func getUser(ctx context.Context, id string, ds model.DataStore) (*model.User, error) {
user, err := ds.User(ctx).FindByUsername(id)
if err != nil && !errors.Is(err, model.ErrNotFound) {
return nil, fmt.Errorf("finding user by name: %w", err)
}
if errors.Is(err, model.ErrNotFound) {
user, err = ds.User(ctx).Get(id)
if err != nil {
return nil, fmt.Errorf("finding user by id: %w", err)
}
}
return user, nil
}

View File

@@ -47,9 +47,7 @@ func CreateServer() *server.Server {
sqlDB := db.Db()
dataStore := persistence.New(sqlDB)
broker := events.GetBroker()
metricsMetrics := metrics.GetPrometheusInstance(dataStore)
manager := plugins.GetManager(dataStore, metricsMetrics)
insights := metrics.GetInstance(dataStore, manager)
insights := metrics.GetInstance(dataStore)
serverServer := server.New(dataStore, broker, insights)
return serverServer
}
@@ -59,20 +57,21 @@ func CreateNativeAPIRouter(ctx context.Context) *nativeapi.Router {
dataStore := persistence.New(sqlDB)
share := core.NewShare(dataStore)
playlists := core.NewPlaylists(dataStore)
metricsMetrics := metrics.GetPrometheusInstance(dataStore)
manager := plugins.GetManager(dataStore, metricsMetrics)
insights := metrics.GetInstance(dataStore, manager)
insights := metrics.GetInstance(dataStore)
fileCache := artwork.GetImageCache()
fFmpeg := ffmpeg.New()
broker := events.GetBroker()
manager := plugins.GetManager(dataStore, broker)
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)
metricsMetrics := metrics.GetPrometheusInstance(dataStore)
modelScanner := scanner.New(ctx, dataStore, cacheWarmer, broker, playlists, metricsMetrics)
watcher := scanner.GetWatcher(dataStore, modelScanner)
library := core.NewLibrary(dataStore, modelScanner, watcher, broker)
maintenance := core.NewMaintenance(dataStore)
router := nativeapi.New(dataStore, share, playlists, insights, library, maintenance, manager)
return router
}
@@ -81,8 +80,8 @@ func CreateSubsonicAPIRouter(ctx context.Context) *subsonic.Router {
dataStore := persistence.New(sqlDB)
fileCache := artwork.GetImageCache()
fFmpeg := ffmpeg.New()
metricsMetrics := metrics.GetPrometheusInstance(dataStore)
manager := plugins.GetManager(dataStore, metricsMetrics)
broker := events.GetBroker()
manager := plugins.GetManager(dataStore, broker)
agentsAgents := agents.GetAgents(dataStore, manager)
provider := external.NewProvider(dataStore, agentsAgents)
artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, provider)
@@ -92,12 +91,12 @@ func CreateSubsonicAPIRouter(ctx context.Context) *subsonic.Router {
archiver := core.NewArchiver(mediaStreamer, dataStore, share)
players := core.NewPlayers(dataStore)
cacheWarmer := artwork.NewCacheWarmer(artworkArtwork, fileCache)
broker := events.GetBroker()
playlists := core.NewPlaylists(dataStore)
scannerScanner := scanner.New(ctx, dataStore, cacheWarmer, broker, playlists, metricsMetrics)
metricsMetrics := metrics.GetPrometheusInstance(dataStore)
modelScanner := scanner.New(ctx, dataStore, cacheWarmer, broker, playlists, metricsMetrics)
playTracker := scrobbler.GetPlayTracker(dataStore, broker, manager)
playbackServer := playback.GetInstance(dataStore)
router := subsonic.New(dataStore, artworkArtwork, mediaStreamer, archiver, players, provider, scannerScanner, broker, playlists, playTracker, share, playbackServer, metricsMetrics)
router := subsonic.New(dataStore, artworkArtwork, mediaStreamer, archiver, players, provider, modelScanner, broker, playlists, playTracker, share, playbackServer, metricsMetrics)
return router
}
@@ -106,8 +105,8 @@ func CreatePublicRouter() *public.Router {
dataStore := persistence.New(sqlDB)
fileCache := artwork.GetImageCache()
fFmpeg := ffmpeg.New()
metricsMetrics := metrics.GetPrometheusInstance(dataStore)
manager := plugins.GetManager(dataStore, metricsMetrics)
broker := events.GetBroker()
manager := plugins.GetManager(dataStore, broker)
agentsAgents := agents.GetAgents(dataStore, manager)
provider := external.NewProvider(dataStore, agentsAgents)
artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, provider)
@@ -136,9 +135,7 @@ func CreateListenBrainzRouter() *listenbrainz.Router {
func CreateInsights() metrics.Insights {
sqlDB := db.Db()
dataStore := persistence.New(sqlDB)
metricsMetrics := metrics.GetPrometheusInstance(dataStore)
manager := plugins.GetManager(dataStore, metricsMetrics)
insights := metrics.GetInstance(dataStore, manager)
insights := metrics.GetInstance(dataStore)
return insights
}
@@ -149,21 +146,21 @@ func CreatePrometheus() metrics.Metrics {
return metricsMetrics
}
func CreateScanner(ctx context.Context) scanner.Scanner {
func CreateScanner(ctx context.Context) model.Scanner {
sqlDB := db.Db()
dataStore := persistence.New(sqlDB)
fileCache := artwork.GetImageCache()
fFmpeg := ffmpeg.New()
metricsMetrics := metrics.GetPrometheusInstance(dataStore)
manager := plugins.GetManager(dataStore, metricsMetrics)
broker := events.GetBroker()
manager := plugins.GetManager(dataStore, broker)
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()
playlists := core.NewPlaylists(dataStore)
scannerScanner := scanner.New(ctx, dataStore, cacheWarmer, broker, playlists, metricsMetrics)
return scannerScanner
metricsMetrics := metrics.GetPrometheusInstance(dataStore)
modelScanner := scanner.New(ctx, dataStore, cacheWarmer, broker, playlists, metricsMetrics)
return modelScanner
}
func CreateScanWatcher(ctx context.Context) scanner.Watcher {
@@ -171,16 +168,16 @@ func CreateScanWatcher(ctx context.Context) scanner.Watcher {
dataStore := persistence.New(sqlDB)
fileCache := artwork.GetImageCache()
fFmpeg := ffmpeg.New()
metricsMetrics := metrics.GetPrometheusInstance(dataStore)
manager := plugins.GetManager(dataStore, metricsMetrics)
broker := events.GetBroker()
manager := plugins.GetManager(dataStore, broker)
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()
playlists := core.NewPlaylists(dataStore)
scannerScanner := scanner.New(ctx, dataStore, cacheWarmer, broker, playlists, metricsMetrics)
watcher := scanner.GetWatcher(dataStore, scannerScanner)
metricsMetrics := metrics.GetPrometheusInstance(dataStore)
modelScanner := scanner.New(ctx, dataStore, cacheWarmer, broker, playlists, metricsMetrics)
watcher := scanner.GetWatcher(dataStore, modelScanner)
return watcher
}
@@ -191,19 +188,19 @@ func GetPlaybackServer() playback.PlaybackServer {
return playbackServer
}
func getPluginManager() plugins.Manager {
func getPluginManager() *plugins.Manager {
sqlDB := db.Db()
dataStore := persistence.New(sqlDB)
metricsMetrics := metrics.GetPrometheusInstance(dataStore)
manager := plugins.GetManager(dataStore, metricsMetrics)
broker := events.GetBroker()
manager := plugins.GetManager(dataStore, broker)
return 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.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(metrics.PluginLoader), new(plugins.Manager)), wire.Bind(new(core.Scanner), new(scanner.Scanner)), wire.Bind(new(core.Watcher), new(scanner.Watcher)))
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, metrics.GetPrometheusInstance, db.Db, plugins.GetManager, wire.Bind(new(agents.PluginLoader), new(*plugins.Manager)), wire.Bind(new(scrobbler.PluginLoader), new(*plugins.Manager)), wire.Bind(new(nativeapi.PluginManager), new(*plugins.Manager)), wire.Bind(new(core.Watcher), new(scanner.Watcher)))
func GetPluginManager(ctx context.Context) plugins.Manager {
func GetPluginManager(ctx context.Context) *plugins.Manager {
manager := getPluginManager()
manager.SetSubsonicRouter(CreateSubsonicAPIRouter(ctx))
return manager

View File

@@ -39,13 +39,12 @@ var allProviders = wire.NewSet(
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(metrics.PluginLoader), new(plugins.Manager)),
wire.Bind(new(core.Scanner), new(scanner.Scanner)),
plugins.GetManager,
wire.Bind(new(agents.PluginLoader), new(*plugins.Manager)),
wire.Bind(new(scrobbler.PluginLoader), new(*plugins.Manager)),
wire.Bind(new(nativeapi.PluginManager), new(*plugins.Manager)),
wire.Bind(new(core.Watcher), new(scanner.Watcher)),
)
@@ -103,7 +102,7 @@ func CreatePrometheus() metrics.Metrics {
))
}
func CreateScanner(ctx context.Context) scanner.Scanner {
func CreateScanner(ctx context.Context) model.Scanner {
panic(wire.Build(
allProviders,
))
@@ -121,13 +120,13 @@ func GetPlaybackServer() playback.PlaybackServer {
))
}
func getPluginManager() plugins.Manager {
func getPluginManager() *plugins.Manager {
panic(wire.Build(
allProviders,
))
}
func GetPluginManager(ctx context.Context) plugins.Manager {
func GetPluginManager(ctx context.Context) *plugins.Manager {
manager := getPluginManager()
manager.SetSubsonicRouter(CreateSubsonicAPIRouter(ctx))
return manager

View File

@@ -41,6 +41,7 @@ type configOptions struct {
UIWelcomeMessage string
MaxSidebarPlaylists int
EnableTranscodingConfig bool
EnableTranscodingCancellation bool
EnableDownloads bool
EnableExternalServices bool
EnableInsightsCollector bool
@@ -86,11 +87,9 @@ type configOptions struct {
AuthRequestLimit int
AuthWindowLength time.Duration
PasswordEncryptionKey string
ReverseProxyUserHeader string
ReverseProxyWhitelist string
ExtAuth extAuthOptions
Plugins pluginsOptions
PluginConfig map[string]map[string]string
HTTPSecurityHeaders secureOptions `json:",omitzero"`
HTTPHeaders httpHeaderOptions `json:",omitzero"`
Prometheus prometheusOptions `json:",omitzero"`
Scanner scannerOptions `json:",omitzero"`
Jukebox jukeboxOptions `json:",omitzero"`
@@ -102,34 +101,38 @@ type configOptions struct {
Spotify spotifyOptions `json:",omitzero"`
Deezer deezerOptions `json:",omitzero"`
ListenBrainz listenBrainzOptions `json:",omitzero"`
Tags map[string]TagConf `json:",omitempty"`
EnableScrobbleHistory bool
Tags map[string]TagConf `json:",omitempty"`
Agents string
// DevFlags. These are used to enable/disable debugging and incomplete features
DevLogLevels map[string]string `json:",omitempty"`
DevLogSourceLine bool
DevEnableProfiler bool
DevAutoCreateAdminPassword string
DevAutoLoginUsername string
DevActivityPanel bool
DevActivityPanelUpdateRate time.Duration
DevSidebarPlaylists bool
DevShowArtistPage bool
DevUIShowConfig bool
DevNewEventStream bool
DevOffsetOptimize int
DevArtworkMaxRequests int
DevArtworkThrottleBacklogLimit int
DevArtworkThrottleBacklogTimeout time.Duration
DevArtistInfoTimeToLive time.Duration
DevAlbumInfoTimeToLive time.Duration
DevExternalScanner bool
DevScannerThreads uint
DevInsightsInitialDelay time.Duration
DevEnablePlayerInsights bool
DevEnablePluginsInsights bool
DevPluginCompilationTimeout time.Duration
DevExternalArtistFetchMultiplier float64
DevLogLevels map[string]string `json:",omitempty"`
DevLogSourceLine bool
DevEnableProfiler bool
DevAutoCreateAdminPassword string
DevAutoLoginUsername string
DevActivityPanel bool
DevActivityPanelUpdateRate time.Duration
DevSidebarPlaylists bool
DevShowArtistPage bool
DevUIShowConfig bool
DevNewEventStream bool
DevOffsetOptimize int
DevArtworkMaxRequests int
DevArtworkThrottleBacklogLimit int
DevArtworkThrottleBacklogTimeout time.Duration
DevArtistInfoTimeToLive time.Duration
DevAlbumInfoTimeToLive time.Duration
DevExternalScanner bool
DevScannerThreads uint
DevSelectiveWatcher bool
DevInsightsInitialDelay time.Duration
DevEnablePlayerInsights bool
DevEnablePluginsInsights bool
DevPluginCompilationTimeout time.Duration
DevExternalArtistFetchMultiplier float64
DevOptimizeDB bool
DevPreserveUnicodeInExternalCalls bool
}
type scannerOptions struct {
@@ -175,7 +178,8 @@ type spotifyOptions struct {
}
type deezerOptions struct {
Enabled bool
Enabled bool
Language string
}
type listenBrainzOptions struct {
@@ -183,8 +187,8 @@ type listenBrainzOptions struct {
BaseURL string
}
type secureOptions struct {
CustomFrameOptionsValue string
type httpHeaderOptions struct {
FrameOptions string
}
type prometheusOptions struct {
@@ -221,9 +225,16 @@ type inspectOptions struct {
}
type pluginsOptions struct {
Enabled bool
Folder string
CacheSize string
Enabled bool
Folder string
CacheSize string
AutoReload bool
LogLevel string
}
type extAuthOptions struct {
TrustedSources string
UserHeader string
}
var (
@@ -244,6 +255,11 @@ func LoadFromFile(confFile string) {
func Load(noConfigDump bool) {
parseIniFileConfiguration()
// Map deprecated options to their new names for backwards compatibility
mapDeprecatedOption("ReverseProxyWhitelist", "ExtAuth.TrustedSources")
mapDeprecatedOption("ReverseProxyUserHeader", "ExtAuth.UserHeader")
mapDeprecatedOption("HTTPSecurityHeaders.CustomFrameOptionsValue", "HTTPHeaders.FrameOptions")
err := viper.Unmarshal(&Server)
if err != nil {
_, _ = fmt.Fprintln(os.Stderr, "FATAL: Error parsing config:", err)
@@ -327,9 +343,18 @@ func Load(noConfigDump bool) {
Server.BaseScheme = u.Scheme
}
// Log configuration source
if Server.ConfigFile != "" {
log.Info("Loaded configuration", "file", Server.ConfigFile)
} else if hasNDEnvVars() {
log.Info("No configuration file found. Loaded configuration only from environment variables")
} else {
log.Warn("No configuration file found. Using default values. To specify a config file, use the --configfile flag or set the ND_CONFIGFILE environment variable.")
}
// Print current configuration if log level is Debug
if log.IsGreaterOrEqualTo(log.LevelDebug) && !noConfigDump {
prettyConf := pretty.Sprintf("Loaded configuration from '%s': %# v", Server.ConfigFile, Server)
prettyConf := pretty.Sprintf("Configuration: %# v", Server)
if Server.EnableLogRedacting {
prettyConf = log.Redact(prettyConf)
}
@@ -344,9 +369,12 @@ func Load(noConfigDump bool) {
log.Warn(fmt.Sprintf("Extractor '%s' is not implemented, using 'taglib'", Server.Scanner.Extractor))
Server.Scanner.Extractor = consts.DefaultScannerExtractor
}
logDeprecatedOptions("Scanner.GenreSeparators")
logDeprecatedOptions("Scanner.GroupAlbumReleases")
logDeprecatedOptions("DevEnableBufferedScrobble") // Deprecated: Buffered scrobbling is now always enabled and this option is ignored
logDeprecatedOptions("Scanner.GenreSeparators", "")
logDeprecatedOptions("Scanner.GroupAlbumReleases", "")
logDeprecatedOptions("DevEnableBufferedScrobble", "") // Deprecated: Buffered scrobbling is now always enabled and this option is ignored
logDeprecatedOptions("ReverseProxyWhitelist", "ExtAuth.TrustedSources")
logDeprecatedOptions("ReverseProxyUserHeader", "ExtAuth.UserHeader")
logDeprecatedOptions("HTTPSecurityHeaders.CustomFrameOptionsValue", "HTTPHeaders.FrameOptions")
// Call init hooks
for _, hook := range hooks {
@@ -354,16 +382,30 @@ func Load(noConfigDump bool) {
}
}
func logDeprecatedOptions(options ...string) {
for _, option := range options {
envVar := "ND_" + strings.ToUpper(strings.ReplaceAll(option, ".", "_"))
if os.Getenv(envVar) != "" {
log.Warn(fmt.Sprintf("Option '%s' is deprecated and will be ignored in a future release", envVar))
}
if viper.InConfig(option) {
log.Warn(fmt.Sprintf("Option '%s' is deprecated and will be ignored in a future release", option))
func logDeprecatedOptions(oldName, newName string) {
envVar := "ND_" + strings.ToUpper(strings.ReplaceAll(oldName, ".", "_"))
newEnvVar := "ND_" + strings.ToUpper(strings.ReplaceAll(newName, ".", "_"))
logWarning := func(oldName, newName string) {
if newName != "" {
log.Warn(fmt.Sprintf("Option '%s' is deprecated and will be ignored in a future release. Please use the new '%s'", oldName, newName))
} else {
log.Warn(fmt.Sprintf("Option '%s' is deprecated and will be ignored in a future release", oldName))
}
}
if os.Getenv(envVar) != "" {
logWarning(envVar, newEnvVar)
}
if viper.InConfig(oldName) {
logWarning(oldName, newName)
}
}
// mapDeprecatedOption is used to provide backwards compatibility for deprecated options. It should be called after
// the config has been read by viper, but before unmarshalling it into the Config struct.
func mapDeprecatedOption(legacyName, newName string) {
if viper.IsSet(legacyName) {
viper.Set(newName, viper.Get(legacyName))
}
}
// parseIniFileConfiguration is used to parse the config file when it is in INI format. For INI files, it
@@ -425,7 +467,7 @@ func validatePurgeMissingOption() error {
}
}
if !valid {
err := fmt.Errorf("Invalid Scanner.PurgeMissing value: '%s'. Must be one of: %v", Server.Scanner.PurgeMissing, allowedValues)
err := fmt.Errorf("invalid Scanner.PurgeMissing value: '%s'. Must be one of: %v", Server.Scanner.PurgeMissing, allowedValues)
log.Error(err.Error())
Server.Scanner.PurgeMissing = consts.PurgeMissingNever
return err
@@ -472,6 +514,16 @@ func AddHook(hook func()) {
hooks = append(hooks, hook)
}
// hasNDEnvVars checks if any ND_ prefixed environment variables are set (excluding ND_CONFIGFILE)
func hasNDEnvVars() bool {
for _, env := range os.Environ() {
if strings.HasPrefix(env, "ND_") && !strings.HasPrefix(env, "ND_CONFIGFILE=") {
return true
}
}
return false
}
func setViperDefaults() {
viper.SetDefault("musicfolder", filepath.Join(".", "music"))
viper.SetDefault("cachefolder", "")
@@ -489,6 +541,7 @@ func setViperDefaults() {
viper.SetDefault("uiwelcomemessage", "")
viper.SetDefault("maxsidebarplaylists", consts.DefaultMaxSidebarPlaylists)
viper.SetDefault("enabletranscodingconfig", false)
viper.SetDefault("enabletranscodingcancellation", false)
viper.SetDefault("transcodingcachesize", "100MB")
viper.SetDefault("imagecachesize", "100MB")
viper.SetDefault("albumplaycountmode", consts.AlbumPlayCountModeAbsolute)
@@ -533,8 +586,8 @@ func setViperDefaults() {
viper.SetDefault("authrequestlimit", 5)
viper.SetDefault("authwindowlength", 20*time.Second)
viper.SetDefault("passwordencryptionkey", "")
viper.SetDefault("reverseproxyuserheader", "Remote-User")
viper.SetDefault("reverseproxywhitelist", "")
viper.SetDefault("extauth.userheader", "Remote-User")
viper.SetDefault("extauth.trustedsources", "")
viper.SetDefault("prometheus.enabled", false)
viper.SetDefault("prometheus.metricspath", consts.PrometheusDefaultPath)
viper.SetDefault("prometheus.password", "")
@@ -555,7 +608,7 @@ func setViperDefaults() {
viper.SetDefault("subsonic.appendsubtitle", true)
viper.SetDefault("subsonic.artistparticipations", false)
viper.SetDefault("subsonic.defaultreportrealpath", false)
viper.SetDefault("subsonic.legacyclients", "DSub")
viper.SetDefault("subsonic.legacyclients", "DSub,SubMusic")
viper.SetDefault("agents", "lastfm,spotify,deezer")
viper.SetDefault("lastfm.enabled", true)
viper.SetDefault("lastfm.language", "en")
@@ -565,9 +618,11 @@ func setViperDefaults() {
viper.SetDefault("spotify.id", "")
viper.SetDefault("spotify.secret", "")
viper.SetDefault("deezer.enabled", true)
viper.SetDefault("deezer.language", "en")
viper.SetDefault("listenbrainz.enabled", true)
viper.SetDefault("listenbrainz.baseurl", "https://api.listenbrainz.org/1/")
viper.SetDefault("httpsecurityheaders.customframeoptionsvalue", "DENY")
viper.SetDefault("enablescrobblehistory", true)
viper.SetDefault("httpheaders.frameoptions", "DENY")
viper.SetDefault("backup.path", "")
viper.SetDefault("backup.schedule", "")
viper.SetDefault("backup.count", 0)
@@ -579,7 +634,8 @@ func setViperDefaults() {
viper.SetDefault("inspect.backlogtimeout", consts.RequestThrottleBacklogTimeout)
viper.SetDefault("plugins.folder", "")
viper.SetDefault("plugins.enabled", false)
viper.SetDefault("plugins.cachesize", "100MB")
viper.SetDefault("plugins.cachesize", "200MB")
viper.SetDefault("plugins.autoreload", false)
// DevFlags. These are used to enable/disable debugging and incomplete features
viper.SetDefault("devlogsourceline", false)
@@ -600,20 +656,28 @@ func setViperDefaults() {
viper.SetDefault("devalbuminfotimetolive", consts.AlbumInfoTimeToLive)
viper.SetDefault("devexternalscanner", true)
viper.SetDefault("devscannerthreads", 5)
viper.SetDefault("devselectivewatcher", true)
viper.SetDefault("devinsightsinitialdelay", consts.InsightsInitialDelay)
viper.SetDefault("devenableplayerinsights", true)
viper.SetDefault("devenablepluginsinsights", true)
viper.SetDefault("devplugincompilationtimeout", time.Minute)
viper.SetDefault("devexternalartistfetchmultiplier", 1.5)
viper.SetDefault("devoptimizedb", true)
viper.SetDefault("devpreserveunicodeinexternalcalls", false)
}
func init() {
setViperDefaults()
}
func InitConfig(cfgFile string) {
func InitConfig(cfgFile string, loadEnvVars bool) {
codecRegistry := viper.NewCodecRegistry()
_ = codecRegistry.RegisterCodec("ini", ini.Codec{})
_ = codecRegistry.RegisterCodec("ini", ini.Codec{
LoadOptions: ini.LoadOptions{
UnescapeValueDoubleQuotes: true,
UnescapeValueCommentSymbols: true,
},
})
viper.SetOptions(viper.WithCodecRegistry(codecRegistry))
cfgFile = getConfigFile(cfgFile)
@@ -627,10 +691,12 @@ func InitConfig(cfgFile string) {
}
_ = viper.BindEnv("port")
viper.SetEnvPrefix("ND")
replacer := strings.NewReplacer(".", "_")
viper.SetEnvKeyReplacer(replacer)
viper.AutomaticEnv()
if loadEnvVars {
viper.SetEnvPrefix("ND")
replacer := strings.NewReplacer(".", "_")
viper.SetEnvKeyReplacer(replacer)
viper.AutomaticEnv()
}
err := viper.ReadInConfig()
if viper.ConfigFileUsed() != "" && err != nil {

View File

@@ -31,7 +31,7 @@ var _ = Describe("Configuration", func() {
filename := filepath.Join("testdata", "cfg."+format)
// Initialize config with the test file
conf.InitConfig(filename)
conf.InitConfig(filename, false)
// Load the configuration (with noConfigDump=true)
conf.Load(true)
@@ -39,6 +39,10 @@ var _ = Describe("Configuration", func() {
Expect(conf.Server.MusicFolder).To(Equal(fmt.Sprintf("/%s/music", format)))
Expect(conf.Server.UIWelcomeMessage).To(Equal("Welcome " + format))
Expect(conf.Server.Tags["custom"].Aliases).To(Equal([]string{format, "test"}))
Expect(conf.Server.Tags["artist"].Split).To(Equal([]string{";"}))
// Check deprecated option mapping
Expect(conf.Server.ExtAuth.UserHeader).To(Equal("X-Auth-User"))
// The config file used should be the one we created
Expect(conf.Server.ConfigFile).To(Equal(filename))

View File

@@ -1,6 +1,8 @@
[default]
MusicFolder = /ini/music
UIWelcomeMessage = Welcome ini
UIWelcomeMessage = 'Welcome ini' ; Just a comment to test the LoadOptions
ReverseProxyUserHeader = 'X-Auth-User'
[Tags]
Custom.Aliases = ini,test
Custom.Aliases = ini,test
artist.Split = ";" # Should be able to read ; as a separator

View File

@@ -1,7 +1,11 @@
{
"musicFolder": "/json/music",
"uiWelcomeMessage": "Welcome json",
"reverseProxyUserHeader": "X-Auth-User",
"Tags": {
"artist": {
"split": ";"
},
"custom": {
"aliases": [
"json",

View File

@@ -1,5 +1,8 @@
musicFolder = "/toml/music"
uiWelcomeMessage = "Welcome toml"
ReverseProxyUserHeader = "X-Auth-User"
Tags.artist.Split = ';'
[Tags.custom]
aliases = ["toml", "test"]

View File

@@ -1,6 +1,9 @@
musicFolder: "/yaml/music"
uiWelcomeMessage: "Welcome yaml"
reverseProxyUserHeader: "X-Auth-User"
Tags:
artist:
split: [";"]
custom:
aliases:
- yaml

View File

@@ -150,6 +150,8 @@ var (
}
)
var HTTPUserAgent = "Navidrome" + "/" + Version
var (
VariousArtists = "Various Artists"
// TODO This will be dynamic when using disambiguation

View File

@@ -64,6 +64,7 @@ func (a *Agents) getEnabledAgentNames() []enabledAgent {
if a.pluginLoader != nil {
availablePlugins = a.pluginLoader.PluginNames("MetadataAgent")
}
log.Trace("Available MetadataAgent plugins", "plugins", availablePlugins)
configuredAgents := strings.Split(conf.Server.Agents, ",")
@@ -87,7 +88,7 @@ func (a *Agents) getEnabledAgentNames() []enabledAgent {
} else if isPlugin {
validAgents = append(validAgents, enabledAgent{name: name, isPlugin: true})
} else {
log.Warn("Unknown agent ignored", "name", name)
log.Debug("Unknown agent ignored", "name", name)
}
}
return validAgents
@@ -354,6 +355,9 @@ func (a *Agents) GetAlbumImages(ctx context.Context, name, artist, mbid string)
continue
}
images, err := retriever.GetAlbumImages(ctx, name, artist, mbid)
if err != nil {
log.Trace(ctx, "Agent GetAlbumImages failed", "agent", ag.AgentName(), "album", name, "artist", artist, "mbid", mbid, err)
}
if len(images) > 0 && err == nil {
log.Debug(ctx, "Got Album Images", "agent", ag.AgentName(), "album", name, "artist", artist,
"mbid", mbid, "elapsed", time.Since(start))

View File

@@ -1,6 +1,7 @@
package deezer
import (
bytes "bytes"
"context"
"encoding/json"
"errors"
@@ -9,11 +10,14 @@ import (
"net/http"
"net/url"
"strconv"
"strings"
"github.com/microcosm-cc/bluemonday"
"github.com/navidrome/navidrome/log"
)
const apiBaseURL = "https://api.deezer.com"
const authBaseURL = "https://auth.deezer.com"
var (
ErrNotFound = errors.New("deezer: not found")
@@ -25,15 +29,21 @@ type httpDoer interface {
type client struct {
httpDoer httpDoer
language string
jwt jwtToken
}
func newClient(hc httpDoer) *client {
return &client{hc}
func newClient(hc httpDoer, language string) *client {
return &client{
httpDoer: hc,
language: language,
}
}
func (c *client) searchArtists(ctx context.Context, name string, limit int) ([]Artist, error) {
params := url.Values{}
params.Add("q", name)
params.Add("order", "RANKING")
params.Add("limit", strconv.Itoa(limit))
req, err := http.NewRequestWithContext(ctx, "GET", apiBaseURL+"/search/artist", nil)
if err != nil {
@@ -53,7 +63,7 @@ func (c *client) searchArtists(ctx context.Context, name string, limit int) ([]A
return results.Data, nil
}
func (c *client) makeRequest(req *http.Request, response interface{}) error {
func (c *client) makeRequest(req *http.Request, response any) error {
log.Trace(req.Context(), fmt.Sprintf("Sending Deezer %s request", req.Method), "url", req.URL)
resp, err := c.httpDoer.Do(req)
if err != nil {
@@ -81,3 +91,129 @@ func (c *client) parseError(data []byte) error {
}
return fmt.Errorf("deezer error(%d): %s", deezerError.Error.Code, deezerError.Error.Message)
}
func (c *client) getRelatedArtists(ctx context.Context, artistID int) ([]Artist, error) {
req, err := http.NewRequestWithContext(ctx, "GET", fmt.Sprintf("%s/artist/%d/related", apiBaseURL, artistID), nil)
if err != nil {
return nil, err
}
var results RelatedArtists
err = c.makeRequest(req, &results)
if err != nil {
return nil, err
}
return results.Data, nil
}
func (c *client) getTopTracks(ctx context.Context, artistID int, limit int) ([]Track, error) {
params := url.Values{}
params.Add("limit", strconv.Itoa(limit))
req, err := http.NewRequestWithContext(ctx, "GET", fmt.Sprintf("%s/artist/%d/top", apiBaseURL, artistID), nil)
if err != nil {
return nil, err
}
req.URL.RawQuery = params.Encode()
var results TopTracks
err = c.makeRequest(req, &results)
if err != nil {
return nil, err
}
return results.Data, nil
}
const pipeAPIURL = "https://pipe.deezer.com/api"
var strictPolicy = bluemonday.StrictPolicy()
func (c *client) getArtistBio(ctx context.Context, artistID int) (string, error) {
jwt, err := c.getJWT(ctx)
if err != nil {
return "", fmt.Errorf("deezer: failed to get JWT: %w", err)
}
query := map[string]any{
"operationName": "ArtistBio",
"variables": map[string]any{
"artistId": strconv.Itoa(artistID),
},
"query": `query ArtistBio($artistId: String!) {
artist(artistId: $artistId) {
bio {
full
}
}
}`,
}
body, err := json.Marshal(query)
if err != nil {
return "", err
}
req, err := http.NewRequestWithContext(ctx, "POST", pipeAPIURL, bytes.NewReader(body))
if err != nil {
return "", err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept-Language", c.language)
req.Header.Set("Authorization", "Bearer "+jwt)
log.Trace(ctx, "Fetching Deezer artist biography via GraphQL", "artistId", artistID, "language", c.language)
resp, err := c.httpDoer.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return "", fmt.Errorf("deezer: failed to fetch biography: %s", resp.Status)
}
data, err := io.ReadAll(resp.Body)
if err != nil {
return "", err
}
type graphQLResponse struct {
Data struct {
Artist struct {
Bio struct {
Full string `json:"full"`
} `json:"bio"`
} `json:"artist"`
} `json:"data"`
Errors []struct {
Message string `json:"message"`
}
}
var result graphQLResponse
if err := json.Unmarshal(data, &result); err != nil {
return "", fmt.Errorf("deezer: failed to parse GraphQL response: %w", err)
}
if len(result.Errors) > 0 {
var errs []error
for m := range result.Errors {
errs = append(errs, errors.New(result.Errors[m].Message))
}
err := errors.Join(errs...)
return "", fmt.Errorf("deezer: GraphQL error: %w", err)
}
if result.Data.Artist.Bio.Full == "" {
return "", errors.New("deezer: biography not found")
}
return cleanBio(result.Data.Artist.Bio.Full), nil
}
func cleanBio(bio string) string {
bio = strings.ReplaceAll(bio, "</p>", "\n")
return strictPolicy.Sanitize(bio)
}

View File

@@ -0,0 +1,101 @@
package deezer
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"sync"
"time"
"github.com/lestrrat-go/jwx/v2/jwt"
"github.com/navidrome/navidrome/log"
)
type jwtToken struct {
token string
expiresAt time.Time
mu sync.RWMutex
}
func (j *jwtToken) get() (string, bool) {
j.mu.RLock()
defer j.mu.RUnlock()
if time.Now().Before(j.expiresAt) {
return j.token, true
}
return "", false
}
func (j *jwtToken) set(token string, expiresIn time.Duration) {
j.mu.Lock()
defer j.mu.Unlock()
j.token = token
j.expiresAt = time.Now().Add(expiresIn)
}
func (c *client) getJWT(ctx context.Context) (string, error) {
// Check if we have a valid cached token
if token, valid := c.jwt.get(); valid {
return token, nil
}
// Fetch a new anonymous token
req, err := http.NewRequestWithContext(ctx, "GET", authBaseURL+"/login/anonymous?jo=p&rto=c", nil)
if err != nil {
return "", err
}
req.Header.Set("Accept", "application/json")
resp, err := c.httpDoer.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return "", fmt.Errorf("deezer: failed to get JWT token: %s", resp.Status)
}
data, err := io.ReadAll(resp.Body)
if err != nil {
return "", err
}
type authResponse struct {
JWT string `json:"jwt"`
}
var result authResponse
if err := json.Unmarshal(data, &result); err != nil {
return "", fmt.Errorf("deezer: failed to parse auth response: %w", err)
}
if result.JWT == "" {
return "", errors.New("deezer: no JWT token in response")
}
// Parse JWT to get actual expiration time
token, err := jwt.ParseString(result.JWT, jwt.WithVerify(false), jwt.WithValidate(false))
if err != nil {
return "", fmt.Errorf("deezer: failed to parse JWT token: %w", err)
}
// Calculate TTL with a 1-minute buffer for clock skew and network delays
expiresAt := token.Expiration()
if expiresAt.IsZero() {
return "", errors.New("deezer: JWT token has no expiration time")
}
ttl := time.Until(expiresAt) - 1*time.Minute
if ttl <= 0 {
return "", errors.New("deezer: JWT token already expired or expires too soon")
}
c.jwt.set(result.JWT, ttl)
log.Trace(ctx, "Fetched new Deezer JWT token", "expiresAt", expiresAt, "ttl", ttl)
return result.JWT, nil
}

View File

@@ -0,0 +1,293 @@
package deezer
import (
"bytes"
"context"
"fmt"
"io"
"net/http"
"sync"
"time"
"github.com/lestrrat-go/jwx/v2/jwt"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("JWT Authentication", func() {
var httpClient *fakeHttpClient
var client *client
var ctx context.Context
BeforeEach(func() {
httpClient = &fakeHttpClient{}
client = newClient(httpClient, "en")
ctx = context.Background()
})
Describe("getJWT", func() {
Context("with a valid JWT response", func() {
It("successfully fetches and caches a JWT token", func() {
testJWT := createTestJWT(5 * time.Minute)
httpClient.mock("https://auth.deezer.com/login/anonymous", http.Response{
StatusCode: 200,
Body: io.NopCloser(bytes.NewBufferString(fmt.Sprintf(`{"jwt":"%s"}`, testJWT))),
})
token, err := client.getJWT(ctx)
Expect(err).To(BeNil())
Expect(token).To(Equal(testJWT))
})
It("returns the cached token on subsequent calls", func() {
testJWT := createTestJWT(5 * time.Minute)
httpClient.mock("https://auth.deezer.com/login/anonymous", http.Response{
StatusCode: 200,
Body: io.NopCloser(bytes.NewBufferString(fmt.Sprintf(`{"jwt":"%s"}`, testJWT))),
})
// First call should fetch from API
token1, err := client.getJWT(ctx)
Expect(err).To(BeNil())
Expect(token1).To(Equal(testJWT))
Expect(httpClient.lastRequest.URL.Path).To(Equal("/login/anonymous"))
// Second call should return cached token without hitting API
httpClient.lastRequest = nil // Clear last request to verify no new request is made
token2, err := client.getJWT(ctx)
Expect(err).To(BeNil())
Expect(token2).To(Equal(testJWT))
Expect(httpClient.lastRequest).To(BeNil()) // No new request made
})
It("parses the JWT expiration time correctly", func() {
expectedExpiration := time.Now().Add(5 * time.Minute)
testToken, err := jwt.NewBuilder().
Expiration(expectedExpiration).
Build()
Expect(err).To(BeNil())
testJWT, err := jwt.Sign(testToken, jwt.WithInsecureNoSignature())
Expect(err).To(BeNil())
httpClient.mock("https://auth.deezer.com/login/anonymous", http.Response{
StatusCode: 200,
Body: io.NopCloser(bytes.NewBufferString(fmt.Sprintf(`{"jwt":"%s"}`, string(testJWT)))),
})
token, err := client.getJWT(ctx)
Expect(err).To(BeNil())
Expect(token).ToNot(BeEmpty())
// Verify the token is cached until close to expiration
// The cache should expire 1 minute before the JWT expires
expectedCacheExpiry := expectedExpiration.Add(-1 * time.Minute)
Expect(client.jwt.expiresAt).To(BeTemporally("~", expectedCacheExpiry, 2*time.Second))
})
})
Context("with JWT tokens that expire soon", func() {
It("rejects tokens that expire in less than 1 minute", func() {
// Create a token that expires in 30 seconds (less than 1-minute buffer)
testJWT := createTestJWT(30 * time.Second)
httpClient.mock("https://auth.deezer.com/login/anonymous", http.Response{
StatusCode: 200,
Body: io.NopCloser(bytes.NewBufferString(fmt.Sprintf(`{"jwt":"%s"}`, testJWT))),
})
_, err := client.getJWT(ctx)
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("JWT token already expired or expires too soon"))
})
It("rejects already expired tokens", func() {
// Create a token that expired 1 minute ago
testJWT := createTestJWT(-1 * time.Minute)
httpClient.mock("https://auth.deezer.com/login/anonymous", http.Response{
StatusCode: 200,
Body: io.NopCloser(bytes.NewBufferString(fmt.Sprintf(`{"jwt":"%s"}`, testJWT))),
})
_, err := client.getJWT(ctx)
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("JWT token already expired or expires too soon"))
})
It("accepts tokens that expire in more than 1 minute", func() {
// Create a token that expires in 2 minutes (just over the 1-minute buffer)
testJWT := createTestJWT(2 * time.Minute)
httpClient.mock("https://auth.deezer.com/login/anonymous", http.Response{
StatusCode: 200,
Body: io.NopCloser(bytes.NewBufferString(fmt.Sprintf(`{"jwt":"%s"}`, testJWT))),
})
token, err := client.getJWT(ctx)
Expect(err).To(BeNil())
Expect(token).ToNot(BeEmpty())
})
})
Context("with invalid responses", func() {
It("handles HTTP error responses", func() {
httpClient.mock("https://auth.deezer.com/login/anonymous", http.Response{
StatusCode: 500,
Body: io.NopCloser(bytes.NewBufferString(`{"error":"Internal server error"}`)),
})
_, err := client.getJWT(ctx)
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("failed to get JWT token"))
})
It("handles malformed JSON responses", func() {
httpClient.mock("https://auth.deezer.com/login/anonymous", http.Response{
StatusCode: 200,
Body: io.NopCloser(bytes.NewBufferString(`{invalid json}`)),
})
_, err := client.getJWT(ctx)
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("failed to parse auth response"))
})
It("handles responses with empty JWT field", func() {
httpClient.mock("https://auth.deezer.com/login/anonymous", http.Response{
StatusCode: 200,
Body: io.NopCloser(bytes.NewBufferString(`{"jwt":""}`)),
})
_, err := client.getJWT(ctx)
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(Equal("deezer: no JWT token in response"))
})
It("handles invalid JWT tokens", func() {
httpClient.mock("https://auth.deezer.com/login/anonymous", http.Response{
StatusCode: 200,
Body: io.NopCloser(bytes.NewBufferString(`{"jwt":"not-a-valid-jwt"}`)),
})
_, err := client.getJWT(ctx)
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("failed to parse JWT token"))
})
It("rejects JWT tokens without expiration", func() {
// Create a JWT without expiration claim
testToken, err := jwt.NewBuilder().
Claim("custom", "value").
Build()
Expect(err).To(BeNil())
// Verify token has no expiration
Expect(testToken.Expiration().IsZero()).To(BeTrue())
testJWT, err := jwt.Sign(testToken, jwt.WithInsecureNoSignature())
Expect(err).To(BeNil())
httpClient.mock("https://auth.deezer.com/login/anonymous", http.Response{
StatusCode: 200,
Body: io.NopCloser(bytes.NewBufferString(fmt.Sprintf(`{"jwt":"%s"}`, string(testJWT)))),
})
_, err = client.getJWT(ctx)
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(Equal("deezer: JWT token has no expiration time"))
})
})
Context("token caching behavior", func() {
It("fetches a new token when the cached token expires", func() {
// First token expires in 5 minutes
firstJWT := createTestJWT(5 * time.Minute)
httpClient.mock("https://auth.deezer.com/login/anonymous", http.Response{
StatusCode: 200,
Body: io.NopCloser(bytes.NewBufferString(fmt.Sprintf(`{"jwt":"%s"}`, firstJWT))),
})
token1, err := client.getJWT(ctx)
Expect(err).To(BeNil())
Expect(token1).To(Equal(firstJWT))
// Manually expire the cached token
client.jwt.expiresAt = time.Now().Add(-1 * time.Second)
// Second token with different expiration (10 minutes)
secondJWT := createTestJWT(10 * time.Minute)
httpClient.mock("https://auth.deezer.com/login/anonymous", http.Response{
StatusCode: 200,
Body: io.NopCloser(bytes.NewBufferString(fmt.Sprintf(`{"jwt":"%s"}`, secondJWT))),
})
token2, err := client.getJWT(ctx)
Expect(err).To(BeNil())
Expect(token2).To(Equal(secondJWT))
Expect(token2).ToNot(Equal(token1))
})
})
})
Describe("jwtToken cache", func() {
var cache *jwtToken
BeforeEach(func() {
cache = &jwtToken{}
})
It("returns false for expired tokens", func() {
cache.set("test-token", -1*time.Second) // Already expired
token, valid := cache.get()
Expect(valid).To(BeFalse())
Expect(token).To(BeEmpty())
})
It("returns true for valid tokens", func() {
cache.set("test-token", 4*time.Minute)
token, valid := cache.get()
Expect(valid).To(BeTrue())
Expect(token).To(Equal("test-token"))
})
It("is thread-safe for concurrent access", func() {
wg := sync.WaitGroup{}
// Writer goroutine
wg.Go(func() {
for i := 0; i < 100; i++ {
cache.set(fmt.Sprintf("token-%d", i), 1*time.Hour)
time.Sleep(1 * time.Millisecond)
}
})
// Reader goroutine
wg.Go(func() {
for i := 0; i < 100; i++ {
cache.get()
time.Sleep(1 * time.Millisecond)
}
})
// Wait for both goroutines to complete
wg.Wait()
// Verify final state is valid
token, valid := cache.get()
Expect(valid).To(BeTrue())
Expect(token).To(HavePrefix("token-"))
})
})
})
// createTestJWT creates a valid JWT token for testing purposes
func createTestJWT(expiresIn time.Duration) string {
token, err := jwt.NewBuilder().
Expiration(time.Now().Add(expiresIn)).
Build()
if err != nil {
panic(fmt.Sprintf("failed to create test JWT: %v", err))
}
signed, err := jwt.Sign(token, jwt.WithInsecureNoSignature())
if err != nil {
panic(fmt.Sprintf("failed to sign test JWT: %v", err))
}
return string(signed)
}

View File

@@ -2,10 +2,11 @@ package deezer
import (
"bytes"
"context"
"fmt"
"io"
"net/http"
"os"
"time"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
@@ -17,7 +18,7 @@ var _ = Describe("client", func() {
BeforeEach(func() {
httpClient = &fakeHttpClient{}
client = newClient(httpClient)
client = newClient(httpClient, "en")
})
Describe("ArtistImages", func() {
@@ -26,7 +27,7 @@ var _ = Describe("client", func() {
Expect(err).To(BeNil())
httpClient.mock("https://api.deezer.com/search/artist", http.Response{Body: f, StatusCode: 200})
artists, err := client.searchArtists(context.TODO(), "Michael Jackson", 20)
artists, err := client.searchArtists(GinkgoT().Context(), "Michael Jackson", 20)
Expect(err).To(BeNil())
Expect(artists).To(HaveLen(17))
Expect(artists[0].Name).To(Equal("Michael Jackson"))
@@ -39,10 +40,136 @@ var _ = Describe("client", func() {
Body: io.NopCloser(bytes.NewBufferString(`{"data":[],"total":0}`)),
})
_, err := client.searchArtists(context.TODO(), "Michael Jackson", 20)
_, err := client.searchArtists(GinkgoT().Context(), "Michael Jackson", 20)
Expect(err).To(MatchError(ErrNotFound))
})
})
Describe("ArtistBio", func() {
BeforeEach(func() {
// Mock the JWT token endpoint with a valid JWT that expires in 5 minutes
testJWT := createTestJWT(5 * time.Minute)
httpClient.mock("https://auth.deezer.com/login/anonymous", http.Response{
StatusCode: 200,
Body: io.NopCloser(bytes.NewBufferString(fmt.Sprintf(`{"jwt":"%s","refresh_token":""}`, testJWT))),
})
})
It("returns artist bio from a successful request", func() {
f, err := os.Open("tests/fixtures/deezer.artist.bio.json")
Expect(err).To(BeNil())
httpClient.mock("https://pipe.deezer.com/api", http.Response{Body: f, StatusCode: 200})
bio, err := client.getArtistBio(GinkgoT().Context(), 27)
Expect(err).To(BeNil())
Expect(bio).To(ContainSubstring("Schoolmates Thomas and Guy-Manuel"))
Expect(bio).ToNot(ContainSubstring("<p>"))
Expect(bio).ToNot(ContainSubstring("</p>"))
})
It("uses the configured language", func() {
client = newClient(httpClient, "fr")
// Mock JWT token for the new client instance with a valid JWT
testJWT := createTestJWT(5 * time.Minute)
httpClient.mock("https://auth.deezer.com/login/anonymous", http.Response{
StatusCode: 200,
Body: io.NopCloser(bytes.NewBufferString(fmt.Sprintf(`{"jwt":"%s","refresh_token":""}`, testJWT))),
})
f, err := os.Open("tests/fixtures/deezer.artist.bio.json")
Expect(err).To(BeNil())
httpClient.mock("https://pipe.deezer.com/api", http.Response{Body: f, StatusCode: 200})
_, err = client.getArtistBio(GinkgoT().Context(), 27)
Expect(err).To(BeNil())
Expect(httpClient.lastRequest.Header.Get("Accept-Language")).To(Equal("fr"))
})
It("includes the JWT token in the request", func() {
f, err := os.Open("tests/fixtures/deezer.artist.bio.json")
Expect(err).To(BeNil())
httpClient.mock("https://pipe.deezer.com/api", http.Response{Body: f, StatusCode: 200})
_, err = client.getArtistBio(GinkgoT().Context(), 27)
Expect(err).To(BeNil())
// Verify that the Authorization header has the Bearer token format
authHeader := httpClient.lastRequest.Header.Get("Authorization")
Expect(authHeader).To(HavePrefix("Bearer "))
Expect(len(authHeader)).To(BeNumerically(">", 20)) // JWT tokens are longer than 20 chars
})
It("handles GraphQL errors", func() {
errorResponse := `{
"data": {
"artist": {
"bio": {
"full": ""
}
}
},
"errors": [
{
"message": "Artist not found"
},
{
"message": "Invalid artist ID"
}
]
}`
httpClient.mock("https://pipe.deezer.com/api", http.Response{
StatusCode: 200,
Body: io.NopCloser(bytes.NewBufferString(errorResponse)),
})
_, err := client.getArtistBio(GinkgoT().Context(), 999)
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("GraphQL error"))
Expect(err.Error()).To(ContainSubstring("Artist not found"))
Expect(err.Error()).To(ContainSubstring("Invalid artist ID"))
})
It("handles empty biography", func() {
emptyBioResponse := `{
"data": {
"artist": {
"bio": {
"full": ""
}
}
}
}`
httpClient.mock("https://pipe.deezer.com/api", http.Response{
StatusCode: 200,
Body: io.NopCloser(bytes.NewBufferString(emptyBioResponse)),
})
_, err := client.getArtistBio(GinkgoT().Context(), 27)
Expect(err).To(MatchError("deezer: biography not found"))
})
It("handles JWT token fetch failure", func() {
httpClient.mock("https://auth.deezer.com/login/anonymous", http.Response{
StatusCode: 500,
Body: io.NopCloser(bytes.NewBufferString(`{"error":"Internal server error"}`)),
})
_, err := client.getArtistBio(GinkgoT().Context(), 27)
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("failed to get JWT"))
})
It("handles JWT token that expires too soon", func() {
// Create a JWT that expires in 30 seconds (less than the 1-minute buffer)
expiredJWT := createTestJWT(30 * time.Second)
httpClient.mock("https://auth.deezer.com/login/anonymous", http.Response{
StatusCode: 200,
Body: io.NopCloser(bytes.NewBufferString(fmt.Sprintf(`{"jwt":"%s","refresh_token":""}`, expiredJWT))),
})
_, err := client.getArtistBio(GinkgoT().Context(), 27)
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("JWT token already expired or expires too soon"))
})
})
})
type fakeHttpClient struct {

View File

@@ -3,6 +3,7 @@ package deezer
import (
"context"
"errors"
"fmt"
"net/http"
"strings"
@@ -12,6 +13,7 @@ import (
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/utils/cache"
"github.com/navidrome/navidrome/utils/slice"
)
const deezerAgentName = "deezer"
@@ -32,7 +34,7 @@ func deezerConstructor(dataStore model.DataStore) agents.Interface {
Timeout: consts.DefaultHttpClientTimeOut,
}
cachedHttpClient := cache.NewHTTPClient(httpClient, consts.DefaultHttpClientTimeOut)
agent.client = newClient(cachedHttpClient)
agent.client = newClient(cachedHttpClient, conf.Server.Deezer.Language)
return agent
}
@@ -81,13 +83,73 @@ func (s *deezerAgent) searchArtist(ctx context.Context, name string) (*Artist, e
return nil, err
}
log.Trace(ctx, "Artists found", "count", len(artists), "searched_name", name)
for i := range artists {
log.Trace(ctx, fmt.Sprintf("Artists found #%d", i), "name", artists[i].Name, "id", artists[i].ID, "link", artists[i].Link)
if i > 2 {
break
}
}
// If the first one has the same name, that's the one
if !strings.EqualFold(artists[0].Name, name) {
log.Trace(ctx, "Top artist do not match", "searched_name", name, "found_name", artists[0].Name)
return nil, agents.ErrNotFound
}
log.Trace(ctx, "Found artist", "name", artists[0].Name, "id", artists[0].ID, "link", artists[0].Link)
return &artists[0], err
}
func (s *deezerAgent) GetSimilarArtists(ctx context.Context, _, name, _ string, limit int) ([]agents.Artist, error) {
artist, err := s.searchArtist(ctx, name)
if err != nil {
return nil, err
}
related, err := s.client.getRelatedArtists(ctx, artist.ID)
if err != nil {
return nil, err
}
res := slice.Map(related, func(r Artist) agents.Artist {
return agents.Artist{
Name: r.Name,
}
})
if len(res) > limit {
res = res[:limit]
}
return res, nil
}
func (s *deezerAgent) GetArtistTopSongs(ctx context.Context, _, artistName, _ string, count int) ([]agents.Song, error) {
artist, err := s.searchArtist(ctx, artistName)
if err != nil {
return nil, err
}
tracks, err := s.client.getTopTracks(ctx, artist.ID, count)
if err != nil {
return nil, err
}
res := slice.Map(tracks, func(r Track) agents.Song {
return agents.Song{
Name: r.Title,
}
})
return res, nil
}
func (s *deezerAgent) GetArtistBiography(ctx context.Context, _, name, _ string) (string, error) {
artist, err := s.searchArtist(ctx, name)
if err != nil {
return "", err
}
return s.client.getArtistBio(ctx, artist.ID)
}
func init() {
conf.AddHook(func() {
if conf.Server.Deezer.Enabled {

View File

@@ -29,3 +29,38 @@ type Error struct {
Code int `json:"code"`
} `json:"error"`
}
type RelatedArtists struct {
Data []Artist `json:"data"`
Total int `json:"total"`
}
type TopTracks struct {
Data []Track `json:"data"`
Total int `json:"total"`
Next string `json:"next"`
}
type Track struct {
ID int `json:"id"`
Title string `json:"title"`
Link string `json:"link"`
Duration int `json:"duration"`
Rank int `json:"rank"`
Preview string `json:"preview"`
Artist Artist `json:"artist"`
Album Album `json:"album"`
Contributors []Artist `json:"contributors"`
}
type Album struct {
ID int `json:"id"`
Title string `json:"title"`
Cover string `json:"cover"`
CoverSmall string `json:"cover_small"`
CoverMedium string `json:"cover_medium"`
CoverBig string `json:"cover_big"`
CoverXl string `json:"cover_xl"`
Tracklist string `json:"tracklist"`
Type string `json:"type"`
}

View File

@@ -35,4 +35,35 @@ var _ = Describe("Responses", func() {
Expect(errorResp.Error.Message).To(Equal("Missing parameters: q"))
})
})
Describe("Related Artists", func() {
It("parses the related artists response correctly", func() {
var resp RelatedArtists
body, err := os.ReadFile("tests/fixtures/deezer.artist.related.json")
Expect(err).To(BeNil())
err = json.Unmarshal(body, &resp)
Expect(err).To(BeNil())
Expect(resp.Data).To(HaveLen(20))
justice := resp.Data[0]
Expect(justice.Name).To(Equal("Justice"))
Expect(justice.ID).To(Equal(6404))
})
})
Describe("Top Tracks", func() {
It("parses the top tracks response correctly", func() {
var resp TopTracks
body, err := os.ReadFile("tests/fixtures/deezer.artist.top.json")
Expect(err).To(BeNil())
err = json.Unmarshal(body, &resp)
Expect(err).To(BeNil())
Expect(resp.Data).To(HaveLen(5))
track := resp.Data[0]
Expect(track.Title).To(Equal("Instant Crush (feat. Julian Casablancas)"))
Expect(track.ID).To(Equal(67238732))
Expect(track.Album.Title).To(Equal("Random Access Memories"))
})
})
})

View File

@@ -38,6 +38,7 @@ type lastfmAgent struct {
secret string
lang string
client *client
httpClient httpDoer
getInfoMutex sync.Mutex
}
@@ -56,6 +57,7 @@ func lastFMConstructor(ds model.DataStore) *lastfmAgent {
Timeout: consts.DefaultHttpClientTimeOut,
}
chc := cache.NewHTTPClient(hc, consts.DefaultHttpClientTimeOut)
l.httpClient = chc
l.client = newClient(l.apiKey, l.secret, l.lang, chc)
return l
}
@@ -190,13 +192,13 @@ func (l *lastfmAgent) GetArtistTopSongs(ctx context.Context, id, artistName, mbi
return res, nil
}
var artistOpenGraphQuery = cascadia.MustCompile(`html > head > meta[property="og:image"]`)
var (
artistOpenGraphQuery = cascadia.MustCompile(`html > head > meta[property="og:image"]`)
artistIgnoredImage = "2a96cbd8b46e442fc41c2b86b821562f" // Last.fm artist placeholder image name
)
func (l *lastfmAgent) GetArtistImages(ctx context.Context, _, name, mbid string) ([]agents.ExternalImage, error) {
log.Debug(ctx, "Getting artist images from Last.fm", "name", name)
hc := http.Client{
Timeout: consts.DefaultHttpClientTimeOut,
}
a, err := l.callArtistGetInfo(ctx, name)
if err != nil {
return nil, fmt.Errorf("get artist info: %w", err)
@@ -205,7 +207,7 @@ func (l *lastfmAgent) GetArtistImages(ctx context.Context, _, name, mbid string)
if err != nil {
return nil, fmt.Errorf("create artist image request: %w", err)
}
resp, err := hc.Do(req)
resp, err := l.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("get artist url: %w", err)
}
@@ -222,11 +224,16 @@ func (l *lastfmAgent) GetArtistImages(ctx context.Context, _, name, mbid string)
return res, nil
}
for _, attr := range n.Attr {
if attr.Key == "content" {
res = []agents.ExternalImage{
{URL: attr.Val},
}
break
if attr.Key != "content" {
continue
}
if strings.Contains(attr.Val, artistIgnoredImage) {
log.Debug(ctx, "Artist image is ignored default image", "name", name, "url", attr.Val)
return res, nil
}
res = []agents.ExternalImage{
{URL: attr.Val},
}
}
return res, nil
@@ -283,11 +290,11 @@ func (l *lastfmAgent) callArtistGetTopTracks(ctx context.Context, artistName str
return t.Track, nil
}
func (l *lastfmAgent) getArtistForScrobble(track *model.MediaFile) string {
if conf.Server.LastFM.ScrobbleFirstArtistOnly && len(track.Participants[model.RoleArtist]) > 0 {
return track.Participants[model.RoleArtist][0].Name
func (l *lastfmAgent) getArtistForScrobble(track *model.MediaFile, role model.Role, displayName string) string {
if conf.Server.LastFM.ScrobbleFirstArtistOnly && len(track.Participants[role]) > 0 {
return track.Participants[role][0].Name
}
return track.Artist
return displayName
}
func (l *lastfmAgent) NowPlaying(ctx context.Context, userId string, track *model.MediaFile, position int) error {
@@ -297,13 +304,13 @@ func (l *lastfmAgent) NowPlaying(ctx context.Context, userId string, track *mode
}
err = l.client.updateNowPlaying(ctx, sk, ScrobbleInfo{
artist: l.getArtistForScrobble(track),
artist: l.getArtistForScrobble(track, model.RoleArtist, track.Artist),
track: track.Title,
album: track.Album,
trackNumber: track.TrackNumber,
mbid: track.MbzRecordingID,
duration: int(track.Duration),
albumArtist: track.AlbumArtist,
albumArtist: l.getArtistForScrobble(track, model.RoleAlbumArtist, track.AlbumArtist),
})
if err != nil {
log.Warn(ctx, "Last.fm client.updateNowPlaying returned error", "track", track.Title, err)
@@ -323,13 +330,13 @@ func (l *lastfmAgent) Scrobble(ctx context.Context, userId string, s scrobbler.S
return nil
}
err = l.client.scrobble(ctx, sk, ScrobbleInfo{
artist: l.getArtistForScrobble(&s.MediaFile),
artist: l.getArtistForScrobble(&s.MediaFile, model.RoleArtist, s.Artist),
track: s.Title,
album: s.Album,
trackNumber: s.TrackNumber,
mbid: s.MbzRecordingID,
duration: int(s.Duration),
albumArtist: s.AlbumArtist,
albumArtist: l.getArtistForScrobble(&s.MediaFile, model.RoleAlbumArtist, s.AlbumArtist),
timestamp: s.TimeStamp,
})
if err == nil {

View File

@@ -201,6 +201,10 @@ var _ = Describe("lastfmAgent", func() {
{Artist: model.Artist{ID: "ar-1", Name: "First Artist"}},
{Artist: model.Artist{ID: "ar-2", Name: "Second Artist"}},
},
model.RoleAlbumArtist: []model.Participant{
{Artist: model.Artist{ID: "ar-1", Name: "First Album Artist"}},
{Artist: model.Artist{ID: "ar-2", Name: "Second Album Artist"}},
},
},
}
})
@@ -229,6 +233,23 @@ var _ = Describe("lastfmAgent", func() {
err := agent.NowPlaying(ctx, "user-2", track, 0)
Expect(err).To(MatchError(scrobbler.ErrNotAuthorized))
})
When("ScrobbleFirstArtistOnly is true", func() {
BeforeEach(func() {
conf.Server.LastFM.ScrobbleFirstArtistOnly = true
})
It("uses only the first artist", func() {
httpClient.Res = http.Response{Body: io.NopCloser(bytes.NewBufferString("{}")), StatusCode: 200}
err := agent.NowPlaying(ctx, "user-1", track, 0)
Expect(err).ToNot(HaveOccurred())
sentParams := httpClient.SavedRequest.URL.Query()
Expect(sentParams.Get("artist")).To(Equal("First Artist"))
Expect(sentParams.Get("albumArtist")).To(Equal("First Album Artist"))
})
})
})
Describe("scrobble", func() {
@@ -267,6 +288,7 @@ var _ = Describe("lastfmAgent", func() {
Expect(err).ToNot(HaveOccurred())
sentParams := httpClient.SavedRequest.URL.Query()
Expect(sentParams.Get("artist")).To(Equal("First Artist"))
Expect(sentParams.Get("albumArtist")).To(Equal("First Album Artist"))
})
})
@@ -393,4 +415,73 @@ var _ = Describe("lastfmAgent", func() {
})
})
})
Describe("GetArtistImages", func() {
var agent *lastfmAgent
var apiClient *tests.FakeHttpClient
var httpClient *tests.FakeHttpClient
BeforeEach(func() {
apiClient = &tests.FakeHttpClient{}
httpClient = &tests.FakeHttpClient{}
client := newClient("API_KEY", "SECRET", "pt", apiClient)
agent = lastFMConstructor(ds)
agent.client = client
agent.httpClient = httpClient
})
It("returns the artist image from the page", func() {
fApi, _ := os.Open("tests/fixtures/lastfm.artist.getinfo.json")
apiClient.Res = http.Response{Body: fApi, StatusCode: 200}
fScraper, _ := os.Open("tests/fixtures/lastfm.artist.page.html")
httpClient.Res = http.Response{Body: fScraper, StatusCode: 200}
images, err := agent.GetArtistImages(ctx, "123", "U2", "")
Expect(err).ToNot(HaveOccurred())
Expect(images).To(HaveLen(1))
Expect(images[0].URL).To(Equal("https://lastfm.freetls.fastly.net/i/u/ar0/818148bf682d429dc21b59a73ef6f68e.png"))
})
It("returns empty list if image is the ignored default image", func() {
fApi, _ := os.Open("tests/fixtures/lastfm.artist.getinfo.json")
apiClient.Res = http.Response{Body: fApi, StatusCode: 200}
fScraper, _ := os.Open("tests/fixtures/lastfm.artist.page.ignored.html")
httpClient.Res = http.Response{Body: fScraper, StatusCode: 200}
images, err := agent.GetArtistImages(ctx, "123", "U2", "")
Expect(err).ToNot(HaveOccurred())
Expect(images).To(BeEmpty())
})
It("returns empty list if page has no meta tags", func() {
fApi, _ := os.Open("tests/fixtures/lastfm.artist.getinfo.json")
apiClient.Res = http.Response{Body: fApi, StatusCode: 200}
fScraper, _ := os.Open("tests/fixtures/lastfm.artist.page.no_meta.html")
httpClient.Res = http.Response{Body: fScraper, StatusCode: 200}
images, err := agent.GetArtistImages(ctx, "123", "U2", "")
Expect(err).ToNot(HaveOccurred())
Expect(images).To(BeEmpty())
})
It("returns error if API call fails", func() {
apiClient.Err = errors.New("api error")
_, err := agent.GetArtistImages(ctx, "123", "U2", "")
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("get artist info"))
})
It("returns error if scraper call fails", func() {
fApi, _ := os.Open("tests/fixtures/lastfm.artist.getinfo.json")
apiClient.Res = http.Response{Body: fApi, StatusCode: 200}
httpClient.Err = errors.New("scraper error")
_, err := agent.GetArtistImages(ctx, "123", "U2", "")
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("get artist url"))
})
})
})

View File

@@ -90,6 +90,7 @@ var _ = Describe("CacheWarmer", func() {
})
It("deduplicates items in 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-1"))

View File

@@ -1,6 +1,7 @@
package artwork
import (
"cmp"
"context"
"crypto/md5"
"fmt"
@@ -11,6 +12,7 @@ import (
"time"
"github.com/Masterminds/squirrel"
"github.com/maruel/natural"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/core"
"github.com/navidrome/navidrome/core/external"
@@ -116,8 +118,30 @@ func loadAlbumFoldersPaths(ctx context.Context, ds model.DataStore, albums ...mo
}
// Sort image files to ensure consistent selection of cover art
// This prioritizes files from lower-numbered disc folders by sorting the paths
slices.Sort(imgFiles)
// This prioritizes files without numeric suffixes (e.g., cover.jpg over cover.1.jpg)
// by comparing base filenames without extensions
slices.SortFunc(imgFiles, compareImageFiles)
return paths, imgFiles, &updatedAt, nil
}
// compareImageFiles compares two image file paths for sorting.
// It extracts the base filename (without extension) and compares case-insensitively.
// This ensures that "cover.jpg" sorts before "cover.1.jpg" since "cover" < "cover.1".
// Note: This function is called O(n log n) times during sorting, but in practice albums
// typically have only 1-20 image files, making the repeated string operations negligible.
func compareImageFiles(a, b string) int {
// Case-insensitive comparison
a = strings.ToLower(a)
b = strings.ToLower(b)
// Extract base filenames without extensions
baseA := strings.TrimSuffix(filepath.Base(a), filepath.Ext(a))
baseB := strings.TrimSuffix(filepath.Base(b), filepath.Ext(b))
// Compare base names first, then full paths if equal
return cmp.Or(
natural.Compare(baseA, baseB),
natural.Compare(a, b),
)
}

View File

@@ -27,26 +27,7 @@ var _ = Describe("Album Artwork Reader", func() {
expectedAt = now.Add(5 * time.Minute)
// Set up the test folders with image files
repo = &fakeFolderRepo{
result: []model.Folder{
{
Path: "Artist/Album/Disc1",
ImagesUpdatedAt: expectedAt,
ImageFiles: []string{"cover.jpg", "back.jpg"},
},
{
Path: "Artist/Album/Disc2",
ImagesUpdatedAt: now,
ImageFiles: []string{"cover.jpg"},
},
{
Path: "Artist/Album/Disc10",
ImagesUpdatedAt: now,
ImageFiles: []string{"cover.jpg"},
},
},
err: nil,
}
repo = &fakeFolderRepo{}
ds = &fakeDataStore{
folderRepo: repo,
}
@@ -58,19 +39,82 @@ var _ = Describe("Album Artwork Reader", func() {
})
It("returns sorted image files", func() {
repo.result = []model.Folder{
{
Path: "Artist/Album/Disc1",
ImagesUpdatedAt: expectedAt,
ImageFiles: []string{"cover.jpg", "back.jpg", "cover.1.jpg"},
},
{
Path: "Artist/Album/Disc2",
ImagesUpdatedAt: now,
ImageFiles: []string{"cover.jpg"},
},
{
Path: "Artist/Album/Disc10",
ImagesUpdatedAt: now,
ImageFiles: []string{"cover.jpg"},
},
}
_, imgFiles, imagesUpdatedAt, err := loadAlbumFoldersPaths(ctx, ds, album)
Expect(err).ToNot(HaveOccurred())
Expect(*imagesUpdatedAt).To(Equal(expectedAt))
// Check that image files are sorted alphabetically
Expect(imgFiles).To(HaveLen(4))
// Check that image files are sorted by base name (without extension)
Expect(imgFiles).To(HaveLen(5))
// The files should be sorted by full path
// Files should be sorted by base filename without extension, then by full path
// "back" < "cover", so back.jpg comes first
// Then all cover.jpg files, sorted by path
Expect(imgFiles[0]).To(Equal(filepath.FromSlash("Artist/Album/Disc1/back.jpg")))
Expect(imgFiles[1]).To(Equal(filepath.FromSlash("Artist/Album/Disc1/cover.jpg")))
Expect(imgFiles[2]).To(Equal(filepath.FromSlash("Artist/Album/Disc10/cover.jpg")))
Expect(imgFiles[3]).To(Equal(filepath.FromSlash("Artist/Album/Disc2/cover.jpg")))
Expect(imgFiles[2]).To(Equal(filepath.FromSlash("Artist/Album/Disc2/cover.jpg")))
Expect(imgFiles[3]).To(Equal(filepath.FromSlash("Artist/Album/Disc10/cover.jpg")))
Expect(imgFiles[4]).To(Equal(filepath.FromSlash("Artist/Album/Disc1/cover.1.jpg")))
})
It("prioritizes files without numeric suffixes", func() {
// Test case for issue #4683: cover.jpg should come before cover.1.jpg
repo.result = []model.Folder{
{
Path: "Artist/Album",
ImagesUpdatedAt: now,
ImageFiles: []string{"cover.1.jpg", "cover.jpg", "cover.2.jpg"},
},
}
_, imgFiles, _, err := loadAlbumFoldersPaths(ctx, ds, album)
Expect(err).ToNot(HaveOccurred())
Expect(imgFiles).To(HaveLen(3))
// cover.jpg should come first because "cover" < "cover.1" < "cover.2"
Expect(imgFiles[0]).To(Equal(filepath.FromSlash("Artist/Album/cover.jpg")))
Expect(imgFiles[1]).To(Equal(filepath.FromSlash("Artist/Album/cover.1.jpg")))
Expect(imgFiles[2]).To(Equal(filepath.FromSlash("Artist/Album/cover.2.jpg")))
})
It("handles case-insensitive sorting", func() {
// Test that Cover.jpg and cover.jpg are treated as equivalent
repo.result = []model.Folder{
{
Path: "Artist/Album",
ImagesUpdatedAt: now,
ImageFiles: []string{"Folder.jpg", "cover.jpg", "BACK.jpg"},
},
}
_, imgFiles, _, err := loadAlbumFoldersPaths(ctx, ds, album)
Expect(err).ToNot(HaveOccurred())
Expect(imgFiles).To(HaveLen(3))
// Files should be sorted case-insensitively: BACK, cover, Folder
Expect(imgFiles[0]).To(Equal(filepath.FromSlash("Artist/Album/BACK.jpg")))
Expect(imgFiles[1]).To(Equal(filepath.FromSlash("Artist/Album/cover.jpg")))
Expect(imgFiles[2]).To(Equal(filepath.FromSlash("Artist/Album/Folder.jpg")))
})
})
})

View File

@@ -8,6 +8,7 @@ import (
"io/fs"
"os"
"path/filepath"
"slices"
"strings"
"time"
@@ -44,7 +45,7 @@ func newArtistArtworkReader(ctx context.Context, artwork *artwork, artID model.A
als, err := artwork.ds.Album(ctx).GetAll(model.QueryOptions{
Filters: squirrel.And{
squirrel.Eq{"album_artist_id": artID.ID},
squirrel.Eq{"jsonb_array_length(participants->'albumartist')": 1},
squirrel.Eq{"json_array_length(participants, '$.albumartist')": 1},
},
})
if err != nil {
@@ -139,11 +140,22 @@ func findImageInFolder(ctx context.Context, folder, pattern string) (io.ReadClos
return nil, "", err
}
// Filter to valid image files
var imagePaths []string
for _, m := range matches {
if !model.IsImageFile(m) {
continue
}
filePath := filepath.Join(folder, m)
imagePaths = append(imagePaths, m)
}
// Sort image files by prioritizing base filenames without numeric
// suffixes (e.g., artist.jpg before artist.1.jpg)
slices.SortFunc(imagePaths, compareImageFiles)
// Try to open files in sorted order
for _, p := range imagePaths {
filePath := filepath.Join(folder, p)
f, err := os.Open(filePath)
if err != nil {
log.Warn(ctx, "Could not open cover art file", "file", filePath, err)

View File

@@ -240,24 +240,79 @@ var _ = Describe("artistArtworkReader", func() {
Expect(os.MkdirAll(artistDir, 0755)).To(Succeed())
// Create multiple matching files
Expect(os.WriteFile(filepath.Join(artistDir, "artist.jpg"), []byte("jpg image"), 0600)).To(Succeed())
Expect(os.WriteFile(filepath.Join(artistDir, "artist.abc"), []byte("text file"), 0600)).To(Succeed())
Expect(os.WriteFile(filepath.Join(artistDir, "artist.png"), []byte("png image"), 0600)).To(Succeed())
Expect(os.WriteFile(filepath.Join(artistDir, "artist.txt"), []byte("text file"), 0600)).To(Succeed())
Expect(os.WriteFile(filepath.Join(artistDir, "artist.jpg"), []byte("jpg image"), 0600)).To(Succeed())
testFunc = fromArtistFolder(ctx, artistDir, "artist.*")
})
It("returns the first valid image file", func() {
It("returns the first valid image file in sorted order", func() {
reader, path, err := testFunc()
Expect(err).ToNot(HaveOccurred())
Expect(reader).ToNot(BeNil())
// Should return an image file, not the text file
Expect(path).To(SatisfyAny(
ContainSubstring("artist.jpg"),
ContainSubstring("artist.png"),
))
Expect(path).ToNot(ContainSubstring("artist.txt"))
// Should return an image file,
// Files are sorted: jpg comes before png alphabetically.
// .abc comes first, but it's not an image.
Expect(path).To(ContainSubstring("artist.jpg"))
reader.Close()
})
})
When("prioritizing files without numeric suffixes", func() {
BeforeEach(func() {
// Test case for issue #4683: artist.jpg should come before artist.1.jpg
artistDir := filepath.Join(tempDir, "artist")
Expect(os.MkdirAll(artistDir, 0755)).To(Succeed())
// Create multiple matches with and without numeric suffixes
Expect(os.WriteFile(filepath.Join(artistDir, "artist.1.jpg"), []byte("artist 1"), 0600)).To(Succeed())
Expect(os.WriteFile(filepath.Join(artistDir, "artist.jpg"), []byte("artist main"), 0600)).To(Succeed())
Expect(os.WriteFile(filepath.Join(artistDir, "artist.2.jpg"), []byte("artist 2"), 0600)).To(Succeed())
testFunc = fromArtistFolder(ctx, artistDir, "artist.*")
})
It("returns artist.jpg before artist.1.jpg and artist.2.jpg", func() {
reader, path, err := testFunc()
Expect(err).ToNot(HaveOccurred())
Expect(reader).ToNot(BeNil())
Expect(path).To(ContainSubstring("artist.jpg"))
// Verify it's the main file, not a numbered variant
data, err := io.ReadAll(reader)
Expect(err).ToNot(HaveOccurred())
Expect(string(data)).To(Equal("artist main"))
reader.Close()
})
})
When("handling case-insensitive sorting", func() {
BeforeEach(func() {
// Test case to ensure case-insensitive natural sorting
artistDir := filepath.Join(tempDir, "artist")
Expect(os.MkdirAll(artistDir, 0755)).To(Succeed())
// Create files with mixed case names
Expect(os.WriteFile(filepath.Join(artistDir, "Folder.jpg"), []byte("folder"), 0600)).To(Succeed())
Expect(os.WriteFile(filepath.Join(artistDir, "artist.jpg"), []byte("artist"), 0600)).To(Succeed())
Expect(os.WriteFile(filepath.Join(artistDir, "BACK.jpg"), []byte("back"), 0600)).To(Succeed())
testFunc = fromArtistFolder(ctx, artistDir, "*.*")
})
It("sorts case-insensitively", func() {
reader, path, err := testFunc()
Expect(err).ToNot(HaveOccurred())
Expect(reader).ToNot(BeNil())
// Should return artist.jpg first (case-insensitive: "artist" < "back" < "folder")
Expect(path).To(ContainSubstring("artist.jpg"))
data, err := io.ReadAll(reader)
Expect(err).ToNot(HaveOccurred())
Expect(string(data)).To(Equal("artist"))
reader.Close()
})
})

View File

@@ -182,6 +182,7 @@ func fromAlbumExternalSource(ctx context.Context, al model.Album, provider exter
func fromURL(ctx context.Context, imageUrl *url.URL) (io.ReadCloser, string, error) {
hc := http.Client{Timeout: 5 * time.Second}
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, imageUrl.String(), nil)
req.Header.Set("User-Agent", consts.HTTPUserAgent)
resp, err := hc.Do(req)
if err != nil {
return nil, "", err

View File

@@ -113,9 +113,9 @@ func WithAdminUser(ctx context.Context, ds model.DataStore) context.Context {
if err != nil {
c, err := ds.User(ctx).CountAll()
if c == 0 && err == nil {
log.Debug(ctx, "Scanner: No admin user yet!", err)
log.Debug(ctx, "No admin user yet!", err)
} else {
log.Error(ctx, "Scanner: No admin user found!", err)
log.Error(ctx, "No admin user found!", err)
}
u = &model.User{}
}

View File

@@ -51,12 +51,28 @@ type provider struct {
type auxAlbum struct {
model.Album
Name string
}
// Name returns the appropriate album name for external API calls
// based on the DevPreserveUnicodeInExternalCalls configuration option
func (a *auxAlbum) Name() string {
if conf.Server.DevPreserveUnicodeInExternalCalls {
return a.Album.Name
}
return str.Clear(a.Album.Name)
}
type auxArtist struct {
model.Artist
Name string
}
// Name returns the appropriate artist name for external API calls
// based on the DevPreserveUnicodeInExternalCalls configuration option
func (a *auxArtist) Name() string {
if conf.Server.DevPreserveUnicodeInExternalCalls {
return a.Artist.Name
}
return str.Clear(a.Artist.Name)
}
type Agents interface {
@@ -88,7 +104,6 @@ func (e *provider) getAlbum(ctx context.Context, id string) (auxAlbum, error) {
switch v := entity.(type) {
case *model.Album:
album.Album = *v
album.Name = str.Clear(v.Name)
case *model.MediaFile:
return e.getAlbum(ctx, v.AlbumID)
default:
@@ -106,8 +121,9 @@ func (e *provider) UpdateAlbumInfo(ctx context.Context, id string) (*model.Album
}
updatedAt := V(album.ExternalInfoUpdatedAt)
albumName := album.Name()
if updatedAt.IsZero() {
log.Debug(ctx, "AlbumInfo not cached. Retrieving it now", "updatedAt", updatedAt, "id", id, "name", album.Name)
log.Debug(ctx, "AlbumInfo not cached. Retrieving it now", "updatedAt", updatedAt, "id", id, "name", albumName)
album, err = e.populateAlbumInfo(ctx, album)
if err != nil {
return nil, err
@@ -116,7 +132,7 @@ func (e *provider) UpdateAlbumInfo(ctx context.Context, id string) (*model.Album
// If info is expired, trigger a populateAlbumInfo in the background
if time.Since(updatedAt) > conf.Server.DevAlbumInfoTimeToLive {
log.Debug("Found expired cached AlbumInfo, refreshing in the background", "updatedAt", album.ExternalInfoUpdatedAt, "name", album.Name)
log.Debug("Found expired cached AlbumInfo, refreshing in the background", "updatedAt", album.ExternalInfoUpdatedAt, "name", albumName)
e.albumQueue.enqueue(&album)
}
@@ -125,12 +141,13 @@ func (e *provider) UpdateAlbumInfo(ctx context.Context, id string) (*model.Album
func (e *provider) populateAlbumInfo(ctx context.Context, album auxAlbum) (auxAlbum, error) {
start := time.Now()
info, err := e.ag.GetAlbumInfo(ctx, album.Name, album.AlbumArtist, album.MbzAlbumID)
albumName := album.Name()
info, err := e.ag.GetAlbumInfo(ctx, albumName, album.AlbumArtist, album.MbzAlbumID)
if errors.Is(err, agents.ErrNotFound) {
return album, nil
}
if err != nil {
log.Error("Error refreshing AlbumInfo", "id", album.ID, "name", album.Name, "artist", album.AlbumArtist,
log.Error("Error refreshing AlbumInfo", "id", album.ID, "name", albumName, "artist", album.AlbumArtist,
"elapsed", time.Since(start), err)
return album, err
}
@@ -142,7 +159,7 @@ func (e *provider) populateAlbumInfo(ctx context.Context, album auxAlbum) (auxAl
album.Description = info.Description
}
images, err := e.ag.GetAlbumImages(ctx, album.Name, album.AlbumArtist, album.MbzAlbumID)
images, err := e.ag.GetAlbumImages(ctx, albumName, album.AlbumArtist, album.MbzAlbumID)
if err == nil && len(images) > 0 {
sort.Slice(images, func(i, j int) bool {
return images[i].Size > images[j].Size
@@ -161,7 +178,7 @@ func (e *provider) populateAlbumInfo(ctx context.Context, album auxAlbum) (auxAl
err = e.ds.Album(ctx).UpdateExternalInfo(&album.Album)
if err != nil {
log.Error(ctx, "Error trying to update album external information", "id", album.ID, "name", album.Name,
log.Error(ctx, "Error trying to update album external information", "id", album.ID, "name", albumName,
"elapsed", time.Since(start), err)
} else {
log.Trace(ctx, "AlbumInfo collected", "album", album, "elapsed", time.Since(start))
@@ -181,7 +198,6 @@ func (e *provider) getArtist(ctx context.Context, id string) (auxArtist, error)
switch v := entity.(type) {
case *model.Artist:
artist.Artist = *v
artist.Name = str.Clear(v.Name)
case *model.MediaFile:
return e.getArtist(ctx, v.ArtistID)
case *model.Album:
@@ -210,8 +226,9 @@ func (e *provider) refreshArtistInfo(ctx context.Context, id string) (auxArtist,
// If we don't have any info, retrieves it now
updatedAt := V(artist.ExternalInfoUpdatedAt)
artistName := artist.Name()
if updatedAt.IsZero() {
log.Debug(ctx, "ArtistInfo not cached. Retrieving it now", "updatedAt", updatedAt, "id", id, "name", artist.Name)
log.Debug(ctx, "ArtistInfo not cached. Retrieving it now", "updatedAt", updatedAt, "id", id, "name", artistName)
artist, err = e.populateArtistInfo(ctx, artist)
if err != nil {
return auxArtist{}, err
@@ -220,7 +237,7 @@ func (e *provider) refreshArtistInfo(ctx context.Context, id string) (auxArtist,
// If info is expired, trigger a populateArtistInfo in the background
if time.Since(updatedAt) > conf.Server.DevArtistInfoTimeToLive {
log.Debug("Found expired cached ArtistInfo, refreshing in the background", "updatedAt", updatedAt, "name", artist.Name)
log.Debug("Found expired cached ArtistInfo, refreshing in the background", "updatedAt", updatedAt, "name", artistName)
e.artistQueue.enqueue(&artist)
}
return artist, nil
@@ -229,8 +246,9 @@ func (e *provider) refreshArtistInfo(ctx context.Context, id string) (auxArtist,
func (e *provider) populateArtistInfo(ctx context.Context, artist auxArtist) (auxArtist, error) {
start := time.Now()
// Get MBID first, if it is not yet available
artistName := artist.Name()
if artist.MbzArtistID == "" {
mbid, err := e.ag.GetArtistMBID(ctx, artist.ID, artist.Name)
mbid, err := e.ag.GetArtistMBID(ctx, artist.ID, artistName)
if mbid != "" && err == nil {
artist.MbzArtistID = mbid
}
@@ -246,14 +264,14 @@ func (e *provider) populateArtistInfo(ctx context.Context, artist auxArtist) (au
_ = g.Wait()
if utils.IsCtxDone(ctx) {
log.Warn(ctx, "ArtistInfo update canceled", "elapsed", "id", artist.ID, "name", artist.Name, time.Since(start), ctx.Err())
log.Warn(ctx, "ArtistInfo update canceled", "id", artist.ID, "name", artistName, "elapsed", time.Since(start), ctx.Err())
return artist, ctx.Err()
}
artist.ExternalInfoUpdatedAt = P(time.Now())
err := e.ds.Artist(ctx).UpdateExternalInfo(&artist.Artist)
if err != nil {
log.Error(ctx, "Error trying to update artist external information", "id", artist.ID, "name", artist.Name,
log.Error(ctx, "Error trying to update artist external information", "id", artist.ID, "name", artistName,
"elapsed", time.Since(start), err)
} else {
log.Trace(ctx, "ArtistInfo collected", "artist", artist, "elapsed", time.Since(start))
@@ -281,7 +299,7 @@ func (e *provider) ArtistRadio(ctx context.Context, id string, count int) (model
}
topCount := max(count, 20)
topSongs, err := e.getMatchingTopSongs(ctx, e.ag, &auxArtist{Name: a.Name, Artist: a}, topCount)
topSongs, err := e.getMatchingTopSongs(ctx, e.ag, &auxArtist{Artist: a}, topCount)
if err != nil {
log.Warn(ctx, "Error getting artist's top songs", "artist", a.Name, err)
return nil
@@ -344,22 +362,23 @@ func (e *provider) AlbumImage(ctx context.Context, id string) (*url.URL, error)
return nil, err
}
images, err := e.ag.GetAlbumImages(ctx, album.Name, album.AlbumArtist, album.MbzAlbumID)
albumName := album.Name()
images, err := e.ag.GetAlbumImages(ctx, albumName, album.AlbumArtist, album.MbzAlbumID)
if err != nil {
switch {
case errors.Is(err, agents.ErrNotFound):
log.Trace(ctx, "Album not found in agent", "albumID", id, "name", album.Name, "artist", album.AlbumArtist)
log.Trace(ctx, "Album not found in agent", "albumID", id, "name", albumName, "artist", album.AlbumArtist)
return nil, model.ErrNotFound
case errors.Is(err, context.Canceled):
log.Debug(ctx, "GetAlbumImages call canceled", err)
default:
log.Warn(ctx, "Error getting album images from agent", "albumID", id, "name", album.Name, "artist", album.AlbumArtist, err)
log.Warn(ctx, "Error getting album images from agent", "albumID", id, "name", albumName, "artist", album.AlbumArtist, err)
}
return nil, err
}
if len(images) == 0 {
log.Warn(ctx, "Agent returned no images without error", "albumID", id, "name", album.Name, "artist", album.AlbumArtist)
log.Warn(ctx, "Agent returned no images without error", "albumID", id, "name", albumName, "artist", album.AlbumArtist)
return nil, model.ErrNotFound
}
@@ -401,9 +420,10 @@ func (e *provider) TopSongs(ctx context.Context, artistName string, count int) (
}
func (e *provider) getMatchingTopSongs(ctx context.Context, agent agents.ArtistTopSongsRetriever, artist *auxArtist, count int) (model.MediaFiles, error) {
songs, err := agent.GetArtistTopSongs(ctx, artist.ID, artist.Name, artist.MbzArtistID, count)
artistName := artist.Name()
songs, err := agent.GetArtistTopSongs(ctx, artist.ID, artistName, artist.MbzArtistID, count)
if err != nil {
return nil, fmt.Errorf("failed to get top songs for artist %s: %w", artist.Name, err)
return nil, fmt.Errorf("failed to get top songs for artist %s: %w", artistName, err)
}
mbidMatches, err := e.loadTracksByMBID(ctx, songs)
@@ -415,13 +435,13 @@ func (e *provider) getMatchingTopSongs(ctx context.Context, agent agents.ArtistT
return nil, fmt.Errorf("failed to load tracks by title: %w", err)
}
log.Trace(ctx, "Top Songs loaded", "name", artist.Name, "numSongs", len(songs), "numMBIDMatches", len(mbidMatches), "numTitleMatches", len(titleMatches))
log.Trace(ctx, "Top Songs loaded", "name", artistName, "numSongs", len(songs), "numMBIDMatches", len(mbidMatches), "numTitleMatches", len(titleMatches))
mfs := e.selectTopSongs(songs, mbidMatches, titleMatches, count)
if len(mfs) == 0 {
log.Debug(ctx, "No matching top songs found", "name", artist.Name)
log.Debug(ctx, "No matching top songs found", "name", artistName)
} else {
log.Debug(ctx, "Found matching top songs", "name", artist.Name, "numSongs", len(mfs))
log.Debug(ctx, "Found matching top songs", "name", artistName, "numSongs", len(mfs))
}
return mfs, nil
@@ -518,7 +538,7 @@ func (e *provider) selectTopSongs(songs []agents.Song, byMBID, byTitle map[strin
}
func (e *provider) callGetURL(ctx context.Context, agent agents.ArtistURLRetriever, artist *auxArtist) {
artisURL, err := agent.GetArtistURL(ctx, artist.ID, artist.Name, artist.MbzArtistID)
artisURL, err := agent.GetArtistURL(ctx, artist.ID, artist.Name(), artist.MbzArtistID)
if err != nil {
return
}
@@ -526,7 +546,7 @@ func (e *provider) callGetURL(ctx context.Context, agent agents.ArtistURLRetriev
}
func (e *provider) callGetBiography(ctx context.Context, agent agents.ArtistBiographyRetriever, artist *auxArtist) {
bio, err := agent.GetArtistBiography(ctx, artist.ID, str.Clear(artist.Name), artist.MbzArtistID)
bio, err := agent.GetArtistBiography(ctx, artist.ID, artist.Name(), artist.MbzArtistID)
if err != nil {
return
}
@@ -536,7 +556,7 @@ func (e *provider) callGetBiography(ctx context.Context, agent agents.ArtistBiog
}
func (e *provider) callGetImage(ctx context.Context, agent agents.ArtistImageRetriever, artist *auxArtist) {
images, err := agent.GetArtistImages(ctx, artist.ID, artist.Name, artist.MbzArtistID)
images, err := agent.GetArtistImages(ctx, artist.ID, artist.Name(), artist.MbzArtistID)
if err != nil {
return
}
@@ -555,13 +575,14 @@ func (e *provider) callGetImage(ctx context.Context, agent agents.ArtistImageRet
func (e *provider) callGetSimilar(ctx context.Context, agent agents.ArtistSimilarRetriever, artist *auxArtist,
limit int, includeNotPresent bool) {
similar, err := agent.GetSimilarArtists(ctx, artist.ID, artist.Name, artist.MbzArtistID, limit)
artistName := artist.Name()
similar, err := agent.GetSimilarArtists(ctx, artist.ID, artistName, artist.MbzArtistID, limit)
if len(similar) == 0 || err != nil {
return
}
start := time.Now()
sa, err := e.mapSimilarArtists(ctx, similar, limit, includeNotPresent)
log.Debug(ctx, "Mapped Similar Artists", "artist", artist.Name, "numSimilar", len(sa), "elapsed", time.Since(start))
log.Debug(ctx, "Mapped Similar Artists", "artist", artistName, "numSimilar", len(sa), "elapsed", time.Since(start))
if err != nil {
return
}
@@ -635,11 +656,7 @@ func (e *provider) findArtistByName(ctx context.Context, artistName string) (*au
if len(artists) == 0 {
return nil, model.ErrNotFound
}
artist := &auxArtist{
Artist: artists[0],
Name: str.Clear(artists[0].Name),
}
return artist, nil
return &auxArtist{Artist: artists[0]}, nil
}
func (e *provider) loadSimilar(ctx context.Context, artist *auxArtist, count int, includeNotPresent bool) error {
@@ -655,7 +672,7 @@ func (e *provider) loadSimilar(ctx context.Context, artist *auxArtist, count int
Filters: squirrel.Eq{"artist.id": ids},
})
if err != nil {
log.Error("Error loading similar artists", "id", artist.ID, "name", artist.Name, err)
log.Error("Error loading similar artists", "id", artist.ID, "name", artist.Name(), err)
return err
}

View File

@@ -260,6 +260,69 @@ var _ = Describe("Provider - AlbumImage", func() {
mockMediaFileRepo.AssertCalled(GinkgoT(), "Get", "not-found")
mockAlbumAgent.AssertNotCalled(GinkgoT(), "GetAlbumImages", mock.Anything, mock.Anything, mock.Anything)
})
Context("Unicode handling in album names", func() {
var albumWithEnDash *model.Album
var expectedURL *url.URL
const (
originalAlbumName = "Raising HellDeluxe" // Album name with en dash
normalizedAlbumName = "Raising Hell-Deluxe" // Normalized version with hyphen
)
BeforeEach(func() {
// Test with en dash () in album name
albumWithEnDash = &model.Album{ID: "album-endash", Name: originalAlbumName, AlbumArtistID: "artist-1"}
mockArtistRepo.Mock = mock.Mock{} // Reset default expectations
mockAlbumRepo.Mock = mock.Mock{} // Reset default expectations
mockArtistRepo.On("Get", "album-endash").Return(nil, model.ErrNotFound).Once()
mockAlbumRepo.On("Get", "album-endash").Return(albumWithEnDash, nil).Once()
expectedURL, _ = url.Parse("http://example.com/album.jpg")
// Mock the album agent to return an image for the album
mockAlbumAgent.On("GetAlbumImages", ctx, mock.AnythingOfType("string"), "", "").
Return([]agents.ExternalImage{
{URL: "http://example.com/album.jpg", Size: 1000},
}, nil).Once()
})
When("DevPreserveUnicodeInExternalCalls is true", func() {
BeforeEach(func() {
conf.Server.DevPreserveUnicodeInExternalCalls = true
})
It("preserves Unicode characters in album names", func() {
// Act
imgURL, err := provider.AlbumImage(ctx, "album-endash")
// Assert
Expect(err).ToNot(HaveOccurred())
Expect(imgURL).To(Equal(expectedURL))
mockAlbumRepo.AssertCalled(GinkgoT(), "Get", "album-endash")
// This is the key assertion: ensure the original Unicode name is used
mockAlbumAgent.AssertCalled(GinkgoT(), "GetAlbumImages", ctx, originalAlbumName, "", "")
})
})
When("DevPreserveUnicodeInExternalCalls is false", func() {
BeforeEach(func() {
conf.Server.DevPreserveUnicodeInExternalCalls = false
})
It("normalizes Unicode characters", func() {
// Act
imgURL, err := provider.AlbumImage(ctx, "album-endash")
// Assert
Expect(err).ToNot(HaveOccurred())
Expect(imgURL).To(Equal(expectedURL))
mockAlbumRepo.AssertCalled(GinkgoT(), "Get", "album-endash")
// This assertion ensures the normalized name is used (en dash → hyphen)
mockAlbumAgent.AssertCalled(GinkgoT(), "GetAlbumImages", ctx, normalizedAlbumName, "", "")
})
})
})
})
// mockAlbumInfoAgent implementation

View File

@@ -265,6 +265,67 @@ var _ = Describe("Provider - ArtistImage", func() {
mockArtistRepo.AssertCalled(GinkgoT(), "Get", "artist-1")
mockImageAgent.AssertCalled(GinkgoT(), "GetArtistImages", ctx, "artist-1", "Artist One", "")
})
Context("Unicode handling in artist names", func() {
var artistWithEnDash *model.Artist
var expectedURL *url.URL
const (
originalArtistName = "RunD.M.C." // Artist name with en dash
normalizedArtistName = "Run-D.M.C." // Normalized version with hyphen
)
BeforeEach(func() {
// Test with en dash () in artist name like "RunD.M.C."
artistWithEnDash = &model.Artist{ID: "artist-endash", Name: originalArtistName}
mockArtistRepo.Mock = mock.Mock{} // Reset default expectations
mockArtistRepo.On("Get", "artist-endash").Return(artistWithEnDash, nil).Once()
expectedURL, _ = url.Parse("http://example.com/rundmc.jpg")
// Mock the image agent to return an image for the artist
mockImageAgent.On("GetArtistImages", ctx, "artist-endash", mock.AnythingOfType("string"), "").
Return([]agents.ExternalImage{
{URL: "http://example.com/rundmc.jpg", Size: 1000},
}, nil).Once()
})
When("DevPreserveUnicodeInExternalCalls is true", func() {
BeforeEach(func() {
conf.Server.DevPreserveUnicodeInExternalCalls = true
})
It("preserves Unicode characters in artist names", func() {
// Act
imgURL, err := provider.ArtistImage(ctx, "artist-endash")
// Assert
Expect(err).ToNot(HaveOccurred())
Expect(imgURL).To(Equal(expectedURL))
mockArtistRepo.AssertCalled(GinkgoT(), "Get", "artist-endash")
// This is the key assertion: ensure the original Unicode name is used
mockImageAgent.AssertCalled(GinkgoT(), "GetArtistImages", ctx, "artist-endash", originalArtistName, "")
})
})
When("DevPreserveUnicodeInExternalCalls is false", func() {
BeforeEach(func() {
conf.Server.DevPreserveUnicodeInExternalCalls = false
})
It("normalizes Unicode characters", func() {
// Act
imgURL, err := provider.ArtistImage(ctx, "artist-endash")
// Assert
Expect(err).ToNot(HaveOccurred())
Expect(imgURL).To(Equal(expectedURL))
mockArtistRepo.AssertCalled(GinkgoT(), "Get", "artist-endash")
// This assertion ensures the normalized name is used (en dash → hyphen)
mockImageAgent.AssertCalled(GinkgoT(), "GetArtistImages", ctx, "artist-endash", normalizedArtistName, "")
})
})
})
})
// mockArtistImageAgent implementation using testify/mock

View File

@@ -112,7 +112,7 @@ func (e *ffmpeg) start(ctx context.Context, args []string) (io.ReadCloser, error
log.Trace(ctx, "Executing ffmpeg command", "cmd", args)
j := &ffCmd{args: args}
j.PipeReader, j.out = io.Pipe()
err := j.start()
err := j.start(ctx)
if err != nil {
return nil, err
}
@@ -127,8 +127,8 @@ type ffCmd struct {
cmd *exec.Cmd
}
func (j *ffCmd) start() error {
cmd := exec.Command(j.args[0], j.args[1:]...) // #nosec
func (j *ffCmd) start(ctx context.Context) error {
cmd := exec.CommandContext(ctx, j.args[0], j.args[1:]...) // #nosec
cmd.Stdout = j.out
if log.IsGreaterOrEqualTo(log.LevelTrace) {
cmd.Stderr = os.Stderr

View File

@@ -1,7 +1,11 @@
package ffmpeg
import (
"context"
"runtime"
sync "sync"
"testing"
"time"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/tests"
@@ -65,4 +69,98 @@ var _ = Describe("ffmpeg", func() {
Expect(args).To(Equal([]string{"/usr/bin/with spaces/ffmpeg.exe", "-i", "one.mp3", "-f", "ffmetadata"}))
})
})
Describe("FFmpeg", func() {
Context("when FFmpeg is available", func() {
var ff FFmpeg
BeforeEach(func() {
ffOnce = sync.Once{}
ff = New()
// Skip if FFmpeg is not available
if !ff.IsAvailable() {
Skip("FFmpeg not available on this system")
}
})
It("should interrupt transcoding when context is cancelled", func() {
ctx, cancel := context.WithTimeout(GinkgoT().Context(), 5*time.Second)
defer cancel()
// Use a command that generates audio indefinitely
// -f lavfi uses FFmpeg's built-in audio source
// -t 0 means no time limit (runs forever)
command := "ffmpeg -f lavfi -i sine=frequency=1000:duration=0 -f mp3 -"
// The input file is not used here, but we need to provide a valid path to the Transcode function
stream, err := ff.Transcode(ctx, command, "tests/fixtures/test.mp3", 128, 0)
Expect(err).ToNot(HaveOccurred())
defer stream.Close()
// Read some data first to ensure FFmpeg is running
buf := make([]byte, 1024)
_, err = stream.Read(buf)
Expect(err).ToNot(HaveOccurred())
// Cancel the context
cancel()
// Next read should fail due to cancelled context
_, err = stream.Read(buf)
Expect(err).To(HaveOccurred())
})
It("should handle immediate context cancellation", func() {
ctx, cancel := context.WithCancel(GinkgoT().Context())
cancel() // Cancel immediately
// This should fail immediately
_, err := ff.Transcode(ctx, "ffmpeg -i %s -f mp3 -", "tests/fixtures/test.mp3", 128, 0)
Expect(err).To(MatchError(context.Canceled))
})
})
Context("with mock process behavior", func() {
var longRunningCmd string
BeforeEach(func() {
// Use a long-running command for testing cancellation
switch runtime.GOOS {
case "windows":
// Use PowerShell's Start-Sleep
ffmpegPath = "powershell"
longRunningCmd = "powershell -Command Start-Sleep -Seconds 10"
default:
// Use sleep on Unix-like systems
ffmpegPath = "sleep"
longRunningCmd = "sleep 10"
}
})
It("should terminate the underlying process when context is cancelled", func() {
ff := New()
ctx, cancel := context.WithTimeout(GinkgoT().Context(), 5*time.Second)
defer cancel()
// Start a process that will run for a while
stream, err := ff.Transcode(ctx, longRunningCmd, "tests/fixtures/test.mp3", 0, 0)
Expect(err).ToNot(HaveOccurred())
defer stream.Close()
// Give the process time to start
time.Sleep(50 * time.Millisecond)
// Cancel the context
cancel()
// Try to read from the stream, which should fail
buf := make([]byte, 100)
_, err = stream.Read(buf)
Expect(err).To(HaveOccurred(), "Expected stream to be closed due to process termination")
// Verify the stream is closed by attempting another read
_, err = stream.Read(buf)
Expect(err).To(HaveOccurred())
})
})
})
})

View File

@@ -21,11 +21,6 @@ import (
"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
@@ -43,13 +38,13 @@ type Library interface {
type libraryService struct {
ds model.DataStore
scanner Scanner
scanner model.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 {
func NewLibrary(ds model.DataStore, scanner model.Scanner, watcher Watcher, broker events.Broker) Library {
return &libraryService{
ds: ds,
scanner: scanner,
@@ -155,7 +150,7 @@ type libraryRepositoryWrapper struct {
model.LibraryRepository
ctx context.Context
ds model.DataStore
scanner Scanner
scanner model.Scanner
watcher Watcher
broker events.Broker
}
@@ -192,7 +187,7 @@ func (r *libraryRepositoryWrapper) Save(entity interface{}) (string, error) {
return strconv.Itoa(lib.ID), nil
}
func (r *libraryRepositoryWrapper) Update(id string, entity interface{}, cols ...string) error {
func (r *libraryRepositoryWrapper) Update(id string, entity interface{}, _ ...string) error {
lib := entity.(*model.Library)
libID, err := strconv.Atoi(id)
if err != nil {

View File

@@ -29,7 +29,7 @@ var _ = Describe("Library Service", func() {
var userRepo *tests.MockedUserRepo
var ctx context.Context
var tempDir string
var scanner *mockScanner
var scanner *tests.MockScanner
var watcherManager *mockWatcherManager
var broker *mockEventBroker
@@ -43,7 +43,7 @@ var _ = Describe("Library Service", func() {
ds.MockedUser = userRepo
// Create a mock scanner that tracks calls
scanner = &mockScanner{}
scanner = tests.NewMockScanner()
// Create a mock watcher manager
watcherManager = &mockWatcherManager{
libraryStates: make(map[int]model.Library),
@@ -616,11 +616,12 @@ var _ = Describe("Library Service", func() {
// Wait briefly for the goroutine to complete
Eventually(func() int {
return scanner.len()
return scanner.GetScanAllCallCount()
}, "1s", "10ms").Should(Equal(1))
// Verify scan was called with correct parameters
Expect(scanner.ScanCalls[0].FullScan).To(BeFalse()) // Should be quick scan
calls := scanner.GetScanAllCalls()
Expect(calls[0].FullScan).To(BeFalse()) // Should be quick scan
})
It("triggers scan when updating library path", func() {
@@ -641,11 +642,12 @@ var _ = Describe("Library Service", func() {
// Wait briefly for the goroutine to complete
Eventually(func() int {
return scanner.len()
return scanner.GetScanAllCallCount()
}, "1s", "10ms").Should(Equal(1))
// Verify scan was called with correct parameters
Expect(scanner.ScanCalls[0].FullScan).To(BeFalse()) // Should be quick scan
calls := scanner.GetScanAllCalls()
Expect(calls[0].FullScan).To(BeFalse()) // Should be quick scan
})
It("does not trigger scan when updating library without path change", func() {
@@ -661,7 +663,7 @@ var _ = Describe("Library Service", func() {
// Wait a bit to ensure no scan was triggered
Consistently(func() int {
return scanner.len()
return scanner.GetScanAllCallCount()
}, "100ms", "10ms").Should(Equal(0))
})
@@ -674,7 +676,7 @@ var _ = Describe("Library Service", func() {
// Ensure no scan was triggered since creation failed
Consistently(func() int {
return scanner.len()
return scanner.GetScanAllCallCount()
}, "100ms", "10ms").Should(Equal(0))
})
@@ -691,7 +693,7 @@ var _ = Describe("Library Service", func() {
// Ensure no scan was triggered since update failed
Consistently(func() int {
return scanner.len()
return scanner.GetScanAllCallCount()
}, "100ms", "10ms").Should(Equal(0))
})
@@ -707,11 +709,12 @@ var _ = Describe("Library Service", func() {
// Wait briefly for the goroutine to complete
Eventually(func() int {
return scanner.len()
return scanner.GetScanAllCallCount()
}, "1s", "10ms").Should(Equal(1))
// Verify scan was called with correct parameters
Expect(scanner.ScanCalls[0].FullScan).To(BeFalse()) // Should be quick scan
calls := scanner.GetScanAllCalls()
Expect(calls[0].FullScan).To(BeFalse()) // Should be quick scan
})
It("does not trigger scan when library deletion fails", func() {
@@ -721,7 +724,7 @@ var _ = Describe("Library Service", func() {
// Ensure no scan was triggered since deletion failed
Consistently(func() int {
return scanner.len()
return scanner.GetScanAllCallCount()
}, "100ms", "10ms").Should(Equal(0))
})
@@ -868,31 +871,6 @@ var _ = Describe("Library Service", func() {
})
})
// 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

226
core/maintenance.go Normal file
View File

@@ -0,0 +1,226 @@
package core
import (
"context"
"fmt"
"slices"
"sync"
"time"
"github.com/Masterminds/squirrel"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/request"
"github.com/navidrome/navidrome/utils/slice"
)
type Maintenance interface {
// DeleteMissingFiles deletes specific missing files by their IDs
DeleteMissingFiles(ctx context.Context, ids []string) error
// DeleteAllMissingFiles deletes all files marked as missing
DeleteAllMissingFiles(ctx context.Context) error
}
type maintenanceService struct {
ds model.DataStore
wg sync.WaitGroup
}
func NewMaintenance(ds model.DataStore) Maintenance {
return &maintenanceService{
ds: ds,
}
}
func (s *maintenanceService) DeleteMissingFiles(ctx context.Context, ids []string) error {
return s.deleteMissing(ctx, ids)
}
func (s *maintenanceService) DeleteAllMissingFiles(ctx context.Context) error {
return s.deleteMissing(ctx, nil)
}
// deleteMissing handles the deletion of missing files and triggers necessary cleanup operations
func (s *maintenanceService) deleteMissing(ctx context.Context, ids []string) error {
// Track affected album IDs before deletion for refresh
affectedAlbumIDs, err := s.getAffectedAlbumIDs(ctx, ids)
if err != nil {
log.Warn(ctx, "Error tracking affected albums for refresh", err)
// Don't fail the operation, just log the warning
}
// Delete missing files within a transaction
err = s.ds.WithTx(func(tx model.DataStore) error {
if len(ids) == 0 {
_, err := tx.MediaFile(ctx).DeleteAllMissing()
return err
}
return tx.MediaFile(ctx).DeleteMissing(ids)
})
if err != nil {
log.Error(ctx, "Error deleting missing tracks from DB", "ids", ids, err)
return err
}
// Run garbage collection to clean up orphaned records
if err := s.ds.GC(ctx); err != nil {
log.Error(ctx, "Error running GC after deleting missing tracks", err)
return err
}
// Refresh statistics in background
s.refreshStatsAsync(ctx, affectedAlbumIDs)
return nil
}
// refreshAlbums recalculates album attributes (size, duration, song count, etc.) from media files.
// It uses batch queries to minimize database round-trips for efficiency.
func (s *maintenanceService) refreshAlbums(ctx context.Context, albumIDs []string) error {
if len(albumIDs) == 0 {
return nil
}
log.Debug(ctx, "Refreshing albums", "count", len(albumIDs))
// Process in chunks to avoid query size limits
const chunkSize = 100
for chunk := range slice.CollectChunks(slices.Values(albumIDs), chunkSize) {
if err := s.refreshAlbumChunk(ctx, chunk); err != nil {
return fmt.Errorf("refreshing album chunk: %w", err)
}
}
log.Debug(ctx, "Successfully refreshed albums", "count", len(albumIDs))
return nil
}
// refreshAlbumChunk processes a single chunk of album IDs
func (s *maintenanceService) refreshAlbumChunk(ctx context.Context, albumIDs []string) error {
albumRepo := s.ds.Album(ctx)
mfRepo := s.ds.MediaFile(ctx)
// Batch load existing albums
albums, err := albumRepo.GetAll(model.QueryOptions{
Filters: squirrel.Eq{"album.id": albumIDs},
})
if err != nil {
return fmt.Errorf("loading albums: %w", err)
}
// Create a map for quick lookup
albumMap := make(map[string]*model.Album, len(albums))
for i := range albums {
albumMap[albums[i].ID] = &albums[i]
}
// Batch load all media files for these albums
mediaFiles, err := mfRepo.GetAll(model.QueryOptions{
Filters: squirrel.Eq{"album_id": albumIDs},
Sort: "album_id, path",
})
if err != nil {
return fmt.Errorf("loading media files: %w", err)
}
// Group media files by album ID
filesByAlbum := make(map[string]model.MediaFiles)
for i := range mediaFiles {
albumID := mediaFiles[i].AlbumID
filesByAlbum[albumID] = append(filesByAlbum[albumID], mediaFiles[i])
}
// Recalculate each album from its media files
for albumID, oldAlbum := range albumMap {
mfs, hasTracks := filesByAlbum[albumID]
if !hasTracks {
// Album has no tracks anymore, skip (will be cleaned up by GC)
log.Debug(ctx, "Skipping album with no tracks", "albumID", albumID)
continue
}
// Recalculate album from media files
newAlbum := mfs.ToAlbum()
// Only update if something changed (avoid unnecessary writes)
if !oldAlbum.Equals(newAlbum) {
// Preserve original timestamps
newAlbum.UpdatedAt = time.Now()
newAlbum.CreatedAt = oldAlbum.CreatedAt
if err := albumRepo.Put(&newAlbum); err != nil {
log.Error(ctx, "Error updating album during refresh", "albumID", albumID, err)
// Continue with other albums instead of failing entirely
continue
}
log.Trace(ctx, "Refreshed album", "albumID", albumID, "name", newAlbum.Name)
}
}
return nil
}
// getAffectedAlbumIDs returns distinct album IDs from missing media files
func (s *maintenanceService) getAffectedAlbumIDs(ctx context.Context, ids []string) ([]string, error) {
var filters squirrel.Sqlizer = squirrel.Eq{"missing": true}
if len(ids) > 0 {
filters = squirrel.And{
squirrel.Eq{"missing": true},
squirrel.Eq{"media_file.id": ids},
}
}
mfs, err := s.ds.MediaFile(ctx).GetAll(model.QueryOptions{
Filters: filters,
})
if err != nil {
return nil, err
}
// Extract unique album IDs
albumIDMap := make(map[string]struct{}, len(mfs))
for _, mf := range mfs {
if mf.AlbumID != "" {
albumIDMap[mf.AlbumID] = struct{}{}
}
}
albumIDs := make([]string, 0, len(albumIDMap))
for id := range albumIDMap {
albumIDs = append(albumIDs, id)
}
return albumIDs, nil
}
// refreshStatsAsync refreshes artist and album statistics in background goroutines
func (s *maintenanceService) refreshStatsAsync(ctx context.Context, affectedAlbumIDs []string) {
// Refresh artist stats in background
s.wg.Add(1)
go func() {
defer s.wg.Done()
bgCtx := request.AddValues(context.Background(), ctx)
if _, err := s.ds.Artist(bgCtx).RefreshStats(true); err != nil {
log.Error(bgCtx, "Error refreshing artist stats after deleting missing files", err)
} else {
log.Debug(bgCtx, "Successfully refreshed artist stats after deleting missing files")
}
// Refresh album stats in background if we have affected albums
if len(affectedAlbumIDs) > 0 {
if err := s.refreshAlbums(bgCtx, affectedAlbumIDs); err != nil {
log.Error(bgCtx, "Error refreshing album stats after deleting missing files", err)
} else {
log.Debug(bgCtx, "Successfully refreshed album stats after deleting missing files", "count", len(affectedAlbumIDs))
}
}
}()
}
// Wait waits for all background goroutines to complete.
// WARNING: This method is ONLY for testing. Never call this in production code.
// Calling Wait() in production will block until ALL background operations complete
// and may cause race conditions with new operations starting.
func (s *maintenanceService) wait() {
s.wg.Wait()
}

364
core/maintenance_test.go Normal file
View File

@@ -0,0 +1,364 @@
package core
import (
"context"
"errors"
"sync"
"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"
"github.com/sirupsen/logrus"
)
var _ = Describe("Maintenance", func() {
var ds *tests.MockDataStore
var mfRepo *extendedMediaFileRepo
var service Maintenance
var ctx context.Context
BeforeEach(func() {
ctx = context.Background()
ctx = request.WithUser(ctx, model.User{ID: "user1", IsAdmin: true})
ds = createTestDataStore()
mfRepo = ds.MockedMediaFile.(*extendedMediaFileRepo)
service = NewMaintenance(ds)
})
Describe("DeleteMissingFiles", func() {
Context("with specific IDs", func() {
It("deletes specific missing files and runs GC", func() {
// Setup: mock missing files with album IDs
mfRepo.SetData(model.MediaFiles{
{ID: "mf1", AlbumID: "album1", Missing: true},
{ID: "mf2", AlbumID: "album2", Missing: true},
})
err := service.DeleteMissingFiles(ctx, []string{"mf1", "mf2"})
Expect(err).ToNot(HaveOccurred())
Expect(mfRepo.deleteMissingCalled).To(BeTrue())
Expect(mfRepo.deletedIDs).To(Equal([]string{"mf1", "mf2"}))
Expect(ds.GCCalled).To(BeTrue(), "GC should be called after deletion")
})
It("triggers artist stats refresh and album refresh after deletion", func() {
artistRepo := ds.MockedArtist.(*extendedArtistRepo)
// Setup: mock missing files with albums
albumRepo := ds.MockedAlbum.(*extendedAlbumRepo)
albumRepo.SetData(model.Albums{
{ID: "album1", Name: "Test Album", SongCount: 5},
})
mfRepo.SetData(model.MediaFiles{
{ID: "mf1", AlbumID: "album1", Missing: true},
{ID: "mf2", AlbumID: "album1", Missing: false, Size: 1000, Duration: 180},
{ID: "mf3", AlbumID: "album1", Missing: false, Size: 2000, Duration: 200},
})
err := service.DeleteMissingFiles(ctx, []string{"mf1"})
Expect(err).ToNot(HaveOccurred())
// Wait for background goroutines to complete
service.(*maintenanceService).wait()
// RefreshStats should be called
Expect(artistRepo.IsRefreshStatsCalled()).To(BeTrue(), "Artist stats should be refreshed")
// Album should be updated with new calculated values
Expect(albumRepo.GetPutCallCount()).To(BeNumerically(">", 0), "Album.Put() should be called to refresh album data")
})
It("returns error if deletion fails", func() {
mfRepo.deleteMissingError = errors.New("delete failed")
err := service.DeleteMissingFiles(ctx, []string{"mf1"})
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("delete failed"))
})
It("continues even if album tracking fails", func() {
mfRepo.SetError(true)
err := service.DeleteMissingFiles(ctx, []string{"mf1"})
// Should not fail, just log warning
Expect(err).ToNot(HaveOccurred())
Expect(mfRepo.deleteMissingCalled).To(BeTrue())
})
It("returns error if GC fails", func() {
mfRepo.SetData(model.MediaFiles{
{ID: "mf1", AlbumID: "album1", Missing: true},
})
// Set GC to return error
ds.GCError = errors.New("gc failed")
err := service.DeleteMissingFiles(ctx, []string{"mf1"})
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("gc failed"))
})
})
Context("album ID extraction", func() {
It("extracts unique album IDs from missing files", func() {
mfRepo.SetData(model.MediaFiles{
{ID: "mf1", AlbumID: "album1", Missing: true},
{ID: "mf2", AlbumID: "album1", Missing: true},
{ID: "mf3", AlbumID: "album2", Missing: true},
})
err := service.DeleteMissingFiles(ctx, []string{"mf1", "mf2", "mf3"})
Expect(err).ToNot(HaveOccurred())
})
It("skips files without album IDs", func() {
mfRepo.SetData(model.MediaFiles{
{ID: "mf1", AlbumID: "", Missing: true},
{ID: "mf2", AlbumID: "album1", Missing: true},
})
err := service.DeleteMissingFiles(ctx, []string{"mf1", "mf2"})
Expect(err).ToNot(HaveOccurred())
})
})
})
Describe("DeleteAllMissingFiles", func() {
It("deletes all missing files and runs GC", func() {
mfRepo.SetData(model.MediaFiles{
{ID: "mf1", AlbumID: "album1", Missing: true},
{ID: "mf2", AlbumID: "album2", Missing: true},
{ID: "mf3", AlbumID: "album3", Missing: true},
})
err := service.DeleteAllMissingFiles(ctx)
Expect(err).ToNot(HaveOccurred())
Expect(ds.GCCalled).To(BeTrue(), "GC should be called after deletion")
})
It("returns error if deletion fails", func() {
mfRepo.SetError(true)
err := service.DeleteAllMissingFiles(ctx)
Expect(err).To(HaveOccurred())
})
It("handles empty result gracefully", func() {
mfRepo.SetData(model.MediaFiles{})
err := service.DeleteAllMissingFiles(ctx)
Expect(err).ToNot(HaveOccurred())
})
})
Describe("Album refresh logic", func() {
var albumRepo *extendedAlbumRepo
BeforeEach(func() {
albumRepo = ds.MockedAlbum.(*extendedAlbumRepo)
})
Context("when album has no tracks after deletion", func() {
It("skips the album without updating it", func() {
// Setup album with no remaining tracks
albumRepo.SetData(model.Albums{
{ID: "album1", Name: "Empty Album", SongCount: 1},
})
mfRepo.SetData(model.MediaFiles{
{ID: "mf1", AlbumID: "album1", Missing: true},
})
err := service.DeleteMissingFiles(ctx, []string{"mf1"})
Expect(err).ToNot(HaveOccurred())
// Wait for background goroutines to complete
service.(*maintenanceService).wait()
// Album should NOT be updated because it has no tracks left
Expect(albumRepo.GetPutCallCount()).To(Equal(0), "Album with no tracks should not be updated")
})
})
Context("when Put fails for one album", func() {
It("continues processing other albums", func() {
albumRepo.SetData(model.Albums{
{ID: "album1", Name: "Album 1"},
{ID: "album2", Name: "Album 2"},
})
mfRepo.SetData(model.MediaFiles{
{ID: "mf1", AlbumID: "album1", Missing: true},
{ID: "mf2", AlbumID: "album1", Missing: false, Size: 1000, Duration: 180},
{ID: "mf3", AlbumID: "album2", Missing: true},
{ID: "mf4", AlbumID: "album2", Missing: false, Size: 2000, Duration: 200},
})
// Make Put fail on first call but succeed on subsequent calls
albumRepo.putError = errors.New("put failed")
albumRepo.failOnce = true
err := service.DeleteMissingFiles(ctx, []string{"mf1", "mf3"})
// Should not fail even if one album's Put fails
Expect(err).ToNot(HaveOccurred())
// Wait for background goroutines to complete
service.(*maintenanceService).wait()
// Put should have been called multiple times
Expect(albumRepo.GetPutCallCount()).To(BeNumerically(">", 0), "Put should be attempted")
})
})
Context("when media file loading fails", func() {
It("logs warning but continues when tracking affected albums fails", func() {
// Set up log capturing
hook, cleanup := tests.LogHook()
defer cleanup()
albumRepo.SetData(model.Albums{
{ID: "album1", Name: "Album 1"},
})
mfRepo.SetData(model.MediaFiles{
{ID: "mf1", AlbumID: "album1", Missing: true},
})
// Make GetAll fail when loading media files
mfRepo.SetError(true)
err := service.DeleteMissingFiles(ctx, []string{"mf1"})
// Deletion should succeed despite the tracking error
Expect(err).ToNot(HaveOccurred())
Expect(mfRepo.deleteMissingCalled).To(BeTrue())
// Verify the warning was logged
Expect(hook.LastEntry()).ToNot(BeNil())
Expect(hook.LastEntry().Level).To(Equal(logrus.WarnLevel))
Expect(hook.LastEntry().Message).To(Equal("Error tracking affected albums for refresh"))
})
})
})
})
// Test helper to create a mock DataStore with controllable behavior
func createTestDataStore() *tests.MockDataStore {
ds := &tests.MockDataStore{}
// Create extended album repo with Put tracking
albumRepo := &extendedAlbumRepo{
MockAlbumRepo: tests.CreateMockAlbumRepo(),
}
ds.MockedAlbum = albumRepo
// Create extended artist repo with RefreshStats tracking
artistRepo := &extendedArtistRepo{
MockArtistRepo: tests.CreateMockArtistRepo(),
}
ds.MockedArtist = artistRepo
// Create extended media file repo with DeleteMissing support
mfRepo := &extendedMediaFileRepo{
MockMediaFileRepo: tests.CreateMockMediaFileRepo(),
}
ds.MockedMediaFile = mfRepo
return ds
}
// Extension of MockMediaFileRepo to add DeleteMissing method
type extendedMediaFileRepo struct {
*tests.MockMediaFileRepo
deleteMissingCalled bool
deletedIDs []string
deleteMissingError error
}
func (m *extendedMediaFileRepo) DeleteMissing(ids []string) error {
m.deleteMissingCalled = true
m.deletedIDs = ids
if m.deleteMissingError != nil {
return m.deleteMissingError
}
// Actually delete from the mock data
for _, id := range ids {
delete(m.Data, id)
}
return nil
}
// Extension of MockAlbumRepo to track Put calls
type extendedAlbumRepo struct {
*tests.MockAlbumRepo
mu sync.RWMutex
putCallCount int
lastPutData *model.Album
putError error
failOnce bool
}
func (m *extendedAlbumRepo) Put(album *model.Album) error {
m.mu.Lock()
m.putCallCount++
m.lastPutData = album
// Handle failOnce behavior
var err error
if m.putError != nil {
if m.failOnce {
err = m.putError
m.putError = nil // Clear error after first failure
m.mu.Unlock()
return err
}
err = m.putError
m.mu.Unlock()
return err
}
m.mu.Unlock()
return m.MockAlbumRepo.Put(album)
}
func (m *extendedAlbumRepo) GetPutCallCount() int {
m.mu.RLock()
defer m.mu.RUnlock()
return m.putCallCount
}
// Extension of MockArtistRepo to track RefreshStats calls
type extendedArtistRepo struct {
*tests.MockArtistRepo
mu sync.RWMutex
refreshStatsCalled bool
refreshStatsError error
}
func (m *extendedArtistRepo) RefreshStats(allArtists bool) (int64, error) {
m.mu.Lock()
m.refreshStatsCalled = true
err := m.refreshStatsError
m.mu.Unlock()
if err != nil {
return 0, err
}
return m.MockArtistRepo.RefreshStats(allArtists)
}
func (m *extendedArtistRepo) IsRefreshStatsCalled() bool {
m.mu.RLock()
defer m.mu.RUnlock()
return m.refreshStatsCalled
}

View File

@@ -204,7 +204,20 @@ func NewTranscodingCache() TranscodingCache {
log.Error(ctx, "Error loading transcoding command", "format", job.format, err)
return nil, os.ErrInvalid
}
out, err := job.ms.transcoder.Transcode(ctx, t.Command, job.filePath, job.bitRate, job.offset)
// Choose the appropriate context based on EnableTranscodingCancellation configuration.
// This is where we decide whether transcoding processes should be cancellable or not.
var transcodingCtx context.Context
if conf.Server.EnableTranscodingCancellation {
// Use the request context directly, allowing cancellation when client disconnects
transcodingCtx = ctx
} else {
// Use background context with request values preserved.
// This prevents cancellation but maintains request metadata (user, client, etc.)
transcodingCtx = request.AddValues(context.Background(), ctx)
}
out, err := job.ms.transcoder.Transcode(transcodingCtx, t.Command, job.filePath, job.bitRate, job.offset)
if err != nil {
log.Error(ctx, "Error starting transcoder", "id", job.mf.ID, err)
return nil, os.ErrInvalid

View File

@@ -6,6 +6,7 @@ import (
"encoding/json"
"math"
"net/http"
"os"
"path/filepath"
"runtime"
"runtime/debug"
@@ -21,7 +22,9 @@ import (
"github.com/navidrome/navidrome/core/metrics/insights"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/plugins/schema"
"github.com/navidrome/navidrome/model/request"
"github.com/navidrome/navidrome/plugins"
"github.com/navidrome/navidrome/server/events"
"github.com/navidrome/navidrome/utils/singleton"
)
@@ -35,18 +38,12 @@ var (
)
type insightsCollector struct {
ds model.DataStore
pluginLoader PluginLoader
lastRun atomic.Int64
lastStatus atomic.Bool
ds model.DataStore
lastRun atomic.Int64
lastStatus atomic.Bool
}
// PluginLoader defines an interface for loading plugins
type PluginLoader interface {
PluginList() map[string]schema.PluginManifest
}
func GetInstance(ds model.DataStore, pluginLoader PluginLoader) Insights {
func GetInstance(ds model.DataStore) Insights {
return singleton.GetInstance(func() *insightsCollector {
id, err := ds.Property(context.TODO()).Get(consts.InsightsIDKey)
if err != nil {
@@ -58,14 +55,21 @@ func GetInstance(ds model.DataStore, pluginLoader PluginLoader) Insights {
}
}
insightsID = id
return &insightsCollector{ds: ds, pluginLoader: pluginLoader}
return &insightsCollector{ds: ds}
})
}
func (c *insightsCollector) Run(ctx context.Context) {
ctx = auth.WithAdminUser(ctx, c.ds)
for {
c.sendInsights(ctx)
// Refresh admin context on each iteration to handle cases where
// admin user wasn't available on previous runs
insightsCtx := auth.WithAdminUser(ctx, c.ds)
u, _ := request.UserFrom(insightsCtx)
if !u.IsAdmin {
log.Trace(insightsCtx, "No admin user available, skipping insights collection")
} else {
c.sendInsights(insightsCtx)
}
select {
case <-time.After(consts.InsightsUpdateInterval):
continue
@@ -160,6 +164,13 @@ var staticData = sync.OnceValue(func() insights.Data {
data.Build.Settings, data.Build.GoVersion = buildInfo()
data.OS.Containerized = consts.InContainer
// Install info
packageFilename := filepath.Join(conf.Server.DataFolder, ".package")
packageFileData, err := os.ReadFile(packageFilename)
if err == nil {
data.OS.Package = string(packageFileData)
}
// OS info
data.OS.Type = runtime.GOOS
data.OS.Arch = runtime.GOARCH
@@ -207,7 +218,7 @@ var staticData = sync.OnceValue(func() insights.Data {
data.Config.ScanSchedule = conf.Server.Scanner.Schedule
data.Config.ScanWatcherWait = uint64(math.Trunc(conf.Server.Scanner.WatcherWait.Seconds()))
data.Config.ScanOnStartup = conf.Server.Scanner.ScanOnStartup
data.Config.ReverseProxyConfigured = conf.Server.ReverseProxyWhitelist != ""
data.Config.ReverseProxyConfigured = conf.Server.ExtAuth.TrustedSources != ""
data.Config.HasCustomPID = conf.Server.PID.Track != "" || conf.Server.PID.Album != ""
data.Config.HasCustomTags = len(conf.Server.Tags) > 0
@@ -303,12 +314,16 @@ func (c *insightsCollector) hasSmartPlaylists(ctx context.Context) (bool, error)
// collectPlugins collects information about installed plugins
func (c *insightsCollector) collectPlugins(_ context.Context) map[string]insights.PluginInfo {
plugins := make(map[string]insights.PluginInfo)
for id, manifest := range c.pluginLoader.PluginList() {
plugins[id] = insights.PluginInfo{
Name: manifest.Name,
Version: manifest.Version,
// TODO Fix import/inject cycles
manager := plugins.GetManager(c.ds, events.GetBroker())
info := manager.GetPluginInfo()
result := make(map[string]insights.PluginInfo, len(info))
for name, p := range info {
result[name] = insights.PluginInfo{
Name: p.Name,
Version: p.Version,
}
}
return plugins
return result
}

View File

@@ -16,6 +16,7 @@ type Data struct {
Containerized bool `json:"containerized"`
Arch string `json:"arch"`
NumCPU int `json:"numCPU"`
Package string `json:"package,omitempty"`
} `json:"os"`
Mem struct {
Alloc uint64 `json:"alloc"`

View File

@@ -42,6 +42,7 @@ type MountInfo struct {
var fsTypeMap = map[int64]string{
0x5346414f: "afs",
0x187: "autofs",
0x61756673: "aufs",
0x9123683E: "btrfs",
0xc36400: "ceph",
@@ -55,9 +56,11 @@ var fsTypeMap = map[int64]string{
0x6a656a63: "fakeowner", // FS inside a container
0x65735546: "fuse",
0x4244: "hfs",
0x482b: "hfs+",
0x9660: "iso9660",
0x3153464a: "jfs",
0x00006969: "nfs",
0x5346544e: "ntfs", // NTFS_SB_MAGIC
0x7366746e: "ntfs",
0x794c7630: "overlayfs",
0x9fa0: "proc",
@@ -69,8 +72,16 @@ var fsTypeMap = map[int64]string{
0x01021997: "v9fs",
0x786f4256: "vboxsf",
0x4d44: "vfat",
0xca451a4e: "virtiofs",
0x58465342: "xfs",
0x2FC12FC1: "zfs",
0x7c7c6673: "prlfs", // Parallels Shared Folders
// Signed/unsigned conversion issues (negative hex values converted to uint32)
-0x6edc97c2: "btrfs", // 0x9123683e
-0x1acb2be: "smb2", // 0xfe534d42
-0xacb2be: "cifs", // 0xff534d42
-0xd0adff0: "f2fs", // 0xf2f52010
}
func getFilesystemType(path string) (string, error) {

View File

@@ -1,6 +1,7 @@
package core
import (
"cmp"
"context"
"encoding/json"
"errors"
@@ -9,7 +10,7 @@ import (
"net/url"
"os"
"path/filepath"
"regexp"
"slices"
"strings"
"time"
@@ -194,22 +195,35 @@ func (s *playlists) parseM3U(ctx context.Context, pls *model.Playlist, folder *m
}
filteredLines = append(filteredLines, line)
}
paths, err := s.normalizePaths(ctx, pls, folder, filteredLines)
resolvedPaths, err := s.resolvePaths(ctx, folder, filteredLines)
if err != nil {
log.Warn(ctx, "Error normalizing paths in playlist", "playlist", pls.Name, err)
log.Warn(ctx, "Error resolving paths in playlist", "playlist", pls.Name, err)
continue
}
found, err := mediaFileRepository.FindByPaths(paths)
// Normalize to NFD for filesystem compatibility (macOS). Database stores paths in NFD.
// See https://github.com/navidrome/navidrome/issues/4663
resolvedPaths = slice.Map(resolvedPaths, func(path string) string {
return strings.ToLower(norm.NFD.String(path))
})
found, err := mediaFileRepository.FindByPaths(resolvedPaths)
if err != nil {
log.Warn(ctx, "Error reading files from DB", "playlist", pls.Name, err)
continue
}
// Build lookup map with library-qualified keys, normalized for comparison
existing := make(map[string]int, len(found))
for idx := range found {
existing[normalizePathForComparison(found[idx].Path)] = idx
// Normalize to lowercase for case-insensitive comparison
// Key format: "libraryID:path"
key := fmt.Sprintf("%d:%s", found[idx].LibraryID, strings.ToLower(found[idx].Path))
existing[key] = idx
}
for _, path := range paths {
idx, ok := existing[normalizePathForComparison(path)]
// Find media files in the order of the resolved paths, to keep playlist order
for _, path := range resolvedPaths {
idx, ok := existing[path]
if ok {
mfs = append(mfs, found[idx])
} else {
@@ -226,69 +240,150 @@ func (s *playlists) parseM3U(ctx context.Context, pls *model.Playlist, folder *m
return nil
}
// normalizePathForComparison normalizes a file path to NFC form and converts to lowercase
// for consistent comparison. This fixes Unicode normalization issues on macOS where
// Apple Music creates playlists with NFC-encoded paths but the filesystem uses NFD.
func normalizePathForComparison(path string) string {
return strings.ToLower(norm.NFC.String(path))
// pathResolution holds the result of resolving a playlist path to a library-relative path.
type pathResolution struct {
absolutePath string
libraryPath string
libraryID int
valid bool
}
// TODO This won't work for multiple libraries
func (s *playlists) normalizePaths(ctx context.Context, pls *model.Playlist, folder *model.Folder, lines []string) ([]string, error) {
libRegex, err := s.compileLibraryPaths(ctx)
// ToQualifiedString converts the path resolution to a library-qualified string with forward slashes.
// Format: "libraryID:relativePath" with forward slashes for path separators.
func (r pathResolution) ToQualifiedString() (string, error) {
if !r.valid {
return "", fmt.Errorf("invalid path resolution")
}
relativePath, err := filepath.Rel(r.libraryPath, r.absolutePath)
if err != nil {
return nil, err
return "", err
}
res := make([]string, 0, len(lines))
for idx, line := range lines {
var libPath string
var filePath string
if folder != nil && !filepath.IsAbs(line) {
libPath = folder.LibraryPath
filePath = filepath.Join(folder.AbsolutePath(), line)
} else {
cleanLine := filepath.Clean(line)
if libPath = libRegex.FindString(cleanLine); libPath != "" {
filePath = cleanLine
}
}
if libPath != "" {
if rel, err := filepath.Rel(libPath, filePath); err == nil {
res = append(res, rel)
} else {
log.Debug(ctx, "Error getting relative path", "playlist", pls.Name, "path", line, "libPath", libPath,
"filePath", filePath, err)
}
} else {
log.Warn(ctx, "Path in playlist not found in any library", "path", line, "line", idx)
}
}
return slice.Map(res, filepath.ToSlash), nil
// Convert path separators to forward slashes
return fmt.Sprintf("%d:%s", r.libraryID, filepath.ToSlash(relativePath)), nil
}
func (s *playlists) compileLibraryPaths(ctx context.Context) (*regexp.Regexp, error) {
libs, err := s.ds.Library(ctx).GetAll()
if err != nil {
return nil, err
}
// libraryMatcher holds sorted libraries with cleaned paths for efficient path matching.
type libraryMatcher struct {
libraries model.Libraries
cleanedPaths []string
}
// Create regex patterns for each library path
patterns := make([]string, len(libs))
// findLibraryForPath finds which library contains the given absolute path.
// Returns library ID and path, or 0 and empty string if not found.
func (lm *libraryMatcher) findLibraryForPath(absolutePath string) (int, string) {
// Check sorted libraries (longest path first) to find the best match
for i, cleanLibPath := range lm.cleanedPaths {
// Check if absolutePath is under this library path
if strings.HasPrefix(absolutePath, cleanLibPath) {
// Ensure it's a proper path boundary (not just a prefix)
if len(absolutePath) == len(cleanLibPath) || absolutePath[len(cleanLibPath)] == filepath.Separator {
return lm.libraries[i].ID, cleanLibPath
}
}
}
return 0, ""
}
// newLibraryMatcher creates a libraryMatcher with libraries sorted by path length (longest first).
// This ensures correct matching when library paths are prefixes of each other.
// Example: /music-classical must be checked before /music
// Otherwise, /music-classical/track.mp3 would match /music instead of /music-classical
func newLibraryMatcher(libs model.Libraries) *libraryMatcher {
// Sort libraries by path length (descending) to ensure longest paths match first.
slices.SortFunc(libs, func(i, j model.Library) int {
return cmp.Compare(len(j.Path), len(i.Path)) // Reverse order for descending
})
// Pre-clean all library paths once for efficient matching
cleanedPaths := make([]string, len(libs))
for i, lib := range libs {
cleanPath := filepath.Clean(lib.Path)
escapedPath := regexp.QuoteMeta(cleanPath)
patterns[i] = fmt.Sprintf("^%s(?:/|$)", escapedPath)
cleanedPaths[i] = filepath.Clean(lib.Path)
}
// Combine all patterns into a single regex
combinedPattern := strings.Join(patterns, "|")
re, err := regexp.Compile(combinedPattern)
return &libraryMatcher{
libraries: libs,
cleanedPaths: cleanedPaths,
}
}
// pathResolver handles path resolution logic for playlist imports.
type pathResolver struct {
matcher *libraryMatcher
}
// newPathResolver creates a pathResolver with libraries loaded from the datastore.
func newPathResolver(ctx context.Context, ds model.DataStore) (*pathResolver, error) {
libs, err := ds.Library(ctx).GetAll()
if err != nil {
return nil, fmt.Errorf("compiling library paths `%s`: %w", combinedPattern, err)
return nil, err
}
return re, nil
matcher := newLibraryMatcher(libs)
return &pathResolver{matcher: matcher}, nil
}
// resolvePath determines the absolute path and library path for a playlist entry.
// For absolute paths, it uses them directly.
// For relative paths, it resolves them relative to the playlist's folder location.
// Example: playlist at /music/playlists/test.m3u with line "../songs/abc.mp3"
//
// resolves to /music/songs/abc.mp3
func (r *pathResolver) resolvePath(line string, folder *model.Folder) pathResolution {
var absolutePath string
if folder != nil && !filepath.IsAbs(line) {
// Resolve relative path to absolute path based on playlist location
absolutePath = filepath.Clean(filepath.Join(folder.AbsolutePath(), line))
} else {
// Use absolute path directly after cleaning
absolutePath = filepath.Clean(line)
}
return r.findInLibraries(absolutePath)
}
// findInLibraries matches an absolute path against all known libraries and returns
// a pathResolution with the library information. Returns an invalid resolution if
// the path is not found in any library.
func (r *pathResolver) findInLibraries(absolutePath string) pathResolution {
libID, libPath := r.matcher.findLibraryForPath(absolutePath)
if libID == 0 {
return pathResolution{valid: false}
}
return pathResolution{
absolutePath: absolutePath,
libraryPath: libPath,
libraryID: libID,
valid: true,
}
}
// resolvePaths converts playlist file paths to library-qualified paths (format: "libraryID:relativePath").
// For relative paths, it resolves them to absolute paths first, then determines which
// library they belong to. This allows playlists to reference files across library boundaries.
func (s *playlists) resolvePaths(ctx context.Context, folder *model.Folder, lines []string) ([]string, error) {
resolver, err := newPathResolver(ctx, s.ds)
if err != nil {
return nil, err
}
results := make([]string, 0, len(lines))
for idx, line := range lines {
resolution := resolver.resolvePath(line, folder)
if !resolution.valid {
log.Warn(ctx, "Path in playlist not found in any library", "path", line, "line", idx)
continue
}
qualifiedPath, err := resolution.ToQualifiedString()
if err != nil {
log.Debug(ctx, "Error getting library-qualified path", "path", line,
"libPath", resolution.libraryPath, "filePath", resolution.absolutePath, err)
continue
}
results = append(results, qualifiedPath)
}
return results, nil
}
func (s *playlists) updatePlaylist(ctx context.Context, newPls *model.Playlist) error {

View File

@@ -0,0 +1,406 @@
package core
import (
"context"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("libraryMatcher", func() {
var ds *tests.MockDataStore
var mockLibRepo *tests.MockLibraryRepo
ctx := context.Background()
BeforeEach(func() {
mockLibRepo = &tests.MockLibraryRepo{}
ds = &tests.MockDataStore{
MockedLibrary: mockLibRepo,
}
})
// Helper function to create a libraryMatcher from the mock datastore
createMatcher := func(ds model.DataStore) *libraryMatcher {
libs, err := ds.Library(ctx).GetAll()
Expect(err).ToNot(HaveOccurred())
return newLibraryMatcher(libs)
}
Describe("Longest library path matching", func() {
It("matches the longest library path when multiple libraries share a prefix", func() {
// Setup libraries with prefix conflicts
mockLibRepo.SetData([]model.Library{
{ID: 1, Path: "/music"},
{ID: 2, Path: "/music-classical"},
{ID: 3, Path: "/music-classical/opera"},
})
matcher := createMatcher(ds)
// Test that longest path matches first and returns correct library ID
testCases := []struct {
path string
expectedLibID int
expectedLibPath string
}{
{"/music-classical/opera/track.mp3", 3, "/music-classical/opera"},
{"/music-classical/track.mp3", 2, "/music-classical"},
{"/music/track.mp3", 1, "/music"},
{"/music-classical/opera/subdir/file.mp3", 3, "/music-classical/opera"},
}
for _, tc := range testCases {
libID, libPath := matcher.findLibraryForPath(tc.path)
Expect(libID).To(Equal(tc.expectedLibID), "Path %s should match library ID %d, but got %d", tc.path, tc.expectedLibID, libID)
Expect(libPath).To(Equal(tc.expectedLibPath), "Path %s should match library path %s, but got %s", tc.path, tc.expectedLibPath, libPath)
}
})
It("handles libraries with similar prefixes but different structures", func() {
mockLibRepo.SetData([]model.Library{
{ID: 1, Path: "/home/user/music"},
{ID: 2, Path: "/home/user/music-backup"},
})
matcher := createMatcher(ds)
// Test that music-backup library is matched correctly
libID, libPath := matcher.findLibraryForPath("/home/user/music-backup/track.mp3")
Expect(libID).To(Equal(2))
Expect(libPath).To(Equal("/home/user/music-backup"))
// Test that music library is still matched correctly
libID, libPath = matcher.findLibraryForPath("/home/user/music/track.mp3")
Expect(libID).To(Equal(1))
Expect(libPath).To(Equal("/home/user/music"))
})
It("matches path that is exactly the library root", func() {
mockLibRepo.SetData([]model.Library{
{ID: 1, Path: "/music"},
{ID: 2, Path: "/music-classical"},
})
matcher := createMatcher(ds)
// Exact library path should match
libID, libPath := matcher.findLibraryForPath("/music-classical")
Expect(libID).To(Equal(2))
Expect(libPath).To(Equal("/music-classical"))
})
It("handles complex nested library structures", func() {
mockLibRepo.SetData([]model.Library{
{ID: 1, Path: "/media"},
{ID: 2, Path: "/media/audio"},
{ID: 3, Path: "/media/audio/classical"},
{ID: 4, Path: "/media/audio/classical/baroque"},
})
matcher := createMatcher(ds)
testCases := []struct {
path string
expectedLibID int
expectedLibPath string
}{
{"/media/audio/classical/baroque/bach/track.mp3", 4, "/media/audio/classical/baroque"},
{"/media/audio/classical/mozart/track.mp3", 3, "/media/audio/classical"},
{"/media/audio/rock/track.mp3", 2, "/media/audio"},
{"/media/video/movie.mp4", 1, "/media"},
}
for _, tc := range testCases {
libID, libPath := matcher.findLibraryForPath(tc.path)
Expect(libID).To(Equal(tc.expectedLibID), "Path %s should match library ID %d", tc.path, tc.expectedLibID)
Expect(libPath).To(Equal(tc.expectedLibPath), "Path %s should match library path %s", tc.path, tc.expectedLibPath)
}
})
})
Describe("Edge cases", func() {
It("handles empty library list", func() {
mockLibRepo.SetData([]model.Library{})
matcher := createMatcher(ds)
Expect(matcher).ToNot(BeNil())
// Should not match anything
libID, libPath := matcher.findLibraryForPath("/music/track.mp3")
Expect(libID).To(Equal(0))
Expect(libPath).To(BeEmpty())
})
It("handles single library", func() {
mockLibRepo.SetData([]model.Library{
{ID: 1, Path: "/music"},
})
matcher := createMatcher(ds)
libID, libPath := matcher.findLibraryForPath("/music/track.mp3")
Expect(libID).To(Equal(1))
Expect(libPath).To(Equal("/music"))
})
It("handles libraries with special characters in paths", func() {
mockLibRepo.SetData([]model.Library{
{ID: 1, Path: "/music[test]"},
{ID: 2, Path: "/music(backup)"},
})
matcher := createMatcher(ds)
Expect(matcher).ToNot(BeNil())
// Special characters should match literally
libID, libPath := matcher.findLibraryForPath("/music[test]/track.mp3")
Expect(libID).To(Equal(1))
Expect(libPath).To(Equal("/music[test]"))
})
})
Describe("Path matching order", func() {
It("ensures longest paths match first", func() {
mockLibRepo.SetData([]model.Library{
{ID: 1, Path: "/a"},
{ID: 2, Path: "/ab"},
{ID: 3, Path: "/abc"},
})
matcher := createMatcher(ds)
// Verify that longer paths match correctly (not cut off by shorter prefix)
testCases := []struct {
path string
expectedLibID int
}{
{"/abc/file.mp3", 3},
{"/ab/file.mp3", 2},
{"/a/file.mp3", 1},
}
for _, tc := range testCases {
libID, _ := matcher.findLibraryForPath(tc.path)
Expect(libID).To(Equal(tc.expectedLibID), "Path %s should match library ID %d", tc.path, tc.expectedLibID)
}
})
})
})
var _ = Describe("pathResolver", func() {
var ds *tests.MockDataStore
var mockLibRepo *tests.MockLibraryRepo
var resolver *pathResolver
ctx := context.Background()
BeforeEach(func() {
mockLibRepo = &tests.MockLibraryRepo{}
ds = &tests.MockDataStore{
MockedLibrary: mockLibRepo,
}
// Setup test libraries
mockLibRepo.SetData([]model.Library{
{ID: 1, Path: "/music"},
{ID: 2, Path: "/music-classical"},
{ID: 3, Path: "/podcasts"},
})
var err error
resolver, err = newPathResolver(ctx, ds)
Expect(err).ToNot(HaveOccurred())
})
Describe("resolvePath", func() {
It("resolves absolute paths", func() {
resolution := resolver.resolvePath("/music/artist/album/track.mp3", nil)
Expect(resolution.valid).To(BeTrue())
Expect(resolution.libraryID).To(Equal(1))
Expect(resolution.libraryPath).To(Equal("/music"))
Expect(resolution.absolutePath).To(Equal("/music/artist/album/track.mp3"))
})
It("resolves relative paths when folder is provided", func() {
folder := &model.Folder{
Path: "playlists",
LibraryPath: "/music",
LibraryID: 1,
}
resolution := resolver.resolvePath("../artist/album/track.mp3", folder)
Expect(resolution.valid).To(BeTrue())
Expect(resolution.libraryID).To(Equal(1))
Expect(resolution.absolutePath).To(Equal("/music/artist/album/track.mp3"))
})
It("returns invalid resolution for paths outside any library", func() {
resolution := resolver.resolvePath("/outside/library/track.mp3", nil)
Expect(resolution.valid).To(BeFalse())
})
})
Describe("resolvePath", func() {
Context("With absolute paths", func() {
It("resolves path within a library", func() {
resolution := resolver.resolvePath("/music/track.mp3", nil)
Expect(resolution.valid).To(BeTrue())
Expect(resolution.libraryID).To(Equal(1))
Expect(resolution.libraryPath).To(Equal("/music"))
Expect(resolution.absolutePath).To(Equal("/music/track.mp3"))
})
It("resolves path to the longest matching library", func() {
resolution := resolver.resolvePath("/music-classical/track.mp3", nil)
Expect(resolution.valid).To(BeTrue())
Expect(resolution.libraryID).To(Equal(2))
Expect(resolution.libraryPath).To(Equal("/music-classical"))
})
It("returns invalid resolution for path outside libraries", func() {
resolution := resolver.resolvePath("/videos/movie.mp4", nil)
Expect(resolution.valid).To(BeFalse())
})
It("cleans the path before matching", func() {
resolution := resolver.resolvePath("/music//artist/../artist/track.mp3", nil)
Expect(resolution.valid).To(BeTrue())
Expect(resolution.absolutePath).To(Equal("/music/artist/track.mp3"))
})
})
Context("With relative paths", func() {
It("resolves relative path within same library", func() {
folder := &model.Folder{
Path: "playlists",
LibraryPath: "/music",
LibraryID: 1,
}
resolution := resolver.resolvePath("../songs/track.mp3", folder)
Expect(resolution.valid).To(BeTrue())
Expect(resolution.libraryID).To(Equal(1))
Expect(resolution.absolutePath).To(Equal("/music/songs/track.mp3"))
})
It("resolves relative path to different library", func() {
folder := &model.Folder{
Path: "playlists",
LibraryPath: "/music",
LibraryID: 1,
}
// Path goes up and into a different library
resolution := resolver.resolvePath("../../podcasts/episode.mp3", folder)
Expect(resolution.valid).To(BeTrue())
Expect(resolution.libraryID).To(Equal(3))
Expect(resolution.libraryPath).To(Equal("/podcasts"))
})
It("uses matcher to find correct library for resolved path", func() {
folder := &model.Folder{
Path: "playlists",
LibraryPath: "/music",
LibraryID: 1,
}
// This relative path resolves to music-classical library
resolution := resolver.resolvePath("../../music-classical/track.mp3", folder)
Expect(resolution.valid).To(BeTrue())
Expect(resolution.libraryID).To(Equal(2))
Expect(resolution.libraryPath).To(Equal("/music-classical"))
})
It("returns invalid for relative paths escaping all libraries", func() {
folder := &model.Folder{
Path: "playlists",
LibraryPath: "/music",
LibraryID: 1,
}
resolution := resolver.resolvePath("../../../../etc/passwd", folder)
Expect(resolution.valid).To(BeFalse())
})
})
})
Describe("Cross-library resolution scenarios", func() {
It("handles playlist in library A referencing file in library B", func() {
// Playlist is in /music/playlists
folder := &model.Folder{
Path: "playlists",
LibraryPath: "/music",
LibraryID: 1,
}
// Relative path that goes to /podcasts library
resolution := resolver.resolvePath("../../podcasts/show/episode.mp3", folder)
Expect(resolution.valid).To(BeTrue())
Expect(resolution.libraryID).To(Equal(3), "Should resolve to podcasts library")
Expect(resolution.libraryPath).To(Equal("/podcasts"))
})
It("prefers longer library paths when resolving", func() {
// Ensure /music-classical is matched instead of /music
resolution := resolver.resolvePath("/music-classical/baroque/track.mp3", nil)
Expect(resolution.valid).To(BeTrue())
Expect(resolution.libraryID).To(Equal(2), "Should match /music-classical, not /music")
})
})
})
var _ = Describe("pathResolution", func() {
Describe("ToQualifiedString", func() {
It("converts valid resolution to qualified string with forward slashes", func() {
resolution := pathResolution{
absolutePath: "/music/artist/album/track.mp3",
libraryPath: "/music",
libraryID: 1,
valid: true,
}
qualifiedStr, err := resolution.ToQualifiedString()
Expect(err).ToNot(HaveOccurred())
Expect(qualifiedStr).To(Equal("1:artist/album/track.mp3"))
})
It("handles Windows-style paths by converting to forward slashes", func() {
resolution := pathResolution{
absolutePath: "/music/artist/album/track.mp3",
libraryPath: "/music",
libraryID: 2,
valid: true,
}
qualifiedStr, err := resolution.ToQualifiedString()
Expect(err).ToNot(HaveOccurred())
// Should always use forward slashes regardless of OS
Expect(qualifiedStr).To(ContainSubstring("2:"))
Expect(qualifiedStr).ToNot(ContainSubstring("\\"))
})
It("returns error for invalid resolution", func() {
resolution := pathResolution{valid: false}
_, err := resolution.ToQualifiedString()
Expect(err).To(HaveOccurred())
})
})
})

View File

@@ -1,4 +1,4 @@
package core
package core_test
import (
"context"
@@ -9,6 +9,7 @@ import (
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/conf/configtest"
"github.com/navidrome/navidrome/core"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/criteria"
"github.com/navidrome/navidrome/model/request"
@@ -20,7 +21,7 @@ import (
var _ = Describe("Playlists", func() {
var ds *tests.MockDataStore
var ps Playlists
var ps core.Playlists
var mockPlsRepo mockedPlaylistRepo
var mockLibRepo *tests.MockLibraryRepo
ctx := context.Background()
@@ -33,16 +34,16 @@ var _ = Describe("Playlists", func() {
MockedLibrary: mockLibRepo,
}
ctx = request.WithUser(ctx, model.User{ID: "123"})
// Path should be libPath, but we want to match the root folder referenced in the m3u, which is `/`
mockLibRepo.SetData([]model.Library{{ID: 1, Path: "/"}})
})
Describe("ImportFile", func() {
var folder *model.Folder
BeforeEach(func() {
ps = NewPlaylists(ds)
ps = core.NewPlaylists(ds)
ds.MockedMediaFile = &mockedMediaFileRepo{}
libPath, _ := os.Getwd()
// Set up library with the actual library path that matches the folder
mockLibRepo.SetData([]model.Library{{ID: 1, Path: libPath}})
folder = &model.Folder{
ID: "1",
LibraryID: 1,
@@ -112,6 +113,224 @@ var _ = Describe("Playlists", func() {
Expect(err.Error()).To(ContainSubstring("line 19, column 1: invalid character '\\n'"))
})
})
Describe("Cross-library relative paths", func() {
var tmpDir, plsDir, songsDir string
BeforeEach(func() {
// Create temp directory structure
tmpDir = GinkgoT().TempDir()
plsDir = tmpDir + "/playlists"
songsDir = tmpDir + "/songs"
Expect(os.Mkdir(plsDir, 0755)).To(Succeed())
Expect(os.Mkdir(songsDir, 0755)).To(Succeed())
// Setup two different libraries with paths matching our temp structure
mockLibRepo.SetData([]model.Library{
{ID: 1, Path: songsDir},
{ID: 2, Path: plsDir},
})
// Create a mock media file repository that returns files for both libraries
// Note: The paths are relative to their respective library roots
ds.MockedMediaFile = &mockedMediaFileFromListRepo{
data: []string{
"abc.mp3", // This is songs/abc.mp3 relative to songsDir
"def.mp3", // This is playlists/def.mp3 relative to plsDir
},
}
ps = core.NewPlaylists(ds)
})
It("handles relative paths that reference files in other libraries", func() {
// Create a temporary playlist file with relative path
plsContent := "#PLAYLIST:Cross Library Test\n../songs/abc.mp3\ndef.mp3"
plsFile := plsDir + "/test.m3u"
Expect(os.WriteFile(plsFile, []byte(plsContent), 0600)).To(Succeed())
// Playlist is in the Playlists library folder
// Important: Path should be relative to LibraryPath, and Name is the folder name
plsFolder := &model.Folder{
ID: "2",
LibraryID: 2,
LibraryPath: plsDir,
Path: "",
Name: "",
}
pls, err := ps.ImportFile(ctx, plsFolder, "test.m3u")
Expect(err).ToNot(HaveOccurred())
Expect(pls.Tracks).To(HaveLen(2))
Expect(pls.Tracks[0].Path).To(Equal("abc.mp3")) // From songsDir library
Expect(pls.Tracks[1].Path).To(Equal("def.mp3")) // From plsDir library
})
It("ignores paths that point outside all libraries", func() {
// Create a temporary playlist file with path outside libraries
plsContent := "#PLAYLIST:Outside Test\n../../outside.mp3\nabc.mp3"
plsFile := plsDir + "/test.m3u"
Expect(os.WriteFile(plsFile, []byte(plsContent), 0600)).To(Succeed())
plsFolder := &model.Folder{
ID: "2",
LibraryID: 2,
LibraryPath: plsDir,
Path: "",
Name: "",
}
pls, err := ps.ImportFile(ctx, plsFolder, "test.m3u")
Expect(err).ToNot(HaveOccurred())
// Should only find abc.mp3, not outside.mp3
Expect(pls.Tracks).To(HaveLen(1))
Expect(pls.Tracks[0].Path).To(Equal("abc.mp3"))
})
It("handles relative paths with multiple '../' components", func() {
// Create a nested structure: tmpDir/playlists/subfolder/test.m3u
subFolder := plsDir + "/subfolder"
Expect(os.Mkdir(subFolder, 0755)).To(Succeed())
// Create the media file in the subfolder directory
// The mock will return it as "def.mp3" relative to plsDir
ds.MockedMediaFile = &mockedMediaFileFromListRepo{
data: []string{
"abc.mp3", // From songsDir library
"def.mp3", // From plsDir library root
},
}
// From subfolder, ../../songs/abc.mp3 should resolve to songs library
// ../def.mp3 should resolve to plsDir/def.mp3
plsContent := "#PLAYLIST:Nested Test\n../../songs/abc.mp3\n../def.mp3"
plsFile := subFolder + "/test.m3u"
Expect(os.WriteFile(plsFile, []byte(plsContent), 0600)).To(Succeed())
// The folder: AbsolutePath = LibraryPath + Path + Name
// So for /playlists/subfolder: LibraryPath=/playlists, Path="", Name="subfolder"
plsFolder := &model.Folder{
ID: "2",
LibraryID: 2,
LibraryPath: plsDir,
Path: "", // Empty because subfolder is directly under library root
Name: "subfolder", // The folder name
}
pls, err := ps.ImportFile(ctx, plsFolder, "test.m3u")
Expect(err).ToNot(HaveOccurred())
Expect(pls.Tracks).To(HaveLen(2))
Expect(pls.Tracks[0].Path).To(Equal("abc.mp3")) // From songsDir library
Expect(pls.Tracks[1].Path).To(Equal("def.mp3")) // From plsDir library root
})
It("correctly resolves libraries when one path is a prefix of another", func() {
// This tests the bug where /music would match before /music-classical
// Create temp directory structure with prefix conflict
tmpDir := GinkgoT().TempDir()
musicDir := tmpDir + "/music"
musicClassicalDir := tmpDir + "/music-classical"
Expect(os.Mkdir(musicDir, 0755)).To(Succeed())
Expect(os.Mkdir(musicClassicalDir, 0755)).To(Succeed())
// Setup two libraries where one is a prefix of the other
mockLibRepo.SetData([]model.Library{
{ID: 1, Path: musicDir}, // /tmp/xxx/music
{ID: 2, Path: musicClassicalDir}, // /tmp/xxx/music-classical
})
// Mock will return tracks from both libraries
ds.MockedMediaFile = &mockedMediaFileFromListRepo{
data: []string{
"rock.mp3", // From music library
"bach.mp3", // From music-classical library
},
}
// Create playlist in music library that references music-classical
plsContent := "#PLAYLIST:Cross Prefix Test\nrock.mp3\n../music-classical/bach.mp3"
plsFile := musicDir + "/test.m3u"
Expect(os.WriteFile(plsFile, []byte(plsContent), 0600)).To(Succeed())
plsFolder := &model.Folder{
ID: "1",
LibraryID: 1,
LibraryPath: musicDir,
Path: "",
Name: "",
}
pls, err := ps.ImportFile(ctx, plsFolder, "test.m3u")
Expect(err).ToNot(HaveOccurred())
Expect(pls.Tracks).To(HaveLen(2))
Expect(pls.Tracks[0].Path).To(Equal("rock.mp3")) // From music library
Expect(pls.Tracks[1].Path).To(Equal("bach.mp3")) // From music-classical library (not music!)
})
It("correctly handles identical relative paths from different libraries", func() {
// This tests the bug where two libraries have files at the same relative path
// and only one appears in the playlist
tmpDir := GinkgoT().TempDir()
musicDir := tmpDir + "/music"
classicalDir := tmpDir + "/classical"
Expect(os.Mkdir(musicDir, 0755)).To(Succeed())
Expect(os.Mkdir(classicalDir, 0755)).To(Succeed())
Expect(os.MkdirAll(musicDir+"/album", 0755)).To(Succeed())
Expect(os.MkdirAll(classicalDir+"/album", 0755)).To(Succeed())
// Create placeholder files so paths resolve correctly
Expect(os.WriteFile(musicDir+"/album/track.mp3", []byte{}, 0600)).To(Succeed())
Expect(os.WriteFile(classicalDir+"/album/track.mp3", []byte{}, 0600)).To(Succeed())
// Both libraries have a file at "album/track.mp3"
mockLibRepo.SetData([]model.Library{
{ID: 1, Path: musicDir},
{ID: 2, Path: classicalDir},
})
// Mock returns files with same relative path but different IDs and library IDs
// Keys use the library-qualified format: "libraryID:path"
ds.MockedMediaFile = &mockedMediaFileRepo{
data: map[string]model.MediaFile{
"1:album/track.mp3": {ID: "music-track", Path: "album/track.mp3", LibraryID: 1, Title: "Rock Song"},
"2:album/track.mp3": {ID: "classical-track", Path: "album/track.mp3", LibraryID: 2, Title: "Classical Piece"},
},
}
// Recreate playlists service to pick up new mock
ps = core.NewPlaylists(ds)
// Create playlist in music library that references both tracks
plsContent := "#PLAYLIST:Same Path Test\nalbum/track.mp3\n../classical/album/track.mp3"
plsFile := musicDir + "/test.m3u"
Expect(os.WriteFile(plsFile, []byte(plsContent), 0600)).To(Succeed())
plsFolder := &model.Folder{
ID: "1",
LibraryID: 1,
LibraryPath: musicDir,
Path: "",
Name: "",
}
pls, err := ps.ImportFile(ctx, plsFolder, "test.m3u")
Expect(err).ToNot(HaveOccurred())
// Should have BOTH tracks, not just one
Expect(pls.Tracks).To(HaveLen(2), "Playlist should contain both tracks with same relative path")
// Verify we got tracks from DIFFERENT libraries (the key fix!)
// Collect the library IDs
libIDs := make(map[int]bool)
for _, track := range pls.Tracks {
libIDs[track.LibraryID] = true
}
Expect(libIDs).To(HaveLen(2), "Tracks should come from two different libraries")
Expect(libIDs[1]).To(BeTrue(), "Should have track from library 1")
Expect(libIDs[2]).To(BeTrue(), "Should have track from library 2")
// Both tracks should have the same relative path
Expect(pls.Tracks[0].Path).To(Equal("album/track.mp3"))
Expect(pls.Tracks[1].Path).To(Equal("album/track.mp3"))
})
})
})
Describe("ImportM3U", func() {
@@ -119,7 +338,7 @@ var _ = Describe("Playlists", func() {
BeforeEach(func() {
repo = &mockedMediaFileFromListRepo{}
ds.MockedMediaFile = repo
ps = NewPlaylists(ds)
ps = core.NewPlaylists(ds)
mockLibRepo.SetData([]model.Library{{ID: 1, Path: "/music"}, {ID: 2, Path: "/new"}})
ctx = request.WithUser(ctx, model.User{ID: "123"})
})
@@ -206,53 +425,23 @@ var _ = Describe("Playlists", func() {
Expect(pls.Tracks[0].Path).To(Equal("abc/tEsT1.Mp3"))
})
It("handles Unicode normalization when comparing paths", func() {
// Test case for Apple Music playlists that use NFC encoding vs macOS filesystem NFD
// The character "è" can be represented as NFC (single codepoint) or NFD (e + combining accent)
const pathWithAccents = "artist/Michèle Desrosiers/album/Noël.m4a"
// Simulate a database entry with NFD encoding (as stored by macOS filesystem)
nfdPath := norm.NFD.String(pathWithAccents)
It("handles Unicode normalization when comparing paths (NFD vs NFC)", func() {
// Simulate macOS filesystem: stores paths in NFD (decomposed) form
// "è" (U+00E8) in NFC becomes "e" + "◌̀" (U+0065 + U+0300) in NFD
nfdPath := "artist/Mich" + string([]rune{'e', '\u0300'}) + "le/song.mp3" // NFD: e + combining grave
repo.data = []string{nfdPath}
// Simulate an Apple Music M3U playlist entry with NFC encoding
nfcPath := norm.NFC.String("/music/" + pathWithAccents)
m3u := strings.Join([]string{
nfcPath,
}, "\n")
// Simulate Apple Music M3U: uses NFC (composed) form
nfcPath := "/music/artist/Mich\u00E8le/song.mp3" // NFC: single è character
m3u := nfcPath + "\n"
f := strings.NewReader(m3u)
pls, err := ps.ImportM3U(ctx, f)
Expect(err).ToNot(HaveOccurred())
Expect(pls.Tracks).To(HaveLen(1), "Should find the track despite Unicode normalization differences")
Expect(pls.Tracks).To(HaveLen(1))
// Should match despite different Unicode normalization forms
Expect(pls.Tracks[0].Path).To(Equal(nfdPath))
})
})
Describe("normalizePathForComparison", func() {
It("normalizes Unicode characters to NFC form and converts to lowercase", func() {
// Test with NFD (decomposed) input - as would come from macOS filesystem
nfdPath := norm.NFD.String("Michèle") // Explicitly convert to NFD form
normalized := normalizePathForComparison(nfdPath)
Expect(normalized).To(Equal("michèle"))
// Test with NFC (composed) input - as would come from Apple Music M3U
nfcPath := "Michèle" // This might be in NFC form
normalizedNfc := normalizePathForComparison(nfcPath)
// Ensure the two paths are not equal in their original forms
Expect(nfdPath).ToNot(Equal(nfcPath))
// Both should normalize to the same result
Expect(normalized).To(Equal(normalizedNfc))
})
It("handles paths with mixed case and Unicode characters", func() {
path := "Artist/Noël Coward/Album/Song.mp3"
normalized := normalizePathForComparison(path)
Expect(normalized).To(Equal("artist/noël coward/album/song.mp3"))
})
})
Describe("InPlaylistsPath", func() {
@@ -269,27 +458,27 @@ var _ = Describe("Playlists", func() {
It("returns true if PlaylistsPath is empty", func() {
conf.Server.PlaylistsPath = ""
Expect(InPlaylistsPath(folder)).To(BeTrue())
Expect(core.InPlaylistsPath(folder)).To(BeTrue())
})
It("returns true if PlaylistsPath is any (**/**)", func() {
conf.Server.PlaylistsPath = "**/**"
Expect(InPlaylistsPath(folder)).To(BeTrue())
Expect(core.InPlaylistsPath(folder)).To(BeTrue())
})
It("returns true if folder is in PlaylistsPath", func() {
conf.Server.PlaylistsPath = "other/**:playlists/**"
Expect(InPlaylistsPath(folder)).To(BeTrue())
Expect(core.InPlaylistsPath(folder)).To(BeTrue())
})
It("returns false if folder is not in PlaylistsPath", func() {
conf.Server.PlaylistsPath = "other"
Expect(InPlaylistsPath(folder)).To(BeFalse())
Expect(core.InPlaylistsPath(folder)).To(BeFalse())
})
It("returns true if for a playlist in root of MusicFolder if PlaylistsPath is '.'", func() {
conf.Server.PlaylistsPath = "."
Expect(InPlaylistsPath(folder)).To(BeFalse())
Expect(core.InPlaylistsPath(folder)).To(BeFalse())
folder2 := model.Folder{
LibraryPath: "/music",
@@ -297,22 +486,47 @@ var _ = Describe("Playlists", func() {
Name: ".",
}
Expect(InPlaylistsPath(folder2)).To(BeTrue())
Expect(core.InPlaylistsPath(folder2)).To(BeTrue())
})
})
})
// mockedMediaFileRepo's FindByPaths method returns a list of MediaFiles with the same paths as the input
// mockedMediaFileRepo's FindByPaths method returns MediaFiles for the given paths.
// If data map is provided, looks up files by key; otherwise creates them from paths.
type mockedMediaFileRepo struct {
model.MediaFileRepository
data map[string]model.MediaFile
}
func (r *mockedMediaFileRepo) FindByPaths(paths []string) (model.MediaFiles, error) {
var mfs model.MediaFiles
// If data map provided, look up files
if r.data != nil {
for _, path := range paths {
if mf, ok := r.data[path]; ok {
mfs = append(mfs, mf)
}
}
return mfs, nil
}
// Otherwise, create MediaFiles from paths
for idx, path := range paths {
// Strip library qualifier if present (format: "libraryID:path")
actualPath := path
libraryID := 1
if parts := strings.SplitN(path, ":", 2); len(parts) == 2 {
if id, err := strconv.Atoi(parts[0]); err == nil {
libraryID = id
actualPath = parts[1]
}
}
mfs = append(mfs, model.MediaFile{
ID: strconv.Itoa(idx),
Path: path,
ID: strconv.Itoa(idx),
Path: actualPath,
LibraryID: libraryID,
})
}
return mfs, nil
@@ -324,13 +538,38 @@ type mockedMediaFileFromListRepo struct {
data []string
}
func (r *mockedMediaFileFromListRepo) FindByPaths([]string) (model.MediaFiles, error) {
func (r *mockedMediaFileFromListRepo) FindByPaths(paths []string) (model.MediaFiles, error) {
var mfs model.MediaFiles
for idx, path := range r.data {
mfs = append(mfs, model.MediaFile{
ID: strconv.Itoa(idx),
Path: path,
})
for idx, dataPath := range r.data {
// Normalize the data path to NFD (simulates macOS filesystem storage)
normalizedDataPath := norm.NFD.String(dataPath)
for _, requestPath := range paths {
// Strip library qualifier if present (format: "libraryID:path")
actualPath := requestPath
libraryID := 1
if parts := strings.SplitN(requestPath, ":", 2); len(parts) == 2 {
if id, err := strconv.Atoi(parts[0]); err == nil {
libraryID = id
actualPath = parts[1]
}
}
// The request path should already be normalized to NFD by production code
// before calling FindByPaths (to match DB storage)
normalizedRequestPath := norm.NFD.String(actualPath)
// Case-insensitive comparison (like SQL's "collate nocase")
if strings.EqualFold(normalizedRequestPath, normalizedDataPath) {
mfs = append(mfs, model.MediaFile{
ID: strconv.Itoa(idx),
Path: dataPath, // Return original path from DB
LibraryID: libraryID,
})
break
}
}
}
return mfs, nil
}

View File

@@ -0,0 +1,81 @@
package publicurl
import (
"cmp"
"net/http"
"net/url"
"path"
"strconv"
"strings"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/core/auth"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
)
// ImageURL generates a public URL for artwork images.
// It creates a signed token for the artwork ID and builds a complete public URL.
func ImageURL(req *http.Request, artID model.ArtworkID, size int) string {
token, _ := auth.CreatePublicToken(map[string]any{"id": artID.String()})
uri := path.Join(consts.URLPathPublicImages, token)
params := url.Values{}
if size > 0 {
params.Add("size", strconv.Itoa(size))
}
return PublicURL(req, uri, params)
}
// PublicURL builds a full URL for public-facing resources.
// It uses ShareURL from config if available, otherwise falls back to extracting
// the scheme and host from the provided http.Request.
// If req is nil and ShareURL is not set, it defaults to http://localhost.
func PublicURL(req *http.Request, u string, params url.Values) string {
if conf.Server.ShareURL == "" {
return AbsoluteURL(req, u, params)
}
shareUrl, err := url.Parse(conf.Server.ShareURL)
if err != nil {
return AbsoluteURL(req, u, params)
}
buildUrl, err := url.Parse(u)
if err != nil {
return AbsoluteURL(req, u, params)
}
buildUrl.Scheme = shareUrl.Scheme
buildUrl.Host = shareUrl.Host
if len(params) > 0 {
buildUrl.RawQuery = params.Encode()
}
return buildUrl.String()
}
// AbsoluteURL builds an absolute URL from a relative path.
// It uses BaseHost/BaseScheme from config if available, otherwise extracts
// the scheme and host from the http.Request.
// If req is nil and BaseHost is not set, it defaults to http://localhost.
func AbsoluteURL(req *http.Request, u string, params url.Values) string {
buildUrl, err := url.Parse(u)
if err != nil {
log.Error(req.Context(), "Failed to parse URL path", "url", u, err)
return ""
}
if strings.HasPrefix(u, "/") {
buildUrl.Path = path.Join(conf.Server.BasePath, buildUrl.Path)
if conf.Server.BaseHost != "" {
buildUrl.Scheme = cmp.Or(conf.Server.BaseScheme, "http")
buildUrl.Host = conf.Server.BaseHost
} else if req != nil {
buildUrl.Scheme = req.URL.Scheme
buildUrl.Host = req.Host
} else {
buildUrl.Scheme = "http"
buildUrl.Host = "localhost"
}
}
if len(params) > 0 {
buildUrl.RawQuery = params.Encode()
}
return buildUrl.String()
}

View File

@@ -0,0 +1,174 @@
package publicurl_test
import (
"net/http"
"net/url"
"testing"
"github.com/go-chi/jwtauth/v5"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/conf/configtest"
"github.com/navidrome/navidrome/core/auth"
"github.com/navidrome/navidrome/core/publicurl"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
func TestPublicURL(t *testing.T) {
tests.Init(t, false)
log.SetLevel(log.LevelFatal)
RegisterFailHandler(Fail)
RunSpecs(t, "Public URL Suite")
}
var _ = Describe("Public URL Utilities", func() {
BeforeEach(func() {
DeferCleanup(configtest.SetupConfig())
})
Describe("PublicURL", func() {
When("ShareURL is set", func() {
BeforeEach(func() {
conf.Server.ShareURL = "https://share.example.com"
})
It("uses ShareURL as the base", func() {
r, _ := http.NewRequest("GET", "http://localhost/test", nil)
result := publicurl.PublicURL(r, "/path/to/resource", nil)
Expect(result).To(Equal("https://share.example.com/path/to/resource"))
})
It("includes query parameters", func() {
r, _ := http.NewRequest("GET", "http://localhost/test", nil)
params := url.Values{"size": []string{"300"}, "format": []string{"png"}}
result := publicurl.PublicURL(r, "/image/123", params)
Expect(result).To(ContainSubstring("https://share.example.com/image/123"))
Expect(result).To(ContainSubstring("size=300"))
Expect(result).To(ContainSubstring("format=png"))
})
It("works without a request", func() {
result := publicurl.PublicURL(nil, "/path/to/resource", nil)
Expect(result).To(Equal("https://share.example.com/path/to/resource"))
})
})
When("ShareURL is not set", func() {
BeforeEach(func() {
conf.Server.ShareURL = ""
})
It("falls back to AbsoluteURL with request", func() {
r, _ := http.NewRequest("GET", "https://myserver.com/test", nil)
r.Host = "myserver.com"
result := publicurl.PublicURL(r, "/path/to/resource", nil)
Expect(result).To(Equal("https://myserver.com/path/to/resource"))
})
It("falls back to localhost without request", func() {
result := publicurl.PublicURL(nil, "/path/to/resource", nil)
Expect(result).To(Equal("http://localhost/path/to/resource"))
})
})
})
Describe("AbsoluteURL", func() {
When("BaseHost is set", func() {
BeforeEach(func() {
conf.Server.BaseHost = "configured.example.com"
conf.Server.BaseScheme = "https"
conf.Server.BasePath = ""
})
It("uses BaseHost and BaseScheme", func() {
r, _ := http.NewRequest("GET", "http://localhost/test", nil)
result := publicurl.AbsoluteURL(r, "/path/to/resource", nil)
Expect(result).To(Equal("https://configured.example.com/path/to/resource"))
})
It("defaults to http scheme if BaseScheme is empty", func() {
conf.Server.BaseScheme = ""
r, _ := http.NewRequest("GET", "http://localhost/test", nil)
result := publicurl.AbsoluteURL(r, "/path/to/resource", nil)
Expect(result).To(Equal("http://configured.example.com/path/to/resource"))
})
})
When("BaseHost is not set", func() {
BeforeEach(func() {
conf.Server.BaseHost = ""
conf.Server.BasePath = ""
})
It("extracts host from request", func() {
r, _ := http.NewRequest("GET", "https://request.example.com/test", nil)
r.Host = "request.example.com"
result := publicurl.AbsoluteURL(r, "/path/to/resource", nil)
Expect(result).To(Equal("https://request.example.com/path/to/resource"))
})
It("falls back to localhost without request", func() {
result := publicurl.AbsoluteURL(nil, "/path/to/resource", nil)
Expect(result).To(Equal("http://localhost/path/to/resource"))
})
})
When("BasePath is set", func() {
BeforeEach(func() {
conf.Server.BasePath = "/navidrome"
conf.Server.BaseHost = "example.com"
conf.Server.BaseScheme = "https"
})
It("prepends BasePath to the URL", func() {
r, _ := http.NewRequest("GET", "http://localhost/test", nil)
result := publicurl.AbsoluteURL(r, "/path/to/resource", nil)
Expect(result).To(Equal("https://example.com/navidrome/path/to/resource"))
})
})
It("passes through absolute URLs unchanged", func() {
r, _ := http.NewRequest("GET", "http://localhost/test", nil)
result := publicurl.AbsoluteURL(r, "https://other.example.com/path", nil)
Expect(result).To(Equal("https://other.example.com/path"))
})
It("includes query parameters", func() {
conf.Server.BaseHost = "example.com"
conf.Server.BaseScheme = "https"
r, _ := http.NewRequest("GET", "http://localhost/test", nil)
params := url.Values{"key": []string{"value"}}
result := publicurl.AbsoluteURL(r, "/path", params)
Expect(result).To(Equal("https://example.com/path?key=value"))
})
})
Describe("ImageURL", func() {
BeforeEach(func() {
conf.Server.ShareURL = "https://share.example.com"
// Initialize JWT auth for token generation
auth.TokenAuth = jwtauth.New("HS256", []byte("test secret"), nil)
})
It("generates a URL with the artwork token", func() {
artID := model.NewArtworkID(model.KindAlbumArtwork, "album-123", nil)
result := publicurl.ImageURL(nil, artID, 0)
Expect(result).To(HavePrefix("https://share.example.com/share/img/"))
})
It("includes size parameter when provided", func() {
artID := model.NewArtworkID(model.KindArtistArtwork, "artist-1", nil)
result := publicurl.ImageURL(nil, artID, 300)
Expect(result).To(ContainSubstring("size=300"))
})
It("omits size parameter when zero", func() {
artID := model.NewArtworkID(model.KindMediaFileArtwork, "track-1", nil)
result := publicurl.ImageURL(nil, artID, 0)
Expect(result).ToNot(ContainSubstring("size="))
})
})
})

View File

@@ -38,9 +38,9 @@ var _ = Describe("BufferedScrobbler", func() {
It("forwards NowPlaying calls", func() {
track := &model.MediaFile{ID: "123", Title: "Test Track"}
Expect(bs.NowPlaying(ctx, "user1", track, 0)).To(Succeed())
Expect(scr.NowPlayingCalled).To(BeTrue())
Expect(scr.UserID).To(Equal("user1"))
Expect(scr.Track).To(Equal(track))
Expect(scr.GetNowPlayingCalled()).To(BeTrue())
Expect(scr.GetUserID()).To(Equal("user1"))
Expect(scr.GetTrack()).To(Equal(track))
})
It("enqueues scrobbles to buffer", func() {
@@ -51,9 +51,10 @@ var _ = Describe("BufferedScrobbler", func() {
Expect(scr.ScrobbleCalled.Load()).To(BeFalse())
Expect(bs.Scrobble(ctx, "user1", scrobble)).To(Succeed())
Expect(buffer.Length()).To(Equal(int64(1)))
// Wait for the scrobble to be sent
// Wait for the background goroutine to process the scrobble.
// We don't check buffer.Length() here because the background goroutine
// may dequeue the entry before we can observe it.
Eventually(scr.ScrobbleCalled.Load).Should(BeTrue())
lastScrobble := scr.LastScrobble.Load()

View File

@@ -31,6 +31,13 @@ type Submission struct {
Timestamp time.Time
}
type nowPlayingEntry struct {
ctx context.Context
userId string
track *model.MediaFile
position int
}
type PlayTracker interface {
NowPlaying(ctx context.Context, playerId string, playerName string, trackId string, position int) error
GetNowPlaying(ctx context.Context) ([]NowPlayingInfo, error)
@@ -52,6 +59,11 @@ type playTracker struct {
pluginScrobblers map[string]Scrobbler
pluginLoader PluginLoader
mu sync.RWMutex
npQueue map[string]nowPlayingEntry
npMu sync.Mutex
npSignal chan struct{}
shutdown chan struct{}
workerDone chan struct{}
}
func GetPlayTracker(ds model.DataStore, broker events.Broker, pluginManager PluginLoader) PlayTracker {
@@ -71,6 +83,10 @@ func newPlayTracker(ds model.DataStore, broker events.Broker, pluginManager Plug
builtinScrobblers: make(map[string]Scrobbler),
pluginScrobblers: make(map[string]Scrobbler),
pluginLoader: pluginManager,
npQueue: make(map[string]nowPlayingEntry),
npSignal: make(chan struct{}, 1),
shutdown: make(chan struct{}),
workerDone: make(chan struct{}),
}
if conf.Server.EnableNowPlaying {
m.OnExpiration(func(_ string, _ NowPlayingInfo) {
@@ -90,9 +106,16 @@ func newPlayTracker(ds model.DataStore, broker events.Broker, pluginManager Plug
p.builtinScrobblers[name] = s
}
log.Debug("List of builtin scrobblers enabled", "names", enabled)
go p.nowPlayingWorker()
return p
}
// stopNowPlayingWorker stops the background worker. This is primarily for testing.
func (p *playTracker) stopNowPlayingWorker() {
close(p.shutdown)
<-p.workerDone // Wait for worker to finish
}
// pluginNamesMatchScrobblers returns true if the set of pluginNames matches the keys in pluginScrobblers
func pluginNamesMatchScrobblers(pluginNames []string, scrobblers map[string]Scrobbler) bool {
if len(pluginNames) != len(scrobblers) {
@@ -198,11 +221,60 @@ func (p *playTracker) NowPlaying(ctx context.Context, playerId string, playerNam
}
player, _ := request.PlayerFrom(ctx)
if player.ScrobbleEnabled {
p.dispatchNowPlaying(ctx, user.ID, mf, position)
p.enqueueNowPlaying(ctx, playerId, user.ID, mf, position)
}
return nil
}
func (p *playTracker) enqueueNowPlaying(ctx context.Context, playerId string, userId string, track *model.MediaFile, position int) {
p.npMu.Lock()
defer p.npMu.Unlock()
ctx = context.WithoutCancel(ctx) // Prevent cancellation from affecting background processing
p.npQueue[playerId] = nowPlayingEntry{
ctx: ctx,
userId: userId,
track: track,
position: position,
}
p.sendNowPlayingSignal()
}
func (p *playTracker) sendNowPlayingSignal() {
// Don't block if the previous signal was not read yet
select {
case p.npSignal <- struct{}{}:
default:
}
}
func (p *playTracker) nowPlayingWorker() {
defer close(p.workerDone)
for {
select {
case <-p.shutdown:
return
case <-time.After(time.Second):
case <-p.npSignal:
}
p.npMu.Lock()
if len(p.npQueue) == 0 {
p.npMu.Unlock()
continue
}
// Keep a copy of the entries to process and clear the queue
entries := p.npQueue
p.npQueue = make(map[string]nowPlayingEntry)
p.npMu.Unlock()
// Process entries without holding lock
for _, entry := range entries {
p.dispatchNowPlaying(entry.ctx, entry.userId, entry.track, entry.position)
}
}
}
func (p *playTracker) dispatchNowPlaying(ctx context.Context, userId string, t *model.MediaFile, position int) {
if t.Artist == consts.UnknownArtist {
log.Debug(ctx, "Ignoring external NowPlaying update for track with unknown artist", "track", t.Title, "artist", t.Artist)
@@ -276,8 +348,14 @@ func (p *playTracker) incPlay(ctx context.Context, track *model.MediaFile, times
}
for _, artist := range track.Participants[model.RoleArtist] {
err = tx.Artist(ctx).IncPlayCount(artist.ID, timestamp)
if err != nil {
return err
}
}
return err
if conf.Server.EnableScrobbleHistory {
return tx.Scrobble(ctx).RecordScrobble(track.ID, timestamp)
}
return nil
})
}

View File

@@ -24,15 +24,26 @@ import (
// Moved to top-level scope to avoid linter issues
type mockPluginLoader struct {
mu sync.RWMutex
names []string
scrobblers map[string]Scrobbler
}
func (m *mockPluginLoader) PluginNames(service string) []string {
m.mu.RLock()
defer m.mu.RUnlock()
return m.names
}
func (m *mockPluginLoader) SetNames(names []string) {
m.mu.Lock()
defer m.mu.Unlock()
m.names = names
}
func (m *mockPluginLoader) LoadScrobbler(name string) (Scrobbler, bool) {
m.mu.RLock()
defer m.mu.RUnlock()
s, ok := m.scrobblers[name]
return s, ok
}
@@ -46,24 +57,24 @@ var _ = Describe("PlayTracker", func() {
var album model.Album
var artist1 model.Artist
var artist2 model.Artist
var fake fakeScrobbler
var fake *fakeScrobbler
BeforeEach(func() {
DeferCleanup(configtest.SetupConfig())
ctx = context.Background()
ctx = GinkgoT().Context()
ctx = request.WithUser(ctx, model.User{ID: "u-1"})
ctx = request.WithPlayer(ctx, model.Player{ScrobbleEnabled: true})
ds = &tests.MockDataStore{}
fake = fakeScrobbler{Authorized: true}
fake = &fakeScrobbler{Authorized: true}
Register("fake", func(model.DataStore) Scrobbler {
return &fake
return fake
})
Register("disabled", func(model.DataStore) Scrobbler {
return nil
})
eventBroker = &fakeEventBroker{}
tracker = newPlayTracker(ds, eventBroker, nil)
tracker.(*playTracker).builtinScrobblers["fake"] = &fake // Bypass buffering for tests
tracker.(*playTracker).builtinScrobblers["fake"] = fake // Bypass buffering for tests
track = model.MediaFile{
ID: "123",
@@ -86,6 +97,11 @@ var _ = Describe("PlayTracker", func() {
_ = ds.Album(ctx).(*tests.MockAlbumRepo).Put(&album)
})
AfterEach(func() {
// Stop the worker goroutine to prevent data races between tests
tracker.(*playTracker).stopNowPlayingWorker()
})
It("does not register disabled scrobblers", func() {
Expect(tracker.(*playTracker).builtinScrobblers).To(HaveKey("fake"))
Expect(tracker.(*playTracker).builtinScrobblers).ToNot(HaveKey("disabled"))
@@ -95,10 +111,10 @@ var _ = Describe("PlayTracker", func() {
It("sends track to agent", func() {
err := tracker.NowPlaying(ctx, "player-1", "player-one", "123", 0)
Expect(err).ToNot(HaveOccurred())
Expect(fake.NowPlayingCalled).To(BeTrue())
Expect(fake.UserID).To(Equal("u-1"))
Expect(fake.Track.ID).To(Equal("123"))
Expect(fake.Track.Participants).To(Equal(track.Participants))
Eventually(func() bool { return fake.GetNowPlayingCalled() }).Should(BeTrue())
Expect(fake.GetUserID()).To(Equal("u-1"))
Expect(fake.GetTrack().ID).To(Equal("123"))
Expect(fake.GetTrack().Participants).To(Equal(track.Participants))
})
It("does not send track to agent if user has not authorized", func() {
fake.Authorized = false
@@ -106,7 +122,7 @@ var _ = Describe("PlayTracker", func() {
err := tracker.NowPlaying(ctx, "player-1", "player-one", "123", 0)
Expect(err).ToNot(HaveOccurred())
Expect(fake.NowPlayingCalled).To(BeFalse())
Expect(fake.GetNowPlayingCalled()).To(BeFalse())
})
It("does not send track to agent if player is not enabled to send scrobbles", func() {
ctx = request.WithPlayer(ctx, model.Player{ScrobbleEnabled: false})
@@ -114,7 +130,7 @@ var _ = Describe("PlayTracker", func() {
err := tracker.NowPlaying(ctx, "player-1", "player-one", "123", 0)
Expect(err).ToNot(HaveOccurred())
Expect(fake.NowPlayingCalled).To(BeFalse())
Expect(fake.GetNowPlayingCalled()).To(BeFalse())
})
It("does not send track to agent if artist is unknown", func() {
track.Artist = consts.UnknownArtist
@@ -122,7 +138,7 @@ var _ = Describe("PlayTracker", func() {
err := tracker.NowPlaying(ctx, "player-1", "player-one", "123", 0)
Expect(err).ToNot(HaveOccurred())
Expect(fake.NowPlayingCalled).To(BeFalse())
Expect(fake.GetNowPlayingCalled()).To(BeFalse())
})
It("stores position when greater than zero", func() {
@@ -130,11 +146,12 @@ var _ = Describe("PlayTracker", func() {
err := tracker.NowPlaying(ctx, "player-1", "player-one", "123", pos)
Expect(err).ToNot(HaveOccurred())
Eventually(func() int { return fake.GetPosition() }).Should(Equal(pos))
playing, err := tracker.GetNowPlaying(ctx)
Expect(err).ToNot(HaveOccurred())
Expect(playing).To(HaveLen(1))
Expect(playing[0].Position).To(Equal(pos))
Expect(fake.Position).To(Equal(pos))
})
It("sends event with count", func() {
@@ -153,6 +170,17 @@ var _ = Describe("PlayTracker", func() {
Expect(err).ToNot(HaveOccurred())
Expect(eventBroker.getEvents()).To(BeEmpty())
})
It("passes user to scrobbler via context (fix for issue #4787)", func() {
ctx = request.WithUser(ctx, model.User{ID: "u-1", UserName: "testuser"})
ctx = request.WithPlayer(ctx, model.Player{ScrobbleEnabled: true})
err := tracker.NowPlaying(ctx, "player-1", "player-one", "123", 0)
Expect(err).ToNot(HaveOccurred())
Eventually(func() bool { return fake.GetNowPlayingCalled() }).Should(BeTrue())
// Verify the username was passed through async dispatch via context
Eventually(func() string { return fake.GetUsername() }).Should(Equal("testuser"))
})
})
Describe("GetNowPlaying", func() {
@@ -160,9 +188,9 @@ var _ = Describe("PlayTracker", func() {
track2 := track
track2.ID = "456"
_ = ds.MediaFile(ctx).Put(&track2)
ctx = request.WithUser(context.Background(), model.User{UserName: "user-1"})
ctx = request.WithUser(GinkgoT().Context(), model.User{UserName: "user-1"})
_ = tracker.NowPlaying(ctx, "player-1", "player-one", "123", 0)
ctx = request.WithUser(context.Background(), model.User{UserName: "user-2"})
ctx = request.WithUser(GinkgoT().Context(), model.User{UserName: "user-2"})
_ = tracker.NowPlaying(ctx, "player-2", "player-two", "456", 0)
playing, err := tracker.GetNowPlaying(ctx)
@@ -210,7 +238,7 @@ var _ = Describe("PlayTracker", func() {
Expect(err).ToNot(HaveOccurred())
Expect(fake.ScrobbleCalled.Load()).To(BeTrue())
Expect(fake.UserID).To(Equal("u-1"))
Expect(fake.GetUserID()).To(Equal("u-1"))
lastScrobble := fake.LastScrobble.Load()
Expect(lastScrobble.TimeStamp).To(BeTemporally("~", ts, 1*time.Second))
Expect(lastScrobble.ID).To(Equal("123"))
@@ -274,49 +302,82 @@ var _ = Describe("PlayTracker", func() {
Expect(artist1.PlayCount).To(Equal(int64(1)))
Expect(artist2.PlayCount).To(Equal(int64(1)))
})
Context("Scrobble History", func() {
It("records scrobble in repository", func() {
conf.Server.EnableScrobbleHistory = true
ctx = request.WithUser(ctx, model.User{ID: "u-1", UserName: "user-1"})
ts := time.Now()
err := tracker.Submit(ctx, []Submission{{TrackID: "123", Timestamp: ts}})
Expect(err).ToNot(HaveOccurred())
mockDS := ds.(*tests.MockDataStore)
mockScrobble := mockDS.Scrobble(ctx).(*tests.MockScrobbleRepo)
Expect(mockScrobble.RecordedScrobbles).To(HaveLen(1))
Expect(mockScrobble.RecordedScrobbles[0].MediaFileID).To(Equal("123"))
Expect(mockScrobble.RecordedScrobbles[0].UserID).To(Equal("u-1"))
Expect(mockScrobble.RecordedScrobbles[0].SubmissionTime).To(Equal(ts))
})
It("does not record scrobble when history is disabled", func() {
conf.Server.EnableScrobbleHistory = false
ctx = request.WithUser(ctx, model.User{ID: "u-1", UserName: "user-1"})
ts := time.Now()
err := tracker.Submit(ctx, []Submission{{TrackID: "123", Timestamp: ts}})
Expect(err).ToNot(HaveOccurred())
mockDS := ds.(*tests.MockDataStore)
mockScrobble := mockDS.Scrobble(ctx).(*tests.MockScrobbleRepo)
Expect(mockScrobble.RecordedScrobbles).To(HaveLen(0))
})
})
})
Describe("Plugin scrobbler logic", func() {
var pluginLoader *mockPluginLoader
var pluginFake fakeScrobbler
var pluginFake *fakeScrobbler
BeforeEach(func() {
pluginFake = fakeScrobbler{Authorized: true}
pluginFake = &fakeScrobbler{Authorized: true}
pluginLoader = &mockPluginLoader{
names: []string{"plugin1"},
scrobblers: map[string]Scrobbler{"plugin1": &pluginFake},
scrobblers: map[string]Scrobbler{"plugin1": pluginFake},
}
tracker = newPlayTracker(ds, events.GetBroker(), pluginLoader)
// Bypass buffering for both built-in and plugin scrobblers
tracker.(*playTracker).builtinScrobblers["fake"] = &fake
tracker.(*playTracker).pluginScrobblers["plugin1"] = &pluginFake
tracker.(*playTracker).builtinScrobblers["fake"] = fake
tracker.(*playTracker).pluginScrobblers["plugin1"] = pluginFake
})
It("registers and uses plugin scrobbler for NowPlaying", func() {
err := tracker.NowPlaying(ctx, "player-1", "player-one", "123", 0)
Expect(err).ToNot(HaveOccurred())
Expect(pluginFake.NowPlayingCalled).To(BeTrue())
Eventually(func() bool { return pluginFake.GetNowPlayingCalled() }).Should(BeTrue())
})
It("removes plugin scrobbler if not present anymore", func() {
// First call: plugin present
_ = tracker.NowPlaying(ctx, "player-1", "player-one", "123", 0)
Expect(pluginFake.NowPlayingCalled).To(BeTrue())
pluginFake.NowPlayingCalled = false
Eventually(func() bool { return pluginFake.GetNowPlayingCalled() }).Should(BeTrue())
pluginFake.nowPlayingCalled.Store(false)
// Remove plugin
pluginLoader.names = []string{}
pluginLoader.SetNames([]string{})
_ = tracker.NowPlaying(ctx, "player-1", "player-one", "123", 0)
Expect(pluginFake.NowPlayingCalled).To(BeFalse())
// Should not be called since plugin was removed
Consistently(func() bool { return pluginFake.GetNowPlayingCalled() }).Should(BeFalse())
})
It("calls both builtin and plugin scrobblers for NowPlaying", func() {
fake.NowPlayingCalled = false
pluginFake.NowPlayingCalled = false
fake.nowPlayingCalled.Store(false)
pluginFake.nowPlayingCalled.Store(false)
err := tracker.NowPlaying(ctx, "player-1", "player-one", "123", 0)
Expect(err).ToNot(HaveOccurred())
Expect(fake.NowPlayingCalled).To(BeTrue())
Expect(pluginFake.NowPlayingCalled).To(BeTrue())
Eventually(func() bool { return fake.GetNowPlayingCalled() }).Should(BeTrue())
Eventually(func() bool { return pluginFake.GetNowPlayingCalled() }).Should(BeTrue())
})
It("calls plugin scrobbler for Submit", func() {
@@ -334,7 +395,7 @@ var _ = Describe("PlayTracker", func() {
var mockedBS *mockBufferedScrobbler
BeforeEach(func() {
ctx = context.Background()
ctx = GinkgoT().Context()
ctx = request.WithUser(ctx, model.User{ID: "u-1"})
ctx = request.WithPlayer(ctx, model.Player{ScrobbleEnabled: true})
ds = &tests.MockDataStore{}
@@ -359,7 +420,7 @@ var _ = Describe("PlayTracker", func() {
It("calls Stop on scrobblers when removing them", func() {
// Change the plugin names to simulate a plugin being removed
mockPlugin.names = []string{}
mockPlugin.SetNames([]string{})
// Call refreshPluginScrobblers which should detect the removed plugin
pTracker.refreshPluginScrobblers()
@@ -375,32 +436,69 @@ var _ = Describe("PlayTracker", func() {
type fakeScrobbler struct {
Authorized bool
NowPlayingCalled bool
nowPlayingCalled atomic.Bool
ScrobbleCalled atomic.Bool
UserID string
Track *model.MediaFile
Position int
userID atomic.Pointer[string]
username atomic.Pointer[string]
track atomic.Pointer[model.MediaFile]
position atomic.Int32
LastScrobble atomic.Pointer[Scrobble]
Error error
}
func (f *fakeScrobbler) GetNowPlayingCalled() bool {
return f.nowPlayingCalled.Load()
}
func (f *fakeScrobbler) GetUserID() string {
if p := f.userID.Load(); p != nil {
return *p
}
return ""
}
func (f *fakeScrobbler) GetTrack() *model.MediaFile {
return f.track.Load()
}
func (f *fakeScrobbler) GetPosition() int {
return int(f.position.Load())
}
func (f *fakeScrobbler) GetUsername() string {
if p := f.username.Load(); p != nil {
return *p
}
return ""
}
func (f *fakeScrobbler) IsAuthorized(ctx context.Context, userId string) bool {
return f.Error == nil && f.Authorized
}
func (f *fakeScrobbler) NowPlaying(ctx context.Context, userId string, track *model.MediaFile, position int) error {
f.NowPlayingCalled = true
f.nowPlayingCalled.Store(true)
if f.Error != nil {
return f.Error
}
f.UserID = userId
f.Track = track
f.Position = position
f.userID.Store(&userId)
// Capture username from context (this is what plugin scrobblers do)
username, _ := request.UsernameFrom(ctx)
if username == "" {
if u, ok := request.UserFrom(ctx); ok {
username = u.UserName
}
}
if username != "" {
f.username.Store(&username)
}
f.track.Store(track)
f.position.Store(int32(position))
return nil
}
func (f *fakeScrobbler) Scrobble(ctx context.Context, userId string, s Scrobble) error {
f.UserID = userId
f.userID.Store(&userId)
f.LastScrobble.Store(&s)
f.ScrobbleCalled.Store(true)
if f.Error != nil {

View File

@@ -13,6 +13,7 @@ import (
"github.com/navidrome/navidrome/model"
. "github.com/navidrome/navidrome/utils/gg"
"github.com/navidrome/navidrome/utils/slice"
"github.com/navidrome/navidrome/utils/str"
)
type Share interface {
@@ -119,9 +120,8 @@ func (r *shareRepositoryWrapper) Save(entity interface{}) (string, error) {
log.Error(r.ctx, "Invalid Resource ID", "id", firstId)
return "", model.ErrNotFound
}
if len(s.Contents) > 30 {
s.Contents = s.Contents[:26] + "..."
}
s.Contents = str.TruncateRunes(s.Contents, 30, "...")
id, err = r.Persistable.Save(s)
return id, err

View File

@@ -38,6 +38,38 @@ var _ = Describe("Share", func() {
Expect(id).ToNot(BeEmpty())
Expect(entity.ID).To(Equal(id))
})
It("does not truncate ASCII labels shorter than 30 characters", func() {
_ = ds.MediaFile(ctx).Put(&model.MediaFile{ID: "456", Title: "Example Media File"})
entity := &model.Share{Description: "test", ResourceIDs: "456"}
_, err := repo.Save(entity)
Expect(err).ToNot(HaveOccurred())
Expect(entity.Contents).To(Equal("Example Media File"))
})
It("truncates ASCII labels longer than 30 characters", func() {
_ = ds.MediaFile(ctx).Put(&model.MediaFile{ID: "789", Title: "Example Media File But The Title Is Really Long For Testing Purposes"})
entity := &model.Share{Description: "test", ResourceIDs: "789"}
_, err := repo.Save(entity)
Expect(err).ToNot(HaveOccurred())
Expect(entity.Contents).To(Equal("Example Media File But The ..."))
})
It("does not truncate CJK labels shorter than 30 runes", func() {
_ = ds.MediaFile(ctx).Put(&model.MediaFile{ID: "456", Title: "青春コンプレックス"})
entity := &model.Share{Description: "test", ResourceIDs: "456"}
_, err := repo.Save(entity)
Expect(err).ToNot(HaveOccurred())
Expect(entity.Contents).To(Equal("青春コンプレックス"))
})
It("truncates CJK labels longer than 30 runes", func() {
_ = ds.MediaFile(ctx).Put(&model.MediaFile{ID: "789", Title: "私の中の幻想的世界観及びその顕現を想起させたある現実での出来事に関する一考察"})
entity := &model.Share{Description: "test", ResourceIDs: "789"}
_, err := repo.Save(entity)
Expect(err).ToNot(HaveOccurred())
Expect(entity.Contents).To(Equal("私の中の幻想的世界観及びその顕現を想起させたある現実で..."))
})
})
Describe("Update", func() {

View File

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

View File

@@ -1,168 +1,167 @@
package db
//
//import (
// "context"
// "database/sql"
// "errors"
// "fmt"
// "os"
// "path/filepath"
// "regexp"
// "slices"
// "time"
//
// "github.com/mattn/go-sqlite3"
// "github.com/navidrome/navidrome/conf"
// "github.com/navidrome/navidrome/log"
//)
//
//const (
// backupPrefix = "navidrome_backup"
// backupRegexString = backupPrefix + "_(.+)\\.db"
//)
//
//var backupRegex = regexp.MustCompile(backupRegexString)
//
//const backupSuffixLayout = "2006.01.02_15.04.05"
//
//func backupPath(t time.Time) string {
// return filepath.Join(
// conf.Server.Backup.Path,
// fmt.Sprintf("%s_%s.db", backupPrefix, t.Format(backupSuffixLayout)),
// )
//}
//
//func backupOrRestore(ctx context.Context, isBackup bool, path string) error {
// // heavily inspired by https://codingrabbits.dev/posts/go_and_sqlite_backup_and_maybe_restore/
// existingConn, err := Db().Conn(ctx)
// if err != nil {
// return fmt.Errorf("getting existing connection: %w", err)
// }
// defer existingConn.Close()
//
// backupDb, err := sql.Open(Driver, path)
// if err != nil {
// return fmt.Errorf("opening backup database in '%s': %w", path, err)
// }
// defer backupDb.Close()
//
// backupConn, err := backupDb.Conn(ctx)
// if err != nil {
// return fmt.Errorf("getting backup connection: %w", err)
// }
// defer backupConn.Close()
//
// err = existingConn.Raw(func(existing any) error {
// return backupConn.Raw(func(backup any) error {
// var sourceOk, destOk bool
// var sourceConn, destConn *sqlite3.SQLiteConn
//
// if isBackup {
// sourceConn, sourceOk = existing.(*sqlite3.SQLiteConn)
// destConn, destOk = backup.(*sqlite3.SQLiteConn)
// } else {
// sourceConn, sourceOk = backup.(*sqlite3.SQLiteConn)
// destConn, destOk = existing.(*sqlite3.SQLiteConn)
// }
//
// if !sourceOk {
// return fmt.Errorf("error trying to convert source to sqlite connection")
// }
// if !destOk {
// return fmt.Errorf("error trying to convert destination to sqlite connection")
// }
//
// backupOp, err := destConn.Backup("main", sourceConn, "main")
// if err != nil {
// return fmt.Errorf("error starting sqlite backup: %w", err)
// }
// defer backupOp.Close()
//
// // Caution: -1 means that sqlite will hold a read lock until the operation finishes
// // This will lock out other writes that could happen at the same time
// done, err := backupOp.Step(-1)
// if !done {
// return fmt.Errorf("backup not done with step -1")
// }
// if err != nil {
// return fmt.Errorf("error during backup step: %w", err)
// }
//
// err = backupOp.Finish()
// if err != nil {
// return fmt.Errorf("error finishing backup: %w", err)
// }
//
// return nil
// })
// })
//
// return err
//}
//
//func Backup(ctx context.Context) (string, error) {
// destPath := backupPath(time.Now())
// log.Debug(ctx, "Creating backup", "path", destPath)
// err := backupOrRestore(ctx, true, destPath)
// if err != nil {
// return "", err
// }
//
// return destPath, nil
//}
//
//func Restore(ctx context.Context, path string) error {
// log.Debug(ctx, "Restoring backup", "path", path)
// return backupOrRestore(ctx, false, path)
//}
//
//func Prune(ctx context.Context) (int, error) {
// files, err := os.ReadDir(conf.Server.Backup.Path)
// if err != nil {
// return 0, fmt.Errorf("unable to read database backup entries: %w", err)
// }
//
// var backupTimes []time.Time
//
// for _, file := range files {
// if !file.IsDir() {
// submatch := backupRegex.FindStringSubmatch(file.Name())
// if len(submatch) == 2 {
// timestamp, err := time.Parse(backupSuffixLayout, submatch[1])
// if err == nil {
// backupTimes = append(backupTimes, timestamp)
// }
// }
// }
// }
//
// if len(backupTimes) <= conf.Server.Backup.Count {
// return 0, nil
// }
//
// slices.SortFunc(backupTimes, func(a, b time.Time) int {
// return b.Compare(a)
// })
//
// pruneCount := 0
// var errs []error
//
// for _, timeToPrune := range backupTimes[conf.Server.Backup.Count:] {
// log.Debug(ctx, "Pruning backup", "time", timeToPrune)
// path := backupPath(timeToPrune)
// err = os.Remove(path)
// if err != nil {
// errs = append(errs, err)
// } else {
// pruneCount++
// }
// }
//
// if len(errs) > 0 {
// err = errors.Join(errs...)
// log.Error(ctx, "Failed to delete one or more files", "errors", err)
// }
//
// return pruneCount, err
//}
import (
"context"
"database/sql"
"errors"
"fmt"
"os"
"path/filepath"
"regexp"
"slices"
"time"
"github.com/mattn/go-sqlite3"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/log"
)
const (
backupPrefix = "navidrome_backup"
backupRegexString = backupPrefix + "_(.+)\\.db"
)
var backupRegex = regexp.MustCompile(backupRegexString)
const backupSuffixLayout = "2006.01.02_15.04.05"
func backupPath(t time.Time) string {
return filepath.Join(
conf.Server.Backup.Path,
fmt.Sprintf("%s_%s.db", backupPrefix, t.Format(backupSuffixLayout)),
)
}
func backupOrRestore(ctx context.Context, isBackup bool, path string) error {
// heavily inspired by https://codingrabbits.dev/posts/go_and_sqlite_backup_and_maybe_restore/
existingConn, err := Db().Conn(ctx)
if err != nil {
return fmt.Errorf("getting existing connection: %w", err)
}
defer existingConn.Close()
backupDb, err := sql.Open(Driver, path)
if err != nil {
return fmt.Errorf("opening backup database in '%s': %w", path, err)
}
defer backupDb.Close()
backupConn, err := backupDb.Conn(ctx)
if err != nil {
return fmt.Errorf("getting backup connection: %w", err)
}
defer backupConn.Close()
err = existingConn.Raw(func(existing any) error {
return backupConn.Raw(func(backup any) error {
var sourceOk, destOk bool
var sourceConn, destConn *sqlite3.SQLiteConn
if isBackup {
sourceConn, sourceOk = existing.(*sqlite3.SQLiteConn)
destConn, destOk = backup.(*sqlite3.SQLiteConn)
} else {
sourceConn, sourceOk = backup.(*sqlite3.SQLiteConn)
destConn, destOk = existing.(*sqlite3.SQLiteConn)
}
if !sourceOk {
return fmt.Errorf("error trying to convert source to sqlite connection")
}
if !destOk {
return fmt.Errorf("error trying to convert destination to sqlite connection")
}
backupOp, err := destConn.Backup("main", sourceConn, "main")
if err != nil {
return fmt.Errorf("error starting sqlite backup: %w", err)
}
defer backupOp.Close()
// Caution: -1 means that sqlite will hold a read lock until the operation finishes
// This will lock out other writes that could happen at the same time
done, err := backupOp.Step(-1)
if !done {
return fmt.Errorf("backup not done with step -1")
}
if err != nil {
return fmt.Errorf("error during backup step: %w", err)
}
err = backupOp.Finish()
if err != nil {
return fmt.Errorf("error finishing backup: %w", err)
}
return nil
})
})
return err
}
func Backup(ctx context.Context) (string, error) {
destPath := backupPath(time.Now())
log.Debug(ctx, "Creating backup", "path", destPath)
err := backupOrRestore(ctx, true, destPath)
if err != nil {
return "", err
}
return destPath, nil
}
func Restore(ctx context.Context, path string) error {
log.Debug(ctx, "Restoring backup", "path", path)
return backupOrRestore(ctx, false, path)
}
func Prune(ctx context.Context) (int, error) {
files, err := os.ReadDir(conf.Server.Backup.Path)
if err != nil {
return 0, fmt.Errorf("unable to read database backup entries: %w", err)
}
var backupTimes []time.Time
for _, file := range files {
if !file.IsDir() {
submatch := backupRegex.FindStringSubmatch(file.Name())
if len(submatch) == 2 {
timestamp, err := time.Parse(backupSuffixLayout, submatch[1])
if err == nil {
backupTimes = append(backupTimes, timestamp)
}
}
}
}
if len(backupTimes) <= conf.Server.Backup.Count {
return 0, nil
}
slices.SortFunc(backupTimes, func(a, b time.Time) int {
return b.Compare(a)
})
pruneCount := 0
var errs []error
for _, timeToPrune := range backupTimes[conf.Server.Backup.Count:] {
log.Debug(ctx, "Pruning backup", "time", timeToPrune)
path := backupPath(timeToPrune)
err = os.Remove(path)
if err != nil {
errs = append(errs, err)
} else {
pruneCount++
}
}
if len(errs) > 0 {
err = errors.Join(errs...)
log.Error(ctx, "Failed to delete one or more files", "errors", err)
}
return pruneCount, err
}

169
db/db.go
View File

@@ -5,22 +5,20 @@ import (
"database/sql"
"embed"
"fmt"
"path/filepath"
"strings"
"time"
"runtime"
embeddedpostgres "github.com/fergusstrange/embedded-postgres"
_ "github.com/jackc/pgx/v5/stdlib"
"github.com/mattn/go-sqlite3"
"github.com/navidrome/navidrome/conf"
_ "github.com/navidrome/navidrome/db/migrations"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/utils/hasher"
"github.com/navidrome/navidrome/utils/singleton"
"github.com/pressly/goose/v3"
)
var (
Dialect = "postgres"
Driver = "pgx"
Dialect = "sqlite3"
Driver = Dialect + "_custom"
Path string
)
@@ -29,77 +27,31 @@ var embedMigrations embed.FS
const migrationsFolder = "migrations"
var postgresInstance *embeddedpostgres.EmbeddedPostgres
func Db() *sql.DB {
return singleton.GetInstance(func() *sql.DB {
start := time.Now()
log.Info("Starting Embedded Postgres...")
postgresInstance = embeddedpostgres.NewDatabase(
embeddedpostgres.
DefaultConfig().
Port(5432).
//Password(password).
Logger(&logAdapter{ctx: context.Background()}).
DataPath(filepath.Join(conf.Server.DataFolder, "postgres")).
StartParameters(map[string]string{
"unix_socket_directories": "/tmp",
"unix_socket_permissions": "0700",
}).
BinariesPath(filepath.Join(conf.Server.CacheFolder, "postgres")),
)
if err := postgresInstance.Start(); err != nil {
if !strings.Contains(err.Error(), "already listening on port") {
_ = postgresInstance.Stop()
log.Fatal("Failed to start embedded Postgres", err)
}
log.Info("Server already running on port 5432, assuming it's our embedded Postgres", "elapsed", time.Since(start))
} else {
log.Info("Embedded Postgres started", "elapsed", time.Since(start))
sql.Register(Driver, &sqlite3.SQLiteDriver{
ConnectHook: func(conn *sqlite3.SQLiteConn) error {
return conn.RegisterFunc("SEEDEDRAND", hasher.HashFunc(), false)
},
})
Path = conf.Server.DbPath
if Path == ":memory:" {
Path = "file::memory:?cache=shared&_foreign_keys=on"
conf.Server.DbPath = Path
}
// Create the navidrome database if it doesn't exist
adminPath := "postgresql://postgres:postgres@/postgres?sslmode=disable&host=/tmp"
adminDB, err := sql.Open(Driver, adminPath)
if err != nil {
_ = postgresInstance.Stop()
log.Fatal("Error connecting to admin database", err)
}
defer adminDB.Close()
// Check if navidrome database exists, create if not
var exists bool
err = adminDB.QueryRow("SELECT EXISTS(SELECT 1 FROM pg_database WHERE datname = 'navidrome')").Scan(&exists)
if err != nil {
_ = postgresInstance.Stop()
log.Fatal("Error checking if database exists", err)
}
if !exists {
log.Info("Creating navidrome database...")
_, err = adminDB.Exec("CREATE DATABASE navidrome")
if err != nil {
_ = postgresInstance.Stop()
log.Fatal("Error creating navidrome database", err)
}
}
// TODO: Implement seeded random function
//sql.Register(Driver, &sqlite3.SQLiteDriver{
// ConnectHook: func(conn *sqlite3.SQLiteConn) error {
// return conn.RegisterFunc("SEEDEDRAND", hasher.HashFunc(), false)
// },
//})
//Path = conf.Server.DbPath
// Ensure client does not attempt TLS when connecting to the embedded Postgres
// and avoid shadowing the package-level Path variable.
Path = "postgresql://postgres:postgres@/navidrome?sslmode=disable&host=/tmp"
log.Debug("Opening DataBase", "dbPath", Path, "driver", Driver)
db, err := sql.Open(Driver, Path)
//db.SetMaxOpenConns(max(4, runtime.NumCPU()))
db.SetMaxOpenConns(max(4, runtime.NumCPU()))
if err != nil {
_ = postgresInstance.Stop()
log.Fatal("Error opening database", err)
}
if conf.Server.DevOptimizeDB {
_, err = db.Exec("PRAGMA optimize=0x10002")
if err != nil {
log.Error("Error applying PRAGMA optimize", err)
return nil
}
}
return db
})
}
@@ -108,24 +60,33 @@ func Close(ctx context.Context) {
// Ignore cancellations when closing the DB
ctx = context.WithoutCancel(ctx)
// Run optimize before closing
Optimize(ctx)
log.Info(ctx, "Closing Database")
err := Db().Close()
if err != nil {
log.Error(ctx, "Error closing Database", err)
}
if postgresInstance != nil {
err = postgresInstance.Stop()
if err != nil {
log.Error(ctx, "Error stopping embedded Postgres", err)
}
}
}
func Init(ctx context.Context) func() {
db := Db()
// Disable foreign_keys to allow re-creating tables in migrations
_, err := db.ExecContext(ctx, "PRAGMA foreign_keys=off")
defer func() {
_, err := db.ExecContext(ctx, "PRAGMA foreign_keys=on")
if err != nil {
log.Error(ctx, "Error re-enabling foreign_keys", err)
}
}()
if err != nil {
log.Error(ctx, "Error disabling foreign_keys", err)
}
goose.SetBaseFS(embedMigrations)
err := goose.SetDialect(Dialect)
err = goose.SetDialect(Dialect)
if err != nil {
log.Fatal(ctx, "Invalid DB driver", "driver", Driver, err)
}
@@ -140,17 +101,54 @@ func Init(ctx context.Context) func() {
log.Fatal(ctx, "Failed to apply new migrations", err)
}
if hasSchemaChanges && conf.Server.DevOptimizeDB {
log.Debug(ctx, "Applying PRAGMA optimize after schema changes")
_, err = db.ExecContext(ctx, "PRAGMA optimize")
if err != nil {
log.Error(ctx, "Error applying PRAGMA optimize", err)
}
}
return func() {
Close(ctx)
}
}
// Optimize runs PRAGMA optimize on each connection in the pool
func Optimize(ctx context.Context) {
if !conf.Server.DevOptimizeDB {
return
}
numConns := Db().Stats().OpenConnections
if numConns == 0 {
log.Debug(ctx, "No open connections to optimize")
return
}
log.Debug(ctx, "Optimizing open connections", "numConns", numConns)
var conns []*sql.Conn
for i := 0; i < numConns; i++ {
conn, err := Db().Conn(ctx)
conns = append(conns, conn)
if err != nil {
log.Error(ctx, "Error getting connection from pool", err)
continue
}
_, err = conn.ExecContext(ctx, "PRAGMA optimize;")
if err != nil {
log.Error(ctx, "Error running PRAGMA optimize", err)
}
}
// Return all connections to the Connection Pool
for _, conn := range conns {
conn.Close()
}
}
type statusLogger struct{ numPending int }
func (*statusLogger) Fatalf(format string, v ...interface{}) { log.Fatal(fmt.Sprintf(format, v...)) }
func (l *statusLogger) Printf(format string, v ...interface{}) {
// format is part of the goose logger signature; reference it to avoid linter warnings
_ = format
if len(v) < 1 {
return
}
@@ -172,15 +170,11 @@ func hasPendingMigrations(ctx context.Context, db *sql.DB, folder string) bool {
}
func isSchemaEmpty(ctx context.Context, db *sql.DB) bool {
rows, err := db.QueryContext(ctx, "SELECT tablename FROM pg_tables WHERE schemaname = 'public' AND tablename = 'goose_db_version';") // nolint:rowserrcheck
rows, err := db.QueryContext(ctx, "SELECT name FROM sqlite_master WHERE type='table' AND name='goose_db_version';") // nolint:rowserrcheck
if err != nil {
log.Fatal(ctx, "Database could not be opened!", err)
}
defer func() {
if cerr := rows.Close(); cerr != nil {
log.Error(ctx, "Error closing rows", cerr)
}
}()
defer rows.Close()
return !rows.Next()
}
@@ -189,11 +183,6 @@ type logAdapter struct {
silent bool
}
func (l *logAdapter) Write(p []byte) (n int, err error) {
log.Debug(l.ctx, string(p))
return len(p), nil
}
func (l *logAdapter) Fatal(v ...interface{}) {
log.Fatal(l.ctx, fmt.Sprint(v...))
}

View File

@@ -0,0 +1,184 @@
package migrations
import (
"context"
"database/sql"
"github.com/navidrome/navidrome/log"
"github.com/pressly/goose/v3"
)
func init() {
goose.AddMigrationContext(Up20200130083147, Down20200130083147)
}
func Up20200130083147(_ context.Context, tx *sql.Tx) error {
log.Info("Creating DB Schema")
_, err := tx.Exec(`
create table if not exists album
(
id varchar(255) not null
primary key,
name varchar(255) default '' not null,
artist_id varchar(255) default '' not null,
cover_art_path varchar(255) default '' not null,
cover_art_id varchar(255) default '' not null,
artist varchar(255) default '' not null,
album_artist varchar(255) default '' not null,
year integer default 0 not null,
compilation bool default FALSE not null,
song_count integer default 0 not null,
duration integer default 0 not null,
genre varchar(255) default '' not null,
created_at datetime,
updated_at datetime
);
create index if not exists album_artist
on album (artist);
create index if not exists album_artist_id
on album (artist_id);
create index if not exists album_genre
on album (genre);
create index if not exists album_name
on album (name);
create index if not exists album_year
on album (year);
create table if not exists annotation
(
ann_id varchar(255) not null
primary key,
user_id varchar(255) default '' not null,
item_id varchar(255) default '' not null,
item_type varchar(255) default '' not null,
play_count integer,
play_date datetime,
rating integer,
starred bool default FALSE not null,
starred_at datetime,
unique (user_id, item_id, item_type)
);
create index if not exists annotation_play_count
on annotation (play_count);
create index if not exists annotation_play_date
on annotation (play_date);
create index if not exists annotation_rating
on annotation (rating);
create index if not exists annotation_starred
on annotation (starred);
create table if not exists artist
(
id varchar(255) not null
primary key,
name varchar(255) default '' not null,
album_count integer default 0 not null
);
create index if not exists artist_name
on artist (name);
create table if not exists media_file
(
id varchar(255) not null
primary key,
path varchar(255) default '' not null,
title varchar(255) default '' not null,
album varchar(255) default '' not null,
artist varchar(255) default '' not null,
artist_id varchar(255) default '' not null,
album_artist varchar(255) default '' not null,
album_id varchar(255) default '' not null,
has_cover_art bool default FALSE not null,
track_number integer default 0 not null,
disc_number integer default 0 not null,
year integer default 0 not null,
size integer default 0 not null,
suffix varchar(255) default '' not null,
duration integer default 0 not null,
bit_rate integer default 0 not null,
genre varchar(255) default '' not null,
compilation bool default FALSE not null,
created_at datetime,
updated_at datetime
);
create index if not exists media_file_album_id
on media_file (album_id);
create index if not exists media_file_genre
on media_file (genre);
create index if not exists media_file_path
on media_file (path);
create index if not exists media_file_title
on media_file (title);
create table if not exists playlist
(
id varchar(255) not null
primary key,
name varchar(255) default '' not null,
comment varchar(255) default '' not null,
duration integer default 0 not null,
owner varchar(255) default '' not null,
public bool default FALSE not null,
tracks text not null
);
create index if not exists playlist_name
on playlist (name);
create table if not exists property
(
id varchar(255) not null
primary key,
value varchar(255) default '' not null
);
create table if not exists search
(
id varchar(255) not null
primary key,
"table" varchar(255) default '' not null,
full_text varchar(255) default '' not null
);
create index if not exists search_full_text
on search (full_text);
create index if not exists search_table
on search ("table");
create table if not exists user
(
id varchar(255) not null
primary key,
user_name varchar(255) default '' not null
unique,
name varchar(255) default '' not null,
email varchar(255) default '' not null
unique,
password varchar(255) default '' not null,
is_admin bool default FALSE not null,
last_login_at datetime,
last_access_at datetime,
created_at datetime not null,
updated_at datetime not null
);`)
return err
}
func Down20200130083147(_ context.Context, tx *sql.Tx) error {
return nil
}

View File

@@ -0,0 +1,64 @@
package migrations
import (
"context"
"database/sql"
"github.com/pressly/goose/v3"
)
func init() {
goose.AddMigrationContext(Up20200131183653, Down20200131183653)
}
func Up20200131183653(_ context.Context, tx *sql.Tx) error {
_, err := tx.Exec(`
create table search_dg_tmp
(
id varchar(255) not null
primary key,
item_type varchar(255) default '' not null,
full_text varchar(255) default '' not null
);
insert into search_dg_tmp(id, item_type, full_text) select id, "table", full_text from search;
drop table search;
alter table search_dg_tmp rename to search;
create index search_full_text
on search (full_text);
create index search_table
on search (item_type);
update annotation set item_type = 'media_file' where item_type = 'mediaFile';
`)
return err
}
func Down20200131183653(_ context.Context, tx *sql.Tx) error {
_, err := tx.Exec(`
create table search_dg_tmp
(
id varchar(255) not null
primary key,
"table" varchar(255) default '' not null,
full_text varchar(255) default '' not null
);
insert into search_dg_tmp(id, "table", full_text) select id, item_type, full_text from search;
drop table search;
alter table search_dg_tmp rename to search;
create index search_full_text
on search (full_text);
create index search_table
on search ("table");
update annotation set item_type = 'mediaFile' where item_type = 'media_file';
`)
return err
}

View File

@@ -0,0 +1,56 @@
package migrations
import (
"context"
"database/sql"
"github.com/pressly/goose/v3"
)
func init() {
goose.AddMigrationContext(Up20200208222418, Down20200208222418)
}
func Up20200208222418(_ context.Context, tx *sql.Tx) error {
_, err := tx.Exec(`
update annotation set play_count = 0 where play_count is null;
update annotation set rating = 0 where rating is null;
create table annotation_dg_tmp
(
ann_id varchar(255) not null
primary key,
user_id varchar(255) default '' not null,
item_id varchar(255) default '' not null,
item_type varchar(255) default '' not null,
play_count integer default 0,
play_date datetime,
rating integer default 0,
starred bool default FALSE not null,
starred_at datetime,
unique (user_id, item_id, item_type)
);
insert into annotation_dg_tmp(ann_id, user_id, item_id, item_type, play_count, play_date, rating, starred, starred_at) select ann_id, user_id, item_id, item_type, play_count, play_date, rating, starred, starred_at from annotation;
drop table annotation;
alter table annotation_dg_tmp rename to annotation;
create index annotation_play_count
on annotation (play_count);
create index annotation_play_date
on annotation (play_date);
create index annotation_rating
on annotation (rating);
create index annotation_starred
on annotation (starred);
`)
return err
}
func Down20200208222418(_ context.Context, tx *sql.Tx) error {
return nil
}

View File

@@ -0,0 +1,130 @@
package migrations
import (
"context"
"database/sql"
"github.com/pressly/goose/v3"
)
func init() {
goose.AddMigrationContext(Up20200220143731, Down20200220143731)
}
func Up20200220143731(_ context.Context, tx *sql.Tx) error {
notice(tx, "This migration will force the next scan to be a full rescan!")
_, err := tx.Exec(`
create table media_file_dg_tmp
(
id varchar(255) not null
primary key,
path varchar(255) default '' not null,
title varchar(255) default '' not null,
album varchar(255) default '' not null,
artist varchar(255) default '' not null,
artist_id varchar(255) default '' not null,
album_artist varchar(255) default '' not null,
album_id varchar(255) default '' not null,
has_cover_art bool default FALSE not null,
track_number integer default 0 not null,
disc_number integer default 0 not null,
year integer default 0 not null,
size integer default 0 not null,
suffix varchar(255) default '' not null,
duration real default 0 not null,
bit_rate integer default 0 not null,
genre varchar(255) default '' not null,
compilation bool default FALSE not null,
created_at datetime,
updated_at datetime
);
insert into media_file_dg_tmp(id, path, title, album, artist, artist_id, album_artist, album_id, has_cover_art, track_number, disc_number, year, size, suffix, duration, bit_rate, genre, compilation, created_at, updated_at) select id, path, title, album, artist, artist_id, album_artist, album_id, has_cover_art, track_number, disc_number, year, size, suffix, duration, bit_rate, genre, compilation, created_at, updated_at from media_file;
drop table media_file;
alter table media_file_dg_tmp rename to media_file;
create index media_file_album_id
on media_file (album_id);
create index media_file_genre
on media_file (genre);
create index media_file_path
on media_file (path);
create index media_file_title
on media_file (title);
create table album_dg_tmp
(
id varchar(255) not null
primary key,
name varchar(255) default '' not null,
artist_id varchar(255) default '' not null,
cover_art_path varchar(255) default '' not null,
cover_art_id varchar(255) default '' not null,
artist varchar(255) default '' not null,
album_artist varchar(255) default '' not null,
year integer default 0 not null,
compilation bool default FALSE not null,
song_count integer default 0 not null,
duration real default 0 not null,
genre varchar(255) default '' not null,
created_at datetime,
updated_at datetime
);
insert into album_dg_tmp(id, name, artist_id, cover_art_path, cover_art_id, artist, album_artist, year, compilation, song_count, duration, genre, created_at, updated_at) select id, name, artist_id, cover_art_path, cover_art_id, artist, album_artist, year, compilation, song_count, duration, genre, created_at, updated_at from album;
drop table album;
alter table album_dg_tmp rename to album;
create index album_artist
on album (artist);
create index album_artist_id
on album (artist_id);
create index album_genre
on album (genre);
create index album_name
on album (name);
create index album_year
on album (year);
create table playlist_dg_tmp
(
id varchar(255) not null
primary key,
name varchar(255) default '' not null,
comment varchar(255) default '' not null,
duration real default 0 not null,
owner varchar(255) default '' not null,
public bool default FALSE not null,
tracks text not null
);
insert into playlist_dg_tmp(id, name, comment, duration, owner, public, tracks) select id, name, comment, duration, owner, public, tracks from playlist;
drop table playlist;
alter table playlist_dg_tmp rename to playlist;
create index playlist_name
on playlist (name);
-- Force a full rescan
delete from property where id like 'LastScan%';
update media_file set updated_at = '0001-01-01';
`)
return err
}
func Down20200220143731(_ context.Context, tx *sql.Tx) error {
return nil
}

View File

@@ -0,0 +1,21 @@
package migrations
import (
"context"
"database/sql"
"github.com/pressly/goose/v3"
)
func init() {
goose.AddMigrationContext(Up20200310171621, Down20200310171621)
}
func Up20200310171621(_ context.Context, tx *sql.Tx) error {
notice(tx, "A full rescan will be performed to enable search by Album Artist!")
return forceFullRescan(tx)
}
func Down20200310171621(_ context.Context, tx *sql.Tx) error {
return nil
}

View File

@@ -0,0 +1,54 @@
package migrations
import (
"context"
"database/sql"
"github.com/pressly/goose/v3"
)
func init() {
goose.AddMigrationContext(Up20200310181627, Down20200310181627)
}
func Up20200310181627(_ context.Context, tx *sql.Tx) error {
_, err := tx.Exec(`
create table transcoding
(
id varchar(255) not null primary key,
name varchar(255) not null,
target_format varchar(255) not null,
command varchar(255) default '' not null,
default_bit_rate int default 192,
unique (name),
unique (target_format)
);
create table player
(
id varchar(255) not null primary key,
name varchar not null,
type varchar,
user_name varchar not null,
client varchar not null,
ip_address varchar,
last_seen timestamp,
max_bit_rate int default 0,
transcoding_id varchar,
unique (name),
foreign key (transcoding_id)
references transcoding(id)
on update restrict
on delete restrict
);
`)
return err
}
func Down20200310181627(_ context.Context, tx *sql.Tx) error {
_, err := tx.Exec(`
drop table transcoding;
drop table player;
`)
return err
}

View File

@@ -0,0 +1,42 @@
package migrations
import (
"context"
"database/sql"
"github.com/pressly/goose/v3"
)
func init() {
goose.AddMigrationContext(Up20200319211049, Down20200319211049)
}
func Up20200319211049(_ context.Context, tx *sql.Tx) error {
_, err := tx.Exec(`
alter table media_file
add full_text varchar(255) default '';
create index if not exists media_file_full_text
on media_file (full_text);
alter table album
add full_text varchar(255) default '';
create index if not exists album_full_text
on album (full_text);
alter table artist
add full_text varchar(255) default '';
create index if not exists artist_full_text
on artist (full_text);
drop table if exists search;
`)
if err != nil {
return err
}
notice(tx, "A full rescan will be performed!")
return forceFullRescan(tx)
}
func Down20200319211049(_ context.Context, tx *sql.Tx) error {
return nil
}

View File

@@ -0,0 +1,35 @@
package migrations
import (
"context"
"database/sql"
"github.com/pressly/goose/v3"
)
func init() {
goose.AddMigrationContext(Up20200325185135, Down20200325185135)
}
func Up20200325185135(_ context.Context, tx *sql.Tx) error {
_, err := tx.Exec(`
alter table album
add album_artist_id varchar(255) default '';
create index album_artist_album_id
on album (album_artist_id);
alter table media_file
add album_artist_id varchar(255) default '';
create index media_file_artist_album_id
on media_file (album_artist_id);
`)
if err != nil {
return err
}
notice(tx, "A full rescan will be performed!")
return forceFullRescan(tx)
}
func Down20200325185135(_ context.Context, tx *sql.Tx) error {
return nil
}

View File

@@ -0,0 +1,21 @@
package migrations
import (
"context"
"database/sql"
"github.com/pressly/goose/v3"
)
func init() {
goose.AddMigrationContext(Up20200326090707, Down20200326090707)
}
func Up20200326090707(_ context.Context, tx *sql.Tx) error {
notice(tx, "A full rescan will be performed!")
return forceFullRescan(tx)
}
func Down20200326090707(_ context.Context, tx *sql.Tx) error {
return nil
}

View File

@@ -0,0 +1,81 @@
package migrations
import (
"context"
"database/sql"
"github.com/pressly/goose/v3"
)
func init() {
goose.AddMigrationContext(Up20200327193744, Down20200327193744)
}
func Up20200327193744(_ context.Context, tx *sql.Tx) error {
_, err := tx.Exec(`
create table album_dg_tmp
(
id varchar(255) not null
primary key,
name varchar(255) default '' not null,
artist_id varchar(255) default '' not null,
cover_art_path varchar(255) default '' not null,
cover_art_id varchar(255) default '' not null,
artist varchar(255) default '' not null,
album_artist varchar(255) default '' not null,
min_year int default 0 not null,
max_year integer default 0 not null,
compilation bool default FALSE not null,
song_count integer default 0 not null,
duration real default 0 not null,
genre varchar(255) default '' not null,
created_at datetime,
updated_at datetime,
full_text varchar(255) default '',
album_artist_id varchar(255) default ''
);
insert into album_dg_tmp(id, name, artist_id, cover_art_path, cover_art_id, artist, album_artist, max_year, compilation, song_count, duration, genre, created_at, updated_at, full_text, album_artist_id) select id, name, artist_id, cover_art_path, cover_art_id, artist, album_artist, year, compilation, song_count, duration, genre, created_at, updated_at, full_text, album_artist_id from album;
drop table album;
alter table album_dg_tmp rename to album;
create index album_artist
on album (artist);
create index album_artist_album
on album (artist);
create index album_artist_album_id
on album (album_artist_id);
create index album_artist_id
on album (artist_id);
create index album_full_text
on album (full_text);
create index album_genre
on album (genre);
create index album_name
on album (name);
create index album_min_year
on album (min_year);
create index album_max_year
on album (max_year);
`)
if err != nil {
return err
}
notice(tx, "A full rescan will be performed!")
return forceFullRescan(tx)
}
func Down20200327193744(_ context.Context, tx *sql.Tx) error {
return nil
}

View File

@@ -0,0 +1,30 @@
package migrations
import (
"context"
"database/sql"
"github.com/pressly/goose/v3"
)
func init() {
goose.AddMigrationContext(Up20200404214704, Down20200404214704)
}
func Up20200404214704(_ context.Context, tx *sql.Tx) error {
_, err := tx.Exec(`
create index if not exists media_file_year
on media_file (year);
create index if not exists media_file_duration
on media_file (duration);
create index if not exists media_file_track_number
on media_file (disc_number, track_number);
`)
return err
}
func Down20200404214704(_ context.Context, tx *sql.Tx) error {
return nil
}

View File

@@ -0,0 +1,21 @@
package migrations
import (
"context"
"database/sql"
"github.com/pressly/goose/v3"
)
func init() {
goose.AddMigrationContext(Up20200409002249, Down20200409002249)
}
func Up20200409002249(_ context.Context, tx *sql.Tx) error {
notice(tx, "A full rescan will be performed to enable search by individual Artist in an Album!")
return forceFullRescan(tx)
}
func Down20200409002249(_ context.Context, tx *sql.Tx) error {
return nil
}

View File

@@ -0,0 +1,28 @@
package migrations
import (
"context"
"database/sql"
"github.com/pressly/goose/v3"
)
func init() {
goose.AddMigrationContext(Up20200411164603, Down20200411164603)
}
func Up20200411164603(_ context.Context, tx *sql.Tx) error {
_, err := tx.Exec(`
alter table playlist
add created_at datetime;
alter table playlist
add updated_at datetime;
update playlist
set created_at = datetime('now'), updated_at = datetime('now');
`)
return err
}
func Down20200411164603(_ context.Context, tx *sql.Tx) error {
return nil
}

View File

@@ -0,0 +1,21 @@
package migrations
import (
"context"
"database/sql"
"github.com/pressly/goose/v3"
)
func init() {
goose.AddMigrationContext(Up20200418110522, Down20200418110522)
}
func Up20200418110522(_ context.Context, tx *sql.Tx) error {
notice(tx, "A full rescan will be performed to fix search Albums by year")
return forceFullRescan(tx)
}
func Down20200418110522(_ context.Context, tx *sql.Tx) error {
return nil
}

View File

@@ -0,0 +1,21 @@
package migrations
import (
"context"
"database/sql"
"github.com/pressly/goose/v3"
)
func init() {
goose.AddMigrationContext(Up20200419222708, Down20200419222708)
}
func Up20200419222708(_ context.Context, tx *sql.Tx) error {
notice(tx, "A full rescan will be performed to change the search behaviour")
return forceFullRescan(tx)
}
func Down20200419222708(_ context.Context, tx *sql.Tx) error {
return nil
}

View File

@@ -0,0 +1,66 @@
package migrations
import (
"context"
"database/sql"
"github.com/pressly/goose/v3"
)
func init() {
goose.AddMigrationContext(Up20200423204116, Down20200423204116)
}
func Up20200423204116(_ context.Context, tx *sql.Tx) error {
_, err := tx.Exec(`
alter table artist
add order_artist_name varchar(255) collate nocase;
alter table artist
add sort_artist_name varchar(255) collate nocase;
create index if not exists artist_order_artist_name
on artist (order_artist_name);
alter table album
add order_album_name varchar(255) collate nocase;
alter table album
add order_album_artist_name varchar(255) collate nocase;
alter table album
add sort_album_name varchar(255) collate nocase;
alter table album
add sort_artist_name varchar(255) collate nocase;
alter table album
add sort_album_artist_name varchar(255) collate nocase;
create index if not exists album_order_album_name
on album (order_album_name);
create index if not exists album_order_album_artist_name
on album (order_album_artist_name);
alter table media_file
add order_album_name varchar(255) collate nocase;
alter table media_file
add order_album_artist_name varchar(255) collate nocase;
alter table media_file
add order_artist_name varchar(255) collate nocase;
alter table media_file
add sort_album_name varchar(255) collate nocase;
alter table media_file
add sort_artist_name varchar(255) collate nocase;
alter table media_file
add sort_album_artist_name varchar(255) collate nocase;
alter table media_file
add sort_title varchar(255) collate nocase;
create index if not exists media_file_order_album_name
on media_file (order_album_name);
create index if not exists media_file_order_artist_name
on media_file (order_artist_name);
`)
if err != nil {
return err
}
notice(tx, "A full rescan will be performed to change the search behaviour")
return forceFullRescan(tx)
}
func Down20200423204116(_ context.Context, tx *sql.Tx) error {
return nil
}

View File

@@ -0,0 +1,28 @@
package migrations
import (
"context"
"database/sql"
"github.com/pressly/goose/v3"
)
func init() {
goose.AddMigrationContext(Up20200508093059, Down20200508093059)
}
func Up20200508093059(_ context.Context, tx *sql.Tx) error {
_, err := tx.Exec(`
alter table artist
add song_count integer default 0 not null;
`)
if err != nil {
return err
}
notice(tx, "A full rescan will be performed to calculate artists' song counts")
return forceFullRescan(tx)
}
func Down20200508093059(_ context.Context, tx *sql.Tx) error {
return nil
}

View File

@@ -0,0 +1,28 @@
package migrations
import (
"context"
"database/sql"
"github.com/pressly/goose/v3"
)
func init() {
goose.AddMigrationContext(Up20200512104202, Down20200512104202)
}
func Up20200512104202(_ context.Context, tx *sql.Tx) error {
_, err := tx.Exec(`
alter table media_file
add disc_subtitle varchar(255);
`)
if err != nil {
return err
}
notice(tx, "A full rescan will be performed to import disc subtitles")
return forceFullRescan(tx)
}
func Down20200512104202(_ context.Context, tx *sql.Tx) error {
return nil
}

View File

@@ -0,0 +1,101 @@
package migrations
import (
"context"
"database/sql"
"strings"
"github.com/navidrome/navidrome/log"
"github.com/pressly/goose/v3"
)
func init() {
goose.AddMigrationContext(Up20200516140647, Down20200516140647)
}
func Up20200516140647(_ context.Context, tx *sql.Tx) error {
_, err := tx.Exec(`
create table if not exists playlist_tracks
(
id integer default 0 not null,
playlist_id varchar(255) not null,
media_file_id varchar(255) not null
);
create unique index if not exists playlist_tracks_pos
on playlist_tracks (playlist_id, id);
`)
if err != nil {
return err
}
rows, err := tx.Query("select id, tracks from playlist")
if err != nil {
return err
}
defer rows.Close()
var id, tracks string
for rows.Next() {
err := rows.Scan(&id, &tracks)
if err != nil {
return err
}
err = Up20200516140647UpdatePlaylistTracks(tx, id, tracks)
if err != nil {
return err
}
}
err = rows.Err()
if err != nil {
return err
}
_, err = tx.Exec(`
create table playlist_dg_tmp
(
id varchar(255) not null
primary key,
name varchar(255) default '' not null,
comment varchar(255) default '' not null,
duration real default 0 not null,
song_count integer default 0 not null,
owner varchar(255) default '' not null,
public bool default FALSE not null,
created_at datetime,
updated_at datetime
);
insert into playlist_dg_tmp(id, name, comment, duration, owner, public, created_at, updated_at)
select id, name, comment, duration, owner, public, created_at, updated_at from playlist;
drop table playlist;
alter table playlist_dg_tmp rename to playlist;
create index playlist_name
on playlist (name);
update playlist set song_count = (select count(*) from playlist_tracks where playlist_id = playlist.id)
where id <> ''
`)
return err
}
func Up20200516140647UpdatePlaylistTracks(tx *sql.Tx, id string, tracks string) error {
trackList := strings.Split(tracks, ",")
stmt, err := tx.Prepare("insert into playlist_tracks (playlist_id, media_file_id, id) values (?, ?, ?)")
if err != nil {
return err
}
for i, trackId := range trackList {
_, err := stmt.Exec(id, trackId, i+1)
if err != nil {
log.Error("Error adding track to playlist", "playlistId", id, "trackId", trackId, err)
}
}
return nil
}
func Down20200516140647(_ context.Context, tx *sql.Tx) error {
return nil
}

View File

@@ -0,0 +1,138 @@
package migrations
import (
"context"
"database/sql"
"github.com/pressly/goose/v3"
)
func init() {
goose.AddMigrationContext(Up20200608153717, Down20200608153717)
}
func Up20200608153717(_ context.Context, tx *sql.Tx) error {
// First delete dangling players
_, err := tx.Exec(`
delete from player where user_name not in (select user_name from user)`)
if err != nil {
return err
}
// Also delete dangling players
_, err = tx.Exec(`
delete from playlist where owner not in (select user_name from user)`)
if err != nil {
return err
}
// Also delete dangling playlist tracks
_, err = tx.Exec(`
delete from playlist_tracks where playlist_id not in (select id from playlist)`)
if err != nil {
return err
}
// Add foreign key to player table
err = updatePlayer_20200608153717(tx)
if err != nil {
return err
}
// Add foreign key to playlist table
err = updatePlaylist_20200608153717(tx)
if err != nil {
return err
}
// Add foreign keys to playlist_tracks table
return updatePlaylistTracks_20200608153717(tx)
}
func updatePlayer_20200608153717(tx *sql.Tx) error {
_, err := tx.Exec(`
create table player_dg_tmp
(
id varchar(255) not null
primary key,
name varchar not null
unique,
type varchar,
user_name varchar not null
references user (user_name)
on update cascade on delete cascade,
client varchar not null,
ip_address varchar,
last_seen timestamp,
max_bit_rate int default 0,
transcoding_id varchar null
);
insert into player_dg_tmp(id, name, type, user_name, client, ip_address, last_seen, max_bit_rate, transcoding_id) select id, name, type, user_name, client, ip_address, last_seen, max_bit_rate, transcoding_id from player;
drop table player;
alter table player_dg_tmp rename to player;
`)
return err
}
func updatePlaylist_20200608153717(tx *sql.Tx) error {
_, err := tx.Exec(`
create table playlist_dg_tmp
(
id varchar(255) not null
primary key,
name varchar(255) default '' not null,
comment varchar(255) default '' not null,
duration real default 0 not null,
song_count integer default 0 not null,
owner varchar(255) default '' not null
constraint playlist_user_user_name_fk
references user (user_name)
on update cascade on delete cascade,
public bool default FALSE not null,
created_at datetime,
updated_at datetime
);
insert into playlist_dg_tmp(id, name, comment, duration, song_count, owner, public, created_at, updated_at) select id, name, comment, duration, song_count, owner, public, created_at, updated_at from playlist;
drop table playlist;
alter table playlist_dg_tmp rename to playlist;
create index playlist_name
on playlist (name);
`)
return err
}
func updatePlaylistTracks_20200608153717(tx *sql.Tx) error {
_, err := tx.Exec(`
create table playlist_tracks_dg_tmp
(
id integer default 0 not null,
playlist_id varchar(255) not null
constraint playlist_tracks_playlist_id_fk
references playlist
on update cascade on delete cascade,
media_file_id varchar(255) not null
);
insert into playlist_tracks_dg_tmp(id, playlist_id, media_file_id) select id, playlist_id, media_file_id from playlist_tracks;
drop table playlist_tracks;
alter table playlist_tracks_dg_tmp rename to playlist_tracks;
create unique index playlist_tracks_pos
on playlist_tracks (playlist_id, id);
`)
return err
}
func Down20200608153717(_ context.Context, tx *sql.Tx) error {
return nil
}

View File

@@ -0,0 +1,43 @@
package migrations
import (
"context"
"database/sql"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/model/id"
"github.com/pressly/goose/v3"
)
func init() {
goose.AddMigrationContext(upAddDefaultTranscodings, downAddDefaultTranscodings)
}
func upAddDefaultTranscodings(_ context.Context, tx *sql.Tx) error {
row := tx.QueryRow("SELECT COUNT(*) FROM transcoding")
var count int
err := row.Scan(&count)
if err != nil {
return err
}
if count > 0 {
return nil
}
stmt, err := tx.Prepare("insert into transcoding (id, name, target_format, default_bit_rate, command) values (?, ?, ?, ?, ?)")
if err != nil {
return err
}
for _, t := range consts.DefaultTranscodings {
_, err := stmt.Exec(id.NewRandom(), t.Name, t.TargetFormat, t.DefaultBitRate, t.Command)
if err != nil {
return err
}
}
return nil
}
func downAddDefaultTranscodings(_ context.Context, tx *sql.Tx) error {
return nil
}

View File

@@ -0,0 +1,28 @@
package migrations
import (
"context"
"database/sql"
"github.com/pressly/goose/v3"
)
func init() {
goose.AddMigrationContext(upAddPlaylistPath, downAddPlaylistPath)
}
func upAddPlaylistPath(_ context.Context, tx *sql.Tx) error {
_, err := tx.Exec(`
alter table playlist
add path string default '' not null;
alter table playlist
add sync bool default false not null;
`)
return err
}
func downAddPlaylistPath(_ context.Context, tx *sql.Tx) error {
return nil
}

View File

@@ -0,0 +1,37 @@
package migrations
import (
"context"
"database/sql"
"github.com/pressly/goose/v3"
)
func init() {
goose.AddMigrationContext(upCreatePlayQueuesTable, downCreatePlayQueuesTable)
}
func upCreatePlayQueuesTable(_ context.Context, tx *sql.Tx) error {
_, err := tx.Exec(`
create table playqueue
(
id varchar(255) not null primary key,
user_id varchar(255) not null
references user (id)
on update cascade on delete cascade,
comment varchar(255),
current varchar(255) not null,
position integer,
changed_by varchar(255),
items varchar(255),
created_at datetime,
updated_at datetime
);
`)
return err
}
func downCreatePlayQueuesTable(_ context.Context, tx *sql.Tx) error {
return nil
}

View File

@@ -0,0 +1,54 @@
package migrations
import (
"context"
"database/sql"
"github.com/pressly/goose/v3"
)
func init() {
goose.AddMigrationContext(upCreateBookmarkTable, downCreateBookmarkTable)
}
func upCreateBookmarkTable(_ context.Context, tx *sql.Tx) error {
_, err := tx.Exec(`
create table bookmark
(
user_id varchar(255) not null
references user
on update cascade on delete cascade,
item_id varchar(255) not null,
item_type varchar(255) not null,
comment varchar(255),
position integer,
changed_by varchar(255),
created_at datetime,
updated_at datetime,
constraint bookmark_pk
unique (user_id, item_id, item_type)
);
create table playqueue_dg_tmp
(
id varchar(255) not null,
user_id varchar(255) not null
references user
on update cascade on delete cascade,
current varchar(255),
position real,
changed_by varchar(255),
items varchar(255),
created_at datetime,
updated_at datetime
);
drop table playqueue;
alter table playqueue_dg_tmp rename to playqueue;
`)
return err
}
func downCreateBookmarkTable(_ context.Context, tx *sql.Tx) error {
return nil
}

View File

@@ -0,0 +1,43 @@
package migrations
import (
"context"
"database/sql"
"github.com/pressly/goose/v3"
)
func init() {
goose.AddMigrationContext(upDropEmailUniqueConstraint, downDropEmailUniqueConstraint)
}
func upDropEmailUniqueConstraint(_ context.Context, tx *sql.Tx) error {
_, err := tx.Exec(`
create table user_dg_tmp
(
id varchar(255) not null
primary key,
user_name varchar(255) default '' not null
unique,
name varchar(255) default '' not null,
email varchar(255) default '' not null,
password varchar(255) default '' not null,
is_admin bool default FALSE not null,
last_login_at datetime,
last_access_at datetime,
created_at datetime not null,
updated_at datetime not null
);
insert into user_dg_tmp(id, user_name, name, email, password, is_admin, last_login_at, last_access_at, created_at, updated_at) select id, user_name, name, email, password, is_admin, last_login_at, last_access_at, created_at, updated_at from user;
drop table user;
alter table user_dg_tmp rename to user;
`)
return err
}
func downDropEmailUniqueConstraint(_ context.Context, tx *sql.Tx) error {
return nil
}

View File

@@ -0,0 +1,24 @@
package migrations
import (
"context"
"database/sql"
"github.com/pressly/goose/v3"
)
func init() {
goose.AddMigrationContext(Up20201003111749, Down20201003111749)
}
func Up20201003111749(_ context.Context, tx *sql.Tx) error {
_, err := tx.Exec(`
create index if not exists annotation_starred_at
on annotation (starred_at);
`)
return err
}
func Down20201003111749(_ context.Context, tx *sql.Tx) error {
return nil
}

View File

@@ -0,0 +1,34 @@
package migrations
import (
"context"
"database/sql"
"github.com/pressly/goose/v3"
)
func init() {
goose.AddMigrationContext(Up20201010162350, Down20201010162350)
}
func Up20201010162350(_ context.Context, tx *sql.Tx) error {
_, err := tx.Exec(`
alter table album
add size integer default 0 not null;
create index if not exists album_size
on album(size);
update album set size = ifnull((
select sum(f.size)
from media_file f
where f.album_id = album.id
), 0)
where id not null;`)
return err
}
func Down20201010162350(_ context.Context, tx *sql.Tx) error {
// This code is executed when the migration is rolled back.
return nil
}

View File

@@ -0,0 +1,45 @@
package migrations
import (
"context"
"database/sql"
"github.com/pressly/goose/v3"
)
func init() {
goose.AddMigrationContext(Up20201012210022, Down20201012210022)
}
func Up20201012210022(_ context.Context, tx *sql.Tx) error {
_, err := tx.Exec(`
alter table artist
add size integer default 0 not null;
create index if not exists artist_size
on artist(size);
update artist set size = ifnull((
select sum(f.size)
from album f
where f.album_artist_id = artist.id
), 0)
where id not null;
alter table playlist
add size integer default 0 not null;
create index if not exists playlist_size
on playlist(size);
update playlist set size = ifnull((
select sum(size)
from media_file f
left join playlist_tracks pt on f.id = pt.media_file_id
where pt.playlist_id = playlist.id
), 0);`)
return err
}
func Down20201012210022(_ context.Context, tx *sql.Tx) error {
return nil
}

View File

@@ -0,0 +1,59 @@
package migrations
import (
"context"
"database/sql"
"github.com/pressly/goose/v3"
)
func init() {
goose.AddMigrationContext(Up20201021085410, Down20201021085410)
}
func Up20201021085410(_ context.Context, tx *sql.Tx) error {
_, err := tx.Exec(`
alter table media_file
add mbz_track_id varchar(255);
alter table media_file
add mbz_album_id varchar(255);
alter table media_file
add mbz_artist_id varchar(255);
alter table media_file
add mbz_album_artist_id varchar(255);
alter table media_file
add mbz_album_type varchar(255);
alter table media_file
add mbz_album_comment varchar(255);
alter table media_file
add catalog_num varchar(255);
alter table album
add mbz_album_id varchar(255);
alter table album
add mbz_album_artist_id varchar(255);
alter table album
add mbz_album_type varchar(255);
alter table album
add mbz_album_comment varchar(255);
alter table album
add catalog_num varchar(255);
create index if not exists album_mbz_album_type
on album (mbz_album_type);
alter table artist
add mbz_artist_id varchar(255);
`)
if err != nil {
return err
}
notice(tx, "A full rescan needs to be performed to import more tags")
return forceFullRescan(tx)
}
func Down20201021085410(_ context.Context, tx *sql.Tx) error {
// This code is executed when the migration is rolled back.
return nil
}

View File

@@ -0,0 +1,28 @@
package migrations
import (
"context"
"database/sql"
"github.com/pressly/goose/v3"
)
func init() {
goose.AddMigrationContext(Up20201021093209, Down20201021093209)
}
func Up20201021093209(_ context.Context, tx *sql.Tx) error {
_, err := tx.Exec(`
create index if not exists media_file_artist
on media_file (artist);
create index if not exists media_file_album_artist
on media_file (album_artist);
create index if not exists media_file_mbz_track_id
on media_file (mbz_track_id);
`)
return err
}
func Down20201021093209(_ context.Context, tx *sql.Tx) error {
return nil
}

View File

@@ -0,0 +1,24 @@
package migrations
import (
"context"
"database/sql"
"github.com/pressly/goose/v3"
)
func init() {
goose.AddMigrationContext(Up20201021135455, Down20201021135455)
}
func Up20201021135455(_ context.Context, tx *sql.Tx) error {
_, err := tx.Exec(`
create index if not exists media_file_artist_id
on media_file (artist_id);
`)
return err
}
func Down20201021135455(_ context.Context, tx *sql.Tx) error {
return nil
}

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