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,12 +55,10 @@
|
||||
4533,
|
||||
4633
|
||||
],
|
||||
// Use 'postCreateCommand' to run commands after the container is created.
|
||||
// "postCreateCommand": "make setup-dev",
|
||||
// Comment out connect as root instead. More info: https://aka.ms/vscode-remote/containers/non-root.
|
||||
"remoteUser": "vscode",
|
||||
"remoteEnv": {
|
||||
"ND_MUSICFOLDER": "./music",
|
||||
"ND_DATAFOLDER": "./data"
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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 {
|
||||
|
||||
@ -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ő"
|
||||
},
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user