Merge branch 'master' into Add-support-for-playlist-cover-art/406

This commit is contained in:
Elliot 2025-03-05 12:28:45 +08:00 committed by GitHub
commit 67162326a9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
48 changed files with 2047 additions and 1285 deletions

View file

@ -137,18 +137,18 @@ func startServer(ctx context.Context) func() error {
// schedulePeriodicScan schedules a periodic scan of the music library, if configured.
func schedulePeriodicScan(ctx context.Context) func() error {
return func() error {
schedule := conf.Server.ScanSchedule
schedule := conf.Server.Scanner.Schedule
if schedule == "" {
log.Warn(ctx, "Periodic scan is DISABLED")
return nil
}
scanner := CreateScanner(ctx)
s := CreateScanner(ctx)
schedulerInstance := scheduler.GetInstance()
log.Info("Scheduling periodic scan", "schedule", schedule)
err := schedulerInstance.Add(schedule, func() {
_, err := scanner.ScanAll(ctx, false)
_, err := s.ScanAll(ctx, false)
if err != nil {
log.Error(ctx, "Error executing periodic scan", err)
}

View file

@ -29,8 +29,6 @@ type configOptions struct {
DbPath string
LogLevel string
LogFile string
ScanInterval time.Duration
ScanSchedule string
SessionTimeout time.Duration
BaseURL string
BasePath string
@ -129,11 +127,11 @@ type configOptions struct {
type scannerOptions struct {
Enabled bool
Schedule string
WatcherWait time.Duration
ScanOnStartup bool
Extractor string // Deprecated: BFR Remove before release?
GenreSeparators string // Deprecated: BFR Update docs
GroupAlbumReleases bool // Deprecated: BFR Update docs
Extractor string
GroupAlbumReleases bool // Deprecated: BFR Update docs
}
type TagConf struct {
@ -305,7 +303,10 @@ func Load(noConfigDump bool) {
}
// BFR Remove before release
Server.Scanner.Extractor = consts.DefaultScannerExtractor
if Server.Scanner.Extractor != consts.DefaultScannerExtractor {
log.Warn(fmt.Sprintf("Extractor '%s' is not implemented, using 'taglib'", Server.Scanner.Extractor))
Server.Scanner.Extractor = consts.DefaultScannerExtractor
}
// Call init hooks
for _, hook := range hooks {
@ -362,25 +363,12 @@ func validatePlaylistsPath() error {
}
func validateScanSchedule() error {
if Server.ScanInterval != -1 {
log.Warn("ScanInterval is DEPRECATED. Please use ScanSchedule. See docs at https://navidrome.org/docs/usage/configuration-options/")
if Server.ScanSchedule != "@every 1m" {
log.Error("You cannot specify both ScanInterval and ScanSchedule, ignoring ScanInterval")
} else {
if Server.ScanInterval == 0 {
Server.ScanSchedule = ""
} else {
Server.ScanSchedule = fmt.Sprintf("@every %s", Server.ScanInterval)
}
log.Warn("Setting ScanSchedule", "schedule", Server.ScanSchedule)
}
}
if Server.ScanSchedule == "0" || Server.ScanSchedule == "" {
Server.ScanSchedule = ""
if Server.Scanner.Schedule == "0" || Server.Scanner.Schedule == "" {
Server.Scanner.Schedule = ""
return nil
}
var err error
Server.ScanSchedule, err = validateSchedule(Server.ScanSchedule, "ScanSchedule")
Server.Scanner.Schedule, err = validateSchedule(Server.Scanner.Schedule, "Scanner.Schedule")
return err
}
@ -389,10 +377,8 @@ func validateBackupSchedule() error {
Server.Backup.Schedule = ""
return nil
}
var err error
Server.Backup.Schedule, err = validateSchedule(Server.Backup.Schedule, "BackupSchedule")
Server.Backup.Schedule, err = validateSchedule(Server.Backup.Schedule, "Backup.Schedule")
return err
}
@ -403,7 +389,7 @@ func validateSchedule(schedule, field string) (string, error) {
c := cron.New()
id, err := c.AddFunc(schedule, func() {})
if err != nil {
log.Error(fmt.Sprintf("Invalid %s. Please read format spec at https://pkg.go.dev/github.com/robfig/cron#hdr-CRON_Expression_Format", field), "schedule", field, err)
log.Error(fmt.Sprintf("Invalid %s. Please read format spec at https://pkg.go.dev/github.com/robfig/cron#hdr-CRON_Expression_Format", field), "schedule", schedule, err)
} else {
c.Remove(id)
}
@ -425,8 +411,6 @@ func init() {
viper.SetDefault("port", 4533)
viper.SetDefault("unixsocketperm", "0660")
viper.SetDefault("sessiontimeout", consts.DefaultSessionTimeout)
viper.SetDefault("scaninterval", -1)
viper.SetDefault("scanschedule", "0")
viper.SetDefault("baseurl", "")
viper.SetDefault("tlscert", "")
viper.SetDefault("tlskey", "")
@ -493,8 +477,8 @@ func init() {
viper.SetDefault("jukebox.adminonly", true)
viper.SetDefault("scanner.enabled", true)
viper.SetDefault("scanner.schedule", "0")
viper.SetDefault("scanner.extractor", consts.DefaultScannerExtractor)
viper.SetDefault("scanner.genreseparators", ";/,")
viper.SetDefault("scanner.groupalbumreleases", false)
viper.SetDefault("scanner.watcherwait", consts.DefaultWatcherWait)
viper.SetDefault("scanner.scanonstartup", true)

View file

@ -187,7 +187,6 @@ var staticData = sync.OnceValue(func() insights.Data {
data.Config.EnablePrometheus = conf.Server.Prometheus.Enabled
data.Config.TranscodingCacheSize = conf.Server.TranscodingCacheSize
data.Config.ImageCacheSize = conf.Server.ImageCacheSize
data.Config.ScanSchedule = conf.Server.ScanSchedule
data.Config.SessionTimeout = uint64(math.Trunc(conf.Server.SessionTimeout.Seconds()))
data.Config.SearchFullString = conf.Server.SearchFullString
data.Config.RecentlyAddedByModTime = conf.Server.RecentlyAddedByModTime
@ -195,6 +194,10 @@ var staticData = sync.OnceValue(func() insights.Data {
data.Config.BackupSchedule = conf.Server.Backup.Schedule
data.Config.BackupCount = conf.Server.Backup.Count
data.Config.DevActivityPanel = conf.Server.DevActivityPanel
data.Config.ScannerEnabled = conf.Server.Scanner.Enabled
data.Config.ScanSchedule = conf.Server.Scanner.Schedule
data.Config.ScanWatcherWait = uint64(math.Trunc(conf.Server.Scanner.WatcherWait.Seconds()))
data.Config.ScanOnStartup = conf.Server.Scanner.ScanOnStartup
return data
})

View file

@ -43,7 +43,10 @@ type Data struct {
LogLevel string `json:"logLevel,omitempty"`
LogFileConfigured bool `json:"logFileConfigured,omitempty"`
TLSConfigured bool `json:"tlsConfigured,omitempty"`
ScannerEnabled bool `json:"scannerEnabled,omitempty"`
ScanSchedule string `json:"scanSchedule,omitempty"`
ScanWatcherWait uint64 `json:"scanWatcherWait,omitempty"`
ScanOnStartup bool `json:"scanOnStartup,omitempty"`
TranscodingCacheSize string `json:"transcodingCacheSize,omitempty"`
ImageCacheSize string `json:"imageCacheSize,omitempty"`
EnableArtworkPrecache bool `json:"enableArtworkPrecache,omitempty"`

View file

@ -9,6 +9,7 @@ import (
"net/url"
"os"
"path/filepath"
"regexp"
"strings"
"time"
@ -188,20 +189,14 @@ func (s *playlists) parseM3U(ctx context.Context, pls *model.Playlist, folder *m
if !model.IsAudioFile(line) {
continue
}
line = filepath.Clean(line)
if folder != nil && !filepath.IsAbs(line) {
line = filepath.Join(folder.AbsolutePath(), line)
var err error
line, err = filepath.Rel(folder.LibraryPath, line)
if err != nil {
log.Trace(ctx, "Error getting relative path", "playlist", pls.Name, "path", line, "folder", folder, err)
continue
}
}
filteredLines = append(filteredLines, line)
}
filteredLines = slice.Map(filteredLines, filepath.ToSlash)
found, err := mediaFileRepository.FindByPaths(filteredLines)
paths, err := s.normalizePaths(ctx, pls, folder, filteredLines)
if err != nil {
log.Warn(ctx, "Error normalizing paths in playlist", "playlist", pls.Name, err)
continue
}
found, err := mediaFileRepository.FindByPaths(paths)
if err != nil {
log.Warn(ctx, "Error reading files from DB", "playlist", pls.Name, err)
continue
@ -210,7 +205,7 @@ func (s *playlists) parseM3U(ctx context.Context, pls *model.Playlist, folder *m
for idx := range found {
existing[strings.ToLower(found[idx].Path)] = idx
}
for _, path := range filteredLines {
for _, path := range paths {
idx, ok := existing[strings.ToLower(path)]
if ok {
mfs = append(mfs, found[idx])
@ -228,6 +223,64 @@ func (s *playlists) parseM3U(ctx context.Context, pls *model.Playlist, folder *m
return nil
}
// TODO This won't work for multiple libraries
func (s *playlists) normalizePaths(ctx context.Context, pls *model.Playlist, folder *model.Folder, lines []string) ([]string, error) {
libRegex, err := s.compileLibraryPaths(ctx)
if err != nil {
return nil, err
}
res := make([]string, 0, len(lines))
for idx, line := range lines {
var libPath string
var filePath string
if folder != nil && !filepath.IsAbs(line) {
libPath = folder.LibraryPath
filePath = filepath.Join(folder.AbsolutePath(), line)
} else {
cleanLine := filepath.Clean(line)
if libPath = libRegex.FindString(cleanLine); libPath != "" {
filePath = cleanLine
}
}
if libPath != "" {
if rel, err := filepath.Rel(libPath, filePath); err == nil {
res = append(res, rel)
} else {
log.Debug(ctx, "Error getting relative path", "playlist", pls.Name, "path", line, "libPath", libPath,
"filePath", filePath, err)
}
} else {
log.Warn(ctx, "Path in playlist not found in any library", "path", line, "line", idx)
}
}
return slice.Map(res, filepath.ToSlash), nil
}
func (s *playlists) compileLibraryPaths(ctx context.Context) (*regexp.Regexp, error) {
libs, err := s.ds.Library(ctx).GetAll()
if err != nil {
return nil, err
}
// Create regex patterns for each library path
patterns := make([]string, len(libs))
for i, lib := range libs {
cleanPath := filepath.Clean(lib.Path)
escapedPath := regexp.QuoteMeta(cleanPath)
patterns[i] = fmt.Sprintf("^%s(?:/|$)", escapedPath)
}
// Combine all patterns into a single regex
combinedPattern := strings.Join(patterns, "|")
re, err := regexp.Compile(combinedPattern)
if err != nil {
return nil, fmt.Errorf("compiling library paths `%s`: %w", combinedPattern, err)
}
return re, nil
}
func (s *playlists) updatePlaylist(ctx context.Context, newPls *model.Playlist) error {
owner, _ := request.UserFrom(ctx)
@ -262,7 +315,7 @@ func (s *playlists) Update(ctx context.Context, playlistID string,
needsInfoUpdate := name != nil || comment != nil || public != nil
needsTrackRefresh := len(idxToRemove) > 0
return s.ds.WithTx(func(tx model.DataStore) error {
return s.ds.WithTxImmediate(func(tx model.DataStore) error {
var pls *model.Playlist
var err error
repo := tx.Playlist(ctx)

View file

@ -20,15 +20,20 @@ import (
var _ = Describe("Playlists", func() {
var ds *tests.MockDataStore
var ps Playlists
var mp mockedPlaylist
var mockPlsRepo mockedPlaylistRepo
var mockLibRepo *tests.MockLibraryRepo
ctx := context.Background()
BeforeEach(func() {
mp = mockedPlaylist{}
mockPlsRepo = mockedPlaylistRepo{}
mockLibRepo = &tests.MockLibraryRepo{}
ds = &tests.MockDataStore{
MockedPlaylist: &mp,
MockedPlaylist: &mockPlsRepo,
MockedLibrary: mockLibRepo,
}
ctx = request.WithUser(ctx, model.User{ID: "123"})
// Path should be libPath, but we want to match the root folder referenced in the m3u, which is `/`
mockLibRepo.SetData([]model.Library{{ID: 1, Path: "/"}})
})
Describe("ImportFile", func() {
@ -48,15 +53,13 @@ var _ = Describe("Playlists", func() {
Describe("M3U", func() {
It("parses well-formed playlists", func() {
// get absolute path for "tests/fixtures" folder
pls, err := ps.ImportFile(ctx, folder, "pls1.m3u")
Expect(err).ToNot(HaveOccurred())
Expect(pls.OwnerID).To(Equal("123"))
Expect(pls.Tracks).To(HaveLen(3))
Expect(pls.Tracks).To(HaveLen(2))
Expect(pls.Tracks[0].Path).To(Equal("tests/fixtures/playlists/test.mp3"))
Expect(pls.Tracks[1].Path).To(Equal("tests/fixtures/playlists/test.ogg"))
Expect(pls.Tracks[2].Path).To(Equal("/tests/fixtures/01 Invisible (RED) Edit Version.mp3"))
Expect(mp.last).To(Equal(pls))
Expect(mockPlsRepo.last).To(Equal(pls))
})
It("parses playlists using LF ending", func() {
@ -76,7 +79,7 @@ var _ = Describe("Playlists", func() {
It("parses well-formed playlists", func() {
pls, err := ps.ImportFile(ctx, folder, "recently_played.nsp")
Expect(err).ToNot(HaveOccurred())
Expect(mp.last).To(Equal(pls))
Expect(mockPlsRepo.last).To(Equal(pls))
Expect(pls.OwnerID).To(Equal("123"))
Expect(pls.Name).To(Equal("Recently Played"))
Expect(pls.Comment).To(Equal("Recently played tracks"))
@ -98,79 +101,90 @@ var _ = Describe("Playlists", func() {
repo = &mockedMediaFileFromListRepo{}
ds.MockedMediaFile = repo
ps = NewPlaylists(ds)
mockLibRepo.SetData([]model.Library{{ID: 1, Path: "/music"}, {ID: 2, Path: "/new"}})
ctx = request.WithUser(ctx, model.User{ID: "123"})
})
It("parses well-formed playlists", func() {
repo.data = []string{
"tests/fixtures/test.mp3",
"tests/fixtures/test.ogg",
"/tests/fixtures/01 Invisible (RED) Edit Version.mp3",
"tests/test.mp3",
"tests/test.ogg",
"tests/01 Invisible (RED) Edit Version.mp3",
"downloads/newfile.flac",
}
f, _ := os.Open("tests/fixtures/playlists/pls-with-name.m3u")
defer f.Close()
m3u := strings.Join([]string{
"#PLAYLIST:playlist 1",
"/music/tests/test.mp3",
"/music/tests/test.ogg",
"/new/downloads/newfile.flac",
"file:///music/tests/01%20Invisible%20(RED)%20Edit%20Version.mp3",
}, "\n")
f := strings.NewReader(m3u)
pls, err := ps.ImportM3U(ctx, f)
Expect(err).ToNot(HaveOccurred())
Expect(pls.OwnerID).To(Equal("123"))
Expect(pls.Name).To(Equal("playlist 1"))
Expect(pls.Sync).To(BeFalse())
Expect(pls.Tracks).To(HaveLen(3))
Expect(pls.Tracks[0].Path).To(Equal("tests/fixtures/test.mp3"))
Expect(pls.Tracks[1].Path).To(Equal("tests/fixtures/test.ogg"))
Expect(pls.Tracks[2].Path).To(Equal("/tests/fixtures/01 Invisible (RED) Edit Version.mp3"))
Expect(mp.last).To(Equal(pls))
f.Close()
Expect(pls.Tracks).To(HaveLen(4))
Expect(pls.Tracks[0].Path).To(Equal("tests/test.mp3"))
Expect(pls.Tracks[1].Path).To(Equal("tests/test.ogg"))
Expect(pls.Tracks[2].Path).To(Equal("downloads/newfile.flac"))
Expect(pls.Tracks[3].Path).To(Equal("tests/01 Invisible (RED) Edit Version.mp3"))
Expect(mockPlsRepo.last).To(Equal(pls))
})
It("sets the playlist name as a timestamp if the #PLAYLIST directive is not present", func() {
repo.data = []string{
"tests/fixtures/test.mp3",
"tests/fixtures/test.ogg",
"/tests/fixtures/01 Invisible (RED) Edit Version.mp3",
"tests/test.mp3",
"tests/test.ogg",
"/tests/01 Invisible (RED) Edit Version.mp3",
}
f, _ := os.Open("tests/fixtures/playlists/pls-without-name.m3u")
defer f.Close()
m3u := strings.Join([]string{
"/music/tests/test.mp3",
"/music/tests/test.ogg",
}, "\n")
f := strings.NewReader(m3u)
pls, err := ps.ImportM3U(ctx, f)
Expect(err).ToNot(HaveOccurred())
_, err = time.Parse(time.RFC3339, pls.Name)
Expect(err).ToNot(HaveOccurred())
Expect(pls.Tracks).To(HaveLen(3))
Expect(pls.Tracks).To(HaveLen(2))
})
It("returns only tracks that exist in the database and in the same other as the m3u", func() {
repo.data = []string{
"test1.mp3",
"test2.mp3",
"test3.mp3",
"album1/test1.mp3",
"album2/test2.mp3",
"album3/test3.mp3",
}
m3u := strings.Join([]string{
"test3.mp3",
"test1.mp3",
"test4.mp3",
"test2.mp3",
"/music/album3/test3.mp3",
"/music/album1/test1.mp3",
"/music/album4/test4.mp3",
"/music/album2/test2.mp3",
}, "\n")
f := strings.NewReader(m3u)
pls, err := ps.ImportM3U(ctx, f)
Expect(err).ToNot(HaveOccurred())
Expect(pls.Tracks).To(HaveLen(3))
Expect(pls.Tracks[0].Path).To(Equal("test3.mp3"))
Expect(pls.Tracks[1].Path).To(Equal("test1.mp3"))
Expect(pls.Tracks[2].Path).To(Equal("test2.mp3"))
Expect(pls.Tracks[0].Path).To(Equal("album3/test3.mp3"))
Expect(pls.Tracks[1].Path).To(Equal("album1/test1.mp3"))
Expect(pls.Tracks[2].Path).To(Equal("album2/test2.mp3"))
})
It("is case-insensitive when comparing paths", func() {
repo.data = []string{
"tEsT1.Mp3",
"abc/tEsT1.Mp3",
}
m3u := strings.Join([]string{
"TeSt1.mP3",
"/music/ABC/TeSt1.mP3",
}, "\n")
f := strings.NewReader(m3u)
pls, err := ps.ImportM3U(ctx, f)
Expect(err).ToNot(HaveOccurred())
Expect(pls.Tracks).To(HaveLen(1))
Expect(pls.Tracks[0].Path).To(Equal("tEsT1.Mp3"))
Expect(pls.Tracks[0].Path).To(Equal("abc/tEsT1.Mp3"))
})
})
@ -254,16 +268,16 @@ func (r *mockedMediaFileFromListRepo) FindByPaths([]string) (model.MediaFiles, e
return mfs, nil
}
type mockedPlaylist struct {
type mockedPlaylistRepo struct {
last *model.Playlist
model.PlaylistRepository
}
func (r *mockedPlaylist) FindByPath(string) (*model.Playlist, error) {
func (r *mockedPlaylistRepo) FindByPath(string) (*model.Playlist, error) {
return nil, model.ErrNotFound
}
func (r *mockedPlaylist) Put(pls *model.Playlist) error {
func (r *mockedPlaylistRepo) Put(pls *model.Playlist) error {
r.last = pls
return nil
}

View file

@ -124,7 +124,7 @@ func (p *playTracker) Submit(ctx context.Context, submissions []Submission) erro
success := 0
for _, s := range submissions {
mf, err := p.ds.MediaFile(ctx).Get(s.TrackID)
mf, err := p.ds.MediaFile(ctx).GetWithParticipants(s.TrackID)
if err != nil {
log.Error(ctx, "Cannot find track for scrobbling", "id", s.TrackID, "user", username, err)
continue

View file

@ -68,6 +68,7 @@ var _ = Describe("PlayTracker", func() {
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))
})
It("does not send track to agent if user has not authorized", func() {
fake.Authorized = false
@ -132,6 +133,7 @@ var _ = Describe("PlayTracker", func() {
Expect(fake.ScrobbleCalled).To(BeTrue())
Expect(fake.UserID).To(Equal("u-1"))
Expect(fake.LastScrobble.ID).To(Equal("123"))
Expect(fake.LastScrobble.Participants).To(Equal(track.Participants))
})
It("increments play counts in the DB", func() {
@ -191,7 +193,6 @@ var _ = Describe("PlayTracker", func() {
Expect(artist1.PlayCount).To(Equal(int64(1)))
Expect(artist2.PlayCount).To(Equal(int64(1)))
})
})
})

View file

@ -41,6 +41,7 @@ type DataStore interface {
Resource(ctx context.Context, model interface{}) ResourceRepository
WithTx(func(tx DataStore) error) error
WithTx(block func(tx DataStore) error, scope ...string) error
WithTxImmediate(block func(tx DataStore) error, scope ...string) error
GC(ctx context.Context) error
}

View file

@ -200,9 +200,9 @@ func init() {
conf.AddHook(func() {
loadTagMappings()
// This is here to avoid cyclic imports. The criteria package needs to know all tag names, so they can be used in
// smart playlists
criteria.AddTagNames(tagNames())
// This is here to avoid cyclic imports. The criteria package needs to know all tag names, so they can be
// used in smart playlists
criteria.AddRoles(slices.Collect(maps.Keys(AllRoles)))
criteria.AddTagNames(tagNames())
})
}

View file

@ -150,27 +150,6 @@ func (r *mediaFileRepository) GetWithParticipants(id string) (*model.MediaFile,
return m, err
}
func (r *mediaFileRepository) getParticipants(m *model.MediaFile) (model.Participants, error) {
ar := NewArtistRepository(r.ctx, r.db)
ids := m.Participants.AllIDs()
artists, err := ar.GetAll(model.QueryOptions{Filters: Eq{"id": ids}})
if err != nil {
return nil, fmt.Errorf("getting participants: %w", err)
}
artistMap := slice.ToMap(artists, func(a model.Artist) (string, model.Artist) {
return a.ID, a
})
p := m.Participants
for role, artistList := range p {
for idx, artist := range artistList {
if a, ok := artistMap[artist.ID]; ok {
p[role][idx].Artist = a
}
}
}
return p, nil
}
func (r *mediaFileRepository) GetAll(options ...model.QueryOptions) (model.MediaFiles, error) {
sq := r.selectMediaFile(options...)
var res dbMediaFiles

View file

@ -118,17 +118,45 @@ func (s *SQLStore) Resource(ctx context.Context, m interface{}) model.ResourceRe
return nil
}
func (s *SQLStore) WithTx(block func(tx model.DataStore) error) error {
conn, ok := s.db.(*dbx.DB)
if !ok {
func (s *SQLStore) WithTx(block func(tx model.DataStore) error, scope ...string) error {
var msg string
if len(scope) > 0 {
msg = scope[0]
}
start := time.Now()
conn, inTx := s.db.(*dbx.DB)
if !inTx {
log.Trace("Nested Transaction started", "scope", msg)
conn = dbx.NewFromDB(db.Db(), db.Driver)
} else {
log.Trace("Transaction started", "scope", msg)
}
return conn.Transactional(func(tx *dbx.Tx) error {
newDb := &SQLStore{db: tx}
return block(newDb)
err := block(newDb)
if !inTx {
log.Trace("Nested Transaction finished", "scope", msg, "elapsed", time.Since(start), err)
} else {
log.Trace("Transaction finished", "scope", msg, "elapsed", time.Since(start), err)
}
return err
})
}
func (s *SQLStore) WithTxImmediate(block func(tx model.DataStore) error, scope ...string) error {
ctx := context.Background()
return s.WithTx(func(tx model.DataStore) error {
// Workaround to force the transaction to be upgraded to immediate mode to avoid deadlocks
// See https://berthub.eu/articles/posts/a-brief-post-on-sqlite3-database-locked-despite-timeout/
_ = tx.Property(ctx).Put("tmp_lock_flag", "")
defer func() {
_ = tx.Property(ctx).Delete("tmp_lock_flag")
}()
return block(tx)
}, scope...)
}
func (s *SQLStore) GC(ctx context.Context) error {
trace := func(ctx context.Context, msg string, f func() error) func() error {
return func() error {

View file

@ -40,7 +40,11 @@ func mf(mf model.MediaFile) model.MediaFile {
mf.Tags = model.Tags{}
mf.LibraryID = 1
mf.LibraryPath = "music" // Default folder
mf.Participants = model.Participants{}
mf.Participants = model.Participants{
model.RoleArtist: model.ParticipantList{
model.Participant{Artist: model.Artist{ID: mf.ArtistID, Name: mf.Artist}},
},
}
return mf
}
@ -135,14 +139,6 @@ var _ = BeforeSuite(func() {
// }
//}
mr := NewMediaFileRepository(ctx, conn)
for i := range testSongs {
err := mr.Put(&testSongs[i])
if err != nil {
panic(err)
}
}
alr := NewAlbumRepository(ctx, conn).(*albumRepository)
for i := range testAlbums {
a := testAlbums[i]
@ -161,6 +157,14 @@ var _ = BeforeSuite(func() {
}
}
mr := NewMediaFileRepository(ctx, conn)
for i := range testSongs {
err := mr.Put(&testSongs[i])
if err != nil {
panic(err)
}
}
rar := NewRadioRepository(ctx, conn)
for i := range testRadios {
r := testRadios[i]

View file

@ -82,6 +82,10 @@ func (r *scrobbleBufferRepository) Next(service string, userId string) (*model.S
if err != nil {
return nil, err
}
res.ScrobbleEntry.Participants, err = r.getParticipants(&res.ScrobbleEntry.MediaFile)
if err != nil {
return nil, err
}
return res.ScrobbleEntry, nil
}

View file

@ -0,0 +1,208 @@
package persistence
import (
"context"
"time"
"github.com/Masterminds/squirrel"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/id"
"github.com/navidrome/navidrome/model/request"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("ScrobbleBufferRepository", func() {
var scrobble model.ScrobbleBufferRepository
var rawRepo sqlRepository
enqueueTime := time.Date(2025, 01, 01, 00, 00, 00, 00, time.Local)
var ids []string
var insertManually = func(service, userId, mediaFileId string, playTime time.Time) {
id := id.NewRandom()
ids = append(ids, id)
ins := squirrel.Insert("scrobble_buffer").SetMap(map[string]interface{}{
"id": id,
"user_id": userId,
"service": service,
"media_file_id": mediaFileId,
"play_time": playTime,
"enqueue_time": enqueueTime,
})
_, err := rawRepo.executeSQL(ins)
Expect(err).ToNot(HaveOccurred())
}
BeforeEach(func() {
ctx := request.WithUser(log.NewContext(context.TODO()), model.User{ID: "userid", UserName: "johndoe", IsAdmin: true})
db := GetDBXBuilder()
scrobble = NewScrobbleBufferRepository(ctx, db)
rawRepo = sqlRepository{
ctx: ctx,
tableName: "scrobble_buffer",
db: db,
}
ids = []string{}
})
AfterEach(func() {
del := squirrel.Delete(rawRepo.tableName)
_, err := rawRepo.executeSQL(del)
Expect(err).ToNot(HaveOccurred())
})
Describe("Without data", func() {
Describe("Count", func() {
It("returns zero when empty", func() {
count, err := scrobble.Length()
Expect(err).ToNot(HaveOccurred())
Expect(count).To(BeZero())
})
})
Describe("Dequeue", func() {
It("is a no-op when deleting a nonexistent item", func() {
err := scrobble.Dequeue(&model.ScrobbleEntry{ID: "fake"})
Expect(err).ToNot(HaveOccurred())
count, err := scrobble.Length()
Expect(err).ToNot(HaveOccurred())
Expect(count).To(Equal(int64(0)))
})
})
Describe("Next", func() {
It("should not fail with no item for the service", func() {
entry, err := scrobble.Next("fake", "userid")
Expect(entry).To(BeNil())
Expect(err).ToNot(HaveOccurred())
})
})
Describe("UserIds", func() {
It("should return empty list with no data", func() {
ids, err := scrobble.UserIDs("service")
Expect(err).ToNot(HaveOccurred())
Expect(ids).To(BeEmpty())
})
})
})
Describe("With data", func() {
timeA := enqueueTime.Add(24 * time.Hour)
timeB := enqueueTime.Add(48 * time.Hour)
timeC := enqueueTime.Add(72 * time.Hour)
timeD := enqueueTime.Add(96 * time.Hour)
BeforeEach(func() {
insertManually("a", "userid", "1001", timeB)
insertManually("a", "userid", "1002", timeA)
insertManually("a", "2222", "1003", timeC)
insertManually("b", "2222", "1004", timeD)
})
Describe("Count", func() {
It("Returns count when populated", func() {
count, err := scrobble.Length()
Expect(err).ToNot(HaveOccurred())
Expect(count).To(Equal(int64(4)))
})
})
Describe("Dequeue", func() {
It("is a no-op when deleting a nonexistent item", func() {
err := scrobble.Dequeue(&model.ScrobbleEntry{ID: "fake"})
Expect(err).ToNot(HaveOccurred())
count, err := scrobble.Length()
Expect(err).ToNot(HaveOccurred())
Expect(count).To(Equal(int64(4)))
})
It("deletes an item when specified properly", func() {
err := scrobble.Dequeue(&model.ScrobbleEntry{ID: ids[3]})
Expect(err).ToNot(HaveOccurred())
count, err := scrobble.Length()
Expect(err).ToNot(HaveOccurred())
Expect(count).To(Equal(int64(3)))
entry, err := scrobble.Next("b", "2222")
Expect(err).ToNot(HaveOccurred())
Expect(entry).To(BeNil())
})
})
Describe("Enqueue", func() {
DescribeTable("enqueues an item properly",
func(service, userId, fileId string, playTime time.Time) {
now := time.Now()
err := scrobble.Enqueue(service, userId, fileId, playTime)
Expect(err).ToNot(HaveOccurred())
count, err := scrobble.Length()
Expect(err).ToNot(HaveOccurred())
Expect(count).To(Equal(int64(5)))
entry, err := scrobble.Next(service, userId)
Expect(err).ToNot(HaveOccurred())
Expect(entry).ToNot(BeNil())
Expect(entry.EnqueueTime).To(BeTemporally("~", now))
Expect(entry.MediaFileID).To(Equal(fileId))
Expect(entry.PlayTime).To(BeTemporally("==", playTime))
},
Entry("to an existing service with multiple values", "a", "userid", "1004", enqueueTime),
Entry("to a new service", "c", "2222", "1001", timeD),
Entry("to an existing service as new user", "b", "userid", "1003", timeC),
)
})
Describe("Next", func() {
DescribeTable("Returns the next item when populated",
func(service, id string, playTime time.Time, fileId, artistId string) {
entry, err := scrobble.Next(service, id)
Expect(err).ToNot(HaveOccurred())
Expect(entry).ToNot(BeNil())
Expect(entry.Service).To(Equal(service))
Expect(entry.UserID).To(Equal(id))
Expect(entry.PlayTime).To(BeTemporally("==", playTime))
Expect(entry.EnqueueTime).To(BeTemporally("==", enqueueTime))
Expect(entry.MediaFileID).To(Equal(fileId))
Expect(entry.MediaFile.Participants).To(HaveLen(1))
artists, ok := entry.MediaFile.Participants[model.RoleArtist]
Expect(ok).To(BeTrue(), "no artist role in participants")
Expect(artists).To(HaveLen(1))
Expect(artists[0].ID).To(Equal(artistId))
},
Entry("Service with multiple values for one user", "a", "userid", timeA, "1002", "3"),
Entry("Service with users", "a", "2222", timeC, "1003", "2"),
Entry("Service with one user", "b", "2222", timeD, "1004", "2"),
)
})
Describe("UserIds", func() {
It("should return ordered list for services", func() {
ids, err := scrobble.UserIDs("a")
Expect(err).ToNot(HaveOccurred())
Expect(ids).To(Equal([]string{"2222", "userid"}))
})
It("should return for a different service", func() {
ids, err := scrobble.UserIDs("b")
Expect(err).ToNot(HaveOccurred())
Expect(ids).To(Equal([]string{"2222"}))
})
})
})
})

View file

@ -64,3 +64,24 @@ func (r sqlRepository) updateParticipants(itemID string, participants model.Part
_, err = r.executeSQL(sqi)
return err
}
func (r *sqlRepository) getParticipants(m *model.MediaFile) (model.Participants, error) {
ar := NewArtistRepository(r.ctx, r.db)
ids := m.Participants.AllIDs()
artists, err := ar.GetAll(model.QueryOptions{Filters: Eq{"id": ids}})
if err != nil {
return nil, fmt.Errorf("getting participants: %w", err)
}
artistMap := slice.ToMap(artists, func(a model.Artist) (string, model.Artist) {
return a.ID, a
})
p := m.Participants
for role, artistList := range p {
for idx, artist := range artistList {
if a, ok := artistMap[artist.ID]; ok {
p[role][idx].Artist = a
}
}
}
return p, nil
}

View file

@ -9,7 +9,7 @@
"trackNumber": "Titel #",
"playCount": "Wiedergaben",
"title": "Titel",
"artist": "Künstler",
"artist": "Interpret",
"album": "Album",
"path": "Dateipfad",
"genre": "Genre",
@ -26,7 +26,13 @@
"bpm": "BPM",
"playDate": "Letzte Wiedergabe",
"channels": "Spuren",
"createdAt": "Hinzugefügt"
"createdAt": "Hinzugefügt",
"grouping": "Gruppierung",
"mood": "Stimmung",
"participants": "Weitere Beteiligte",
"tags": "Weitere Tags",
"mappedTags": "Gemappte Tags",
"rawTags": "Tag Rohdaten"
},
"actions": {
"addToQueue": "Später abspielen",
@ -58,7 +64,13 @@
"originalDate": "Ursprünglich",
"releaseDate": "Erschienen",
"releases": "Veröffentlichung |||| Veröffentlichungen",
"released": "Erschienen"
"released": "Erschienen",
"recordLabel": "Label",
"catalogNum": "Katalognummer",
"releaseType": "Typ",
"grouping": "Gruppierung",
"media": "Medium",
"mood": "Stimmung"
},
"actions": {
"playAll": "Abspielen",
@ -89,7 +101,23 @@
"playCount": "Wiedergaben",
"rating": "Bewertung",
"genre": "Genre",
"size": "Größe"
"size": "Größe",
"role": "Rolle"
},
"roles": {
"albumartist": "Albuminterpret |||| Albuminterpreten",
"artist": "Interpret |||| Interpreten",
"composer": "Komponist |||| Komponisten",
"conductor": "Dirigent |||| Dirigenten",
"lyricist": "Texter |||| Texter",
"arranger": "Arrangeur |||| Arrangeure",
"producer": "Produzent |||| Produzenten",
"director": "Direktor |||| Direktoren",
"engineer": "Ingenieur |||| Ingenieure",
"mixer": "Mixer |||| Mixer",
"remixer": "Remixer |||| Remixer",
"djmixer": "DJ Mixer |||| DJ Mixer",
"performer": "ausübender Künstler |||| ausübende Künstler"
}
},
"user": {
@ -198,6 +226,20 @@
"createdAt": "Erstellt am",
"downloadable": "Downloads erlauben?"
}
},
"missing": {
"name": "Fehlende Datei |||| Fehlende Dateien",
"fields": {
"path": "Pfad",
"size": "Größe",
"updatedAt": "Fehlt seit"
},
"actions": {
"remove": "Entfernen"
},
"notifications": {
"removed": "Fehlende Datei(en) entfernt"
}
}
},
"ra": {
@ -375,7 +417,9 @@
"shareSuccess": "URL in Zwischenablage kopiert: %{url}",
"shareFailure": "Fehler URL %{url} konnte nicht in Zwischenablage kopiert werden",
"downloadDialogTitle": "Download %{resource} '%{name}' (%{size})",
"shareCopyToClipboard": "In Zwischenablage kopieren: Ctrl+C, Enter"
"shareCopyToClipboard": "In Zwischenablage kopieren: Ctrl+C, Enter",
"remove_missing_title": "Fehlende Dateien entfernen",
"remove_missing_content": "Möchtest du die ausgewählten Fehlenden Dateien wirklich aus der Datenbank entfernen? Alle Referenzen zu den Dateien wie Anzahl Wiedergaben und Bewertungen werden permanent gelöscht."
},
"menu": {
"library": "Bibliothek",
@ -421,7 +465,7 @@
"toggleMiniModeText": "Minimieren",
"destroyText": "Zerstören",
"downloadText": "Herunterladen",
"removeAudioListsText": "Audiolisten löschen",
"removeAudioListsText": "Audiolisten entfernen",
"clickToDeleteText": "Klicken um %{name} zu Löschen",
"emptyLyricText": "Kein Liedtext",
"playModeText": {

View file

@ -26,7 +26,13 @@
"bpm": "BPM",
"playDate": "Últimas reproducciones",
"channels": "Canales",
"createdAt": "Creado el"
"createdAt": "Creado el",
"grouping": "",
"mood": "",
"participants": "",
"tags": "",
"mappedTags": "",
"rawTags": ""
},
"actions": {
"addToQueue": "Reproducir después",
@ -58,7 +64,13 @@
"originalDate": "Original",
"releaseDate": "Publicado",
"releases": "Lanzamiento |||| Lanzamientos",
"released": "Publicado"
"released": "Publicado",
"recordLabel": "",
"catalogNum": "",
"releaseType": "",
"grouping": "",
"media": "",
"mood": ""
},
"actions": {
"playAll": "Reproducir",
@ -73,8 +85,8 @@
"lists": {
"all": "Todos",
"random": "Aleatorio",
"recentlyAdded": "Añadidos recientemente",
"recentlyPlayed": "Reproducidos recientemente",
"recentlyAdded": "Recientes",
"recentlyPlayed": "Recientes",
"mostPlayed": "Más reproducidos",
"starred": "Favoritos",
"topRated": "Los mejores calificados"
@ -89,7 +101,23 @@
"playCount": "Reproducciones",
"rating": "Calificación",
"genre": "Género",
"size": "Tamaño"
"size": "Tamaño",
"role": ""
},
"roles": {
"albumartist": "",
"artist": "",
"composer": "",
"conductor": "",
"lyricist": "",
"arranger": "",
"producer": "",
"director": "",
"engineer": "",
"mixer": "",
"remixer": "",
"djmixer": "",
"performer": ""
}
},
"user": {
@ -198,6 +226,20 @@
"createdAt": "Creado el",
"downloadable": "¿Permitir descargas?"
}
},
"missing": {
"name": "",
"fields": {
"path": "",
"size": "",
"updatedAt": ""
},
"actions": {
"remove": ""
},
"notifications": {
"removed": ""
}
}
},
"ra": {
@ -375,7 +417,9 @@
"shareSuccess": "URL copiada al portapapeles: %{url}",
"shareFailure": "Error al copiar la URL %{url} al portapapeles",
"downloadDialogTitle": "Descargar %{resource} '%{name}' (%{size})",
"shareCopyToClipboard": "Copiar al portapapeles: Ctrl+C, Intro"
"shareCopyToClipboard": "Copiar al portapapeles: Ctrl+C, Intro",
"remove_missing_title": "",
"remove_missing_content": ""
},
"menu": {
"library": "Biblioteca",
@ -465,4 +509,4 @@
"current_song": "Canción actual"
}
}
}
}

View file

@ -1,470 +1,512 @@
{
"languageName": "Euskara",
"resources": {
"song": {
"name": "Abestia |||| Abestiak",
"fields": {
"albumArtist": "Albumaren artista",
"duration": "Iraupena",
"trackNumber": "#",
"playCount": "Erreprodukzioak",
"title": "Titulua",
"artist": "Artista",
"album": "Albuma",
"path": "Fitxategiaren bidea",
"genre": "Generoa",
"compilation": "Konpilazioa",
"year": "Urtea",
"size": "Fitxategiaren tamaina",
"updatedAt": "Eguneratze-data:",
"bitRate": "Bit tasa",
"channels": "Kanalak",
"discSubtitle": "Diskoaren azpititulua",
"starred": "Gogokoa",
"comment": "Iruzkina",
"rating": "Balorazioa",
"quality": "Kalitatea",
"bpm": "BPM",
"playDate": "Azkenekoz erreproduzitua:",
"createdAt": "Gehitu zen data:"
},
"actions": {
"addToQueue": "Erreproduzitu ondoren",
"playNow": "Erreproduzitu orain",
"addToPlaylist": "Gehitu erreprodukzio-zerrendara",
"shuffleAll": "Erreprodukzio aleatorioa",
"download": "Deskargatu",
"playNext": "Hurrengoa",
"info": "Lortu informazioa"
}
},
"album": {
"name": "Albuma |||| Albumak",
"fields": {
"albumArtist": "Albumaren artista",
"artist": "Artista",
"duration": "Iraupena",
"songCount": "abesti",
"playCount": "Erreprodukzioak",
"size": "Fitxategiaren tamaina",
"name": "Izena",
"genre": "Generoa",
"compilation": "Konpilazioa",
"year": "Urtea",
"originalDate": "Jatorrizkoa",
"releaseDate": "Argitaratze-data:",
"releases": "Argitaratzea |||| Argitaratzeak",
"released": "Argitaratua",
"updatedAt": "Aktualizatze-data:",
"comment": "Iruzkina",
"rating": "Balorazioa",
"createdAt": "Gehitu zen data:"
},
"actions": {
"playAll": "Erreproduzitu",
"playNext": "Erreproduzitu segidan",
"addToQueue": "Erreproduzitu amaieran",
"share": "Partekatu",
"shuffle": "Aletorioa",
"addToPlaylist": "Gehitu zerrendara",
"download": "Deskargatu",
"info": "Lortu informazioa"
},
"lists": {
"all": "Guztiak",
"random": "Aleatorioa",
"recentlyAdded": "Berriki gehitutakoak",
"recentlyPlayed": "Berriki entzundakoak",
"mostPlayed": "Gehien entzundakoak",
"starred": "Gogokoak",
"topRated": "Hobekien baloratutakoak"
}
},
"artist": {
"name": "Artista |||| Artistak",
"fields": {
"name": "Izena",
"albumCount": "Album kopurua",
"songCount": "Abesti kopurua",
"size": "Tamaina",
"playCount": "Erreprodukzio kopurua",
"rating": "Balorazioa",
"genre": "Generoa"
}
},
"user": {
"name": "Erabiltzailea |||| Erabiltzaileak",
"fields": {
"userName": "Erabiltzailearen izena",
"isAdmin": "Administratzailea da",
"lastLoginAt": "Azken saio hasiera:",
"lastAccessAt": "Azken sarbidea",
"updatedAt": "Eguneratze-data:",
"name": "Izena",
"password": "Pasahitza",
"createdAt": "Sortze-data:",
"changePassword": "Pasahitza aldatu?",
"currentPassword": "Uneko pasahitza",
"newPassword": "Pasahitz berria",
"token": "Tokena"
},
"helperTexts": {
"name": "Aldaketak saioa hasten duzun hurrengoan islatuko dira"
},
"notifications": {
"created": "Erabiltzailea sortu da",
"updated": "Erabiltzailea eguneratu da",
"deleted": "Erabiltzailea ezabatu da"
},
"message": {
"listenBrainzToken": "Idatzi zure ListenBrainz erabiltzailearen tokena",
"clickHereForToken": "Egin klik hemen tokena lortzeko"
}
},
"player": {
"name": "Erreproduktorea |||| Erreproduktoreak",
"fields": {
"name": "Izena",
"transcodingId": "Transkodifikazioa",
"maxBitRate": "Gehienezko bit tasa",
"client": "Bezeroa",
"userName": "Erabiltzailea",
"lastSeen": "Azken konexioa",
"reportRealPath": "Erakutsi bide absolutua",
"scrobbleEnabled": "Bidali erabiltzailearen ohiturak hirugarrenen zerbitzuetara"
}
},
"transcoding": {
"name": "Transkodeketa |||| Transkodeketak",
"fields": {
"name": "Izena",
"targetFormat": "Helburuko formatua",
"defaultBitRate": "Bit tasa, defektuz",
"command": "Komandoa"
}
},
"playlist": {
"name": "Zerrenda |||| Zerrendak",
"fields": {
"name": "Izena",
"duration": "Iraupena",
"ownerName": "Jabea",
"public": "Publikoa",
"updatedAt": "Eguneratze-data:",
"createdAt": "Sortze-data:",
"songCount": "abesti",
"comment": "Iruzkina",
"sync": "Automatikoki inportatuak",
"path": "Inportatze-data:"
},
"actions": {
"selectPlaylist": "Hautatu zerrenda:",
"addNewPlaylist": "Sortu \"%{name}\"",
"export": "Esportatu",
"makePublic": "Egin publikoa",
"makePrivate": "Egin pribatua"
},
"message": {
"duplicate_song": "Hautatutako abesti batzuk lehendik ere daude zerrendan",
"song_exist": "Bikoiztutakoak gehitzen ari dira erreprodukzio-zerrendara. Ziur gehitu nahi dituzula?"
}
},
"radio": {
"name": "Irratia |||| Irratiak",
"fields": {
"name": "Izena",
"streamUrl": "Jarioaren URLa",
"homePageUrl": "Web orriaren URLa",
"updatedAt": "Eguneratze-data:",
"createdAt": "Sortze-data:"
},
"actions": {
"playNow": "Erreproduzitu orain"
}
},
"share": {
"name": "Partekatu",
"fields": {
"username": "Partekatzailea:",
"url": "URLa",
"description": "Deskribapena",
"downloadable": "Deskargatzea ahalbidetu?",
"contents": "Edukia",
"expiresAt": "Iraungitze-data:",
"lastVisitedAt": "Azkenekoz bisitatu zen:",
"visitCount": "Bisita kopurua",
"format": "Formatua",
"maxBitRate": "Gehienezko bit tasa",
"updatedAt": "Eguneratze-data:",
"createdAt": "Sortze-data:"
},
"notifications": {},
"actions": {}
}
"languageName": "Euskara",
"resources": {
"song": {
"name": "Abestia |||| Abestiak",
"fields": {
"albumArtist": "Albumaren artista",
"duration": "Iraupena",
"trackNumber": "#",
"playCount": "Erreprodukzioak",
"title": "Titulua",
"artist": "Artista",
"album": "Albuma",
"path": "Fitxategiaren bidea",
"genre": "Generoa",
"compilation": "Konpilazioa",
"year": "Urtea",
"size": "Fitxategiaren tamaina",
"updatedAt": "Eguneratze-data:",
"bitRate": "Bit tasa",
"discSubtitle": "Diskoaren azpititulua",
"starred": "Gogokoa",
"comment": "Iruzkina",
"rating": "Balorazioa",
"quality": "Kalitatea",
"bpm": "BPM",
"playDate": "Azkenekoz erreproduzitua:",
"channels": "Kanalak",
"createdAt": "Gehitu zen data:",
"grouping": "",
"mood": "",
"participants": "",
"tags": "",
"mappedTags": "",
"rawTags": ""
},
"actions": {
"addToQueue": "Erreproduzitu ondoren",
"playNow": "Erreproduzitu orain",
"addToPlaylist": "Gehitu erreprodukzio-zerrendara",
"shuffleAll": "Erreprodukzio aleatorioa",
"download": "Deskargatu",
"playNext": "Hurrengoa",
"info": "Lortu informazioa"
}
},
"ra": {
"auth": {
"welcome1": "Eskerrik asko Navidrome instalatzeagatik!",
"welcome2": "Lehenik eta behin, sortu administratzaile kontua",
"confirmPassword": "Baieztatu pasahitza",
"buttonCreateAdmin": "Sortu administratzailea",
"auth_check_error": "Hasi saioa aurrera egiteko",
"user_menu": "Profila",
"username": "Erabiltzailea",
"password": "Pasahitza",
"sign_in": "Sartu",
"sign_in_error": "Autentifikazioak huts egin du, saiatu berriro",
"logout": "Amaitu saioa",
"insightsCollectionNote": "Navidromek erabilera-datu anonimoak biltzen ditu\nproiektua hobetzeko asmoz. Klikatu [hemen] gehiago ikasteko\neta, hala nahi izanez gero, parte hartzen uzteko"
},
"validation": {
"invalidChars": "Erabili hizkiak eta zenbakiak bakarrik",
"passwordDoesNotMatch": "Pasahitzak ez datoz bat",
"required": "Beharrezkoa",
"minLength": "Gutxienez %{min} karaktere izan behar ditu",
"maxLength": "Gehienez %{max} karaktere izan ditzake",
"minValue": "Gutxienez %{min} izan behar da",
"maxValue": "Gehienez %{max} izan daiteke",
"number": "Zenbakia izan behar da",
"email": "Baliozko ePosta helbidea izan behar da",
"oneOf": "Hauetako bat izan behar da: %{options}",
"regex": "Formatu zehatzarekin bat etorri behar da (regexp): %{pattern}",
"unique": "Bakarra izan behar da",
"url": "Baliozko URLa izan behar da"
},
"action": {
"add_filter": "Gehitu iragazkia",
"add": "Gehitu",
"back": "Itzuli",
"bulk_actions": "elementu 1 hautatuta |||| %{smart_count} elementu hautatuta",
"cancel": "Utzi",
"clear_input_value": "Garbitu balioa",
"clone": "Bikoiztu",
"confirm": "Baieztatu",
"create": "Sortu",
"delete": "Ezabatu",
"edit": "Editatu",
"export": "Esportatu",
"list": "Zerrenda",
"refresh": "Freskatu",
"remove_filter": "Ezabatu iragazkia",
"remove": "Ezabatu",
"save": "Gorde",
"search": "Bilatu",
"show": "Erakutsi",
"sort": "Ordenatu",
"undo": "Desegin",
"expand": "Hedatu",
"close": "Itxi",
"open_menu": "Ireki menua",
"close_menu": "Itxi menua",
"unselect": "Utzi hautatzeari",
"skip": "Utzi alde batera",
"bulk_actions_mobile": "1 |||| %{smart_count}",
"share": "Partekatu",
"download": "Deskargatu"
},
"boolean": {
"true": "Bai",
"false": "Ez"
},
"page": {
"create": "Sortu %{name}",
"dashboard": "Mahaigaina",
"edit": "%{name} #%{id}",
"error": "Zerbaitek huts egin du",
"list": "%{name}",
"loading": "Kargatzen",
"not_found": "Ez da aurkitu",
"show": "%{name} #%{id}",
"empty": "Oraindik ez dago %{name}(r)ik.",
"invite": "Sortu nahi al duzu?"
},
"input": {
"file": {
"upload_several": "Jaregin edo hautatu igo nahi dituzun fitxategiak.",
"upload_single": "AJaregin edo hautatu igo nahi duzun fitxategia."
},
"image": {
"upload_several": "Jaregin edo hautatu igo nahi dituzun irudiak.",
"upload_single": "Jaregin edo hautatu igo nahi duzun irudia."
},
"references": {
"all_missing": "Ezin dira erreferentziazko datuak aurkitu.",
"many_missing": "Erreferentzietako bat gutxieenez ez dago eskuragai.",
"single_missing": "Ez dirudi erreferentzia eskuragai dagoenik."
},
"password": {
"toggle_visible": "Ezkutatu pasahitza",
"toggle_hidden": "Erakutsi pasahitza"
}
},
"message": {
"about": "Honi buruz",
"are_you_sure": "Ziur zaude?",
"bulk_delete_content": "Ziur %{name} ezabatu nahi duzula? |||| Ziur %{smart_count} hauek ezabatu nahi dituzula?",
"bulk_delete_title": "Ezabatu %{name} |||| Ezabatu %{smart_count} %{name}",
"delete_content": "Ziur elementu hau ezabatu nahi duzula?",
"delete_title": "Ezabatu %{name} #%{id}",
"details": "Xehetasunak",
"error": "Bezeroan errorea gertatu da eta eskaera ezin izan da gauzatu",
"invalid_form": "Formularioa ez da baliozkoa. Egiaztatu errorerik ez dagoela",
"loading": "Orria kargatzen ari da, itxaron",
"no": "Ez",
"not_found": "URLa ez da zuzena edo jarraitutako esteka akastuna da.",
"yes": "Bai",
"unsaved_changes": "Ez dira aldaketa batzuk gorde. Ziur muzin egin nahi diezula?"
},
"navigation": {
"no_results": "Ez da emaitzarik aurkitu",
"no_more_results": "%{page} orrialde-zenbakia mugetatik kanpo dago. Saiatu aurreko orrialdearekin.",
"page_out_of_boundaries": "%{page} orrialde-zenbakia mugetatik kanpo dago",
"page_out_from_end": "Ezin zara azken orrialdea baino haratago joan",
"page_out_from_begin": "Ezin zara lehenengo orrialdea baino aurrerago joan",
"page_range_info": "%{offsetBegin}-%{offsetEnd}, %{total} guztira",
"page_rows_per_page": "Errenkadak orrialdeko:",
"next": "Hurrengoa",
"prev": "Aurrekoa",
"skip_nav": "Joan edukira"
},
"notification": {
"updated": "Elementu bat eguneratu da |||| %{smart_count} elementu eguneratu dira",
"created": "Elementua sortu da",
"deleted": "Elementu bat ezabatu da |||| %{smart_count} elementu ezabatu dira.",
"bad_item": "Elementu okerra",
"item_doesnt_exist": "Elementua ez dago",
"http_error": "Errorea zerbitzariarekin komunikatzerakoan",
"data_provider_error": "Errorea datuen hornitzailean. Berrikusi kontsola xehetasun gehiagorako.",
"i18n_error": "Ezin izan dira zehaztutako hizkuntzaren itzulpenak kargatu",
"canceled": "Ekintza bertan behera utzi da",
"logged_out": "Saioa amaitu da, konektatu berriro.",
"new_version": "Bertsio berria eskuragai! Freskatu leihoa."
},
"toggleFieldsMenu": {
"columnsToDisplay": "Erakusteko zutabeak",
"layout": "Antolaketa",
"grid": "Sareta",
"table": "Taula"
}
"album": {
"name": "Albuma |||| Albumak",
"fields": {
"albumArtist": "Albumaren artista",
"artist": "Artista",
"duration": "Iraupena",
"songCount": "abesti",
"playCount": "Erreprodukzioak",
"name": "Izena",
"genre": "Generoa",
"compilation": "Konpilazioa",
"year": "Urtea",
"updatedAt": "Aktualizatze-data:",
"comment": "Iruzkina",
"rating": "Balorazioa",
"createdAt": "Gehitu zen data:",
"size": "Fitxategiaren tamaina",
"originalDate": "Jatorrizkoa",
"releaseDate": "Argitaratze-data:",
"releases": "Argitaratzea |||| Argitaratzeak",
"released": "Argitaratua",
"recordLabel": "",
"catalogNum": "",
"releaseType": "",
"grouping": "",
"media": "",
"mood": ""
},
"actions": {
"playAll": "Erreproduzitu",
"playNext": "Erreproduzitu segidan",
"addToQueue": "Erreproduzitu amaieran",
"shuffle": "Aletorioa",
"addToPlaylist": "Gehitu zerrendara",
"download": "Deskargatu",
"info": "Lortu informazioa",
"share": "Partekatu"
},
"lists": {
"all": "Guztiak",
"random": "Aleatorioa",
"recentlyAdded": "Berriki gehitutakoak",
"recentlyPlayed": "Berriki entzundakoak",
"mostPlayed": "Gehien entzundakoak",
"starred": "Gogokoak",
"topRated": "Hobekien baloratutakoak"
}
},
"message": {
"note": "OHARRA",
"transcodingDisabled": "Segurtasun arrazoiak direla-eta, transkodeketaren ezarpenak web-interfazearen bidez aldatzea ezgaituta dago. Transkodeketa-aukerak aldatu (editatu edo gehitu) nahi badituzu, berrabiarazi zerbitzaria konfigurazio-aukeraren %{config}-arekin.",
"transcodingEnabled": "Navidrome %{config}-ekin martxan dago eta, beraz, web-interfazeko transkodeketa-ataletik sistema-komandoak exekuta daitezke. Segurtasun arrazoiak tarteko, ezgaitzea gomendatzen dugu, eta transkodeketa-aukerak konfiguratzen ari zarenean bakarrik gaitzea.",
"songsAddedToPlaylist": "Abesti bat zerrendara gehitu da |||| %{smart_count} abesti zerrendara gehitu dira",
"noPlaylistsAvailable": "Ez dago zerrendarik erabilgarri",
"delete_user_title": "Ezabatu '%{name}' erabiltzailea",
"delete_user_content": "Ziur zaide erabiltzaile hau eta bere datu guztiak (zerrendak eta hobespenak barne) ezabatu nahi dituzula?",
"notifications_blocked": "Nabigatzaileak jakinarazpenak blokeatzen ditu",
"notifications_not_available": "Nabigatzaile hau ez da jakinarazpenekin bateragarria edo Navidrome ez da HTTPS erabiltzen ari",
"lastfmLinkSuccess": "Last.fm konektatuta dago eta erabiltzailearen ohiturak hirugarrenen zerbitzuekin partekatzea gaituta dago",
"lastfmLinkFailure": "Ezin izan da Last.fm-rekin konektatu",
"lastfmUnlinkSuccess": "Last.fm deskonektatu da eta erabiltzailearen ohiturak hirugarrenen zerbitzuekin partekatzea ezgaitu da",
"lastfmUnlinkFailure": "Ezin izan da Last.fm deskonektatu",
"listenBrainzLinkSuccess": "Ondo konektatu da ListenBrainz-ekin eta %{user} erabiltzailearen ohiturak hirugarrenen zerbitzuekin partekatzea aktibatu da",
"listenBrainzLinkFailure": "Ezin izan da ListenBrainz-ekin konektatu: %{error}",
"listenBrainzUnlinkSuccess": "ListenBrainz deskonektatu da eta erabiltzailearen ohiturak hirugarrenen zerbitzuekin partekatzea desaktibatu da",
"listenBrainzUnlinkFailure": "Ezin izan da ListenBrainz deskonektatu",
"openIn": {
"lastfm": "Ireki Last.fm-n",
"musicbrainz": "Ireki MusicBrainz-en"
},
"lastfmLink": "Irakurri gehiago…",
"downloadOriginalFormat": "Deskargatu jatorrizko formatua",
"shareOriginalFormat": "Partekatu jatorrizko formatua",
"shareDialogTitle": "Partekatu '%{name}' %{resource}",
"shareBatchDialogTitle": "Partekatu %{resource} bat |||| Partekatu %{smart_count} %{resource}",
"shareSuccess": "URLa arbelera kopiatu da: %{url}",
"shareFailure": "Errorea %{url} URLa arbelera kopiatzean",
"downloadDialogTitle": "Deskargatu '%{name}' %{resource}, (%{size})",
"shareCopyToClipboard": "Kopiatu arbelera: Ktrl + C, Sartu tekla"
"artist": {
"name": "Artista |||| Artistak",
"fields": {
"name": "Izena",
"albumCount": "Album kopurua",
"songCount": "Abesti kopurua",
"playCount": "Erreprodukzio kopurua",
"rating": "Balorazioa",
"genre": "Generoa",
"size": "Tamaina",
"role": ""
},
"roles": {
"albumartist": "",
"artist": "",
"composer": "",
"conductor": "",
"lyricist": "",
"arranger": "",
"producer": "",
"director": "",
"engineer": "",
"mixer": "",
"remixer": "",
"djmixer": "",
"performer": ""
}
},
"menu": {
"library": "Liburutegia",
"settings": "Ezarpenak",
"version": "Bertsioa",
"theme": "Itxura",
"personal": {
"name": "Pertsonala",
"options": {
"theme": "Itxura",
"language": "Hizkuntza",
"defaultView": "Bista, defektuz",
"desktop_notifications": "Mahaigaineko jakinarazpenak",
"lastfmNotConfigured": "Ez da Last.fm-ren API gakoa konfiguratu",
"lastfmScrobbling": "Bidali Last.fm-ra erabiltzailearen ohiturak",
"listenBrainzScrobbling": "Bidali ListenBrainz-era erabiltzailearen ohiturak",
"replaygain": "ReplayGain modua",
"preAmp": "ReplayGain PreAmp (dB)",
"gain": {
"none": "Bat ere ez",
"album": "Albuma",
"track": "Pista"
}
}
},
"albumList": "Albumak",
"playlists": "Zerrendak",
"sharedPlaylists": "Partekatutako erreprodukzio-zerrendak",
"about": "Honi buruz"
"user": {
"name": "Erabiltzailea |||| Erabiltzaileak",
"fields": {
"userName": "Erabiltzailearen izena",
"isAdmin": "Administratzailea da",
"lastLoginAt": "Azken saio hasiera:",
"updatedAt": "Eguneratze-data:",
"name": "Izena",
"password": "Pasahitza",
"createdAt": "Sortze-data:",
"changePassword": "Pasahitza aldatu?",
"currentPassword": "Uneko pasahitza",
"newPassword": "Pasahitz berria",
"token": "Tokena",
"lastAccessAt": "Azken sarbidea"
},
"helperTexts": {
"name": "Aldaketak saioa hasten duzun hurrengoan islatuko dira"
},
"notifications": {
"created": "Erabiltzailea sortu da",
"updated": "Erabiltzailea eguneratu da",
"deleted": "Erabiltzailea ezabatu da"
},
"message": {
"listenBrainzToken": "Idatzi zure ListenBrainz erabiltzailearen tokena",
"clickHereForToken": "Egin klik hemen tokena lortzeko"
}
},
"player": {
"playListsText": "Erreprodukzio-zerrenda",
"openText": "Ireki",
"closeText": "Itxi",
"notContentText": "Ez dago musikarik",
"clickToPlayText": "Egin klik erreproduzitzeko",
"clickToPauseText": "Egin klik eteteko",
"nextTrackText": "Hurrengo pista",
"previousTrackText": "Aurreko pista",
"reloadText": "Freskatu",
"volumeText": "Bolumena",
"toggleLyricText": "Erakutsi letrak",
"toggleMiniModeText": "Ikonotu",
"destroyText": "Suntsitu",
"downloadText": "Deskargatu",
"removeAudioListsText": "Ezabatu audio-zerrendak",
"clickToDeleteText": "Egin klik %{name} ezabatzeko",
"emptyLyricText": "Ez dago letrarik",
"playModeText": {
"order": "Ordenean",
"orderLoop": "Errepikatu",
"singleLoop": "Errepikatu bakarra",
"shufflePlay": "Aleatorioa"
}
"name": "Erreproduktorea |||| Erreproduktoreak",
"fields": {
"name": "Izena",
"transcodingId": "Transkodifikazioa",
"maxBitRate": "Gehienezko bit tasa",
"client": "Bezeroa",
"userName": "Erabiltzailea",
"lastSeen": "Azken konexioa",
"reportRealPath": "Erakutsi bide absolutua",
"scrobbleEnabled": "Bidali erabiltzailearen ohiturak hirugarrenen zerbitzuetara"
}
},
"about": {
"links": {
"homepage": "Hasierako orria",
"source": "Iturburu kodea",
"featureRequests": "Eskatu ezaugarria",
"lastInsightsCollection": "Bildutako azken datuak",
"insights": {
"disabled": "Ezgaituta",
"waiting": "Zain"
"transcoding": {
"name": "Transkodeketa |||| Transkodeketak",
"fields": {
"name": "Izena",
"targetFormat": "Helburuko formatua",
"defaultBitRate": "Bit tasa, defektuz",
"command": "Komandoa"
}
},
"playlist": {
"name": "Zerrenda |||| Zerrendak",
"fields": {
"name": "Izena",
"duration": "Iraupena",
"ownerName": "Jabea",
"public": "Publikoa",
"updatedAt": "Eguneratze-data:",
"createdAt": "Sortze-data:",
"songCount": "abesti",
"comment": "Iruzkina",
"sync": "Automatikoki inportatuak",
"path": "Inportatze-data:"
},
"actions": {
"selectPlaylist": "Hautatu zerrenda:",
"addNewPlaylist": "Sortu \"%{name}\"",
"export": "Esportatu",
"makePublic": "Egin publikoa",
"makePrivate": "Egin pribatua"
},
"message": {
"duplicate_song": "Hautatutako abesti batzuk lehendik ere daude zerrendan",
"song_exist": "Bikoiztutakoak gehitzen ari dira erreprodukzio-zerrendara. Ziur gehitu nahi dituzula?"
}
},
"radio": {
"name": "Irratia |||| Irratiak",
"fields": {
"name": "Izena",
"streamUrl": "Jarioaren URLa",
"homePageUrl": "Web orriaren URLa",
"updatedAt": "Eguneratze-data:",
"createdAt": "Sortze-data:"
},
"actions": {
"playNow": "Erreproduzitu orain"
}
},
"share": {
"name": "Partekatu",
"fields": {
"username": "Partekatzailea:",
"url": "URLa",
"description": "Deskribapena",
"contents": "Edukia",
"expiresAt": "Iraungitze-data:",
"lastVisitedAt": "Azkenekoz bisitatu zen:",
"visitCount": "Bisita kopurua",
"format": "Formatua",
"maxBitRate": "Gehienezko bit tasa",
"updatedAt": "Eguneratze-data:",
"createdAt": "Sortze-data:",
"downloadable": "Deskargatzea ahalbidetu?"
}
},
"missing": {
"name": "",
"fields": {
"path": "",
"size": "",
"updatedAt": ""
},
"actions": {
"remove": ""
},
"notifications": {
"removed": ""
}
}
},
"activity": {
"title": "Ekintzak",
"totalScanned": "Arakatutako karpeta guztiak",
"quickScan": "Arakatze azkarra",
"fullScan": "Arakatze sakona",
"serverUptime": "Zerbitzariak piztuta daraman denbora",
"serverDown": "LINEAZ KANPO"
"ra": {
"auth": {
"welcome1": "Eskerrik asko Navidrome instalatzeagatik!",
"welcome2": "Lehenik eta behin, sortu administratzaile kontua",
"confirmPassword": "Baieztatu pasahitza",
"buttonCreateAdmin": "Sortu administratzailea",
"auth_check_error": "Hasi saioa aurrera egiteko",
"user_menu": "Profila",
"username": "Erabiltzailea",
"password": "Pasahitza",
"sign_in": "Sartu",
"sign_in_error": "Autentifikazioak huts egin du, saiatu berriro",
"logout": "Amaitu saioa",
"insightsCollectionNote": ""
},
"help": {
"title": "Navidromeren laster-teklak",
"hotkeys": {
"show_help": "Erakutsi laguntza",
"toggle_menu": "Alboko barra bai / ez",
"toggle_play": "Erreproduzitu / Eten",
"prev_song": "Aurreko abestia",
"next_song": "Hurrengo abestia",
"vol_up": "Igo bolumena",
"vol_down": "Jaitsi bolumena",
"toggle_love": "Abestia gogoko bai / ez",
"current_song": "Uneko abestia"
}
"validation": {
"invalidChars": "Erabili hizkiak eta zenbakiak bakarrik",
"passwordDoesNotMatch": "Pasahitzak ez datoz bat",
"required": "Beharrezkoa",
"minLength": "Gutxienez %{min} karaktere izan behar ditu",
"maxLength": "Gehienez %{max} karaktere izan ditzake",
"minValue": "Gutxienez %{min} izan behar da",
"maxValue": "Gehienez %{max} izan daiteke",
"number": "Zenbakia izan behar da",
"email": "Baliozko ePosta helbidea izan behar da",
"oneOf": "Hauetako bat izan behar da: %{options}",
"regex": "Formatu zehatzarekin bat etorri behar da (regexp): %{pattern}",
"unique": "Bakarra izan behar da",
"url": "Baliozko URLa izan behar da"
},
"action": {
"add_filter": "Gehitu iragazkia",
"add": "Gehitu",
"back": "Itzuli",
"bulk_actions": "elementu 1 hautatuta |||| %{smart_count} elementu hautatuta",
"cancel": "Utzi",
"clear_input_value": "Garbitu balioa",
"clone": "Bikoiztu",
"confirm": "Baieztatu",
"create": "Sortu",
"delete": "Ezabatu",
"edit": "Editatu",
"export": "Esportatu",
"list": "Zerrenda",
"refresh": "Freskatu",
"remove_filter": "Ezabatu iragazkia",
"remove": "Ezabatu",
"save": "Gorde",
"search": "Bilatu",
"show": "Erakutsi",
"sort": "Ordenatu",
"undo": "Desegin",
"expand": "Hedatu",
"close": "Itxi",
"open_menu": "Ireki menua",
"close_menu": "Itxi menua",
"unselect": "Utzi hautatzeari",
"skip": "Utzi alde batera",
"bulk_actions_mobile": "1 |||| %{smart_count}",
"share": "Partekatu",
"download": "Deskargatu"
},
"boolean": {
"true": "Bai",
"false": "Ez"
},
"page": {
"create": "Sortu %{name}",
"dashboard": "Mahaigaina",
"edit": "%{name} #%{id}",
"error": "Zerbaitek huts egin du",
"list": "%{name}",
"loading": "Kargatzen",
"not_found": "Ez da aurkitu",
"show": "%{name} #%{id}",
"empty": "Oraindik ez dago %{name}(r)ik.",
"invite": "Sortu nahi al duzu?"
},
"input": {
"file": {
"upload_several": "Jaregin edo hautatu igo nahi dituzun fitxategiak.",
"upload_single": "AJaregin edo hautatu igo nahi duzun fitxategia."
},
"image": {
"upload_several": "Jaregin edo hautatu igo nahi dituzun irudiak.",
"upload_single": "Jaregin edo hautatu igo nahi duzun irudia."
},
"references": {
"all_missing": "Ezin dira erreferentziazko datuak aurkitu.",
"many_missing": "Erreferentzietako bat gutxieenez ez dago eskuragai.",
"single_missing": "Ez dirudi erreferentzia eskuragai dagoenik."
},
"password": {
"toggle_visible": "Ezkutatu pasahitza",
"toggle_hidden": "Erakutsi pasahitza"
}
},
"message": {
"about": "Honi buruz",
"are_you_sure": "Ziur zaude?",
"bulk_delete_content": "Ziur %{name} ezabatu nahi duzula? |||| Ziur %{smart_count} hauek ezabatu nahi dituzula?",
"bulk_delete_title": "Ezabatu %{name} |||| Ezabatu %{smart_count} %{name}",
"delete_content": "Ziur elementu hau ezabatu nahi duzula?",
"delete_title": "Ezabatu %{name} #%{id}",
"details": "Xehetasunak",
"error": "Bezeroan errorea gertatu da eta eskaera ezin izan da gauzatu",
"invalid_form": "Formularioa ez da baliozkoa. Egiaztatu errorerik ez dagoela",
"loading": "Orria kargatzen ari da, itxaron",
"no": "Ez",
"not_found": "URLa ez da zuzena edo jarraitutako esteka akastuna da.",
"yes": "Bai",
"unsaved_changes": "Ez dira aldaketa batzuk gorde. Ziur muzin egin nahi diezula?"
},
"navigation": {
"no_results": "Ez da emaitzarik aurkitu",
"no_more_results": "%{page} orrialde-zenbakia mugetatik kanpo dago. Saiatu aurreko orrialdearekin.",
"page_out_of_boundaries": "%{page} orrialde-zenbakia mugetatik kanpo dago",
"page_out_from_end": "Ezin zara azken orrialdea baino haratago joan",
"page_out_from_begin": "Ezin zara lehenengo orrialdea baino aurrerago joan",
"page_range_info": "%{offsetBegin}-%{offsetEnd}, %{total} guztira",
"page_rows_per_page": "Errenkadak orrialdeko:",
"next": "Hurrengoa",
"prev": "Aurrekoa",
"skip_nav": "Joan edukira"
},
"notification": {
"updated": "Elementu bat eguneratu da |||| %{smart_count} elementu eguneratu dira",
"created": "Elementua sortu da",
"deleted": "Elementu bat ezabatu da |||| %{smart_count} elementu ezabatu dira.",
"bad_item": "Elementu okerra",
"item_doesnt_exist": "Elementua ez dago",
"http_error": "Errorea zerbitzariarekin komunikatzerakoan",
"data_provider_error": "Errorea datuen hornitzailean. Berrikusi kontsola xehetasun gehiagorako.",
"i18n_error": "Ezin izan dira zehaztutako hizkuntzaren itzulpenak kargatu",
"canceled": "Ekintza bertan behera utzi da",
"logged_out": "Saioa amaitu da, konektatu berriro.",
"new_version": "Bertsio berria eskuragai! Freskatu leihoa."
},
"toggleFieldsMenu": {
"columnsToDisplay": "Erakusteko zutabeak",
"layout": "Antolaketa",
"grid": "Sareta",
"table": "Taula"
}
}
},
"message": {
"note": "OHARRA",
"transcodingDisabled": "Segurtasun arrazoiak direla-eta, transkodeketaren ezarpenak web-interfazearen bidez aldatzea ezgaituta dago. Transkodeketa-aukerak aldatu (editatu edo gehitu) nahi badituzu, berrabiarazi zerbitzaria konfigurazio-aukeraren %{config}-arekin.",
"transcodingEnabled": "Navidrome %{config}-ekin martxan dago eta, beraz, web-interfazeko transkodeketa-ataletik sistema-komandoak exekuta daitezke. Segurtasun arrazoiak tarteko, ezgaitzea gomendatzen dugu, eta transkodeketa-aukerak konfiguratzen ari zarenean bakarrik gaitzea.",
"songsAddedToPlaylist": "Abesti bat zerrendara gehitu da |||| %{smart_count} abesti zerrendara gehitu dira",
"noPlaylistsAvailable": "Ez dago zerrendarik erabilgarri",
"delete_user_title": "Ezabatu '%{name}' erabiltzailea",
"delete_user_content": "Ziur zaide erabiltzaile hau eta bere datu guztiak (zerrendak eta hobespenak barne) ezabatu nahi dituzula?",
"notifications_blocked": "Nabigatzaileak jakinarazpenak blokeatzen ditu",
"notifications_not_available": "Nabigatzaile hau ez da jakinarazpenekin bateragarria edo Navidrome ez da HTTPS erabiltzen ari",
"lastfmLinkSuccess": "Last.fm konektatuta dago eta erabiltzailearen ohiturak hirugarrenen zerbitzuekin partekatzea gaituta dago",
"lastfmLinkFailure": "Ezin izan da Last.fm-rekin konektatu",
"lastfmUnlinkSuccess": "Last.fm deskonektatu da eta erabiltzailearen ohiturak hirugarrenen zerbitzuekin partekatzea ezgaitu da",
"lastfmUnlinkFailure": "Ezin izan da Last.fm deskonektatu",
"openIn": {
"lastfm": "Ikusi Last.fm-n",
"musicbrainz": "Ikusi MusicBrainz-en"
},
"lastfmLink": "Irakurri gehiago…",
"listenBrainzLinkSuccess": "Ondo konektatu da ListenBrainz-ekin eta %{user} erabiltzailearen ohiturak hirugarrenen zerbitzuekin partekatzea aktibatu da",
"listenBrainzLinkFailure": "Ezin izan da ListenBrainz-ekin konektatu: %{error}",
"listenBrainzUnlinkSuccess": "ListenBrainz deskonektatu da eta erabiltzailearen ohiturak hirugarrenen zerbitzuekin partekatzea desaktibatu da",
"listenBrainzUnlinkFailure": "Ezin izan da ListenBrainz deskonektatu",
"downloadOriginalFormat": "Deskargatu jatorrizko formatua",
"shareOriginalFormat": "Partekatu jatorrizko formatua",
"shareDialogTitle": "Partekatu '%{name}' %{resource}",
"shareBatchDialogTitle": "Partekatu %{resource} bat |||| Partekatu %{smart_count} %{resource}",
"shareSuccess": "URLa arbelera kopiatu da: %{url}",
"shareFailure": "Errorea %{url} URLa arbelera kopiatzean",
"downloadDialogTitle": "Deskargatu '%{name}' %{resource}, (%{size})",
"shareCopyToClipboard": "Kopiatu arbelera: Ktrl + C, Sartu tekla",
"remove_missing_title": "",
"remove_missing_content": ""
},
"menu": {
"library": "Liburutegia",
"settings": "Ezarpenak",
"version": "Bertsioa",
"theme": "Itxura",
"personal": {
"name": "Pertsonala",
"options": {
"theme": "Itxura",
"language": "Hizkuntza",
"defaultView": "Bista, defektuz",
"desktop_notifications": "Mahaigaineko jakinarazpenak",
"lastfmScrobbling": "Bidali Last.fm-ra erabiltzailearen ohiturak",
"listenBrainzScrobbling": "Bidali ListenBrainz-era erabiltzailearen ohiturak",
"replaygain": "ReplayGain modua",
"preAmp": "ReplayGain PreAmp (dB)",
"gain": {
"none": "Bat ere ez",
"album": "Albuma",
"track": "Pista"
},
"lastfmNotConfigured": ""
}
},
"albumList": "Albumak",
"about": "Honi buruz",
"playlists": "Zerrendak",
"sharedPlaylists": "Partekatutako erreprodukzio-zerrendak"
},
"player": {
"playListsText": "Erreprodukzio-zerrenda",
"openText": "Ireki",
"closeText": "Itxi",
"notContentText": "Ez dago musikarik",
"clickToPlayText": "Egin klik erreproduzitzeko",
"clickToPauseText": "Egin klik eteteko",
"nextTrackText": "Hurrengo pista",
"previousTrackText": "Aurreko pista",
"reloadText": "Freskatu",
"volumeText": "Bolumena",
"toggleLyricText": "Erakutsi letrak",
"toggleMiniModeText": "Ikonotu",
"destroyText": "Suntsitu",
"downloadText": "Deskargatu",
"removeAudioListsText": "Ezabatu audio-zerrendak",
"clickToDeleteText": "Egin klik %{name} ezabatzeko",
"emptyLyricText": "Ez dago letrarik",
"playModeText": {
"order": "Ordenean",
"orderLoop": "Errepikatu",
"singleLoop": "Errepikatu bakarra",
"shufflePlay": "Aleatorioa"
}
},
"about": {
"links": {
"homepage": "Hasierako orria",
"source": "Iturburu kodea",
"featureRequests": "Eskatu ezaugarria",
"lastInsightsCollection": "",
"insights": {
"disabled": "",
"waiting": ""
}
}
},
"activity": {
"title": "Ekintzak",
"totalScanned": "Arakatutako karpeta guztiak",
"quickScan": "Arakatze azkarra",
"fullScan": "Arakatze sakona",
"serverUptime": "Zerbitzariak piztuta daraman denbora",
"serverDown": "LINEAZ KANPO"
},
"help": {
"title": "Navidromeren laster-teklak",
"hotkeys": {
"show_help": "Erakutsi laguntza",
"toggle_menu": "Alboko barra bai / ez",
"toggle_play": "Erreproduzitu / Eten",
"prev_song": "Aurreko abestia",
"next_song": "Hurrengo abestia",
"vol_up": "Igo bolumena",
"vol_down": "Jaitsi bolumena",
"toggle_love": "Abestia gogoko bai / ez",
"current_song": "Uneko abestia"
}
}
}

View file

@ -26,7 +26,13 @@
"bpm": "BPM",
"playDate": "Último reproducido",
"channels": "Canles",
"createdAt": "Engadido"
"createdAt": "Engadido",
"grouping": "Grupos",
"mood": "Estado",
"participants": "Participantes adicionais",
"tags": "Etiquetas adicionais",
"mappedTags": "",
"rawTags": "Etiquetas en cru"
},
"actions": {
"addToQueue": "Ao final da cola",
@ -58,7 +64,13 @@
"originalDate": "Orixinal",
"releaseDate": "Publicado",
"releases": "Publicación ||| Publicacións",
"released": "Publicado"
"released": "Publicado",
"recordLabel": "Editorial",
"catalogNum": "Número de catálogo",
"releaseType": "Tipo",
"grouping": "Grupos",
"media": "Multimedia",
"mood": "Estado"
},
"actions": {
"playAll": "Reproducir",
@ -89,7 +101,23 @@
"playCount": "Reproducións",
"rating": "Valoración",
"genre": "Xénero",
"size": "Tamaño"
"size": "Tamaño",
"role": "Rol"
},
"roles": {
"albumartist": "Artista do álbum |||| Artistas do álbum",
"artist": "Artista |||| Artistas",
"composer": "Composición |||| Composición",
"conductor": "Condutor |||| Condutoras",
"lyricist": "Letrista |||| Letristas",
"arranger": "Arranxos |||| Arranxos",
"producer": "Produtora |||| Produtoras",
"director": "Dirección |||| Dirección",
"engineer": "Enxeñería |||| Enxeñería",
"mixer": "Mistura |||| Mistura",
"remixer": "Remezcla |||| Remezcla",
"djmixer": "Mezcla DJs |||| Mezcla DJs",
"performer": "Intérprete |||| Intérpretes"
}
},
"user": {
@ -198,6 +226,20 @@
"createdAt": "Creada o",
"downloadable": "Permitir descargas?"
}
},
"missing": {
"name": "Falta o ficheiro |||| Faltan os ficheiros",
"fields": {
"path": "Ruta",
"size": "Tamaño",
"updatedAt": "Desapareceu o"
},
"actions": {
"remove": "Retirar"
},
"notifications": {
"removed": "Ficheiro(s) faltantes retirados"
}
}
},
"ra": {
@ -212,7 +254,8 @@
"password": "Contrasinal",
"sign_in": "Accede",
"sign_in_error": "Fallou a autenticación, volve intentalo",
"logout": "Pechar sesión"
"logout": "Pechar sesión",
"insightsCollectionNote": "Navidrome recolle datos anónimos de uso para mellorar o proxecto. Peme [aquí] para saber máis e desactivar se queres"
},
"validation": {
"invalidChars": "Utiliza só letras e números",
@ -374,7 +417,9 @@
"shareSuccess": "URL copiado ao portapapeis: %{url}",
"shareFailure": "Erro ao copiar o URL %{url} ao portapapeis",
"downloadDialogTitle": "Descargar %{resource} '%{name}' (%{size})",
"shareCopyToClipboard": "Copiar ao portapapeis: Ctrl+C, Enter"
"shareCopyToClipboard": "Copiar ao portapapeis: Ctrl+C, Enter",
"remove_missing_title": "Retirar ficheiros que faltan",
"remove_missing_content": "Tes certeza de querer retirar da base de datos os ficheiros que faltan? Isto retirará de xeito permanente todas a referencias a eles, incluíndo a conta de reproducións e valoracións."
},
"menu": {
"library": "Biblioteca",
@ -396,7 +441,8 @@
"none": "Desactivada",
"album": "Usar ganancia do Álbum",
"track": "Usar ganancia da Canción"
}
},
"lastfmNotConfigured": "Clave da API Last.fm non configurada"
}
},
"albumList": "Álbums",
@ -433,7 +479,12 @@
"links": {
"homepage": "Inicio",
"source": "Código fonte",
"featureRequests": "Solicitar funcións"
"featureRequests": "Solicitar funcións",
"lastInsightsCollection": "Última colección insights",
"insights": {
"disabled": "Desactivado",
"waiting": "Agardando"
}
}
},
"activity": {

View file

@ -1,460 +1,512 @@
{
"languageName": "Bahasa Indonesia",
"resources": {
"song": {
"name": "Lagu |||| Lagu",
"fields": {
"albumArtist": "Artis Album",
"duration": "Durasi",
"trackNumber": "#",
"playCount": "Dimainkan",
"title": "Judul",
"artist": "Artis",
"album": "Album",
"path": "Jalur file",
"genre": "Genre",
"compilation": "Kompilasi",
"year": "Tahun",
"size": "Ukuran file",
"updatedAt": "Diperbarui pada",
"bitRate": "Laju bit",
"discSubtitle": "Subtitle Disk",
"starred": "Favorit",
"comment": "Komentar",
"rating": "Peringkat",
"quality": "Kualitas",
"bpm": "BPM",
"playDate": "Terakhir Dimainkan",
"channels": "Saluran",
"createdAt": "Tgl. Ditambahkan"
},
"actions": {
"addToQueue": "Tambah ke antrean",
"playNow": "Mainkan sekarang",
"addToPlaylist": "Tambahkan ke Playlist",
"shuffleAll": "Mainkan Acak",
"download": "Unduh",
"playNext": "Mainkan selanjutnya",
"info": "Lihat Info"
}
},
"album": {
"name": "Album |||| Album",
"fields": {
"albumArtist": "Artis Album",
"artist": "Artis",
"duration": "Durasi",
"songCount": "Lagu",
"playCount": "Dimainkan",
"name": "Nama",
"genre": "Genre",
"compilation": "Kompilasi",
"year": "Tahun",
"updatedAt": "Diperbarui pada",
"comment": "Komentar",
"rating": "Peringkat",
"createdAt": "Tgl. Ditambahkan",
"size": "Ukuran",
"originalDate": "Tanggal",
"releaseDate": "Rilis",
"releases": "Rilis |||| Rilis",
"released": "Dirilis"
},
"actions": {
"playAll": "Mainkan",
"playNext": "Mainkan selanjutnya",
"addToQueue": "Tambah ke antrean",
"shuffle": "Acak",
"addToPlaylist": "Tambahkan ke Playlist",
"download": "Unduh",
"info": "Lihat Info",
"share": "Bagikan"
},
"lists": {
"all": "Semua",
"random": "Acak",
"recentlyAdded": "Terakhir Ditambahkan",
"recentlyPlayed": "Terakhir Dimainkan",
"mostPlayed": "Sering Dimainkan",
"starred": "Favorit",
"topRated": "Peringkat Teratas"
}
},
"artist": {
"name": "Artis |||| Artis",
"fields": {
"name": "Nama",
"albumCount": "Jumlah Album",
"songCount": "Jumlah Lagu",
"playCount": "Dimainkan",
"rating": "Peringkat",
"genre": "Genre",
"size": "Ukuran"
}
},
"user": {
"name": "Pengguna |||| Pengguna",
"fields": {
"userName": "Nama Pengguna",
"isAdmin": "Admin",
"lastLoginAt": "Terakhir Login",
"updatedAt": "Diperbarui pada",
"name": "Nama",
"password": "Kata Sandi",
"createdAt": "Dibuat pada",
"changePassword": "Ganti Kata Sandi?",
"currentPassword": "Kata Sandi Sebelumnya",
"newPassword": "Kata Sandi Baru",
"token": "Token"
},
"helperTexts": {
"name": "Perubahan pada nama Kamu akan terlihat pada login berikutnya"
},
"notifications": {
"created": "Pengguna dibuat",
"updated": "Pengguna diperbarui",
"deleted": "Pengguna dihapus"
},
"message": {
"listenBrainzToken": "Masukkan token pengguna ListenBrainz Kamu.",
"clickHereForToken": "Klik di sini untuk mendapatkan token ListenBrainz"
}
},
"player": {
"name": "Pemutar |||| Pemutar",
"fields": {
"name": "Nama",
"transcodingId": "Transkode",
"maxBitRate": "Maks. Laju Bit",
"client": "Klien",
"userName": "Nama Pengguna",
"lastSeen": "Terakhir Terlihat Pada",
"reportRealPath": "Laporkan Jalur Sebenarnya",
"scrobbleEnabled": "Kirim Scrobbles ke layanan eksternal"
}
},
"transcoding": {
"name": "Transkode |||| Transkode",
"fields": {
"name": "Nama",
"targetFormat": "Target Format",
"defaultBitRate": "Laju Bit Bawaan",
"command": "Perintah"
}
},
"playlist": {
"name": "Playlist |||| Playlist",
"fields": {
"name": "Nama",
"duration": "Durasi",
"ownerName": "Pemilik",
"public": "Publik",
"updatedAt": "Diperbarui pada",
"createdAt": "Dibuat pada",
"songCount": "Lagu",
"comment": "Komentar",
"sync": "Impor Otomatis",
"path": "Impor Dari"
},
"actions": {
"selectPlaylist": "Pilih playlist:",
"addNewPlaylist": "Buat \"%{name}\"",
"export": "Ekspor",
"makePublic": "Jadikan Publik",
"makePrivate": "Jadikan Pribadi"
},
"message": {
"duplicate_song": "Tambahkan lagu duplikat",
"song_exist": "Ada lagu duplikat yang ditambahkan ke daftar putar. Apakah Kamu ingin menambahkan lagu duplikat atau melewatkannya?"
}
},
"radio": {
"name": "Radio |||| Radio",
"fields": {
"name": "Nama",
"streamUrl": "URL Sumber",
"homePageUrl": "Halaman Beranda URL",
"updatedAt": "Diperbarui pada",
"createdAt": "Dibuat pada"
},
"actions": {
"playNow": "Mainkan sekarang"
}
},
"share": {
"name": "Bagikan |||| Bagikan",
"fields": {
"username": "Dibagikan Oleh",
"url": "URL",
"description": "Deskripsi",
"contents": "Konten",
"expiresAt": "Berakhir",
"lastVisitedAt": "Terakhir Dikunjungi",
"visitCount": "Pengunjung",
"format": "Format",
"maxBitRate": "Maks. Laju Bit",
"updatedAt": "Diperbarui pada",
"createdAt": "Dibuat pada",
"downloadable": "Izinkan Pengunduhan?"
}
}
"languageName": "Bahasa Indonesia",
"resources": {
"song": {
"name": "Lagu |||| Lagu",
"fields": {
"albumArtist": "Artis Album",
"duration": "Durasi",
"trackNumber": "#",
"playCount": "Diputar",
"title": "Judul",
"artist": "Artis",
"album": "Album",
"path": "Lokasi file",
"genre": "Genre",
"compilation": "Kompilasi",
"year": "Tahun",
"size": "Ukuran file",
"updatedAt": "Diperbarui pada",
"bitRate": "Bit rate",
"discSubtitle": "Subtitle Disk",
"starred": "Favorit",
"comment": "Komentar",
"rating": "Peringkat",
"quality": "Kualitas",
"bpm": "BPM",
"playDate": "Terakhir Diputar",
"channels": "Saluran",
"createdAt": "Tgl. Ditambahkan",
"grouping": "Mengelompokkan",
"mood": "Mood",
"participants": "Partisipan tambahan",
"tags": "Tag tambahan",
"mappedTags": "Tag yang dipetakan",
"rawTags": "Tag raw"
},
"actions": {
"addToQueue": "Tambah ke antrean",
"playNow": "Putar sekarang",
"addToPlaylist": "Tambahkan ke Playlist",
"shuffleAll": "Acak Semua",
"download": "Unduh",
"playNext": "Putar Berikutnya",
"info": "Lihat Info"
}
},
"ra": {
"auth": {
"welcome1": "Terima kasih telah menginstal Navidrome!",
"welcome2": "Untuk memulai, buat dulu akun admin",
"confirmPassword": "Konfirmasi Kata Sandi",
"buttonCreateAdmin": "Buat Akun Admin",
"auth_check_error": "Silahkan masuk untuk melanjutkan",
"user_menu": "Profil",
"username": "Nama Pengguna",
"password": "Kata Sandi",
"sign_in": "Masuk",
"sign_in_error": "Otentikasi gagal, silakan coba lagi",
"logout": "Keluar"
},
"validation": {
"invalidChars": "Harap menggunakan huruf dan angka saja",
"passwordDoesNotMatch": "Kata sandi tidak cocok",
"required": "Wajib",
"minLength": "Setidaknya harus %{min} karakter",
"maxLength": "Harus berisi %{max} karakter atau kurang",
"minValue": "Minimal harus %{min}",
"maxValue": "Harus %{max} atau kurang",
"number": "Harus berupa angka",
"email": "Harus berupa email yang valid",
"oneOf": "Harus salah satu dari: %{options}",
"regex": "Harus cocok dengan format spesifik (regexp): %{pattern}",
"unique": "Harus unik",
"url": "Harus berupa URL yang valid"
},
"action": {
"add_filter": "Tambah filter",
"add": "Tambah",
"back": "Kembali",
"bulk_actions": "1 item dipilih |||| %{smart_count} item dipilih",
"cancel": "Batalkan",
"clear_input_value": "Hapus",
"clone": "Klon",
"confirm": "Konfirmasi",
"create": "Buat",
"delete": "Hapus",
"edit": "Edit",
"export": "Ekspor",
"list": "Daftar",
"refresh": "Refresh",
"remove_filter": "Hapus filter ini",
"remove": "Hapus",
"save": "Simpan",
"search": "Cari",
"show": "Tunjukkan",
"sort": "Sortir",
"undo": "Batalkan",
"expand": "Luaskan",
"close": "Tutup",
"open_menu": "Buka menu",
"close_menu": "Tutup menu",
"unselect": "Batalkan pilihan",
"skip": "Lewati",
"bulk_actions_mobile": "1 |||| %{smart_count}",
"share": "Bagikan",
"download": "Unduh"
},
"boolean": {
"true": "Ya",
"false": "Tidak"
},
"page": {
"create": "Buat %{name}",
"dashboard": "Dashboard",
"edit": "%{name} #%{id}",
"error": "Ada yang tidak beres",
"list": "%{name}",
"loading": "Memuat",
"not_found": "Tidak ditemukan",
"show": "%{name} #%{id}",
"empty": "Belum ada %{name}.",
"invite": "Apakah Kamu ingin menambahkan satu?"
},
"input": {
"file": {
"upload_several": "Letakkan beberapa file untuk diunggah, atau klik untuk memilih salah satu.",
"upload_single": "Letakkan file untuk diunggah, atau klik untuk memilihnya."
},
"image": {
"upload_several": "Letakkan beberapa gambar untuk diunggah, atau klik untuk memilih salah satu.",
"upload_single": "Letakkan gambar untuk diunggah, atau klik untuk memilihnya."
},
"references": {
"all_missing": "Tidak dapat menemukan data referensi.",
"many_missing": "Tampaknya beberapa referensi tidak tersedia.",
"single_missing": "Tampaknya referensi tidak tersedia."
},
"password": {
"toggle_visible": "Sembunyikan Kata Sandi",
"toggle_hidden": "Tampilkan Kata Sandi"
}
},
"message": {
"about": "Tentang",
"are_you_sure": "Kamu Yakin?",
"bulk_delete_content": "Kamu yakin ingin menghapus %{name} ini? |||| Kamu yakin ingin menghapus %{smart_count} item ini?",
"bulk_delete_title": "Hapus %{name} |||| Hapus %{smart_count} %{name}",
"delete_content": "Kamu ingin menghapus item ini?",
"delete_title": "Hapus %{name} #%{id}",
"details": "Detail",
"error": "Terjadi kesalahan klien dan permintaan Kamu tidak dapat diselesaikan.",
"invalid_form": "Formulirnya tidak valid. Silakan periksa kesalahannya",
"loading": "Halaman sedang dimuat, mohon tunggu sebentar",
"no": "Tidak",
"not_found": "Mungkin Kamu mengetik URL yang salah, atau Kamu mengikuti tautan yang buruk.",
"yes": "Ya",
"unsaved_changes": "Beberapa perubahan tidak disimpan. Apakah Kamu yakin ingin mengabaikannya?"
},
"navigation": {
"no_results": "Tidak ada hasil yang ditemukan",
"no_more_results": "Nomor halaman %{page} melampaui batas. Coba halaman sebelumnya.",
"page_out_of_boundaries": "Nomor halaman %{page} melampaui batas",
"page_out_from_end": "Tidak dapat menelusuri sebelum halaman terakhir",
"page_out_from_begin": "Tidak dapat menelusuri sebelum halaman 1",
"page_range_info": "%{offsetBegin}-%{offsetEnd} dari %{total}",
"page_rows_per_page": "Item per halaman:",
"next": "Selanjutnya",
"prev": "Sebelumnya",
"skip_nav": "Lewati ke konten"
},
"notification": {
"updated": "Elemen diperbarui |||| %{smart_count} elemen diperbarui",
"created": "Elemen dibuat",
"deleted": "Elemen dihapus |||| %{smart_count} elemen dihapus",
"bad_item": "Elemen salah",
"item_doesnt_exist": "Tidak ada elemen",
"http_error": "Kesalahan komunikasi server",
"data_provider_error": "dataProvider galat. Periksa konsol untuk detailnya.",
"i18n_error": "Tidak dapat memuat terjemahan untuk bahasa yang diatur",
"canceled": "Tindakan dibatalkan",
"logged_out": "Sesi Kamu telah berakhir, harap sambungkan kembali.",
"new_version": "Tersedia versi baru! Silakan menyegarkan jendela ini."
},
"toggleFieldsMenu": {
"columnsToDisplay": "Kolom Untuk Ditampilkan",
"layout": "Layout",
"grid": "Grid",
"table": "Tabel"
}
"album": {
"name": "Album |||| Album",
"fields": {
"albumArtist": "Artis Album",
"artist": "Artis",
"duration": "Durasi",
"songCount": "Lagu",
"playCount": "Diputar",
"name": "Nama",
"genre": "Genre",
"compilation": "Kompilasi",
"year": "Tahun",
"updatedAt": "Diperbarui pada",
"comment": "Komentar",
"rating": "Peringkat",
"createdAt": "Tgl. Ditambahkan",
"size": "Ukuran",
"originalDate": "Tanggal",
"releaseDate": "Dirilis",
"releases": "Rilis |||| Rilis",
"released": "Dirilis",
"recordLabel": "Label",
"catalogNum": "Nomer Katalog",
"releaseType": "Tipe",
"grouping": "Pengelompokkan",
"media": "Media",
"mood": "Mood"
},
"actions": {
"playAll": "Putar",
"playNext": "Putar Selanjutnya",
"addToQueue": "Putar Nanti",
"shuffle": "Acak",
"addToPlaylist": "Tambahkan ke Playlist",
"download": "Unduh",
"info": "Lihat Info",
"share": "Bagikan"
},
"lists": {
"all": "Semua",
"random": "Acak",
"recentlyAdded": "Terakhir Ditambahkan",
"recentlyPlayed": "Terakhir Diputar",
"mostPlayed": "Sering Diputar",
"starred": "Favorit",
"topRated": "Peringkat Teratas"
}
},
"message": {
"note": "CATATAN",
"transcodingDisabled": "Mengubah konfigurasi transkode melalui antarmuka web dinonaktifkan karena alasan keamanan. Jika Kamu ingin mengubah (mengedit atau menambahkan) opsi transkode, restart server dengan opsi konfigurasi %{config}.",
"transcodingEnabled": "Navidrome saat ini berjalan dengan %{config}, sehingga memungkinkan untuk menjalankan perintah sistem dari pengaturan Transkode menggunakan antarmuka web. Kami sarankan untuk menonaktifkannya demi alasan keamanan dan hanya mengaktifkannya saat mengonfigurasi opsi Transcoding.",
"songsAddedToPlaylist": "Menambahkan 1 lagu ke playlist |||| Menambahkan %{smart_count} lagu ke playlist",
"noPlaylistsAvailable": "Tidak tersedia",
"delete_user_title": "Hapus pengguna '%{name}'",
"delete_user_content": "Apakah Kamu yakin ingin menghapus pengguna ini dan semua datanya (termasuk daftar putar dan preferensi)?",
"notifications_blocked": "Kamu telah memblokir Notifikasi untuk situs ini di pengaturan browser Anda",
"notifications_not_available": "Browser ini tidak mendukung notifikasi desktop atau Kamu tidak mengakses Navidrome melalui https",
"lastfmLinkSuccess": "Last.fm berhasil ditautkan dan scrobbling diaktifkan",
"lastfmLinkFailure": "Last.fm tidak dapat ditautkan",
"lastfmUnlinkSuccess": "Tautan Last.fm dibatalkan dan scrobbling dinonaktifkan",
"lastfmUnlinkFailure": "Tautan Last.fm tidak dapat dibatalkan",
"openIn": {
"lastfm": "Lihat di Last.fm",
"musicbrainz": "Lihat di MusicBrainz"
},
"lastfmLink": "Baca selengkapnya...",
"listenBrainzLinkSuccess": "ListenBrainz berhasil ditautkan dan scrobbling diaktifkan sebagai pengguna: %{user}",
"listenBrainzLinkFailure": "ListenBrainz tidak dapat ditautkan: %{error}",
"listenBrainzUnlinkSuccess": "Tautan ListenBrainz dibatalkan dan scrobbling dinonaktifkan",
"listenBrainzUnlinkFailure": "Tautan ListenBrainz tidak dapat dibatalkan",
"downloadOriginalFormat": "Unduh dalam format asli",
"shareOriginalFormat": "Bagikan dalam format asli",
"shareDialogTitle": "Bagikan %{resource} '%{name}'",
"shareBatchDialogTitle": "Bagikan 1 %{resource} |||| Bagikan %{smart_count} %{resource}",
"shareSuccess": "URL disalin ke papan klip: %{url}",
"shareFailure": "Terjadi kesalahan saat menyalin URL %{url} ke papan klip",
"downloadDialogTitle": "Unduh %{resource} '%{name}' (%{size})",
"shareCopyToClipboard": "Salin ke papan klip: Ctrl+C, Enter"
"artist": {
"name": "Artis |||| Artis",
"fields": {
"name": "Nama",
"albumCount": "Jumlah Album",
"songCount": "Jumlah Lagu",
"playCount": "Diputar",
"rating": "Peringkat",
"genre": "Genre",
"size": "Ukuran",
"role": "Peran"
},
"roles": {
"albumartist": "Artis Album |||| Artis Album",
"artist": "Artis |||| Artis",
"composer": "Komposer |||| Komposer",
"conductor": "Konduktor |||| Konduktor",
"lyricist": "Penulis Lirik |||| Penulis Lirik",
"arranger": "Arranger |||| Arranger",
"producer": "Produser |||| Produser",
"director": "Director |||| Director",
"engineer": "Engineer |||| Engineer",
"mixer": "Mixer |||| Mixer",
"remixer": "Remixer |||| Remixer",
"djmixer": "DJ Mixer |||| Dj Mixer",
"performer": "Performer |||| Performer"
}
},
"menu": {
"library": "Perpustakaan",
"settings": "Pengaturan",
"version": "Versi",
"theme": "Tema",
"personal": {
"name": "Personal",
"options": {
"theme": "Tema",
"language": "Bahasa",
"defaultView": "Tampilan Bawaan",
"desktop_notifications": "Pemberitahuan Desktop",
"lastfmScrobbling": "Scrobble ke Last.fm",
"listenBrainzScrobbling": "Scrobble ke ListenBrainz",
"replaygain": "Mode ReplayGain",
"preAmp": "ReplayGain PreAmp (dB)",
"gain": {
"none": "Nonaktif",
"album": "Gunakan Gain Album",
"track": "Gunakan Gain Lagu"
}
}
},
"albumList": "Album",
"about": "Tentang",
"playlists": "Playlist",
"sharedPlaylists": "Playlist yang Dibagikan"
"user": {
"name": "Pengguna |||| Pengguna",
"fields": {
"userName": "Nama Pengguna",
"isAdmin": "Admin",
"lastLoginAt": "Terakhir Login",
"updatedAt": "Diperbarui pada",
"name": "Nama",
"password": "Kata Sandi",
"createdAt": "Dibuat pada",
"changePassword": "Ganti Kata Sandi?",
"currentPassword": "Kata Sandi Sebelumnya",
"newPassword": "Kata Sandi Baru",
"token": "Token",
"lastAccessAt": "Terakhir Diakses"
},
"helperTexts": {
"name": "Perubahan pada nama Kamu akan terlihat pada login berikutnya"
},
"notifications": {
"created": "Pengguna dibuat",
"updated": "Pengguna diperbarui",
"deleted": "Pengguna dihapus"
},
"message": {
"listenBrainzToken": "Masukkan token pengguna ListenBrainz Kamu.",
"clickHereForToken": "Klik di sini untuk mendapatkan token baru anda"
}
},
"player": {
"playListsText": "Mainkan Antrean",
"openText": "Buka text",
"closeText": "Tutup text",
"notContentText": "Tidak ada musik",
"clickToPlayText": "Klik untuk mainkan",
"clickToPauseText": "Klik untuk menjeda",
"nextTrackText": "Lagu Selanjutnya",
"previousTrackText": "Lagu Sebelumnya",
"reloadText": "Muat ulang",
"volumeText": "Volume",
"toggleLyricText": "Lirik",
"toggleMiniModeText": "Minimalkan",
"destroyText": "Tutup",
"downloadText": "Unduh",
"removeAudioListsText": "Hapus daftar audio",
"clickToDeleteText": "Klik untuk menghapus %{name}",
"emptyLyricText": "Tidak ada lirik",
"playModeText": {
"order": "Berurutan",
"orderLoop": "Ulang",
"singleLoop": "Ulangi Satu",
"shufflePlay": "Acak"
}
"name": "Pemutar |||| Pemutar",
"fields": {
"name": "Nama",
"transcodingId": "Transkode",
"maxBitRate": "Maks. Bit Rate",
"client": "Klien",
"userName": "Nama Pengguna",
"lastSeen": "Terakhir Terlihat Pada",
"reportRealPath": "Laporkan Jalur Sebenarnya",
"scrobbleEnabled": "Kirim Scrobbles ke layanan eksternal"
}
},
"about": {
"links": {
"homepage": "Halaman beranda",
"source": "Kode sumber",
"featureRequests": "Permintaan fitur"
}
"transcoding": {
"name": "Transkode |||| Transkode",
"fields": {
"name": "Nama",
"targetFormat": "Target Format",
"defaultBitRate": "Bit Rate Bawaan",
"command": "Perintah"
}
},
"activity": {
"title": "Aktivitas",
"totalScanned": "Total Folder yang Dipindai",
"quickScan": "Pemindaian Cepat",
"fullScan": "Pemindaian Penuh",
"serverUptime": "Waktu Aktif Server",
"serverDown": "OFFLINE"
"playlist": {
"name": "Playlist |||| Playlist",
"fields": {
"name": "Nama",
"duration": "Durasi",
"ownerName": "Pemilik",
"public": "Publik",
"updatedAt": "Diperbarui pada",
"createdAt": "Dibuat pada",
"songCount": "Lagu",
"comment": "Komentar",
"sync": "Impor Otomatis",
"path": "Impor Dari"
},
"actions": {
"selectPlaylist": "Pilih playlist:",
"addNewPlaylist": "Buat \"%{name}\"",
"export": "Ekspor",
"makePublic": "Jadikan Publik",
"makePrivate": "Jadikan Pribadi"
},
"message": {
"duplicate_song": "Tambahkan lagu duplikat",
"song_exist": "Ada lagu duplikat yang ditambahkan ke daftar putar. Apakah Kamu ingin menambahkan lagu duplikat atau melewatkannya?"
}
},
"help": {
"title": "Tombol Pintasan Navidrome",
"hotkeys": {
"show_help": "Tampilkan Bantuan Ini",
"toggle_menu": "Menu Samping",
"toggle_play": "Mainkan / Jeda",
"prev_song": "Lagu Sebelumnya",
"next_song": "Lagu Selanjutnya",
"vol_up": "Volume Naik",
"vol_down": "Volume Turun",
"toggle_love": "Tambahkan lagu ini ke favorit",
"current_song": "Buka Lagu Saat Ini"
}
"radio": {
"name": "Radio |||| Radio",
"fields": {
"name": "Nama",
"streamUrl": "URL Stream",
"homePageUrl": "Halaman Beranda URL",
"updatedAt": "Diperbarui pada",
"createdAt": "Dibuat pada"
},
"actions": {
"playNow": "Putar Sekarang"
}
},
"share": {
"name": "Bagikan |||| Bagikan",
"fields": {
"username": "Dibagikan Oleh",
"url": "URL",
"description": "Deskripsi",
"contents": "Konten",
"expiresAt": "Berakhir",
"lastVisitedAt": "Terakhir Dikunjungi",
"visitCount": "Pengunjung",
"format": "Format",
"maxBitRate": "Maks. Laju Bit",
"updatedAt": "Diperbarui pada",
"createdAt": "Dibuat pada",
"downloadable": "Izinkan Pengunduhan?"
}
},
"missing": {
"name": "File yang Hilang |||| File yang Hilang",
"fields": {
"path": "Jalur",
"size": "Ukuran",
"updatedAt": "Tidak muncul di"
},
"actions": {
"remove": "Hapus"
},
"notifications": {
"removed": "File yang hilang dihapus"
}
}
},
"ra": {
"auth": {
"welcome1": "Terima kasih telah menginstal Navidrome!",
"welcome2": "Untuk memulai, buat dulu akun admin",
"confirmPassword": "Konfirmasi Kata Sandi",
"buttonCreateAdmin": "Buat Akun Admin",
"auth_check_error": "Silahkan masuk untuk melanjutkan",
"user_menu": "Profil",
"username": "Nama Pengguna",
"password": "Kata Sandi",
"sign_in": "Masuk",
"sign_in_error": "Otentikasi gagal, silakan coba lagi",
"logout": "Keluar",
"insightsCollectionNote": "Navidrome mengumpulkan penggunaan data anonim untuk membantu menyempurnakan project ini. Klik [disini] untuk mempelajari lebih lanjut dan untuk opt-out jika anda mau"
},
"validation": {
"invalidChars": "Harap menggunakan huruf dan angka saja",
"passwordDoesNotMatch": "Kata sandi tidak cocok",
"required": "Wajib",
"minLength": "Setidaknya harus %{min} karakter",
"maxLength": "Harus berisi %{max} karakter atau kurang",
"minValue": "Minimal harus %{min}",
"maxValue": "Harus %{max} atau kurang",
"number": "Harus berupa angka",
"email": "Harus berupa email yang valid",
"oneOf": "Harus salah satu dari: %{options}",
"regex": "Harus cocok dengan format spesifik (regexp): %{pattern}",
"unique": "Harus unik",
"url": "Harus berupa URL yang valid"
},
"action": {
"add_filter": "Tambah filter",
"add": "Tambah",
"back": "Kembali",
"bulk_actions": "1 item dipilih |||| %{smart_count} item dipilih",
"cancel": "Batalkan",
"clear_input_value": "Hapus",
"clone": "Klon",
"confirm": "Konfirmasi",
"create": "Buat",
"delete": "Hapus",
"edit": "Sunting",
"export": "Ekspor",
"list": "Daftar",
"refresh": "Segarkan",
"remove_filter": "Hapus filter ini",
"remove": "Hapus",
"save": "Simpan",
"search": "Cari",
"show": "Tampilkan",
"sort": "Sortir",
"undo": "Batalkan",
"expand": "Luaskan",
"close": "Tutup",
"open_menu": "Buka menu",
"close_menu": "Tutup menu",
"unselect": "Batalkan pilihan",
"skip": "Lewati",
"bulk_actions_mobile": "1 |||| %{smart_count}",
"share": "Bagikan",
"download": "Unduh"
},
"boolean": {
"true": "Ya",
"false": "Tidak"
},
"page": {
"create": "Buat %{name}",
"dashboard": "Dasbor",
"edit": "%{name} #%{id}",
"error": "Ada yang tidak beres",
"list": "%{name}",
"loading": "Memuat",
"not_found": "Tidak ditemukan",
"show": "%{name} #%{id}",
"empty": "Belum ada %{name}.",
"invite": "Apakah kamu ingin menambahkan satu?"
},
"input": {
"file": {
"upload_several": "Letakkan beberapa file untuk diunggah, atau klik untuk memilih salah satu.",
"upload_single": "Letakkan file untuk diunggah, atau klik untuk memilihnya."
},
"image": {
"upload_several": "Letakkan beberapa gambar untuk diunggah, atau klik untuk memilih salah satu.",
"upload_single": "Letakkan gambar untuk diunggah, atau klik untuk memilihnya."
},
"references": {
"all_missing": "Tidak dapat menemukan data referensi.",
"many_missing": "Tampaknya beberapa referensi tidak tersedia.",
"single_missing": "Referensi yang ter asosiasi tidak tersedia untuk ditampilkan."
},
"password": {
"toggle_visible": "Sembunyikan kata sandi",
"toggle_hidden": "Tampilkan kata sandi"
}
},
"message": {
"about": "Tentang",
"are_you_sure": "Kamu Yakin?",
"bulk_delete_content": "Kamu yakin ingin menghapus %{name} ini? |||| Kamu yakin ingin menghapus %{smart_count} item ini?",
"bulk_delete_title": "Hapus %{name} |||| Hapus %{smart_count} %{name}",
"delete_content": "Kamu ingin menghapus item ini?",
"delete_title": "Hapus %{name} #%{id}",
"details": "Detail",
"error": "Terjadi kesalahan klien dan permintaan Kamu tidak dapat diselesaikan.",
"invalid_form": "Form tidak valid. Silakan periksa kesalahannya",
"loading": "Halaman sedang dimuat, mohon tunggu sebentar",
"no": "Tidak",
"not_found": "Mungkin Kamu mengetik URL yang salah, atau Kamu mengikuti tautan yang buruk.",
"yes": "Ya",
"unsaved_changes": "Beberapa perubahan tidak disimpan. Apakah Kamu yakin ingin mengabaikannya?"
},
"navigation": {
"no_results": "Tidak ada hasil yang ditemukan",
"no_more_results": "Nomor halaman %{page} melampaui batas. Coba halaman sebelumnya.",
"page_out_of_boundaries": "Nomor halaman %{page} melampaui batas",
"page_out_from_end": "Tidak dapat menelusuri sebelum halaman terakhir",
"page_out_from_begin": "Tidak dapat menelusuri sebelum halaman 1",
"page_range_info": "%{offsetBegin}-%{offsetEnd} dari %{total}",
"page_rows_per_page": "Item per halaman:",
"next": "Selanjutnya",
"prev": "Sebelumnya",
"skip_nav": "Lewati ke konten"
},
"notification": {
"updated": "Elemen diperbarui |||| %{smart_count} elemen diperbarui",
"created": "Elemen dibuat",
"deleted": "Elemen dihapus |||| %{smart_count} elemen dihapus",
"bad_item": "Elemen salah",
"item_doesnt_exist": "Tidak ada elemen",
"http_error": "Kesalahan komunikasi peladen",
"data_provider_error": "dataProvider galat. Periksa konsol untuk detailnya.",
"i18n_error": "Tidak dapat memuat terjemahan untuk bahasa yang diatur",
"canceled": "Tindakan dibatalkan",
"logged_out": "Sesi Kamu telah berakhir, harap sambungkan kembali.",
"new_version": "Tersedia versi baru! Silakan menyegarkan jendela ini."
},
"toggleFieldsMenu": {
"columnsToDisplay": "Kolom Untuk Ditampilkan",
"layout": "Tata Letak",
"grid": "Ubin",
"table": "Tabel"
}
},
"message": {
"note": "CATATAN",
"transcodingDisabled": "Mengubah konfigurasi transkode melalui antarmuka web dinonaktifkan karena alasan keamanan. Jika Kamu ingin mengubah (mengedit atau menambahkan) opsi transkode, restart server dengan opsi konfigurasi %{config}.",
"transcodingEnabled": "Navidrome saat ini berjalan dengan %{config}, sehingga memungkinkan untuk menjalankan perintah sistem dari pengaturan Transkode menggunakan antarmuka web. Kami sarankan untuk menonaktifkannya demi alasan keamanan dan hanya mengaktifkannya saat mengonfigurasi opsi Transcoding.",
"songsAddedToPlaylist": "Menambahkan 1 lagu ke playlist |||| Menambahkan %{smart_count} lagu ke playlist",
"noPlaylistsAvailable": "Tidak tersedia",
"delete_user_title": "Hapus pengguna '%{name}'",
"delete_user_content": "Apakah Kamu yakin ingin menghapus pengguna ini dan semua datanya (termasuk daftar putar dan preferensi)?",
"notifications_blocked": "Kamu telah memblokir Notifikasi untuk situs ini di pengaturan browser Anda",
"notifications_not_available": "Browser ini tidak mendukung notifikasi desktop atau Kamu tidak mengakses Navidrome melalui https",
"lastfmLinkSuccess": "Last.fm berhasil ditautkan dan scrobbling diaktifkan",
"lastfmLinkFailure": "Last.fm tidak dapat ditautkan",
"lastfmUnlinkSuccess": "Tautan Last.fm dibatalkan dan scrobbling dinonaktifkan",
"lastfmUnlinkFailure": "Tautan Last.fm tidak dapat dibatalkan",
"openIn": {
"lastfm": "Lihat di Last.fm",
"musicbrainz": "Lihat di MusicBrainz"
},
"lastfmLink": "Baca selengkapnya...",
"listenBrainzLinkSuccess": "ListenBrainz berhasil ditautkan dan scrobbling diaktifkan sebagai pengguna: %{user}",
"listenBrainzLinkFailure": "ListenBrainz tidak dapat ditautkan: %{error}",
"listenBrainzUnlinkSuccess": "Tautan ListenBrainz dibatalkan dan scrobbling dinonaktifkan",
"listenBrainzUnlinkFailure": "Tautan ListenBrainz tidak dapat dibatalkan",
"downloadOriginalFormat": "Unduh dalam format asli",
"shareOriginalFormat": "Bagikan dalam format asli",
"shareDialogTitle": "Bagikan %{resource} '%{name}'",
"shareBatchDialogTitle": "Bagikan 1 %{resource} |||| Bagikan %{smart_count} %{resource}",
"shareSuccess": "URL disalin ke papan klip: %{url}",
"shareFailure": "Terjadi kesalahan saat menyalin URL %{url} ke papan klip",
"downloadDialogTitle": "Unduh %{resource} '%{name}' (%{size})",
"shareCopyToClipboard": "Salin ke papan klip: Ctrl+C, Enter",
"remove_missing_title": "Hapus file yang hilang",
"remove_missing_content": "Apakah Anda yakin ingin menghapus file-file yang hilang dari basis data? Tindakan ini akan menghapus secara permanen semua referensi ke file-file tersebut, termasuk jumlah pemutaran dan peringkatnya."
},
"menu": {
"library": "Pustaka",
"settings": "Pengaturan",
"version": "Versi",
"theme": "Tema",
"personal": {
"name": "Personal",
"options": {
"theme": "Tema",
"language": "Bahasa",
"defaultView": "Tampilan Bawaan",
"desktop_notifications": "Pemberitahuan Desktop",
"lastfmScrobbling": "Scrobble ke Last.fm",
"listenBrainzScrobbling": "Scrobble ke ListenBrainz",
"replaygain": "Mode ReplayGain",
"preAmp": "ReplayGain PreAmp (dB)",
"gain": {
"none": "Nonaktif",
"album": "Gunakan Gain Album",
"track": "Gunakan Gain Lagu"
},
"lastfmNotConfigured": "API-Key Last.fm belum dikonfigurasi"
}
},
"albumList": "Album",
"about": "Tentang",
"playlists": "Playlist",
"sharedPlaylists": "Playlist yang Dibagikan"
},
"player": {
"playListsText": "Mainkan Antrean",
"openText": "Buka",
"closeText": "Tutup",
"notContentText": "Tidak ada musik",
"clickToPlayText": "Klik untuk memutar",
"clickToPauseText": "Klik untuk menjeda",
"nextTrackText": "Lagu Selanjutnya",
"previousTrackText": "Lagu Sebelumnya",
"reloadText": "Muat ulang",
"volumeText": "Volume",
"toggleLyricText": "Lirik",
"toggleMiniModeText": "Minimalkan",
"destroyText": "Tutup",
"downloadText": "Unduh",
"removeAudioListsText": "Hapus daftar audio",
"clickToDeleteText": "Klik untuk menghapus %{name}",
"emptyLyricText": "Tidak ada lirik",
"playModeText": {
"order": "Berurutan",
"orderLoop": "Ulang",
"singleLoop": "Ulangi Satu",
"shufflePlay": "Acak"
}
},
"about": {
"links": {
"homepage": "Halaman beranda",
"source": "Kode sumber",
"featureRequests": "Permintaan fitur",
"lastInsightsCollection": "Koleksi insight terakhir",
"insights": {
"disabled": "Nonaktifkan",
"waiting": "Menunggu"
}
}
},
"activity": {
"title": "Aktivitas",
"totalScanned": "Total Folder yang Dipindai",
"quickScan": "Pemindaian Cepat",
"fullScan": "Pemindaian Penuh",
"serverUptime": "Waktu Aktif Peladen",
"serverDown": "LURING"
},
"help": {
"title": "Tombol Pintasan Navidrome",
"hotkeys": {
"show_help": "Tampilkan Bantuan Ini",
"toggle_menu": "Menu Samping",
"toggle_play": "Putar / Jeda",
"prev_song": "Lagu Sebelumnya",
"next_song": "Lagu Selanjutnya",
"vol_up": "Volume Naik",
"vol_down": "Volume Turun",
"toggle_love": "Tambahkan lagu ini ke favorit",
"current_song": "Buka Lagu Saat Ini"
}
}
}

View file

@ -26,7 +26,13 @@
"bpm": "BPM",
"playDate": "最後の再生",
"channels": "チャンネル",
"createdAt": "追加日"
"createdAt": "追加日",
"grouping": "",
"mood": "",
"participants": "",
"tags": "",
"mappedTags": "",
"rawTags": ""
},
"actions": {
"addToQueue": "最後に再生",
@ -58,7 +64,13 @@
"originalDate": "オリジナルの日付",
"releaseDate": "リリース日",
"releases": "リリース",
"released": "リリース"
"released": "リリース",
"recordLabel": "",
"catalogNum": "",
"releaseType": "",
"grouping": "",
"media": "",
"mood": ""
},
"actions": {
"playAll": "再生",
@ -89,7 +101,23 @@
"playCount": "再生数",
"rating": "レート",
"genre": "ジャンル",
"size": "サイズ"
"size": "サイズ",
"role": ""
},
"roles": {
"albumartist": "",
"artist": "",
"composer": "",
"conductor": "",
"lyricist": "",
"arranger": "",
"producer": "",
"director": "",
"engineer": "",
"mixer": "",
"remixer": "",
"djmixer": "",
"performer": ""
}
},
"user": {
@ -198,6 +226,20 @@
"createdAt": "作成日",
"downloadable": "ダウンロードを許可しますか?"
}
},
"missing": {
"name": "",
"fields": {
"path": "",
"size": "",
"updatedAt": ""
},
"actions": {
"remove": ""
},
"notifications": {
"removed": ""
}
}
},
"ra": {
@ -212,7 +254,8 @@
"password": "パスワード",
"sign_in": "ログイン",
"sign_in_error": "認証に失敗しました。入力を確認してください",
"logout": "ログアウト"
"logout": "ログアウト",
"insightsCollectionNote": "Navidromeでは、プロジェクトの改善に役立てるため、匿名の利用データを収集しています。詳しくは [here] をクリックしてください。"
},
"validation": {
"invalidChars": "文字と数字のみを使用してください",
@ -374,7 +417,9 @@
"shareSuccess": "コピーしました: %{url}",
"shareFailure": "コピーに失敗しました %{url}",
"downloadDialogTitle": "ダウンロード %{resource} '%{name}' (%{size})",
"shareCopyToClipboard": "クリップボードへコピー: Ctrl+C, Enter"
"shareCopyToClipboard": "クリップボードへコピー: Ctrl+C, Enter",
"remove_missing_title": "",
"remove_missing_content": ""
},
"menu": {
"library": "ライブラリ",
@ -396,7 +441,8 @@
"none": "無効",
"album": "アルバムゲインを使う",
"track": "トラックゲインを使う"
}
},
"lastfmNotConfigured": "Last.fmのAPIキーが設定されていません"
}
},
"albumList": "アルバム",
@ -433,7 +479,12 @@
"links": {
"homepage": "ホームページ",
"source": "ソースコード",
"featureRequests": "機能リクエスト"
"featureRequests": "機能リクエスト",
"lastInsightsCollection": "最後のデータ収集",
"insights": {
"disabled": "無効",
"waiting": "待機中"
}
}
},
"activity": {

View file

@ -30,7 +30,9 @@
"grouping": "Agrupamento",
"mood": "Mood",
"participants": "Outros Participantes",
"tags": "Outras Tags"
"tags": "Outras Tags",
"mappedTags": "Tags mapeadas",
"rawTags": "Tags originais"
},
"actions": {
"addToQueue": "Adicionar à fila",
@ -124,7 +126,6 @@
"userName": "Usuário",
"isAdmin": "Admin?",
"lastLoginAt": "Últ. Login",
"lastAccessAt": "Últ. Acesso",
"updatedAt": "Últ. Atualização",
"name": "Nome",
"password": "Senha",
@ -132,7 +133,8 @@
"changePassword": "Trocar Senha?",
"currentPassword": "Senha Atual",
"newPassword": "Nova Senha",
"token": "Token"
"token": "Token",
"lastAccessAt": "Últ. Acesso"
},
"helperTexts": {
"name": "Alterações no seu nome só serão refletidas no próximo login"
@ -393,8 +395,6 @@
"noPlaylistsAvailable": "Nenhuma playlist",
"delete_user_title": "Excluir usuário '%{name}'",
"delete_user_content": "Você tem certeza que deseja excluir o usuário e todos os seus dados (incluindo suas playlists e preferências)?",
"remove_missing_title": "Remover arquivos ausentes",
"remove_missing_content": "Você tem certeza que deseja remover os arquivos selecionados do banco de dados? Isso removerá permanentemente qualquer referência a eles, incluindo suas contagens de reprodução e classificações.",
"notifications_blocked": "Você bloqueou notificações para este site nas configurações do seu browser",
"notifications_not_available": "Este navegador não suporta notificações",
"lastfmLinkSuccess": "Sua conta no Last.fm foi conectada com sucesso",
@ -417,7 +417,9 @@
"shareSuccess": "Link copiado para o clipboard : %{url}",
"shareFailure": "Erro ao copiar o link %{url} para o clipboard",
"downloadDialogTitle": "Baixar %{resource} '%{name}' (%{size})",
"shareCopyToClipboard": "Copie para o clipboard: Ctrl+C, Enter"
"shareCopyToClipboard": "Copie para o clipboard: Ctrl+C, Enter",
"remove_missing_title": "Remover arquivos ausentes",
"remove_missing_content": "Você tem certeza que deseja remover os arquivos selecionados do banco de dados? Isso removerá permanentemente qualquer referência a eles, incluindo suas contagens de reprodução e classificações."
},
"menu": {
"library": "Biblioteca",
@ -431,7 +433,6 @@
"language": "Língua",
"defaultView": "Tela inicial",
"desktop_notifications": "Notificações",
"lastfmNotConfigured": "A API-Key do Last.fm não está configurada",
"lastfmScrobbling": "Enviar scrobbles para Last.fm",
"listenBrainzScrobbling": "Enviar scrobbles para ListenBrainz",
"replaygain": "Modo ReplayGain",
@ -440,7 +441,8 @@
"none": "Desligado",
"album": "Usar ganho do álbum",
"track": "Usar ganho do faixa"
}
},
"lastfmNotConfigured": "A API-Key do Last.fm não está configurada"
}
},
"albumList": "Álbuns",

View file

@ -26,7 +26,13 @@
"bpm": "BPM",
"playDate": "Последнее воспроизведение",
"channels": "Каналы",
"createdAt": "Дата добавления"
"createdAt": "Дата добавления",
"grouping": "",
"mood": "",
"participants": "",
"tags": "",
"mappedTags": "",
"rawTags": ""
},
"actions": {
"addToQueue": "В очередь",
@ -58,7 +64,13 @@
"originalDate": "Оригинал",
"releaseDate": "Релиз",
"releases": "Релиз |||| Релиза |||| Релизов",
"released": "Релиз"
"released": "Релиз",
"recordLabel": "",
"catalogNum": "",
"releaseType": "",
"grouping": "",
"media": "",
"mood": ""
},
"actions": {
"playAll": "Играть",
@ -89,7 +101,23 @@
"playCount": "Проигран",
"rating": "Рейтинг",
"genre": "Жанр",
"size": "Размер"
"size": "Размер",
"role": ""
},
"roles": {
"albumartist": "",
"artist": "",
"composer": "",
"conductor": "",
"lyricist": "",
"arranger": "",
"producer": "",
"director": "",
"engineer": "",
"mixer": "",
"remixer": "",
"djmixer": "",
"performer": ""
}
},
"user": {
@ -135,11 +163,11 @@
}
},
"transcoding": {
"name": "Транскодирование |||| Транскодирование",
"name": "Транскодирование |||| Транскодирование",
"fields": {
"name": "Название",
"targetFormat": "Целевой формат",
"defaultBitRate": "Стандартный битрейт",
"defaultBitRate": "Битрейт по умолчанию",
"command": "Команда"
}
},
@ -183,9 +211,9 @@
}
},
"share": {
"name": "Ссылка доступа |||| Ссылки доступа",
"name": "Общий доступ |||| Общий доступ",
"fields": {
"username": "Кто поделился",
"username": "Поделился",
"url": "Ссылка",
"description": "Описание",
"contents": "Содержание",
@ -194,9 +222,23 @@
"visitCount": "Посещения",
"format": "Формат",
"maxBitRate": "Макс. Битрейт",
"updatedAt": "Обновлено",
"updatedAt": "Обновлено в",
"createdAt": "Создано",
"downloadable": "Разрешить скачивание?"
"downloadable": "Разрешить загрузку?"
}
},
"missing": {
"name": "",
"fields": {
"path": "",
"size": "",
"updatedAt": ""
},
"actions": {
"remove": ""
},
"notifications": {
"removed": ""
}
}
},
@ -213,7 +255,7 @@
"sign_in": "Войти",
"sign_in_error": "Ошибка аутентификации, попробуйте снова",
"logout": "Выйти",
"insightsCollectionNote": "Navidrome собирает анонимные данные об использовании для\nулучшения проекта. Нажмите [здесь], чтобы\nузнать больше или отказаться"
"insightsCollectionNote": ""
},
"validation": {
"invalidChars": "Пожалуйста, используйте только буквы и цифры",
@ -375,7 +417,9 @@
"shareSuccess": "URL скопирован в буфер обмена: %{url}",
"shareFailure": "Ошибка копирования URL-адреса %{url} в буфер обмена",
"downloadDialogTitle": "Скачать %{resource} '%{name}' (%{size})",
"shareCopyToClipboard": "Копировать в буфер обмена: Ctrl+C, Enter"
"shareCopyToClipboard": "Копировать в буфер обмена: Ctrl+C, Enter",
"remove_missing_title": "",
"remove_missing_content": ""
},
"menu": {
"library": "Библиотека",
@ -389,7 +433,6 @@
"language": "Язык",
"defaultView": "Вид по умолчанию",
"desktop_notifications": "Уведомления на рабочем столе",
"lastfmNotConfigured": "API-ключ Last.fm не настроен",
"lastfmScrobbling": "Скробблинг Last.fm",
"listenBrainzScrobbling": "Скробблинг ListenBrainz",
"replaygain": "ReplayGain режим",
@ -398,7 +441,8 @@
"none": "Отключить",
"album": "Использовать усиление альбома",
"track": "Использовать усиление трека"
}
},
"lastfmNotConfigured": ""
}
},
"albumList": "Альбомы",
@ -435,12 +479,12 @@
"links": {
"homepage": "Главная",
"source": "Код",
"featureRequests": "Предложения"
},
"lastInsightsCollection": "Последний сбор данных",
"insights": {
"disabled": "Отключено",
"waiting": "Пока нет"
"featureRequests": "Предложения",
"lastInsightsCollection": "",
"insights": {
"disabled": "",
"waiting": ""
}
}
},
"activity": {
@ -465,4 +509,4 @@
"current_song": "Перейти к текущей песне"
}
}
}
}

View file

@ -26,7 +26,13 @@
"bpm": "BPM",
"playDate": "Son Oynatılma",
"channels": "Kanal",
"createdAt": "Eklenme tarihi"
"createdAt": "Eklenme tarihi",
"grouping": "Gruplama",
"mood": "Mod",
"participants": "Ek katılımcılar",
"tags": "Ek Etiketler",
"mappedTags": "Eşlenen etiketler",
"rawTags": "Ham etiketler"
},
"actions": {
"addToQueue": "Oynatma Sırasına Ekle",
@ -58,7 +64,13 @@
"originalDate": "Orijinal",
"releaseDate": "Yayınlanma Tarihi",
"releases": "Yayınlanan |||| Yayınlananlar",
"released": "Yayınlandı"
"released": "Yayınlandı",
"recordLabel": "Etiket",
"catalogNum": "Katalog Numarası",
"releaseType": "Tür",
"grouping": "Gruplama",
"media": "Medya",
"mood": "Mod"
},
"actions": {
"playAll": "Oynat",
@ -89,7 +101,23 @@
"playCount": "Oynatmalar",
"rating": "Derecelendirme",
"genre": "Tür",
"size": "Boyut"
"size": "Boyut",
"role": "Rol"
},
"roles": {
"albumartist": "Albüm Sanatçısı |||| Albüm Sanatçısı",
"artist": "Sanatçı |||| Sanatçı",
"composer": "Besteci |||| Besteci",
"conductor": "Şef |||| Şef",
"lyricist": "Söz Yazarı |||| Söz Yazarı",
"arranger": "Düzenleyici |||| Düzenleyici",
"producer": "Yapımcı |||| Yapımcı",
"director": "Yönetmen |||| Yönetmen",
"engineer": "Teknisyen |||| Teknisyen",
"mixer": "Mikser |||| Mikser",
"remixer": "Remiks |||| Remiks",
"djmixer": "DJ Mikseri |||| DJ Mikseri",
"performer": "Sanatçı |||| Sanatçı"
}
},
"user": {
@ -198,6 +226,20 @@
"createdAt": "Oluşturma Tarihi",
"downloadable": "İndirmelere İzin Ver"
}
},
"missing": {
"name": "Eksik Dosya |||| Eksik Dosyalar",
"fields": {
"path": "Yol",
"size": "Boyut",
"updatedAt": "Kaybolma"
},
"actions": {
"remove": "Kaldır"
},
"notifications": {
"removed": "Eksik dosya(lar) kaldırıldı"
}
}
},
"ra": {
@ -275,7 +317,7 @@
"loading": "Yükleniyor",
"not_found": "Bulunamadı",
"show": "%{name} #%{id}",
"empty": "Henüz %{name} Oluşturulmadı.",
"empty": "%{name} henüz yok.",
"invite": "Bir tane oluşturmak ister misin?"
},
"input": {
@ -375,7 +417,9 @@
"shareSuccess": "URL panoya kopyalandı: %{url}",
"shareFailure": "%{url} panoya kopyalanırken hata oluştu",
"downloadDialogTitle": "%{resource}: '%{name}' (%{size}) dosyasını indirin",
"shareCopyToClipboard": "Panoya kopyala: Ctrl+C, Enter"
"shareCopyToClipboard": "Panoya kopyala: Ctrl+C, Enter",
"remove_missing_title": "Eksik dosyaları kaldır",
"remove_missing_content": "Seçili eksik dosyaları veritabanından kaldırmak istediğinizden emin misiniz? Bu, oynatma sayıları ve derecelendirmeleri dahil olmak üzere bunlara ilişkin tüm referansları kalıcı olarak kaldıracaktır."
},
"menu": {
"library": "Kütüphane",

View file

@ -390,7 +390,7 @@ func (p *phaseFolders) persistChanges(entry *folderEntry) (*folderEntry, error)
}
}
return nil
})
}, "scanner: persist changes")
if err != nil {
log.Error(p.ctx, "Scanner: Error persisting changes to DB", "folder", entry.path, err)
}
@ -464,7 +464,7 @@ func (p *phaseFolders) finalize(err error) error {
}
}
return nil
})
}, "scanner: finalize phaseFolders")
return errors.Join(err, errF)
}

View file

@ -106,79 +106,75 @@ func (p *phaseMissingTracks) stages() []ppl.Stage[*missingTracks] {
}
func (p *phaseMissingTracks) processMissingTracks(in *missingTracks) (*missingTracks, error) {
err := p.ds.WithTx(func(tx model.DataStore) error {
for _, ms := range in.missing {
var exactMatch model.MediaFile
var equivalentMatch model.MediaFile
for _, ms := range in.missing {
var exactMatch model.MediaFile
var equivalentMatch model.MediaFile
// Identify exact and equivalent matches
for _, mt := range in.matched {
if ms.Equals(mt) {
exactMatch = mt
break // Prioritize exact match
}
if ms.IsEquivalent(mt) {
equivalentMatch = mt
}
// Identify exact and equivalent matches
for _, mt := range in.matched {
if ms.Equals(mt) {
exactMatch = mt
break // Prioritize exact match
}
// Use the exact match if found
if exactMatch.ID != "" {
log.Debug(p.ctx, "Scanner: Found missing track in a new place", "missing", ms.Path, "movedTo", exactMatch.Path, "lib", in.lib.Name)
err := p.moveMatched(tx, exactMatch, ms)
if err != nil {
log.Error(p.ctx, "Scanner: Error moving matched track", "missing", ms.Path, "movedTo", exactMatch.Path, "lib", in.lib.Name, err)
return err
}
p.totalMatched.Add(1)
continue
}
// If there is only one missing and one matched track, consider them equivalent (same PID)
if len(in.missing) == 1 && len(in.matched) == 1 {
singleMatch := in.matched[0]
log.Debug(p.ctx, "Scanner: Found track with same persistent ID in a new place", "missing", ms.Path, "movedTo", singleMatch.Path, "lib", in.lib.Name)
err := p.moveMatched(tx, singleMatch, ms)
if err != nil {
log.Error(p.ctx, "Scanner: Error updating matched track", "missing", ms.Path, "movedTo", singleMatch.Path, "lib", in.lib.Name, err)
return err
}
p.totalMatched.Add(1)
continue
}
// Use the equivalent match if no other better match was found
if equivalentMatch.ID != "" {
log.Debug(p.ctx, "Scanner: Found missing track with same base path", "missing", ms.Path, "movedTo", equivalentMatch.Path, "lib", in.lib.Name)
err := p.moveMatched(tx, equivalentMatch, ms)
if err != nil {
log.Error(p.ctx, "Scanner: Error updating matched track", "missing", ms.Path, "movedTo", equivalentMatch.Path, "lib", in.lib.Name, err)
return err
}
p.totalMatched.Add(1)
if ms.IsEquivalent(mt) {
equivalentMatch = mt
}
}
return nil
})
if err != nil {
return nil, err
// Use the exact match if found
if exactMatch.ID != "" {
log.Debug(p.ctx, "Scanner: Found missing track in a new place", "missing", ms.Path, "movedTo", exactMatch.Path, "lib", in.lib.Name)
err := p.moveMatched(exactMatch, ms)
if err != nil {
log.Error(p.ctx, "Scanner: Error moving matched track", "missing", ms.Path, "movedTo", exactMatch.Path, "lib", in.lib.Name, err)
return nil, err
}
p.totalMatched.Add(1)
continue
}
// If there is only one missing and one matched track, consider them equivalent (same PID)
if len(in.missing) == 1 && len(in.matched) == 1 {
singleMatch := in.matched[0]
log.Debug(p.ctx, "Scanner: Found track with same persistent ID in a new place", "missing", ms.Path, "movedTo", singleMatch.Path, "lib", in.lib.Name)
err := p.moveMatched(singleMatch, ms)
if err != nil {
log.Error(p.ctx, "Scanner: Error updating matched track", "missing", ms.Path, "movedTo", singleMatch.Path, "lib", in.lib.Name, err)
return nil, err
}
p.totalMatched.Add(1)
continue
}
// Use the equivalent match if no other better match was found
if equivalentMatch.ID != "" {
log.Debug(p.ctx, "Scanner: Found missing track with same base path", "missing", ms.Path, "movedTo", equivalentMatch.Path, "lib", in.lib.Name)
err := p.moveMatched(equivalentMatch, ms)
if err != nil {
log.Error(p.ctx, "Scanner: Error updating matched track", "missing", ms.Path, "movedTo", equivalentMatch.Path, "lib", in.lib.Name, err)
return nil, err
}
p.totalMatched.Add(1)
}
}
return in, nil
}
func (p *phaseMissingTracks) moveMatched(tx model.DataStore, mt, ms model.MediaFile) error {
discardedID := mt.ID
mt.ID = ms.ID
err := tx.MediaFile(p.ctx).Put(&mt)
if err != nil {
return fmt.Errorf("update matched track: %w", err)
}
err = tx.MediaFile(p.ctx).Delete(discardedID)
if err != nil {
return fmt.Errorf("delete discarded track: %w", err)
}
p.state.changesDetected.Store(true)
return nil
func (p *phaseMissingTracks) moveMatched(mt, ms model.MediaFile) error {
return p.ds.WithTx(func(tx model.DataStore) error {
discardedID := mt.ID
mt.ID = ms.ID
err := tx.MediaFile(p.ctx).Put(&mt)
if err != nil {
return fmt.Errorf("update matched track: %w", err)
}
err = tx.MediaFile(p.ctx).Delete(discardedID)
if err != nil {
return fmt.Errorf("delete discarded track: %w", err)
}
p.state.changesDetected.Store(true)
return nil
})
}
func (p *phaseMissingTracks) finalize(err error) error {

View file

@ -104,19 +104,13 @@ func (p *phaseRefreshAlbums) refreshAlbum(album *model.Album) (*model.Album, err
return nil, nil
}
start := time.Now()
err := p.ds.WithTx(func(tx model.DataStore) error {
err := tx.Album(p.ctx).Put(album)
log.Debug(p.ctx, "Scanner: refreshing album", "album_id", album.ID, "name", album.Name, "songCount", album.SongCount, "elapsed", time.Since(start))
if err != nil {
return fmt.Errorf("refreshing album %s: %w", album.ID, err)
}
p.refreshed.Add(1)
p.state.changesDetected.Store(true)
return nil
})
err := p.ds.Album(p.ctx).Put(album)
log.Debug(p.ctx, "Scanner: refreshing album", "album_id", album.ID, "name", album.Name, "songCount", album.SongCount, "elapsed", time.Since(start), err)
if err != nil {
return nil, err
return nil, fmt.Errorf("refreshing album %s: %w", album.ID, err)
}
p.refreshed.Add(1)
p.state.changesDetected.Store(true)
return album, nil
}
@ -135,23 +129,21 @@ func (p *phaseRefreshAlbums) finalize(err error) error {
log.Debug(p.ctx, "Scanner: No changes detected, skipping refreshing annotations")
return nil
}
return p.ds.WithTx(func(tx model.DataStore) error {
// Refresh album annotations
start := time.Now()
cnt, err := tx.Album(p.ctx).RefreshPlayCounts()
if err != nil {
return fmt.Errorf("refreshing album annotations: %w", err)
}
log.Debug(p.ctx, "Scanner: Refreshed album annotations", "albums", cnt, "elapsed", time.Since(start))
// Refresh album annotations
start := time.Now()
cnt, err := p.ds.Album(p.ctx).RefreshPlayCounts()
if err != nil {
return fmt.Errorf("refreshing album annotations: %w", err)
}
log.Debug(p.ctx, "Scanner: Refreshed album annotations", "albums", cnt, "elapsed", time.Since(start))
// Refresh artist annotations
start = time.Now()
cnt, err = tx.Artist(p.ctx).RefreshPlayCounts()
if err != nil {
return fmt.Errorf("refreshing artist annotations: %w", err)
}
log.Debug(p.ctx, "Scanner: Refreshed artist annotations", "artists", cnt, "elapsed", time.Since(start))
p.state.changesDetected.Store(true)
return nil
})
// Refresh artist annotations
start = time.Now()
cnt, err = p.ds.Artist(p.ctx).RefreshPlayCounts()
if err != nil {
return fmt.Errorf("refreshing artist annotations: %w", err)
}
log.Debug(p.ctx, "Scanner: Refreshed artist annotations", "artists", cnt, "elapsed", time.Since(start))
p.state.changesDetected.Store(true)
return nil
}

View file

@ -128,7 +128,7 @@ func (s *scannerImpl) runGC(ctx context.Context, state *scanState) func() error
log.Debug(ctx, "Scanner: No changes detected, skipping GC")
}
return nil
})
}, "scanner: GC")
}
}
@ -138,24 +138,22 @@ func (s *scannerImpl) runRefreshStats(ctx context.Context, state *scanState) fun
log.Debug(ctx, "Scanner: No changes detected, skipping refreshing stats")
return nil
}
return s.ds.WithTx(func(tx model.DataStore) error {
start := time.Now()
stats, err := tx.Artist(ctx).RefreshStats()
if err != nil {
log.Error(ctx, "Scanner: Error refreshing artists stats", err)
return fmt.Errorf("refreshing artists stats: %w", err)
}
log.Debug(ctx, "Scanner: Refreshed artist stats", "stats", stats, "elapsed", time.Since(start))
start := time.Now()
stats, err := s.ds.Artist(ctx).RefreshStats()
if err != nil {
log.Error(ctx, "Scanner: Error refreshing artists stats", err)
return fmt.Errorf("refreshing artists stats: %w", err)
}
log.Debug(ctx, "Scanner: Refreshed artist stats", "stats", stats, "elapsed", time.Since(start))
start = time.Now()
err = tx.Tag(ctx).UpdateCounts()
if err != nil {
log.Error(ctx, "Scanner: Error updating tag counts", err)
return fmt.Errorf("updating tag counts: %w", err)
}
log.Debug(ctx, "Scanner: Updated tag counts", "elapsed", time.Since(start))
return nil
})
start = time.Now()
err = s.ds.Tag(ctx).UpdateCounts()
if err != nil {
log.Error(ctx, "Scanner: Error updating tag counts", err)
return fmt.Errorf("updating tag counts: %w", err)
}
log.Debug(ctx, "Scanner: Updated tag counts", "elapsed", time.Since(start))
return nil
}
}
@ -189,7 +187,7 @@ func (s *scannerImpl) runUpdateLibraries(ctx context.Context, libs model.Librari
}
}
return nil
})
}, "scanner: update libraries")
}
}

