Compare commits

..

5 Commits

Author SHA1 Message Date
Deluan Quintão
5cb851f2a8 Merge branch 'master' into fix/default-language-app-startup 2025-05-25 17:55:23 -04:00
Deluan
5c18951e31 fix(ui): streamline locale setting in App component
Signed-off-by: Deluan <deluan@navidrome.org>
2025-04-28 09:52:53 -04:00
Deluan
2b744c878e fix(ui): move default language initialization to Admin component
Signed-off-by: Deluan <deluan@navidrome.org>
2025-04-28 09:42:21 -04:00
Deluan
4548e75d49 style(ui): format App.jsx with Prettier
Ran Prettier on ui/src/App.jsx to satisfy code style checks after adding default-language useEffect.
2025-04-25 08:12:44 -04:00
Deluan
841af03393 fix: load ND_DEFAULTLANGUAGE on app startup
Added  in  to apply  on initial mount, ensuring the locale is set even when the login page is skipped by reverse-proxy authentication. Removed the redundant language-init effect from . Fixes #3605.
2025-04-25 08:09:06 -04:00
48 changed files with 384 additions and 1200 deletions

View File

@@ -19,7 +19,7 @@ CROSS_TAGLIB_VERSION ?= 2.0.2-1
UI_SRC_FILES := $(shell find ui -type f -not -path "ui/build/*" -not -path "ui/node_modules/*")
setup: check_env download-deps install-golangci-lint setup-git ##@1_Run_First Install dependencies and prepare development environment
setup: check_env download-deps setup-git ##@1_Run_First Install dependencies and prepare development environment
@echo Downloading Node dependencies...
@(cd ./ui && npm ci)
.PHONY: setup

View File

@@ -154,11 +154,10 @@ type TagConf struct {
}
type lastfmOptions struct {
Enabled bool
ApiKey string
Secret string
Language string
ScrobbleFirstArtistOnly bool
Enabled bool
ApiKey string
Secret string
Language string
}
type spotifyOptions struct {
@@ -529,7 +528,6 @@ func setViperDefaults() {
viper.SetDefault("lastfm.language", "en")
viper.SetDefault("lastfm.apikey", "")
viper.SetDefault("lastfm.secret", "")
viper.SetDefault("lastfm.scrobblefirstartistonly", false)
viper.SetDefault("spotify.id", "")
viper.SetDefault("spotify.secret", "")
viper.SetDefault("listenbrainz.enabled", true)

View File

@@ -279,13 +279,6 @@ func (l *lastfmAgent) callArtistGetTopTracks(ctx context.Context, artistName str
return t.Track, nil
}
func (l *lastfmAgent) getArtistForScrobble(track *model.MediaFile) string {
if conf.Server.LastFM.ScrobbleFirstArtistOnly && len(track.Participants[model.RoleArtist]) > 0 {
return track.Participants[model.RoleArtist][0].Name
}
return track.Artist
}
func (l *lastfmAgent) NowPlaying(ctx context.Context, userId string, track *model.MediaFile) error {
sk, err := l.sessionKeys.Get(ctx, userId)
if err != nil || sk == "" {
@@ -293,7 +286,7 @@ func (l *lastfmAgent) NowPlaying(ctx context.Context, userId string, track *mode
}
err = l.client.updateNowPlaying(ctx, sk, ScrobbleInfo{
artist: l.getArtistForScrobble(track),
artist: track.Artist,
track: track.Title,
album: track.Album,
trackNumber: track.TrackNumber,
@@ -319,7 +312,7 @@ func (l *lastfmAgent) Scrobble(ctx context.Context, userId string, s scrobbler.S
return nil
}
err = l.client.scrobble(ctx, sk, ScrobbleInfo{
artist: l.getArtistForScrobble(&s.MediaFile),
artist: s.Artist,
track: s.Title,
album: s.Album,
trackNumber: s.TrackNumber,

View File

@@ -196,12 +196,6 @@ var _ = Describe("lastfmAgent", func() {
TrackNumber: 1,
Duration: 180,
MbzRecordingID: "mbz-123",
Participants: map[model.Role]model.ParticipantList{
model.RoleArtist: []model.Participant{
{Artist: model.Artist{ID: "ar-1", Name: "First Artist"}},
{Artist: model.Artist{ID: "ar-2", Name: "Second Artist"}},
},
},
}
})
@@ -253,23 +247,6 @@ var _ = Describe("lastfmAgent", func() {
Expect(sentParams.Get("timestamp")).To(Equal(strconv.FormatInt(ts.Unix(), 10)))
})
When("ScrobbleFirstArtistOnly is true", func() {
BeforeEach(func() {
conf.Server.LastFM.ScrobbleFirstArtistOnly = true
})
It("uses only the first artist", func() {
ts := time.Now()
httpClient.Res = http.Response{Body: io.NopCloser(bytes.NewBufferString("{}")), StatusCode: 200}
err := agent.Scrobble(ctx, "user-1", scrobbler.Scrobble{MediaFile: *track, TimeStamp: ts})
Expect(err).ToNot(HaveOccurred())
sentParams := httpClient.SavedRequest.URL.Query()
Expect(sentParams.Get("artist")).To(Equal("First Artist"))
})
})
It("skips songs with less than 31 seconds", func() {
track.Duration = 29
httpClient.Res = http.Response{Body: io.NopCloser(bytes.NewBufferString("{}")), StatusCode: 200}

View File

@@ -98,7 +98,7 @@ func (a *archiver) ZipShare(ctx context.Context, id string, out io.Writer) error
return model.ErrNotAuthorized
}
log.Debug(ctx, "Zipping share", "name", s.ID, "format", s.Format, "bitrate", s.MaxBitRate, "numTracks", len(s.Tracks))
return a.zipMediaFiles(ctx, id, s.ID, s.Format, s.MaxBitRate, out, s.Tracks, false)
return a.zipMediaFiles(ctx, id, s.Format, s.MaxBitRate, out, s.Tracks)
}
func (a *archiver) ZipPlaylist(ctx context.Context, id string, format string, bitrate int, out io.Writer) error {
@@ -109,40 +109,15 @@ func (a *archiver) ZipPlaylist(ctx context.Context, id string, format string, bi
}
mfs := pls.MediaFiles()
log.Debug(ctx, "Zipping playlist", "name", pls.Name, "format", format, "bitrate", bitrate, "numTracks", len(mfs))
return a.zipMediaFiles(ctx, id, pls.Name, format, bitrate, out, mfs, true)
return a.zipMediaFiles(ctx, id, format, bitrate, out, mfs)
}
func (a *archiver) zipMediaFiles(ctx context.Context, id, name string, format string, bitrate int, out io.Writer, mfs model.MediaFiles, addM3U bool) error {
func (a *archiver) zipMediaFiles(ctx context.Context, id string, format string, bitrate int, out io.Writer, mfs model.MediaFiles) error {
z := createZipWriter(out, format, bitrate)
zippedMfs := make(model.MediaFiles, len(mfs))
for idx, mf := range mfs {
file := a.playlistFilename(mf, format, idx)
_ = a.addFileToZip(ctx, z, mf, format, bitrate, file)
mf.Path = file
zippedMfs[idx] = mf
}
// Add M3U file if requested
if addM3U && len(zippedMfs) > 0 {
plsName := sanitizeName(name)
w, err := z.CreateHeader(&zip.FileHeader{
Name: plsName + ".m3u",
Modified: mfs[0].UpdatedAt,
Method: zip.Store,
})
if err != nil {
log.Error(ctx, "Error creating playlist zip entry", err)
return err
}
_, err = w.Write([]byte(zippedMfs.ToM3U8(plsName, false)))
if err != nil {
log.Error(ctx, "Error writing m3u in zip", err)
return err
}
}
err := z.Close()
if err != nil {
log.Error(ctx, "Error closing zip file", "id", id, err)

View File

@@ -145,21 +145,9 @@ var _ = Describe("Archiver", func() {
zr, err := zip.NewReader(bytes.NewReader(out.Bytes()), int64(out.Len()))
Expect(err).To(BeNil())
Expect(len(zr.File)).To(Equal(3))
Expect(len(zr.File)).To(Equal(2))
Expect(zr.File[0].Name).To(Equal("01 - AC_DC - track1.mp3"))
Expect(zr.File[1].Name).To(Equal("02 - Artist 2 - track2.mp3"))
Expect(zr.File[2].Name).To(Equal("Test Playlist.m3u"))
// Verify M3U content
m3uFile, err := zr.File[2].Open()
Expect(err).To(BeNil())
defer m3uFile.Close()
m3uContent, err := io.ReadAll(m3uFile)
Expect(err).To(BeNil())
expectedM3U := "#EXTM3U\n#PLAYLIST:Test Playlist\n#EXTINF:0,AC/DC - track1\n01 - AC_DC - track1.mp3\n#EXTINF:0,Artist 2 - track2\n02 - Artist 2 - track2.mp3\n"
Expect(string(m3uContent)).To(Equal(expectedM3U))
})
})
})

View File

@@ -9,7 +9,6 @@ import (
"mime"
"path/filepath"
"slices"
"strings"
"time"
"github.com/gohugoio/hashstructure"
@@ -331,23 +330,6 @@ func firstArtPath(currentPath string, currentDisc int, m MediaFile) (string, int
return currentPath, currentDisc
}
// ToM3U8 exports the playlist to the Extended M3U8 format, as specified in
// https://docs.fileformat.com/audio/m3u/#extended-m3u
func (mfs MediaFiles) ToM3U8(title string, absolutePaths bool) string {
buf := strings.Builder{}
buf.WriteString("#EXTM3U\n")
buf.WriteString(fmt.Sprintf("#PLAYLIST:%s\n", title))
for _, t := range mfs {
buf.WriteString(fmt.Sprintf("#EXTINF:%.f,%s - %s\n", t.Duration, t.Artist, t.Title))
if absolutePaths {
buf.WriteString(t.AbsolutePath() + "\n")
} else {
buf.WriteString(t.Path + "\n")
}
}
return buf.String()
}
type MediaFileCursor iter.Seq2[MediaFile, error]
type MediaFileRepository interface {

View File

@@ -402,72 +402,6 @@ var _ = Describe("MediaFiles", func() {
})
})
})
Describe("ToM3U8", func() {
It("returns header only for empty MediaFiles", func() {
mfs = MediaFiles{}
result := mfs.ToM3U8("My Playlist", false)
Expect(result).To(Equal("#EXTM3U\n#PLAYLIST:My Playlist\n"))
})
DescribeTable("duration formatting",
func(duration float32, expected string) {
mfs = MediaFiles{{Title: "Song", Artist: "Artist", Duration: duration, Path: "song.mp3"}}
result := mfs.ToM3U8("Test", false)
Expect(result).To(ContainSubstring(expected))
},
Entry("zero duration", float32(0.0), "#EXTINF:0,"),
Entry("whole number", float32(120.0), "#EXTINF:120,"),
Entry("rounds 0.5 down", float32(180.5), "#EXTINF:180,"),
Entry("rounds 0.6 up", float32(240.6), "#EXTINF:241,"),
)
Context("multiple tracks", func() {
BeforeEach(func() {
mfs = MediaFiles{
{Title: "Song One", Artist: "Artist A", Duration: 120, Path: "a/song1.mp3", LibraryPath: "/music"},
{Title: "Song Two", Artist: "Artist B", Duration: 241, Path: "b/song2.mp3", LibraryPath: "/music"},
{Title: "Song with \"quotes\" & ampersands", Artist: "Artist with Ümläuts", Duration: 90, Path: "special/file.mp3", LibraryPath: "/música"},
}
})
DescribeTable("generates correct output",
func(absolutePaths bool, expectedContent string) {
result := mfs.ToM3U8("Multi Track", absolutePaths)
Expect(result).To(Equal(expectedContent))
},
Entry("relative paths",
false,
"#EXTM3U\n#PLAYLIST:Multi Track\n#EXTINF:120,Artist A - Song One\na/song1.mp3\n#EXTINF:241,Artist B - Song Two\nb/song2.mp3\n#EXTINF:90,Artist with Ümläuts - Song with \"quotes\" & ampersands\nspecial/file.mp3\n",
),
Entry("absolute paths",
true,
"#EXTM3U\n#PLAYLIST:Multi Track\n#EXTINF:120,Artist A - Song One\n/music/a/song1.mp3\n#EXTINF:241,Artist B - Song Two\n/music/b/song2.mp3\n#EXTINF:90,Artist with Ümläuts - Song with \"quotes\" & ampersands\n/música/special/file.mp3\n",
),
Entry("special characters",
false,
"#EXTM3U\n#PLAYLIST:Multi Track\n#EXTINF:120,Artist A - Song One\na/song1.mp3\n#EXTINF:241,Artist B - Song Two\nb/song2.mp3\n#EXTINF:90,Artist with Ümläuts - Song with \"quotes\" & ampersands\nspecial/file.mp3\n",
),
)
})
Context("path variations", func() {
It("handles different path structures", func() {
mfs = MediaFiles{
{Title: "Root", Artist: "Artist", Duration: 60, Path: "song.mp3", LibraryPath: "/lib"},
{Title: "Nested", Artist: "Artist", Duration: 60, Path: "deep/nested/song.mp3", LibraryPath: "/lib"},
}
relativeResult := mfs.ToM3U8("Test", false)
Expect(relativeResult).To(ContainSubstring("song.mp3\n"))
Expect(relativeResult).To(ContainSubstring("deep/nested/song.mp3\n"))
absoluteResult := mfs.ToM3U8("Test", true)
Expect(absoluteResult).To(ContainSubstring("/lib/song.mp3\n"))
Expect(absoluteResult).To(ContainSubstring("/lib/deep/nested/song.mp3\n"))
})
})
})
})
var _ = Describe("MediaFile", func() {

View File

@@ -1,8 +1,10 @@
package model
import (
"fmt"
"slices"
"strconv"
"strings"
"time"
"github.com/navidrome/navidrome/model/criteria"
@@ -51,9 +53,17 @@ func (pls *Playlist) RemoveTracks(idxToRemove []int) {
pls.Tracks = newTracks
}
// ToM3U8 exports the playlist to the Extended M3U8 format
// ToM3U8 exports the playlist to the Extended M3U8 format, as specified in
// https://docs.fileformat.com/audio/m3u/#extended-m3u
func (pls *Playlist) ToM3U8() string {
return pls.MediaFiles().ToM3U8(pls.Name, true)
buf := strings.Builder{}
buf.WriteString("#EXTM3U\n")
buf.WriteString(fmt.Sprintf("#PLAYLIST:%s\n", pls.Name))
for _, t := range pls.Tracks {
buf.WriteString(fmt.Sprintf("#EXTINF:%.f,%s - %s\n", t.Duration, t.Artist, t.Title))
buf.WriteString(t.AbsolutePath() + "\n")
}
return buf.String()
}
func (pls *Playlist) AddTracks(mediaFileIds []string) {

View File

@@ -13,17 +13,13 @@ var _ = Describe("Playlist", func() {
pls = model.Playlist{Name: "Mellow sunset"}
pls.Tracks = model.PlaylistTracks{
{MediaFile: model.MediaFile{Artist: "Morcheeba feat. Kurt Wagner", Title: "What New York Couples Fight About",
Duration: 377.84,
LibraryPath: "/music/library", Path: "Morcheeba/Charango/01-06 What New York Couples Fight About.mp3"}},
Duration: 377.84, Path: "/music/library/Morcheeba/Charango/01-06 What New York Couples Fight About.mp3"}},
{MediaFile: model.MediaFile{Artist: "A Tribe Called Quest", Title: "Description of a Fool (Groove Armada's Acoustic mix)",
Duration: 374.49,
LibraryPath: "/music/library", Path: "Groove Armada/Back to Mine_ Groove Armada/01-01 Description of a Fool (Groove Armada's Acoustic mix).mp3"}},
Duration: 374.49, Path: "/music/library/Groove Armada/Back to Mine_ Groove Armada/01-01 Description of a Fool (Groove Armada's Acoustic mix).mp3"}},
{MediaFile: model.MediaFile{Artist: "Lou Reed", Title: "Walk on the Wild Side",
Duration: 253.1,
LibraryPath: "/music/library", Path: "Lou Reed/Walk on the Wild Side_ The Best of Lou Reed/01-06 Walk on the Wild Side.m4a"}},
Duration: 253.1, Path: "/music/library/Lou Reed/Walk on the Wild Side_ The Best of Lou Reed/01-06 Walk on the Wild Side.m4a"}},
{MediaFile: model.MediaFile{Artist: "Legião Urbana", Title: "On the Way Home",
Duration: 163.89,
LibraryPath: "/music/library", Path: "Legião Urbana/Música p_ acampamentos/02-05 On the Way Home.mp3"}},
Duration: 163.89, Path: "/music/library/Legião Urbana/Música p_ acampamentos/02-05 On the Way Home.mp3"}},
}
})
It("generates the correct M3U format", func() {

View File

@@ -2,6 +2,7 @@ package model
import (
"cmp"
"fmt"
"strings"
"time"
@@ -49,9 +50,17 @@ func (s Share) CoverArtID() ArtworkID {
type Shares []Share
// ToM3U8 exports the share to the Extended M3U8 format.
// ToM3U8 exports the playlist to the Extended M3U8 format, as specified in
// https://docs.fileformat.com/audio/m3u/#extended-m3u
func (s Share) ToM3U8() string {
return s.Tracks.ToM3U8(cmp.Or(s.Description, s.ID), false)
buf := strings.Builder{}
buf.WriteString("#EXTM3U\n")
buf.WriteString(fmt.Sprintf("#PLAYLIST:%s\n", cmp.Or(s.Description, s.ID)))
for _, t := range s.Tracks {
buf.WriteString(fmt.Sprintf("#EXTINF:%.f,%s - %s\n", t.Duration, t.Artist, t.Title))
buf.WriteString(t.Path + "\n")
}
return buf.String()
}
type ShareRepository interface {

View File

@@ -129,12 +129,7 @@ func NewArtistRepository(ctx context.Context, db dbx.Builder) model.ArtistReposi
}
func roleFilter(_ string, role any) Sqlizer {
if role, ok := role.(string); ok {
if _, ok := model.AllRoles[role]; ok {
return NotEq{fmt.Sprintf("stats ->> '$.%v'", role): nil}
}
}
return Eq{"1": 2}
return NotEq{fmt.Sprintf("stats ->> '$.%v'", role): nil}
}
func (r *artistRepository) selectArtist(options ...model.QueryOptions) SelectBuilder {

View File

@@ -321,26 +321,4 @@ var _ = Describe("ArtistRepository", func() {
})
})
})
Describe("roleFilter", func() {
It("filters out roles not present in the participants model", func() {
Expect(roleFilter("", "artist")).To(Equal(squirrel.NotEq{"stats ->> '$.artist'": nil}))
Expect(roleFilter("", "albumartist")).To(Equal(squirrel.NotEq{"stats ->> '$.albumartist'": nil}))
Expect(roleFilter("", "composer")).To(Equal(squirrel.NotEq{"stats ->> '$.composer'": nil}))
Expect(roleFilter("", "conductor")).To(Equal(squirrel.NotEq{"stats ->> '$.conductor'": nil}))
Expect(roleFilter("", "lyricist")).To(Equal(squirrel.NotEq{"stats ->> '$.lyricist'": nil}))
Expect(roleFilter("", "arranger")).To(Equal(squirrel.NotEq{"stats ->> '$.arranger'": nil}))
Expect(roleFilter("", "producer")).To(Equal(squirrel.NotEq{"stats ->> '$.producer'": nil}))
Expect(roleFilter("", "director")).To(Equal(squirrel.NotEq{"stats ->> '$.director'": nil}))
Expect(roleFilter("", "engineer")).To(Equal(squirrel.NotEq{"stats ->> '$.engineer'": nil}))
Expect(roleFilter("", "mixer")).To(Equal(squirrel.NotEq{"stats ->> '$.mixer'": nil}))
Expect(roleFilter("", "remixer")).To(Equal(squirrel.NotEq{"stats ->> '$.remixer'": nil}))
Expect(roleFilter("", "djmixer")).To(Equal(squirrel.NotEq{"stats ->> '$.djmixer'": nil}))
Expect(roleFilter("", "performer")).To(Equal(squirrel.NotEq{"stats ->> '$.performer'": nil}))
Expect(roleFilter("", "wizard")).To(Equal(squirrel.Eq{"1": 2}))
Expect(roleFilter("", "songanddanceman")).To(Equal(squirrel.Eq{"1": 2}))
Expect(roleFilter("", "artist') SELECT LIKE(CHAR(65,66,67,68,69,70,71),UPPER(HEX(RANDOMBLOB(500000000/2))))--")).To(Equal(squirrel.Eq{"1": 2}))
})
})
})

