Compare commits

...

24 Commits

Author SHA1 Message Date
dependabot[bot]
fa460916b8
Merge 08a5d6447745284b4abe1a76f3ac223047190779 into 5bc26de0e7704a92cb0c934f19599a48b5a5d684 2025-12-03 01:45:15 +00: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]
08a5d64477
chore(deps-dev): bump typescript from 5.8.3 to 5.9.3 in /ui
Bumps [typescript](https://github.com/microsoft/TypeScript) from 5.8.3 to 5.9.3.
- [Release notes](https://github.com/microsoft/TypeScript/releases)
- [Changelog](https://github.com/microsoft/TypeScript/blob/main/azure-pipelines.release-publish.yml)
- [Commits](https://github.com/microsoft/TypeScript/compare/v5.8.3...v5.9.3)

---
updated-dependencies:
- dependency-name: typescript
  dependency-version: 5.9.3
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-11-03 17:42:57 +00:00
60 changed files with 1586 additions and 333 deletions

View File

@ -9,12 +9,19 @@ ARG INSTALL_NODE="true"
ARG NODE_VERSION="lts/*" 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 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 \ 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 # Install TagLib from cross-taglib releases
# RUN go get -x <your-dependency-or-tool> 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. # [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 # 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", "VARIANT": "1.25",
// Options // Options
"INSTALL_NODE": "true", "INSTALL_NODE": "true",
"NODE_VERSION": "v24" "NODE_VERSION": "v24",
"CROSS_TAGLIB_VERSION": "2.1.1-1"
} }
}, },
"workspaceMount": "", "workspaceMount": "",
@ -54,12 +55,10 @@
4533, 4533,
4633 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. // Comment out connect as root instead. More info: https://aka.ms/vscode-remote/containers/non-root.
"remoteUser": "vscode", "remoteUser": "vscode",
"remoteEnv": { "remoteEnv": {
"ND_MUSICFOLDER": "./music", "ND_MUSICFOLDER": "./music",
"ND_DATAFOLDER": "./data" "ND_DATAFOLDER": "./data"
} }
} }

View File