View file

@ -35,7 +35,7 @@ func initialSetup(ds model.DataStore) {
err = properties.Put(consts.InitialSetupFlagKey, time.Now().String())
return err
})
}, "initial setup")
}
// If the Dev Admin user is not present, create it

View file

@ -100,7 +100,7 @@ func deleteFromPlaylist(ds model.DataStore) http.HandlerFunc {
p := req.Params(r)
playlistId, _ := p.String(":playlistId")
ids, _ := p.Strings("id")
err := ds.WithTx(func(tx model.DataStore) error {
err := ds.WithTxImmediate(func(tx model.DataStore) error {
tracksRepo := tx.Playlist(r.Context()).Tracks(playlistId, true)
return tracksRepo.Delete(ids...)
})

View file

@ -112,7 +112,7 @@ func (api *Router) setStar(ctx context.Context, star bool, ids ...string) error
return nil
}
event := &events.RefreshResource{}
err := api.ds.WithTx(func(tx model.DataStore) error {
err := api.ds.WithTxImmediate(func(tx model.DataStore) error {
for _, id := range ids {
exist, err := tx.Album(ctx).Exists(id)
if err != nil {

View file

@ -67,9 +67,6 @@ func (api *Router) GetCoverArt(w http.ResponseWriter, r *http.Request) (*respons
square := p.BoolOr("square", false)
imgReader, lastUpdate, err := api.artwork.GetOrPlaceholder(ctx, id, size, square)
w.Header().Set("cache-control", "public, max-age=315360000")
w.Header().Set("last-modified", lastUpdate.Format(time.RFC1123))
switch {
case errors.Is(err, context.Canceled):
return nil, nil
@ -82,6 +79,9 @@ func (api *Router) GetCoverArt(w http.ResponseWriter, r *http.Request) (*respons
}
defer imgReader.Close()
w.Header().Set("cache-control", "public, max-age=315360000")
w.Header().Set("last-modified", lastUpdate.Format(time.RFC1123))
cnt, err := io.Copy(w, imgReader)
if err != nil {
log.Warn(ctx, "Error sending image", "count", cnt, err)

View file

@ -58,7 +58,7 @@ func (api *Router) getPlaylist(ctx context.Context, id string) (*responses.Subso
}
func (api *Router) create(ctx context.Context, playlistId, name string, ids []string) (string, error) {
err := api.ds.WithTx(func(tx model.DataStore) error {
err := api.ds.WithTxImmediate(func(tx model.DataStore) error {
owner := getUser(ctx)
var pls *model.Playlist
var err error

View file

@ -1,3 +1,2 @@
test.mp3
test.ogg
file:///tests/fixtures/01%20Invisible%20(RED)%20Edit%20Version.mp3
test.ogg

View file

@ -1,2 +1,4 @@
test.mp3
test.ogg
../test.mp3
../test.ogg
/tests/fixtures/01%20Invisible%20(RED)%20Edit%20Version.mp3
/invalid/path/xyz.mp3

View file

@ -209,7 +209,11 @@ func (db *MockDataStore) Radio(ctx context.Context) model.RadioRepository {
return db.MockedRadio
}
func (db *MockDataStore) WithTx(block func(model.DataStore) error) error {
func (db *MockDataStore) WithTx(block func(tx model.DataStore) error, label ...string) error {
return block(db)
}
func (db *MockDataStore) WithTxImmediate(block func(tx model.DataStore) error, label ...string) error {
return block(db)
}

View file

@ -48,7 +48,11 @@ func (m *MockMediaFileRepo) Get(id string) (*model.MediaFile, error) {
return nil, errors.New("error")
}
if d, ok := m.data[id]; ok {
return d, nil
// Intentionally clone the file and remove participants. This should
// catch any caller that actually means to call GetWithParticipants
res := *d
res.Participants = model.Participants{}
return &res, nil
}
return nil, model.ErrNotFound
}

View file

@ -2,7 +2,6 @@ package tests
import (
"context"
"io/fs"
"os"
"path/filepath"
@ -18,7 +17,7 @@ func TempFileName(t testingT, prefix, suffix string) string {
return filepath.Join(t.TempDir(), prefix+id.NewRandom()+suffix)
}
func TempFile(t testingT, prefix, suffix string) (fs.File, string, error) {
func TempFile(t testingT, prefix, suffix string) (*os.File, string, error) {
name := TempFileName(t, prefix, suffix)
f, err := os.Create(name)
return f, name, err

View file

@ -1,4 +1,2 @@
User-agent: bingbot
Disallow: /manifest.webmanifest
User-agent: *
Disallow: /

View file

@ -26,6 +26,9 @@ const useStyles = makeStyles({
tableCell: {
width: '17.5%',
},
value: {
whiteSpace: 'pre-line',
},
})
const AlbumInfo = (props) => {
@ -113,7 +116,9 @@ const AlbumInfo = (props) => {
})}
:
</TableCell>
<TableCell align="left">{data[key]}</TableCell>
<TableCell align="left" className={classes.value}>
{data[key]}
</TableCell>
</TableRow>
)
})}

View file

@ -60,9 +60,16 @@ const AlbumShowLayout = (props) => {
let perPage = 0
let pagination = null
if (record?.stats?.['artist']?.albumCount > maxPerPage) {
const count = Math.max(
record?.stats?.['albumartist']?.albumCount || 0,
record?.stats?.['artist']?.albumCount ?? 0,
)
if (count > maxPerPage) {
perPage = Math.trunc(maxPerPage / perPageOptions[0]) * perPageOptions[0]
const rowsPerPageOptions = [1, 2, 3].map((option) => option * perPage)
const rowsPerPageOptions = [1, 2, 3].map((option) =>
Math.trunc(option * (perPage / 3)),
)
pagination = <Pagination rowsPerPageOptions={rowsPerPageOptions} />
}

View file

@ -23,6 +23,7 @@ const ALink = withWidth()((props) => {
{...rest}
>
{artist.name}
{artist.subroles?.length > 0 ? ` (${artist.subroles.join(', ')})` : ''}
</Link>
)
})
@ -89,19 +90,29 @@ export const ArtistLinkField = ({ record, className, limit, source }) => {
}
// Dedupe artists, only shows the first 3
const seen = new Set()
const seen = new Map()
const dedupedArtists = []
let limitedShow = false
for (const artist of artists ?? []) {
if (!seen.has(artist.id)) {
seen.add(artist.id)
if (dedupedArtists.length < limit) {
dedupedArtists.push(artist)
seen.set(artist.id, dedupedArtists.length)
dedupedArtists.push({
...artist,
subroles: artist.subRole ? [artist.subRole] : [],
})
} else {
limitedShow = true
break
}
} else {
const position = seen.get(artist.id)
if (position !== -1) {
const existing = dedupedArtists[position]
if (artist.subRole && !existing.subroles.includes(artist.subRole)) {
existing.subroles.push(artist.subRole)
}
}
}
}

View file

@ -36,6 +36,9 @@ const useStyles = makeStyles({
tableCell: {
width: '17.5%',
},
value: {
whiteSpace: 'pre-line',
},
})
export const SongInfo = (props) => {
@ -111,27 +114,27 @@ export const SongInfo = (props) => {
return (
<TableContainer>
<Table aria-label="song details" size="small">
<TableBody>
{record.rawTags && (
<Tabs value={tab} onChange={(_, value) => setTab(value)}>
<Tab
label={translate(`resources.song.fields.mappedTags`)}
id="mapped-tags-tab"
aria-controls="mapped-tags-body"
/>
<Tab
label={translate(`resources.song.fields.rawTags`)}
id="raw-tags-tab"
aria-controls="raw-tags-body"
/>
</Tabs>
)}
<div
hidden={tab === 1}
id="mapped-tags-body"
aria-labelledby={record.rawTags ? 'mapped-tags-tab' : undefined}
>
{record.rawTags && (
<Tabs value={tab} onChange={(_, value) => setTab(value)}>
<Tab
label={translate(`resources.song.fields.mappedTags`)}
id="mapped-tags-tab"
aria-controls="mapped-tags-body"
/>
<Tab
label={translate(`resources.song.fields.rawTags`)}
id="raw-tags-tab"
aria-controls="raw-tags-body"
/>
</Tabs>
)}
<div
hidden={tab == 1}
id="mapped-tags-body"
aria-labelledby={record.rawTags ? 'mapped-tags-tab' : undefined}
>
<Table aria-label="song details" size="small">
<TableBody>
{Object.keys(data).map((key) => {
return (
<TableRow key={`${record.id}-${key}`}>
@ -141,7 +144,9 @@ export const SongInfo = (props) => {
})}
:
</TableCell>
<TableCell align="left">{data[key]}</TableCell>
<TableCell align="left" className={classes.value}>
{data[key]}
</TableCell>
</TableRow>
)
})}
@ -152,7 +157,7 @@ export const SongInfo = (props) => {
scope="row"
className={classes.tableCell}
></TableCell>
<TableCell align="left">
<TableCell align="left" className={classes.value}>
<h4>{translate(`resources.song.fields.tags`)}</h4>
</TableCell>
</TableRow>
@ -162,16 +167,22 @@ export const SongInfo = (props) => {
<TableCell scope="row" className={classes.tableCell}>
{name}:
</TableCell>
<TableCell align="left">{values.join(' • ')}</TableCell>
<TableCell align="left" className={classes.value}>
{values.join(' • ')}
</TableCell>
</TableRow>
))}
</div>
{record.rawTags && (
<div
hidden={tab === 0}
id="raw-tags-body"
aria-labelledby="raw-tags-tab"
>
</TableBody>
</Table>
</div>
{record.rawTags && (
<div
hidden={tab === 0}
id="raw-tags-body"
aria-labelledby="raw-tags-tab"
>
<Table size="small" aria-label="song raw tags">
<TableBody>
<TableRow key={`${record.id}-raw-path`}>
<TableCell scope="row" className={classes.tableCell}>
{translate(`resources.song.fields.path`)}:
@ -183,13 +194,15 @@ export const SongInfo = (props) => {
<TableCell scope="row" className={classes.tableCell}>
{key}:
</TableCell>
<TableCell align="left">{value.join(' • ')}</TableCell>
<TableCell align="left" className={classes.value}>
{value.join(' • ')}
</TableCell>
</TableRow>
))}
</div>
)}
</TableBody>
</Table>
</TableBody>
</Table>
</div>
)}
</TableContainer>
)
}

View file

@ -1,4 +1,4 @@
import React from 'react'
import React, { useEffect, useState } from 'react'
import PropTypes from 'prop-types'
import Link from '@material-ui/core/Link'
import Dialog from '@material-ui/core/Dialog'
@ -16,6 +16,8 @@ import config from '../config'
import { DialogTitle } from './DialogTitle'
import { DialogContent } from './DialogContent'
import { INSIGHTS_DOC_URL } from '../consts.js'
import subsonic from '../subsonic/index.js'
import { Typography } from '@material-ui/core'
const links = {
homepage: 'navidrome.org',
@ -29,7 +31,7 @@ const links = {
const LinkToVersion = ({ version }) => {
if (version === 'dev') {
return <TableCell align="left">{version}</TableCell>
return <>{version}</>
}
const parts = version.split(' ')
@ -41,12 +43,46 @@ const LinkToVersion = ({ version }) => {
}...${commitID}`
: `https://github.com/navidrome/navidrome/releases/tag/v${parts[0]}`
return (
<TableCell align="left">
<>
<Link href={url} target="_blank" rel="noopener noreferrer">
{parts[0]}
</Link>
{' (' + commitID + ')'}
</TableCell>
</>
)
}
const ShowVersion = ({ uiVersion, serverVersion }) => {
const translate = useTranslate()
const showRefresh = uiVersion !== serverVersion
return (
<>
<TableRow>
<TableCell align="right" component="th" scope="row">
{translate('menu.version')}:
</TableCell>
<TableCell align="left">
<LinkToVersion version={serverVersion} />
</TableCell>
</TableRow>
{showRefresh && (
<TableRow>
<TableCell align="right" component="th" scope="row">
UI {translate('menu.version')}:
</TableCell>
<TableCell align="left">
<LinkToVersion version={uiVersion} />
<Link onClick={() => window.location.reload()}>
<Typography variant={'caption'}>
{' ' + translate('ra.notification.new_version')}
</Typography>
</Link>
</TableCell>
</TableRow>
)}
</>
)
}
@ -54,6 +90,23 @@ const AboutDialog = ({ open, onClose }) => {
const translate = useTranslate()
const { permissions } = usePermissions()
const { data, loading } = useGetOne('insights', 'insights_status')
const [serverVersion, setServerVersion] = useState('')
const uiVersion = config.version
useEffect(() => {
subsonic
.ping()
.then((resp) => resp.json['subsonic-response'])
.then((data) => {
if (data.status === 'ok') {
setServerVersion(data.serverVersion)
}
})
.catch((e) => {
// eslint-disable-next-line no-console
console.error('error pinging server', e)
})
}, [setServerVersion])
const lastRun = !loading && data?.lastRun
let insightsStatus = 'N/A'
@ -74,12 +127,10 @@ const AboutDialog = ({ open, onClose }) => {
<TableContainer component={Paper}>
<Table aria-label={translate('menu.about')} size="small">
<TableBody>
<TableRow>
<TableCell align="right" component="th" scope="row">
{translate('menu.version')}:
</TableCell>
<LinkToVersion version={config.version} />
</TableRow>
<ShowVersion
uiVersion={uiVersion}
serverVersion={serverVersion}
/>
{Object.keys(links).map((key) => {
return (
<TableRow key={key}>

View file

@ -4,12 +4,15 @@ import { LinkToVersion } from './AboutDialog'
import TableBody from '@material-ui/core/TableBody'
import TableRow from '@material-ui/core/TableRow'
import Table from '@material-ui/core/Table'
import TableCell from '@material-ui/core/TableCell'
const Wrapper = ({ version }) => (
<Table>
<TableBody>
<TableRow>
<LinkToVersion version={version} />
<TableCell>
<LinkToVersion version={version} />
</TableCell>
</TableRow>
</TableBody>
</Table>

View file

@ -29,6 +29,8 @@ const url = (command, id, options) => {
return `/rest/${command}?${params.toString()}`
}
const ping = () => httpClient(url('ping'))
const scrobble = (id, time, submission = true) =>
httpClient(
url('scrobble', id, {
@ -88,6 +90,7 @@ const streamUrl = (id, options) => {
export default {
url,
ping,
scrobble,
nowPlaying,
download,

View file

@ -140,7 +140,7 @@ var _ = Describe("Slice Utils", func() {
Expect(count).To(Equal(expected))
},
Entry("returns empty slice for an empty input", "tests/fixtures/empty.txt", 0),
Entry("returns the lines of a file", "tests/fixtures/playlists/pls1.m3u", 3),
Entry("returns the lines of a file", "tests/fixtures/playlists/pls1.m3u", 2),
Entry("returns empty if file does not exist", "tests/fixtures/NON-EXISTENT", 0),
)