View File

@@ -32,15 +32,12 @@
"participants": "Weitere Beteiligte",
"tags": "Weitere Tags",
"mappedTags": "Gemappte Tags",
"rawTags": "Tag Rohdaten",
"bitDepth": "Bittiefe",
"sampleRate": "Samplerate",
"missing": "Fehlend"
"rawTags": "Tag Rohdaten"
},
"actions": {
"addToQueue": "Später abspielen",
"playNow": "Jetzt abspielen",
"addToPlaylist": "Zu einer Wiedergabeliste hinzufügen",
"addToPlaylist": "Zur Playlist hinzufügen",
"shuffleAll": "Zufallswiedergabe",
"download": "Herunterladen",
"playNext": "Als nächstes abspielen",
@@ -73,16 +70,14 @@
"releaseType": "Typ",
"grouping": "Gruppierung",
"media": "Medium",
"mood": "Stimmung",
"date": "Aufnahmedatum",
"missing": "Fehlend"
"mood": "Stimmung"
},
"actions": {
"playAll": "Abspielen",
"playNext": "Als nächstes abspielen",
"addToQueue": "Später abspielen",
"shuffle": "Zufallswiedergabe",
"addToPlaylist": "Zu einer Wiedergabeliste hinzufügen",
"addToPlaylist": "Zur Playlist hinzufügen",
"download": "Herunterladen",
"info": "Mehr Informationen",
"share": "Freigabe erstellen"
@@ -107,8 +102,7 @@
"rating": "Bewertung",
"genre": "Genre",
"size": "Größe",
"role": "Rolle",
"missing": "Fehlend"
"role": "Rolle"
},
"roles": {
"albumartist": "Albuminterpret |||| Albuminterpreten",
@@ -178,7 +172,7 @@
}
},
"playlist": {
"name": "Wiedergabeliste |||| Wiedergabelisten",
"name": "Playlist |||| Playlists",
"fields": {
"name": "Name",
"duration": "Dauer",
@@ -192,12 +186,11 @@
"path": "Importieren aus"
},
"actions": {
"selectPlaylist": "Wiedergabeliste auswählen:",
"selectPlaylist": "Titel zur Playlist hinzufügen",
"addNewPlaylist": "\"%{name}\" erstellen",
"export": "Exportieren",
"makePublic": "Öffentlich machen",
"makePrivate": "Privat stellen",
"saveQueue": "Warteschlange in Wiedergabeliste speichern"
"makePrivate": "Privat stellen"
},
"message": {
"duplicate_song": "Duplikate hinzufügen",
@@ -242,13 +235,11 @@
"updatedAt": "Fehlt seit"
},
"actions": {
"remove": "Entfernen",
"remove_all": "alle entfernen"
"remove": "Entfernen"
},
"notifications": {
"removed": "Fehlende Datei(en) entfernt"
},
"empty": "keine fehlenden Dateien"
}
}
},
"ra": {
@@ -400,10 +391,10 @@
"note": "HINWEIS",
"transcodingDisabled": "Die Änderung der Transcodierungskonfiguration über die Web-UI ist aus Sicherheitsgründen deaktiviert. Wenn du die Transcodierungsoptionen ändern (bearbeiten oder hinzufügen) möchtest, starte den Server mit der Konfigurationsoption %{config} neu.",
"transcodingEnabled": "Navidrome läuft derzeit mit %{config}, wodurch es möglich ist, Systembefehle aus den Transkodierungseinstellungen über die Webschnittstelle auszuführen. Wir empfehlen, es aus Sicherheitsgründen zu deaktivieren und nur bei der Konfiguration von Transkodierungsoptionen zu aktivieren.",
"songsAddedToPlaylist": "Einen Titel zur Wiedergabeliste hinzugefügt |||| %{smart_count} Titel zur Wiedergabeliste hinzugefügt",
"noPlaylistsAvailable": "Keine Wiedergabeliste verfügbar",
"songsAddedToPlaylist": "Einen Titel zur Playlist hinzugefügt |||| %{smart_count} Titel zur Playlist hinzugefügt",
"noPlaylistsAvailable": "Keine Playlist verfügbar",
"delete_user_title": "Benutzer '%{name}' löschen",
"delete_user_content": "Möchtest du diesen Benutzer und alle seine Daten (einschließlich Wiedergabelisten und Einstellungen) wirklich löschen?",
"delete_user_content": "Möchtest du diesen Benutzer und alle seine Daten (einschließlich Playlisten und Einstellungen) wirklich löschen?",
"notifications_blocked": "Sie haben Benachrichtigungen für diese Seite in den Einstellungen Ihres Browsers blockiert",
"notifications_not_available": "Dieser Browser unterstützt keine Desktop-Benachrichtigungen",
"lastfmLinkSuccess": "Last.fm Verbindung hergestellt und scrobbling aktiviert",
@@ -428,9 +419,7 @@
"downloadDialogTitle": "Download %{resource} '%{name}' (%{size})",
"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.",
"remove_all_missing_title": "Alle fehlenden Dateien entfernen",
"remove_all_missing_content": "Möchtest du wirklich alle Fehlenden Dateien aus der Datenbank entfernen? Alle Referenzen zu den Dateien wie Anzahl Wiedergaben und Bewertungen werden permanent gelöscht."
"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",
@@ -458,8 +447,8 @@
},
"albumList": "Alben",
"about": "Über",
"playlists": "Wiedergabelisten",
"sharedPlaylists": "Geteilte Wiedergabelisten"
"playlists": "Playlisten",
"sharedPlaylists": "Geteilte Playlisten"
},
"player": {
"playListsText": "Wiedergabeliste abspielen",
@@ -504,10 +493,7 @@
"quickScan": "Schneller Scan",
"fullScan": "Kompletter Scan",
"serverUptime": "Server-Betriebszeit",
"serverDown": "OFFLINE",
"scanType": "Typ",
"status": "Scan Fehler",
"elapsedTime": "Laufzeit"
"serverDown": "OFFLINE"
},
"help": {
"title": "Navidrome Hotkeys",

View File

@@ -33,9 +33,7 @@
"tags": "Πρόσθετες Ετικέτες",
"mappedTags": "Χαρτογραφημένες ετικέτες",
"rawTags": "Ακατέργαστες ετικέτες",
"bitDepth": "Λίγο βάθος",
"sampleRate": "Ποσοστό δειγματοληψίας",
"missing": "Απών"
"bitDepth": "Λίγο βάθος"
},
"actions": {
"addToQueue": "Αναπαραγωγη Μετα",
@@ -74,8 +72,7 @@
"grouping": "Ομαδοποίηση",
"media": "Μέσα",
"mood": "Διάθεση",
"date": "Ημερομηνία Ηχογράφησης",
"missing": "Απών"
"date": "Ημερομηνία Ηχογράφησης"
},
"actions": {
"playAll": "Αναπαραγωγή",
@@ -107,8 +104,7 @@
"rating": "Βαθμολογια",
"genre": "Είδος",
"size": "Μέγεθος",
"role": "Ρόλος",
"missing": "Απών"
"role": "Ρόλος"
},
"roles": {
"albumartist": "Καλλιτέχνης Άλμπουμ |||| Καλλιτέχνες άλμπουμ",
@@ -136,7 +132,7 @@
"name": "Όνομα",
"password": "Κωδικός Πρόσβασης",
"createdAt": "Δημιουργήθηκε στις",
"changePassword": "Αλλαγή Κωδικού Πρόσβασης?",
"changePassword": "Αλλαγή Κωδικού Πρόσβασης;",
"currentPassword": "Υπάρχων Κωδικός Πρόσβασης",
"newPassword": "Νέος Κωδικός Πρόσβασης",
"token": "Token",
@@ -196,12 +192,11 @@
"addNewPlaylist": "Δημιουργία \"%{name}\"",
"export": "Εξαγωγη",
"makePublic": "Να γίνει δημόσιο",
"makePrivate": "Να γίνει ιδιωτικό",
"saveQueue": "Αποθήκευση ουράς στη λίστα αναπαραγωγής"
"makePrivate": "Να γίνει ιδιωτικό"
},
"message": {
"duplicate_song": "Προσθήκη διπλοεγγραφών τραγουδιών",
"song_exist": "Υπάρχουν διπλοεγγραφές στην λίστα αναπαραγωγής. Θέλετε να προστεθούν οι διπλοεγγραφές ή να τις παραβλέψετε?"
"song_exist": "Υπάρχουν διπλοεγγραφές στην λίστα αναπαραγωγής. Θέλετε να προστεθούν οι διπλοεγγραφές ή να τις παραβλέψετε;"
}
},
"radio": {
@@ -242,8 +237,7 @@
"updatedAt": "Εξαφανίστηκε"
},
"actions": {
"remove": "Αφαίρεση",
"remove_all": "Αφαίρεση όλων"
"remove": "Αφαίρεση"
},
"notifications": {
"removed": "Λείπει αρχείο(α) αφαιρέθηκε"
@@ -311,7 +305,7 @@
"skip": "Παράβλεψη",
"bulk_actions_mobile": "1 |||| %{smart_count}",
"share": "Κοινοποίηση",
"download": "Λήψη"
"download": "Λήψη "
},
"boolean": {
"true": "Ναι",
@@ -350,10 +344,10 @@
},
"message": {
"about": "Σχετικά",
"are_you_sure": "Είστε σίγουροι?",
"bulk_delete_content": "Είστε σίγουροι πως θέλετε να διαγράψετε το %{name}? |||| Είστε σίγουροι πως θέλετε να διαγράψετε τα %{smart_count}?",
"are_you_sure": "Είστε σίγουροι;",
"bulk_delete_content": "Είστε σίγουροι πως θέλετε να διαγράψετε το %{name}; |||| Είστε σίγουροι πως θέλετε να διαγράψετε τα %{smart_count};",
"bulk_delete_title": "Διαγραφή του %{name} |||| Διαγραφή του %{smart_count} %{name}",
"delete_content": "Είστε σίγουροι πως θέλετε να διαγράψετε αυτό το αντικείμενο?",
"delete_content": "Είστε σίγουροι πως θέλετε να διαγράψετε αυτό το αντικείμενο;",
"delete_title": "Διαγραφή του %{name} #%{id}",
"details": "Λεπτομέρειες",
"error": "Παρουσιάστηκε ένα πρόβλημα από τη μεριά του πελάτη και το αίτημα σας δεν μπορεί να ολοκληρωθεί.",
@@ -362,12 +356,12 @@
"no": "Όχι",
"not_found": "Είτε έχετε εισάγει λανθασμένο URL, είτε ακολουθήσατε έναν υπερσύνδεσμο που δεν ισχύει.",
"yes": "Ναι",
"unsaved_changes": "Μερικές από τις αλλαγές σας δεν έχουν αποθηκευτεί. Είστε σίγουροι πως θέλετε να τις αγνοήσετε?"
"unsaved_changes": "Μερικές από τις αλλαγές σας δεν έχουν αποθηκευτεί. Είστε σίγουροι πως θέλετε να τις αγνοήσετε;"
},
"navigation": {
"no_results": "Δεν βρέθηκαν αποτελέσματα",
"no_more_results": "Η σελίδα %{page} είναι εκτός ορίων. Δοκιμάστε την προηγούμενη σελίδα.",
"page_out_of_boundaries": "Η σελίδα %{page} είναι εκτός ορίων",
"page_out_of_boundaries": "Η σελίδα {page} είναι εκτός ορίων",
"page_out_from_end": "Δεν είναι δυνατή η πλοήγηση πέραν της τελευταίας σελίδας",
"page_out_from_begin": "Δεν είναι δυνατή η πλοήγηση πριν τη σελίδα 1",
"page_range_info": "%{offsetBegin}-%{offsetEnd} από %{total}",
@@ -403,7 +397,7 @@
"songsAddedToPlaylist": "Προστέθηκε 1 τραγούδι στη λίστα αναπαραγωγής |||| Προστέθηκαν %{smart_count} τραγούδια στη λίστα αναπαραγωγής",
"noPlaylistsAvailable": "Κανένα διαθέσιμο",
"delete_user_title": "Διαγραφή του χρήστη '%{name}'",
"delete_user_content": "Είστε σίγουροι πως θέλετε να διαγράψετε αυτό το χρήστη και όλα τα δεδομένα του (συμπεριλαμβανομένων των λιστών αναπαραγωγής και προτιμήσεων)?",
"delete_user_content": "Είστε σίγουροι πως θέλετε να διαγράψετε αυτό το χρήστη και όλα τα δεδομένα του (συμπεριλαμβανομένων των λιστών αναπαραγωγής και προτιμήσεων);",
"notifications_blocked": "Έχετε μπλοκάρει τις Ειδοποιήσεις από τη σελίδα, μέσω των ρυθμίσεων του περιηγητή ιστού σας",
"notifications_not_available": "Αυτός ο περιηγητής ιστού δεν υποστηρίζει ειδοποιήσεις στην επιφάνεια εργασίας ή δεν έχετε πρόσβαση στο Navidrome μέσω https",
"lastfmLinkSuccess": "Το Last.fm έχει διασυνδεθεί επιτυχώς και η λειτουργία scrobbling ενεργοποιήθηκε",
@@ -428,9 +422,7 @@
"downloadDialogTitle": "Λήψη %{resource} '%{name}'(%{size})",
"shareCopyToClipboard": "Αντιγραφή στο πρόχειρο: Ctrl+C, Enter",
"remove_missing_title": "Αφαιρέστε τα αρχεία που λείπουν",
"remove_missing_content": "Είστε βέβαιοι ότι θέλετε να αφαιρέσετε τα επιλεγμένα αρχεία που λείπουν από τη βάση δεδομένων? Αυτό θα καταργήσει οριστικά τυχόν αναφορές σε αυτά, συμπεριλαμβανομένων των αριθμών παιχνιδιών και των αξιολογήσεών τους.",
"remove_all_missing_title": "Αφαίρεση όλων των αρχείων που λείπουν",
"remove_all_missing_content": "Είστε βέβαιοι ότι θέλετε να καταργήσετε όλα τα αρχεία που λείπουν από τη βάση δεδομένων? Αυτό θα καταργήσει οριστικά τυχόν αναφορές σε αυτά, συμπεριλαμβανομένου του αριθμού αναπαραγωγών και των αξιολογήσεών τους."
"remove_missing_content": "Είστε βέβαιοι ότι θέλετε να αφαιρέσετε τα επιλεγμένα αρχεία που λείπουν από τη βάση δεδομένων; Αυτό θα καταργήσει οριστικά τυχόν αναφορές σε αυτά, συμπεριλαμβανομένων των αριθμών παιχνιδιών και των αξιολογήσεών τους."
},
"menu": {
"library": "Βιβλιοθήκη",
@@ -504,10 +496,7 @@
"quickScan": "Γρήγορη Σάρωση",
"fullScan": "Πλήρης Σάρωση",
"serverUptime": "Λειτουργία Διακομιστή",
"serverDown": "ΕΚΤΟΣ ΣΥΝΔΕΣΗΣ",
"scanType": "Τύπος",
"status": "Σφάλμα σάρωσης",
"elapsedTime": "Χρόνος που πέρασε"
"serverDown": "ΕΚΤΟΣ ΣΥΝΔΕΣΗΣ"
},
"help": {
"title": "Συντομεύσεις του Navidrome",

View File

@@ -24,18 +24,16 @@
"rating": "Takso",
"quality": "Kvalito",
"bpm": "Pulsrapideco",
"playDate": "Laste Ludita",
"channels": "Kanaloj",
"createdAt": "Dato de aligo",
"playDate": "",
"channels": "",
"createdAt": "",
"grouping": "",
"mood": "Humoro",
"mood": "",
"participants": "",
"tags": "Aldonaj Etikedoj",
"mappedTags": "Mapigitaj etikedoj",
"rawTags": "Krudaj etikedoj",
"bitDepth": "",
"sampleRate": "",
"missing": ""
"tags": "",
"mappedTags": "",
"rawTags": "",
"bitDepth": ""
},
"actions": {
"addToQueue": "Ludi Poste",
@@ -44,7 +42,7 @@
"shuffleAll": "Miksu Ĉiujn",
"download": "Elŝuti",
"playNext": "Ludu Poste",
"info": "Akiri Informon"
"info": ""
}
},
"album": {
@@ -62,20 +60,19 @@
"updatedAt": "Ĝisdatigita je :",
"comment": "Komento",
"rating": "Takso",
"createdAt": "Dato aldonita",
"size": "Grando",
"originalDate": "Originala",
"releaseDate": "Publikiĝis",
"releases": "Publikiĝo |||| Publikiĝoj",
"released": "Publikiĝis",
"createdAt": "",
"size": "",
"originalDate": "",
"releaseDate": "",
"releases": "",
"released": "",
"recordLabel": "",
"catalogNum": "",
"releaseType": "Tipo",
"releaseType": "",
"grouping": "",
"media": "",
"mood": "Humoro",
"date": "",
"missing": ""
"mood": "",
"date": ""
},
"actions": {
"playAll": "Ludi",
@@ -84,44 +81,43 @@
"shuffle": "Miksi",
"addToPlaylist": "Aldoni al la Ludlisto",
"download": "Elŝuti",
"info": "Akiri Informon",
"share": "Diskonigi"
"info": "",
"share": ""
},
"lists": {
"all": "Ĉiuj",
"random": "Hazardaj",
"recentlyAdded": "Lastatempe Aldonitaj",
"recentlyPlayed": "Lastatempe Luditaj",
"random": "Hazarda",
"recentlyAdded": "Lastatempe Aldonita",
"recentlyPlayed": "Lastatempe Ludita",
"mostPlayed": "Plej Luditaj",
"starred": "Stelplenaj",
"topRated": "Plej Alte Taksitaj"
"starred": "Stelplena",
"topRated": "Plej Alte Taksite"
}
},
"artist": {
"name": "Artisto |||| Artistoj",
"fields": {
"name": "Nomo",
"albumCount": "Kvanto da Albumoj",
"songCount": "Kanta Kalkulo",
"playCount": "Ludoj",
"albumCount": "Nombro da albumoj",
"songCount": "Kanto kalkula",
"playCount": "Teatraĵoj",
"rating": "Takso",
"genre": "Ĝenro",
"size": "Grando",
"role": "",
"missing": ""
"genre": "",
"size": "",
"role": ""
},
"roles": {
"albumartist": "Albuma Artisto |||| Albumaj Artistoj",
"artist": "Artisto |||| Artistoj",
"composer": "Komponisto |||| Komponistoj",
"conductor": "Dirigento |||| Dirigentoj",
"lyricist": "Kantoteksisto |||| Kantotekstistoj",
"arranger": "Aranĝisto |||| Aranĝistoj",
"albumartist": "",
"artist": "",
"composer": "",
"conductor": "",
"lyricist": "",
"arranger": "",
"producer": "",
"director": "",
"engineer": "",
"mixer": "Miksisto |||| Miksistoj",
"remixer": "Remiksisto |||| Remiksistoj",
"mixer": "",
"remixer": "",
"djmixer": "",
"performer": ""
}
@@ -139,8 +135,8 @@
"changePassword": "Ĉu Ŝanĝi Pasvorton?",
"currentPassword": "Nuna Pasvorto",
"newPassword": "Nova Pasvorto",
"token": "Ĵetono",
"lastAccessAt": "Lasta Atingo"
"token": "",
"lastAccessAt": ""
},
"helperTexts": {
"name": "Ŝanĝoj de via nomo nur ĝisdatiĝs je via sekvanta ensaluto"
@@ -151,8 +147,8 @@
"deleted": "Uzanto forigita"
},
"message": {
"listenBrainzToken": "Enigi vian uzantan ĵetonon de ListenBrainz.",
"clickHereForToken": "Alkakli ĉi tie por akiri vian ĵetonon"
"listenBrainzToken": "",
"clickHereForToken": ""
}
},
"player": {
@@ -165,7 +161,7 @@
"userName": "Uzantnomo",
"lastSeen": "Laste Vidita Je",
"reportRealPath": "Raporti vera pado",
"scrobbleEnabled": "Sendi Scrobbles al eksteraj servoj"
"scrobbleEnabled": ""
}
},
"transcoding": {
@@ -195,9 +191,8 @@
"selectPlaylist": "Elektu ludliston :",
"addNewPlaylist": "Krei \"%{name}\"",
"export": "Eksporti",
"makePublic": "Publikigi",
"makePrivate": "Malpublikigi",
"saveQueue": ""
"makePublic": "",
"makePrivate": ""
},
"message": {
"duplicate_song": "Aldoni duobligitajn kantojn",
@@ -205,33 +200,33 @@
}
},
"radio": {
"name": "Radio |||| Radioj",
"name": "",
"fields": {
"name": "Nomo",
"streamUrl": "Flua Ligilo",
"homePageUrl": "Hejmpaĝa Ligilo",
"updatedAt": "Ĝisdatiĝis je",
"createdAt": "Kreiĝis je"
"name": "",
"streamUrl": "",
"homePageUrl": "",
"updatedAt": "",
"createdAt": ""
},
"actions": {
"playNow": "Ludi Nun"
"playNow": ""
}
},
"share": {
"name": "Diskonigo |||| Diskonigoj",
"name": "",
"fields": {
"username": "Diskonigite De",
"url": "Ligilo",
"description": "Priskribo",
"contents": "Enhavo",
"expiresAt": "Senvalidiĝas",
"lastVisitedAt": "Laste Vizitita",
"visitCount": "Vizitoj",
"format": "Formato",
"maxBitRate": "Maks. Bitrapido",
"updatedAt": "Ĝisdatiĝis je",
"createdAt": "Fariĝis je",
"downloadable": "Ĉu Ebligi Elŝutojn?"
"username": "",
"url": "",
"description": "",
"contents": "",
"expiresAt": "",
"lastVisitedAt": "",
"visitCount": "",
"format": "",
"maxBitRate": "",
"updatedAt": "",
"createdAt": "",
"downloadable": ""
}
},
"missing": {
@@ -242,8 +237,7 @@
"updatedAt": ""
},
"actions": {
"remove": "",
"remove_all": ""
"remove": ""
},
"notifications": {
"removed": ""
@@ -264,7 +258,7 @@
"sign_in": "Ensaluti",
"sign_in_error": "Aŭtentikigo malsukcesis, bonvolu reprovi",
"logout": "Elsaluti",
"insightsCollectionNote": "Navidrome kolektas anoniman uzdatumon por helpi\nplibonigi la projekton. Alklaku [ĉi tie] por lerni pli kaj\nsupozi permeson se vi volas"
"insightsCollectionNote": ""
},
"validation": {
"invalidChars": "Bonvolu uzi nur literojn kaj ciferojn",
@@ -279,7 +273,7 @@
"oneOf": "Devas esti unu el: %{options}",
"regex": "Devas kongrui kun specifa formato (regexp): %{pattern}",
"unique": "Devas esti unika",
"url": "Devas esti valida ligilo"
"url": ""
},
"action": {
"add_filter": "Aldoni filtrilon",
@@ -309,9 +303,9 @@
"close_menu": "Fermu menuon",
"unselect": "Malelekti",
"skip": "Pasigi",
"bulk_actions_mobile": "1 |||| %{smart_count}",
"share": "Diskonigi",
"download": "Elŝuti"
"bulk_actions_mobile": "",
"share": "",
"download": ""
},
"boolean": {
"true": "Jes",
@@ -387,13 +381,13 @@
"i18n_error": "Ne eblas ŝargi la tradukojn por la specifa lingvo",
"canceled": "Ago nuligita",
"logged_out": "Via seanco finiĝis, bonvolu rekonekti.",
"new_version": "Nova versio haveblas! Bonvolu reŝargi la fenestron."
"new_version": ""
},
"toggleFieldsMenu": {
"columnsToDisplay": "Kolumnoj Por Montri",
"columnsToDisplay": "",
"layout": "Aranĝo",
"grid": "Krado",
"table": "Tabelo"
"table": ""
}
},
"message": {
@@ -406,31 +400,29 @@
"delete_user_content": "Ĉu vi certas, ke vi volas forigi ĉi tiun uzanton kaj ĉiujn iliajn datumojn (inkluzive ludlistojn kaj preferojn) ?",
"notifications_blocked": "Vi blokis sciigojn por ĉi tiu retejo en la agordoj de via retumilo",
"notifications_not_available": "Ĉi tiu retumilo ne subtenas labortablajn sciigojn aŭ vi ne aliras Navidrome per https",
"lastfmLinkSuccess": "Last.fm sukcese ligiĝis kaj scrobbling ebliĝis",
"lastfmLinkFailure": "Last.fm ne povis ligiĝi",
"lastfmUnlinkSuccess": "Last.fm malligiĝis kaj scrobbling malebliĝis",
"lastfmUnlinkFailure": "Last.fm ne povis malligiĝi",
"lastfmLinkSuccess": "",
"lastfmLinkFailure": "",
"lastfmUnlinkSuccess": "",
"lastfmUnlinkFailure": "",
"openIn": {
"lastfm": "Malfermi en Last.fm",
"musicbrainz": "Malfermi en MusicBrainz"
"lastfm": "",
"musicbrainz": ""
},
"lastfmLink": "Legi Pli...",
"listenBrainzLinkSuccess": "ListenBrainz sukcese ligiĝis kaj scrobbling ebliĝis kiel uzanto: %{user}",
"listenBrainzLinkFailure": "ListenBrainz ne povis ligiĝi: %{error}",
"listenBrainzUnlinkSuccess": "ListenBrainz malligiĝis kaj scrobbling malebliĝis",
"listenBrainzUnlinkFailure": "ListenBrainz ne povis malligiĝi",
"downloadOriginalFormat": "Elŝuti en originala formato",
"shareOriginalFormat": "Diskonigi en originala formato",
"shareDialogTitle": "Diskonigi %{resource} '%{name}'",
"shareBatchDialogTitle": "Diskonigi 1 %{resource} |||| Diskonigi %{smart_count} %{resource}",
"shareSuccess": "Ligilo kopiiĝis al la tondujo: %{url}",
"shareFailure": "Eraro de kopio de ligilo %{url} al la tondujo",
"downloadDialogTitle": "Elŝuti %{resource} '%{name}' (%{size})",
"shareCopyToClipboard": "Kopii al la tondujo: Ctrl+C, Enter",
"lastfmLink": "",
"listenBrainzLinkSuccess": "",
"listenBrainzLinkFailure": "",
"listenBrainzUnlinkSuccess": "",
"listenBrainzUnlinkFailure": "",
"downloadOriginalFormat": "",
"shareOriginalFormat": "",
"shareDialogTitle": "",
"shareBatchDialogTitle": "",
"shareSuccess": "",
"shareFailure": "",
"downloadDialogTitle": "",
"shareCopyToClipboard": "",
"remove_missing_title": "",
"remove_missing_content": "Ĉu vi certas, ke vi volas forigi la elektitajn mankajn dosierojn de la datumbazo? Ĉi tio forigos eterne ĉiujn referencojn de ili, inkluzive iliajn ludkvantojn kaj taksojn.",
"remove_all_missing_title": "",
"remove_all_missing_content": ""
"remove_missing_content": ""
},
"menu": {
"library": "Biblioteko",
@@ -444,22 +436,22 @@
"language": "Lingvo",
"defaultView": "Defaŭlta Vido",
"desktop_notifications": "Labortablaj sciigoj",
"lastfmScrobbling": "Scrobble al Last.fm",
"listenBrainzScrobbling": "Scrobble al ListenBrainz",
"replaygain": "ReplayGain-Reĝimo",
"preAmp": "ReplayGain PreAmp (dB)",
"lastfmScrobbling": "",
"listenBrainzScrobbling": "",
"replaygain": "",
"preAmp": "",
"gain": {
"none": "Malebligita",
"album": "Uzi Albuman Songajnon",
"track": "Uzi Kantan Songajnon"
"none": "",
"album": "",
"track": ""
},
"lastfmNotConfigured": ""
}
},
"albumList": "Albumoj",
"about": "Pri",
"playlists": "Ludlistoj",
"sharedPlaylists": "Diskonigitaj Ludistoj"
"playlists": "",
"sharedPlaylists": ""
},
"player": {
"playListsText": "Atendovico",
@@ -493,7 +485,7 @@
"featureRequests": "Trajta peto",
"lastInsightsCollection": "",
"insights": {
"disabled": "Malebligita",
"disabled": "",
"waiting": ""
}
}
@@ -504,10 +496,7 @@
"quickScan": "Rapida Skanado",
"fullScan": "Plena Skanado",
"serverUptime": "Servila daŭro de funkciado",
"serverDown": "SENKONEKTA",
"scanType": "",
"status": "",
"elapsedTime": ""
"serverDown": "SENKONEKTA"
},
"help": {
"title": "Navidrome klavkomando",
@@ -520,7 +509,7 @@
"vol_up": "Pli volumo",
"vol_down": "Malpli volumo",
"toggle_love": "Baskuli la stelon de nuna kanto",
"current_song": "Iri al Nuna Kanto"
"current_song": ""
}
}
}

