Compare commits

...

14 Commits

Author SHA1 Message Date
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
42 changed files with 1003 additions and 286 deletions

View File

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

View File

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

View File

@ -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().String("uiloginbackgroundurl", viper.GetString("uiloginbackgroundurl"), "URL to a backaground image used in the Login page")
rootCmd.Flags().Bool("enabletranscodingconfig", viper.GetBool("enabletranscodingconfig"), "enables transcoding configuration in the UI")
rootCmd.Flags().Bool("enabletranscodingcancellation", viper.GetBool("enabletranscodingcancellation"), "enables transcoding context cancellation")
rootCmd.Flags().String("transcodingcachesize", viper.GetString("transcodingcachesize"), "size of transcoding cache")
rootCmd.Flags().String("imagecachesize", viper.GetString("imagecachesize"), "size of image (art work) cache. set to 0 to disable cache")
rootCmd.Flags().String("albumplaycountmode", viper.GetString("albumplaycountmode"), "how to compute playcount for albums. absolute (default) or normalized")
@ -397,6 +398,7 @@ func init() {
_ = viper.BindPFlag("prometheus.metricspath", rootCmd.Flags().Lookup("prometheus.metricspath"))
_ = viper.BindPFlag("enabletranscodingconfig", rootCmd.Flags().Lookup("enabletranscodingconfig"))
_ = viper.BindPFlag("enabletranscodingcancellation", rootCmd.Flags().Lookup("enabletranscodingcancellation"))
_ = viper.BindPFlag("transcodingcachesize", rootCmd.Flags().Lookup("transcodingcachesize"))
_ = viper.BindPFlag("imagecachesize", rootCmd.Flags().Lookup("imagecachesize"))
}

View File

