mirror of
https://github.com/navidrome/navidrome.git
synced 2026-05-03 06:51:16 +00:00
Compare commits
8 Commits
950aafd58b
...
8e6a07ae4d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8e6a07ae4d | ||
|
|
5c43025ce1 | ||
|
|
ff5ebe1829 | ||
|
|
3ac2c6b6ed | ||
|
|
0faf744e32 | ||
|
|
33d9ce6ecc | ||
|
|
f14692c1f0 | ||
|
|
75b253687a |
@ -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
|
||||
|
||||
@ -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,8 +55,6 @@
|
||||
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": {
|
||||
|
||||
@ -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"))
|
||||
}
|
||||
|
||||
@ -41,6 +41,7 @@ type configOptions struct {
|
||||
UIWelcomeMessage string
|
||||
MaxSidebarPlaylists int
|
||||
EnableTranscodingConfig bool
|
||||
EnableTranscodingCancellation bool
|
||||
EnableDownloads bool
|
||||
EnableExternalServices bool
|
||||
EnableInsightsCollector bool
|
||||
@ -492,6 +493,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)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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())
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,15 @@ var fsTypeMap = map[int64]string{
|
||||
0x01021997: "v9fs",
|
||||
0x786f4256: "vboxsf",
|
||||
0x4d44: "vfat",
|
||||
0xca451a4e: "virtiofs",
|
||||
0x58465342: "xfs",
|
||||
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) {
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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ő"
|
||||
},
|
||||
|
||||
@ -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} 分前"
|
||||
}
|
||||
}
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user