View File

@@ -28,19 +28,16 @@
"channels": "Canales",
"createdAt": "Creado el",
"grouping": "Agrupación",
"mood": "Estado de ánimo",
"mood": "",
"participants": "Participantes",
"tags": "Etiquetas",
"mappedTags": "Etiquetas asignadas",
"rawTags": "Etiquetas sin procesar",
"bitDepth": "Profundidad de bits",
"sampleRate": "Frecuencia de muestreo",
"missing": "Faltante"
"rawTags": "Etiquetas sin procesar"
},
"actions": {
"addToQueue": "Reproducir después",
"playNow": "Reproducir ahora",
"addToPlaylist": "Agregar a la playlist",
"addToPlaylist": "Agregar a la lista de reproducción",
"shuffleAll": "Todas aleatorias",
"download": "Descarga",
"playNext": "Siguiente",
@@ -72,10 +69,8 @@
"catalogNum": "Número de catálogo",
"releaseType": "Tipo de lanzamiento",
"grouping": "Agrupación",
"media": "Medios",
"mood": "Estado de ánimo",
"date": "Fecha de grabación",
"missing": "Faltante"
"media": "",
"mood": ""
},
"actions": {
"playAll": "Reproducir",
@@ -107,8 +102,7 @@
"rating": "Calificación",
"genre": "Género",
"size": "Tamaño",
"role": "Rol",
"missing": "Faltante"
"role": "Rol"
},
"roles": {
"albumartist": "Artista del álbum",
@@ -196,12 +190,11 @@
"addNewPlaylist": "Creada \"%{name}\"",
"export": "Exportar",
"makePublic": "Hazla pública",
"makePrivate": "Hazla privada",
"saveQueue": "Guardar la fila de reproducción en una playlist"
"makePrivate": "Hazla privada"
},
"message": {
"duplicate_song": "Algunas de las canciones seleccionadas están presentes en la playlist",
"song_exist": "Se están agregando duplicados a la playlist. ¿Quieres agregar los duplicados o omitirlos?"
"duplicate_song": "Algunas de las canciones seleccionadas están presentes en la lista de reproducción",
"song_exist": "Se están agregando duplicados a la lista de reproducción. ¿Quieres agregar los duplicados o omitirlos?"
}
},
"radio": {
@@ -242,13 +235,11 @@
"updatedAt": "Actualizado el"
},
"actions": {
"remove": "Eliminar",
"remove_all": "Eliminar todo"
"remove": "Eliminar"
},
"notifications": {
"removed": "Eliminado"
},
"empty": "No hay archivos perdidos"
}
}
},
"ra": {
@@ -428,9 +419,7 @@
"downloadDialogTitle": "Descargar %{resource} '%{name}' (%{size})",
"shareCopyToClipboard": "Copiar al portapapeles: Ctrl+C, Intro",
"remove_missing_title": "Eliminar elemento faltante",
"remove_missing_content": "¿Realmente desea eliminar los archivos faltantes seleccionados de la base de datos? Esto eliminará permanentemente cualquier referencia a ellos, incluidas sus reproducciones y valoraciones.",
"remove_all_missing_title": "Eliminar todos los archivos perdidos",
"remove_all_missing_content": "¿Realmente desea eliminar todos los archivos faltantes de la base de datos? Esto eliminará permanentemente cualquier referencia a ellos, incluidas sus reproducciones y valoraciones."
"remove_missing_content": ""
},
"menu": {
"library": "Biblioteca",
@@ -462,7 +451,7 @@
"sharedPlaylists": "Playlists Compartidas"
},
"player": {
"playListsText": "Fila de reproducción",
"playListsText": "Lista de reproducción",
"openText": "Abrir",
"closeText": "Cerrar",
"notContentText": "Sin música",
@@ -504,10 +493,7 @@
"quickScan": "Escaneo rápido",
"fullScan": "Escaneo completo",
"serverUptime": "Uptime del servidor",
"serverDown": "OFFLINE",
"scanType": "Tipo",
"status": "Error de escaneo",
"elapsedTime": "Tiempo transcurrido"
"serverDown": "OFFLINE"
},
"help": {
"title": "Atajos de teclado de Navidrome",
@@ -523,4 +509,4 @@
"current_song": "Canción actual"
}
}
}
}