@ -346,7 +346,7 @@ func startPluginManager(ctx context.Context) func() error {
// TODO: Implement some struct tags to map flags to viper // TODO: Implement some struct tags to map flags to viper
func init() { func init() {
cobra.OnInitialize(func() { cobra.OnInitialize(func() {
conf.InitConfig(cfgFile) conf.InitConfig(cfgFile, true)
}) })
rootCmd.PersistentFlags().StringVarP(&cfgFile, "configfile", "c", "", `config file (default "./navidrome.toml")`) rootCmd.PersistentFlags().StringVarP(&cfgFile, "configfile", "c", "", `config file (default "./navidrome.toml")`)
@ -374,6 +374,7 @@ func init() {
rootCmd.Flags().Duration("scaninterval", viper.GetDuration("scaninterval"), "how frequently to scan for changes in your music library") 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().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("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("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("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") rootCmd.Flags().String("albumplaycountmode", viper.GetString("albumplaycountmode"), "how to compute playcount for albums. absolute (default) or normalized")
@ -397,6 +398,7 @@ func init() {
_ = viper.BindPFlag("prometheus.metricspath", rootCmd.Flags().Lookup("prometheus.metricspath")) _ = viper.BindPFlag("prometheus.metricspath", rootCmd.Flags().Lookup("prometheus.metricspath"))
_ = viper.BindPFlag("enabletranscodingconfig", rootCmd.Flags().Lookup("enabletranscodingconfig")) _ = viper.BindPFlag("enabletranscodingconfig", rootCmd.Flags().Lookup("enabletranscodingconfig"))
_ = viper.BindPFlag("enabletranscodingcancellation", rootCmd.Flags().Lookup("enabletranscodingcancellation"))
_ = viper.BindPFlag("transcodingcachesize", rootCmd.Flags().Lookup("transcodingcachesize")) _ = viper.BindPFlag("transcodingcachesize", rootCmd.Flags().Lookup("transcodingcachesize"))
_ = viper.BindPFlag("imagecachesize", rootCmd.Flags().Lookup("imagecachesize")) _ = viper.BindPFlag("imagecachesize", rootCmd.Flags().Lookup("imagecachesize"))
} }

View File

@ -41,6 +41,7 @@ type configOptions struct {
UIWelcomeMessage string UIWelcomeMessage string
MaxSidebarPlaylists int MaxSidebarPlaylists int
EnableTranscodingConfig bool EnableTranscodingConfig bool
EnableTranscodingCancellation bool
EnableDownloads bool EnableDownloads bool
EnableExternalServices bool EnableExternalServices bool
EnableInsightsCollector bool EnableInsightsCollector bool
@ -86,8 +87,7 @@ type configOptions struct {
AuthRequestLimit int AuthRequestLimit int
AuthWindowLength time.Duration AuthWindowLength time.Duration
PasswordEncryptionKey string PasswordEncryptionKey string
ReverseProxyUserHeader string ExtAuth extAuthOptions
ReverseProxyWhitelist string
Plugins pluginsOptions Plugins pluginsOptions
PluginConfig map[string]map[string]string PluginConfig map[string]map[string]string
HTTPSecurityHeaders secureOptions `json:",omitzero"` HTTPSecurityHeaders secureOptions `json:",omitzero"`
@ -106,31 +106,33 @@ type configOptions struct {
Agents string Agents string
// DevFlags. These are used to enable/disable debugging and incomplete features // DevFlags. These are used to enable/disable debugging and incomplete features
DevLogLevels map[string]string `json:",omitempty"` DevLogLevels map[string]string `json:",omitempty"`
DevLogSourceLine bool DevLogSourceLine bool
DevEnableProfiler bool DevEnableProfiler bool
DevAutoCreateAdminPassword string DevAutoCreateAdminPassword string
DevAutoLoginUsername string DevAutoLoginUsername string
DevActivityPanel bool DevActivityPanel bool
DevActivityPanelUpdateRate time.Duration DevActivityPanelUpdateRate time.Duration
DevSidebarPlaylists bool DevSidebarPlaylists bool
DevShowArtistPage bool DevShowArtistPage bool
DevUIShowConfig bool DevUIShowConfig bool
DevNewEventStream bool DevNewEventStream bool
DevOffsetOptimize int DevOffsetOptimize int
DevArtworkMaxRequests int DevArtworkMaxRequests int
DevArtworkThrottleBacklogLimit int DevArtworkThrottleBacklogLimit int
DevArtworkThrottleBacklogTimeout time.Duration DevArtworkThrottleBacklogTimeout time.Duration
DevArtistInfoTimeToLive time.Duration DevArtistInfoTimeToLive time.Duration
DevAlbumInfoTimeToLive time.Duration DevAlbumInfoTimeToLive time.Duration
DevExternalScanner bool DevExternalScanner bool
DevScannerThreads uint DevScannerThreads uint
DevSelectiveWatcher bool DevSelectiveWatcher bool
DevInsightsInitialDelay time.Duration DevInsightsInitialDelay time.Duration
DevEnablePlayerInsights bool DevEnablePlayerInsights bool
DevEnablePluginsInsights bool DevEnablePluginsInsights bool
DevPluginCompilationTimeout time.Duration DevPluginCompilationTimeout time.Duration
DevExternalArtistFetchMultiplier float64 DevExternalArtistFetchMultiplier float64
DevOptimizeDB bool
DevPreserveUnicodeInExternalCalls bool
} }
type scannerOptions struct { type scannerOptions struct {
@ -228,6 +230,11 @@ type pluginsOptions struct {
CacheSize string CacheSize string
} }
type extAuthOptions struct {
TrustedSources string
UserHeader string
}
var ( var (
Server = &configOptions{} Server = &configOptions{}
hooks []func() hooks []func()
@ -246,6 +253,10 @@ func LoadFromFile(confFile string) {
func Load(noConfigDump bool) { func Load(noConfigDump bool) {
parseIniFileConfiguration() parseIniFileConfiguration()
// Map deprecated options to their new names for backwards compatibility
mapDeprecatedOption("ReverseProxyWhitelist", "ExtAuth.TrustedSources")
mapDeprecatedOption("ReverseProxyUserHeader", "ExtAuth.UserHeader")
err := viper.Unmarshal(&Server) err := viper.Unmarshal(&Server)
if err != nil { if err != nil {
_, _ = fmt.Fprintln(os.Stderr, "FATAL: Error parsing config:", err) _, _ = fmt.Fprintln(os.Stderr, "FATAL: Error parsing config:", err)
@ -329,9 +340,16 @@ func Load(noConfigDump bool) {
Server.BaseScheme = u.Scheme Server.BaseScheme = u.Scheme
} }
// Log configuration source
if Server.ConfigFile != "" {
log.Info("Loaded configuration", "file", Server.ConfigFile)
} 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 // Print current configuration if log level is Debug
if log.IsGreaterOrEqualTo(log.LevelDebug) && !noConfigDump { 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 { if Server.EnableLogRedacting {
prettyConf = log.Redact(prettyConf) prettyConf = log.Redact(prettyConf)
} }
@ -349,6 +367,7 @@ func Load(noConfigDump bool) {
logDeprecatedOptions("Scanner.GenreSeparators") logDeprecatedOptions("Scanner.GenreSeparators")
logDeprecatedOptions("Scanner.GroupAlbumReleases") logDeprecatedOptions("Scanner.GroupAlbumReleases")
logDeprecatedOptions("DevEnableBufferedScrobble") // Deprecated: Buffered scrobbling is now always enabled and this option is ignored logDeprecatedOptions("DevEnableBufferedScrobble") // Deprecated: Buffered scrobbling is now always enabled and this option is ignored
logDeprecatedOptions("ReverseProxyWhitelist", "ReverseProxyUserHeader")
// Call init hooks // Call init hooks
for _, hook := range hooks { for _, hook := range hooks {
@ -368,6 +387,14 @@ func logDeprecatedOptions(options ...string) {
} }
} }
// 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 // parseIniFileConfiguration is used to parse the config file when it is in INI format. For INI files, it
// would require a nested structure, so instead we unmarshal it to a map and then merge the nested [default] // would require a nested structure, so instead we unmarshal it to a map and then merge the nested [default]
// section into the root level. // section into the root level.
@ -427,7 +454,7 @@ func validatePurgeMissingOption() error {
} }
} }
if !valid { 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()) log.Error(err.Error())
Server.Scanner.PurgeMissing = consts.PurgeMissingNever Server.Scanner.PurgeMissing = consts.PurgeMissingNever
return err return err
@ -491,6 +518,7 @@ func setViperDefaults() {
viper.SetDefault("uiwelcomemessage", "") viper.SetDefault("uiwelcomemessage", "")
viper.SetDefault("maxsidebarplaylists", consts.DefaultMaxSidebarPlaylists) viper.SetDefault("maxsidebarplaylists", consts.DefaultMaxSidebarPlaylists)
viper.SetDefault("enabletranscodingconfig", false) viper.SetDefault("enabletranscodingconfig", false)
viper.SetDefault("enabletranscodingcancellation", false)
viper.SetDefault("transcodingcachesize", "100MB") viper.SetDefault("transcodingcachesize", "100MB")
viper.SetDefault("imagecachesize", "100MB") viper.SetDefault("imagecachesize", "100MB")
viper.SetDefault("albumplaycountmode", consts.AlbumPlayCountModeAbsolute) viper.SetDefault("albumplaycountmode", consts.AlbumPlayCountModeAbsolute)
@ -535,8 +563,8 @@ func setViperDefaults() {
viper.SetDefault("authrequestlimit", 5) viper.SetDefault("authrequestlimit", 5)
viper.SetDefault("authwindowlength", 20*time.Second) viper.SetDefault("authwindowlength", 20*time.Second)
viper.SetDefault("passwordencryptionkey", "") viper.SetDefault("passwordencryptionkey", "")
viper.SetDefault("reverseproxyuserheader", "Remote-User") viper.SetDefault("extauth.userheader", "Remote-User")
viper.SetDefault("reverseproxywhitelist", "") viper.SetDefault("extauth.trustedsources", "")
viper.SetDefault("prometheus.enabled", false) viper.SetDefault("prometheus.enabled", false)
viper.SetDefault("prometheus.metricspath", consts.PrometheusDefaultPath) viper.SetDefault("prometheus.metricspath", consts.PrometheusDefaultPath)
viper.SetDefault("prometheus.password", "") viper.SetDefault("prometheus.password", "")
@ -609,13 +637,15 @@ func setViperDefaults() {
viper.SetDefault("devenablepluginsinsights", true) viper.SetDefault("devenablepluginsinsights", true)
viper.SetDefault("devplugincompilationtimeout", time.Minute) viper.SetDefault("devplugincompilationtimeout", time.Minute)
viper.SetDefault("devexternalartistfetchmultiplier", 1.5) viper.SetDefault("devexternalartistfetchmultiplier", 1.5)
viper.SetDefault("devoptimizedb", true)
viper.SetDefault("devpreserveunicodeinexternalcalls", false)
} }
func init() { func init() {
setViperDefaults() setViperDefaults()
} }
func InitConfig(cfgFile string) { func InitConfig(cfgFile string, loadEnvVars bool) {
codecRegistry := viper.NewCodecRegistry() codecRegistry := viper.NewCodecRegistry()
_ = codecRegistry.RegisterCodec("ini", ini.Codec{ _ = codecRegistry.RegisterCodec("ini", ini.Codec{
LoadOptions: ini.LoadOptions{ LoadOptions: ini.LoadOptions{
@ -636,10 +666,12 @@ func InitConfig(cfgFile string) {
} }
_ = viper.BindEnv("port") _ = viper.BindEnv("port")
viper.SetEnvPrefix("ND") if loadEnvVars {
replacer := strings.NewReplacer(".", "_") viper.SetEnvPrefix("ND")
viper.SetEnvKeyReplacer(replacer) replacer := strings.NewReplacer(".", "_")
viper.AutomaticEnv() viper.SetEnvKeyReplacer(replacer)
viper.AutomaticEnv()
}
err := viper.ReadInConfig() err := viper.ReadInConfig()
if viper.ConfigFileUsed() != "" && err != nil { if viper.ConfigFileUsed() != "" && err != nil {

View File

@ -31,7 +31,7 @@ var _ = Describe("Configuration", func() {
filename := filepath.Join("testdata", "cfg."+format) filename := filepath.Join("testdata", "cfg."+format)
// Initialize config with the test file // Initialize config with the test file
conf.InitConfig(filename) conf.InitConfig(filename, false)
// Load the configuration (with noConfigDump=true) // Load the configuration (with noConfigDump=true)
conf.Load(true) conf.Load(true)
@ -41,6 +41,9 @@ var _ = Describe("Configuration", func() {
Expect(conf.Server.Tags["custom"].Aliases).To(Equal([]string{format, "test"})) Expect(conf.Server.Tags["custom"].Aliases).To(Equal([]string{format, "test"}))
Expect(conf.Server.Tags["artist"].Split).To(Equal([]string{";"})) 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 // The config file used should be the one we created
Expect(conf.Server.ConfigFile).To(Equal(filename)) Expect(conf.Server.ConfigFile).To(Equal(filename))
}, },

View File

@ -1,6 +1,7 @@
[default] [default]
MusicFolder = /ini/music MusicFolder = /ini/music
UIWelcomeMessage = 'Welcome ini' ; Just a comment to test the LoadOptions UIWelcomeMessage = 'Welcome ini' ; Just a comment to test the LoadOptions
ReverseProxyUserHeader = 'X-Auth-User'
[Tags] [Tags]
Custom.Aliases = ini,test Custom.Aliases = ini,test

View File

@ -1,6 +1,7 @@
{ {
"musicFolder": "/json/music", "musicFolder": "/json/music",
"uiWelcomeMessage": "Welcome json", "uiWelcomeMessage": "Welcome json",
"reverseProxyUserHeader": "X-Auth-User",
"Tags": { "Tags": {
"artist": { "artist": {
"split": ";" "split": ";"

View File

@ -1,5 +1,6 @@
musicFolder = "/toml/music" musicFolder = "/toml/music"
uiWelcomeMessage = "Welcome toml" uiWelcomeMessage = "Welcome toml"
ReverseProxyUserHeader = "X-Auth-User"
Tags.artist.Split = ';' Tags.artist.Split = ';'

View File

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

View File

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

View File

@ -51,12 +51,28 @@ type provider struct {
type auxAlbum struct { type auxAlbum struct {
model.Album 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 { type auxArtist struct {
model.Artist 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 { type Agents interface {
@ -88,7 +104,6 @@ func (e *provider) getAlbum(ctx context.Context, id string) (auxAlbum, error) {
switch v := entity.(type) { switch v := entity.(type) {
case *model.Album: case *model.Album:
album.Album = *v album.Album = *v
album.Name = str.Clear(v.Name)
case *model.MediaFile: case *model.MediaFile:
return e.getAlbum(ctx, v.AlbumID) return e.getAlbum(ctx, v.AlbumID)
default: default:
@ -106,8 +121,9 @@ func (e *provider) UpdateAlbumInfo(ctx context.Context, id string) (*model.Album
} }
updatedAt := V(album.ExternalInfoUpdatedAt) updatedAt := V(album.ExternalInfoUpdatedAt)
albumName := album.Name()
if updatedAt.IsZero() { 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) album, err = e.populateAlbumInfo(ctx, album)
if err != nil { if err != nil {
return nil, err 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 info is expired, trigger a populateAlbumInfo in the background
if time.Since(updatedAt) > conf.Server.DevAlbumInfoTimeToLive { 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) 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) { func (e *provider) populateAlbumInfo(ctx context.Context, album auxAlbum) (auxAlbum, error) {
start := time.Now() 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) { if errors.Is(err, agents.ErrNotFound) {
return album, nil return album, nil
} }
if err != 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) "elapsed", time.Since(start), err)
return album, err return album, err
} }
@ -142,7 +159,7 @@ func (e *provider) populateAlbumInfo(ctx context.Context, album auxAlbum) (auxAl
album.Description = info.Description 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 { if err == nil && len(images) > 0 {
sort.Slice(images, func(i, j int) bool { sort.Slice(images, func(i, j int) bool {
return images[i].Size > images[j].Size 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) err = e.ds.Album(ctx).UpdateExternalInfo(&album.Album)
if err != nil { 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) "elapsed", time.Since(start), err)
} else { } else {
log.Trace(ctx, "AlbumInfo collected", "album", album, "elapsed", time.Since(start)) 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) { switch v := entity.(type) {
case *model.Artist: case *model.Artist:
artist.Artist = *v artist.Artist = *v
artist.Name = str.Clear(v.Name)
case *model.MediaFile: case *model.MediaFile:
return e.getArtist(ctx, v.ArtistID) return e.getArtist(ctx, v.ArtistID)
case *model.Album: 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 // If we don't have any info, retrieves it now
updatedAt := V(artist.ExternalInfoUpdatedAt) updatedAt := V(artist.ExternalInfoUpdatedAt)
artistName := artist.Name()
if updatedAt.IsZero() { 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) artist, err = e.populateArtistInfo(ctx, artist)
if err != nil { if err != nil {
return auxArtist{}, err 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 info is expired, trigger a populateArtistInfo in the background
if time.Since(updatedAt) > conf.Server.DevArtistInfoTimeToLive { 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) e.artistQueue.enqueue(&artist)
} }
return artist, nil 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) { func (e *provider) populateArtistInfo(ctx context.Context, artist auxArtist) (auxArtist, error) {
start := time.Now() start := time.Now()
// Get MBID first, if it is not yet available // Get MBID first, if it is not yet available
artistName := artist.Name()
if artist.MbzArtistID == "" { 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 { if mbid != "" && err == nil {
artist.MbzArtistID = mbid artist.MbzArtistID = mbid
} }
@ -246,14 +264,14 @@ func (e *provider) populateArtistInfo(ctx context.Context, artist auxArtist) (au
_ = g.Wait() _ = g.Wait()
if utils.IsCtxDone(ctx) { 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() return artist, ctx.Err()
} }
artist.ExternalInfoUpdatedAt = P(time.Now()) artist.ExternalInfoUpdatedAt = P(time.Now())
err := e.ds.Artist(ctx).UpdateExternalInfo(&artist.Artist) err := e.ds.Artist(ctx).UpdateExternalInfo(&artist.Artist)
if err != nil { 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) "elapsed", time.Since(start), err)
} else { } else {
log.Trace(ctx, "ArtistInfo collected", "artist", artist, "elapsed", time.Since(start)) 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) 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 { if err != nil {
log.Warn(ctx, "Error getting artist's top songs", "artist", a.Name, err) log.Warn(ctx, "Error getting artist's top songs", "artist", a.Name, err)
return nil return nil
@ -344,22 +362,23 @@ func (e *provider) AlbumImage(ctx context.Context, id string) (*url.URL, error)
return nil, err 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 { if err != nil {
switch { switch {
case errors.Is(err, agents.ErrNotFound): 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 return nil, model.ErrNotFound
case errors.Is(err, context.Canceled): case errors.Is(err, context.Canceled):
log.Debug(ctx, "GetAlbumImages call canceled", err) log.Debug(ctx, "GetAlbumImages call canceled", err)
default: 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 return nil, err
} }
if len(images) == 0 { 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 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) { 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 { 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) 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) 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) mfs := e.selectTopSongs(songs, mbidMatches, titleMatches, count)
if len(mfs) == 0 { 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 { } 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 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) { 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 { if err != nil {
return 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) { 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 { if err != nil {
return 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) { 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 { if err != nil {
return 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, func (e *provider) callGetSimilar(ctx context.Context, agent agents.ArtistSimilarRetriever, artist *auxArtist,
limit int, includeNotPresent bool) { 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 { if len(similar) == 0 || err != nil {
return return
} }
start := time.Now() start := time.Now()
sa, err := e.mapSimilarArtists(ctx, similar, limit, includeNotPresent) 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 { if err != nil {
return return
} }
@ -635,11 +656,7 @@ func (e *provider) findArtistByName(ctx context.Context, artistName string) (*au
if len(artists) == 0 { if len(artists) == 0 {
return nil, model.ErrNotFound return nil, model.ErrNotFound
} }
artist := &auxArtist{ return &auxArtist{Artist: artists[0]}, nil
Artist: artists[0],
Name: str.Clear(artists[0].Name),
}
return artist, nil
} }
func (e *provider) loadSimilar(ctx context.Context, artist *auxArtist, count int, includeNotPresent bool) error { 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}, Filters: squirrel.Eq{"artist.id": ids},
}) })
if err != nil { 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 return err
} }

View File

@ -260,6 +260,69 @@ var _ = Describe("Provider - AlbumImage", func() {
mockMediaFileRepo.AssertCalled(GinkgoT(), "Get", "not-found") mockMediaFileRepo.AssertCalled(GinkgoT(), "Get", "not-found")
mockAlbumAgent.AssertNotCalled(GinkgoT(), "GetAlbumImages", mock.Anything, mock.Anything, mock.Anything) 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 // mockAlbumInfoAgent implementation

View File

@ -265,6 +265,67 @@ var _ = Describe("Provider - ArtistImage", func() {
mockArtistRepo.AssertCalled(GinkgoT(), "Get", "artist-1") mockArtistRepo.AssertCalled(GinkgoT(), "Get", "artist-1")
mockImageAgent.AssertCalled(GinkgoT(), "GetArtistImages", ctx, "artist-1", "Artist One", "") 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 // 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) log.Trace(ctx, "Executing ffmpeg command", "cmd", args)
j := &ffCmd{args: args} j := &ffCmd{args: args}
j.PipeReader, j.out = io.Pipe() j.PipeReader, j.out = io.Pipe()
err := j.start() err := j.start(ctx)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -127,8 +127,8 @@ type ffCmd struct {
cmd *exec.Cmd cmd *exec.Cmd
} }
func (j *ffCmd) start() error { func (j *ffCmd) start(ctx context.Context) error {
cmd := exec.Command(j.args[0], j.args[1:]...) // #nosec cmd := exec.CommandContext(ctx, j.args[0], j.args[1:]...) // #nosec
cmd.Stdout = j.out cmd.Stdout = j.out
if log.IsGreaterOrEqualTo(log.LevelTrace) { if log.IsGreaterOrEqualTo(log.LevelTrace) {
cmd.Stderr = os.Stderr cmd.Stderr = os.Stderr

View File

@ -1,7 +1,11 @@
package ffmpeg package ffmpeg
import ( import (
"context"
"runtime"
sync "sync"
"testing" "testing"
"time"
"github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/tests" "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"})) 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

@ -204,7 +204,20 @@ func NewTranscodingCache() TranscodingCache {
log.Error(ctx, "Error loading transcoding command", "format", job.format, err) log.Error(ctx, "Error loading transcoding command", "format", job.format, err)
return nil, os.ErrInvalid 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 { if err != nil {
log.Error(ctx, "Error starting transcoder", "id", job.mf.ID, err) log.Error(ctx, "Error starting transcoder", "id", job.mf.ID, err)
return nil, os.ErrInvalid return nil, os.ErrInvalid

View File

@ -22,6 +22,7 @@ import (
"github.com/navidrome/navidrome/core/metrics/insights" "github.com/navidrome/navidrome/core/metrics/insights"
"github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/request"
"github.com/navidrome/navidrome/plugins/schema" "github.com/navidrome/navidrome/plugins/schema"
"github.com/navidrome/navidrome/utils/singleton" "github.com/navidrome/navidrome/utils/singleton"
) )
@ -64,9 +65,16 @@ func GetInstance(ds model.DataStore, pluginLoader PluginLoader) Insights {
} }
func (c *insightsCollector) Run(ctx context.Context) { func (c *insightsCollector) Run(ctx context.Context) {
ctx = auth.WithAdminUser(ctx, c.ds)
for { 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 { select {
case <-time.After(consts.InsightsUpdateInterval): case <-time.After(consts.InsightsUpdateInterval):
continue continue
@ -215,7 +223,7 @@ var staticData = sync.OnceValue(func() insights.Data {
data.Config.ScanSchedule = conf.Server.Scanner.Schedule data.Config.ScanSchedule = conf.Server.Scanner.Schedule
data.Config.ScanWatcherWait = uint64(math.Trunc(conf.Server.Scanner.WatcherWait.Seconds())) data.Config.ScanWatcherWait = uint64(math.Trunc(conf.Server.Scanner.WatcherWait.Seconds()))
data.Config.ScanOnStartup = conf.Server.Scanner.ScanOnStartup 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.HasCustomPID = conf.Server.PID.Track != "" || conf.Server.PID.Album != ""
data.Config.HasCustomTags = len(conf.Server.Tags) > 0 data.Config.HasCustomTags = len(conf.Server.Tags) > 0

View File

@ -42,6 +42,7 @@ type MountInfo struct {
var fsTypeMap = map[int64]string{ var fsTypeMap = map[int64]string{
0x5346414f: "afs", 0x5346414f: "afs",
0x187: "autofs",
0x61756673: "aufs", 0x61756673: "aufs",
0x9123683E: "btrfs", 0x9123683E: "btrfs",
0xc36400: "ceph", 0xc36400: "ceph",
@ -55,9 +56,11 @@ var fsTypeMap = map[int64]string{
0x6a656a63: "fakeowner", // FS inside a container 0x6a656a63: "fakeowner", // FS inside a container
0x65735546: "fuse", 0x65735546: "fuse",
0x4244: "hfs", 0x4244: "hfs",
0x482b: "hfs+",
0x9660: "iso9660", 0x9660: "iso9660",
0x3153464a: "jfs", 0x3153464a: "jfs",
0x00006969: "nfs", 0x00006969: "nfs",
0x5346544e: "ntfs", // NTFS_SB_MAGIC
0x7366746e: "ntfs", 0x7366746e: "ntfs",
0x794c7630: "overlayfs", 0x794c7630: "overlayfs",
0x9fa0: "proc", 0x9fa0: "proc",
@ -69,8 +72,16 @@ var fsTypeMap = map[int64]string{
0x01021997: "v9fs", 0x01021997: "v9fs",
0x786f4256: "vboxsf", 0x786f4256: "vboxsf",
0x4d44: "vfat", 0x4d44: "vfat",
0xca451a4e: "virtiofs",
0x58465342: "xfs", 0x58465342: "xfs",
0x2FC12FC1: "zfs", 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) { func getFilesystemType(path string) (string, error) {

View File

@ -38,9 +38,9 @@ var _ = Describe("BufferedScrobbler", func() {
It("forwards NowPlaying calls", func() { It("forwards NowPlaying calls", func() {
track := &model.MediaFile{ID: "123", Title: "Test Track"} track := &model.MediaFile{ID: "123", Title: "Test Track"}
Expect(bs.NowPlaying(ctx, "user1", track, 0)).To(Succeed()) Expect(bs.NowPlaying(ctx, "user1", track, 0)).To(Succeed())
Expect(scr.NowPlayingCalled).To(BeTrue()) Expect(scr.GetNowPlayingCalled()).To(BeTrue())
Expect(scr.UserID).To(Equal("user1")) Expect(scr.GetUserID()).To(Equal("user1"))
Expect(scr.Track).To(Equal(track)) Expect(scr.GetTrack()).To(Equal(track))
}) })
It("enqueues scrobbles to buffer", func() { It("enqueues scrobbles to buffer", func() {
@ -51,9 +51,10 @@ var _ = Describe("BufferedScrobbler", func() {
Expect(scr.ScrobbleCalled.Load()).To(BeFalse()) Expect(scr.ScrobbleCalled.Load()).To(BeFalse())
Expect(bs.Scrobble(ctx, "user1", scrobble)).To(Succeed()) 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()) Eventually(scr.ScrobbleCalled.Load).Should(BeTrue())
lastScrobble := scr.LastScrobble.Load() lastScrobble := scr.LastScrobble.Load()

View File

@ -31,6 +31,12 @@ type Submission struct {
Timestamp time.Time Timestamp time.Time
} }
type nowPlayingEntry struct {
userId string
track *model.MediaFile
position int
}
type PlayTracker interface { type PlayTracker interface {
NowPlaying(ctx context.Context, playerId string, playerName string, trackId string, position int) error NowPlaying(ctx context.Context, playerId string, playerName string, trackId string, position int) error
GetNowPlaying(ctx context.Context) ([]NowPlayingInfo, error) GetNowPlaying(ctx context.Context) ([]NowPlayingInfo, error)
@ -52,6 +58,11 @@ type playTracker struct {
pluginScrobblers map[string]Scrobbler pluginScrobblers map[string]Scrobbler
pluginLoader PluginLoader pluginLoader PluginLoader
mu sync.RWMutex 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 { func GetPlayTracker(ds model.DataStore, broker events.Broker, pluginManager PluginLoader) PlayTracker {
@ -71,6 +82,10 @@ func newPlayTracker(ds model.DataStore, broker events.Broker, pluginManager Plug
builtinScrobblers: make(map[string]Scrobbler), builtinScrobblers: make(map[string]Scrobbler),
pluginScrobblers: make(map[string]Scrobbler), pluginScrobblers: make(map[string]Scrobbler),
pluginLoader: pluginManager, pluginLoader: pluginManager,
npQueue: make(map[string]nowPlayingEntry),
npSignal: make(chan struct{}, 1),
shutdown: make(chan struct{}),
workerDone: make(chan struct{}),
} }
if conf.Server.EnableNowPlaying { if conf.Server.EnableNowPlaying {
m.OnExpiration(func(_ string, _ NowPlayingInfo) { m.OnExpiration(func(_ string, _ NowPlayingInfo) {
@ -90,9 +105,16 @@ func newPlayTracker(ds model.DataStore, broker events.Broker, pluginManager Plug
p.builtinScrobblers[name] = s p.builtinScrobblers[name] = s
} }
log.Debug("List of builtin scrobblers enabled", "names", enabled) log.Debug("List of builtin scrobblers enabled", "names", enabled)
go p.nowPlayingWorker()
return p 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 // pluginNamesMatchScrobblers returns true if the set of pluginNames matches the keys in pluginScrobblers
func pluginNamesMatchScrobblers(pluginNames []string, scrobblers map[string]Scrobbler) bool { func pluginNamesMatchScrobblers(pluginNames []string, scrobblers map[string]Scrobbler) bool {
if len(pluginNames) != len(scrobblers) { if len(pluginNames) != len(scrobblers) {
@ -198,11 +220,58 @@ func (p *playTracker) NowPlaying(ctx context.Context, playerId string, playerNam
} }
player, _ := request.PlayerFrom(ctx) player, _ := request.PlayerFrom(ctx)
if player.ScrobbleEnabled { if player.ScrobbleEnabled {
p.dispatchNowPlaying(ctx, user.ID, mf, position) p.enqueueNowPlaying(playerId, user.ID, mf, position)
} }
return nil return nil
} }
func (p *playTracker) enqueueNowPlaying(playerId string, userId string, track *model.MediaFile, position int) {
p.npMu.Lock()
defer p.npMu.Unlock()
p.npQueue[playerId] = nowPlayingEntry{
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(context.Background(), entry.userId, entry.track, entry.position)
}
}
}
func (p *playTracker) dispatchNowPlaying(ctx context.Context, userId string, t *model.MediaFile, position int) { func (p *playTracker) dispatchNowPlaying(ctx context.Context, userId string, t *model.MediaFile, position int) {
if t.Artist == consts.UnknownArtist { if t.Artist == consts.UnknownArtist {
log.Debug(ctx, "Ignoring external NowPlaying update for track with unknown artist", "track", t.Title, "artist", t.Artist) log.Debug(ctx, "Ignoring external NowPlaying update for track with unknown artist", "track", t.Title, "artist", t.Artist)

View File

@ -24,15 +24,26 @@ import (
// Moved to top-level scope to avoid linter issues // Moved to top-level scope to avoid linter issues
type mockPluginLoader struct { type mockPluginLoader struct {
mu sync.RWMutex
names []string names []string
scrobblers map[string]Scrobbler scrobblers map[string]Scrobbler
} }
func (m *mockPluginLoader) PluginNames(service string) []string { func (m *mockPluginLoader) PluginNames(service string) []string {
m.mu.RLock()
defer m.mu.RUnlock()
return m.names 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) { func (m *mockPluginLoader) LoadScrobbler(name string) (Scrobbler, bool) {
m.mu.RLock()
defer m.mu.RUnlock()
s, ok := m.scrobblers[name] s, ok := m.scrobblers[name]
return s, ok return s, ok
} }
@ -46,7 +57,7 @@ var _ = Describe("PlayTracker", func() {
var album model.Album var album model.Album
var artist1 model.Artist var artist1 model.Artist
var artist2 model.Artist var artist2 model.Artist
var fake fakeScrobbler var fake *fakeScrobbler
BeforeEach(func() { BeforeEach(func() {
DeferCleanup(configtest.SetupConfig()) DeferCleanup(configtest.SetupConfig())
@ -54,16 +65,16 @@ var _ = Describe("PlayTracker", func() {
ctx = request.WithUser(ctx, model.User{ID: "u-1"}) ctx = request.WithUser(ctx, model.User{ID: "u-1"})
ctx = request.WithPlayer(ctx, model.Player{ScrobbleEnabled: true}) ctx = request.WithPlayer(ctx, model.Player{ScrobbleEnabled: true})
ds = &tests.MockDataStore{} ds = &tests.MockDataStore{}
fake = fakeScrobbler{Authorized: true} fake = &fakeScrobbler{Authorized: true}
Register("fake", func(model.DataStore) Scrobbler { Register("fake", func(model.DataStore) Scrobbler {
return &fake return fake
}) })
Register("disabled", func(model.DataStore) Scrobbler { Register("disabled", func(model.DataStore) Scrobbler {
return nil return nil
}) })
eventBroker = &fakeEventBroker{} eventBroker = &fakeEventBroker{}
tracker = newPlayTracker(ds, eventBroker, nil) 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{ track = model.MediaFile{
ID: "123", ID: "123",
@ -86,6 +97,11 @@ var _ = Describe("PlayTracker", func() {
_ = ds.Album(ctx).(*tests.MockAlbumRepo).Put(&album) _ = 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() { It("does not register disabled scrobblers", func() {
Expect(tracker.(*playTracker).builtinScrobblers).To(HaveKey("fake")) Expect(tracker.(*playTracker).builtinScrobblers).To(HaveKey("fake"))
Expect(tracker.(*playTracker).builtinScrobblers).ToNot(HaveKey("disabled")) Expect(tracker.(*playTracker).builtinScrobblers).ToNot(HaveKey("disabled"))
@ -95,10 +111,10 @@ var _ = Describe("PlayTracker", func() {
It("sends track to agent", func() { It("sends track to agent", func() {
err := tracker.NowPlaying(ctx, "player-1", "player-one", "123", 0) err := tracker.NowPlaying(ctx, "player-1", "player-one", "123", 0)
Expect(err).ToNot(HaveOccurred()) Expect(err).ToNot(HaveOccurred())
Expect(fake.NowPlayingCalled).To(BeTrue()) Eventually(func() bool { return fake.GetNowPlayingCalled() }).Should(BeTrue())
Expect(fake.UserID).To(Equal("u-1")) Expect(fake.GetUserID()).To(Equal("u-1"))
Expect(fake.Track.ID).To(Equal("123")) Expect(fake.GetTrack().ID).To(Equal("123"))
Expect(fake.Track.Participants).To(Equal(track.Participants)) Expect(fake.GetTrack().Participants).To(Equal(track.Participants))
}) })
It("does not send track to agent if user has not authorized", func() { It("does not send track to agent if user has not authorized", func() {
fake.Authorized = false fake.Authorized = false
@ -106,7 +122,7 @@ var _ = Describe("PlayTracker", func() {
err := tracker.NowPlaying(ctx, "player-1", "player-one", "123", 0) err := tracker.NowPlaying(ctx, "player-1", "player-one", "123", 0)
Expect(err).ToNot(HaveOccurred()) 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() { It("does not send track to agent if player is not enabled to send scrobbles", func() {
ctx = request.WithPlayer(ctx, model.Player{ScrobbleEnabled: false}) 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) err := tracker.NowPlaying(ctx, "player-1", "player-one", "123", 0)
Expect(err).ToNot(HaveOccurred()) 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() { It("does not send track to agent if artist is unknown", func() {
track.Artist = consts.UnknownArtist track.Artist = consts.UnknownArtist
@ -122,7 +138,7 @@ var _ = Describe("PlayTracker", func() {
err := tracker.NowPlaying(ctx, "player-1", "player-one", "123", 0) err := tracker.NowPlaying(ctx, "player-1", "player-one", "123", 0)
Expect(err).ToNot(HaveOccurred()) Expect(err).ToNot(HaveOccurred())
Expect(fake.NowPlayingCalled).To(BeFalse()) Expect(fake.GetNowPlayingCalled()).To(BeFalse())
}) })
It("stores position when greater than zero", func() { 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) err := tracker.NowPlaying(ctx, "player-1", "player-one", "123", pos)
Expect(err).ToNot(HaveOccurred()) Expect(err).ToNot(HaveOccurred())
Eventually(func() int { return fake.GetPosition() }).Should(Equal(pos))
playing, err := tracker.GetNowPlaying(ctx) playing, err := tracker.GetNowPlaying(ctx)
Expect(err).ToNot(HaveOccurred()) Expect(err).ToNot(HaveOccurred())
Expect(playing).To(HaveLen(1)) Expect(playing).To(HaveLen(1))
Expect(playing[0].Position).To(Equal(pos)) Expect(playing[0].Position).To(Equal(pos))
Expect(fake.Position).To(Equal(pos))
}) })
It("sends event with count", func() { It("sends event with count", func() {
@ -210,7 +227,7 @@ var _ = Describe("PlayTracker", func() {
Expect(err).ToNot(HaveOccurred()) Expect(err).ToNot(HaveOccurred())
Expect(fake.ScrobbleCalled.Load()).To(BeTrue()) Expect(fake.ScrobbleCalled.Load()).To(BeTrue())
Expect(fake.UserID).To(Equal("u-1")) Expect(fake.GetUserID()).To(Equal("u-1"))
lastScrobble := fake.LastScrobble.Load() lastScrobble := fake.LastScrobble.Load()
Expect(lastScrobble.TimeStamp).To(BeTemporally("~", ts, 1*time.Second)) Expect(lastScrobble.TimeStamp).To(BeTemporally("~", ts, 1*time.Second))
Expect(lastScrobble.ID).To(Equal("123")) Expect(lastScrobble.ID).To(Equal("123"))
@ -278,45 +295,46 @@ var _ = Describe("PlayTracker", func() {
Describe("Plugin scrobbler logic", func() { Describe("Plugin scrobbler logic", func() {
var pluginLoader *mockPluginLoader var pluginLoader *mockPluginLoader
var pluginFake fakeScrobbler var pluginFake *fakeScrobbler
BeforeEach(func() { BeforeEach(func() {
pluginFake = fakeScrobbler{Authorized: true} pluginFake = &fakeScrobbler{Authorized: true}
pluginLoader = &mockPluginLoader{ pluginLoader = &mockPluginLoader{
names: []string{"plugin1"}, names: []string{"plugin1"},
scrobblers: map[string]Scrobbler{"plugin1": &pluginFake}, scrobblers: map[string]Scrobbler{"plugin1": pluginFake},
} }
tracker = newPlayTracker(ds, events.GetBroker(), pluginLoader) tracker = newPlayTracker(ds, events.GetBroker(), pluginLoader)
// Bypass buffering for both built-in and plugin scrobblers // Bypass buffering for both built-in and plugin scrobblers
tracker.(*playTracker).builtinScrobblers["fake"] = &fake tracker.(*playTracker).builtinScrobblers["fake"] = fake
tracker.(*playTracker).pluginScrobblers["plugin1"] = &pluginFake tracker.(*playTracker).pluginScrobblers["plugin1"] = pluginFake
}) })
It("registers and uses plugin scrobbler for NowPlaying", func() { It("registers and uses plugin scrobbler for NowPlaying", func() {
err := tracker.NowPlaying(ctx, "player-1", "player-one", "123", 0) err := tracker.NowPlaying(ctx, "player-1", "player-one", "123", 0)
Expect(err).ToNot(HaveOccurred()) 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() { It("removes plugin scrobbler if not present anymore", func() {
// First call: plugin present // First call: plugin present
_ = tracker.NowPlaying(ctx, "player-1", "player-one", "123", 0) _ = tracker.NowPlaying(ctx, "player-1", "player-one", "123", 0)
Expect(pluginFake.NowPlayingCalled).To(BeTrue()) Eventually(func() bool { return pluginFake.GetNowPlayingCalled() }).Should(BeTrue())
pluginFake.NowPlayingCalled = false pluginFake.nowPlayingCalled.Store(false)
// Remove plugin // Remove plugin
pluginLoader.names = []string{} pluginLoader.SetNames([]string{})
_ = tracker.NowPlaying(ctx, "player-1", "player-one", "123", 0) _ = 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() { It("calls both builtin and plugin scrobblers for NowPlaying", func() {
fake.NowPlayingCalled = false fake.nowPlayingCalled.Store(false)
pluginFake.NowPlayingCalled = false pluginFake.nowPlayingCalled.Store(false)
err := tracker.NowPlaying(ctx, "player-1", "player-one", "123", 0) err := tracker.NowPlaying(ctx, "player-1", "player-one", "123", 0)
Expect(err).ToNot(HaveOccurred()) Expect(err).ToNot(HaveOccurred())
Expect(fake.NowPlayingCalled).To(BeTrue()) Eventually(func() bool { return fake.GetNowPlayingCalled() }).Should(BeTrue())
Expect(pluginFake.NowPlayingCalled).To(BeTrue()) Eventually(func() bool { return pluginFake.GetNowPlayingCalled() }).Should(BeTrue())
}) })
It("calls plugin scrobbler for Submit", func() { It("calls plugin scrobbler for Submit", func() {
@ -359,7 +377,7 @@ var _ = Describe("PlayTracker", func() {
It("calls Stop on scrobblers when removing them", func() { It("calls Stop on scrobblers when removing them", func() {
// Change the plugin names to simulate a plugin being removed // Change the plugin names to simulate a plugin being removed
mockPlugin.names = []string{} mockPlugin.SetNames([]string{})
// Call refreshPluginScrobblers which should detect the removed plugin // Call refreshPluginScrobblers which should detect the removed plugin
pTracker.refreshPluginScrobblers() pTracker.refreshPluginScrobblers()
@ -375,32 +393,51 @@ var _ = Describe("PlayTracker", func() {
type fakeScrobbler struct { type fakeScrobbler struct {
Authorized bool Authorized bool
NowPlayingCalled bool nowPlayingCalled atomic.Bool
ScrobbleCalled atomic.Bool ScrobbleCalled atomic.Bool
UserID string userID atomic.Pointer[string]
Track *model.MediaFile track atomic.Pointer[model.MediaFile]
Position int position atomic.Int32
LastScrobble atomic.Pointer[Scrobble] LastScrobble atomic.Pointer[Scrobble]
Error error 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) IsAuthorized(ctx context.Context, userId string) bool { func (f *fakeScrobbler) IsAuthorized(ctx context.Context, userId string) bool {
return f.Error == nil && f.Authorized return f.Error == nil && f.Authorized
} }
func (f *fakeScrobbler) NowPlaying(ctx context.Context, userId string, track *model.MediaFile, position int) error { 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 { if f.Error != nil {
return f.Error return f.Error
} }
f.UserID = userId f.userID.Store(&userId)
f.Track = track f.track.Store(track)
f.Position = position f.position.Store(int32(position))
return nil return nil
} }
func (f *fakeScrobbler) Scrobble(ctx context.Context, userId string, s Scrobble) error { func (f *fakeScrobbler) Scrobble(ctx context.Context, userId string, s Scrobble) error {
f.UserID = userId f.userID.Store(&userId)
f.LastScrobble.Store(&s) f.LastScrobble.Store(&s)
f.ScrobbleCalled.Store(true) f.ScrobbleCalled.Store(true)
if f.Error != nil { if f.Error != nil {

View File

@ -45,10 +45,12 @@ func Db() *sql.DB {
if err != nil { if err != nil {
log.Fatal("Error opening database", err) log.Fatal("Error opening database", err)
} }
_, err = db.Exec("PRAGMA optimize=0x10002") if conf.Server.DevOptimizeDB {
if err != nil { _, err = db.Exec("PRAGMA optimize=0x10002")
log.Error("Error applying PRAGMA optimize", err) if err != nil {
return nil log.Error("Error applying PRAGMA optimize", err)
return nil
}
} }
return db return db
}) })
@ -99,7 +101,7 @@ func Init(ctx context.Context) func() {
log.Fatal(ctx, "Failed to apply new migrations", err) log.Fatal(ctx, "Failed to apply new migrations", err)
} }
if hasSchemaChanges { if hasSchemaChanges && conf.Server.DevOptimizeDB {
log.Debug(ctx, "Applying PRAGMA optimize after schema changes") log.Debug(ctx, "Applying PRAGMA optimize after schema changes")
_, err = db.ExecContext(ctx, "PRAGMA optimize") _, err = db.ExecContext(ctx, "PRAGMA optimize")
if err != nil { if err != nil {
@ -114,6 +116,9 @@ func Init(ctx context.Context) func() {
// Optimize runs PRAGMA optimize on each connection in the pool // Optimize runs PRAGMA optimize on each connection in the pool
func Optimize(ctx context.Context) { func Optimize(ctx context.Context) {
if !conf.Server.DevOptimizeDB {
return
}
numConns := Db().Stats().OpenConnections numConns := Db().Stats().OpenConnections
if numConns == 0 { if numConns == 0 {
log.Debug(ctx, "No open connections to optimize") log.Debug(ctx, "No open connections to optimize")

View File

@ -7,6 +7,7 @@ import (
"strings" "strings"
"sync" "sync"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/consts" "github.com/navidrome/navidrome/consts"
) )
@ -21,11 +22,13 @@ func notice(tx *sql.Tx, msg string) {
// Call this in migrations that requires a full rescan // Call this in migrations that requires a full rescan
func forceFullRescan(tx *sql.Tx) error { func forceFullRescan(tx *sql.Tx) error {
// If a full scan is required, most probably the query optimizer is outdated, so we run `analyze`. // If a full scan is required, most probably the query optimizer is outdated, so we run `analyze`.
_, err := tx.Exec(`ANALYZE;`) if conf.Server.DevOptimizeDB {
if err != nil { _, err := tx.Exec(`ANALYZE;`)
return err if err != nil {
return err
}
} }
_, err = tx.Exec(fmt.Sprintf(` _, err := tx.Exec(fmt.Sprintf(`
INSERT OR REPLACE into property (id, value) values ('%s', '1'); INSERT OR REPLACE into property (id, value) values ('%s', '1');
`, consts.FullScanAfterMigrationFlagKey)) `, consts.FullScanAfterMigrationFlagKey))
return err return err

View File

@ -29,8 +29,8 @@ var redacted = &Hook{
"(Secret:\")[\\w]*", "(Secret:\")[\\w]*",
"(Spotify.*ID:\")[\\w]*", "(Spotify.*ID:\")[\\w]*",
"(PasswordEncryptionKey:[\\s]*\")[^\"]*", "(PasswordEncryptionKey:[\\s]*\")[^\"]*",
"(ReverseProxyUserHeader:[\\s]*\")[^\"]*", "(UserHeader:[\\s]*\")[^\"]*",
"(ReverseProxyWhitelist:[\\s]*\")[^\"]*", "(TrustedSources:[\\s]*\")[^\"]*",
"(MetricsPath:[\\s]*\")[^\"]*", "(MetricsPath:[\\s]*\")[^\"]*",
"(DevAutoCreateAdminPassword:[\\s]*\")[^\"]*", "(DevAutoCreateAdminPassword:[\\s]*\")[^\"]*",
"(DevAutoLoginUsername:[\\s]*\")[^\"]*", "(DevAutoLoginUsername:[\\s]*\")[^\"]*",

View File

@ -179,7 +179,9 @@ func (r *libraryRepository) ScanEnd(id int) error {
// https://www.sqlite.org/pragma.html#pragma_optimize // https://www.sqlite.org/pragma.html#pragma_optimize
// Use mask 0x10000 to check table sizes without running ANALYZE // Use mask 0x10000 to check table sizes without running ANALYZE
// Running ANALYZE can cause query planner issues with expression-based collation indexes // Running ANALYZE can cause query planner issues with expression-based collation indexes
_, err = r.executeSQL(Expr("PRAGMA optimize=0x10000;")) if conf.Server.DevOptimizeDB {
_, err = r.executeSQL(Expr("PRAGMA optimize=0x10000;"))
}
return err return err
} }

View File

@ -264,6 +264,11 @@ func (r *playlistRepository) refreshSmartPlaylist(pls *model.Playlist) bool {
"annotation.item_id = media_file.id" + "annotation.item_id = media_file.id" +
" AND annotation.item_type = 'media_file'" + " AND annotation.item_type = 'media_file'" +
" AND annotation.user_id = '" + usr.ID + "')") " AND annotation.user_id = '" + usr.ID + "')")
// Only include media files from libraries the user has access to
sq = r.applyLibraryFilter(sq, "media_file")
// Apply the criteria rules
sq = r.addCriteria(sq, rules) sq = r.addCriteria(sq, rules)
insSql := Insert("playlist_tracks").Columns("id", "playlist_id", "media_file_id").Select(sq) insSql := Insert("playlist_tracks").Columns("id", "playlist_id", "media_file_id").Select(sq)
_, err = r.executeSQL(insSql) _, err = r.executeSQL(insSql)

View File

@ -366,4 +366,136 @@ var _ = Describe("PlaylistRepository", func() {
Expect(foundWithoutGrouping).To(BeTrue()) Expect(foundWithoutGrouping).To(BeTrue())
}) })
}) })
Describe("Smart Playlists Library Filtering", func() {
var mfRepo model.MediaFileRepository
var testPlaylistID string
var lib2ID int
var restrictedUserID string
var uniqueLibPath string
BeforeEach(func() {
db := GetDBXBuilder()
// Generate unique IDs for this test run
uniqueSuffix := time.Now().Format("20060102150405.000")
restrictedUserID = "restricted-user-" + uniqueSuffix
uniqueLibPath = "/music/lib2-" + uniqueSuffix
// Create a second library with unique name and path to avoid conflicts with other tests
_, err := db.DB().Exec("INSERT INTO library (name, path, created_at, updated_at) VALUES (?, ?, datetime('now'), datetime('now'))", "Library 2-"+uniqueSuffix, uniqueLibPath)
Expect(err).ToNot(HaveOccurred())
err = db.DB().QueryRow("SELECT last_insert_rowid()").Scan(&lib2ID)
Expect(err).ToNot(HaveOccurred())
// Create a restricted user with access only to library 1
_, err = db.DB().Exec("INSERT INTO user (id, user_name, name, is_admin, password, created_at, updated_at) VALUES (?, ?, 'Restricted User', false, 'pass', datetime('now'), datetime('now'))", restrictedUserID, restrictedUserID)
Expect(err).ToNot(HaveOccurred())
_, err = db.DB().Exec("INSERT INTO user_library (user_id, library_id) VALUES (?, 1)", restrictedUserID)
Expect(err).ToNot(HaveOccurred())
// Create test media files in each library
ctx := log.NewContext(GinkgoT().Context())
ctx = request.WithUser(ctx, model.User{ID: "userid", UserName: "userid", IsAdmin: true})
mfRepo = NewMediaFileRepository(ctx, db)
// Song in library 1 (accessible by restricted user)
songLib1 := model.MediaFile{
ID: "lib1-song",
Title: "Song in Lib1",
Artist: "Test Artist",
ArtistID: "1",
Album: "Test Album",
AlbumID: "101",
Path: "/music/lib1/song.mp3",
LibraryID: 1,
Participants: model.Participants{},
Tags: model.Tags{},
Lyrics: "[]",
}
Expect(mfRepo.Put(&songLib1)).To(Succeed())
// Song in library 2 (NOT accessible by restricted user)
songLib2 := model.MediaFile{
ID: "lib2-song",
Title: "Song in Lib2",
Artist: "Test Artist",
ArtistID: "1",
Album: "Test Album",
AlbumID: "101",
Path: uniqueLibPath + "/song.mp3",
LibraryID: lib2ID,
Participants: model.Participants{},
Tags: model.Tags{},
Lyrics: "[]",
}
Expect(mfRepo.Put(&songLib2)).To(Succeed())
})
AfterEach(func() {
db := GetDBXBuilder()
if testPlaylistID != "" {
_ = repo.Delete(testPlaylistID)
testPlaylistID = ""
}
// Clean up test data
_, _ = db.Delete("media_file", dbx.HashExp{"id": "lib1-song"}).Execute()
_, _ = db.Delete("media_file", dbx.HashExp{"id": "lib2-song"}).Execute()
_, _ = db.Delete("user_library", dbx.HashExp{"user_id": restrictedUserID}).Execute()
_, _ = db.Delete("user", dbx.HashExp{"id": restrictedUserID}).Execute()
_, _ = db.DB().Exec("DELETE FROM library WHERE id = ?", lib2ID)
})
It("should only include tracks from libraries the user has access to (issue #4738)", func() {
db := GetDBXBuilder()
ctx := log.NewContext(GinkgoT().Context())
// Create the smart playlist as the restricted user
restrictedUser := model.User{ID: restrictedUserID, UserName: restrictedUserID, IsAdmin: false}
ctx = request.WithUser(ctx, restrictedUser)
restrictedRepo := NewPlaylistRepository(ctx, db)
// Create a smart playlist that matches all songs
rules := &criteria.Criteria{
Expression: criteria.All{
criteria.Gt{"playCount": -1}, // Matches everything
},
}
newPls := model.Playlist{Name: "All Songs", OwnerID: restrictedUserID, Rules: rules}
Expect(restrictedRepo.Put(&newPls)).To(Succeed())
testPlaylistID = newPls.ID
By("refreshing the smart playlist")
conf.Server.SmartPlaylistRefreshDelay = -1 * time.Second // Force refresh
pls, err := restrictedRepo.GetWithTracks(newPls.ID, true, false)
Expect(err).ToNot(HaveOccurred())
By("verifying only the track from library 1 is in the playlist")
var foundLib1Song, foundLib2Song bool
for _, track := range pls.Tracks {
if track.MediaFileID == "lib1-song" {
foundLib1Song = true
}
if track.MediaFileID == "lib2-song" {
foundLib2Song = true
}
}
Expect(foundLib1Song).To(BeTrue(), "Song from library 1 should be in the playlist")
Expect(foundLib2Song).To(BeFalse(), "Song from library 2 should NOT be in the playlist")
By("verifying playlist_tracks table only contains the accessible track")
var playlistTracksCount int
err = db.DB().QueryRow("SELECT count(*) FROM playlist_tracks WHERE playlist_id = ?", newPls.ID).Scan(&playlistTracksCount)
Expect(err).ToNot(HaveOccurred())
// Count should only include tracks visible to the user (lib1-song)
// The count may include other test songs from library 1, but NOT lib2-song
var lib2TrackCount int
err = db.DB().QueryRow("SELECT count(*) FROM playlist_tracks WHERE playlist_id = ? AND media_file_id = 'lib2-song'", newPls.ID).Scan(&lib2TrackCount)
Expect(err).ToNot(HaveOccurred())
Expect(lib2TrackCount).To(Equal(0), "lib2-song should not be in playlist_tracks")
By("verifying SongCount matches visible tracks")
Expect(pls.SongCount).To(Equal(len(pls.Tracks)), "SongCount should match the number of visible tracks")
})
})
}) })

View File

@ -2,6 +2,7 @@ package persistence
import ( import (
"context" "context"
"time"
"github.com/deluan/rest" "github.com/deluan/rest"
"github.com/navidrome/navidrome/conf/configtest" "github.com/navidrome/navidrome/conf/configtest"
@ -45,6 +46,9 @@ var _ = Describe("Tag Library Filtering", func() {
BeforeEach(func() { BeforeEach(func() {
DeferCleanup(configtest.SetupConfig()) DeferCleanup(configtest.SetupConfig())
// Generate unique path suffix to avoid conflicts with other tests
uniqueSuffix := time.Now().Format("20060102150405.000")
// Clean up database // Clean up database
db := GetDBXBuilder() db := GetDBXBuilder()
_, err := db.NewQuery("DELETE FROM library_tag").Execute() _, err := db.NewQuery("DELETE FROM library_tag").Execute()
@ -57,12 +61,12 @@ var _ = Describe("Tag Library Filtering", func() {
_, err = db.NewQuery("DELETE FROM library WHERE id > 1").Execute() _, err = db.NewQuery("DELETE FROM library WHERE id > 1").Execute()
Expect(err).ToNot(HaveOccurred()) Expect(err).ToNot(HaveOccurred())
// Create test libraries // Create test libraries with unique names and paths to avoid conflicts with other tests
_, err = db.NewQuery("INSERT INTO library (id, name, path) VALUES ({:id}, {:name}, {:path})"). _, err = db.NewQuery("INSERT INTO library (id, name, path) VALUES ({:id}, {:name}, {:path})").
Bind(dbx.Params{"id": libraryID2, "name": "Library 2", "path": "/music/lib2"}).Execute() Bind(dbx.Params{"id": libraryID2, "name": "Library 2-" + uniqueSuffix, "path": "/music/lib2-" + uniqueSuffix}).Execute()
Expect(err).ToNot(HaveOccurred()) Expect(err).ToNot(HaveOccurred())
_, err = db.NewQuery("INSERT INTO library (id, name, path) VALUES ({:id}, {:name}, {:path})"). _, err = db.NewQuery("INSERT INTO library (id, name, path) VALUES ({:id}, {:name}, {:path})").
Bind(dbx.Params{"id": libraryID3, "name": "Library 3", "path": "/music/lib3"}).Execute() Bind(dbx.Params{"id": libraryID3, "name": "Library 3-" + uniqueSuffix, "path": "/music/lib3-" + uniqueSuffix}).Execute()
Expect(err).ToNot(HaveOccurred()) Expect(err).ToNot(HaveOccurred())
// Give admin access to all libraries // Give admin access to all libraries

View File

@ -83,7 +83,7 @@
"actions": { "actions": {
"playAll": "Afspil", "playAll": "Afspil",
"playNext": "Afspil næste", "playNext": "Afspil næste",
"addToQueue": "Afspil senere", "addToQueue": "Føj til kø",
"shuffle": "Bland", "shuffle": "Bland",
"addToPlaylist": "Føj til afspilningsliste", "addToPlaylist": "Føj til afspilningsliste",
"download": "Download", "download": "Download",
@ -301,14 +301,19 @@
"actions": { "actions": {
"scan": "Scanningsbibliotek", "scan": "Scanningsbibliotek",
"manageUsers": "Administrer brugeradgang", "manageUsers": "Administrer brugeradgang",
"viewDetails": "Se detaljer" "viewDetails": "Se detaljer",
"quickScan": "hurtig skanning",
"fullScan": "Fuld skanning"
}, },
"notifications": { "notifications": {
"created": "Bibliotek oprettet", "created": "Bibliotek oprettet",
"updated": "Biblioteket er blevet opdateret", "updated": "Biblioteket er blevet opdateret",
"deleted": "Biblioteket er blevet slettet", "deleted": "Biblioteket er blevet slettet",
"scanStarted": "Biblioteksscanning startet", "scanStarted": "Biblioteksscanning startet",
"scanCompleted": "Biblioteksscanning fuldført" "scanCompleted": "Biblioteksscanning fuldført",
"quickScanStarted": "hurtig skanning startet",
"fullScanStarted": "Fuld skanning startet",
"scanError": "Kan ikke starte skanning. Tjek loggen"
}, },
"validation": { "validation": {
"nameRequired": "Biblioteksnavn er påkrævet", "nameRequired": "Biblioteksnavn er påkrævet",
@ -549,7 +554,7 @@
"closeText": "Luk", "closeText": "Luk",
"notContentText": "Ingen musik", "notContentText": "Ingen musik",
"clickToPlayText": "Tryk for at afspille", "clickToPlayText": "Tryk for at afspille",
"clickToPauseText": "Tryk for at pause", "clickToPauseText": "Tryk for at sætte på pause",
"nextTrackText": "Næste nummer", "nextTrackText": "Næste nummer",
"previousTrackText": "Forrige nummer", "previousTrackText": "Forrige nummer",
"reloadText": "Genindlæs", "reloadText": "Genindlæs",
@ -604,7 +609,8 @@
"serverDown": "OFFLINE", "serverDown": "OFFLINE",
"scanType": "Type", "scanType": "Type",
"status": "Scanningsfejl", "status": "Scanningsfejl",
"elapsedTime": "Medgået tid" "elapsedTime": "Medgået tid",
"selectiveScan": "Selektiv"
}, },
"help": { "help": {
"title": "Navidrome genvejstaster", "title": "Navidrome genvejstaster",

View File

@ -301,14 +301,19 @@
"actions": { "actions": {
"scan": "Bibliothek scannen", "scan": "Bibliothek scannen",
"manageUsers": "Zugriff verwalten", "manageUsers": "Zugriff verwalten",
"viewDetails": "Details ansehen" "viewDetails": "Details ansehen",
"quickScan": "Schneller Scan",
"fullScan": "Kompletter Scan"
}, },
"notifications": { "notifications": {
"created": "Bibliothek erfolgreich erstellt", "created": "Bibliothek erfolgreich erstellt",
"updated": "Bibliothek erfolgreich geändert", "updated": "Bibliothek erfolgreich geändert",
"deleted": "Bibliothek erfolgreich gelöscht", "deleted": "Bibliothek erfolgreich gelöscht",
"scanStarted": "Bibliothek Scan gestartet", "scanStarted": "Bibliothek Scan gestartet",
"scanCompleted": "Bibliothek Scan vollständig" "scanCompleted": "Bibliothek Scan vollständig",
"quickScanStarted": "Schneller Scan gestartet",
"fullScanStarted": "Kompletter Scan gestartet",
"scanError": "Fehler beim Starten des Scans. Logs prüfen"
}, },
"validation": { "validation": {
"nameRequired": "Bibliotheksname ist Pflichtfeld", "nameRequired": "Bibliotheksname ist Pflichtfeld",
@ -604,7 +609,8 @@
"serverDown": "OFFLINE", "serverDown": "OFFLINE",
"scanType": "Typ", "scanType": "Typ",
"status": "Scan Fehler", "status": "Scan Fehler",
"elapsedTime": "Laufzeit" "elapsedTime": "Laufzeit",
"selectiveScan": "Selektiver Scan"
}, },
"help": { "help": {
"title": "Navidrome Hotkeys", "title": "Navidrome Hotkeys",

View File

@ -301,14 +301,19 @@
"actions": { "actions": {
"scan": "Σάρωση βιβλιοθήκης", "scan": "Σάρωση βιβλιοθήκης",
"manageUsers": "Διαχείριση πρόσβασης χρήστη", "manageUsers": "Διαχείριση πρόσβασης χρήστη",
"viewDetails": "Προβολή λεπτομερειών" "viewDetails": "Προβολή λεπτομερειών",
"quickScan": "Γρήγορη σάρωση",
"fullScan": "Πλήρης σάρωση"
}, },
"notifications": { "notifications": {
"created": "Η βιβλιοθήκη δημιουργήθηκε με επιτυχία", "created": "Η βιβλιοθήκη δημιουργήθηκε με επιτυχία",
"updated": "Η βιβλιοθήκη ενημερώθηκε με επιτυχία", "updated": "Η βιβλιοθήκη ενημερώθηκε με επιτυχία",
"deleted": "Η βιβλιοθήκη διαγράφηκε με επιτυχία", "deleted": "Η βιβλιοθήκη διαγράφηκε με επιτυχία",
"scanStarted": "Ξεκίνησε η σάρωση της βιβλιοθήκης", "scanStarted": "Ξεκίνησε η σάρωση της βιβλιοθήκης",
"scanCompleted": "Η σάρωση της βιβλιοθήκης ολοκληρώθηκε" "scanCompleted": "Η σάρωση της βιβλιοθήκης ολοκληρώθηκε",
"quickScanStarted": "Η Γρήγορη Σάρωση ξεκίνησε",
"fullScanStarted": "Η πλήρης σάρωση ξεκίνησε",
"scanError": "Σφάλμα κατά την έναρξη της σάρωσης. Ελέγξτε τα αρχεία καταγραφής."
}, },
"validation": { "validation": {
"nameRequired": "Απαιτείται όνομα βιβλιοθήκης", "nameRequired": "Απαιτείται όνομα βιβλιοθήκης",
@ -604,7 +609,8 @@
"serverDown": "ΕΚΤΟΣ ΣΥΝΔΕΣΗΣ", "serverDown": "ΕΚΤΟΣ ΣΥΝΔΕΣΗΣ",
"scanType": "Τύπος", "scanType": "Τύπος",
"status": "Σφάλμα σάρωσης", "status": "Σφάλμα σάρωσης",
"elapsedTime": "Χρόνος που πέρασε" "elapsedTime": "Χρόνος που πέρασε",
"selectiveScan": "Εκλεκτικός"
}, },
"help": { "help": {
"title": "Συντομεύσεις του Navidrome", "title": "Συντομεύσεις του Navidrome",

View File

@ -36,7 +36,7 @@
"bitDepth": "Profundidad de bits", "bitDepth": "Profundidad de bits",
"sampleRate": "Frecuencia de muestreo", "sampleRate": "Frecuencia de muestreo",
"missing": "Faltante", "missing": "Faltante",
"libraryName": "" "libraryName": "Biblioteca"
}, },
"actions": { "actions": {
"addToQueue": "Reproducir después", "addToQueue": "Reproducir después",
@ -78,7 +78,7 @@
"mood": "Estado de ánimo", "mood": "Estado de ánimo",
"date": "Fecha de grabación", "date": "Fecha de grabación",
"missing": "Faltante", "missing": "Faltante",
"libraryName": "" "libraryName": "Biblioteca"
}, },
"actions": { "actions": {
"playAll": "Reproducir", "playAll": "Reproducir",
@ -127,12 +127,12 @@
"remixer": "Remixer", "remixer": "Remixer",
"djmixer": "DJ Mixer", "djmixer": "DJ Mixer",
"performer": "Intérprete", "performer": "Intérprete",
"maincredit": "" "maincredit": "Artista del álbum o Artista |||| Artistas del álbum o Artistas"
}, },
"actions": { "actions": {
"shuffle": "Aleatorio", "shuffle": "Aleatorio",
"radio": "Radio", "radio": "Radio",
"topSongs": "" "topSongs": "Más destacadas"
} }
}, },
"user": { "user": {
@ -150,11 +150,11 @@
"newPassword": "Nueva contraseña", "newPassword": "Nueva contraseña",
"token": "Token", "token": "Token",
"lastAccessAt": "Último acceso", "lastAccessAt": "Último acceso",
"libraries": "" "libraries": "Bibliotecas"
}, },
"helperTexts": { "helperTexts": {
"name": "Los cambios a tu nombre se verán en el próximo inicio de sesión", "name": "Los cambios a tu nombre se verán en el próximo inicio de sesión",
"libraries": "" "libraries": "Selecciona bibliotecas específicas para este usuario o déjalo vacío para usar las bibliotecas por defecto"
}, },
"notifications": { "notifications": {
"created": "Usuario creado", "created": "Usuario creado",
@ -164,11 +164,11 @@
"message": { "message": {
"listenBrainzToken": "Escribe tu token de usuario de ListenBrainz", "listenBrainzToken": "Escribe tu token de usuario de ListenBrainz",
"clickHereForToken": "Click aquí para obtener tu token", "clickHereForToken": "Click aquí para obtener tu token",
"selectAllLibraries": "", "selectAllLibraries": "Seleccionar todas las bibliotecas",
"adminAutoLibraries": "" "adminAutoLibraries": "Los usuarios administradores tienen acceso a todas las bibliotecas automáticamente"
}, },
"validation": { "validation": {
"librariesRequired": "" "librariesRequired": "Se debe seleccionar al menos una biblioteca para los usuarios que no sean administradores"
} }
}, },
"player": { "player": {
@ -261,7 +261,7 @@
"path": "Ruta", "path": "Ruta",
"size": "Tamaño", "size": "Tamaño",
"updatedAt": "Actualizado el", "updatedAt": "Actualizado el",
"libraryName": "" "libraryName": "Biblioteca"
}, },
"actions": { "actions": {
"remove": "Eliminar", "remove": "Eliminar",
@ -273,55 +273,60 @@
"empty": "No hay archivos perdidos" "empty": "No hay archivos perdidos"
}, },
"library": { "library": {
"name": "", "name": "Biblioteca |||| Bibliotecas",
"fields": { "fields": {
"name": "", "name": "Nombre",
"path": "", "path": "Ruta",
"remotePath": "", "remotePath": "Ruta remota",
"lastScanAt": "", "lastScanAt": "Último escaneo",
"songCount": "", "songCount": "Canciones",
"albumCount": "", "albumCount": "Álbumes",
"artistCount": "", "artistCount": "Artistas",
"totalSongs": "", "totalSongs": "Canciones",
"totalAlbums": "", "totalAlbums": "Álbumes",
"totalArtists": "", "totalArtists": "Artistas",
"totalFolders": "", "totalFolders": "Carpetas",
"totalFiles": "", "totalFiles": "Archivos",
"totalMissingFiles": "", "totalMissingFiles": "Archivos faltantes",
"totalSize": "", "totalSize": "Tamaño total",
"totalDuration": "", "totalDuration": "Duración",
"defaultNewUsers": "", "defaultNewUsers": "Valor por defecto para los nuevos usuarios",
"createdAt": "", "createdAt": "Creado",
"updatedAt": "" "updatedAt": "Actualizado"
}, },
"sections": { "sections": {
"basic": "", "basic": "Información básica",
"statistics": "" "statistics": "Estadísticas"
}, },
"actions": { "actions": {
"scan": "", "scan": "Escanear biblioteca",
"manageUsers": "", "manageUsers": "Gestionar el acceso de usarios",
"viewDetails": "" "viewDetails": "Ver detalles",
"quickScan": "Escaneo rápido",
"fullScan": "Escaneo completo"
}, },
"notifications": { "notifications": {
"created": "", "created": "La biblioteca se creó correctamente",
"updated": "", "updated": "La biblioteca se actualizó correctamente",
"deleted": "", "deleted": "La biblioteca se eliminó correctamente",
"scanStarted": "", "scanStarted": "El escaneo de la biblioteca ha comenzado",
"scanCompleted": "" "scanCompleted": "El escaneo de la biblioteca se completó",
"quickScanStarted": "Escaneo rápido ha comenzado",
"fullScanStarted": "Escaneo completo ha comenzado",
"scanError": "Error al iniciar el escaneo. Revisa los registros"
}, },
"validation": { "validation": {
"nameRequired": "", "nameRequired": "El nombre de la biblioteca es obligatorio",
"pathRequired": "", "pathRequired": "La ruta de la biblioteca es obligatoria",
"pathNotDirectory": "", "pathNotDirectory": "La ruta de la biblioteca debe ser un directorio",
"pathNotFound": "", "pathNotFound": "Ruta de la biblioteca no encontrada",
"pathNotAccessible": "", "pathNotAccessible": "La ruta de la biblioteca no es accesible",
"pathInvalid": "" "pathInvalid": "Ruta de la biblioteca no válida"
}, },
"messages": { "messages": {
"deleteConfirm": "", "deleteConfirm": "¿Estás seguro/a de que quieres eliminar esta biblioteca? Esto eliminará todos los datos asociados y el acceso de les usuaries.",
"scanInProgress": "", "scanInProgress": "Escaneo en curso...",
"noLibrariesAssigned": "" "noLibrariesAssigned": "No hay bibliotecas asignadas a este usuario"
} }
} }
}, },
@ -506,7 +511,7 @@
"remove_all_missing_title": "Eliminar todos los archivos perdidos", "remove_all_missing_title": "Eliminar todos los archivos perdidos",
"remove_all_missing_content": "¿Realmente desea eliminar todos los archivos faltantes de la base de datos? Esto eliminará permanentemente cualquier referencia a ellos, incluidas sus reproducciones y valoraciones.", "remove_all_missing_content": "¿Realmente desea eliminar todos los archivos faltantes de la base de datos? Esto eliminará permanentemente cualquier referencia a ellos, incluidas sus reproducciones y valoraciones.",
"noSimilarSongsFound": "No se encontraron canciones similares", "noSimilarSongsFound": "No se encontraron canciones similares",
"noTopSongsFound": "" "noTopSongsFound": "No se encontraron canciones destacadas"
}, },
"menu": { "menu": {
"library": "Biblioteca", "library": "Biblioteca",
@ -537,10 +542,10 @@
"playlists": "Playlists", "playlists": "Playlists",
"sharedPlaylists": "Playlists Compartidas", "sharedPlaylists": "Playlists Compartidas",
"librarySelector": { "librarySelector": {
"allLibraries": "", "allLibraries": "Todas las bibliotecas (%{count})",
"multipleLibraries": "", "multipleLibraries": "%{selected} de %{total} bibliotecas",
"selectLibraries": "", "selectLibraries": "Seleccionar bibliotecas",
"none": "" "none": "Ninguno"
} }
}, },
"player": { "player": {
@ -604,7 +609,8 @@
"serverDown": "OFFLINE", "serverDown": "OFFLINE",
"scanType": "Tipo", "scanType": "Tipo",
"status": "Error de escaneo", "status": "Error de escaneo",
"elapsedTime": "Tiempo transcurrido" "elapsedTime": "Tiempo transcurrido",
"selectiveScan": "Selectivo"
}, },
"help": { "help": {
"title": "Atajos de teclado de Navidrome", "title": "Atajos de teclado de Navidrome",
@ -621,8 +627,8 @@
} }
}, },
"nowPlaying": { "nowPlaying": {
"title": "", "title": "En reproducción",
"empty": "", "empty": "Nada en reproducción",
"minutesAgo": "" "minutesAgo": "Hace %{smart_count} minuto |||| Hace %{smart_count} minutos"
} }
} }

View File

@ -12,6 +12,7 @@
"artist": "Artista", "artist": "Artista",
"album": "Albuma", "album": "Albuma",
"path": "Fitxategiaren bidea", "path": "Fitxategiaren bidea",
"libraryName": "Liburutegia",
"genre": "Generoa", "genre": "Generoa",
"compilation": "Konpilazioa", "compilation": "Konpilazioa",
"year": "Urtea", "year": "Urtea",
@ -58,6 +59,7 @@
"playCount": "Erreprodukzioak", "playCount": "Erreprodukzioak",
"size": "Fitxategiaren tamaina", "size": "Fitxategiaren tamaina",
"name": "Izena", "name": "Izena",
"libraryName": "Liburutegia",
"genre": "Generoa", "genre": "Generoa",
"compilation": "Konpilazioa", "compilation": "Konpilazioa",
"year": "Urtea", "year": "Urtea",
@ -147,19 +149,26 @@
"currentPassword": "Uneko pasahitza", "currentPassword": "Uneko pasahitza",
"newPassword": "Pasahitz berria", "newPassword": "Pasahitz berria",
"token": "Tokena", "token": "Tokena",
"lastAccessAt": "Azken sarbidea" "lastAccessAt": "Azken sarbidea",
"libraries": "Liburutegiak"
}, },
"helperTexts": { "helperTexts": {
"name": "Aldaketak saioa hasten duzun hurrengoan islatuko dira" "name": "Aldaketak saioa hasten duzun hurrengoan islatuko dira",
"libraries": "Hautatu erabiltzaile honentzat liburutegi jakinak, edo utzi hutsik defektuzko liburutegiak erabiltzeko"
}, },
"notifications": { "notifications": {
"created": "Erabiltzailea sortu da", "created": "Erabiltzailea sortu da",
"updated": "Erabiltzailea eguneratu da", "updated": "Erabiltzailea eguneratu da",
"deleted": "Erabiltzailea ezabatu da" "deleted": "Erabiltzailea ezabatu da"
}, },
"validation": {
"librariesRequired": "Gutxienez liburutegi bat hautatu behar da administratzaile ez diren erabiltzaileentzat"
},
"message": { "message": {
"listenBrainzToken": "Idatzi zure ListenBrainz erabiltzailearen tokena", "listenBrainzToken": "Idatzi zure ListenBrainz erabiltzailearen tokena",
"clickHereForToken": "Egin klik hemen tokena lortzeko" "clickHereForToken": "Egin klik hemen tokena lortzeko",
"selectAllLibraries": "Hautatu liburutegi guztiak",
"adminAutoLibraries": "Administratzaileek automatikoki dute liburutegi guztietara sarbidea"
} }
}, },
"player": { "player": {
@ -254,6 +263,7 @@
"fields": { "fields": {
"path": "Bidea", "path": "Bidea",
"size": "Tamaina", "size": "Tamaina",
"libraryName": "Liburutegia",
"updatedAt": "Desagertze-data:" "updatedAt": "Desagertze-data:"
}, },
"actions": { "actions": {
@ -263,6 +273,58 @@
"notifications": { "notifications": {
"removed": "Aurkitzen ez ziren fitxategiak kendu dira" "removed": "Aurkitzen ez ziren fitxategiak kendu dira"
} }
},
"library": {
"name": "Liburutegia |||| Liburutegiak",
"fields": {
"name": "Izena",
"path": "Fitxategiaren bidea",
"remotePath": "Urruneko bidea",
"lastScanAt": "Azken araketa",
"songCount": "Abestiak",
"albumCount": "Albumak",
"artistCount": "Artistak",
"totalSongs": "Abestiak",
"totalAlbums": "Albumak",
"totalArtists": "Artistak",
"totalFolders": "Karpetak",
"totalFiles": "Fitxategiak",
"totalMissingFiles": "Fitxategiak faltan",
"totalSize": "Tamaina guztira",
"totalDuration": "Iraupena",
"defaultNewUsers": "Defektuz erabiltzaile berrientzat",
"createdAt": "Sortze-data",
"updatedAt": "Eguneratze-data"
},
"sections": {
"basic": "Oinarrizko informazioa",
"statistics": "Estatistikak"
},
"actions": {
"scan": "Arakatu liburutegia",
"manageUsers": "Kudeatu erabiltzaileen sarbidea",
"viewDetails": "Ikusi xehetasunak"
},
"notifications": {
"created": "Liburutegia ondo sortu da",
"updated": "Liburutegia ondo eguneratu da",
"deleted": "Liburutegia ondo ezabatu da",
"scanStarted": "Liburutegiaren araketa hasi da",
"scanCompleted": "Liburutegiaren araketa amaitu da"
},
"validation": {
"nameRequired": "Liburutegiaren izena beharrezkoa da",
"pathRequired": "Liburutegiaren bidea beharrezkoa da",
"pathNotDirectory": "Liburutegiaren bidea direktorio bat izan behar da",
"pathNotFound": "Ez da liburutegiaren bidea aurkitu",
"pathNotAccessible": "Liburutegiaren bidea ez dago eskuragai",
"pathInvalid": "Liburutegiaren bidea ez da baliozkoa"
},
"messages": {
"deleteConfirm": "Ziur liburutegia ezabatu nahi duzula? Erlazionatutako datu guztiak eta erabiltzaileen sarbidea kenduko ditu.",
"scanInProgress": "Araketa abian da…",
"noLibrariesAssigned": "Ez da liburutegirik egokitu erabiltzaile honentzat"
}
} }
}, },
"ra": { "ra": {
@ -450,6 +512,12 @@
}, },
"menu": { "menu": {
"library": "Liburutegia", "library": "Liburutegia",
"librarySelector": {
"allLibraries": "Liburutegi guztiak (%{count})",
"multipleLibraries": "%{total} liburutegitik %{selected} hautatuta",
"selectLibraries": "Hautatu liburutegiak",
"none": "Bat ere ez"
},
"settings": "Ezarpenak", "settings": "Ezarpenak",
"version": "Bertsioa", "version": "Bertsioa",
"theme": "Itxura", "theme": "Itxura",

View File

@ -301,14 +301,19 @@
"actions": { "actions": {
"scan": "Scanner la bibliothèque", "scan": "Scanner la bibliothèque",
"manageUsers": "Gérer les accès utilisateurs", "manageUsers": "Gérer les accès utilisateurs",
"viewDetails": "Voir les détails" "viewDetails": "Voir les détails",
"quickScan": "Scan Rapide",
"fullScan": "Scan Complet"
}, },
"notifications": { "notifications": {
"created": "Bibliothèque créée avec succès", "created": "Bibliothèque créée avec succès",
"updated": "Bibliothèque mise à jour avec succès", "updated": "Bibliothèque mise à jour avec succès",
"deleted": "Bibliothèque supprimée avec succès", "deleted": "Bibliothèque supprimée avec succès",
"scanStarted": "Le scan de la bibliothèque a commencé", "scanStarted": "Le scan de la bibliothèque a commencé",
"scanCompleted": "Le scan de la bibliothèque est terminé" "scanCompleted": "Le scan de la bibliothèque est terminé",
"quickScanStarted": "Scan rapide démarré",
"fullScanStarted": "Scan complet démarré",
"scanError": "Une erreur est survenue en démarrant le scan. Veuillez regarder les logs"
}, },
"validation": { "validation": {
"nameRequired": "La bibliothèque doit obligatoirement avoir un nom", "nameRequired": "La bibliothèque doit obligatoirement avoir un nom",
@ -604,7 +609,8 @@
"serverDown": "HORS LIGNE", "serverDown": "HORS LIGNE",
"scanType": "Type", "scanType": "Type",
"status": "Erreur de scan", "status": "Erreur de scan",
"elapsedTime": "Temps écoulé" "elapsedTime": "Temps écoulé",
"selectiveScan": "Sélectif"
}, },
"help": { "help": {
"title": "Raccourcis Navidrome", "title": "Raccourcis Navidrome",

View File

@ -300,7 +300,9 @@
}, },
"actions": { "actions": {
"scan": "Könyvtár szkennelése", "scan": "Könyvtár szkennelése",
"manageUsers": "Elérés kezelése", "quickScan": "Gyors szkennelés",
"fullScan": "Teljes szkennelés",
"manageUsers": "Hozzáférés kezelése",
"viewDetails": "Részletek" "viewDetails": "Részletek"
}, },
"notifications": { "notifications": {
@ -598,11 +600,12 @@
"activity": { "activity": {
"title": "Aktivitás", "title": "Aktivitás",
"totalScanned": "Összes beolvasott mappa:", "totalScanned": "Összes beolvasott mappa:",
"quickScan": "Gyors szkennelés", "quickScan": "Gyors",
"fullScan": "Teljes szkennelés", "fullScan": "Teljes",
"selectiveScan": "Szelektív",
"serverUptime": "Szerver üzemidő", "serverUptime": "Szerver üzemidő",
"serverDown": "OFFLINE", "serverDown": "OFFLINE",
"scanType": "Típus", "scanType": "Legutóbbi szkennelés",
"status": "Szkennelési hiba", "status": "Szkennelési hiba",
"elapsedTime": "Eltelt idő" "elapsedTime": "Eltelt idő"
}, },

View File

@ -27,12 +27,16 @@
"playDate": "最後の再生", "playDate": "最後の再生",
"channels": "チャンネル", "channels": "チャンネル",
"createdAt": "追加日", "createdAt": "追加日",
"grouping": "", "grouping": "グループ分け",
"mood": "", "mood": "ムード",
"participants": "", "participants": "追加参加者",
"tags": "", "tags": "追加タグ",
"mappedTags": "", "mappedTags": "マッピング済みタグ",
"rawTags": "" "rawTags": "未処理タグ",
"bitDepth": "ビット深度",
"sampleRate": "サンプリングレート",
"missing": "不明",
"libraryName": "ライブラリ"
}, },
"actions": { "actions": {
"addToQueue": "最後に再生", "addToQueue": "最後に再生",
@ -41,7 +45,8 @@
"shuffleAll": "全曲シャッフル", "shuffleAll": "全曲シャッフル",
"download": "ダウンロード", "download": "ダウンロード",
"playNext": "次に再生", "playNext": "次に再生",
"info": "詳細" "info": "詳細",
"showInPlaylist": "含まれるプレイリスト"
} }
}, },
"album": { "album": {
@ -65,12 +70,15 @@
"releaseDate": "リリース日", "releaseDate": "リリース日",
"releases": "リリース", "releases": "リリース",
"released": "リリース", "released": "リリース",
"recordLabel": "", "recordLabel": "ラベル",
"catalogNum": "", "catalogNum": "カタログ番号",
"releaseType": "", "releaseType": "タイプ",
"grouping": "", "grouping": "グループ分け",
"media": "", "media": "メディア",
"mood": "" "mood": "ムード",
"date": "録音日",
"missing": "不明",
"libraryName": "ライブラリ"
}, },
"actions": { "actions": {
"playAll": "再生", "playAll": "再生",
@ -102,22 +110,29 @@
"rating": "レート", "rating": "レート",
"genre": "ジャンル", "genre": "ジャンル",
"size": "サイズ", "size": "サイズ",
"role": "" "role": "役割",
"missing": "不明"
}, },
"roles": { "roles": {
"albumartist": "", "albumartist": "アルバムアーティスト",
"artist": "", "artist": "アーティスト",
"composer": "", "composer": "作曲家",
"conductor": "", "conductor": "指揮者",
"lyricist": "", "lyricist": "作詞家",
"arranger": "", "arranger": "編曲者",
"producer": "", "producer": "プロデューサー",
"director": "", "director": "ディレクター",
"engineer": "", "engineer": "エンジニア",
"mixer": "", "mixer": "ミキサー",
"remixer": "", "remixer": "リミキサー",
"djmixer": "", "djmixer": "DJ ミキサー",
"performer": "" "performer": "演奏者",
"maincredit": "アルバムアーティストもしくはアーティスト"
},
"actions": {
"shuffle": "シャッフル",
"radio": "ラジオ",
"topSongs": "トップソング"
} }
}, },
"user": { "user": {
@ -134,10 +149,12 @@
"currentPassword": "現在のパスワード", "currentPassword": "現在のパスワード",
"newPassword": "新しいパスワード", "newPassword": "新しいパスワード",
"token": "トークン", "token": "トークン",
"lastAccessAt": "最終アクセス" "lastAccessAt": "最終アクセス",
"libraries": "ライブラリ"
}, },
"helperTexts": { "helperTexts": {
"name": "名前の変更は次回ログイン以降反映されます" "name": "名前の変更は次回ログイン以降反映されます",
"libraries": "このユーザーに対して特定ライブラリを選択するか、デフォルトのライブラリを使用する場合は空欄のままにします"
}, },
"notifications": { "notifications": {
"created": "ユーザーが作成されました", "created": "ユーザーが作成されました",
@ -146,7 +163,12 @@
}, },
"message": { "message": {
"listenBrainzToken": "ListenBrainzユーザートークンを入力", "listenBrainzToken": "ListenBrainzユーザートークンを入力",
"clickHereForToken": "ここをクリックしトークンを入手" "clickHereForToken": "ここをクリックしトークンを入手",
"selectAllLibraries": "全てのライブラリを選択",
"adminAutoLibraries": "管理者ユーザーは自動的にすべてのライブラリにアクセスできます"
},
"validation": {
"librariesRequired": "管理者以外のユーザーには少なくとも1つのライブラリを選択する必要があります"
} }
}, },
"player": { "player": {
@ -190,11 +212,17 @@
"addNewPlaylist": "'%{name}' を作成", "addNewPlaylist": "'%{name}' を作成",
"export": "エクスポート", "export": "エクスポート",
"makePublic": "公開する", "makePublic": "公開する",
"makePrivate": "非公開にする" "makePrivate": "非公開にする",
"saveQueue": "キューをプレイリストに保存",
"searchOrCreate": "プレイリストを検索または入力して新規作成...",
"pressEnterToCreate": "Enterキーを押して新しいプレイリストを作成",
"removeFromSelection": "選択から削除"
}, },
"message": { "message": {
"duplicate_song": "重複する曲を追加", "duplicate_song": "重複する曲を追加",
"song_exist": "既にプレイリストに存在する曲です。追加しますか?" "song_exist": "既にプレイリストに存在する曲です。追加しますか?",
"noPlaylistsFound": "プレイリストが見つかりません",
"noPlaylists": "利用可能なプレイリストはありません"
} }
}, },
"radio": { "radio": {
@ -228,17 +256,77 @@
} }
}, },
"missing": { "missing": {
"name": "", "name": "欠落したファイル",
"fields": { "fields": {
"path": "", "path": "パス",
"size": "", "size": "サイズ",
"updatedAt": "" "updatedAt": "欠落日",
"libraryName": "ライブラリ"
}, },
"actions": { "actions": {
"remove": "" "remove": "削除",
"remove_all": "全て削除"
}, },
"notifications": { "notifications": {
"removed": "" "removed": "欠落ファイルが削除されました"
},
"empty": "ファイルの欠落はありません"
},
"library": {
"name": "ライブラリ",
"fields": {
"name": "名前",
"path": "パス",
"remotePath": "リモートパス",
"lastScanAt": "最終スキャン",
"songCount": "曲数",
"albumCount": "アルバム数",
"artistCount": "アーティスト数",
"totalSongs": "曲数",
"totalAlbums": "アルバム数",
"totalArtists": "アーティスト数",
"totalFolders": "フォルダー数",
"totalFiles": "ファイル数",
"totalMissingFiles": "欠落したファイル",
"totalSize": "合計サイズ",
"totalDuration": "合計時間",
"defaultNewUsers": "新規ユーザーに対するデフォルト",
"createdAt": "作成日",
"updatedAt": "更新日"
},
"sections": {
"basic": "基本情報",
"statistics": "統計"
},
"actions": {
"scan": "ライブラリをスキャン",
"manageUsers": "ユーザーアクセス管理",
"viewDetails": "詳細を表示",
"quickScan": "クイックスキャン",
"fullScan": "フルスキャン"
},
"notifications": {
"created": "ライブラリが正常に作成されました",
"updated": "ライブラリが正常に更新されました",
"deleted": "ライブラリが正常に削除されました",
"scanStarted": "スキャンを開始しました",
"scanCompleted": "スキャンが完了しました",
"quickScanStarted": "クイックスキャンを開始しました",
"fullScanStarted": "フルスキャンを開始しました",
"scanError": "スキャン開始中にエラーが発生。ログを確認してください"
},
"validation": {
"nameRequired": "ライブラリの名前が必要です",
"pathRequired": "ライブラリのパスが必要です",
"pathNotDirectory": "ライブラリパスはディレクトリである必要があります",
"pathNotFound": "ライブラリのパスが見つかりません",
"pathNotAccessible": "ライブラリパスへアクセスできません",
"pathInvalid": "無効なライブラリパス"
},
"messages": {
"deleteConfirm": "このライブラリを削除しますか?関連する全てのデータとユーザーアクセスが削除されます。",
"scanInProgress": "スキャン中...",
"noLibrariesAssigned": "このユーザーに割り当てられているライブラリはありません"
} }
} }
}, },
@ -418,8 +506,12 @@
"shareFailure": "コピーに失敗しました %{url}", "shareFailure": "コピーに失敗しました %{url}",
"downloadDialogTitle": "ダウンロード %{resource} '%{name}' (%{size})", "downloadDialogTitle": "ダウンロード %{resource} '%{name}' (%{size})",
"shareCopyToClipboard": "クリップボードへコピー: Ctrl+C, Enter", "shareCopyToClipboard": "クリップボードへコピー: Ctrl+C, Enter",
"remove_missing_title": "", "remove_missing_title": "欠落ファイルを削除",
"remove_missing_content": "" "remove_missing_content": "選択した欠落ファイルをデータベースから削除してもよろしいですか?これにより、再生数や評価を含むそれらのファイルへの参照が完全に削除されます。",
"remove_all_missing_title": "全ての欠落ファイルを削除",
"remove_all_missing_content": "データベースから欠落ファイルをすべて削除してもよろしいですか?これにより、再生数や評価を含むそれらのファイルへの参照が永久に削除されます。",
"noSimilarSongsFound": "類似の曲が見つかりませんでした",
"noTopSongsFound": "トップソングが見つかりません"
}, },
"menu": { "menu": {
"library": "ライブラリ", "library": "ライブラリ",
@ -448,7 +540,13 @@
"albumList": "アルバム", "albumList": "アルバム",
"about": "詳細", "about": "詳細",
"playlists": "プレイリスト", "playlists": "プレイリスト",
"sharedPlaylists": "共有プレイリスト" "sharedPlaylists": "共有プレイリスト",
"librarySelector": {
"allLibraries": "全てのライブラリ( %{count} )",
"multipleLibraries": "%{selected} 個 / %{total} 個のライブラリ",
"selectLibraries": "ライブラリを選択",
"none": "無し"
}
}, },
"player": { "player": {
"playListsText": "再生リスト", "playListsText": "再生リスト",
@ -485,15 +583,34 @@
"disabled": "無効", "disabled": "無効",
"waiting": "待機中" "waiting": "待機中"
} }
},
"tabs": {
"about": "詳細",
"config": "設定"
},
"config": {
"configName": "設定名",
"environmentVariable": "環境変数",
"currentValue": "現在値",
"configurationFile": "設定ファイル",
"exportToml": "設定をエクスポート(TOML)",
"exportSuccess": "設定をTOML形式でクリップボードへエクスポートしました",
"exportFailed": "設定のコピーに失敗しました",
"devFlagsHeader": "開発フラグ(変更・削除の可能性あり)",
"devFlagsComment": "これらは実験的な設定であり、将来のバージョンで削除される可能性があります"
} }
}, },
"activity": { "activity": {
"title": "活動", "title": "活動",
"totalScanned": "スキャン済みフォルダー", "totalScanned": "スキャン済みフォルダー",
"quickScan": "クイックスキャン", "quickScan": "クイック",
"fullScan": "フルスキャン", "fullScan": "フル",
"serverUptime": "サーバー稼働時間", "serverUptime": "サーバー稼働時間",
"serverDown": "サーバーオフライン" "serverDown": "サーバーオフライン",
"scanType": "最終スキャン",
"status": "スキャンエラー",
"elapsedTime": "経過時間",
"selectiveScan": "選択的スキャン"
}, },
"help": { "help": {
"title": "ホットキー", "title": "ホットキー",
@ -508,5 +625,10 @@
"toggle_love": "星の付け外し", "toggle_love": "星の付け外し",
"current_song": "現在の曲へ移動" "current_song": "現在の曲へ移動"
} }
},
"nowPlaying": {
"title": "再生中",
"empty": "何も再生されていません",
"minutesAgo": "%{smart_count} 分前 |||| %{smart_count} 分前"
} }
} }

View File

@ -301,14 +301,19 @@
"actions": { "actions": {
"scan": "Skanuj Bibliotekę", "scan": "Skanuj Bibliotekę",
"manageUsers": "Zarządzaj Dostępami Użytkownika", "manageUsers": "Zarządzaj Dostępami Użytkownika",
"viewDetails": "Zobacz Szczegóły" "viewDetails": "Zobacz Szczegóły",
"quickScan": "Szybkie Skanowanie",
"fullScan": "Pełne Skanowanie"
}, },
"notifications": { "notifications": {
"created": "Biblioteka utworzona prawidłowo", "created": "Biblioteka utworzona prawidłowo",
"updated": "Biblioteka zaktualizowana prawidłowo", "updated": "Biblioteka zaktualizowana prawidłowo",
"deleted": "Biblioteka usunięta prawidłowo", "deleted": "Biblioteka usunięta prawidłowo",
"scanStarted": "Rozpoczęto skan biblioteki", "scanStarted": "Rozpoczęto skan biblioteki",
"scanCompleted": "Zakończono skan biblioteki" "scanCompleted": "Zakończono skan biblioteki",
"quickScanStarted": "Szybkie skanowanie rozpoczęte",
"fullScanStarted": "Pełne skanowanie rozpoczęte",
"scanError": "Błąd podczas startu skanowania. Sprawdź logi"
}, },
"validation": { "validation": {
"nameRequired": "Nazwa biblioteki jest wymagana", "nameRequired": "Nazwa biblioteki jest wymagana",
@ -604,7 +609,8 @@
"serverDown": "NIEDOSTĘPNY", "serverDown": "NIEDOSTĘPNY",
"scanType": "Typ", "scanType": "Typ",
"status": "Błąd Skanowania", "status": "Błąd Skanowania",
"elapsedTime": "Upłynięty Czas" "elapsedTime": "Upłynięty Czas",
"selectiveScan": "Selektywne"
}, },
"help": { "help": {
"title": "Skróty Klawiszowe Navidrome", "title": "Skróty Klawiszowe Navidrome",

View File

@ -301,20 +301,25 @@
"actions": { "actions": {
"scan": "Сканировать библиотеку", "scan": "Сканировать библиотеку",
"manageUsers": "Управление доступом пользователей", "manageUsers": "Управление доступом пользователей",
"viewDetails": "Просмотреть подробности" "viewDetails": "Просмотреть подробности",
"quickScan": "Быстрое сканирование",
"fullScan": "Полное сканирование"
}, },
"notifications": { "notifications": {
"created": "Библиотека успешно создана", "created": "Библиотека успешно создана",
"updated": "Библиотека успешно обновлена", "updated": "Библиотека успешно обновлена",
"deleted": "Библиотека успешно удалена", "deleted": "Библиотека успешно удалена",
"scanStarted": "Сканирование библиотеки начато", "scanStarted": "Сканирование библиотеки начато",
"scanCompleted": "Сканирование библиотеки закончено" "scanCompleted": "Сканирование библиотеки закончено",
"quickScanStarted": "Быстрое сканирование началось",
"fullScanStarted": "Началось полное сканирование",
"scanError": "Ошибка при запуске сканирования. Проверьте логи"
}, },
"validation": { "validation": {
"nameRequired": "Имя библиотеки обязательно", "nameRequired": "Имя библиотеки обязательно",
"pathRequired": "Путь к библиотеке обязателен", "pathRequired": "Путь к библиотеке обязателен",
"pathNotDirectory": "Путь к библиотеке должен быть директорией", "pathNotDirectory": "Путь к библиотеке должен быть директорией",
"pathNotFound": "Путь к библиотеке не найдено", "pathNotFound": "Путь к библиотеке не найден",
"pathNotAccessible": "Путь к библиотеке недоступен", "pathNotAccessible": "Путь к библиотеке недоступен",
"pathInvalid": "Неверный путь к библиотеке" "pathInvalid": "Неверный путь к библиотеке"
}, },
@ -604,7 +609,8 @@
"serverDown": "Оффлайн", "serverDown": "Оффлайн",
"scanType": "Тип", "scanType": "Тип",
"status": "Ошибка сканирования", "status": "Ошибка сканирования",
"elapsedTime": "Прошедшее время" "elapsedTime": "Прошедшее время",
"selectiveScan": "Избирательный"
}, },
"help": { "help": {
"title": "Горячие клавиши Navidrome", "title": "Горячие клавиши Navidrome",

View File

@ -301,14 +301,19 @@
"actions": { "actions": {
"scan": "Scanna bibliotek", "scan": "Scanna bibliotek",
"manageUsers": "Hantera användaråtkomst", "manageUsers": "Hantera användaråtkomst",
"viewDetails": "Se detaljer" "viewDetails": "Se detaljer",
"quickScan": "Snabbscan",
"fullScan": "Komplett scan"
}, },
"notifications": { "notifications": {
"created": "Biblioteket har skapats", "created": "Biblioteket har skapats",
"updated": "Biblioteket har uppdaterats", "updated": "Biblioteket har uppdaterats",
"deleted": "Biblioteket har raderats", "deleted": "Biblioteket har raderats",
"scanStarted": "Biblioteksscan startad", "scanStarted": "Biblioteksscan startad",
"scanCompleted": "Biblioteksscan avslutad" "scanCompleted": "Biblioteksscan avslutad",
"quickScanStarted": "Snabbscan startad",
"fullScanStarted": "Komplett scan startad",
"scanError": "Fel vid start av scan. Se loggarna"
}, },
"validation": { "validation": {
"nameRequired": "Biblioteksnamn krävs", "nameRequired": "Biblioteksnamn krävs",
@ -604,7 +609,8 @@
"serverDown": "OFFLINE", "serverDown": "OFFLINE",
"scanType": "Typ", "scanType": "Typ",
"status": "Fel vid scanning", "status": "Fel vid scanning",
"elapsedTime": "Spelad tid" "elapsedTime": "Spelad tid",
"selectiveScan": "Urval"
}, },
"help": { "help": {
"title": "Navidrome kortkommandon", "title": "Navidrome kortkommandon",

View File

@ -301,14 +301,19 @@
"actions": { "actions": {
"scan": "สแกนห้องสมุด", "scan": "สแกนห้องสมุด",
"manageUsers": "ตั้งค่าการเข้าถึง", "manageUsers": "ตั้งค่าการเข้าถึง",
"viewDetails": "ดูรายละเอียด" "viewDetails": "ดูรายละเอียด",
"quickScan": "สแกนแบบเร็ว",
"fullScan": "สแกนแบบเต็ม"
}, },
"notifications": { "notifications": {
"created": "สร้างห้องสมุดเรียบร้อย", "created": "สร้างห้องสมุดเรียบร้อย",
"updated": "อัพเดทห้องสมุดเรียบร้อย", "updated": "อัพเดทห้องสมุดเรียบร้อย",
"deleted": "ลบห้องสมุดเพลงเรียบร้อยแล้ว", "deleted": "ลบห้องสมุดเพลงเรียบร้อยแล้ว",
"scanStarted": "เริ่มสแกนห้องสมุด", "scanStarted": "เริ่มสแกนห้องสมุด",
"scanCompleted": "สแกนห้องสมุดเสร็จแล้ว" "scanCompleted": "สแกนห้องสมุดเสร็จแล้ว",
"quickScanStarted": "เริ่มสแกนแบบเร็ว",
"fullScanStarted": "เริ่มสแกนแบบเต็ม",
"scanError": "การเริ่มสแกนผิดพลาด ดูในบันทึก"
}, },
"validation": { "validation": {
"nameRequired": "ต้องใส่ชื่อห้องสมุดเพลง", "nameRequired": "ต้องใส่ชื่อห้องสมุดเพลง",
@ -604,7 +609,8 @@
"serverDown": "ออฟไลน์", "serverDown": "ออฟไลน์",
"scanType": "ประเภท", "scanType": "ประเภท",
"status": "สแกนผิดพลาด", "status": "สแกนผิดพลาด",
"elapsedTime": "เวลาที่ใช้" "elapsedTime": "เวลาที่ใช้",
"selectiveScan": "เลือก"
}, },
"help": { "help": {
"title": "คีย์ลัด Navidrome", "title": "คีย์ลัด Navidrome",

View File

@ -301,14 +301,19 @@
"actions": { "actions": {
"scan": "Сканувати бібліотеку", "scan": "Сканувати бібліотеку",
"manageUsers": "Керування доступом користувачів", "manageUsers": "Керування доступом користувачів",
"viewDetails": "Переглянути подробиці" "viewDetails": "Переглянути подробиці",
"quickScan": "Швидке сканування",
"fullScan": "Повне сканування"
}, },
"notifications": { "notifications": {
"created": "Бібліотеку успішно створено", "created": "Бібліотеку успішно створено",
"updated": "Бібліотеку успішно оновлено", "updated": "Бібліотеку успішно оновлено",
"deleted": "Бібліотеку успішно видалено", "deleted": "Бібліотеку успішно видалено",
"scanStarted": "Сканування бібліотеки розпочато", "scanStarted": "Сканування бібліотеки розпочато",
"scanCompleted": "Сканування бібліотеки закінчено" "scanCompleted": "Сканування бібліотеки закінчено",
"quickScanStarted": "Швидке сканування виконується",
"fullScanStarted": "Повне сканування виконується",
"scanError": "Помилка при виконанні сканування. Перевірте лоґи"
}, },
"validation": { "validation": {
"nameRequired": "Ім'я бібліотеки обов'язкове", "nameRequired": "Ім'я бібліотеки обов'язкове",
@ -604,7 +609,8 @@
"serverDown": "Оффлайн", "serverDown": "Оффлайн",
"scanType": "Тип", "scanType": "Тип",
"status": "Помилка сканування", "status": "Помилка сканування",
"elapsedTime": "Пройдений час" "elapsedTime": "Пройдений час",
"selectiveScan": "Вибірковий"
}, },
"help": { "help": {
"title": "Гарячі клавіші Navidrome", "title": "Гарячі клавіші Navidrome",

View File

@ -193,24 +193,24 @@ func UsernameFromToken(r *http.Request) string {
return token.Subject() return token.Subject()
} }
func UsernameFromReverseProxyHeader(r *http.Request) string { func UsernameFromExtAuthHeader(r *http.Request) string {
if conf.Server.ReverseProxyWhitelist == "" { if conf.Server.ExtAuth.TrustedSources == "" {
return "" return ""
} }
reverseProxyIp, ok := request.ReverseProxyIpFrom(r.Context()) reverseProxyIp, ok := request.ReverseProxyIpFrom(r.Context())
if !ok { if !ok {
log.Error("ReverseProxyWhitelist enabled but no proxy IP found in request context. Please report this error.") log.Error("ExtAuth enabled but no proxy IP found in request context. Please report this error.")
return "" return ""
} }
if !validateIPAgainstList(reverseProxyIp, conf.Server.ReverseProxyWhitelist) { if !validateIPAgainstList(reverseProxyIp, conf.Server.ExtAuth.TrustedSources) {
log.Warn(r.Context(), "IP is not whitelisted for reverse proxy login", "proxy-ip", reverseProxyIp, "client-ip", r.RemoteAddr) log.Warn(r.Context(), "IP is not whitelisted for external authentication", "proxy-ip", reverseProxyIp, "client-ip", r.RemoteAddr)
return "" return ""
} }
username := r.Header.Get(conf.Server.ReverseProxyUserHeader) username := r.Header.Get(conf.Server.ExtAuth.UserHeader)
if username == "" { if username == "" {
return "" return ""
} }
log.Trace(r, "Found username in ReverseProxyUserHeader", "username", username) log.Trace(r, "Found username in ExtAuth.UserHeader", "username", username)
return username return username
} }
@ -256,7 +256,7 @@ func authenticateRequest(ds model.DataStore, r *http.Request, findUsernameFns ..
func Authenticator(ds model.DataStore) func(next http.Handler) http.Handler { func Authenticator(ds model.DataStore) func(next http.Handler) http.Handler {
return func(next http.Handler) http.Handler { return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx, err := authenticateRequest(ds, r, UsernameFromConfig, UsernameFromToken, UsernameFromReverseProxyHeader) ctx, err := authenticateRequest(ds, r, UsernameFromConfig, UsernameFromToken, UsernameFromExtAuthHeader)
if err != nil { if err != nil {
_ = rest.RespondWithError(w, http.StatusUnauthorized, "Not authenticated") _ = rest.RespondWithError(w, http.StatusUnauthorized, "Not authenticated")
return return
@ -291,7 +291,7 @@ func JWTRefresher(next http.Handler) http.Handler {
func handleLoginFromHeaders(ds model.DataStore, r *http.Request) map[string]interface{} { func handleLoginFromHeaders(ds model.DataStore, r *http.Request) map[string]interface{} {
username := UsernameFromConfig(r) username := UsernameFromConfig(r)
if username == "" { if username == "" {
username = UsernameFromReverseProxyHeader(r) username = UsernameFromExtAuthHeader(r)
if username == "" { if username == "" {
return nil return nil
} }

View File

@ -80,7 +80,7 @@ var _ = Describe("Auth", func() {
req.Header.Add("Remote-User", "janedoe") req.Header.Add("Remote-User", "janedoe")
resp = httptest.NewRecorder() resp = httptest.NewRecorder()
conf.Server.UILoginBackgroundURL = "" conf.Server.UILoginBackgroundURL = ""
conf.Server.ReverseProxyWhitelist = "192.168.0.0/16,2001:4860:4860::/48" conf.Server.ExtAuth.TrustedSources = "192.168.0.0/16,2001:4860:4860::/48"
}) })
It("sets auth data if IPv4 matches whitelist", func() { It("sets auth data if IPv4 matches whitelist", func() {
@ -155,7 +155,7 @@ var _ = Describe("Auth", func() {
It("does not set auth data when listening on unix socket without whitelist", func() { It("does not set auth data when listening on unix socket without whitelist", func() {
conf.Server.Address = "unix:/tmp/navidrome-test" conf.Server.Address = "unix:/tmp/navidrome-test"
conf.Server.ReverseProxyWhitelist = "" conf.Server.ExtAuth.TrustedSources = ""
// No ReverseProxyIp in request context // No ReverseProxyIp in request context
serveIndex(ds, fs, nil)(resp, req) serveIndex(ds, fs, nil)(resp, req)
@ -176,7 +176,7 @@ var _ = Describe("Auth", func() {
It("sets auth data when listening on unix socket with correct whitelist", func() { It("sets auth data when listening on unix socket with correct whitelist", func() {
conf.Server.Address = "unix:/tmp/navidrome-test" conf.Server.Address = "unix:/tmp/navidrome-test"
conf.Server.ReverseProxyWhitelist = conf.Server.ReverseProxyWhitelist + ",@" conf.Server.ExtAuth.TrustedSources = conf.Server.ExtAuth.TrustedSources + ",@"
req = req.WithContext(request.WithReverseProxyIp(req.Context(), "@")) req = req.WithContext(request.WithReverseProxyIp(req.Context(), "@"))
serveIndex(ds, fs, nil)(resp, req) serveIndex(ds, fs, nil)(resp, req)
@ -302,8 +302,8 @@ var _ = Describe("Auth", func() {
ds = &tests.MockDataStore{} ds = &tests.MockDataStore{}
req = httptest.NewRequest("GET", "/", nil) req = httptest.NewRequest("GET", "/", nil)
req = req.WithContext(request.WithReverseProxyIp(req.Context(), trustedIP)) req = req.WithContext(request.WithReverseProxyIp(req.Context(), trustedIP))
conf.Server.ReverseProxyWhitelist = "192.168.0.0/16" conf.Server.ExtAuth.TrustedSources = "192.168.0.0/16"
conf.Server.ReverseProxyUserHeader = "Remote-User" conf.Server.ExtAuth.UserHeader = "Remote-User"
}) })
It("makes the first user an admin", func() { It("makes the first user an admin", func() {

View File

@ -168,7 +168,7 @@ func clientUniqueIDMiddleware(next http.Handler) http.Handler {
// realIPMiddleware applies middleware.RealIP, and additionally saves the request's original RemoteAddr to the request's // realIPMiddleware applies middleware.RealIP, and additionally saves the request's original RemoteAddr to the request's
// context if navidrome is behind a trusted reverse proxy. // context if navidrome is behind a trusted reverse proxy.
func realIPMiddleware(next http.Handler) http.Handler { func realIPMiddleware(next http.Handler) http.Handler {
if conf.Server.ReverseProxyWhitelist != "" { if conf.Server.ExtAuth.TrustedSources != "" {
return chi.Chain( return chi.Chain(
reqToCtx(request.ReverseProxyIp, func(r *http.Request) any { return r.RemoteAddr }), reqToCtx(request.ReverseProxyIp, func(r *http.Request) any { return r.RemoteAddr }),
middleware.RealIP, middleware.RealIP,

View File

@ -1,8 +1,11 @@
package server package server
import ( import (
"bytes"
"cmp" "cmp"
"context" "context"
"crypto/tls"
"encoding/pem"
"errors" "errors"
"fmt" "fmt"
"net" "net"
@ -69,6 +72,13 @@ func (s *Server) Run(ctx context.Context, addr string, port int, tlsCert string,
// Determine if TLS is enabled // Determine if TLS is enabled
tlsEnabled := tlsCert != "" && tlsKey != "" tlsEnabled := tlsCert != "" && tlsKey != ""
// Validate TLS certificates before starting the server
if tlsEnabled {
if err := validateTLSCertificates(tlsCert, tlsKey); err != nil {
return err
}
}
// Create a listener based on the address type (either Unix socket or TCP) // Create a listener based on the address type (either Unix socket or TCP)
var listener net.Listener var listener net.Listener
var err error var err error
@ -89,17 +99,17 @@ func (s *Server) Run(ctx context.Context, addr string, port int, tlsCert string,
// Start the server in a new goroutine and send an error signal to errC if there's an error // Start the server in a new goroutine and send an error signal to errC if there's an error
errC := make(chan error) errC := make(chan error)
go func() { go func() {
var err error
if tlsEnabled { if tlsEnabled {
// Start the HTTPS server // Start the HTTPS server
log.Info("Starting server with TLS (HTTPS) enabled", "tlsCert", tlsCert, "tlsKey", tlsKey) log.Info("Starting server with TLS (HTTPS) enabled", "tlsCert", tlsCert, "tlsKey", tlsKey)
if err := server.ServeTLS(listener, tlsCert, tlsKey); !errors.Is(err, http.ErrServerClosed) { err = server.ServeTLS(listener, tlsCert, tlsKey)
errC <- err
}
} else { } else {
// Start the HTTP server // Start the HTTP server
if err := server.Serve(listener); !errors.Is(err, http.ErrServerClosed) { err = server.Serve(listener)
errC <- err }
} if !errors.Is(err, http.ErrServerClosed) {
errC <- err
} }
}() }()
@ -249,3 +259,56 @@ func AbsoluteURL(r *http.Request, u string, params url.Values) string {
} }
return buildUrl.String() return buildUrl.String()
} }
// validateTLSCertificates validates the TLS certificate and key files before starting the server.
// It provides detailed error messages for common issues like encrypted private keys.
func validateTLSCertificates(certFile, keyFile string) error {
// Read the key file to check for encryption
keyData, err := os.ReadFile(keyFile)
if err != nil {
return fmt.Errorf("reading TLS key file: %w", err)
}
// Parse PEM blocks and check for encryption
block, _ := pem.Decode(keyData)
if block == nil {
return errors.New("TLS key file does not contain a valid PEM block")
}
// Check for encrypted private key indicators
if isEncryptedPEM(block, keyData) {
return errors.New("TLS private key is encrypted (password-protected). " +
"Navidrome does not support encrypted private keys. " +
"Please decrypt your key using: openssl pkey -in <encrypted-key> -out <decrypted-key>")
}
// Try to load the certificate pair to validate it
_, err = tls.LoadX509KeyPair(certFile, keyFile)
if err != nil {
return fmt.Errorf("loading TLS certificate/key pair: %w", err)
}
return nil
}
// isEncryptedPEM checks if a PEM block represents an encrypted private key.
func isEncryptedPEM(block *pem.Block, rawData []byte) bool {
// Check for PKCS#8 encrypted format (BEGIN ENCRYPTED PRIVATE KEY)
if block.Type == "ENCRYPTED PRIVATE KEY" {
return true
}
// Check for legacy encrypted format with Proc-Type header
if block.Headers != nil {
if procType, ok := block.Headers["Proc-Type"]; ok && strings.Contains(procType, "ENCRYPTED") {
return true
}
}
// Also check raw data for DEK-Info header (in case pem.Decode doesn't parse headers correctly)
if bytes.Contains(rawData, []byte("DEK-Info:")) || bytes.Contains(rawData, []byte("Proc-Type: 4,ENCRYPTED")) {
return true
}
return false
}

View File

@ -1,13 +1,20 @@
package server package server
import ( import (
"context"
"crypto/tls"
"crypto/x509"
"fmt"
"io/fs" "io/fs"
"net/http" "net/http"
"net/url" "net/url"
"os" "os"
"path/filepath" "path/filepath"
"time"
"github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/conf/configtest"
"github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2" . "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega" . "github.com/onsi/gomega"
) )
@ -107,3 +114,146 @@ var _ = Describe("createUnixSocketFile", func() {
}) })
}) })
}) })
var _ = Describe("TLS support", func() {
Describe("validateTLSCertificates", func() {
const testDataDir = "server/testdata"
When("certificate and key are valid and unencrypted", func() {
It("returns nil", func() {
certFile := filepath.Join(testDataDir, "test_cert.pem")
keyFile := filepath.Join(testDataDir, "test_key.pem")
err := validateTLSCertificates(certFile, keyFile)
Expect(err).ToNot(HaveOccurred())
})
})
When("private key is encrypted with PKCS#8 format", func() {
It("returns an error with helpful message", func() {
certFile := filepath.Join(testDataDir, "test_cert_encrypted.pem")
keyFile := filepath.Join(testDataDir, "test_key_encrypted.pem")
err := validateTLSCertificates(certFile, keyFile)
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("encrypted"))
Expect(err.Error()).To(ContainSubstring("openssl"))
})
})
When("private key is encrypted with legacy format (Proc-Type header)", func() {
It("returns an error with helpful message", func() {
certFile := filepath.Join(testDataDir, "test_cert.pem")
keyFile := filepath.Join(testDataDir, "test_key_encrypted_legacy.pem")
err := validateTLSCertificates(certFile, keyFile)
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("encrypted"))
Expect(err.Error()).To(ContainSubstring("openssl"))
})
})
When("key file does not exist", func() {
It("returns an error", func() {
certFile := filepath.Join(testDataDir, "test_cert.pem")
keyFile := filepath.Join(testDataDir, "nonexistent.pem")
err := validateTLSCertificates(certFile, keyFile)
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("reading TLS key file"))
})
})
When("key file does not contain valid PEM", func() {
It("returns an error", func() {
// Create a temp file with invalid PEM content
tmpFile, err := os.CreateTemp("", "invalid_key*.pem")
Expect(err).ToNot(HaveOccurred())
DeferCleanup(func() {
_ = os.Remove(tmpFile.Name())
})
_, err = tmpFile.WriteString("not a valid PEM file")
Expect(err).ToNot(HaveOccurred())
_ = tmpFile.Close()
certFile := filepath.Join(testDataDir, "test_cert.pem")
err = validateTLSCertificates(certFile, tmpFile.Name())
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("valid PEM block"))
})
})
When("certificate file does not exist", func() {
It("returns an error from tls.LoadX509KeyPair", func() {
certFile := filepath.Join(testDataDir, "nonexistent_cert.pem")
keyFile := filepath.Join(testDataDir, "test_key.pem")
err := validateTLSCertificates(certFile, keyFile)
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("loading TLS certificate/key pair"))
})
})
})
Describe("Server TLS", func() {
const testDataDir = "server/testdata"
When("server is started with valid TLS certificates", func() {
It("accepts HTTPS connections", func() {
DeferCleanup(configtest.SetupConfig())
// Create server with mock dependencies
ds := &tests.MockDataStore{}
server := New(ds, nil, nil)
// Load the test certificate to create a trusted CA pool
certFile := filepath.Join(testDataDir, "test_cert.pem")
keyFile := filepath.Join(testDataDir, "test_key.pem")
caCert, err := os.ReadFile(certFile)
Expect(err).ToNot(HaveOccurred())
caCertPool := x509.NewCertPool()
caCertPool.AppendCertsFromPEM(caCert)
// Create an HTTPS client that trusts our test certificate
httpClient := &http.Client{
Timeout: 5 * time.Second,
Transport: &http.Transport{
TLSClientConfig: &tls.Config{
RootCAs: caCertPool,
MinVersion: tls.VersionTLS12,
},
},
}
// Start the server in a goroutine
ctx, cancel := context.WithCancel(GinkgoT().Context())
defer cancel()
errChan := make(chan error, 1)
go func() {
errChan <- server.Run(ctx, "127.0.0.1", 14534, certFile, keyFile)
}()
Eventually(func() error {
// Make an HTTPS request to the server
resp, err := httpClient.Get("https://127.0.0.1:14534/ping")
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("unexpected status code: %d", resp.StatusCode)
}
return nil
}, 2*time.Second, 100*time.Millisecond).Should(Succeed())
// Stop the server
cancel()
// Wait for server to stop (with timeout)
select {
case <-errChan:
// Server stopped
case <-time.After(2 * time.Second):
Fail("Server did not stop in time")
}
})
})
})
})

View File

@ -56,7 +56,7 @@ func fromInternalOrProxyAuth(r *http.Request) (string, bool) {
return username, true return username, true
} }
return server.UsernameFromReverseProxyHeader(r), false return server.UsernameFromExtAuthHeader(r), false
} }
func checkRequiredParameters(next http.Handler) http.Handler { func checkRequiredParameters(next http.Handler) http.Handler {

View File

@ -95,8 +95,8 @@ var _ = Describe("Middlewares", func() {
}) })
It("passes when all required params are available (reverse-proxy case)", func() { It("passes when all required params are available (reverse-proxy case)", func() {
conf.Server.ReverseProxyWhitelist = "127.0.0.234/32" conf.Server.ExtAuth.TrustedSources = "127.0.0.234/32"
conf.Server.ReverseProxyUserHeader = "Remote-User" conf.Server.ExtAuth.UserHeader = "Remote-User"
r := newGetRequest("v=1.15", "c=test") r := newGetRequest("v=1.15", "c=test")
r.Header.Add("Remote-User", "user") r.Header.Add("Remote-User", "user")
@ -254,8 +254,8 @@ var _ = Describe("Middlewares", func() {
When("using reverse proxy authentication", func() { When("using reverse proxy authentication", func() {
BeforeEach(func() { BeforeEach(func() {
DeferCleanup(configtest.SetupConfig()) DeferCleanup(configtest.SetupConfig())
conf.Server.ReverseProxyWhitelist = "192.168.1.1/24" conf.Server.ExtAuth.TrustedSources = "192.168.1.1/24"
conf.Server.ReverseProxyUserHeader = "Remote-User" conf.Server.ExtAuth.UserHeader = "Remote-User"
}) })
It("passes authentication with correct IP and header", func() { It("passes authentication with correct IP and header", func() {

23
server/testdata/test_cert.pem vendored Normal file
View File

@ -0,0 +1,23 @@
-----BEGIN CERTIFICATE-----
MIIDwzCCAqugAwIBAgIUXqdUxUOo8kmsDe71iTR+Vr7btP8wDQYJKoZIhvcNAQEL
BQAwYjELMAkGA1UEBhMCVVMxDTALBgNVBAgMBFRlc3QxDTALBgNVBAcMBFRlc3Qx
EjAQBgNVBAoMCU5hdmlkcm9tZTENMAsGA1UECwwEVGVzdDESMBAGA1UEAwwJbG9j
YWxob3N0MCAXDTI1MTEyODE5NTkxNVoYDzIxMjUxMTA0MTk1OTE1WjBiMQswCQYD
VQQGEwJVUzENMAsGA1UECAwEVGVzdDENMAsGA1UEBwwEVGVzdDESMBAGA1UECgwJ
TmF2aWRyb21lMQ0wCwYDVQQLDARUZXN0MRIwEAYDVQQDDAlsb2NhbGhvc3QwggEi
MA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCkB/TQgl5ei5KRSHt5OJim8rKS
MzRlkK4BjSEM4D9ESbebdpEVjX48QuBYACrCvgvVp7mQGF5anl8Hm89trvd8ooVQ
x9IPQQ6gRKM+4gLrt9FHvFGGzZQS8UTQXN5oBi11E+8/Vs47HLUNXC2TRtRLCMyK
LYXQIXbhdp9anImlt+IHUxIQUchK6Zkld/gCm56X1bbzN/Zq91PQLpx2FZ0eZTjN
KaNgztLa+K/BDnTuk3iTTs9GEp6VCvqQE/6fk/UN/tkk2dLwKIFvPVR/YeAhVdz/
OHC4L3B36QN3+VQ2yDjsp1PVAPX07UnzXO3Oj7uGYnMQxwprGMEubm3nADDxAgMB
AAGjbzBtMB0GA1UdDgQWBBRAZHUVuLyzc0CfuZR9ApqMbawIqzAfBgNVHSMEGDAW
gBRAZHUVuLyzc0CfuZR9ApqMbawIqzAPBgNVHRMBAf8EBTADAQH/MBoGA1UdEQQT
MBGCCWxvY2FsaG9zdIcEfwAAATANBgkqhkiG9w0BAQsFAAOCAQEAmDLXcPx9LNHs
GxQIE6Q5BXbVO7c8qrWmJf5FK5VWaifNZ9U+IBi+VlB4jCLK/OkwsviN/jOnwRYx
owjq0QG0YdRT4uD9fEMrAj+EwbnrQYZQvT0yGEWA+KW5TW08wt+/qnGJDwEgbjYJ
HTdICVMhs/e8Ex48fAgO8WSsdTDekOrhuwzIfeJ1LU4ZptLsD2ePFxuzutdIuW51
/mspQGsjXqZ1qnLsavLXh/lds2g602rTpYBNZVjV9WiOvaQS8vviOxBN6f+9vgRz
a8SEbHqBG6jeyVqVZ7MjxcYxaIkxeBwMyMwgb+wwDfVXo2FZzX2TVeB7ZppI+IKv
TXYurWPYsQ==
-----END CERTIFICATE-----

22
server/testdata/test_cert_encrypted.pem vendored Normal file
View File

@ -0,0 +1,22 @@
-----BEGIN CERTIFICATE-----
MIIDpzCCAo+gAwIBAgIUEa7gEJYwJqYEJjTY7otQ+oUyELwwDQYJKoZIhvcNAQEL
BQAwYjELMAkGA1UEBhMCVVMxDTALBgNVBAgMBFRlc3QxDTALBgNVBAcMBFRlc3Qx
EjAQBgNVBAoMCU5hdmlkcm9tZTENMAsGA1UECwwEVGVzdDESMBAGA1UEAwwJbG9j
YWxob3N0MCAXDTI1MTEyODE5NTI0OVoYDzIxMjUxMTA0MTk1MjQ5WjBiMQswCQYD
VQQGEwJVUzENMAsGA1UECAwEVGVzdDENMAsGA1UEBwwEVGVzdDESMBAGA1UECgwJ
TmF2aWRyb21lMQ0wCwYDVQQLDARUZXN0MRIwEAYDVQQDDAlsb2NhbGhvc3QwggEi
MA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDBHgqJ1d9EnNxqoSZ6xXrIz/mV
Y0nWJW16/qIAvCdovSeTZhG9iqG8dUqcuu2BdD9MMHndJ2oFn3iD8EJR92dH8KBA
8xOmtZ0BEEWgXPBivywZVd1ChIflEWj6m5wwLNjb57SPpUiwaLxBQB8ByEaAAZE/
bLqvHI3vW/4s5apky17SPIqmkmqEYlRcg97tlRXsPuwoAVM9cvLMMEqtIR1CB/72
gboY2Gi2r/plLF/Rg3Dom6QljMWi57XXWJFwGYSXaZuM0gvn04e3oLu+1E+WMoq/
9rExWij2DlsmXd/RiScliFp6R4H84wQUyqrAUNytvgRO+oVnRjEA0l3oCYdRAgMB
AAGjUzBRMB0GA1UdDgQWBBQQKpB1UaKm98FnBdl8uKdRscrVTzAfBgNVHSMEGDAW
gBQQKpB1UaKm98FnBdl8uKdRscrVTzAPBgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3
DQEBCwUAA4IBAQBP07l+2LmpFtcxqMGmsiNYwFuHpQCxJd4YRZHjLX7O+oJExMgR
2yP4mpMKurgKOv7unTDLwvjQRa6ZTYJCsYtvC6hbyqlGc7AfNTu6DKz8r35/2/V5
hPsG5lNb91HhvHE839mLAvpi02LoFH2Sr8BR7s6qxfNKYcP8PUOJQXltJ6yAa8YJ
syeXQQ3RIyGsJANeaC06S3UdkBM5H5BLfIHnHu3GybJjwL51va4WCdHe8QV6GI0g
RDiThDVkBSXAr136vnMdlrYCxMoxY56itJ0zbYg2ELQKU9o1w/ZJQo9uvmy9jCoZ
Hy1L5a2vUDbsdONdvRkYZRHqMpG4bdD8D3j2
-----END CERTIFICATE-----

28
server/testdata/test_key.pem vendored Normal file
View File

@ -0,0 +1,28 @@
-----BEGIN PRIVATE KEY-----
MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCkB/TQgl5ei5KR
SHt5OJim8rKSMzRlkK4BjSEM4D9ESbebdpEVjX48QuBYACrCvgvVp7mQGF5anl8H
m89trvd8ooVQx9IPQQ6gRKM+4gLrt9FHvFGGzZQS8UTQXN5oBi11E+8/Vs47HLUN
XC2TRtRLCMyKLYXQIXbhdp9anImlt+IHUxIQUchK6Zkld/gCm56X1bbzN/Zq91PQ
Lpx2FZ0eZTjNKaNgztLa+K/BDnTuk3iTTs9GEp6VCvqQE/6fk/UN/tkk2dLwKIFv
PVR/YeAhVdz/OHC4L3B36QN3+VQ2yDjsp1PVAPX07UnzXO3Oj7uGYnMQxwprGMEu
bm3nADDxAgMBAAECggEABqJFvesP2v4FEvgd+kSWM+ZL34rPmy3zQ5/MDuPA20ep
89EjQ/5hdRl1TknPcOnTu7PZVuENa9fM2xdrl7GEU9eU0bQLJE/KwiOUgJYObS8V
eTO+DlghHXUBhfXDjux1CS+htOuTUqOyFNS+CR9Lta8o6ou1xjmcP7kW78i17mxF
TuH5SZlS8W9PFLXHCInbMtqGFaT2ss09kvoPk2FDvHfxEdy6M9tKkguz02g+4bqI
aAMp2N7AOfmRpC0HvVa1ZfZo5Z8/KMoNcIm3pV9DEVM369J9EzhnMNpkGben90aT
FqO2JNsy52wmXFZUc9xe8uPdfDahALCkBGncLyLNmQKBgQDZREjocjdzOoPSlCdx
mRNe9suHz2FpUpsHCPOCotG63hFVKpah/ZvpHSsQx5rXs/mawDTmzGY9GQiBrSvg
OhfHIyT3NOhVaNcMxTqJX7rs7OG8D0MBacD9ASSeZ89MUn8q1EHZr5qxLtXl5Ikw
mHtiGRdiKGFFrG9H0zncbGhy7QKBgQDBRhQ9RAasTdmUiNQly9GVFkXto4T/9UHx
rVU44htCI2IVZUMTGlNfclfxpByDrzyA56rMzN9SAkiIp4nPpMDs5hayXaaPoojs
CPzV7r2OjemZ6CTeQ1ODImRL8L/E3jJSgWd6YYoHSQ5hjEX4yT6ft0u0tZUfdMKd
VENWIJ/hlQKBgQCo2hXjeOi5R8+tN3EUKwhP9HOnX7dv+D/9jqpZa5qdpPpJeyjI
SmYCHKYci1Q+sWOaLiiu+km20B65UVFZGSzjmd+fs+GghzMifKGKo/iNK2ggFKhZ
j8vplRrVdQ45XZ/xNDbdLEmHzEN2QE+Skd7KFYADzCgU0vdFFdbRBPuD3QKBgGIq
fQctMRJ9LCE0akSURGwr9vKflmMHKCpfdqTAu0WZgS0K1Mm0GlqlUiPKzizYaauz
f14sRNV7kWnPZsDPlqn8p9SKmpnj3RW97uWeMCtiyx6/+VHm8ljts/GaY1zT2s1r
KqrPNfNDWQmU3MljNeqbh9lOTWK/xEVy0gzB31MNAoGAQNWrZvVdAbL95XW6STUu
JmQlqJTlluuqS0Rrd/uVEQwW0Vd1dZjRQcFAFiSiCQWTbtId5gFZd6hiIQl53Xz0
5cd+9mcyA/TaoCJYbMOFYsKbZMCBhefsovJlVQXedqJrIY6BdeGlet4GTAH5Qyl0
ytEIUnvn5YmmbI7PDz80XpU=
-----END PRIVATE KEY-----

30
server/testdata/test_key_encrypted.pem vendored Normal file
View File

@ -0,0 +1,30 @@
-----BEGIN ENCRYPTED PRIVATE KEY-----
MIIFNTBfBgkqhkiG9w0BBQ0wUjAxBgkqhkiG9w0BBQwwJAQQPH9PYzryCI3smm81
J8rm+QICCAAwDAYIKoZIhvcNAgkFADAdBglghkgBZQMEASoEEI+9XxNfKSiMYIVB
UfcGfncEggTQVw7tPslGy3mlofCNnhBSnMViv9kj6M11smD6Y8vHG0k9Kq+6g+Dx
mQE9ILrSZBzM0uS3y484u+vkdqlT4KehhjIx0IiezurOcM45UdTAwLFLPzeEDlHI
lOWQ3gOTB3J5AxiUQOa6QsDIM7AZilidQG0BxQYWyRBA5B8evJwJoAvdzzA9wGSm
2YdNm3tA6rU5U8cVG+qTJP9pjbtRx0medC/CBZdxGkrWBQH+aySfahJdU8X1JI2e
SY4WJRw1rLCow+DnHjZS/IVHFJivJSRYvnvw8fwjOMVtkf+dAVctKlb1Fj9X+RdG
T1sq3i6zwFLE/RRz4qM4DKZ6UaD9wRFLow8FmNWVuJJiPgCLx2rrNMe32quS/kQP
iOsXAUeA/Yg1fdMCJORxl0nWDmLYcNtBghCmS1lyk+t+AKWwJudrds5tQQe8ha2t
Q41is+tDKwGDC1wt4WXJvBhgAJzuqFtr30H0M1eBhwwDdaDd9v0Zr3r8V49WZM2c
i3qkwPPYkQD+pOcR12xBV8ptvDxaUl7RGlVqnEWHagT51BaIaXQ9teUrG6UPt8o2
LELJXF6CiwkbN6Y9sYx5XiKrIGxVhlQSZ1nB3XSFRHbu6e7VHPjnVwUeeg87J2Am
MEwqDzPU5sjKRn84+M91Y4uFAIeinaOJAQ0/tZVrf1iSeCMQyMUhW/8m7JPfG19F
NbJSPRXQuKmYKbWfXcMW2UFbp0zDs7s7p4zzbfde9IbVdq/o2nv3ZrNbrLak6O7y
FVt9q/xG4Tty6hSK6xtqtNZWcmfiMcTlk1Qcz2STvScbXtqgcgR6WUZfkLuzi09I
EDYFnzU5JNSY3U3VTv2hAPeU4xjTNM6kjF7L9JFGvdjH8Ko9UdxG9RZMd8xhBM/n
hxdzdVba4bDDz2z+0A2blSObrPrNsKr/3ZbnfuUiSs5NmqmUOifZ1t1PqGGO2Y5S
/cDKtrPk226hGomsUBfHtiIJPG1VRl4UaZiduqK3GGhtF491KU1mAfYzueok3TPq
JhLtLDIvEaFgmOmitFzROI/ifm6s4ssUvcvtbjwJumbjkU38OxYZFwbhwbe268G2
vgspJamlEGJNdGDzrCFQlA2+A9kazCttztikfh5QGV6WFfkc3Bt1XTPL51vtliQy
MS2gUnJUY2fuYCfz8rxLH1kQmyYsHQz5rUYyBkeDffrG9MzarmzSJXR63FRzVMf1
LQ7BSzei7dF6+J4KVCxjbGWF3GUGmGeOP5g5vJ3xb3YPJNJLT4Vai103pay59TGP
tESM2Vn0gJEvYApi707noFH5uFTW1cp7lloF41ddIUkL/QO7j+sjvBww+4DqBB7J
BmvLMnswa23yw9egYRG5jOXyCgIr+1rnNcph1HGJsvxvgJ2gwwo5NKCG8SC6LcZQ
fbDjX+ssmobLE3ktN03FZPMp32/ciexzuZoamfyiPXh7xE++ckifNEKJlNhx+kCG
mSR2wh+UGigQkgp/JxOzl6C4fhUbrEZr17oBqGim2p8h+GE0zD5JSHcn1rP86gGU
8JG/ilG4I8uMxUwhGj7amrWXUlJBd1by7e1EAL+utCo14/Tx3otB9/JtqY+lm9Ey
1ptPhMRQxvDNWrCmYM2kyrGghdNfEMir6GKDWI6PY9cwAFv/PLOxr1c=
-----END ENCRYPTED PRIVATE KEY-----

View File

@ -0,0 +1,30 @@
-----BEGIN RSA PRIVATE KEY-----
Proc-Type: 4,ENCRYPTED
DEK-Info: AES-256-CBC,3C969050EAB73F121B7F0E6B75C42525
V6pSaAsrn9CQNo4p88QshJLbg8zkQJEom81dPbYSVqQSZa9YlPtpLZ9YtuLj/Ay0
TScEKIj/gzQ32wNl6nhcSNIL9yy+X11r5gNv1kIHkecf+EbDW20VOiJsfD+6LUyW
hA96AIbPOwc76iCuvsKHPKU9MlEmjGipmk/C2RQLHCZJ3WkiDRgCM8KQ7vKhfACT
w908yj4cB1e/P0JPq8t/3F7kPJ+6SVM1vMEffHl0otQR3rAyrK8QikwJ0K9qX62d
cqchTVlEyyZBYovR8DrRRUDbsXS5j1ZmX3NQpvTSTFowr+33fMrY+4Oz8sdR4yx1
CQc0A0sHHxSEIr2xu4KzczwOYVJN8PVdU0pgvFj9KEm66N6EY5CSFIBHyO/ycOt9
U+wpkRjf3zS6ZaUU0NKdOcop4YX33i99/tZF2RNR1i7ETLYph+/LCf09286Bi3u/
UCCuWedyECPdz0c6j0s27Fdfc/HEK90OEzeWh/fc+H2gJZhqJYK9V47HPTQNNMnB
U1a6FsJlrKE3E6nfSnTLxrSx9m/XTV7HV+HkgX+q8VhN7Q2VHUqkPzE7ZOPYpZ+A
dQzsm1TmEMxym6osYqFzQScXR1NZasrV2MTQ2J16dUgCdGAM2YMUD9JaoJR+u77M
WAjYzDiRg84rLr/KbJPAwHbsfo2KpiapJGSBBEDhz4W1/LOrFhsjaqIMSy4yZDGm
1KqXGHIlqmuHI7v4fD8vuzhj7GUujRx85HSZWakE/uc6s5WrhkSeVKYJWPfpsxTv
dT3oLOGJ+nRzWxM3aFtuJghX0nIGdKxT4EAUNXz0/vLT3OP1QCZR+oELrriFzmtj
+O30bGH2SAFZEQJ/uTQg6celoNh89IzH4DJkcn67hqpX6mUiU9CrIr/eR9C/en8Q
smTbbC1C1pDUaCwR26Z+zgM90amh4yfOFKK2geO2Kj+TmwFHUvi6ZnSzMzCvty3t
+wdIrUtf55Lw51JCpLGl70mg4b/zBj5hqBkU2YvAAnz/htjfH/wrD6ZAF1TCdlRO
gyODrJjGRnLd/v0XLk0wp+RkAjBcSlRlkUvZY5BtugL7dIdwiNGGQPcOni9IVeG0
6vDUEQnDOLYDj4d/JcckTLuHdrP+SW+0RQl2HK5+/w1hScGXN4O48gccu7yR/MN8
DmpCg5rD/nq8sxJosmSt07GrN36KppYt8LCXQbSg3NG2Ad715caS2C+0Qtdm5MPD
rM1UyTXQYSJXgUN9yZS/pmzlguCywnnvsBPU6j3ljZwcoD41QJ/1OU09/W6sIMQR
IAiM35JHiLJiccFgxSE1qx5F1UZqX4P47jF0Wzi/sE/DYXg5qw2DoauqXNzqnumH
71UDGK1V6wQIV7UCZDa0WUfFzu470XpuFb8VmMOuHSQxkZESc9cz8k/ueAuO438Q
jnlkF1Ge2EEPuaK2zeaTj/lGyYA1AUfHRRgt/EMUQSBntmhlpnwVPYTVvYtHO2N5
wp7/y39KirnlTl99i3XiOJ4WF4gIU2IaSlqMo4+e/A32h2JFi9QfNyfItXe6Fm1X
d0j2XGHzwMfHEFKdWyrgtVZwc38/1d6xWYAhs02b2basV/0AQhFTaKf5Z268eBNJ
-----END RSA PRIVATE KEY-----

16
ui/package-lock.json generated
View File

@ -63,7 +63,7 @@
"jsdom": "^26.1.0", "jsdom": "^26.1.0",
"prettier": "^3.6.2", "prettier": "^3.6.2",
"ra-test": "^3.19.12", "ra-test": "^3.19.12",
"typescript": "^5.8.3", "typescript": "^5.9.3",
"vite": "^7.1.12", "vite": "^7.1.12",
"vite-plugin-pwa": "^1.1.0", "vite-plugin-pwa": "^1.1.0",
"vitest": "^4.0.3" "vitest": "^4.0.3"
@ -7173,10 +7173,11 @@
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==" "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="
}, },
"node_modules/js-yaml": { "node_modules/js-yaml": {
"version": "4.1.0", "version": "4.1.1",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
"integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
"dev": true, "dev": true,
"license": "MIT",
"dependencies": { "dependencies": {
"argparse": "^2.0.1" "argparse": "^2.0.1"
}, },
@ -10610,10 +10611,11 @@
} }
}, },
"node_modules/typescript": { "node_modules/typescript": {
"version": "5.8.3", "version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true, "dev": true,
"license": "Apache-2.0",
"bin": { "bin": {
"tsc": "bin/tsc", "tsc": "bin/tsc",
"tsserver": "bin/tsserver" "tsserver": "bin/tsserver"

View File

@ -72,7 +72,7 @@
"jsdom": "^26.1.0", "jsdom": "^26.1.0",
"prettier": "^3.6.2", "prettier": "^3.6.2",
"ra-test": "^3.19.12", "ra-test": "^3.19.12",
"typescript": "^5.8.3", "typescript": "^5.9.3",
"vite": "^7.1.12", "vite": "^7.1.12",
"vite-plugin-pwa": "^1.1.0", "vite-plugin-pwa": "^1.1.0",
"vitest": "^4.0.3" "vitest": "^4.0.3"

View File

@ -53,6 +53,7 @@ const SharePlayer = () => {
remove: false, remove: false,
spaceBar: true, spaceBar: true,
volumeFade: { fadeIn: 200, fadeOut: 200 }, volumeFade: { fadeIn: 200, fadeOut: 200 },
sortableOptions: { delay: 200, delayOnTouchOnly: true },
} }
return ( return (
<ReactJkMusicPlayer <ReactJkMusicPlayer

View File

@ -47,17 +47,15 @@ const stylesheet = `
.react-jinke-music-player-main .music-player-panel, .react-jinke-music-player-main .music-player-panel,
.react-jinke-music-player-mobile, .react-jinke-music-player-mobile,
.ril__outer{ .ril__outer{
background-color: #1f1f1f; background-color: #1a1a1a;
border: 1px solid #fff1; border: 1px solid #fff1;
} }
.ril__toolbar{
background-color: #1d1d1d
}
.ril__toolbarItem{ .ril__toolbarItem{
font-size: 100%; font-size: 100%;
color: #eee color: #eee
} }
.audio-lists-panel{ .audio-lists-panel,
.ril__toolbar{
background-color: #1f1f1f; background-color: #1f1f1f;
border: 1px solid #fff1; border: 1px solid #fff1;
border-radius: 6px 6px 0 0; border-radius: 6px 6px 0 0;

View File

@ -137,22 +137,19 @@ export default {
albumName: { albumName: {
color: '#eee', color: '#eee',
}, },
albumSubtitle: {
color: '#ccc',
},
albumPlayButton: { albumPlayButton: {
color: '#ff4e6b !important', color: '#ff4e6b',
}, },
albumArtistName: { albumArtistName: {
color: '#ff4e6b !important', color: '#ccc',
}, },
cover: { cover: {
borderRadius: '10px !important', borderRadius: '6px',
}, },
}, },
NDLogin: { NDLogin: {
systemNameLink: { systemNameLink: {
color: '#D60017', color: '#ff4e6b',
}, },
welcome: { welcome: {
color: '#eee', color: '#eee',
@ -161,6 +158,9 @@ export default {
minWidth: 300, minWidth: 300,
backgroundColor: '#1d1d1d', backgroundColor: '#1d1d1d',
}, },
icon: {
filter: 'hue-rotate(115deg)',
},
}, },
MuiPaper: { MuiPaper: {
elevation1: { elevation1: {
@ -169,6 +169,9 @@ export default {
root: { root: {
color: '#eee', color: '#eee',
}, },
rounded: {
borderRadius: '6px',
},
}, },
NDMobileArtistDetails: { NDMobileArtistDetails: {
bgContainer: { bgContainer: {
@ -189,6 +192,30 @@ export default {
paddingBottom: '1rem', paddingBottom: '1rem',
}, },
}, },
RaDeleteWithConfirmButton: {
deleteButton: {
color: 'unset',
},
},
RaPaginationActions: {
currentPageButton: {
border: '2px solid #D60017',
background: 'transparent',
},
button: {
border: '2px solid #D60017',
},
actions: {
'@global': {
'.next-page': {
border: '0 none',
},
'.previous-page': {
border: '0 none',
},
},
},
},
}, },
player: { player: {
theme: 'dark', theme: 'dark',

View File

@ -42,6 +42,12 @@ const useCurrentTheme = () => {
document.head.removeChild(style) document.head.removeChild(style)
} }
} }
// Set body background color to match theme (fixes white background on pull-to-refresh)
const isDark = theme.palette?.type === 'dark'
const bgColor =
theme.palette?.background?.default || (isDark ? '#303030' : '#fafafa')
document.body.style.backgroundColor = bgColor
}, [theme]) }, [theme])
return theme return theme

View File

@ -15,6 +15,10 @@ function createMatchMedia(theme) {
}) })
} }
beforeEach(() => {
document.body.style.backgroundColor = ''
})
describe('useCurrentTheme', () => { describe('useCurrentTheme', () => {
describe('with user preference theme as light', () => { describe('with user preference theme as light', () => {
beforeAll(() => { beforeAll(() => {
@ -117,4 +121,44 @@ describe('useCurrentTheme', () => {
expect(result.current.themeName).toMatch('Spotify-ish') expect(result.current.themeName).toMatch('Spotify-ish')
}) })
}) })
describe('body background color', () => {
beforeAll(() => {
window.matchMedia = createMatchMedia('dark')
})
it('sets body background for dark theme', () => {
renderHook(() => useCurrentTheme(), {
wrapper: ({ children }) => (
<Provider store={createStore(themeReducer, { theme: 'DarkTheme' })}>
{children}
</Provider>
),
})
// Dark theme uses MUI default dark background
expect(document.body.style.backgroundColor).toBe('rgb(48, 48, 48)')
})
it('sets body background for light theme', () => {
renderHook(() => useCurrentTheme(), {
wrapper: ({ children }) => (
<Provider store={createStore(themeReducer, { theme: 'LightTheme' })}>
{children}
</Provider>
),
})
// Light theme uses MUI default light background
expect(document.body.style.backgroundColor).toBe('rgb(250, 250, 250)')
})
it('sets body background for theme with custom background', () => {
renderHook(() => useCurrentTheme(), {
wrapper: ({ children }) => (
<Provider
store={createStore(themeReducer, { theme: 'SpotifyTheme' })}
>
{children}
</Provider>
),
})
// Spotify theme has explicit background.default: #121212
expect(document.body.style.backgroundColor).toBe('rgb(18, 18, 18)')
})
})
}) })