@ -41,6 +41,7 @@ type configOptions struct {
UIWelcomeMessage string
MaxSidebarPlaylists int
EnableTranscodingConfig bool
EnableTranscodingCancellation bool
EnableDownloads bool
EnableExternalServices bool
EnableInsightsCollector bool
@ -86,8 +87,7 @@ type configOptions struct {
AuthRequestLimit int
AuthWindowLength time.Duration
PasswordEncryptionKey string
ReverseProxyUserHeader string
ReverseProxyWhitelist string
ExtAuth extAuthOptions
Plugins pluginsOptions
PluginConfig map[string]map[string]string
HTTPSecurityHeaders secureOptions `json:",omitzero"`
@ -106,32 +106,33 @@ type configOptions struct {
Agents string
// DevFlags. These are used to enable/disable debugging and incomplete features
DevLogLevels map[string]string `json:",omitempty"`
DevLogSourceLine bool
DevEnableProfiler bool
DevAutoCreateAdminPassword string
DevAutoLoginUsername string
DevActivityPanel bool
DevActivityPanelUpdateRate time.Duration
DevSidebarPlaylists bool
DevShowArtistPage bool
DevUIShowConfig bool
DevNewEventStream bool
DevOffsetOptimize int
DevArtworkMaxRequests int
DevArtworkThrottleBacklogLimit int
DevArtworkThrottleBacklogTimeout time.Duration
DevArtistInfoTimeToLive time.Duration
DevAlbumInfoTimeToLive time.Duration
DevExternalScanner bool
DevScannerThreads uint
DevSelectiveWatcher bool
DevInsightsInitialDelay time.Duration
DevEnablePlayerInsights bool
DevEnablePluginsInsights bool
DevPluginCompilationTimeout time.Duration
DevExternalArtistFetchMultiplier float64
DevOptimizeDB bool
DevLogLevels map[string]string `json:",omitempty"`
DevLogSourceLine bool
DevEnableProfiler bool
DevAutoCreateAdminPassword string
DevAutoLoginUsername string
DevActivityPanel bool
DevActivityPanelUpdateRate time.Duration
DevSidebarPlaylists bool
DevShowArtistPage bool
DevUIShowConfig bool
DevNewEventStream bool
DevOffsetOptimize int
DevArtworkMaxRequests int
DevArtworkThrottleBacklogLimit int
DevArtworkThrottleBacklogTimeout time.Duration
DevArtistInfoTimeToLive time.Duration
DevAlbumInfoTimeToLive time.Duration
DevExternalScanner bool
DevScannerThreads uint
DevSelectiveWatcher bool
DevInsightsInitialDelay time.Duration
DevEnablePlayerInsights bool
DevEnablePluginsInsights bool
DevPluginCompilationTimeout time.Duration
DevExternalArtistFetchMultiplier float64
DevOptimizeDB bool
DevPreserveUnicodeInExternalCalls bool
}
type scannerOptions struct {
@ -229,6 +230,11 @@ type pluginsOptions struct {
CacheSize string
}
type extAuthOptions struct {
TrustedSources string
UserHeader string
}
var (
Server = &configOptions{}
hooks []func()
@ -247,6 +253,10 @@ func LoadFromFile(confFile string) {
func Load(noConfigDump bool) {
parseIniFileConfiguration()
// Map deprecated options to their new names for backwards compatibility
mapDeprecatedOption("ReverseProxyWhitelist", "ExtAuth.TrustedSources")
mapDeprecatedOption("ReverseProxyUserHeader", "ExtAuth.UserHeader")
err := viper.Unmarshal(&Server)
if err != nil {
_, _ = fmt.Fprintln(os.Stderr, "FATAL: Error parsing config:", err)
@ -330,9 +340,16 @@ func Load(noConfigDump bool) {
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
if log.IsGreaterOrEqualTo(log.LevelDebug) && !noConfigDump {
prettyConf := pretty.Sprintf("Loaded configuration from '%s': %# v", Server.ConfigFile, Server)
prettyConf := pretty.Sprintf("Configuration: %# v", Server)
if Server.EnableLogRedacting {
prettyConf = log.Redact(prettyConf)
}
@ -350,6 +367,7 @@ func Load(noConfigDump bool) {
logDeprecatedOptions("Scanner.GenreSeparators")
logDeprecatedOptions("Scanner.GroupAlbumReleases")
logDeprecatedOptions("DevEnableBufferedScrobble") // Deprecated: Buffered scrobbling is now always enabled and this option is ignored
logDeprecatedOptions("ReverseProxyWhitelist", "ReverseProxyUserHeader")
// Call init hooks
for _, hook := range hooks {
@ -369,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
// would require a nested structure, so instead we unmarshal it to a map and then merge the nested [default]
// section into the root level.
@ -492,6 +518,7 @@ func setViperDefaults() {
viper.SetDefault("uiwelcomemessage", "")
viper.SetDefault("maxsidebarplaylists", consts.DefaultMaxSidebarPlaylists)
viper.SetDefault("enabletranscodingconfig", false)
viper.SetDefault("enabletranscodingcancellation", false)
viper.SetDefault("transcodingcachesize", "100MB")
viper.SetDefault("imagecachesize", "100MB")
viper.SetDefault("albumplaycountmode", consts.AlbumPlayCountModeAbsolute)
@ -536,8 +563,8 @@ func setViperDefaults() {
viper.SetDefault("authrequestlimit", 5)
viper.SetDefault("authwindowlength", 20*time.Second)
viper.SetDefault("passwordencryptionkey", "")
viper.SetDefault("reverseproxyuserheader", "Remote-User")
viper.SetDefault("reverseproxywhitelist", "")
viper.SetDefault("extauth.userheader", "Remote-User")
viper.SetDefault("extauth.trustedsources", "")
viper.SetDefault("prometheus.enabled", false)
viper.SetDefault("prometheus.metricspath", consts.PrometheusDefaultPath)
viper.SetDefault("prometheus.password", "")
@ -611,6 +638,7 @@ func setViperDefaults() {
viper.SetDefault("devplugincompilationtimeout", time.Minute)
viper.SetDefault("devexternalartistfetchmultiplier", 1.5)
viper.SetDefault("devoptimizedb", true)
viper.SetDefault("devpreserveunicodeinexternalcalls", false)
}
func init() {

View File

@ -41,6 +41,9 @@ var _ = Describe("Configuration", func() {
Expect(conf.Server.Tags["custom"].Aliases).To(Equal([]string{format, "test"}))
Expect(conf.Server.Tags["artist"].Split).To(Equal([]string{";"}))
// Check deprecated option mapping
Expect(conf.Server.ExtAuth.UserHeader).To(Equal("X-Auth-User"))
// The config file used should be the one we created
Expect(conf.Server.ConfigFile).To(Equal(filename))
},

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -223,7 +223,7 @@ var staticData = sync.OnceValue(func() insights.Data {
data.Config.ScanSchedule = conf.Server.Scanner.Schedule
data.Config.ScanWatcherWait = uint64(math.Trunc(conf.Server.Scanner.WatcherWait.Seconds()))
data.Config.ScanOnStartup = conf.Server.Scanner.ScanOnStartup
data.Config.ReverseProxyConfigured = conf.Server.ReverseProxyWhitelist != ""
data.Config.ReverseProxyConfigured = conf.Server.ExtAuth.TrustedSources != ""
data.Config.HasCustomPID = conf.Server.PID.Track != "" || conf.Server.PID.Album != ""
data.Config.HasCustomTags = len(conf.Server.Tags) > 0

View File

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

View File

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

View File

@ -31,6 +31,12 @@ type Submission struct {
Timestamp time.Time
}
type nowPlayingEntry struct {
userId string
track *model.MediaFile
position int
}
type PlayTracker interface {
NowPlaying(ctx context.Context, playerId string, playerName string, trackId string, position int) error
GetNowPlaying(ctx context.Context) ([]NowPlayingInfo, error)
@ -52,6 +58,11 @@ type playTracker struct {
pluginScrobblers map[string]Scrobbler
pluginLoader PluginLoader
mu sync.RWMutex
npQueue map[string]nowPlayingEntry
npMu sync.Mutex
npSignal chan struct{}
shutdown chan struct{}
workerDone chan struct{}
}
func GetPlayTracker(ds model.DataStore, broker events.Broker, pluginManager PluginLoader) PlayTracker {
@ -71,6 +82,10 @@ func newPlayTracker(ds model.DataStore, broker events.Broker, pluginManager Plug
builtinScrobblers: make(map[string]Scrobbler),
pluginScrobblers: make(map[string]Scrobbler),
pluginLoader: pluginManager,
npQueue: make(map[string]nowPlayingEntry),
npSignal: make(chan struct{}, 1),
shutdown: make(chan struct{}),
workerDone: make(chan struct{}),
}
if conf.Server.EnableNowPlaying {
m.OnExpiration(func(_ string, _ NowPlayingInfo) {
@ -90,9 +105,16 @@ func newPlayTracker(ds model.DataStore, broker events.Broker, pluginManager Plug
p.builtinScrobblers[name] = s
}
log.Debug("List of builtin scrobblers enabled", "names", enabled)
go p.nowPlayingWorker()
return p
}
// stopNowPlayingWorker stops the background worker. This is primarily for testing.
func (p *playTracker) stopNowPlayingWorker() {
close(p.shutdown)
<-p.workerDone // Wait for worker to finish
}
// pluginNamesMatchScrobblers returns true if the set of pluginNames matches the keys in pluginScrobblers
func pluginNamesMatchScrobblers(pluginNames []string, scrobblers map[string]Scrobbler) bool {
if len(pluginNames) != len(scrobblers) {
@ -198,11 +220,58 @@ func (p *playTracker) NowPlaying(ctx context.Context, playerId string, playerNam
}
player, _ := request.PlayerFrom(ctx)
if player.ScrobbleEnabled {
p.dispatchNowPlaying(ctx, user.ID, mf, position)
p.enqueueNowPlaying(playerId, user.ID, mf, position)
}
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) {
if t.Artist == consts.UnknownArtist {
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
type mockPluginLoader struct {
mu sync.RWMutex
names []string
scrobblers map[string]Scrobbler
}
func (m *mockPluginLoader) PluginNames(service string) []string {
m.mu.RLock()
defer m.mu.RUnlock()
return m.names
}
func (m *mockPluginLoader) SetNames(names []string) {
m.mu.Lock()
defer m.mu.Unlock()
m.names = names
}
func (m *mockPluginLoader) LoadScrobbler(name string) (Scrobbler, bool) {
m.mu.RLock()
defer m.mu.RUnlock()
s, ok := m.scrobblers[name]
return s, ok
}
@ -46,7 +57,7 @@ var _ = Describe("PlayTracker", func() {
var album model.Album
var artist1 model.Artist
var artist2 model.Artist
var fake fakeScrobbler
var fake *fakeScrobbler
BeforeEach(func() {
DeferCleanup(configtest.SetupConfig())
@ -54,16 +65,16 @@ var _ = Describe("PlayTracker", func() {
ctx = request.WithUser(ctx, model.User{ID: "u-1"})
ctx = request.WithPlayer(ctx, model.Player{ScrobbleEnabled: true})
ds = &tests.MockDataStore{}
fake = fakeScrobbler{Authorized: true}
fake = &fakeScrobbler{Authorized: true}
Register("fake", func(model.DataStore) Scrobbler {
return &fake
return fake
})
Register("disabled", func(model.DataStore) Scrobbler {
return nil
})
eventBroker = &fakeEventBroker{}
tracker = newPlayTracker(ds, eventBroker, nil)
tracker.(*playTracker).builtinScrobblers["fake"] = &fake // Bypass buffering for tests
tracker.(*playTracker).builtinScrobblers["fake"] = fake // Bypass buffering for tests
track = model.MediaFile{
ID: "123",
@ -86,6 +97,11 @@ var _ = Describe("PlayTracker", func() {
_ = ds.Album(ctx).(*tests.MockAlbumRepo).Put(&album)
})
AfterEach(func() {
// Stop the worker goroutine to prevent data races between tests
tracker.(*playTracker).stopNowPlayingWorker()
})
It("does not register disabled scrobblers", func() {
Expect(tracker.(*playTracker).builtinScrobblers).To(HaveKey("fake"))
Expect(tracker.(*playTracker).builtinScrobblers).ToNot(HaveKey("disabled"))
@ -95,10 +111,10 @@ var _ = Describe("PlayTracker", func() {
It("sends track to agent", func() {
err := tracker.NowPlaying(ctx, "player-1", "player-one", "123", 0)
Expect(err).ToNot(HaveOccurred())
Expect(fake.NowPlayingCalled).To(BeTrue())
Expect(fake.UserID).To(Equal("u-1"))
Expect(fake.Track.ID).To(Equal("123"))
Expect(fake.Track.Participants).To(Equal(track.Participants))
Eventually(func() bool { return fake.GetNowPlayingCalled() }).Should(BeTrue())
Expect(fake.GetUserID()).To(Equal("u-1"))
Expect(fake.GetTrack().ID).To(Equal("123"))
Expect(fake.GetTrack().Participants).To(Equal(track.Participants))
})
It("does not send track to agent if user has not authorized", func() {
fake.Authorized = false
@ -106,7 +122,7 @@ var _ = Describe("PlayTracker", func() {
err := tracker.NowPlaying(ctx, "player-1", "player-one", "123", 0)
Expect(err).ToNot(HaveOccurred())
Expect(fake.NowPlayingCalled).To(BeFalse())
Expect(fake.GetNowPlayingCalled()).To(BeFalse())
})
It("does not send track to agent if player is not enabled to send scrobbles", func() {
ctx = request.WithPlayer(ctx, model.Player{ScrobbleEnabled: false})
@ -114,7 +130,7 @@ var _ = Describe("PlayTracker", func() {
err := tracker.NowPlaying(ctx, "player-1", "player-one", "123", 0)
Expect(err).ToNot(HaveOccurred())
Expect(fake.NowPlayingCalled).To(BeFalse())
Expect(fake.GetNowPlayingCalled()).To(BeFalse())
})
It("does not send track to agent if artist is unknown", func() {
track.Artist = consts.UnknownArtist
@ -122,7 +138,7 @@ var _ = Describe("PlayTracker", func() {
err := tracker.NowPlaying(ctx, "player-1", "player-one", "123", 0)
Expect(err).ToNot(HaveOccurred())
Expect(fake.NowPlayingCalled).To(BeFalse())
Expect(fake.GetNowPlayingCalled()).To(BeFalse())
})
It("stores position when greater than zero", func() {
@ -130,11 +146,12 @@ var _ = Describe("PlayTracker", func() {
err := tracker.NowPlaying(ctx, "player-1", "player-one", "123", pos)
Expect(err).ToNot(HaveOccurred())
Eventually(func() int { return fake.GetPosition() }).Should(Equal(pos))
playing, err := tracker.GetNowPlaying(ctx)
Expect(err).ToNot(HaveOccurred())
Expect(playing).To(HaveLen(1))
Expect(playing[0].Position).To(Equal(pos))
Expect(fake.Position).To(Equal(pos))
})
It("sends event with count", func() {
@ -210,7 +227,7 @@ var _ = Describe("PlayTracker", func() {
Expect(err).ToNot(HaveOccurred())
Expect(fake.ScrobbleCalled.Load()).To(BeTrue())
Expect(fake.UserID).To(Equal("u-1"))
Expect(fake.GetUserID()).To(Equal("u-1"))
lastScrobble := fake.LastScrobble.Load()
Expect(lastScrobble.TimeStamp).To(BeTemporally("~", ts, 1*time.Second))
Expect(lastScrobble.ID).To(Equal("123"))
@ -278,45 +295,46 @@ var _ = Describe("PlayTracker", func() {
Describe("Plugin scrobbler logic", func() {
var pluginLoader *mockPluginLoader
var pluginFake fakeScrobbler
var pluginFake *fakeScrobbler
BeforeEach(func() {
pluginFake = fakeScrobbler{Authorized: true}
pluginFake = &fakeScrobbler{Authorized: true}
pluginLoader = &mockPluginLoader{
names: []string{"plugin1"},
scrobblers: map[string]Scrobbler{"plugin1": &pluginFake},
scrobblers: map[string]Scrobbler{"plugin1": pluginFake},
}
tracker = newPlayTracker(ds, events.GetBroker(), pluginLoader)
// Bypass buffering for both built-in and plugin scrobblers
tracker.(*playTracker).builtinScrobblers["fake"] = &fake
tracker.(*playTracker).pluginScrobblers["plugin1"] = &pluginFake
tracker.(*playTracker).builtinScrobblers["fake"] = fake
tracker.(*playTracker).pluginScrobblers["plugin1"] = pluginFake
})
It("registers and uses plugin scrobbler for NowPlaying", func() {
err := tracker.NowPlaying(ctx, "player-1", "player-one", "123", 0)
Expect(err).ToNot(HaveOccurred())
Expect(pluginFake.NowPlayingCalled).To(BeTrue())
Eventually(func() bool { return pluginFake.GetNowPlayingCalled() }).Should(BeTrue())
})
It("removes plugin scrobbler if not present anymore", func() {
// First call: plugin present
_ = tracker.NowPlaying(ctx, "player-1", "player-one", "123", 0)
Expect(pluginFake.NowPlayingCalled).To(BeTrue())
pluginFake.NowPlayingCalled = false
Eventually(func() bool { return pluginFake.GetNowPlayingCalled() }).Should(BeTrue())
pluginFake.nowPlayingCalled.Store(false)
// Remove plugin
pluginLoader.names = []string{}
pluginLoader.SetNames([]string{})
_ = tracker.NowPlaying(ctx, "player-1", "player-one", "123", 0)
Expect(pluginFake.NowPlayingCalled).To(BeFalse())
// Should not be called since plugin was removed
Consistently(func() bool { return pluginFake.GetNowPlayingCalled() }).Should(BeFalse())
})
It("calls both builtin and plugin scrobblers for NowPlaying", func() {
fake.NowPlayingCalled = false
pluginFake.NowPlayingCalled = false
fake.nowPlayingCalled.Store(false)
pluginFake.nowPlayingCalled.Store(false)
err := tracker.NowPlaying(ctx, "player-1", "player-one", "123", 0)
Expect(err).ToNot(HaveOccurred())
Expect(fake.NowPlayingCalled).To(BeTrue())
Expect(pluginFake.NowPlayingCalled).To(BeTrue())
Eventually(func() bool { return fake.GetNowPlayingCalled() }).Should(BeTrue())
Eventually(func() bool { return pluginFake.GetNowPlayingCalled() }).Should(BeTrue())
})
It("calls plugin scrobbler for Submit", func() {
@ -359,7 +377,7 @@ var _ = Describe("PlayTracker", func() {
It("calls Stop on scrobblers when removing them", func() {
// Change the plugin names to simulate a plugin being removed
mockPlugin.names = []string{}
mockPlugin.SetNames([]string{})
// Call refreshPluginScrobblers which should detect the removed plugin
pTracker.refreshPluginScrobblers()
@ -375,32 +393,51 @@ var _ = Describe("PlayTracker", func() {
type fakeScrobbler struct {
Authorized bool
NowPlayingCalled bool
nowPlayingCalled atomic.Bool
ScrobbleCalled atomic.Bool
UserID string
Track *model.MediaFile
Position int
userID atomic.Pointer[string]
track atomic.Pointer[model.MediaFile]
position atomic.Int32
LastScrobble atomic.Pointer[Scrobble]
Error error
}
func (f *fakeScrobbler) GetNowPlayingCalled() bool {
return f.nowPlayingCalled.Load()
}
func (f *fakeScrobbler) GetUserID() string {
if p := f.userID.Load(); p != nil {
return *p
}
return ""
}
func (f *fakeScrobbler) GetTrack() *model.MediaFile {
return f.track.Load()
}
func (f *fakeScrobbler) GetPosition() int {
return int(f.position.Load())
}
func (f *fakeScrobbler) IsAuthorized(ctx context.Context, userId string) bool {
return f.Error == nil && f.Authorized
}
func (f *fakeScrobbler) NowPlaying(ctx context.Context, userId string, track *model.MediaFile, position int) error {
f.NowPlayingCalled = true
f.nowPlayingCalled.Store(true)
if f.Error != nil {
return f.Error
}
f.UserID = userId
f.Track = track
f.Position = position
f.userID.Store(&userId)
f.track.Store(track)
f.position.Store(int32(position))
return nil
}
func (f *fakeScrobbler) Scrobble(ctx context.Context, userId string, s Scrobble) error {
f.UserID = userId
f.userID.Store(&userId)
f.LastScrobble.Store(&s)
f.ScrobbleCalled.Store(true)
if f.Error != nil {

View File

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

View File

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

View File

@ -301,14 +301,19 @@
"actions": {
"scan": "Bibliothek scannen",
"manageUsers": "Zugriff verwalten",
"viewDetails": "Details ansehen"
"viewDetails": "Details ansehen",
"quickScan": "Schneller Scan",
"fullScan": "Kompletter Scan"
},
"notifications": {
"created": "Bibliothek erfolgreich erstellt",
"updated": "Bibliothek erfolgreich geändert",
"deleted": "Bibliothek erfolgreich gelöscht",
"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": {
"nameRequired": "Bibliotheksname ist Pflichtfeld",
@ -604,7 +609,8 @@
"serverDown": "OFFLINE",
"scanType": "Typ",
"status": "Scan Fehler",
"elapsedTime": "Laufzeit"
"elapsedTime": "Laufzeit",
"selectiveScan": "Selektiver Scan"
},
"help": {
"title": "Navidrome Hotkeys",

View File

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

View File

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

View File

@ -12,6 +12,7 @@
"artist": "Artista",
"album": "Albuma",
"path": "Fitxategiaren bidea",
"libraryName": "Liburutegia",
"genre": "Generoa",
"compilation": "Konpilazioa",
"year": "Urtea",
@ -58,6 +59,7 @@
"playCount": "Erreprodukzioak",
"size": "Fitxategiaren tamaina",
"name": "Izena",
"libraryName": "Liburutegia",
"genre": "Generoa",
"compilation": "Konpilazioa",
"year": "Urtea",
@ -147,19 +149,26 @@
"currentPassword": "Uneko pasahitza",
"newPassword": "Pasahitz berria",
"token": "Tokena",
"lastAccessAt": "Azken sarbidea"
"lastAccessAt": "Azken sarbidea",
"libraries": "Liburutegiak"
},
"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": {
"created": "Erabiltzailea sortu da",
"updated": "Erabiltzailea eguneratu da",
"deleted": "Erabiltzailea ezabatu da"
},
"validation": {
"librariesRequired": "Gutxienez liburutegi bat hautatu behar da administratzaile ez diren erabiltzaileentzat"
},
"message": {
"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": {
@ -254,6 +263,7 @@
"fields": {
"path": "Bidea",
"size": "Tamaina",
"libraryName": "Liburutegia",
"updatedAt": "Desagertze-data:"
},
"actions": {
@ -263,6 +273,58 @@
"notifications": {
"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": {
@ -450,6 +512,12 @@
},
"menu": {
"library": "Liburutegia",
"librarySelector": {
"allLibraries": "Liburutegi guztiak (%{count})",
"multipleLibraries": "%{total} liburutegitik %{selected} hautatuta",
"selectLibraries": "Hautatu liburutegiak",
"none": "Bat ere ez"
},
"settings": "Ezarpenak",
"version": "Bertsioa",
"theme": "Itxura",

View File

@ -301,14 +301,19 @@
"actions": {
"scan": "Scanner la bibliothèque",
"manageUsers": "Gérer les accès utilisateurs",
"viewDetails": "Voir les détails"
"viewDetails": "Voir les détails",
"quickScan": "Scan Rapide",
"fullScan": "Scan Complet"
},
"notifications": {
"created": "Bibliothèque créée avec succès",
"updated": "Bibliothèque mise à jour avec succès",
"deleted": "Bibliothèque supprimée avec succès",
"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": {
"nameRequired": "La bibliothèque doit obligatoirement avoir un nom",
@ -604,7 +609,8 @@
"serverDown": "HORS LIGNE",
"scanType": "Type",
"status": "Erreur de scan",
"elapsedTime": "Temps écoulé"
"elapsedTime": "Temps écoulé",
"selectiveScan": "Sélectif"
},
"help": {
"title": "Raccourcis Navidrome",

View File

@ -300,7 +300,9 @@
},
"actions": {
"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"
},
"notifications": {
@ -598,11 +600,12 @@
"activity": {
"title": "Aktivitás",
"totalScanned": "Összes beolvasott mappa:",
"quickScan": "Gyors szkennelés",
"fullScan": "Teljes szkennelés",
"quickScan": "Gyors",
"fullScan": "Teljes",
"selectiveScan": "Szelektív",
"serverUptime": "Szerver üzemidő",
"serverDown": "OFFLINE",
"scanType": "Típus",
"scanType": "Legutóbbi szkennelés",
"status": "Szkennelési hiba",
"elapsedTime": "Eltelt idő"
},

View File

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

View File

@ -301,14 +301,19 @@
"actions": {
"scan": "Skanuj Bibliotekę",
"manageUsers": "Zarządzaj Dostępami Użytkownika",
"viewDetails": "Zobacz Szczegóły"
"viewDetails": "Zobacz Szczegóły",
"quickScan": "Szybkie Skanowanie",
"fullScan": "Pełne Skanowanie"
},
"notifications": {
"created": "Biblioteka utworzona prawidłowo",
"updated": "Biblioteka zaktualizowana prawidłowo",
"deleted": "Biblioteka usunięta prawidłowo",
"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": {
"nameRequired": "Nazwa biblioteki jest wymagana",
@ -604,7 +609,8 @@
"serverDown": "NIEDOSTĘPNY",
"scanType": "Typ",
"status": "Błąd Skanowania",
"elapsedTime": "Upłynięty Czas"
"elapsedTime": "Upłynięty Czas",
"selectiveScan": "Selektywne"
},
"help": {
"title": "Skróty Klawiszowe Navidrome",

View File

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

View File

@ -301,14 +301,19 @@
"actions": {
"scan": "Scanna bibliotek",
"manageUsers": "Hantera användaråtkomst",
"viewDetails": "Se detaljer"
"viewDetails": "Se detaljer",
"quickScan": "Snabbscan",
"fullScan": "Komplett scan"
},
"notifications": {
"created": "Biblioteket har skapats",
"updated": "Biblioteket har uppdaterats",
"deleted": "Biblioteket har raderats",
"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": {
"nameRequired": "Biblioteksnamn krävs",
@ -604,7 +609,8 @@
"serverDown": "OFFLINE",
"scanType": "Typ",
"status": "Fel vid scanning",
"elapsedTime": "Spelad tid"
"elapsedTime": "Spelad tid",
"selectiveScan": "Urval"
},
"help": {
"title": "Navidrome kortkommandon",

View File

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

View File

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

View File

@ -193,24 +193,24 @@ func UsernameFromToken(r *http.Request) string {
return token.Subject()
}
func UsernameFromReverseProxyHeader(r *http.Request) string {
if conf.Server.ReverseProxyWhitelist == "" {
func UsernameFromExtAuthHeader(r *http.Request) string {
if conf.Server.ExtAuth.TrustedSources == "" {
return ""
}
reverseProxyIp, ok := request.ReverseProxyIpFrom(r.Context())
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 ""
}
if !validateIPAgainstList(reverseProxyIp, conf.Server.ReverseProxyWhitelist) {
log.Warn(r.Context(), "IP is not whitelisted for reverse proxy login", "proxy-ip", reverseProxyIp, "client-ip", r.RemoteAddr)
if !validateIPAgainstList(reverseProxyIp, conf.Server.ExtAuth.TrustedSources) {
log.Warn(r.Context(), "IP is not whitelisted for external authentication", "proxy-ip", reverseProxyIp, "client-ip", r.RemoteAddr)
return ""
}
username := r.Header.Get(conf.Server.ReverseProxyUserHeader)
username := r.Header.Get(conf.Server.ExtAuth.UserHeader)
if username == "" {
return ""
}
log.Trace(r, "Found username in ReverseProxyUserHeader", "username", username)
log.Trace(r, "Found username in ExtAuth.UserHeader", "username", 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 {
return func(next http.Handler) http.Handler {
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 {
_ = rest.RespondWithError(w, http.StatusUnauthorized, "Not authenticated")
return
@ -291,7 +291,7 @@ func JWTRefresher(next http.Handler) http.Handler {
func handleLoginFromHeaders(ds model.DataStore, r *http.Request) map[string]interface{} {
username := UsernameFromConfig(r)
if username == "" {
username = UsernameFromReverseProxyHeader(r)
username = UsernameFromExtAuthHeader(r)
if username == "" {
return nil
}

View File

@ -80,7 +80,7 @@ var _ = Describe("Auth", func() {
req.Header.Add("Remote-User", "janedoe")
resp = httptest.NewRecorder()
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() {
@ -155,7 +155,7 @@ var _ = Describe("Auth", func() {
It("does not set auth data when listening on unix socket without whitelist", func() {
conf.Server.Address = "unix:/tmp/navidrome-test"
conf.Server.ReverseProxyWhitelist = ""
conf.Server.ExtAuth.TrustedSources = ""
// No ReverseProxyIp in request context
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() {
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(), "@"))
serveIndex(ds, fs, nil)(resp, req)
@ -302,8 +302,8 @@ var _ = Describe("Auth", func() {
ds = &tests.MockDataStore{}
req = httptest.NewRequest("GET", "/", nil)
req = req.WithContext(request.WithReverseProxyIp(req.Context(), trustedIP))
conf.Server.ReverseProxyWhitelist = "192.168.0.0/16"
conf.Server.ReverseProxyUserHeader = "Remote-User"
conf.Server.ExtAuth.TrustedSources = "192.168.0.0/16"
conf.Server.ExtAuth.UserHeader = "Remote-User"
})
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
// context if navidrome is behind a trusted reverse proxy.
func realIPMiddleware(next http.Handler) http.Handler {
if conf.Server.ReverseProxyWhitelist != "" {
if conf.Server.ExtAuth.TrustedSources != "" {
return chi.Chain(
reqToCtx(request.ReverseProxyIp, func(r *http.Request) any { return r.RemoteAddr }),
middleware.RealIP,

View File

@ -56,7 +56,7 @@ func fromInternalOrProxyAuth(r *http.Request) (string, bool) {
return username, true
}
return server.UsernameFromReverseProxyHeader(r), false
return server.UsernameFromExtAuthHeader(r), false
}
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() {
conf.Server.ReverseProxyWhitelist = "127.0.0.234/32"
conf.Server.ReverseProxyUserHeader = "Remote-User"
conf.Server.ExtAuth.TrustedSources = "127.0.0.234/32"
conf.Server.ExtAuth.UserHeader = "Remote-User"
r := newGetRequest("v=1.15", "c=test")
r.Header.Add("Remote-User", "user")
@ -254,8 +254,8 @@ var _ = Describe("Middlewares", func() {
When("using reverse proxy authentication", func() {
BeforeEach(func() {
DeferCleanup(configtest.SetupConfig())
conf.Server.ReverseProxyWhitelist = "192.168.1.1/24"
conf.Server.ReverseProxyUserHeader = "Remote-User"
conf.Server.ExtAuth.TrustedSources = "192.168.1.1/24"
conf.Server.ExtAuth.UserHeader = "Remote-User"
})
It("passes authentication with correct IP and header", func() {

7
ui/package-lock.json generated
View File

@ -7173,10 +7173,11 @@
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="
},
"node_modules/js-yaml": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz",
"integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==",
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
"integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
"dev": true,
"license": "MIT",
"dependencies": {
"argparse": "^2.0.1"
},

View File

@ -42,6 +42,12 @@ const useCurrentTheme = () => {
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])
return theme

View File

@ -15,6 +15,10 @@ function createMatchMedia(theme) {
})
}
beforeEach(() => {
document.body.style.backgroundColor = ''
})
describe('useCurrentTheme', () => {
describe('with user preference theme as light', () => {
beforeAll(() => {
@ -117,4 +121,44 @@ describe('useCurrentTheme', () => {
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)')
})
})
})