View File

@@ -32,10 +32,7 @@
"participants": "Lisäosallistujat",
"tags": "Lisätunnisteet",
"mappedTags": "Mäpättyt tunnisteet",
"rawTags": "Raakatunnisteet",
"bitDepth": "Bittisyvyys",
"sampleRate": "Näytteenottotaajuus",
"missing": ""
"rawTags": "Raakatunnisteet"
},
"actions": {
"addToQueue": "Lisää jonoon",
@@ -73,9 +70,7 @@
"releaseType": "Tyyppi",
"grouping": "Ryhmittely",
"media": "Media",
"mood": "Tunnelma",
"date": "Tallennuspäivä",
"missing": ""
"mood": "Tunnelma"
},
"actions": {
"playAll": "Soita",
@@ -107,8 +102,7 @@
"rating": "Arvostelu",
"genre": "Tyylilaji",
"size": "Koko",
"role": "Rooli",
"missing": ""
"role": "Rooli"
},
"roles": {
"albumartist": "Albumitaiteilija |||| Albumitaiteilijat",
@@ -196,8 +190,7 @@
"addNewPlaylist": "Luo \"%{name}\"",
"export": "Vie",
"makePublic": "Tee julkinen",
"makePrivate": "Tee yksityinen",
"saveQueue": ""
"makePrivate": "Tee yksityinen"
},
"message": {
"duplicate_song": "Lisää olemassa oleva kappale",
@@ -242,13 +235,11 @@
"updatedAt": "Katosi"
},
"actions": {
"remove": "Poista",
"remove_all": ""
"remove": "Poista"
},
"notifications": {
"removed": "Puuttuvat tiedostot poistettu"
},
"empty": "Ei puuttuvia tiedostoja"
}
}
},
"ra": {
@@ -428,9 +419,7 @@
"downloadDialogTitle": "Lataa %{resource} '%{name}' (%{size})",
"shareCopyToClipboard": "Kopio leikepöydälle: Ctrl+C, Enter",
"remove_missing_title": "Poista puuttuvat tiedostot",
"remove_missing_content": "Oletko varma, että haluat poistaa valitut puuttuvat tiedostot tietokannasta? Tämä poistaa pysyvästi kaikki viittaukset niihin, mukaan lukien niiden soittojen määrät ja arvostelut.",
"remove_all_missing_title": "",
"remove_all_missing_content": ""
"remove_missing_content": "Oletko varma, että haluat poistaa valitut puuttuvat tiedostot tietokannasta? Tämä poistaa pysyvästi kaikki viittaukset niihin, mukaan lukien niiden soittojen määrät ja arvostelut."
},
"menu": {
"library": "Kirjasto",
@@ -504,10 +493,7 @@
"quickScan": "Nopea tarkistus",
"fullScan": "Täysi tarkistus",
"serverUptime": "Palvelun käyttöaika",
"serverDown": "SAMMUTETTU",
"scanType": "",
"status": "",
"elapsedTime": ""
"serverDown": "SAMMUTETTU"
},
"help": {
"title": "Navidrome pikapainikkeet",

View File

@@ -18,6 +18,7 @@
"size": "Taille",
"updatedAt": "Mise à jour",
"bitRate": "Bitrate",
"sampleRate": "Fréquence d'échantillonnage",
"discSubtitle": "Sous-titre du disque",
"starred": "Favoris",
"comment": "Commentaire",
@@ -33,9 +34,7 @@
"tags": "Étiquettes supplémentaires",
"mappedTags": "Étiquettes correspondantes",
"rawTags": "Étiquettes brutes",
"bitDepth": "Profondeur de bits",
"sampleRate": "Fréquence d'échantillonnage",
"missing": "Manquant"
"bitDepth": "Profondeur de bit"
},
"actions": {
"addToQueue": "Ajouter à la file",
@@ -59,6 +58,7 @@
"genre": "Genre",
"compilation": "Compilation",
"year": "Année",
"date": "Date d'enregistrement",
"updatedAt": "Mis à jour le",
"comment": "Commentaire",
"rating": "Classement",
@@ -73,9 +73,7 @@
"releaseType": "Type",
"grouping": "Regroupement",
"media": "Média",
"mood": "Humeur",
"date": "Date d'enregistrement",
"missing": "Manquant"
"mood": "Humeur"
},
"actions": {
"playAll": "Lire",
@@ -107,8 +105,7 @@
"rating": "Classement",
"genre": "Genre",
"size": "Taille",
"role": "Rôle",
"missing": "Manquant"
"role": "Rôle"
},
"roles": {
"albumartist": "Artiste de l'album |||| Artistes de l'album",
@@ -196,8 +193,7 @@
"addNewPlaylist": "Créer \"%{name}\"",
"export": "Exporter",
"makePublic": "Rendre publique",
"makePrivate": "Rendre privée",
"saveQueue": "Sauvegarder la file de lecture dans la playlist"
"makePrivate": "Rendre privée"
},
"message": {
"duplicate_song": "Pistes déjà présentes dans la playlist",
@@ -242,8 +238,7 @@
"updatedAt": "A disparu le"
},
"actions": {
"remove": "Supprimer",
"remove_all": "Tout supprimer"
"remove": "Supprimer"
},
"notifications": {
"removed": "Fichier(s) manquant(s) supprimé(s)"
@@ -428,9 +423,7 @@
"downloadDialogTitle": "Télécharger %{resource} '%{name}' (%{size})",
"shareCopyToClipboard": "Copier vers le presse-papier : Ctrl+C, Enter",
"remove_missing_title": "Supprimer les fichiers manquants",
"remove_missing_content": "Êtes-vous sûr(e) de vouloir supprimer les fichiers manquants sélectionnés de la base de données ? Ceci supprimera définitivement toute référence à ceux-ci, y compris leurs nombres d'écoutes et leurs notations",
"remove_all_missing_title": "Supprimer tous les fichiers manquants",
"remove_all_missing_content": "Êtes-vous sûr(e) de vouloir supprimer tous les fichiers manquants de la base de données ? Cette action est permanente et supprimera leurs nombres d'écoutes, leur notations et tout ce qui y fait référence."
"remove_missing_content": "Êtes-vous sûr(e) de vouloir supprimer les fichiers manquants sélectionnés de la base de données ? Ceci supprimera définitivement toute référence à ceux-ci, y compris leurs nombres d'écoutes et leurs notations"
},
"menu": {
"library": "Bibliothèque",
@@ -504,10 +497,7 @@
"quickScan": "Scan rapide",
"fullScan": "Scan complet",
"serverUptime": "Disponibilité du serveur",
"serverDown": "HORS LIGNE",
"scanType": "Type",
"status": "Erreur de scan",
"elapsedTime": "Temps écoulé"
"serverDown": "HORS LIGNE"
},
"help": {
"title": "Raccourcis Navidrome",

View File

@@ -34,8 +34,7 @@
"participants": "További résztvevők",
"tags": "További címkék",
"mappedTags": "Feldolgozott címkék",
"rawTags": "Nyers címkék",
"missing": "Hiányzó"
"rawTags": "Nyers címkék"
},
"actions": {
"addToQueue": "Lejátszás útolsóként",
@@ -74,8 +73,7 @@
"releaseType": "Típus",
"grouping": "Csoportosítás",
"media": "Média",
"mood": "Hangulat",
"missing": "Hiányzó"
"mood": "Hangulat"
},
"actions": {
"playAll": "Lejátszás",
@@ -107,8 +105,7 @@
"rating": "Értékelés",
"genre": "Stílus",
"size": "Méret",
"role": "Szerep",
"missing": "Hiányzó"
"role": "Szerep"
},
"roles": {
"albumartist": "Album előadó |||| Album előadók",

View File

@@ -32,10 +32,7 @@
"participants": "Partisipan tambahan",
"tags": "Tag tambahan",
"mappedTags": "Tag yang dipetakan",
"rawTags": "Tag raw",
"bitDepth": "Bit depth",
"sampleRate": "Sample rate",
"missing": "Hilang"
"rawTags": "Tag raw"
},
"actions": {
"addToQueue": "Tambah ke antrean",
@@ -73,9 +70,7 @@
"releaseType": "Tipe",
"grouping": "Pengelompokkan",
"media": "Media",
"mood": "Mood",
"date": "Tanggal Perekaman",
"missing": "Hilang"
"mood": "Mood"
},
"actions": {
"playAll": "Putar",
@@ -107,8 +102,7 @@
"rating": "Peringkat",
"genre": "Genre",
"size": "Ukuran",
"role": "Peran",
"missing": "Hilang"
"role": "Peran"
},
"roles": {
"albumartist": "Artis Album |||| Artis Album",
@@ -169,7 +163,7 @@
}
},
"transcoding": {
"name": "Transkoding |||| Transkoding",
"name": "Transkode |||| Transkode",
"fields": {
"name": "Nama",
"targetFormat": "Target Format",
@@ -196,8 +190,7 @@
"addNewPlaylist": "Buat \"%{name}\"",
"export": "Ekspor",
"makePublic": "Jadikan Publik",
"makePrivate": "Jadikan Pribadi",
"saveQueue": "Simpan Antrean ke Playlist"
"makePrivate": "Jadikan Pribadi"
},
"message": {
"duplicate_song": "Tambahkan lagu duplikat",
@@ -242,13 +235,11 @@
"updatedAt": "Tidak muncul di"
},
"actions": {
"remove": "Hapus",
"remove_all": "Hapus Semua"
"remove": "Hapus"
},
"notifications": {
"removed": "File yang hilang dihapus"
},
"empty": "Tidak ada File yang Hilang"
}
}
},
"ra": {
@@ -286,7 +277,7 @@
"add": "Tambah",
"back": "Kembali",
"bulk_actions": "1 item dipilih |||| %{smart_count} item dipilih",
"cancel": "Batal",
"cancel": "Batalkan",
"clear_input_value": "Hapus",
"clone": "Klon",
"confirm": "Konfirmasi",
@@ -301,7 +292,7 @@
"save": "Simpan",
"search": "Cari",
"show": "Tampilkan",
"sort": "Urutkan",
"sort": "Sortir",
"undo": "Batalkan",
"expand": "Luaskan",
"close": "Tutup",
@@ -321,7 +312,7 @@
"create": "Buat %{name}",
"dashboard": "Dasbor",
"edit": "%{name} #%{id}",
"error": "Terjadi kesalahan",
"error": "Ada yang tidak beres",
"list": "%{name}",
"loading": "Memuat",
"not_found": "Tidak ditemukan",
@@ -365,7 +356,7 @@
"unsaved_changes": "Beberapa perubahan tidak disimpan. Apakah Kamu yakin ingin mengabaikannya?"
},
"navigation": {
"no_results": "Hasil tidak ditemukan",
"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",
@@ -380,8 +371,8 @@
"updated": "Elemen diperbarui |||| %{smart_count} elemen diperbarui",
"created": "Elemen dibuat",
"deleted": "Elemen dihapus |||| %{smart_count} elemen dihapus",
"bad_item": "Kesalahan elemen",
"item_doesnt_exist": "Elemen tidak ditemukan",
"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",
@@ -428,9 +419,7 @@
"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.",
"remove_all_missing_title": "Hapus semua file yang hilang",
"remove_all_missing_content": "Apa kamu yakin ingin menghapus semua file dari database? Ini akan menghapus permanen dan apapun referensi ke mereka, termasuk hitungan pemutaran dan rating mereka."
"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",
@@ -462,7 +451,7 @@
"sharedPlaylists": "Playlist yang Dibagikan"
},
"player": {
"playListsText": "Putar Antrean",
"playListsText": "Mainkan Antrean",
"openText": "Buka",
"closeText": "Tutup",
"notContentText": "Tidak ada musik",
@@ -482,7 +471,7 @@
"playModeText": {
"order": "Berurutan",
"orderLoop": "Ulang",
"singleLoop": "Ulangi Sekali",
"singleLoop": "Ulangi Satu",
"shufflePlay": "Acak"
}
},
@@ -504,10 +493,7 @@
"quickScan": "Pemindaian Cepat",
"fullScan": "Pemindaian Penuh",
"serverUptime": "Waktu Aktif Peladen",
"serverDown": "LURING",
"scanType": "Tipe",
"status": "Kesalahan Memindai",
"elapsedTime": "Waktu Berakhir"
"serverDown": "LURING"
},
"help": {
"title": "Tombol Pintasan Navidrome",

View File

@@ -26,16 +26,7 @@
"bpm": "BPM",
"playDate": "Laatst afgespeeld",
"channels": "Kanalen",
"createdAt": "Datum toegevoegd",
"grouping": "Groep",
"mood": "Sfeer",
"participants": "Extra deelnemers",
"tags": "Extra tags",
"mappedTags": "Gemapte tags",
"rawTags": "Onbewerkte tags",
"bitDepth": "Bit diepte",
"sampleRate": "Sample waarde",
"missing": "Ontbrekend"
"createdAt": "Datum toegevoegd"
},
"actions": {
"addToQueue": "Voeg toe aan wachtrij",
@@ -67,15 +58,7 @@
"originalDate": "Origineel",
"releaseDate": "Uitgegeven",
"releases": "Uitgave |||| Uitgaven",
"released": "Uitgegeven",
"recordLabel": "Label",
"catalogNum": "Catalogus nummer",
"releaseType": "Type",
"grouping": "Groep",
"media": "Media",
"mood": "Sfeer",
"date": "Opnamedatum",
"missing": "Ontbrekend"
"released": "Uitgegeven"
},
"actions": {
"playAll": "Afspelen",
@@ -106,24 +89,7 @@
"playCount": "Afgespeeld",
"rating": "Beoordeling",
"genre": "Genre",
"size": "Grootte",
"role": "Rol",
"missing": "Ontbrekend"
},
"roles": {
"albumartist": "Album artiest |||| Album artiesten",
"artist": "Artiest |||| Artiesten",
"composer": "Componist |||| Componisten",
"conductor": "Dirigent |||| Dirigenten",
"lyricist": "Tekstschrijver |||| Tekstschrijvers",
"arranger": "Arrangeur |||| Arrangeurs",
"producer": "Producent |||| Producenten",
"director": "Regisseur |||| Regisseurs",
"engineer": "Opnametechnicus |||| Opnametechnici",
"mixer": "Mixer |||| Mixers",
"remixer": "Remixer |||| Remixers",
"djmixer": "DJ Mixer |||| DJ Mixers",
"performer": "Performer |||| Performers"
"size": "Grootte"
}
},
"user": {
@@ -196,8 +162,7 @@
"addNewPlaylist": "Creëer \"%{name}\"",
"export": "Exporteer",
"makePublic": "Openbaar maken",
"makePrivate": "Privé maken",
"saveQueue": "Bewaar wachtrij als playlist"
"makePrivate": "Privé maken"
},
"message": {
"duplicate_song": "Dubbele nummers toevoegen",
@@ -233,22 +198,6 @@
"createdAt": "Gecreëerd op",
"downloadable": "Downloads toestaan?"
}
},
"missing": {
"name": "Ontbrekend bestand |||| Ontbrekende bestanden",
"fields": {
"path": "Pad",
"size": "Grootte",
"updatedAt": "Verdwenen op"
},
"actions": {
"remove": "Verwijder",
"remove_all": "Alles verwijderen"
},
"notifications": {
"removed": "Ontbrekende bestanden verwijderd"
},
"empty": "Geen ontbrekende bestanden"
}
},
"ra": {
@@ -263,8 +212,7 @@
"password": "Wachtwoord",
"sign_in": "Inloggen",
"sign_in_error": "Authenticatie mislukt, probeer opnieuw a.u.b.",
"logout": "Uitloggen",
"insightsCollectionNote": "Navidrome verzamelt anonieme gebruiksdata om het project te verbeteren. Klik [hier] voor meer info en de mogelijkheid om te weigeren"
"logout": "Uitloggen"
},
"validation": {
"invalidChars": "Gebruik alleen letters en cijfers",
@@ -426,11 +374,7 @@
"shareSuccess": "URL gekopieeerd naar klembord: %{url}",
"shareFailure": "Fout bij kopieren URL %{url} naar klembord",
"downloadDialogTitle": "Download %{resource} '%{name}' (%{size})",
"shareCopyToClipboard": "Kopieeer naar klembord: Ctrl+C, Enter",
"remove_missing_title": "Verwijder ontbrekende bestanden",
"remove_missing_content": "Weet je zeker dat je alle ontbrekende bestanden van de database wil verwijderen? Dit wist permanent al hun referenties inclusief afspeel tellers en beoordelingen.",
"remove_all_missing_title": "Verwijder alle ontbrekende bestanden",
"remove_all_missing_content": "Weet je zeker dat je alle ontbrekende bestanden van de database wil verwijderen? Dit wist permanent al hun referenties inclusief afspeel tellers en beoordelingen."
"shareCopyToClipboard": "Kopieeer naar klembord: Ctrl+C, Enter"
},
"menu": {
"library": "Bibliotheek",
@@ -452,17 +396,16 @@
"none": "Uitgeschakeld",
"album": "Gebruik Album Gain",
"track": "Gebruik Track Gain"
},
"lastfmNotConfigured": "Last.fm API-sleutel is niet geconfigureerd"
}
}
},
"albumList": "Albums",
"about": "Over",
"playlists": "Afspeellijsten",
"sharedPlaylists": "Gedeelde afspeellijsten"
"playlists": "Playlists",
"sharedPlaylists": "Gedeelde playlists"
},
"player": {
"playListsText": "Wachtrij",
"playListsText": "Afspeellijst afspelen",
"openText": "Openen",
"closeText": "Sluiten",
"notContentText": "Geen muziek",
@@ -490,12 +433,7 @@
"links": {
"homepage": "Thuispagina",
"source": "Broncode",
"featureRequests": "Functie verzoeken",
"lastInsightsCollection": "Laatste inzichten",
"insights": {
"disabled": "Uitgeschakeld",
"waiting": "Wachten"
}
"featureRequests": "Functie verzoeken"
}
},
"activity": {
@@ -504,10 +442,7 @@
"quickScan": "Snelle scan",
"fullScan": "Volledige scan",
"serverUptime": "Server uptime",
"serverDown": "Offline",
"scanType": "Type",
"status": "Scan fout",
"elapsedTime": "Verlopen tijd"
"serverDown": "Offline"
},
"help": {
"title": "Navidrome sneltoetsen",

View File

@@ -18,6 +18,7 @@
"size": "Tamanho",
"updatedAt": "Últ. Atualização",
"bitRate": "Bitrate",
"bitDepth": "Profundidade de bits",
"discSubtitle": "Sub-título do disco",
"starred": "Favorita",
"comment": "Comentário",
@@ -33,8 +34,6 @@
"tags": "Outras Tags",
"mappedTags": "Tags mapeadas",
"rawTags": "Tags originais",
"bitDepth": "Profundidade de bits",
"sampleRate": "Taxa de amostragem",
"missing": "Ausente"
},
"actions": {
@@ -59,6 +58,7 @@
"genre": "Gênero",
"compilation": "Coletânea",
"year": "Ano",
"date": "Data de Lançamento",
"updatedAt": "Últ. Atualização",
"comment": "Comentário",
"rating": "Classificação",
@@ -74,7 +74,6 @@
"grouping": "Agrupamento",
"media": "Mídia",
"mood": "Mood",
"date": "Data de Lançamento",
"missing": "Ausente"
},
"actions": {
@@ -195,9 +194,9 @@
"selectPlaylist": "Selecione a playlist:",
"addNewPlaylist": "Criar \"%{name}\"",
"export": "Exportar",
"saveQueue": "Salvar fila em nova Playlist",
"makePublic": "Pública",
"makePrivate": "Pessoal",
"saveQueue": "Salvar fila em nova Playlist"
"makePrivate": "Pessoal"
},
"message": {
"duplicate_song": "Adicionar músicas duplicadas",
@@ -236,6 +235,7 @@
},
"missing": {
"name": "Arquivo ausente |||| Arquivos ausentes",
"empty": "Nenhum arquivo ausente",
"fields": {
"path": "Caminho",
"size": "Tamanho",
@@ -247,8 +247,7 @@
},
"notifications": {
"removed": "Arquivo(s) ausente(s) removido(s)"
},
"empty": "Nenhum arquivo ausente"
}
}
},
"ra": {
@@ -523,4 +522,4 @@
"current_song": "Vai para música atual"
}
}
}
}

