Compare commits

...

8 Commits

Author SHA1 Message Date
navidrome-bot
8e6a07ae4d fix(ui): update Danish, German, Greek, Spanish, French, Japanese, Polish, Russian, Swedish, Thai, Ukrainian translations from POEditor 2025-12-02 16:34:57 +00: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
24 changed files with 684 additions and 193 deletions

View File

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

View File

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

View File

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

View File

@ -41,6 +41,7 @@ type configOptions struct {
UIWelcomeMessage string UIWelcomeMessage string
MaxSidebarPlaylists int MaxSidebarPlaylists int
EnableTranscodingConfig bool EnableTranscodingConfig bool
EnableTranscodingCancellation bool
EnableDownloads bool EnableDownloads bool
EnableExternalServices bool EnableExternalServices bool
EnableInsightsCollector bool EnableInsightsCollector bool
@ -492,6 +493,7 @@ func setViperDefaults() {
viper.SetDefault("uiwelcomemessage", "") viper.SetDefault("uiwelcomemessage", "")
viper.SetDefault("maxsidebarplaylists", consts.DefaultMaxSidebarPlaylists) viper.SetDefault("maxsidebarplaylists", consts.DefaultMaxSidebarPlaylists)
viper.SetDefault("enabletranscodingconfig", false) viper.SetDefault("enabletranscodingconfig", false)
viper.SetDefault("enabletranscodingcancellation", false)
viper.SetDefault("transcodingcachesize", "100MB") viper.SetDefault("transcodingcachesize", "100MB")
viper.SetDefault("imagecachesize", "100MB") viper.SetDefault("imagecachesize", "100MB")
viper.SetDefault("albumplaycountmode", consts.AlbumPlayCountModeAbsolute) viper.SetDefault("albumplaycountmode", consts.AlbumPlayCountModeAbsolute)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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