View File

@@ -33,9 +33,8 @@
"tags": "Дополнительные теги",
"mappedTags": "Сопоставленные теги",
"rawTags": "Исходные теги",
"bitDepth": "Битовая глубина (Bit)",
"sampleRate": "Частота дискретизации (Hz)",
"missing": "Поле отсутствует"
"bitDepth": "Битовая глубина",
"sampleRate": "Частота дискретизации (Гц)"
},
"actions": {
"addToQueue": "В очередь",
@@ -74,8 +73,7 @@
"grouping": "Группирование",
"media": "Медиа",
"mood": "Настроение",
"date": "Дата записи",
"missing": "Поле отсутствует"
"date": "Дата записи"
},
"actions": {
"playAll": "Играть",
@@ -107,8 +105,7 @@
"rating": "Рейтинг",
"genre": "Жанр",
"size": "Размер",
"role": "Роль",
"missing": "Поле отсутствует"
"role": "Роль"
},
"roles": {
"albumartist": "Исполнитель альбома |||| Исполнители альбома",
@@ -160,7 +157,7 @@
"fields": {
"name": "Имя",
"transcodingId": "Транскодирование",
"maxBitRate": "Макс. битрейт",
"maxBitRate": "Макс. Битрейт",
"client": "Клиент",
"userName": "Пользователь",
"lastSeen": "Был на сайте",
@@ -178,7 +175,7 @@
}
},
"playlist": {
"name": "Плейлист |||| Плейлисты",
"name": "Плейлистов |||| Плейлисты",
"fields": {
"name": "Название",
"duration": "Длительность",
@@ -196,8 +193,7 @@
"addNewPlaylist": "Создать \"%{name}\"",
"export": "Экспорт",
"makePublic": "Опубликовать",
"makePrivate": "Сделать личным",
"saveQueue": "Сохранить очередь в плейлист"
"makePrivate": "Сделать личным"
},
"message": {
"duplicate_song": "Повторяющиеся треки",
@@ -228,7 +224,7 @@
"lastVisitedAt": "Последнее посещение",
"visitCount": "Посещения",
"format": "Формат",
"maxBitRate": "Макс. битрейт",
"maxBitRate": "Макс. Битрейт",
"updatedAt": "Обновлено в",
"createdAt": "Создано",
"downloadable": "Разрешить загрузку?"
@@ -242,8 +238,7 @@
"updatedAt": "Исчез"
},
"actions": {
"remove": "Удалить",
"remove_all": "Убрать все"
"remove": "Удалить"
},
"notifications": {
"removed": "Отсутствующие файлы удалены"
@@ -279,7 +274,7 @@
"oneOf": "Должно быть одним из: %{options}",
"regex": "Должно быть в формате (regexp): %{pattern}",
"unique": "Должно быть уникальным",
"url": "Должен быть действительный URL"
"url": "Должен быть действительным URL адрес"
},
"action": {
"add_filter": "Фильтр",
@@ -296,7 +291,7 @@
"export": "Экспорт",
"list": "Список",
"refresh": "Обновить",
"remove_filter": "Убрать этот фильтр",
"remove_filter": "Убрать фильтр",
"remove": "Удалить",
"save": "Сохранить",
"search": "Поиск",
@@ -387,7 +382,7 @@
"i18n_error": "Не удалось загрузить перевод для указанного языка",
"canceled": "Операция отменена",
"logged_out": "Ваша сессия завершена, попробуйте переподключиться/войти снова",
"new_version": "Доступна новая версия! Пожалуйста, обновите это окно."
"new_version": "Доступна новая версия! Пожалуйста, обновите это окно"
},
"toggleFieldsMenu": {
"columnsToDisplay": "Отображение столбцов",
@@ -428,9 +423,7 @@
"downloadDialogTitle": "Скачать %{resource} '%{name}' (%{size})",
"shareCopyToClipboard": "Копировать в буфер обмена: Ctrl+C, Enter",
"remove_missing_title": "Удалить отсутствующие файлы",
"remove_missing_content": "Вы уверены, что хотите удалить выбранные отсутствующие файлы из базы данных? Это навсегда удалит все ссылки на них, включая данные о прослушиваниях и рейтингах.",
"remove_all_missing_title": "Удалите все отсутствующие файлы",
"remove_all_missing_content": "Вы уверены, что хотите удалить все отсутствующие файлы из базы данных? Это навсегда удалит все упоминания о них, включая количество игр и рейтинг."
"remove_missing_content": "Вы уверены, что хотите удалить выбранные отсутствующие файлы из базы данных? Это навсегда удалит все ссылки на них, включая данные о прослушиваниях и рейтингах."
},
"menu": {
"library": "Библиотека",
@@ -489,7 +482,7 @@
"about": {
"links": {
"homepage": "Главная",
"source": "Исходный код",
"source": "Код",
"featureRequests": "Предложения",
"lastInsightsCollection": "Последний сбор данных",
"insights": {
@@ -504,10 +497,7 @@
"quickScan": "Быстрое сканирование",
"fullScan": "Полное сканирование",
"serverUptime": "Время работы сервера",
"serverDown": "Оффлайн",
"scanType": "Тип",
"status": "Ошибка сканирования",
"elapsedTime": "Прошедшее время"
"serverDown": "Оффлайн"
},
"help": {
"title": "Горячие клавиши Navidrome",
@@ -520,7 +510,7 @@
"vol_up": "Увеличить громкость",
"vol_down": "Уменьшить громкость",
"toggle_love": "Добавить / удалить песню из избранного",
"current_song": "Перейти к текущему треку"
"current_song": "Перейти к текущей песне"
}
}
}

View File

@@ -26,16 +26,7 @@
"bpm": "BPM",
"playDate": "Senast spelad",
"channels": "Channels",
"createdAt": "Skapad",
"grouping": "Gruppering",
"mood": "Stämning",
"participants": "Ytterligare medverkande",
"tags": "Ytterligare taggar",
"mappedTags": "Mappade taggar",
"rawTags": "Omodifierade taggar",
"bitDepth": "Bitdjup",
"sampleRate": "Samplingsfrekvens",
"missing": "Saknade"
"createdAt": "Skapad"
},
"actions": {
"addToQueue": "Lägg till i kön",
@@ -67,15 +58,7 @@
"originalDate": "Originaldatum",
"releaseDate": "Utgivningsdatum",
"releases": "Utgåva |||| Utgåvor",
"released": "Utgiven",
"recordLabel": "Skivbolag",
"catalogNum": "Katalognummer",
"releaseType": "Typ",
"grouping": "Gruppering",
"media": "Media",
"mood": "Stämning",
"date": "Inspelningsdatum",
"missing": "Saknade"
"released": "Utgiven"
},
"actions": {
"playAll": "Spela",
@@ -106,24 +89,7 @@
"playCount": "Spelningar",
"rating": "Betyg",
"genre": "Genre",
"size": "Storlek",
"role": "Roll",
"missing": "Saknade"
},
"roles": {
"albumartist": "Albumartist |||| Albumartister",
"artist": "Artist |||| Artister",
"composer": "Kompositör |||| Kompositörer",
"conductor": "Dirigent |||| Dirigenter",
"lyricist": "Textförfattare |||| Textförfattare",
"arranger": "Arrangör |||| Arrangörer",
"producer": "Producent |||| Producenter",
"director": "Inspelningsledare |||| Inspelningsledare",
"engineer": "Ljudtekniker |||| Ljudtekniker",
"mixer": "Mixare |||| Mixare",
"remixer": "Remixare |||| Remixare",
"djmixer": "DJ-mixare |||| DJ-mixare",
"performer": "Utövande artist |||| Utövande artister"
"size": "Storlek"
}
},
"user": {
@@ -196,8 +162,7 @@
"addNewPlaylist": "Skapa \"%{name}\"",
"export": "Exportera",
"makePublic": "Gör offentlig",
"makePrivate": "Gör privat",
"saveQueue": "Spara kö till spellista"
"makePrivate": "Gör privat"
},
"message": {
"duplicate_song": "Lägg till dubletter",
@@ -233,22 +198,6 @@
"createdAt": "Skapad",
"downloadable": "Tillåt nedladdning?"
}
},
"missing": {
"name": "Saknad fil |||| Saknade filer",
"fields": {
"path": "Sökväg",
"size": "Storlek",
"updatedAt": "Försvann"
},
"actions": {
"remove": "Radera",
"remove_all": "Radera alla"
},
"notifications": {
"removed": "Saknade fil(er) borttagna"
},
"empty": "Inga saknade filer"
}
},
"ra": {
@@ -426,11 +375,7 @@
"shareSuccess": "URL kopierades till urklipp: %{url}",
"shareFailure": "Fel vid kopiering av URL %{url} till urklipp",
"downloadDialogTitle": "Ladda ner %{resource} '%{name}' (%{size})",
"shareCopyToClipboard": "Kopiera till urklipp: Ctrl+C, Enter",
"remove_missing_title": "Ta bort saknade filer",
"remove_missing_content": "Är du säker på att du vill ta bort de valda saknade filerna från databasen? Detta kommer permanent radera alla referenser till dem, inklusive antal spelningar och betyg.",
"remove_all_missing_title": "Ta bort alla saknade filer",
"remove_all_missing_content": "Är du säker på att du vill ta bort alla saknade filer från databasen? Detta kommer permanent radera alla referenser till dem, inklusive antal spelningar och betyg."
"shareCopyToClipboard": "Kopiera till urklipp: Ctrl+C, Enter"
},
"menu": {
"library": "Bibliotek",
@@ -504,10 +449,7 @@
"quickScan": "Snabbscan",
"fullScan": "Komplett scan",
"serverUptime": "Serverdrifttid",
"serverDown": "OFFLINE",
"scanType": "Typ",
"status": "Fel vid scanning",
"elapsedTime": "Spelad tid"
"serverDown": "OFFLINE"
},
"help": {
"title": "Navidrome kortkommandon",

View File

@@ -34,8 +34,7 @@
"mappedTags": "Eşlenen etiketler",
"rawTags": "Ham etiketler",
"bitDepth": "Bit derinliği",
"sampleRate": "Örnekleme Oranı",
"missing": ""
"sampleRate": "Örnekleme Oranı"
},
"actions": {
"addToQueue": "Oynatma Sırasına Ekle",
@@ -74,8 +73,7 @@
"grouping": "Gruplama",
"media": "Medya",
"mood": "Mod",
"date": "Kayıt Tarihi",
"missing": ""
"date": "Kayıt Tarihi"
},
"actions": {
"playAll": "Oynat",
@@ -107,8 +105,7 @@
"rating": "Derecelendirme",
"genre": "Tür",
"size": "Boyut",
"role": "Rol",
"missing": ""
"role": "Rol"
},
"roles": {
"albumartist": "Albüm Sanatçısı |||| Albüm Sanatçısı",
@@ -196,8 +193,7 @@
"addNewPlaylist": "Oluştur \"%{name}\"",
"export": "Aktar",
"makePublic": "Herkese Açık Yap",
"makePrivate": "Özel Yap",
"saveQueue": ""
"makePrivate": "Özel Yap"
},
"message": {
"duplicate_song": "Yinelenen şarkıları ekle",
@@ -242,8 +238,7 @@
"updatedAt": "Kaybolma"
},
"actions": {
"remove": "Kaldır",
"remove_all": "Tümünü Kaldır"
"remove": "Kaldır"
},
"notifications": {
"removed": "Eksik dosya(lar) kaldırıldı"
@@ -428,9 +423,7 @@
"downloadDialogTitle": "%{resource}: '%{name}' (%{size}) dosyasını indirin",
"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.",
"remove_all_missing_title": "Tüm eksik dosyaları kaldırın",
"remove_all_missing_content": "Veritabanından tüm eksik dosyaları kaldırmak istediğinizden emin misiniz? Bu, oynatma sayısı ve derecelendirmelerde dahil olmak üzere bunlara ilişkili tüm değerleri kalıcı olarak kaldıracaktı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",
@@ -504,10 +497,7 @@
"quickScan": "Hızlı Tarama",
"fullScan": "Tam Tarama",
"serverUptime": "Sunucu Çalışma Süresi",
"serverDown": "ÇEVRİMDIŞI",
"scanType": "Tür",
"status": "Tarama Hatası",
"elapsedTime": "Geçen Süre"
"serverDown": "ÇEVRİMDIŞI"
},
"help": {
"title": "Navidrome Kısayolları",

View File

@@ -32,10 +32,7 @@
"participants": "Додаткові вчасники",
"tags": "Додаткові теги",
"mappedTags": "Зіставлені теги",
"rawTags": "Вихідні теги",
"bitDepth": "Глибина розрядності",
"sampleRate": "Частота дискретизації",
"missing": "Поле відсутнє"
"rawTags": "Вихідні теги"
},
"actions": {
"addToQueue": "Прослухати пізніше",
@@ -73,9 +70,7 @@
"releaseType": "Тип",
"grouping": "Групування",
"media": "Медіа",
"mood": "Настрій",
"date": "Дата запису",
"missing": "Поле відсутнє"
"mood": "Настрій"
},
"actions": {
"playAll": "Прослухати",
@@ -107,8 +102,7 @@
"rating": "Рейтинг",
"genre": "Жанр",
"size": "Розмір",
"role": "Роль",
"missing": "Поле відсутнє"
"role": "Роль"
},
"roles": {
"albumartist": "Виконавець альбому |||| Виконавці альбому",
@@ -196,8 +190,7 @@
"addNewPlaylist": "Створити \"%{name}\"",
"export": "Експортувати",
"makePublic": "Зробити публічним",
"makePrivate": "Зробити приватним",
"saveQueue": "Зберегти чергу до плейлиста"
"makePrivate": "Зробити приватним"
},
"message": {
"duplicate_song": "Додати повторювані пісні",
@@ -242,13 +235,11 @@
"updatedAt": "Зник"
},
"actions": {
"remove": "Видалити",
"remove_all": "Вилучити всі"
"remove": "Видалити"
},
"notifications": {
"removed": "Видалено зниклі файл(и)"
},
"empty": "Немає відсутніх файлів"
}
}
},
"ra": {
@@ -428,9 +419,7 @@
"downloadDialogTitle": "Завантаження %{resource} '%{name}' (%{size})",
"shareCopyToClipboard": "Скопіювати в буфер: Ctrl+C, Enter",
"remove_missing_title": "Видалити зниклі файли",
"remove_missing_content": "Ви впевнені, що хочете видалити вибрані відсутні файли з бази даних? Це назавжди видалить усі посилання на них, включаючи кількість прослуховувань та рейтинги.",
"remove_all_missing_title": "Видалити всі відсутні файли",
"remove_all_missing_content": "Ви впевнені, що хочете видалити всі відсутні файли з бази даних? Це назавжди видалить будь-які посилання на них, включно з кількістю відтворень та рейтингами."
"remove_missing_content": "Ви впевнені, що хочете видалити вибрані відсутні файли з бази даних? Це назавжди видалить усі посилання на них, включаючи кількість прослуховувань та рейтинги."
},
"menu": {
"library": "Бібліотека",
@@ -504,10 +493,7 @@
"quickScan": "Швидке сканування",
"fullScan": "Повне сканування",
"serverUptime": "Час роботи",
"serverDown": "Оффлайн",
"scanType": "Тип",
"status": "Помилка сканування",
"elapsedTime": "Пройдений час"
"serverDown": "Оффлайн"
},
"help": {
"title": "Гарячі клавіші Navidrome",

View File

@@ -37,9 +37,8 @@ func (pub *Router) handleImages(w http.ResponseWriter, r *http.Request) {
return
}
size := p.IntOr("size", 0)
square := p.BoolOr("square", false)
imgReader, lastUpdate, err := pub.artwork.Get(ctx, artId, size, square)
imgReader, lastUpdate, err := pub.artwork.Get(ctx, artId, size, false)
switch {
case errors.Is(err, context.Canceled):
return

View File

@@ -1,7 +1,12 @@
import ReactGA from 'react-ga'
import { Provider } from 'react-redux'
import { createHashHistory } from 'history'
import { Admin as RAAdmin, Resource } from 'react-admin'
import {
Admin as RAAdmin,
Resource,
useSetLocale,
useRefresh,
} from 'react-admin'
import { HotKeys } from 'react-hotkeys'
import dataProvider from './dataProvider'
import authProvider from './authProvider'
@@ -32,7 +37,7 @@ import {
shareDialogReducer,
} from './reducers'
import createAdminStore from './store/createAdminStore'
import { i18nProvider } from './i18n'
import { i18nProvider, retrieveTranslation } from './i18n'
import config, { shareInfo } from './config'
import { keyMap } from './hotkeys'
import useChangeThemeColor from './useChangeThemeColor'
@@ -40,6 +45,7 @@ import SharePlayer from './share/SharePlayer'
import { HTML5Backend } from 'react-dnd-html5-backend'
import { DndProvider } from 'react-dnd'
import missing from './missing/index.js'
import { useEffect } from 'react'
const history = createHashHistory()
@@ -78,6 +84,24 @@ const App = () => (
)
const Admin = (props) => {
const setLocale = useSetLocale()
const refresh = useRefresh()
useEffect(() => {
if (config.defaultLanguage !== '' && !localStorage.getItem('locale')) {
retrieveTranslation(config.defaultLanguage)
.then(() => setLocale(config.defaultLanguage))
.then(() => {
localStorage.setItem('locale', config.defaultLanguage)
refresh(true)
})
.catch((e) => {
// eslint-disable-next-line no-console
console.error(
'Cannot load language "' + config.defaultLanguage + '": ' + e,
)
})
}
}, [setLocale, refresh])
useChangeThemeColor()
/* eslint-disable react/jsx-key */
return (

View File

@@ -72,10 +72,6 @@ const useStyles = makeStyles(
width: '15em',
minWidth: '15em',
},
backgroundColor: 'transparent',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
},
cover: {
objectFit: 'contain',
@@ -83,11 +79,6 @@ const useStyles = makeStyles(
display: 'block',
width: '100%',
height: '100%',
backgroundColor: 'transparent',
transition: 'opacity 0.3s ease-in-out',
},
coverLoading: {
opacity: 0.5,
},
loveButton: {
top: theme.spacing(-0.2),
@@ -222,8 +213,6 @@ const AlbumDetails = (props) => {
const [isLightboxOpen, setLightboxOpen] = useState(false)
const [expanded, setExpanded] = useState(false)
const [albumInfo, setAlbumInfo] = useState()
const [imageLoading, setImageLoading] = useState(false)
const [imageError, setImageError] = useState(false)
let notes =
albumInfo?.notes?.replace(new RegExp('<.*>', 'g'), '') || record.notes
@@ -247,51 +236,23 @@ const AlbumDetails = (props) => {
})
}, [record])
// Reset image state when album changes
useEffect(() => {
setImageLoading(true)
setImageError(false)
}, [record.id])
const imageUrl = subsonic.getCoverArtUrl(record, 300)
const fullImageUrl = subsonic.getCoverArtUrl(record)
const handleImageLoad = useCallback(() => {
setImageLoading(false)
setImageError(false)
}, [])
const handleImageError = useCallback(() => {
setImageLoading(false)
setImageError(true)
}, [])
const handleOpenLightbox = useCallback(() => {
if (!imageError) {
setLightboxOpen(true)
}
}, [imageError])
const handleOpenLightbox = useCallback(() => setLightboxOpen(true), [])
const handleCloseLightbox = useCallback(() => setLightboxOpen(false), [])
return (
<Card className={classes.root}>
<div className={classes.cardContents}>
<div className={classes.coverParent}>
<CardMedia
key={record.id}
component={'img'}
src={imageUrl}
width="400"
height="400"
className={`${classes.cover} ${imageLoading ? classes.coverLoading : ''}`}
className={classes.cover}
onClick={handleOpenLightbox}
onLoad={handleImageLoad}
onError={handleImageError}
title={record.name}
style={{
cursor: imageError ? 'default' : 'pointer',
}}
/>
</div>
<div className={classes.details}>
@@ -376,7 +337,7 @@ const AlbumDetails = (props) => {
</Collapse>
</div>
)}
{isLightboxOpen && !imageError && (
{isLightboxOpen && (
<Lightbox
imagePadding={50}
animationDuration={200}

View File

@@ -94,10 +94,6 @@ const useCoverStyles = makeStyles({
width: '100%',
objectFit: 'contain',
height: (props) => props.height,
transition: 'opacity 0.3s ease-in-out',
},
coverLoading: {
opacity: 0.5,
},
})
@@ -117,8 +113,6 @@ const Cover = withContentRect('bounds')(({
// Force height to be the same as the width determined by the GridList
// noinspection JSSuspiciousNameCombination
const classes = useCoverStyles({ height: contentRect.bounds.width })
const [imageLoading, setImageLoading] = React.useState(true)
const [imageError, setImageError] = React.useState(false)
const [, dragAlbumRef] = useDrag(
() => ({
type: DraggableTypes.ALBUM,
@@ -127,33 +121,13 @@ const Cover = withContentRect('bounds')(({
}),
[record],
)
// Reset image state when record changes
React.useEffect(() => {
setImageLoading(true)
setImageError(false)
}, [record.id])
const handleImageLoad = React.useCallback(() => {
setImageLoading(false)
setImageError(false)
}, [])
const handleImageError = React.useCallback(() => {
setImageLoading(false)
setImageError(true)
}, [])
return (
<div ref={measureRef}>
<div ref={dragAlbumRef}>
<img
key={record.id} // Force re-render when record changes
src={subsonic.getCoverArtUrl(record, 300, true)}
alt={record.name}
className={`${classes.cover} ${imageLoading ? classes.coverLoading : ''}`}
onLoad={handleImageLoad}
onError={handleImageError}
className={classes.cover}
/>
</div>
</div>

View File

@@ -4,13 +4,12 @@ import {
ShowContextProvider,
useShowContext,
useShowController,
Title as RaTitle,
} from 'react-admin'
import { makeStyles } from '@material-ui/core/styles'
import AlbumSongs from './AlbumSongs'
import AlbumDetails from './AlbumDetails'
import AlbumActions from './AlbumActions'
import { useResourceRefresh, Title } from '../common'
import { useResourceRefresh } from '../common'
const useStyles = makeStyles(
(theme) => ({
@@ -31,7 +30,6 @@ const AlbumShowLayout = (props) => {
return (
<>
{record && <RaTitle title={<Title subTitle={record.name} />} />}
{record && <AlbumDetails {...context} />}
{record && (
<ReferenceManyField

View File

@@ -6,16 +6,8 @@ import { ImLastfm2 } from 'react-icons/im'
import MusicBrainz from '../icons/MusicBrainz'
import { intersperse } from '../utils'
import config from '../config'
import { makeStyles } from '@material-ui/core/styles'
const useStyles = makeStyles({
linkBar: {
minHeight: '1.875em',
},
})
const ArtistExternalLinks = ({ artistInfo, record }) => {
const classes = useStyles()
const translate = useTranslate()
let linkButtons = []
const lastFMlink = artistInfo?.biography?.match(
@@ -60,7 +52,7 @@ const ArtistExternalLinks = ({ artistInfo, record }) => {
<MusicBrainz className="musicbrainz-icon" />,
)
return <div className={classes.linkBar}>{intersperse(linkButtons, ' ')}</div>
return <div>{intersperse(linkButtons, ' ')}</div>
}
export default ArtistExternalLinks

View File

@@ -7,13 +7,12 @@ import {
useShowContext,
ReferenceManyField,
Pagination,
Title as RaTitle,
} from 'react-admin'
import subsonic from '../subsonic'
import AlbumGridView from '../album/AlbumGridView'
import MobileArtistDetails from './MobileArtistDetails'
import DesktopArtistDetails from './DesktopArtistDetails'
import { useAlbumsPerPage, useResourceRefresh, Title } from '../common/index.js'
import { useAlbumsPerPage, useResourceRefresh } from '../common/index.js'
const ArtistDetails = (props) => {
const record = useRecordContext(props)
@@ -77,7 +76,6 @@ const ArtistShowLayout = (props) => {
return (
<>
{record && <RaTitle title={<Title subTitle={record.name} />} />}
{record && <ArtistDetails />}
{record && (
<ReferenceManyField

View File

@@ -29,7 +29,6 @@ const useStyles = makeStyles(
float: 'left',
wordBreak: 'break-word',
cursor: 'pointer',
minHeight: '4.5em',
},
content: {
flex: '1 0 auto',
@@ -39,22 +38,11 @@ const useStyles = makeStyles(
height: '12rem',
borderRadius: '6em',
cursor: 'pointer',
backgroundColor: 'transparent',
transition: 'opacity 0.3s ease-in-out',
objectFit: 'cover',
},
coverLoading: {
opacity: 0.5,
},
artistImage: {
maxHeight: '12rem',
minHeight: '12rem',
width: '12rem',
minWidth: '12rem',
backgroundColor: 'inherit',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
boxShadow: 'none',
},
artistDetail: {
@@ -85,31 +73,8 @@ const DesktopArtistDetails = ({ artistInfo, record, biography }) => {
const classes = useStyles()
const title = record.name
const [isLightboxOpen, setLightboxOpen] = React.useState(false)
const [imageLoading, setImageLoading] = React.useState(false)
const [imageError, setImageError] = React.useState(false)
// Reset image state when artist changes
React.useEffect(() => {
setImageLoading(true)
setImageError(false)
}, [record.id])
const handleImageLoad = React.useCallback(() => {
setImageLoading(false)
setImageError(false)
}, [])
const handleImageError = React.useCallback(() => {
setImageLoading(false)
setImageError(true)
}, [])
const handleOpenLightbox = React.useCallback(() => {
if (!imageError) {
setLightboxOpen(true)
}
}, [imageError])
const handleOpenLightbox = React.useCallback(() => setLightboxOpen(true), [])
const handleCloseLightbox = React.useCallback(
() => setLightboxOpen(false),
[],
@@ -121,17 +86,10 @@ const DesktopArtistDetails = ({ artistInfo, record, biography }) => {
<Card className={classes.artistImage}>
{artistInfo && (
<CardMedia
key={record.id}
component="img"
src={subsonic.getCoverArtUrl(record, 300)}
className={`${classes.cover} ${imageLoading ? classes.coverLoading : ''}`}
className={classes.cover}
image={subsonic.getCoverArtUrl(record, 300)}
onClick={handleOpenLightbox}
onLoad={handleImageLoad}
onError={handleImageError}
title={title}
style={{
cursor: imageError ? 'default' : 'pointer',
}}
/>
)}
</Card>
@@ -182,7 +140,7 @@ const DesktopArtistDetails = ({ artistInfo, record, biography }) => {
)}
</Typography>
</div>
{isLightboxOpen && !imageError && (
{isLightboxOpen && (
<Lightbox
imagePadding={50}
animationDuration={200}

View File

@@ -50,12 +50,6 @@ const useStyles = makeStyles(
width: 151,
boxShadow: '0px 0px 6px 0px #565656',
borderRadius: '5px',
backgroundColor: 'transparent',
transition: 'opacity 0.3s ease-in-out',
objectFit: 'cover',
},
coverLoading: {
opacity: 0.5,
},
artistImage: {
marginLeft: '1em',
@@ -87,31 +81,8 @@ const MobileArtistDetails = ({ artistInfo, biography, record }) => {
const classes = useStyles({ img, expanded })
const title = record.name
const [isLightboxOpen, setLightboxOpen] = React.useState(false)
const [imageLoading, setImageLoading] = React.useState(false)
const [imageError, setImageError] = React.useState(false)
// Reset image state when artist changes
React.useEffect(() => {
setImageLoading(true)
setImageError(false)
}, [record.id])
const handleImageLoad = React.useCallback(() => {
setImageLoading(false)
setImageError(false)
}, [])
const handleImageError = React.useCallback(() => {
setImageLoading(false)
setImageError(true)
}, [])
const handleOpenLightbox = React.useCallback(() => {
if (!imageError) {
setLightboxOpen(true)
}
}, [imageError])
const handleOpenLightbox = React.useCallback(() => setLightboxOpen(true), [])
const handleCloseLightbox = React.useCallback(
() => setLightboxOpen(false),
[],
@@ -124,17 +95,10 @@ const MobileArtistDetails = ({ artistInfo, biography, record }) => {
<Card className={classes.artistImage}>
{artistInfo && (
<CardMedia
key={record.id}
component="img"
src={subsonic.getCoverArtUrl(record, 300)}
className={`${classes.cover} ${imageLoading ? classes.coverLoading : ''}`}
className={classes.cover}
image={subsonic.getCoverArtUrl(record, 300)}
onClick={handleOpenLightbox}
onLoad={handleImageLoad}
onError={handleImageError}
title={title}
style={{
cursor: imageError ? 'default' : 'pointer',
}}
/>
)}
</Card>
@@ -172,7 +136,7 @@ const MobileArtistDetails = ({ artistInfo, biography, record }) => {
</Typography>
</Collapse>
</div>
{isLightboxOpen && !imageError && (
{isLightboxOpen && (
<Lightbox
imagePadding={50}
animationDuration={200}

View File

@@ -57,7 +57,7 @@ const useStyles = makeStyles((theme) => ({
const PlayerToolbar = ({ id, isRadio }) => {
const dispatch = useDispatch()
const { data, loading } = useGetOne('song', id, { enabled: !!id })
const { data, loading } = useGetOne('song', id)
const [toggleLove, toggling] = useToggleLove('song', data)
const isDesktop = useMediaQuery('(min-width:810px)')
const classes = useStyles()

View File

@@ -1,4 +1,4 @@
import React, { useState, useCallback, useEffect } from 'react'
import React, { useState, useCallback } from 'react'
import PropTypes from 'prop-types'
import { Field, Form } from 'react-final-form'
import { useDispatch } from 'react-redux'
@@ -13,8 +13,6 @@ import {
createMuiTheme,
useLogin,
useNotify,
useRefresh,
useSetLocale,
useTranslate,
useVersion,
} from 'react-admin'
@@ -24,7 +22,6 @@ import Notification from './Notification'
import useCurrentTheme from '../themes/useCurrentTheme'
import config from '../config'
import { clearQueue } from '../actions'
import { retrieveTranslation } from '../i18n'
import { INSIGHTS_DOC_URL } from '../consts.js'
const useStyles = makeStyles(
@@ -400,27 +397,8 @@ Login.propTypes = {
// the right theme
const LoginWithTheme = (props) => {
const theme = useCurrentTheme()
const setLocale = useSetLocale()
const refresh = useRefresh()
const version = useVersion()
useEffect(() => {
if (config.defaultLanguage !== '' && !localStorage.getItem('locale')) {
retrieveTranslation(config.defaultLanguage)
.then(() => {
setLocale(config.defaultLanguage).then(() => {
localStorage.setItem('locale', config.defaultLanguage)
})
refresh(true)
})
.catch((e) => {
throw new Error(
'Cannot load language "' + config.defaultLanguage + '": ' + e,
)
})
}
}, [refresh, setLocale])
return (
<ThemeProvider theme={createMuiTheme(theme)}>
<Login key={version} {...props} />

View File

@@ -1,81 +1,37 @@
import {
Card,
CardContent,
CardMedia,
Typography,
useMediaQuery,
} from '@material-ui/core'
import { Card, CardContent, Typography } from '@material-ui/core'
import { makeStyles } from '@material-ui/core/styles'
import { useTranslate } from 'react-admin'
import { useCallback, useState, useEffect } from 'react'
import Lightbox from 'react-image-lightbox'
import 'react-image-lightbox/style.css'
import { CollapsibleComment, DurationField, SizeField } from '../common'
import subsonic from '../subsonic'
const useStyles = makeStyles(
(theme) => ({
root: {
container: {
[theme.breakpoints.down('xs')]: {
padding: '0.7em',
minWidth: '20em',
minWidth: '24em',
},
[theme.breakpoints.up('sm')]: {
padding: '1em',
minWidth: '32em',
},
},
cardContents: {
display: 'flex',
},
details: {
display: 'flex',
flexDirection: 'column',
},
content: {
flex: '2 0 auto',
},
coverParent: {
display: 'inline-block',
verticalAlign: 'top',
[theme.breakpoints.down('xs')]: {
height: '8em',
width: '8em',
minWidth: '8em',
width: '14em',
},
[theme.breakpoints.up('sm')]: {
height: '10em',
width: '10em',
minWidth: '10em',
width: '26em',
},
[theme.breakpoints.up('lg')]: {
height: '15em',
width: '15em',
minWidth: '15em',
width: '38em',
},
backgroundColor: 'transparent',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
},
cover: {
objectFit: 'contain',
cursor: 'pointer',
display: 'block',
width: '100%',
height: '100%',
backgroundColor: 'transparent',
transition: 'opacity 0.3s ease-in-out',
},
coverLoading: {
opacity: 0.5,
},
title: {
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
wordBreak: 'break-word',
},
stats: {
marginTop: '1em',
marginBottom: '0.5em',
},
}),
{
@@ -87,95 +43,31 @@ const PlaylistDetails = (props) => {
const { record = {} } = props
const translate = useTranslate()
const classes = useStyles()
const isDesktop = useMediaQuery((theme) => theme.breakpoints.up('lg'))
const [isLightboxOpen, setLightboxOpen] = useState(false)
const [imageLoading, setImageLoading] = useState(false)
const [imageError, setImageError] = useState(false)
const imageUrl = subsonic.getCoverArtUrl(record, 300, true)
const fullImageUrl = subsonic.getCoverArtUrl(record)
// Reset image state when playlist changes
useEffect(() => {
setImageLoading(true)
setImageError(false)
}, [record.id])
const handleImageLoad = useCallback(() => {
setImageLoading(false)
setImageError(false)
}, [])
const handleImageError = useCallback(() => {
setImageLoading(false)
setImageError(true)
}, [])
const handleOpenLightbox = useCallback(() => {
if (!imageError) {
setLightboxOpen(true)
}
}, [imageError])
const handleCloseLightbox = useCallback(() => setLightboxOpen(false), [])
return (
<Card className={classes.root}>
<div className={classes.cardContents}>
<div className={classes.coverParent}>
<CardMedia
key={record.id} // Force re-render when playlist changes
component={'img'}
src={imageUrl}
width="400"
height="400"
className={`${classes.cover} ${imageLoading ? classes.coverLoading : ''}`}
onClick={handleOpenLightbox}
onLoad={handleImageLoad}
onError={handleImageError}
title={record.name}
style={{
cursor: imageError ? 'default' : 'pointer',
}}
/>
</div>
<div className={classes.details}>
<CardContent className={classes.content}>
<Typography
variant={isDesktop ? 'h5' : 'h6'}
className={classes.title}
>
{record.name || translate('ra.page.loading')}
</Typography>
<Typography component="p" className={classes.stats}>
{record.songCount ? (
<span>
{record.songCount}{' '}
{translate('resources.song.name', {
smart_count: record.songCount,
})}
{' · '}
<DurationField record={record} source={'duration'} />
{' · '}
<SizeField record={record} source={'size'} />
</span>
) : (
<span>&nbsp;</span>
)}
</Typography>
<CollapsibleComment record={record} />
</CardContent>
</div>
</div>
{isLightboxOpen && !imageError && (
<Lightbox
imagePadding={50}
animationDuration={200}
imageTitle={record.name}
mainSrc={fullImageUrl}
onCloseRequest={handleCloseLightbox}
/>
)}
<Card className={classes.container}>
<CardContent className={classes.details}>
<Typography variant="h5" className={classes.title}>
{record.name || translate('ra.page.loading')}
</Typography>
<Typography component="p">
{record.songCount ? (
<span>
{record.songCount}{' '}
{translate('resources.song.name', {
smart_count: record.songCount,
})}
{' · '}
<DurationField record={record} source={'duration'} />
{' · '}
<SizeField record={record} source={'size'} />
</span>
) : (
<span>&nbsp;</span>
)}
</Typography>
<CollapsibleComment record={record} />
</CardContent>
</Card>
)
}

View File

@@ -5,7 +5,6 @@ import {
useShowContext,
useShowController,
Pagination,
Title as RaTitle,
} from 'react-admin'
import { makeStyles } from '@material-ui/core/styles'
import PlaylistDetails from './PlaylistDetails'
@@ -32,7 +31,6 @@ const PlaylistShowLayout = (props) => {
return (
<>
{record && <RaTitle title={<Title subTitle={record.name} />} />}
{record && <PlaylistDetails {...context} />}
{record && (
<ReferenceManyField

View File

@@ -149,9 +149,7 @@ const PlaylistSongs = ({ playlistId, readOnly, actions, ...props }) => {
playCount: isDesktop && (
<NumberField source="playCount" sortByOrder={'DESC'} />
),
playDate: isDesktop && (
<DateField source="playDate" sortByOrder={'DESC'} showTime />
),
playDate: <DateField source="playDate" sortByOrder={'DESC'} showTime />,
quality: isDesktop && <QualityInfo source="quality" sortable={false} />,
channels: isDesktop && <NumberField source="channels" />,
bpm: isDesktop && <NumberField source="bpm" />,

View File

@@ -6,6 +6,7 @@ import {
DateField,
EditButton,
Filter,
List,
sanitizeListRestProps,
SearchInput,
SimpleList,
@@ -14,7 +15,6 @@ import {
UrlField,
useTranslate,
} from 'react-admin'
import { List } from '../common'
import { ToggleFieldsMenu, useSelectedFields } from '../common'
import { StreamField } from './StreamField'
import { setTrack } from '../actions'

View File

@@ -2,13 +2,13 @@ import {
Datagrid,
FunctionField,
BooleanField,
List,
NumberField,
SimpleList,
TextField,
useNotify,
useTranslate,
} from 'react-admin'
import { List } from '../common'
import React from 'react'
import { IconButton, Link, useMediaQuery } from '@material-ui/core'
import ShareIcon from '@material-ui/icons/Share'

View File

@@ -29,7 +29,7 @@ const SharePlayer = () => {
return {
name: s.title,
musicSrc: shareStreamUrl(s.id),
cover: shareCoverUrl(s.id, true),
cover: shareCoverUrl(s.id),
singer: s.artist,
duration: s.duration,
}

View File

@@ -61,14 +61,11 @@ const getCoverArtUrl = (record, size, square) => {
...(square && { square }),
}
// TODO Move this logic to server
// TODO Move this logic to server. `song` and `album` should have a CoverArtID
if (record.album) {
return baseUrl(url('getCoverArt', 'mf-' + record.id, options))
} else if (record.albumArtist) {
return baseUrl(url('getCoverArt', 'al-' + record.id, options))
} else if (record.sync !== undefined) {
// This is a playlist
return baseUrl(url('getCoverArt', 'pl-' + record.id, options))
} else {
return baseUrl(url('getCoverArt', 'ar-' + record.id, options))
}

View File

@@ -1,106 +0,0 @@
import { vi } from 'vitest'
import subsonic from './index'
describe('getCoverArtUrl', () => {
beforeEach(() => {
// Mock window.location
delete window.location
window.location = { href: 'http://localhost:3000/app' }
// Mock localStorage values required by subsonic
const localStorageMock = {
getItem: vi.fn((key) => {
const values = {
username: 'testuser',
'subsonic-token': 'testtoken',
'subsonic-salt': 'testsalt',
}
return values[key] || null
}),
setItem: vi.fn(),
clear: vi.fn(),
}
Object.defineProperty(window, 'localStorage', { value: localStorageMock })
})
it('should return playlist cover art URL for records with sync property', () => {
const playlistRecord = {
id: 'playlist-123',
sync: true,
updatedAt: '2023-01-01T00:00:00Z',
}
const url = subsonic.getCoverArtUrl(playlistRecord, 300, true)
expect(url).toContain('pl-playlist-123')
expect(url).toContain('size=300')
expect(url).toContain('square=true')
expect(url).toContain('_=2023-01-01T00%3A00%3A00Z')
})
it('should add timestamp for playlists without updatedAt', () => {
const playlistRecord = {
id: 'playlist-123',
sync: true,
}
const url = subsonic.getCoverArtUrl(playlistRecord, 300, true)
expect(url).toContain('pl-playlist-123')
expect(url).toContain('size=300')
expect(url).toContain('square=true')
expect(url).not.toContain('_=')
})
it('should return album cover art URL for records with albumArtist', () => {
const albumRecord = {
id: 'album-123',
albumArtist: 'Test Artist',
updatedAt: '2023-01-01T00:00:00Z',
}
const url = subsonic.getCoverArtUrl(albumRecord, 300, true)
expect(url).toContain('al-album-123')
expect(url).toContain('size=300')
expect(url).toContain('square=true')
})
it('should return media file cover art URL for records with album', () => {
const songRecord = {
id: 'song-123',
album: 'Test Album',
updatedAt: '2023-01-01T00:00:00Z',
}
const url = subsonic.getCoverArtUrl(songRecord, 300, true)
expect(url).toContain('mf-song-123')
expect(url).toContain('size=300')
expect(url).toContain('square=true')
})
it('should return artist cover art URL for other records', () => {
const artistRecord = {
id: 'artist-123',
updatedAt: '2023-01-01T00:00:00Z',
}
const url = subsonic.getCoverArtUrl(artistRecord, 300, true)
expect(url).toContain('ar-artist-123')
expect(url).toContain('size=300')
expect(url).toContain('square=true')
})
it('should handle records without updatedAt', () => {
const record = {
id: 'test-123',
}
const url = subsonic.getCoverArtUrl(record)
expect(url).toContain('ar-test-123')
expect(url).not.toContain('_=')
})
})

View File

@@ -33,14 +33,8 @@ export const shareDownloadUrl = (id) => {
return shareUrl(config.publicBaseUrl + '/d/' + id)
}
export const shareCoverUrl = (id, square) => {
return shareUrl(
config.publicBaseUrl +
'/img/' +
id +
'?size=300' +
(square ? '&square=true' : ''),
)
export const shareCoverUrl = (id) => {
return shareUrl(config.publicBaseUrl + '/img/' + id + '?size=300')
}
export const docsUrl = (path) => `https://www.navidrome.org${path}`

View File

@@ -16,10 +16,7 @@ func TestChrono(t *testing.T) {
RunSpecs(t, "Chrono Suite")
}
// Note: These tests use longer sleep durations and generous tolerances to avoid flakiness
// due to system scheduling delays. For a more elegant approach in the future, consider
// using Go 1.24's experimental testing/synctest package with GOEXPERIMENT=synctest.
// Note: These tests may be flaky due to the use of time.Sleep.
var _ = Describe("Meter", func() {
var meter *Meter
@@ -30,62 +27,44 @@ var _ = Describe("Meter", func() {
Describe("Stop", func() {
It("should return the elapsed time", func() {
meter.Start()
time.Sleep(50 * time.Millisecond)
time.Sleep(20 * time.Millisecond)
elapsed := meter.Stop()
// Use generous tolerance to account for system scheduling delays
Expect(elapsed).To(BeNumerically(">=", 30*time.Millisecond))
Expect(elapsed).To(BeNumerically("<=", 200*time.Millisecond))
Expect(elapsed).To(BeNumerically("~", 20*time.Millisecond, 10*time.Millisecond))
})
It("should accumulate elapsed time on multiple starts and stops", func() {
// First cycle
meter.Start()
time.Sleep(50 * time.Millisecond)
firstElapsed := meter.Stop()
time.Sleep(20 * time.Millisecond)
meter.Stop()
// Second cycle
meter.Start()
time.Sleep(50 * time.Millisecond)
totalElapsed := meter.Stop()
time.Sleep(20 * time.Millisecond)
elapsed := meter.Stop()
// Test that time accumulates (second measurement should be greater than first)
Expect(totalElapsed).To(BeNumerically(">", firstElapsed))
// Test that accumulated time is reasonable (should be roughly double the first)
Expect(totalElapsed).To(BeNumerically(">=", time.Duration(float64(firstElapsed)*1.5)))
Expect(totalElapsed).To(BeNumerically("<=", firstElapsed*3))
// Sanity check: total should be at least 60ms (allowing for some timing variance)
Expect(totalElapsed).To(BeNumerically(">=", 60*time.Millisecond))
Expect(elapsed).To(BeNumerically("~", 40*time.Millisecond, 20*time.Millisecond))
})
})
Describe("Elapsed", func() {
It("should return the total elapsed time", func() {
meter.Start()
time.Sleep(50 * time.Millisecond)
time.Sleep(20 * time.Millisecond)
meter.Stop()
// Should not count the time the meter was stopped
time.Sleep(50 * time.Millisecond)
time.Sleep(20 * time.Millisecond)
meter.Start()
time.Sleep(50 * time.Millisecond)
time.Sleep(20 * time.Millisecond)
meter.Stop()
elapsed := meter.Elapsed()
// Should be roughly 100ms (2 x 50ms), but allow for significant variance
Expect(elapsed).To(BeNumerically(">=", 60*time.Millisecond))
Expect(elapsed).To(BeNumerically("<=", 300*time.Millisecond))
Expect(meter.Elapsed()).To(BeNumerically("~", 40*time.Millisecond, 20*time.Millisecond))
})
It("should include the current running time if started", func() {
meter.Start()
time.Sleep(50 * time.Millisecond)
elapsed := meter.Elapsed()
// Use generous tolerance to account for system scheduling delays
Expect(elapsed).To(BeNumerically(">=", 30*time.Millisecond))
Expect(elapsed).To(BeNumerically("<=", 200*time.Millisecond))
time.Sleep(20 * time.Millisecond)
Expect(meter.Elapsed()).To(BeNumerically("~", 20*time.Millisecond, 10*time.Millisecond))
})
})
})