"))
+ })
+
+ It("uses the configured language", func() {
+ client = newClient(httpClient, "fr")
+ // Mock JWT token for the new client instance with a valid JWT
+ testJWT := createTestJWT(5 * time.Minute)
+ httpClient.mock("https://auth.deezer.com/login/anonymous", http.Response{
+ StatusCode: 200,
+ Body: io.NopCloser(bytes.NewBufferString(fmt.Sprintf(`{"jwt":"%s","refresh_token":""}`, testJWT))),
+ })
+ f, err := os.Open("tests/fixtures/deezer.artist.bio.json")
+ Expect(err).To(BeNil())
+ httpClient.mock("https://pipe.deezer.com/api", http.Response{Body: f, StatusCode: 200})
+
+ _, err = client.getArtistBio(GinkgoT().Context(), 27)
+ Expect(err).To(BeNil())
+ Expect(httpClient.lastRequest.Header.Get("Accept-Language")).To(Equal("fr"))
+ })
+
+ It("includes the JWT token in the request", func() {
+ f, err := os.Open("tests/fixtures/deezer.artist.bio.json")
+ Expect(err).To(BeNil())
+ httpClient.mock("https://pipe.deezer.com/api", http.Response{Body: f, StatusCode: 200})
+
+ _, err = client.getArtistBio(GinkgoT().Context(), 27)
+ Expect(err).To(BeNil())
+ // Verify that the Authorization header has the Bearer token format
+ authHeader := httpClient.lastRequest.Header.Get("Authorization")
+ Expect(authHeader).To(HavePrefix("Bearer "))
+ Expect(len(authHeader)).To(BeNumerically(">", 20)) // JWT tokens are longer than 20 chars
+ })
+
+ It("handles GraphQL errors", func() {
+ errorResponse := `{
+ "data": {
+ "artist": {
+ "bio": {
+ "full": ""
+ }
+ }
+ },
+ "errors": [
+ {
+ "message": "Artist not found"
+ },
+ {
+ "message": "Invalid artist ID"
+ }
+ ]
+ }`
+ httpClient.mock("https://pipe.deezer.com/api", http.Response{
+ StatusCode: 200,
+ Body: io.NopCloser(bytes.NewBufferString(errorResponse)),
+ })
+
+ _, err := client.getArtistBio(GinkgoT().Context(), 999)
+ Expect(err).To(HaveOccurred())
+ Expect(err.Error()).To(ContainSubstring("GraphQL error"))
+ Expect(err.Error()).To(ContainSubstring("Artist not found"))
+ Expect(err.Error()).To(ContainSubstring("Invalid artist ID"))
+ })
+
+ It("handles empty biography", func() {
+ emptyBioResponse := `{
+ "data": {
+ "artist": {
+ "bio": {
+ "full": ""
+ }
+ }
+ }
+ }`
+ httpClient.mock("https://pipe.deezer.com/api", http.Response{
+ StatusCode: 200,
+ Body: io.NopCloser(bytes.NewBufferString(emptyBioResponse)),
+ })
+
+ _, err := client.getArtistBio(GinkgoT().Context(), 27)
+ Expect(err).To(MatchError("deezer: biography not found"))
+ })
+
+ It("handles JWT token fetch failure", func() {
+ httpClient.mock("https://auth.deezer.com/login/anonymous", http.Response{
+ StatusCode: 500,
+ Body: io.NopCloser(bytes.NewBufferString(`{"error":"Internal server error"}`)),
+ })
+
+ _, err := client.getArtistBio(GinkgoT().Context(), 27)
+ Expect(err).To(HaveOccurred())
+ Expect(err.Error()).To(ContainSubstring("failed to get JWT"))
+ })
+
+ It("handles JWT token that expires too soon", func() {
+ // Create a JWT that expires in 30 seconds (less than the 1-minute buffer)
+ expiredJWT := createTestJWT(30 * time.Second)
+ httpClient.mock("https://auth.deezer.com/login/anonymous", http.Response{
+ StatusCode: 200,
+ Body: io.NopCloser(bytes.NewBufferString(fmt.Sprintf(`{"jwt":"%s","refresh_token":""}`, expiredJWT))),
+ })
+
+ _, err := client.getArtistBio(GinkgoT().Context(), 27)
+ Expect(err).To(HaveOccurred())
+ Expect(err.Error()).To(ContainSubstring("JWT token already expired or expires too soon"))
+ })
+ })
})
type fakeHttpClient struct {
diff --git a/core/agents/deezer/deezer.go b/core/agents/deezer/deezer.go
index 8cabfbcfb..8f3e505ec 100644
--- a/core/agents/deezer/deezer.go
+++ b/core/agents/deezer/deezer.go
@@ -12,6 +12,7 @@ import (
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/utils/cache"
+ "github.com/navidrome/navidrome/utils/slice"
)
const deezerAgentName = "deezer"
@@ -32,7 +33,7 @@ func deezerConstructor(dataStore model.DataStore) agents.Interface {
Timeout: consts.DefaultHttpClientTimeOut,
}
cachedHttpClient := cache.NewHTTPClient(httpClient, consts.DefaultHttpClientTimeOut)
- agent.client = newClient(cachedHttpClient)
+ agent.client = newClient(cachedHttpClient, conf.Server.Deezer.Language)
return agent
}
@@ -88,6 +89,56 @@ func (s *deezerAgent) searchArtist(ctx context.Context, name string) (*Artist, e
return &artists[0], err
}
+func (s *deezerAgent) GetSimilarArtists(ctx context.Context, _, name, _ string, limit int) ([]agents.Artist, error) {
+ artist, err := s.searchArtist(ctx, name)
+ if err != nil {
+ return nil, err
+ }
+
+ related, err := s.client.getRelatedArtists(ctx, artist.ID)
+ if err != nil {
+ return nil, err
+ }
+
+ res := slice.Map(related, func(r Artist) agents.Artist {
+ return agents.Artist{
+ Name: r.Name,
+ }
+ })
+ if len(res) > limit {
+ res = res[:limit]
+ }
+ return res, nil
+}
+
+func (s *deezerAgent) GetArtistTopSongs(ctx context.Context, _, artistName, _ string, count int) ([]agents.Song, error) {
+ artist, err := s.searchArtist(ctx, artistName)
+ if err != nil {
+ return nil, err
+ }
+
+ tracks, err := s.client.getTopTracks(ctx, artist.ID, count)
+ if err != nil {
+ return nil, err
+ }
+
+ res := slice.Map(tracks, func(r Track) agents.Song {
+ return agents.Song{
+ Name: r.Title,
+ }
+ })
+ return res, nil
+}
+
+func (s *deezerAgent) GetArtistBiography(ctx context.Context, _, name, _ string) (string, error) {
+ artist, err := s.searchArtist(ctx, name)
+ if err != nil {
+ return "", err
+ }
+
+ return s.client.getArtistBio(ctx, artist.ID)
+}
+
func init() {
conf.AddHook(func() {
if conf.Server.Deezer.Enabled {
diff --git a/core/agents/deezer/responses.go b/core/agents/deezer/responses.go
index 112fe28ec..266c44c62 100644
--- a/core/agents/deezer/responses.go
+++ b/core/agents/deezer/responses.go
@@ -29,3 +29,38 @@ type Error struct {
Code int `json:"code"`
} `json:"error"`
}
+
+type RelatedArtists struct {
+ Data []Artist `json:"data"`
+ Total int `json:"total"`
+}
+
+type TopTracks struct {
+ Data []Track `json:"data"`
+ Total int `json:"total"`
+ Next string `json:"next"`
+}
+
+type Track struct {
+ ID int `json:"id"`
+ Title string `json:"title"`
+ Link string `json:"link"`
+ Duration int `json:"duration"`
+ Rank int `json:"rank"`
+ Preview string `json:"preview"`
+ Artist Artist `json:"artist"`
+ Album Album `json:"album"`
+ Contributors []Artist `json:"contributors"`
+}
+
+type Album struct {
+ ID int `json:"id"`
+ Title string `json:"title"`
+ Cover string `json:"cover"`
+ CoverSmall string `json:"cover_small"`
+ CoverMedium string `json:"cover_medium"`
+ CoverBig string `json:"cover_big"`
+ CoverXl string `json:"cover_xl"`
+ Tracklist string `json:"tracklist"`
+ Type string `json:"type"`
+}
diff --git a/core/agents/deezer/responses_test.go b/core/agents/deezer/responses_test.go
index 95a7f43f4..a9de5c5fb 100644
--- a/core/agents/deezer/responses_test.go
+++ b/core/agents/deezer/responses_test.go
@@ -35,4 +35,35 @@ var _ = Describe("Responses", func() {
Expect(errorResp.Error.Message).To(Equal("Missing parameters: q"))
})
})
+
+ Describe("Related Artists", func() {
+ It("parses the related artists response correctly", func() {
+ var resp RelatedArtists
+ body, err := os.ReadFile("tests/fixtures/deezer.artist.related.json")
+ Expect(err).To(BeNil())
+ err = json.Unmarshal(body, &resp)
+ Expect(err).To(BeNil())
+
+ Expect(resp.Data).To(HaveLen(20))
+ justice := resp.Data[0]
+ Expect(justice.Name).To(Equal("Justice"))
+ Expect(justice.ID).To(Equal(6404))
+ })
+ })
+
+ Describe("Top Tracks", func() {
+ It("parses the top tracks response correctly", func() {
+ var resp TopTracks
+ body, err := os.ReadFile("tests/fixtures/deezer.artist.top.json")
+ Expect(err).To(BeNil())
+ err = json.Unmarshal(body, &resp)
+ Expect(err).To(BeNil())
+
+ Expect(resp.Data).To(HaveLen(5))
+ track := resp.Data[0]
+ Expect(track.Title).To(Equal("Instant Crush (feat. Julian Casablancas)"))
+ Expect(track.ID).To(Equal(67238732))
+ Expect(track.Album.Title).To(Equal("Random Access Memories"))
+ })
+ })
})
diff --git a/core/agents/lastfm/agent.go b/core/agents/lastfm/agent.go
index d01b496ec..e3e53b234 100644
--- a/core/agents/lastfm/agent.go
+++ b/core/agents/lastfm/agent.go
@@ -38,6 +38,7 @@ type lastfmAgent struct {
secret string
lang string
client *client
+ httpClient httpDoer
getInfoMutex sync.Mutex
}
@@ -56,6 +57,7 @@ func lastFMConstructor(ds model.DataStore) *lastfmAgent {
Timeout: consts.DefaultHttpClientTimeOut,
}
chc := cache.NewHTTPClient(hc, consts.DefaultHttpClientTimeOut)
+ l.httpClient = chc
l.client = newClient(l.apiKey, l.secret, l.lang, chc)
return l
}
@@ -190,13 +192,13 @@ func (l *lastfmAgent) GetArtistTopSongs(ctx context.Context, id, artistName, mbi
return res, nil
}
-var artistOpenGraphQuery = cascadia.MustCompile(`html > head > meta[property="og:image"]`)
+var (
+ artistOpenGraphQuery = cascadia.MustCompile(`html > head > meta[property="og:image"]`)
+ artistIgnoredImage = "2a96cbd8b46e442fc41c2b86b821562f" // Last.fm artist placeholder image name
+)
func (l *lastfmAgent) GetArtistImages(ctx context.Context, _, name, mbid string) ([]agents.ExternalImage, error) {
log.Debug(ctx, "Getting artist images from Last.fm", "name", name)
- hc := http.Client{
- Timeout: consts.DefaultHttpClientTimeOut,
- }
a, err := l.callArtistGetInfo(ctx, name)
if err != nil {
return nil, fmt.Errorf("get artist info: %w", err)
@@ -205,7 +207,7 @@ func (l *lastfmAgent) GetArtistImages(ctx context.Context, _, name, mbid string)
if err != nil {
return nil, fmt.Errorf("create artist image request: %w", err)
}
- resp, err := hc.Do(req)
+ resp, err := l.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("get artist url: %w", err)
}
@@ -222,11 +224,16 @@ func (l *lastfmAgent) GetArtistImages(ctx context.Context, _, name, mbid string)
return res, nil
}
for _, attr := range n.Attr {
- if attr.Key == "content" {
- res = []agents.ExternalImage{
- {URL: attr.Val},
- }
- break
+ if attr.Key != "content" {
+ continue
+ }
+ if strings.Contains(attr.Val, artistIgnoredImage) {
+ log.Debug(ctx, "Artist image is ignored default image", "name", name, "url", attr.Val)
+ return res, nil
+ }
+
+ res = []agents.ExternalImage{
+ {URL: attr.Val},
}
}
return res, nil
@@ -283,11 +290,11 @@ 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
+func (l *lastfmAgent) getArtistForScrobble(track *model.MediaFile, role model.Role, displayName string) string {
+ if conf.Server.LastFM.ScrobbleFirstArtistOnly && len(track.Participants[role]) > 0 {
+ return track.Participants[role][0].Name
}
- return track.Artist
+ return displayName
}
func (l *lastfmAgent) NowPlaying(ctx context.Context, userId string, track *model.MediaFile, position int) error {
@@ -297,13 +304,13 @@ func (l *lastfmAgent) NowPlaying(ctx context.Context, userId string, track *mode
}
err = l.client.updateNowPlaying(ctx, sk, ScrobbleInfo{
- artist: l.getArtistForScrobble(track),
+ artist: l.getArtistForScrobble(track, model.RoleArtist, track.Artist),
track: track.Title,
album: track.Album,
trackNumber: track.TrackNumber,
mbid: track.MbzRecordingID,
duration: int(track.Duration),
- albumArtist: track.AlbumArtist,
+ albumArtist: l.getArtistForScrobble(track, model.RoleAlbumArtist, track.AlbumArtist),
})
if err != nil {
log.Warn(ctx, "Last.fm client.updateNowPlaying returned error", "track", track.Title, err)
@@ -323,13 +330,13 @@ 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: l.getArtistForScrobble(&s.MediaFile, model.RoleArtist, s.Artist),
track: s.Title,
album: s.Album,
trackNumber: s.TrackNumber,
mbid: s.MbzRecordingID,
duration: int(s.Duration),
- albumArtist: s.AlbumArtist,
+ albumArtist: l.getArtistForScrobble(&s.MediaFile, model.RoleAlbumArtist, s.AlbumArtist),
timestamp: s.TimeStamp,
})
if err == nil {
diff --git a/core/agents/lastfm/agent_test.go b/core/agents/lastfm/agent_test.go
index 4476d592f..fc6238408 100644
--- a/core/agents/lastfm/agent_test.go
+++ b/core/agents/lastfm/agent_test.go
@@ -201,6 +201,10 @@ var _ = Describe("lastfmAgent", func() {
{Artist: model.Artist{ID: "ar-1", Name: "First Artist"}},
{Artist: model.Artist{ID: "ar-2", Name: "Second Artist"}},
},
+ model.RoleAlbumArtist: []model.Participant{
+ {Artist: model.Artist{ID: "ar-1", Name: "First Album Artist"}},
+ {Artist: model.Artist{ID: "ar-2", Name: "Second Album Artist"}},
+ },
},
}
})
@@ -229,6 +233,23 @@ var _ = Describe("lastfmAgent", func() {
err := agent.NowPlaying(ctx, "user-2", track, 0)
Expect(err).To(MatchError(scrobbler.ErrNotAuthorized))
})
+
+ When("ScrobbleFirstArtistOnly is true", func() {
+ BeforeEach(func() {
+ conf.Server.LastFM.ScrobbleFirstArtistOnly = true
+ })
+
+ It("uses only the first artist", func() {
+ httpClient.Res = http.Response{Body: io.NopCloser(bytes.NewBufferString("{}")), StatusCode: 200}
+
+ err := agent.NowPlaying(ctx, "user-1", track, 0)
+
+ Expect(err).ToNot(HaveOccurred())
+ sentParams := httpClient.SavedRequest.URL.Query()
+ Expect(sentParams.Get("artist")).To(Equal("First Artist"))
+ Expect(sentParams.Get("albumArtist")).To(Equal("First Album Artist"))
+ })
+ })
})
Describe("scrobble", func() {
@@ -267,6 +288,7 @@ var _ = Describe("lastfmAgent", func() {
Expect(err).ToNot(HaveOccurred())
sentParams := httpClient.SavedRequest.URL.Query()
Expect(sentParams.Get("artist")).To(Equal("First Artist"))
+ Expect(sentParams.Get("albumArtist")).To(Equal("First Album Artist"))
})
})
@@ -393,4 +415,73 @@ var _ = Describe("lastfmAgent", func() {
})
})
})
+
+ Describe("GetArtistImages", func() {
+ var agent *lastfmAgent
+ var apiClient *tests.FakeHttpClient
+ var httpClient *tests.FakeHttpClient
+
+ BeforeEach(func() {
+ apiClient = &tests.FakeHttpClient{}
+ httpClient = &tests.FakeHttpClient{}
+ client := newClient("API_KEY", "SECRET", "pt", apiClient)
+ agent = lastFMConstructor(ds)
+ agent.client = client
+ agent.httpClient = httpClient
+ })
+
+ It("returns the artist image from the page", func() {
+ fApi, _ := os.Open("tests/fixtures/lastfm.artist.getinfo.json")
+ apiClient.Res = http.Response{Body: fApi, StatusCode: 200}
+
+ fScraper, _ := os.Open("tests/fixtures/lastfm.artist.page.html")
+ httpClient.Res = http.Response{Body: fScraper, StatusCode: 200}
+
+ images, err := agent.GetArtistImages(ctx, "123", "U2", "")
+ Expect(err).ToNot(HaveOccurred())
+ Expect(images).To(HaveLen(1))
+ Expect(images[0].URL).To(Equal("https://lastfm.freetls.fastly.net/i/u/ar0/818148bf682d429dc21b59a73ef6f68e.png"))
+ })
+
+ It("returns empty list if image is the ignored default image", func() {
+ fApi, _ := os.Open("tests/fixtures/lastfm.artist.getinfo.json")
+ apiClient.Res = http.Response{Body: fApi, StatusCode: 200}
+
+ fScraper, _ := os.Open("tests/fixtures/lastfm.artist.page.ignored.html")
+ httpClient.Res = http.Response{Body: fScraper, StatusCode: 200}
+
+ images, err := agent.GetArtistImages(ctx, "123", "U2", "")
+ Expect(err).ToNot(HaveOccurred())
+ Expect(images).To(BeEmpty())
+ })
+
+ It("returns empty list if page has no meta tags", func() {
+ fApi, _ := os.Open("tests/fixtures/lastfm.artist.getinfo.json")
+ apiClient.Res = http.Response{Body: fApi, StatusCode: 200}
+
+ fScraper, _ := os.Open("tests/fixtures/lastfm.artist.page.no_meta.html")
+ httpClient.Res = http.Response{Body: fScraper, StatusCode: 200}
+
+ images, err := agent.GetArtistImages(ctx, "123", "U2", "")
+ Expect(err).ToNot(HaveOccurred())
+ Expect(images).To(BeEmpty())
+ })
+
+ It("returns error if API call fails", func() {
+ apiClient.Err = errors.New("api error")
+ _, err := agent.GetArtistImages(ctx, "123", "U2", "")
+ Expect(err).To(HaveOccurred())
+ Expect(err.Error()).To(ContainSubstring("get artist info"))
+ })
+
+ It("returns error if scraper call fails", func() {
+ fApi, _ := os.Open("tests/fixtures/lastfm.artist.getinfo.json")
+ apiClient.Res = http.Response{Body: fApi, StatusCode: 200}
+
+ httpClient.Err = errors.New("scraper error")
+ _, err := agent.GetArtistImages(ctx, "123", "U2", "")
+ Expect(err).To(HaveOccurred())
+ Expect(err.Error()).To(ContainSubstring("get artist url"))
+ })
+ })
})
diff --git a/core/artwork/cache_warmer_test.go b/core/artwork/cache_warmer_test.go
index 4125d6de0..7ae3a16e0 100644
--- a/core/artwork/cache_warmer_test.go
+++ b/core/artwork/cache_warmer_test.go
@@ -90,6 +90,7 @@ var _ = Describe("CacheWarmer", func() {
})
It("deduplicates items in buffer", func() {
+ fc.SetReady(false) // Make cache unavailable so items stay in buffer
cw := NewCacheWarmer(aw, fc).(*cacheWarmer)
cw.PreCache(model.MustParseArtworkID("al-1"))
cw.PreCache(model.MustParseArtworkID("al-1"))
diff --git a/core/artwork/reader_album.go b/core/artwork/reader_album.go
index 55d8b4352..cb4db97fe 100644
--- a/core/artwork/reader_album.go
+++ b/core/artwork/reader_album.go
@@ -1,6 +1,7 @@
package artwork
import (
+ "cmp"
"context"
"crypto/md5"
"fmt"
@@ -11,6 +12,7 @@ import (
"time"
"github.com/Masterminds/squirrel"
+ "github.com/maruel/natural"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/core"
"github.com/navidrome/navidrome/core/external"
@@ -116,8 +118,30 @@ func loadAlbumFoldersPaths(ctx context.Context, ds model.DataStore, albums ...mo
}
// Sort image files to ensure consistent selection of cover art
- // This prioritizes files from lower-numbered disc folders by sorting the paths
- slices.Sort(imgFiles)
+ // This prioritizes files without numeric suffixes (e.g., cover.jpg over cover.1.jpg)
+ // by comparing base filenames without extensions
+ slices.SortFunc(imgFiles, compareImageFiles)
return paths, imgFiles, &updatedAt, nil
}
+
+// compareImageFiles compares two image file paths for sorting.
+// It extracts the base filename (without extension) and compares case-insensitively.
+// This ensures that "cover.jpg" sorts before "cover.1.jpg" since "cover" < "cover.1".
+// Note: This function is called O(n log n) times during sorting, but in practice albums
+// typically have only 1-20 image files, making the repeated string operations negligible.
+func compareImageFiles(a, b string) int {
+ // Case-insensitive comparison
+ a = strings.ToLower(a)
+ b = strings.ToLower(b)
+
+ // Extract base filenames without extensions
+ baseA := strings.TrimSuffix(filepath.Base(a), filepath.Ext(a))
+ baseB := strings.TrimSuffix(filepath.Base(b), filepath.Ext(b))
+
+ // Compare base names first, then full paths if equal
+ return cmp.Or(
+ natural.Compare(baseA, baseB),
+ natural.Compare(a, b),
+ )
+}
diff --git a/core/artwork/reader_album_test.go b/core/artwork/reader_album_test.go
index 2665632b9..fd5f8a2be 100644
--- a/core/artwork/reader_album_test.go
+++ b/core/artwork/reader_album_test.go
@@ -27,26 +27,7 @@ var _ = Describe("Album Artwork Reader", func() {
expectedAt = now.Add(5 * time.Minute)
// Set up the test folders with image files
- repo = &fakeFolderRepo{
- result: []model.Folder{
- {
- Path: "Artist/Album/Disc1",
- ImagesUpdatedAt: expectedAt,
- ImageFiles: []string{"cover.jpg", "back.jpg"},
- },
- {
- Path: "Artist/Album/Disc2",
- ImagesUpdatedAt: now,
- ImageFiles: []string{"cover.jpg"},
- },
- {
- Path: "Artist/Album/Disc10",
- ImagesUpdatedAt: now,
- ImageFiles: []string{"cover.jpg"},
- },
- },
- err: nil,
- }
+ repo = &fakeFolderRepo{}
ds = &fakeDataStore{
folderRepo: repo,
}
@@ -58,19 +39,82 @@ var _ = Describe("Album Artwork Reader", func() {
})
It("returns sorted image files", func() {
+ repo.result = []model.Folder{
+ {
+ Path: "Artist/Album/Disc1",
+ ImagesUpdatedAt: expectedAt,
+ ImageFiles: []string{"cover.jpg", "back.jpg", "cover.1.jpg"},
+ },
+ {
+ Path: "Artist/Album/Disc2",
+ ImagesUpdatedAt: now,
+ ImageFiles: []string{"cover.jpg"},
+ },
+ {
+ Path: "Artist/Album/Disc10",
+ ImagesUpdatedAt: now,
+ ImageFiles: []string{"cover.jpg"},
+ },
+ }
+
_, imgFiles, imagesUpdatedAt, err := loadAlbumFoldersPaths(ctx, ds, album)
Expect(err).ToNot(HaveOccurred())
Expect(*imagesUpdatedAt).To(Equal(expectedAt))
- // Check that image files are sorted alphabetically
- Expect(imgFiles).To(HaveLen(4))
+ // Check that image files are sorted by base name (without extension)
+ Expect(imgFiles).To(HaveLen(5))
- // The files should be sorted by full path
+ // Files should be sorted by base filename without extension, then by full path
+ // "back" < "cover", so back.jpg comes first
+ // Then all cover.jpg files, sorted by path
Expect(imgFiles[0]).To(Equal(filepath.FromSlash("Artist/Album/Disc1/back.jpg")))
Expect(imgFiles[1]).To(Equal(filepath.FromSlash("Artist/Album/Disc1/cover.jpg")))
- Expect(imgFiles[2]).To(Equal(filepath.FromSlash("Artist/Album/Disc10/cover.jpg")))
- Expect(imgFiles[3]).To(Equal(filepath.FromSlash("Artist/Album/Disc2/cover.jpg")))
+ Expect(imgFiles[2]).To(Equal(filepath.FromSlash("Artist/Album/Disc2/cover.jpg")))
+ Expect(imgFiles[3]).To(Equal(filepath.FromSlash("Artist/Album/Disc10/cover.jpg")))
+ Expect(imgFiles[4]).To(Equal(filepath.FromSlash("Artist/Album/Disc1/cover.1.jpg")))
+ })
+
+ It("prioritizes files without numeric suffixes", func() {
+ // Test case for issue #4683: cover.jpg should come before cover.1.jpg
+ repo.result = []model.Folder{
+ {
+ Path: "Artist/Album",
+ ImagesUpdatedAt: now,
+ ImageFiles: []string{"cover.1.jpg", "cover.jpg", "cover.2.jpg"},
+ },
+ }
+
+ _, imgFiles, _, err := loadAlbumFoldersPaths(ctx, ds, album)
+
+ Expect(err).ToNot(HaveOccurred())
+ Expect(imgFiles).To(HaveLen(3))
+
+ // cover.jpg should come first because "cover" < "cover.1" < "cover.2"
+ Expect(imgFiles[0]).To(Equal(filepath.FromSlash("Artist/Album/cover.jpg")))
+ Expect(imgFiles[1]).To(Equal(filepath.FromSlash("Artist/Album/cover.1.jpg")))
+ Expect(imgFiles[2]).To(Equal(filepath.FromSlash("Artist/Album/cover.2.jpg")))
+ })
+
+ It("handles case-insensitive sorting", func() {
+ // Test that Cover.jpg and cover.jpg are treated as equivalent
+ repo.result = []model.Folder{
+ {
+ Path: "Artist/Album",
+ ImagesUpdatedAt: now,
+ ImageFiles: []string{"Folder.jpg", "cover.jpg", "BACK.jpg"},
+ },
+ }
+
+ _, imgFiles, _, err := loadAlbumFoldersPaths(ctx, ds, album)
+
+ Expect(err).ToNot(HaveOccurred())
+ Expect(imgFiles).To(HaveLen(3))
+
+ // Files should be sorted case-insensitively: BACK, cover, Folder
+ Expect(imgFiles[0]).To(Equal(filepath.FromSlash("Artist/Album/BACK.jpg")))
+ Expect(imgFiles[1]).To(Equal(filepath.FromSlash("Artist/Album/cover.jpg")))
+ Expect(imgFiles[2]).To(Equal(filepath.FromSlash("Artist/Album/Folder.jpg")))
})
})
})
diff --git a/core/artwork/reader_artist.go b/core/artwork/reader_artist.go
index cb029a16e..da8141a2d 100644
--- a/core/artwork/reader_artist.go
+++ b/core/artwork/reader_artist.go
@@ -8,6 +8,7 @@ import (
"io/fs"
"os"
"path/filepath"
+ "slices"
"strings"
"time"
@@ -139,11 +140,22 @@ func findImageInFolder(ctx context.Context, folder, pattern string) (io.ReadClos
return nil, "", err
}
+ // Filter to valid image files
+ var imagePaths []string
for _, m := range matches {
if !model.IsImageFile(m) {
continue
}
- filePath := filepath.Join(folder, m)
+ imagePaths = append(imagePaths, m)
+ }
+
+ // Sort image files by prioritizing base filenames without numeric
+ // suffixes (e.g., artist.jpg before artist.1.jpg)
+ slices.SortFunc(imagePaths, compareImageFiles)
+
+ // Try to open files in sorted order
+ for _, p := range imagePaths {
+ filePath := filepath.Join(folder, p)
f, err := os.Open(filePath)
if err != nil {
log.Warn(ctx, "Could not open cover art file", "file", filePath, err)
diff --git a/core/artwork/reader_artist_test.go b/core/artwork/reader_artist_test.go
index 527b0849f..e6a0168f8 100644
--- a/core/artwork/reader_artist_test.go
+++ b/core/artwork/reader_artist_test.go
@@ -240,24 +240,79 @@ var _ = Describe("artistArtworkReader", func() {
Expect(os.MkdirAll(artistDir, 0755)).To(Succeed())
// Create multiple matching files
- Expect(os.WriteFile(filepath.Join(artistDir, "artist.jpg"), []byte("jpg image"), 0600)).To(Succeed())
+ Expect(os.WriteFile(filepath.Join(artistDir, "artist.abc"), []byte("text file"), 0600)).To(Succeed())
Expect(os.WriteFile(filepath.Join(artistDir, "artist.png"), []byte("png image"), 0600)).To(Succeed())
- Expect(os.WriteFile(filepath.Join(artistDir, "artist.txt"), []byte("text file"), 0600)).To(Succeed())
+ Expect(os.WriteFile(filepath.Join(artistDir, "artist.jpg"), []byte("jpg image"), 0600)).To(Succeed())
testFunc = fromArtistFolder(ctx, artistDir, "artist.*")
})
- It("returns the first valid image file", func() {
+ It("returns the first valid image file in sorted order", func() {
reader, path, err := testFunc()
Expect(err).ToNot(HaveOccurred())
Expect(reader).ToNot(BeNil())
- // Should return an image file, not the text file
- Expect(path).To(SatisfyAny(
- ContainSubstring("artist.jpg"),
- ContainSubstring("artist.png"),
- ))
- Expect(path).ToNot(ContainSubstring("artist.txt"))
+ // Should return an image file,
+ // Files are sorted: jpg comes before png alphabetically.
+ // .abc comes first, but it's not an image.
+ Expect(path).To(ContainSubstring("artist.jpg"))
+ reader.Close()
+ })
+ })
+
+ When("prioritizing files without numeric suffixes", func() {
+ BeforeEach(func() {
+ // Test case for issue #4683: artist.jpg should come before artist.1.jpg
+ artistDir := filepath.Join(tempDir, "artist")
+ Expect(os.MkdirAll(artistDir, 0755)).To(Succeed())
+
+ // Create multiple matches with and without numeric suffixes
+ Expect(os.WriteFile(filepath.Join(artistDir, "artist.1.jpg"), []byte("artist 1"), 0600)).To(Succeed())
+ Expect(os.WriteFile(filepath.Join(artistDir, "artist.jpg"), []byte("artist main"), 0600)).To(Succeed())
+ Expect(os.WriteFile(filepath.Join(artistDir, "artist.2.jpg"), []byte("artist 2"), 0600)).To(Succeed())
+
+ testFunc = fromArtistFolder(ctx, artistDir, "artist.*")
+ })
+
+ It("returns artist.jpg before artist.1.jpg and artist.2.jpg", func() {
+ reader, path, err := testFunc()
+ Expect(err).ToNot(HaveOccurred())
+ Expect(reader).ToNot(BeNil())
+ Expect(path).To(ContainSubstring("artist.jpg"))
+
+ // Verify it's the main file, not a numbered variant
+ data, err := io.ReadAll(reader)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(string(data)).To(Equal("artist main"))
+ reader.Close()
+ })
+ })
+
+ When("handling case-insensitive sorting", func() {
+ BeforeEach(func() {
+ // Test case to ensure case-insensitive natural sorting
+ artistDir := filepath.Join(tempDir, "artist")
+ Expect(os.MkdirAll(artistDir, 0755)).To(Succeed())
+
+ // Create files with mixed case names
+ Expect(os.WriteFile(filepath.Join(artistDir, "Folder.jpg"), []byte("folder"), 0600)).To(Succeed())
+ Expect(os.WriteFile(filepath.Join(artistDir, "artist.jpg"), []byte("artist"), 0600)).To(Succeed())
+ Expect(os.WriteFile(filepath.Join(artistDir, "BACK.jpg"), []byte("back"), 0600)).To(Succeed())
+
+ testFunc = fromArtistFolder(ctx, artistDir, "*.*")
+ })
+
+ It("sorts case-insensitively", func() {
+ reader, path, err := testFunc()
+ Expect(err).ToNot(HaveOccurred())
+ Expect(reader).ToNot(BeNil())
+
+ // Should return artist.jpg first (case-insensitive: "artist" < "back" < "folder")
+ Expect(path).To(ContainSubstring("artist.jpg"))
+
+ data, err := io.ReadAll(reader)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(string(data)).To(Equal("artist"))
reader.Close()
})
})
diff --git a/core/auth/auth.go b/core/auth/auth.go
index fd2b670a4..ddd12767b 100644
--- a/core/auth/auth.go
+++ b/core/auth/auth.go
@@ -113,9 +113,9 @@ func WithAdminUser(ctx context.Context, ds model.DataStore) context.Context {
if err != nil {
c, err := ds.User(ctx).CountAll()
if c == 0 && err == nil {
- log.Debug(ctx, "Scanner: No admin user yet!", err)
+ log.Debug(ctx, "No admin user yet!", err)
} else {
- log.Error(ctx, "Scanner: No admin user found!", err)
+ log.Error(ctx, "No admin user found!", err)
}
u = &model.User{}
}
diff --git a/core/external/provider.go b/core/external/provider.go
index 8e9a458c1..413c7e0c4 100644
--- a/core/external/provider.go
+++ b/core/external/provider.go
@@ -51,12 +51,28 @@ type provider struct {
type auxAlbum struct {
model.Album
- Name string
+}
+
+// Name returns the appropriate album name for external API calls
+// based on the DevPreserveUnicodeInExternalCalls configuration option
+func (a *auxAlbum) Name() string {
+ if conf.Server.DevPreserveUnicodeInExternalCalls {
+ return a.Album.Name
+ }
+ return str.Clear(a.Album.Name)
}
type auxArtist struct {
model.Artist
- Name string
+}
+
+// Name returns the appropriate artist name for external API calls
+// based on the DevPreserveUnicodeInExternalCalls configuration option
+func (a *auxArtist) Name() string {
+ if conf.Server.DevPreserveUnicodeInExternalCalls {
+ return a.Artist.Name
+ }
+ return str.Clear(a.Artist.Name)
}
type Agents interface {
@@ -88,7 +104,6 @@ func (e *provider) getAlbum(ctx context.Context, id string) (auxAlbum, error) {
switch v := entity.(type) {
case *model.Album:
album.Album = *v
- album.Name = str.Clear(v.Name)
case *model.MediaFile:
return e.getAlbum(ctx, v.AlbumID)
default:
@@ -106,8 +121,9 @@ func (e *provider) UpdateAlbumInfo(ctx context.Context, id string) (*model.Album
}
updatedAt := V(album.ExternalInfoUpdatedAt)
+ albumName := album.Name()
if updatedAt.IsZero() {
- log.Debug(ctx, "AlbumInfo not cached. Retrieving it now", "updatedAt", updatedAt, "id", id, "name", album.Name)
+ log.Debug(ctx, "AlbumInfo not cached. Retrieving it now", "updatedAt", updatedAt, "id", id, "name", albumName)
album, err = e.populateAlbumInfo(ctx, album)
if err != nil {
return nil, err
@@ -116,7 +132,7 @@ func (e *provider) UpdateAlbumInfo(ctx context.Context, id string) (*model.Album
// If info is expired, trigger a populateAlbumInfo in the background
if time.Since(updatedAt) > conf.Server.DevAlbumInfoTimeToLive {
- log.Debug("Found expired cached AlbumInfo, refreshing in the background", "updatedAt", album.ExternalInfoUpdatedAt, "name", album.Name)
+ log.Debug("Found expired cached AlbumInfo, refreshing in the background", "updatedAt", album.ExternalInfoUpdatedAt, "name", albumName)
e.albumQueue.enqueue(&album)
}
@@ -125,12 +141,13 @@ func (e *provider) UpdateAlbumInfo(ctx context.Context, id string) (*model.Album
func (e *provider) populateAlbumInfo(ctx context.Context, album auxAlbum) (auxAlbum, error) {
start := time.Now()
- info, err := e.ag.GetAlbumInfo(ctx, album.Name, album.AlbumArtist, album.MbzAlbumID)
+ albumName := album.Name()
+ info, err := e.ag.GetAlbumInfo(ctx, albumName, album.AlbumArtist, album.MbzAlbumID)
if errors.Is(err, agents.ErrNotFound) {
return album, nil
}
if err != nil {
- log.Error("Error refreshing AlbumInfo", "id", album.ID, "name", album.Name, "artist", album.AlbumArtist,
+ log.Error("Error refreshing AlbumInfo", "id", album.ID, "name", albumName, "artist", album.AlbumArtist,
"elapsed", time.Since(start), err)
return album, err
}
@@ -142,7 +159,7 @@ func (e *provider) populateAlbumInfo(ctx context.Context, album auxAlbum) (auxAl
album.Description = info.Description
}
- images, err := e.ag.GetAlbumImages(ctx, album.Name, album.AlbumArtist, album.MbzAlbumID)
+ images, err := e.ag.GetAlbumImages(ctx, albumName, album.AlbumArtist, album.MbzAlbumID)
if err == nil && len(images) > 0 {
sort.Slice(images, func(i, j int) bool {
return images[i].Size > images[j].Size
@@ -161,7 +178,7 @@ func (e *provider) populateAlbumInfo(ctx context.Context, album auxAlbum) (auxAl
err = e.ds.Album(ctx).UpdateExternalInfo(&album.Album)
if err != nil {
- log.Error(ctx, "Error trying to update album external information", "id", album.ID, "name", album.Name,
+ log.Error(ctx, "Error trying to update album external information", "id", album.ID, "name", albumName,
"elapsed", time.Since(start), err)
} else {
log.Trace(ctx, "AlbumInfo collected", "album", album, "elapsed", time.Since(start))
@@ -181,7 +198,6 @@ func (e *provider) getArtist(ctx context.Context, id string) (auxArtist, error)
switch v := entity.(type) {
case *model.Artist:
artist.Artist = *v
- artist.Name = str.Clear(v.Name)
case *model.MediaFile:
return e.getArtist(ctx, v.ArtistID)
case *model.Album:
@@ -210,8 +226,9 @@ func (e *provider) refreshArtistInfo(ctx context.Context, id string) (auxArtist,
// If we don't have any info, retrieves it now
updatedAt := V(artist.ExternalInfoUpdatedAt)
+ artistName := artist.Name()
if updatedAt.IsZero() {
- log.Debug(ctx, "ArtistInfo not cached. Retrieving it now", "updatedAt", updatedAt, "id", id, "name", artist.Name)
+ log.Debug(ctx, "ArtistInfo not cached. Retrieving it now", "updatedAt", updatedAt, "id", id, "name", artistName)
artist, err = e.populateArtistInfo(ctx, artist)
if err != nil {
return auxArtist{}, err
@@ -220,7 +237,7 @@ func (e *provider) refreshArtistInfo(ctx context.Context, id string) (auxArtist,
// If info is expired, trigger a populateArtistInfo in the background
if time.Since(updatedAt) > conf.Server.DevArtistInfoTimeToLive {
- log.Debug("Found expired cached ArtistInfo, refreshing in the background", "updatedAt", updatedAt, "name", artist.Name)
+ log.Debug("Found expired cached ArtistInfo, refreshing in the background", "updatedAt", updatedAt, "name", artistName)
e.artistQueue.enqueue(&artist)
}
return artist, nil
@@ -229,8 +246,9 @@ func (e *provider) refreshArtistInfo(ctx context.Context, id string) (auxArtist,
func (e *provider) populateArtistInfo(ctx context.Context, artist auxArtist) (auxArtist, error) {
start := time.Now()
// Get MBID first, if it is not yet available
+ artistName := artist.Name()
if artist.MbzArtistID == "" {
- mbid, err := e.ag.GetArtistMBID(ctx, artist.ID, artist.Name)
+ mbid, err := e.ag.GetArtistMBID(ctx, artist.ID, artistName)
if mbid != "" && err == nil {
artist.MbzArtistID = mbid
}
@@ -246,14 +264,14 @@ func (e *provider) populateArtistInfo(ctx context.Context, artist auxArtist) (au
_ = g.Wait()
if utils.IsCtxDone(ctx) {
- log.Warn(ctx, "ArtistInfo update canceled", "elapsed", "id", artist.ID, "name", artist.Name, time.Since(start), ctx.Err())
+ log.Warn(ctx, "ArtistInfo update canceled", "id", artist.ID, "name", artistName, "elapsed", time.Since(start), ctx.Err())
return artist, ctx.Err()
}
artist.ExternalInfoUpdatedAt = P(time.Now())
err := e.ds.Artist(ctx).UpdateExternalInfo(&artist.Artist)
if err != nil {
- log.Error(ctx, "Error trying to update artist external information", "id", artist.ID, "name", artist.Name,
+ log.Error(ctx, "Error trying to update artist external information", "id", artist.ID, "name", artistName,
"elapsed", time.Since(start), err)
} else {
log.Trace(ctx, "ArtistInfo collected", "artist", artist, "elapsed", time.Since(start))
@@ -281,7 +299,7 @@ func (e *provider) ArtistRadio(ctx context.Context, id string, count int) (model
}
topCount := max(count, 20)
- topSongs, err := e.getMatchingTopSongs(ctx, e.ag, &auxArtist{Name: a.Name, Artist: a}, topCount)
+ topSongs, err := e.getMatchingTopSongs(ctx, e.ag, &auxArtist{Artist: a}, topCount)
if err != nil {
log.Warn(ctx, "Error getting artist's top songs", "artist", a.Name, err)
return nil
@@ -344,22 +362,23 @@ func (e *provider) AlbumImage(ctx context.Context, id string) (*url.URL, error)
return nil, err
}
- images, err := e.ag.GetAlbumImages(ctx, album.Name, album.AlbumArtist, album.MbzAlbumID)
+ albumName := album.Name()
+ images, err := e.ag.GetAlbumImages(ctx, albumName, album.AlbumArtist, album.MbzAlbumID)
if err != nil {
switch {
case errors.Is(err, agents.ErrNotFound):
- log.Trace(ctx, "Album not found in agent", "albumID", id, "name", album.Name, "artist", album.AlbumArtist)
+ log.Trace(ctx, "Album not found in agent", "albumID", id, "name", albumName, "artist", album.AlbumArtist)
return nil, model.ErrNotFound
case errors.Is(err, context.Canceled):
log.Debug(ctx, "GetAlbumImages call canceled", err)
default:
- log.Warn(ctx, "Error getting album images from agent", "albumID", id, "name", album.Name, "artist", album.AlbumArtist, err)
+ log.Warn(ctx, "Error getting album images from agent", "albumID", id, "name", albumName, "artist", album.AlbumArtist, err)
}
return nil, err
}
if len(images) == 0 {
- log.Warn(ctx, "Agent returned no images without error", "albumID", id, "name", album.Name, "artist", album.AlbumArtist)
+ log.Warn(ctx, "Agent returned no images without error", "albumID", id, "name", albumName, "artist", album.AlbumArtist)
return nil, model.ErrNotFound
}
@@ -401,9 +420,10 @@ func (e *provider) TopSongs(ctx context.Context, artistName string, count int) (
}
func (e *provider) getMatchingTopSongs(ctx context.Context, agent agents.ArtistTopSongsRetriever, artist *auxArtist, count int) (model.MediaFiles, error) {
- songs, err := agent.GetArtistTopSongs(ctx, artist.ID, artist.Name, artist.MbzArtistID, count)
+ artistName := artist.Name()
+ songs, err := agent.GetArtistTopSongs(ctx, artist.ID, artistName, artist.MbzArtistID, count)
if err != nil {
- return nil, fmt.Errorf("failed to get top songs for artist %s: %w", artist.Name, err)
+ return nil, fmt.Errorf("failed to get top songs for artist %s: %w", artistName, err)
}
mbidMatches, err := e.loadTracksByMBID(ctx, songs)
@@ -415,13 +435,13 @@ func (e *provider) getMatchingTopSongs(ctx context.Context, agent agents.ArtistT
return nil, fmt.Errorf("failed to load tracks by title: %w", err)
}
- log.Trace(ctx, "Top Songs loaded", "name", artist.Name, "numSongs", len(songs), "numMBIDMatches", len(mbidMatches), "numTitleMatches", len(titleMatches))
+ log.Trace(ctx, "Top Songs loaded", "name", artistName, "numSongs", len(songs), "numMBIDMatches", len(mbidMatches), "numTitleMatches", len(titleMatches))
mfs := e.selectTopSongs(songs, mbidMatches, titleMatches, count)
if len(mfs) == 0 {
- log.Debug(ctx, "No matching top songs found", "name", artist.Name)
+ log.Debug(ctx, "No matching top songs found", "name", artistName)
} else {
- log.Debug(ctx, "Found matching top songs", "name", artist.Name, "numSongs", len(mfs))
+ log.Debug(ctx, "Found matching top songs", "name", artistName, "numSongs", len(mfs))
}
return mfs, nil
@@ -518,7 +538,7 @@ func (e *provider) selectTopSongs(songs []agents.Song, byMBID, byTitle map[strin
}
func (e *provider) callGetURL(ctx context.Context, agent agents.ArtistURLRetriever, artist *auxArtist) {
- artisURL, err := agent.GetArtistURL(ctx, artist.ID, artist.Name, artist.MbzArtistID)
+ artisURL, err := agent.GetArtistURL(ctx, artist.ID, artist.Name(), artist.MbzArtistID)
if err != nil {
return
}
@@ -526,7 +546,7 @@ func (e *provider) callGetURL(ctx context.Context, agent agents.ArtistURLRetriev
}
func (e *provider) callGetBiography(ctx context.Context, agent agents.ArtistBiographyRetriever, artist *auxArtist) {
- bio, err := agent.GetArtistBiography(ctx, artist.ID, str.Clear(artist.Name), artist.MbzArtistID)
+ bio, err := agent.GetArtistBiography(ctx, artist.ID, artist.Name(), artist.MbzArtistID)
if err != nil {
return
}
@@ -536,7 +556,7 @@ func (e *provider) callGetBiography(ctx context.Context, agent agents.ArtistBiog
}
func (e *provider) callGetImage(ctx context.Context, agent agents.ArtistImageRetriever, artist *auxArtist) {
- images, err := agent.GetArtistImages(ctx, artist.ID, artist.Name, artist.MbzArtistID)
+ images, err := agent.GetArtistImages(ctx, artist.ID, artist.Name(), artist.MbzArtistID)
if err != nil {
return
}
@@ -555,13 +575,14 @@ func (e *provider) callGetImage(ctx context.Context, agent agents.ArtistImageRet
func (e *provider) callGetSimilar(ctx context.Context, agent agents.ArtistSimilarRetriever, artist *auxArtist,
limit int, includeNotPresent bool) {
- similar, err := agent.GetSimilarArtists(ctx, artist.ID, artist.Name, artist.MbzArtistID, limit)
+ artistName := artist.Name()
+ similar, err := agent.GetSimilarArtists(ctx, artist.ID, artistName, artist.MbzArtistID, limit)
if len(similar) == 0 || err != nil {
return
}
start := time.Now()
sa, err := e.mapSimilarArtists(ctx, similar, limit, includeNotPresent)
- log.Debug(ctx, "Mapped Similar Artists", "artist", artist.Name, "numSimilar", len(sa), "elapsed", time.Since(start))
+ log.Debug(ctx, "Mapped Similar Artists", "artist", artistName, "numSimilar", len(sa), "elapsed", time.Since(start))
if err != nil {
return
}
@@ -635,11 +656,7 @@ func (e *provider) findArtistByName(ctx context.Context, artistName string) (*au
if len(artists) == 0 {
return nil, model.ErrNotFound
}
- artist := &auxArtist{
- Artist: artists[0],
- Name: str.Clear(artists[0].Name),
- }
- return artist, nil
+ return &auxArtist{Artist: artists[0]}, nil
}
func (e *provider) loadSimilar(ctx context.Context, artist *auxArtist, count int, includeNotPresent bool) error {
@@ -655,7 +672,7 @@ func (e *provider) loadSimilar(ctx context.Context, artist *auxArtist, count int
Filters: squirrel.Eq{"artist.id": ids},
})
if err != nil {
- log.Error("Error loading similar artists", "id", artist.ID, "name", artist.Name, err)
+ log.Error("Error loading similar artists", "id", artist.ID, "name", artist.Name(), err)
return err
}
diff --git a/core/external/provider_albumimage_test.go b/core/external/provider_albumimage_test.go
index 9b682462d..8a81b4f4d 100644
--- a/core/external/provider_albumimage_test.go
+++ b/core/external/provider_albumimage_test.go
@@ -260,6 +260,69 @@ var _ = Describe("Provider - AlbumImage", func() {
mockMediaFileRepo.AssertCalled(GinkgoT(), "Get", "not-found")
mockAlbumAgent.AssertNotCalled(GinkgoT(), "GetAlbumImages", mock.Anything, mock.Anything, mock.Anything)
})
+
+ Context("Unicode handling in album names", func() {
+ var albumWithEnDash *model.Album
+ var expectedURL *url.URL
+
+ const (
+ originalAlbumName = "Raising Hell–Deluxe" // Album name with en dash
+ normalizedAlbumName = "Raising Hell-Deluxe" // Normalized version with hyphen
+ )
+
+ BeforeEach(func() {
+ // Test with en dash (–) in album name
+ albumWithEnDash = &model.Album{ID: "album-endash", Name: originalAlbumName, AlbumArtistID: "artist-1"}
+ mockArtistRepo.Mock = mock.Mock{} // Reset default expectations
+ mockAlbumRepo.Mock = mock.Mock{} // Reset default expectations
+ mockArtistRepo.On("Get", "album-endash").Return(nil, model.ErrNotFound).Once()
+ mockAlbumRepo.On("Get", "album-endash").Return(albumWithEnDash, nil).Once()
+
+ expectedURL, _ = url.Parse("http://example.com/album.jpg")
+
+ // Mock the album agent to return an image for the album
+ mockAlbumAgent.On("GetAlbumImages", ctx, mock.AnythingOfType("string"), "", "").
+ Return([]agents.ExternalImage{
+ {URL: "http://example.com/album.jpg", Size: 1000},
+ }, nil).Once()
+ })
+
+ When("DevPreserveUnicodeInExternalCalls is true", func() {
+ BeforeEach(func() {
+ conf.Server.DevPreserveUnicodeInExternalCalls = true
+ })
+
+ It("preserves Unicode characters in album names", func() {
+ // Act
+ imgURL, err := provider.AlbumImage(ctx, "album-endash")
+
+ // Assert
+ Expect(err).ToNot(HaveOccurred())
+ Expect(imgURL).To(Equal(expectedURL))
+ mockAlbumRepo.AssertCalled(GinkgoT(), "Get", "album-endash")
+ // This is the key assertion: ensure the original Unicode name is used
+ mockAlbumAgent.AssertCalled(GinkgoT(), "GetAlbumImages", ctx, originalAlbumName, "", "")
+ })
+ })
+
+ When("DevPreserveUnicodeInExternalCalls is false", func() {
+ BeforeEach(func() {
+ conf.Server.DevPreserveUnicodeInExternalCalls = false
+ })
+
+ It("normalizes Unicode characters", func() {
+ // Act
+ imgURL, err := provider.AlbumImage(ctx, "album-endash")
+
+ // Assert
+ Expect(err).ToNot(HaveOccurred())
+ Expect(imgURL).To(Equal(expectedURL))
+ mockAlbumRepo.AssertCalled(GinkgoT(), "Get", "album-endash")
+ // This assertion ensures the normalized name is used (en dash → hyphen)
+ mockAlbumAgent.AssertCalled(GinkgoT(), "GetAlbumImages", ctx, normalizedAlbumName, "", "")
+ })
+ })
+ })
})
// mockAlbumInfoAgent implementation
diff --git a/core/external/provider_artistimage_test.go b/core/external/provider_artistimage_test.go
index 96341836a..11290bb66 100644
--- a/core/external/provider_artistimage_test.go
+++ b/core/external/provider_artistimage_test.go
@@ -265,6 +265,67 @@ var _ = Describe("Provider - ArtistImage", func() {
mockArtistRepo.AssertCalled(GinkgoT(), "Get", "artist-1")
mockImageAgent.AssertCalled(GinkgoT(), "GetArtistImages", ctx, "artist-1", "Artist One", "")
})
+
+ Context("Unicode handling in artist names", func() {
+ var artistWithEnDash *model.Artist
+ var expectedURL *url.URL
+
+ const (
+ originalArtistName = "Run–D.M.C." // Artist name with en dash
+ normalizedArtistName = "Run-D.M.C." // Normalized version with hyphen
+ )
+
+ BeforeEach(func() {
+ // Test with en dash (–) in artist name like "Run–D.M.C."
+ artistWithEnDash = &model.Artist{ID: "artist-endash", Name: originalArtistName}
+ mockArtistRepo.Mock = mock.Mock{} // Reset default expectations
+ mockArtistRepo.On("Get", "artist-endash").Return(artistWithEnDash, nil).Once()
+
+ expectedURL, _ = url.Parse("http://example.com/rundmc.jpg")
+
+ // Mock the image agent to return an image for the artist
+ mockImageAgent.On("GetArtistImages", ctx, "artist-endash", mock.AnythingOfType("string"), "").
+ Return([]agents.ExternalImage{
+ {URL: "http://example.com/rundmc.jpg", Size: 1000},
+ }, nil).Once()
+
+ })
+
+ When("DevPreserveUnicodeInExternalCalls is true", func() {
+ BeforeEach(func() {
+ conf.Server.DevPreserveUnicodeInExternalCalls = true
+ })
+ It("preserves Unicode characters in artist names", func() {
+ // Act
+ imgURL, err := provider.ArtistImage(ctx, "artist-endash")
+
+ // Assert
+ Expect(err).ToNot(HaveOccurred())
+ Expect(imgURL).To(Equal(expectedURL))
+ mockArtistRepo.AssertCalled(GinkgoT(), "Get", "artist-endash")
+ // This is the key assertion: ensure the original Unicode name is used
+ mockImageAgent.AssertCalled(GinkgoT(), "GetArtistImages", ctx, "artist-endash", originalArtistName, "")
+ })
+ })
+
+ When("DevPreserveUnicodeInExternalCalls is false", func() {
+ BeforeEach(func() {
+ conf.Server.DevPreserveUnicodeInExternalCalls = false
+ })
+
+ It("normalizes Unicode characters", func() {
+ // Act
+ imgURL, err := provider.ArtistImage(ctx, "artist-endash")
+
+ // Assert
+ Expect(err).ToNot(HaveOccurred())
+ Expect(imgURL).To(Equal(expectedURL))
+ mockArtistRepo.AssertCalled(GinkgoT(), "Get", "artist-endash")
+ // This assertion ensures the normalized name is used (en dash → hyphen)
+ mockImageAgent.AssertCalled(GinkgoT(), "GetArtistImages", ctx, "artist-endash", normalizedArtistName, "")
+ })
+ })
+ })
})
// mockArtistImageAgent implementation using testify/mock
diff --git a/core/ffmpeg/ffmpeg.go b/core/ffmpeg/ffmpeg.go
index 2e0d5a4b7..d134077ce 100644
--- a/core/ffmpeg/ffmpeg.go
+++ b/core/ffmpeg/ffmpeg.go
@@ -112,7 +112,7 @@ func (e *ffmpeg) start(ctx context.Context, args []string) (io.ReadCloser, error
log.Trace(ctx, "Executing ffmpeg command", "cmd", args)
j := &ffCmd{args: args}
j.PipeReader, j.out = io.Pipe()
- err := j.start()
+ err := j.start(ctx)
if err != nil {
return nil, err
}
@@ -127,8 +127,8 @@ type ffCmd struct {
cmd *exec.Cmd
}
-func (j *ffCmd) start() error {
- cmd := exec.Command(j.args[0], j.args[1:]...) // #nosec
+func (j *ffCmd) start(ctx context.Context) error {
+ cmd := exec.CommandContext(ctx, j.args[0], j.args[1:]...) // #nosec
cmd.Stdout = j.out
if log.IsGreaterOrEqualTo(log.LevelTrace) {
cmd.Stderr = os.Stderr
diff --git a/core/ffmpeg/ffmpeg_test.go b/core/ffmpeg/ffmpeg_test.go
index 7e67a2a6a..debe0b51e 100644
--- a/core/ffmpeg/ffmpeg_test.go
+++ b/core/ffmpeg/ffmpeg_test.go
@@ -1,7 +1,11 @@
package ffmpeg
import (
+ "context"
+ "runtime"
+ sync "sync"
"testing"
+ "time"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/tests"
@@ -65,4 +69,98 @@ var _ = Describe("ffmpeg", func() {
Expect(args).To(Equal([]string{"/usr/bin/with spaces/ffmpeg.exe", "-i", "one.mp3", "-f", "ffmetadata"}))
})
})
+
+ Describe("FFmpeg", func() {
+ Context("when FFmpeg is available", func() {
+ var ff FFmpeg
+
+ BeforeEach(func() {
+ ffOnce = sync.Once{}
+ ff = New()
+ // Skip if FFmpeg is not available
+ if !ff.IsAvailable() {
+ Skip("FFmpeg not available on this system")
+ }
+ })
+
+ It("should interrupt transcoding when context is cancelled", func() {
+ ctx, cancel := context.WithTimeout(GinkgoT().Context(), 5*time.Second)
+ defer cancel()
+
+ // Use a command that generates audio indefinitely
+ // -f lavfi uses FFmpeg's built-in audio source
+ // -t 0 means no time limit (runs forever)
+ command := "ffmpeg -f lavfi -i sine=frequency=1000:duration=0 -f mp3 -"
+
+ // The input file is not used here, but we need to provide a valid path to the Transcode function
+ stream, err := ff.Transcode(ctx, command, "tests/fixtures/test.mp3", 128, 0)
+ Expect(err).ToNot(HaveOccurred())
+ defer stream.Close()
+
+ // Read some data first to ensure FFmpeg is running
+ buf := make([]byte, 1024)
+ _, err = stream.Read(buf)
+ Expect(err).ToNot(HaveOccurred())
+
+ // Cancel the context
+ cancel()
+
+ // Next read should fail due to cancelled context
+ _, err = stream.Read(buf)
+ Expect(err).To(HaveOccurred())
+ })
+
+ It("should handle immediate context cancellation", func() {
+ ctx, cancel := context.WithCancel(GinkgoT().Context())
+ cancel() // Cancel immediately
+
+ // This should fail immediately
+ _, err := ff.Transcode(ctx, "ffmpeg -i %s -f mp3 -", "tests/fixtures/test.mp3", 128, 0)
+ Expect(err).To(MatchError(context.Canceled))
+ })
+ })
+
+ Context("with mock process behavior", func() {
+ var longRunningCmd string
+ BeforeEach(func() {
+ // Use a long-running command for testing cancellation
+ switch runtime.GOOS {
+ case "windows":
+ // Use PowerShell's Start-Sleep
+ ffmpegPath = "powershell"
+ longRunningCmd = "powershell -Command Start-Sleep -Seconds 10"
+ default:
+ // Use sleep on Unix-like systems
+ ffmpegPath = "sleep"
+ longRunningCmd = "sleep 10"
+ }
+ })
+
+ It("should terminate the underlying process when context is cancelled", func() {
+ ff := New()
+ ctx, cancel := context.WithTimeout(GinkgoT().Context(), 5*time.Second)
+ defer cancel()
+
+ // Start a process that will run for a while
+ stream, err := ff.Transcode(ctx, longRunningCmd, "tests/fixtures/test.mp3", 0, 0)
+ Expect(err).ToNot(HaveOccurred())
+ defer stream.Close()
+
+ // Give the process time to start
+ time.Sleep(50 * time.Millisecond)
+
+ // Cancel the context
+ cancel()
+
+ // Try to read from the stream, which should fail
+ buf := make([]byte, 100)
+ _, err = stream.Read(buf)
+ Expect(err).To(HaveOccurred(), "Expected stream to be closed due to process termination")
+
+ // Verify the stream is closed by attempting another read
+ _, err = stream.Read(buf)
+ Expect(err).To(HaveOccurred())
+ })
+ })
+ })
})
diff --git a/core/library.go b/core/library.go
index 7abd35c8f..f4f55ec5a 100644
--- a/core/library.go
+++ b/core/library.go
@@ -21,11 +21,6 @@ import (
"github.com/navidrome/navidrome/utils/slice"
)
-// Scanner interface for triggering scans
-type Scanner interface {
- ScanAll(ctx context.Context, fullScan bool) (warnings []string, err error)
-}
-
// Watcher interface for managing file system watchers
type Watcher interface {
Watch(ctx context.Context, lib *model.Library) error
@@ -43,13 +38,13 @@ type Library interface {
type libraryService struct {
ds model.DataStore
- scanner Scanner
+ scanner model.Scanner
watcher Watcher
broker events.Broker
}
// NewLibrary creates a new Library service
-func NewLibrary(ds model.DataStore, scanner Scanner, watcher Watcher, broker events.Broker) Library {
+func NewLibrary(ds model.DataStore, scanner model.Scanner, watcher Watcher, broker events.Broker) Library {
return &libraryService{
ds: ds,
scanner: scanner,
@@ -155,7 +150,7 @@ type libraryRepositoryWrapper struct {
model.LibraryRepository
ctx context.Context
ds model.DataStore
- scanner Scanner
+ scanner model.Scanner
watcher Watcher
broker events.Broker
}
@@ -192,7 +187,7 @@ func (r *libraryRepositoryWrapper) Save(entity interface{}) (string, error) {
return strconv.Itoa(lib.ID), nil
}
-func (r *libraryRepositoryWrapper) Update(id string, entity interface{}, cols ...string) error {
+func (r *libraryRepositoryWrapper) Update(id string, entity interface{}, _ ...string) error {
lib := entity.(*model.Library)
libID, err := strconv.Atoi(id)
if err != nil {
diff --git a/core/library_test.go b/core/library_test.go
index bfbb4300a..bf73a62b7 100644
--- a/core/library_test.go
+++ b/core/library_test.go
@@ -29,7 +29,7 @@ var _ = Describe("Library Service", func() {
var userRepo *tests.MockedUserRepo
var ctx context.Context
var tempDir string
- var scanner *mockScanner
+ var scanner *tests.MockScanner
var watcherManager *mockWatcherManager
var broker *mockEventBroker
@@ -43,7 +43,7 @@ var _ = Describe("Library Service", func() {
ds.MockedUser = userRepo
// Create a mock scanner that tracks calls
- scanner = &mockScanner{}
+ scanner = tests.NewMockScanner()
// Create a mock watcher manager
watcherManager = &mockWatcherManager{
libraryStates: make(map[int]model.Library),
@@ -616,11 +616,12 @@ var _ = Describe("Library Service", func() {
// Wait briefly for the goroutine to complete
Eventually(func() int {
- return scanner.len()
+ return scanner.GetScanAllCallCount()
}, "1s", "10ms").Should(Equal(1))
// Verify scan was called with correct parameters
- Expect(scanner.ScanCalls[0].FullScan).To(BeFalse()) // Should be quick scan
+ calls := scanner.GetScanAllCalls()
+ Expect(calls[0].FullScan).To(BeFalse()) // Should be quick scan
})
It("triggers scan when updating library path", func() {
@@ -641,11 +642,12 @@ var _ = Describe("Library Service", func() {
// Wait briefly for the goroutine to complete
Eventually(func() int {
- return scanner.len()
+ return scanner.GetScanAllCallCount()
}, "1s", "10ms").Should(Equal(1))
// Verify scan was called with correct parameters
- Expect(scanner.ScanCalls[0].FullScan).To(BeFalse()) // Should be quick scan
+ calls := scanner.GetScanAllCalls()
+ Expect(calls[0].FullScan).To(BeFalse()) // Should be quick scan
})
It("does not trigger scan when updating library without path change", func() {
@@ -661,7 +663,7 @@ var _ = Describe("Library Service", func() {
// Wait a bit to ensure no scan was triggered
Consistently(func() int {
- return scanner.len()
+ return scanner.GetScanAllCallCount()
}, "100ms", "10ms").Should(Equal(0))
})
@@ -674,7 +676,7 @@ var _ = Describe("Library Service", func() {
// Ensure no scan was triggered since creation failed
Consistently(func() int {
- return scanner.len()
+ return scanner.GetScanAllCallCount()
}, "100ms", "10ms").Should(Equal(0))
})
@@ -691,7 +693,7 @@ var _ = Describe("Library Service", func() {
// Ensure no scan was triggered since update failed
Consistently(func() int {
- return scanner.len()
+ return scanner.GetScanAllCallCount()
}, "100ms", "10ms").Should(Equal(0))
})
@@ -707,11 +709,12 @@ var _ = Describe("Library Service", func() {
// Wait briefly for the goroutine to complete
Eventually(func() int {
- return scanner.len()
+ return scanner.GetScanAllCallCount()
}, "1s", "10ms").Should(Equal(1))
// Verify scan was called with correct parameters
- Expect(scanner.ScanCalls[0].FullScan).To(BeFalse()) // Should be quick scan
+ calls := scanner.GetScanAllCalls()
+ Expect(calls[0].FullScan).To(BeFalse()) // Should be quick scan
})
It("does not trigger scan when library deletion fails", func() {
@@ -721,7 +724,7 @@ var _ = Describe("Library Service", func() {
// Ensure no scan was triggered since deletion failed
Consistently(func() int {
- return scanner.len()
+ return scanner.GetScanAllCallCount()
}, "100ms", "10ms").Should(Equal(0))
})
@@ -868,31 +871,6 @@ var _ = Describe("Library Service", func() {
})
})
-// mockScanner provides a simple mock implementation of core.Scanner for testing
-type mockScanner struct {
- ScanCalls []ScanCall
- mu sync.RWMutex
-}
-
-type ScanCall struct {
- FullScan bool
-}
-
-func (m *mockScanner) ScanAll(ctx context.Context, fullScan bool) (warnings []string, err error) {
- m.mu.Lock()
- defer m.mu.Unlock()
- m.ScanCalls = append(m.ScanCalls, ScanCall{
- FullScan: fullScan,
- })
- return []string{}, nil
-}
-
-func (m *mockScanner) len() int {
- m.mu.RLock()
- defer m.mu.RUnlock()
- return len(m.ScanCalls)
-}
-
// mockWatcherManager provides a simple mock implementation of core.Watcher for testing
type mockWatcherManager struct {
StartedWatchers []model.Library
diff --git a/core/lyrics/sources.go b/core/lyrics/sources.go
index 6d4a4cc6f..857dc2eef 100644
--- a/core/lyrics/sources.go
+++ b/core/lyrics/sources.go
@@ -8,6 +8,7 @@ import (
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
+ "github.com/navidrome/navidrome/utils/ioutils"
)
func fromEmbedded(ctx context.Context, mf *model.MediaFile) (model.LyricList, error) {
@@ -27,8 +28,7 @@ func fromExternalFile(ctx context.Context, mf *model.MediaFile, suffix string) (
externalLyric := basePath[0:len(basePath)-len(ext)] + suffix
- contents, err := os.ReadFile(externalLyric)
-
+ contents, err := ioutils.UTF8ReadFile(externalLyric)
if errors.Is(err, os.ErrNotExist) {
log.Trace(ctx, "no lyrics found at path", "path", externalLyric)
return nil, nil
diff --git a/core/lyrics/sources_test.go b/core/lyrics/sources_test.go
index e92564c00..b3d502101 100644
--- a/core/lyrics/sources_test.go
+++ b/core/lyrics/sources_test.go
@@ -108,5 +108,39 @@ var _ = Describe("sources", func() {
},
}))
})
+
+ It("should handle LRC files with UTF-8 BOM marker (issue #4631)", func() {
+ // The function looks for , so we need to pass
+ // a MediaFile with .mp3 path and look for .lrc suffix
+ mf := model.MediaFile{Path: "tests/fixtures/bom-test.mp3"}
+ lyrics, err := fromExternalFile(ctx, &mf, ".lrc")
+
+ Expect(err).To(BeNil())
+ Expect(lyrics).ToNot(BeNil())
+ Expect(lyrics).To(HaveLen(1))
+
+ // The critical assertion: even with BOM, synced should be true
+ Expect(lyrics[0].Synced).To(BeTrue(), "Lyrics with BOM marker should be recognized as synced")
+ Expect(lyrics[0].Line).To(HaveLen(1))
+ Expect(lyrics[0].Line[0].Start).To(Equal(gg.P(int64(0))))
+ Expect(lyrics[0].Line[0].Value).To(ContainSubstring("作曲"))
+ })
+
+ It("should handle UTF-16 LE encoded LRC files", func() {
+ mf := model.MediaFile{Path: "tests/fixtures/bom-utf16-test.mp3"}
+ lyrics, err := fromExternalFile(ctx, &mf, ".lrc")
+
+ Expect(err).To(BeNil())
+ Expect(lyrics).ToNot(BeNil())
+ Expect(lyrics).To(HaveLen(1))
+
+ // UTF-16 should be properly converted to UTF-8
+ Expect(lyrics[0].Synced).To(BeTrue(), "UTF-16 encoded lyrics should be recognized as synced")
+ Expect(lyrics[0].Line).To(HaveLen(2))
+ Expect(lyrics[0].Line[0].Start).To(Equal(gg.P(int64(18800))))
+ Expect(lyrics[0].Line[0].Value).To(Equal("We're no strangers to love"))
+ Expect(lyrics[0].Line[1].Start).To(Equal(gg.P(int64(22801))))
+ Expect(lyrics[0].Line[1].Value).To(Equal("You know the rules and so do I"))
+ })
})
})
diff --git a/core/maintenance.go b/core/maintenance.go
new file mode 100644
index 000000000..750fd3a9e
--- /dev/null
+++ b/core/maintenance.go
@@ -0,0 +1,226 @@
+package core
+
+import (
+ "context"
+ "fmt"
+ "slices"
+ "sync"
+ "time"
+
+ "github.com/Masterminds/squirrel"
+ "github.com/navidrome/navidrome/log"
+ "github.com/navidrome/navidrome/model"
+ "github.com/navidrome/navidrome/model/request"
+ "github.com/navidrome/navidrome/utils/slice"
+)
+
+type Maintenance interface {
+ // DeleteMissingFiles deletes specific missing files by their IDs
+ DeleteMissingFiles(ctx context.Context, ids []string) error
+ // DeleteAllMissingFiles deletes all files marked as missing
+ DeleteAllMissingFiles(ctx context.Context) error
+}
+
+type maintenanceService struct {
+ ds model.DataStore
+ wg sync.WaitGroup
+}
+
+func NewMaintenance(ds model.DataStore) Maintenance {
+ return &maintenanceService{
+ ds: ds,
+ }
+}
+
+func (s *maintenanceService) DeleteMissingFiles(ctx context.Context, ids []string) error {
+ return s.deleteMissing(ctx, ids)
+}
+
+func (s *maintenanceService) DeleteAllMissingFiles(ctx context.Context) error {
+ return s.deleteMissing(ctx, nil)
+}
+
+// deleteMissing handles the deletion of missing files and triggers necessary cleanup operations
+func (s *maintenanceService) deleteMissing(ctx context.Context, ids []string) error {
+ // Track affected album IDs before deletion for refresh
+ affectedAlbumIDs, err := s.getAffectedAlbumIDs(ctx, ids)
+ if err != nil {
+ log.Warn(ctx, "Error tracking affected albums for refresh", err)
+ // Don't fail the operation, just log the warning
+ }
+
+ // Delete missing files within a transaction
+ err = s.ds.WithTx(func(tx model.DataStore) error {
+ if len(ids) == 0 {
+ _, err := tx.MediaFile(ctx).DeleteAllMissing()
+ return err
+ }
+ return tx.MediaFile(ctx).DeleteMissing(ids)
+ })
+ if err != nil {
+ log.Error(ctx, "Error deleting missing tracks from DB", "ids", ids, err)
+ return err
+ }
+
+ // Run garbage collection to clean up orphaned records
+ if err := s.ds.GC(ctx); err != nil {
+ log.Error(ctx, "Error running GC after deleting missing tracks", err)
+ return err
+ }
+
+ // Refresh statistics in background
+ s.refreshStatsAsync(ctx, affectedAlbumIDs)
+
+ return nil
+}
+
+// refreshAlbums recalculates album attributes (size, duration, song count, etc.) from media files.
+// It uses batch queries to minimize database round-trips for efficiency.
+func (s *maintenanceService) refreshAlbums(ctx context.Context, albumIDs []string) error {
+ if len(albumIDs) == 0 {
+ return nil
+ }
+
+ log.Debug(ctx, "Refreshing albums", "count", len(albumIDs))
+
+ // Process in chunks to avoid query size limits
+ const chunkSize = 100
+ for chunk := range slice.CollectChunks(slices.Values(albumIDs), chunkSize) {
+ if err := s.refreshAlbumChunk(ctx, chunk); err != nil {
+ return fmt.Errorf("refreshing album chunk: %w", err)
+ }
+ }
+
+ log.Debug(ctx, "Successfully refreshed albums", "count", len(albumIDs))
+ return nil
+}
+
+// refreshAlbumChunk processes a single chunk of album IDs
+func (s *maintenanceService) refreshAlbumChunk(ctx context.Context, albumIDs []string) error {
+ albumRepo := s.ds.Album(ctx)
+ mfRepo := s.ds.MediaFile(ctx)
+
+ // Batch load existing albums
+ albums, err := albumRepo.GetAll(model.QueryOptions{
+ Filters: squirrel.Eq{"album.id": albumIDs},
+ })
+ if err != nil {
+ return fmt.Errorf("loading albums: %w", err)
+ }
+
+ // Create a map for quick lookup
+ albumMap := make(map[string]*model.Album, len(albums))
+ for i := range albums {
+ albumMap[albums[i].ID] = &albums[i]
+ }
+
+ // Batch load all media files for these albums
+ mediaFiles, err := mfRepo.GetAll(model.QueryOptions{
+ Filters: squirrel.Eq{"album_id": albumIDs},
+ Sort: "album_id, path",
+ })
+ if err != nil {
+ return fmt.Errorf("loading media files: %w", err)
+ }
+
+ // Group media files by album ID
+ filesByAlbum := make(map[string]model.MediaFiles)
+ for i := range mediaFiles {
+ albumID := mediaFiles[i].AlbumID
+ filesByAlbum[albumID] = append(filesByAlbum[albumID], mediaFiles[i])
+ }
+
+ // Recalculate each album from its media files
+ for albumID, oldAlbum := range albumMap {
+ mfs, hasTracks := filesByAlbum[albumID]
+ if !hasTracks {
+ // Album has no tracks anymore, skip (will be cleaned up by GC)
+ log.Debug(ctx, "Skipping album with no tracks", "albumID", albumID)
+ continue
+ }
+
+ // Recalculate album from media files
+ newAlbum := mfs.ToAlbum()
+
+ // Only update if something changed (avoid unnecessary writes)
+ if !oldAlbum.Equals(newAlbum) {
+ // Preserve original timestamps
+ newAlbum.UpdatedAt = time.Now()
+ newAlbum.CreatedAt = oldAlbum.CreatedAt
+
+ if err := albumRepo.Put(&newAlbum); err != nil {
+ log.Error(ctx, "Error updating album during refresh", "albumID", albumID, err)
+ // Continue with other albums instead of failing entirely
+ continue
+ }
+ log.Trace(ctx, "Refreshed album", "albumID", albumID, "name", newAlbum.Name)
+ }
+ }
+
+ return nil
+}
+
+// getAffectedAlbumIDs returns distinct album IDs from missing media files
+func (s *maintenanceService) getAffectedAlbumIDs(ctx context.Context, ids []string) ([]string, error) {
+ var filters squirrel.Sqlizer = squirrel.Eq{"missing": true}
+ if len(ids) > 0 {
+ filters = squirrel.And{
+ squirrel.Eq{"missing": true},
+ squirrel.Eq{"media_file.id": ids},
+ }
+ }
+
+ mfs, err := s.ds.MediaFile(ctx).GetAll(model.QueryOptions{
+ Filters: filters,
+ })
+ if err != nil {
+ return nil, err
+ }
+
+ // Extract unique album IDs
+ albumIDMap := make(map[string]struct{}, len(mfs))
+ for _, mf := range mfs {
+ if mf.AlbumID != "" {
+ albumIDMap[mf.AlbumID] = struct{}{}
+ }
+ }
+
+ albumIDs := make([]string, 0, len(albumIDMap))
+ for id := range albumIDMap {
+ albumIDs = append(albumIDs, id)
+ }
+
+ return albumIDs, nil
+}
+
+// refreshStatsAsync refreshes artist and album statistics in background goroutines
+func (s *maintenanceService) refreshStatsAsync(ctx context.Context, affectedAlbumIDs []string) {
+ // Refresh artist stats in background
+ s.wg.Add(1)
+ go func() {
+ defer s.wg.Done()
+ bgCtx := request.AddValues(context.Background(), ctx)
+ if _, err := s.ds.Artist(bgCtx).RefreshStats(true); err != nil {
+ log.Error(bgCtx, "Error refreshing artist stats after deleting missing files", err)
+ } else {
+ log.Debug(bgCtx, "Successfully refreshed artist stats after deleting missing files")
+ }
+
+ // Refresh album stats in background if we have affected albums
+ if len(affectedAlbumIDs) > 0 {
+ if err := s.refreshAlbums(bgCtx, affectedAlbumIDs); err != nil {
+ log.Error(bgCtx, "Error refreshing album stats after deleting missing files", err)
+ } else {
+ log.Debug(bgCtx, "Successfully refreshed album stats after deleting missing files", "count", len(affectedAlbumIDs))
+ }
+ }
+ }()
+}
+
+// Wait waits for all background goroutines to complete.
+// WARNING: This method is ONLY for testing. Never call this in production code.
+// Calling Wait() in production will block until ALL background operations complete
+// and may cause race conditions with new operations starting.
+func (s *maintenanceService) wait() {
+ s.wg.Wait()
+}
diff --git a/core/maintenance_test.go b/core/maintenance_test.go
new file mode 100644
index 000000000..09b442438
--- /dev/null
+++ b/core/maintenance_test.go
@@ -0,0 +1,364 @@
+package core
+
+import (
+ "context"
+ "errors"
+ "sync"
+
+ "github.com/navidrome/navidrome/model"
+ "github.com/navidrome/navidrome/model/request"
+ "github.com/navidrome/navidrome/tests"
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+ "github.com/sirupsen/logrus"
+)
+
+var _ = Describe("Maintenance", func() {
+ var ds *tests.MockDataStore
+ var mfRepo *extendedMediaFileRepo
+ var service Maintenance
+ var ctx context.Context
+
+ BeforeEach(func() {
+ ctx = context.Background()
+ ctx = request.WithUser(ctx, model.User{ID: "user1", IsAdmin: true})
+
+ ds = createTestDataStore()
+ mfRepo = ds.MockedMediaFile.(*extendedMediaFileRepo)
+ service = NewMaintenance(ds)
+ })
+
+ Describe("DeleteMissingFiles", func() {
+ Context("with specific IDs", func() {
+ It("deletes specific missing files and runs GC", func() {
+ // Setup: mock missing files with album IDs
+ mfRepo.SetData(model.MediaFiles{
+ {ID: "mf1", AlbumID: "album1", Missing: true},
+ {ID: "mf2", AlbumID: "album2", Missing: true},
+ })
+
+ err := service.DeleteMissingFiles(ctx, []string{"mf1", "mf2"})
+
+ Expect(err).ToNot(HaveOccurred())
+ Expect(mfRepo.deleteMissingCalled).To(BeTrue())
+ Expect(mfRepo.deletedIDs).To(Equal([]string{"mf1", "mf2"}))
+ Expect(ds.GCCalled).To(BeTrue(), "GC should be called after deletion")
+ })
+
+ It("triggers artist stats refresh and album refresh after deletion", func() {
+ artistRepo := ds.MockedArtist.(*extendedArtistRepo)
+ // Setup: mock missing files with albums
+ albumRepo := ds.MockedAlbum.(*extendedAlbumRepo)
+ albumRepo.SetData(model.Albums{
+ {ID: "album1", Name: "Test Album", SongCount: 5},
+ })
+ mfRepo.SetData(model.MediaFiles{
+ {ID: "mf1", AlbumID: "album1", Missing: true},
+ {ID: "mf2", AlbumID: "album1", Missing: false, Size: 1000, Duration: 180},
+ {ID: "mf3", AlbumID: "album1", Missing: false, Size: 2000, Duration: 200},
+ })
+
+ err := service.DeleteMissingFiles(ctx, []string{"mf1"})
+
+ Expect(err).ToNot(HaveOccurred())
+
+ // Wait for background goroutines to complete
+ service.(*maintenanceService).wait()
+
+ // RefreshStats should be called
+ Expect(artistRepo.IsRefreshStatsCalled()).To(BeTrue(), "Artist stats should be refreshed")
+
+ // Album should be updated with new calculated values
+ Expect(albumRepo.GetPutCallCount()).To(BeNumerically(">", 0), "Album.Put() should be called to refresh album data")
+ })
+
+ It("returns error if deletion fails", func() {
+ mfRepo.deleteMissingError = errors.New("delete failed")
+
+ err := service.DeleteMissingFiles(ctx, []string{"mf1"})
+
+ Expect(err).To(HaveOccurred())
+ Expect(err.Error()).To(ContainSubstring("delete failed"))
+ })
+
+ It("continues even if album tracking fails", func() {
+ mfRepo.SetError(true)
+
+ err := service.DeleteMissingFiles(ctx, []string{"mf1"})
+
+ // Should not fail, just log warning
+ Expect(err).ToNot(HaveOccurred())
+ Expect(mfRepo.deleteMissingCalled).To(BeTrue())
+ })
+
+ It("returns error if GC fails", func() {
+ mfRepo.SetData(model.MediaFiles{
+ {ID: "mf1", AlbumID: "album1", Missing: true},
+ })
+
+ // Set GC to return error
+ ds.GCError = errors.New("gc failed")
+
+ err := service.DeleteMissingFiles(ctx, []string{"mf1"})
+
+ Expect(err).To(HaveOccurred())
+ Expect(err.Error()).To(ContainSubstring("gc failed"))
+ })
+ })
+
+ Context("album ID extraction", func() {
+ It("extracts unique album IDs from missing files", func() {
+ mfRepo.SetData(model.MediaFiles{
+ {ID: "mf1", AlbumID: "album1", Missing: true},
+ {ID: "mf2", AlbumID: "album1", Missing: true},
+ {ID: "mf3", AlbumID: "album2", Missing: true},
+ })
+
+ err := service.DeleteMissingFiles(ctx, []string{"mf1", "mf2", "mf3"})
+
+ Expect(err).ToNot(HaveOccurred())
+ })
+
+ It("skips files without album IDs", func() {
+ mfRepo.SetData(model.MediaFiles{
+ {ID: "mf1", AlbumID: "", Missing: true},
+ {ID: "mf2", AlbumID: "album1", Missing: true},
+ })
+
+ err := service.DeleteMissingFiles(ctx, []string{"mf1", "mf2"})
+
+ Expect(err).ToNot(HaveOccurred())
+ })
+ })
+ })
+
+ Describe("DeleteAllMissingFiles", func() {
+ It("deletes all missing files and runs GC", func() {
+ mfRepo.SetData(model.MediaFiles{
+ {ID: "mf1", AlbumID: "album1", Missing: true},
+ {ID: "mf2", AlbumID: "album2", Missing: true},
+ {ID: "mf3", AlbumID: "album3", Missing: true},
+ })
+
+ err := service.DeleteAllMissingFiles(ctx)
+
+ Expect(err).ToNot(HaveOccurred())
+ Expect(ds.GCCalled).To(BeTrue(), "GC should be called after deletion")
+ })
+
+ It("returns error if deletion fails", func() {
+ mfRepo.SetError(true)
+
+ err := service.DeleteAllMissingFiles(ctx)
+
+ Expect(err).To(HaveOccurred())
+ })
+
+ It("handles empty result gracefully", func() {
+ mfRepo.SetData(model.MediaFiles{})
+
+ err := service.DeleteAllMissingFiles(ctx)
+
+ Expect(err).ToNot(HaveOccurred())
+ })
+ })
+
+ Describe("Album refresh logic", func() {
+ var albumRepo *extendedAlbumRepo
+
+ BeforeEach(func() {
+ albumRepo = ds.MockedAlbum.(*extendedAlbumRepo)
+ })
+
+ Context("when album has no tracks after deletion", func() {
+ It("skips the album without updating it", func() {
+ // Setup album with no remaining tracks
+ albumRepo.SetData(model.Albums{
+ {ID: "album1", Name: "Empty Album", SongCount: 1},
+ })
+ mfRepo.SetData(model.MediaFiles{
+ {ID: "mf1", AlbumID: "album1", Missing: true},
+ })
+
+ err := service.DeleteMissingFiles(ctx, []string{"mf1"})
+
+ Expect(err).ToNot(HaveOccurred())
+
+ // Wait for background goroutines to complete
+ service.(*maintenanceService).wait()
+
+ // Album should NOT be updated because it has no tracks left
+ Expect(albumRepo.GetPutCallCount()).To(Equal(0), "Album with no tracks should not be updated")
+ })
+ })
+
+ Context("when Put fails for one album", func() {
+ It("continues processing other albums", func() {
+ albumRepo.SetData(model.Albums{
+ {ID: "album1", Name: "Album 1"},
+ {ID: "album2", Name: "Album 2"},
+ })
+ mfRepo.SetData(model.MediaFiles{
+ {ID: "mf1", AlbumID: "album1", Missing: true},
+ {ID: "mf2", AlbumID: "album1", Missing: false, Size: 1000, Duration: 180},
+ {ID: "mf3", AlbumID: "album2", Missing: true},
+ {ID: "mf4", AlbumID: "album2", Missing: false, Size: 2000, Duration: 200},
+ })
+
+ // Make Put fail on first call but succeed on subsequent calls
+ albumRepo.putError = errors.New("put failed")
+ albumRepo.failOnce = true
+
+ err := service.DeleteMissingFiles(ctx, []string{"mf1", "mf3"})
+
+ // Should not fail even if one album's Put fails
+ Expect(err).ToNot(HaveOccurred())
+
+ // Wait for background goroutines to complete
+ service.(*maintenanceService).wait()
+
+ // Put should have been called multiple times
+ Expect(albumRepo.GetPutCallCount()).To(BeNumerically(">", 0), "Put should be attempted")
+ })
+ })
+
+ Context("when media file loading fails", func() {
+ It("logs warning but continues when tracking affected albums fails", func() {
+ // Set up log capturing
+ hook, cleanup := tests.LogHook()
+ defer cleanup()
+
+ albumRepo.SetData(model.Albums{
+ {ID: "album1", Name: "Album 1"},
+ })
+ mfRepo.SetData(model.MediaFiles{
+ {ID: "mf1", AlbumID: "album1", Missing: true},
+ })
+ // Make GetAll fail when loading media files
+ mfRepo.SetError(true)
+
+ err := service.DeleteMissingFiles(ctx, []string{"mf1"})
+
+ // Deletion should succeed despite the tracking error
+ Expect(err).ToNot(HaveOccurred())
+ Expect(mfRepo.deleteMissingCalled).To(BeTrue())
+
+ // Verify the warning was logged
+ Expect(hook.LastEntry()).ToNot(BeNil())
+ Expect(hook.LastEntry().Level).To(Equal(logrus.WarnLevel))
+ Expect(hook.LastEntry().Message).To(Equal("Error tracking affected albums for refresh"))
+ })
+ })
+ })
+})
+
+// Test helper to create a mock DataStore with controllable behavior
+func createTestDataStore() *tests.MockDataStore {
+ ds := &tests.MockDataStore{}
+
+ // Create extended album repo with Put tracking
+ albumRepo := &extendedAlbumRepo{
+ MockAlbumRepo: tests.CreateMockAlbumRepo(),
+ }
+ ds.MockedAlbum = albumRepo
+
+ // Create extended artist repo with RefreshStats tracking
+ artistRepo := &extendedArtistRepo{
+ MockArtistRepo: tests.CreateMockArtistRepo(),
+ }
+ ds.MockedArtist = artistRepo
+
+ // Create extended media file repo with DeleteMissing support
+ mfRepo := &extendedMediaFileRepo{
+ MockMediaFileRepo: tests.CreateMockMediaFileRepo(),
+ }
+ ds.MockedMediaFile = mfRepo
+
+ return ds
+}
+
+// Extension of MockMediaFileRepo to add DeleteMissing method
+type extendedMediaFileRepo struct {
+ *tests.MockMediaFileRepo
+ deleteMissingCalled bool
+ deletedIDs []string
+ deleteMissingError error
+}
+
+func (m *extendedMediaFileRepo) DeleteMissing(ids []string) error {
+ m.deleteMissingCalled = true
+ m.deletedIDs = ids
+ if m.deleteMissingError != nil {
+ return m.deleteMissingError
+ }
+ // Actually delete from the mock data
+ for _, id := range ids {
+ delete(m.Data, id)
+ }
+ return nil
+}
+
+// Extension of MockAlbumRepo to track Put calls
+type extendedAlbumRepo struct {
+ *tests.MockAlbumRepo
+ mu sync.RWMutex
+ putCallCount int
+ lastPutData *model.Album
+ putError error
+ failOnce bool
+}
+
+func (m *extendedAlbumRepo) Put(album *model.Album) error {
+ m.mu.Lock()
+ m.putCallCount++
+ m.lastPutData = album
+
+ // Handle failOnce behavior
+ var err error
+ if m.putError != nil {
+ if m.failOnce {
+ err = m.putError
+ m.putError = nil // Clear error after first failure
+ m.mu.Unlock()
+ return err
+ }
+ err = m.putError
+ m.mu.Unlock()
+ return err
+ }
+ m.mu.Unlock()
+
+ return m.MockAlbumRepo.Put(album)
+}
+
+func (m *extendedAlbumRepo) GetPutCallCount() int {
+ m.mu.RLock()
+ defer m.mu.RUnlock()
+ return m.putCallCount
+}
+
+// Extension of MockArtistRepo to track RefreshStats calls
+type extendedArtistRepo struct {
+ *tests.MockArtistRepo
+ mu sync.RWMutex
+ refreshStatsCalled bool
+ refreshStatsError error
+}
+
+func (m *extendedArtistRepo) RefreshStats(allArtists bool) (int64, error) {
+ m.mu.Lock()
+ m.refreshStatsCalled = true
+ err := m.refreshStatsError
+ m.mu.Unlock()
+
+ if err != nil {
+ return 0, err
+ }
+ return m.MockArtistRepo.RefreshStats(allArtists)
+}
+
+func (m *extendedArtistRepo) IsRefreshStatsCalled() bool {
+ m.mu.RLock()
+ defer m.mu.RUnlock()
+ return m.refreshStatsCalled
+}
diff --git a/core/media_streamer.go b/core/media_streamer.go
index b3593c4eb..c741ed476 100644
--- a/core/media_streamer.go
+++ b/core/media_streamer.go
@@ -204,7 +204,20 @@ func NewTranscodingCache() TranscodingCache {
log.Error(ctx, "Error loading transcoding command", "format", job.format, err)
return nil, os.ErrInvalid
}
- out, err := job.ms.transcoder.Transcode(ctx, t.Command, job.filePath, job.bitRate, job.offset)
+
+ // Choose the appropriate context based on EnableTranscodingCancellation configuration.
+ // This is where we decide whether transcoding processes should be cancellable or not.
+ var transcodingCtx context.Context
+ if conf.Server.EnableTranscodingCancellation {
+ // Use the request context directly, allowing cancellation when client disconnects
+ transcodingCtx = ctx
+ } else {
+ // Use background context with request values preserved.
+ // This prevents cancellation but maintains request metadata (user, client, etc.)
+ transcodingCtx = request.AddValues(context.Background(), ctx)
+ }
+
+ out, err := job.ms.transcoder.Transcode(transcodingCtx, t.Command, job.filePath, job.bitRate, job.offset)
if err != nil {
log.Error(ctx, "Error starting transcoder", "id", job.mf.ID, err)
return nil, os.ErrInvalid
diff --git a/core/metrics/insights.go b/core/metrics/insights.go
index f4f8738e7..411bc9ac1 100644
--- a/core/metrics/insights.go
+++ b/core/metrics/insights.go
@@ -6,6 +6,7 @@ import (
"encoding/json"
"math"
"net/http"
+ "os"
"path/filepath"
"runtime"
"runtime/debug"
@@ -21,6 +22,7 @@ import (
"github.com/navidrome/navidrome/core/metrics/insights"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
+ "github.com/navidrome/navidrome/model/request"
"github.com/navidrome/navidrome/plugins/schema"
"github.com/navidrome/navidrome/utils/singleton"
)
@@ -63,9 +65,16 @@ func GetInstance(ds model.DataStore, pluginLoader PluginLoader) Insights {
}
func (c *insightsCollector) Run(ctx context.Context) {
- ctx = auth.WithAdminUser(ctx, c.ds)
for {
- c.sendInsights(ctx)
+ // Refresh admin context on each iteration to handle cases where
+ // admin user wasn't available on previous runs
+ insightsCtx := auth.WithAdminUser(ctx, c.ds)
+ u, _ := request.UserFrom(insightsCtx)
+ if !u.IsAdmin {
+ log.Trace(insightsCtx, "No admin user available, skipping insights collection")
+ } else {
+ c.sendInsights(insightsCtx)
+ }
select {
case <-time.After(consts.InsightsUpdateInterval):
continue
@@ -160,6 +169,13 @@ var staticData = sync.OnceValue(func() insights.Data {
data.Build.Settings, data.Build.GoVersion = buildInfo()
data.OS.Containerized = consts.InContainer
+ // Install info
+ packageFilename := filepath.Join(conf.Server.DataFolder, ".package")
+ packageFileData, err := os.ReadFile(packageFilename)
+ if err == nil {
+ data.OS.Package = string(packageFileData)
+ }
+
// OS info
data.OS.Type = runtime.GOOS
data.OS.Arch = runtime.GOARCH
@@ -207,7 +223,7 @@ var staticData = sync.OnceValue(func() insights.Data {
data.Config.ScanSchedule = conf.Server.Scanner.Schedule
data.Config.ScanWatcherWait = uint64(math.Trunc(conf.Server.Scanner.WatcherWait.Seconds()))
data.Config.ScanOnStartup = conf.Server.Scanner.ScanOnStartup
- data.Config.ReverseProxyConfigured = conf.Server.ReverseProxyWhitelist != ""
+ data.Config.ReverseProxyConfigured = conf.Server.ExtAuth.TrustedSources != ""
data.Config.HasCustomPID = conf.Server.PID.Track != "" || conf.Server.PID.Album != ""
data.Config.HasCustomTags = len(conf.Server.Tags) > 0
diff --git a/core/metrics/insights/data.go b/core/metrics/insights/data.go
index 105a6218e..c46eb8743 100644
--- a/core/metrics/insights/data.go
+++ b/core/metrics/insights/data.go
@@ -16,6 +16,7 @@ type Data struct {
Containerized bool `json:"containerized"`
Arch string `json:"arch"`
NumCPU int `json:"numCPU"`
+ Package string `json:"package,omitempty"`
} `json:"os"`
Mem struct {
Alloc uint64 `json:"alloc"`
diff --git a/core/metrics/insights_linux.go b/core/metrics/insights_linux.go
index dbf3c277c..f37c945c1 100644
--- a/core/metrics/insights_linux.go
+++ b/core/metrics/insights_linux.go
@@ -42,6 +42,7 @@ type MountInfo struct {
var fsTypeMap = map[int64]string{
0x5346414f: "afs",
+ 0x187: "autofs",
0x61756673: "aufs",
0x9123683E: "btrfs",
0xc36400: "ceph",
@@ -55,9 +56,11 @@ var fsTypeMap = map[int64]string{
0x6a656a63: "fakeowner", // FS inside a container
0x65735546: "fuse",
0x4244: "hfs",
+ 0x482b: "hfs+",
0x9660: "iso9660",
0x3153464a: "jfs",
0x00006969: "nfs",
+ 0x5346544e: "ntfs", // NTFS_SB_MAGIC
0x7366746e: "ntfs",
0x794c7630: "overlayfs",
0x9fa0: "proc",
@@ -69,8 +72,16 @@ var fsTypeMap = map[int64]string{
0x01021997: "v9fs",
0x786f4256: "vboxsf",
0x4d44: "vfat",
+ 0xca451a4e: "virtiofs",
0x58465342: "xfs",
0x2FC12FC1: "zfs",
+ 0x7c7c6673: "prlfs", // Parallels Shared Folders
+
+ // Signed/unsigned conversion issues (negative hex values converted to uint32)
+ -0x6edc97c2: "btrfs", // 0x9123683e
+ -0x1acb2be: "smb2", // 0xfe534d42
+ -0xacb2be: "cifs", // 0xff534d42
+ -0xd0adff0: "f2fs", // 0xf2f52010
}
func getFilesystemType(path string) (string, error) {
diff --git a/core/playlists.go b/core/playlists.go
index 2eebc94e7..ed90cc23b 100644
--- a/core/playlists.go
+++ b/core/playlists.go
@@ -1,6 +1,7 @@
package core
import (
+ "cmp"
"context"
"encoding/json"
"errors"
@@ -9,7 +10,7 @@ import (
"net/url"
"os"
"path/filepath"
- "regexp"
+ "slices"
"strings"
"time"
@@ -20,6 +21,7 @@ import (
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/criteria"
"github.com/navidrome/navidrome/model/request"
+ "github.com/navidrome/navidrome/utils/ioutils"
"github.com/navidrome/navidrome/utils/slice"
"golang.org/x/text/unicode/norm"
)
@@ -97,12 +99,13 @@ func (s *playlists) parsePlaylist(ctx context.Context, playlistFile string, fold
}
defer file.Close()
+ reader := ioutils.UTF8Reader(file)
extension := strings.ToLower(filepath.Ext(playlistFile))
switch extension {
case ".nsp":
- err = s.parseNSP(ctx, pls, file)
+ err = s.parseNSP(ctx, pls, reader)
default:
- err = s.parseM3U(ctx, pls, folder, file)
+ err = s.parseM3U(ctx, pls, folder, reader)
}
return pls, err
}
@@ -192,22 +195,35 @@ func (s *playlists) parseM3U(ctx context.Context, pls *model.Playlist, folder *m
}
filteredLines = append(filteredLines, line)
}
- paths, err := s.normalizePaths(ctx, pls, folder, filteredLines)
+ resolvedPaths, err := s.resolvePaths(ctx, folder, filteredLines)
if err != nil {
- log.Warn(ctx, "Error normalizing paths in playlist", "playlist", pls.Name, err)
+ log.Warn(ctx, "Error resolving paths in playlist", "playlist", pls.Name, err)
continue
}
- found, err := mediaFileRepository.FindByPaths(paths)
+
+ // Normalize to NFD for filesystem compatibility (macOS). Database stores paths in NFD.
+ // See https://github.com/navidrome/navidrome/issues/4663
+ resolvedPaths = slice.Map(resolvedPaths, func(path string) string {
+ return strings.ToLower(norm.NFD.String(path))
+ })
+
+ found, err := mediaFileRepository.FindByPaths(resolvedPaths)
if err != nil {
log.Warn(ctx, "Error reading files from DB", "playlist", pls.Name, err)
continue
}
+ // Build lookup map with library-qualified keys, normalized for comparison
existing := make(map[string]int, len(found))
for idx := range found {
- existing[normalizePathForComparison(found[idx].Path)] = idx
+ // Normalize to lowercase for case-insensitive comparison
+ // Key format: "libraryID:path"
+ key := fmt.Sprintf("%d:%s", found[idx].LibraryID, strings.ToLower(found[idx].Path))
+ existing[key] = idx
}
- for _, path := range paths {
- idx, ok := existing[normalizePathForComparison(path)]
+
+ // Find media files in the order of the resolved paths, to keep playlist order
+ for _, path := range resolvedPaths {
+ idx, ok := existing[path]
if ok {
mfs = append(mfs, found[idx])
} else {
@@ -224,69 +240,150 @@ func (s *playlists) parseM3U(ctx context.Context, pls *model.Playlist, folder *m
return nil
}
-// normalizePathForComparison normalizes a file path to NFC form and converts to lowercase
-// for consistent comparison. This fixes Unicode normalization issues on macOS where
-// Apple Music creates playlists with NFC-encoded paths but the filesystem uses NFD.
-func normalizePathForComparison(path string) string {
- return strings.ToLower(norm.NFC.String(path))
+// pathResolution holds the result of resolving a playlist path to a library-relative path.
+type pathResolution struct {
+ absolutePath string
+ libraryPath string
+ libraryID int
+ valid bool
}
-// TODO This won't work for multiple libraries
-func (s *playlists) normalizePaths(ctx context.Context, pls *model.Playlist, folder *model.Folder, lines []string) ([]string, error) {
- libRegex, err := s.compileLibraryPaths(ctx)
+// ToQualifiedString converts the path resolution to a library-qualified string with forward slashes.
+// Format: "libraryID:relativePath" with forward slashes for path separators.
+func (r pathResolution) ToQualifiedString() (string, error) {
+ if !r.valid {
+ return "", fmt.Errorf("invalid path resolution")
+ }
+ relativePath, err := filepath.Rel(r.libraryPath, r.absolutePath)
if err != nil {
- return nil, err
+ return "", err
}
-
- res := make([]string, 0, len(lines))
- for idx, line := range lines {
- var libPath string
- var filePath string
-
- if folder != nil && !filepath.IsAbs(line) {
- libPath = folder.LibraryPath
- filePath = filepath.Join(folder.AbsolutePath(), line)
- } else {
- cleanLine := filepath.Clean(line)
- if libPath = libRegex.FindString(cleanLine); libPath != "" {
- filePath = cleanLine
- }
- }
-
- if libPath != "" {
- if rel, err := filepath.Rel(libPath, filePath); err == nil {
- res = append(res, rel)
- } else {
- log.Debug(ctx, "Error getting relative path", "playlist", pls.Name, "path", line, "libPath", libPath,
- "filePath", filePath, err)
- }
- } else {
- log.Warn(ctx, "Path in playlist not found in any library", "path", line, "line", idx)
- }
- }
- return slice.Map(res, filepath.ToSlash), nil
+ // Convert path separators to forward slashes
+ return fmt.Sprintf("%d:%s", r.libraryID, filepath.ToSlash(relativePath)), nil
}
-func (s *playlists) compileLibraryPaths(ctx context.Context) (*regexp.Regexp, error) {
- libs, err := s.ds.Library(ctx).GetAll()
- if err != nil {
- return nil, err
- }
+// libraryMatcher holds sorted libraries with cleaned paths for efficient path matching.
+type libraryMatcher struct {
+ libraries model.Libraries
+ cleanedPaths []string
+}
- // Create regex patterns for each library path
- patterns := make([]string, len(libs))
+// findLibraryForPath finds which library contains the given absolute path.
+// Returns library ID and path, or 0 and empty string if not found.
+func (lm *libraryMatcher) findLibraryForPath(absolutePath string) (int, string) {
+ // Check sorted libraries (longest path first) to find the best match
+ for i, cleanLibPath := range lm.cleanedPaths {
+ // Check if absolutePath is under this library path
+ if strings.HasPrefix(absolutePath, cleanLibPath) {
+ // Ensure it's a proper path boundary (not just a prefix)
+ if len(absolutePath) == len(cleanLibPath) || absolutePath[len(cleanLibPath)] == filepath.Separator {
+ return lm.libraries[i].ID, cleanLibPath
+ }
+ }
+ }
+ return 0, ""
+}
+
+// newLibraryMatcher creates a libraryMatcher with libraries sorted by path length (longest first).
+// This ensures correct matching when library paths are prefixes of each other.
+// Example: /music-classical must be checked before /music
+// Otherwise, /music-classical/track.mp3 would match /music instead of /music-classical
+func newLibraryMatcher(libs model.Libraries) *libraryMatcher {
+ // Sort libraries by path length (descending) to ensure longest paths match first.
+ slices.SortFunc(libs, func(i, j model.Library) int {
+ return cmp.Compare(len(j.Path), len(i.Path)) // Reverse order for descending
+ })
+
+ // Pre-clean all library paths once for efficient matching
+ cleanedPaths := make([]string, len(libs))
for i, lib := range libs {
- cleanPath := filepath.Clean(lib.Path)
- escapedPath := regexp.QuoteMeta(cleanPath)
- patterns[i] = fmt.Sprintf("^%s(?:/|$)", escapedPath)
+ cleanedPaths[i] = filepath.Clean(lib.Path)
}
- // Combine all patterns into a single regex
- combinedPattern := strings.Join(patterns, "|")
- re, err := regexp.Compile(combinedPattern)
+ return &libraryMatcher{
+ libraries: libs,
+ cleanedPaths: cleanedPaths,
+ }
+}
+
+// pathResolver handles path resolution logic for playlist imports.
+type pathResolver struct {
+ matcher *libraryMatcher
+}
+
+// newPathResolver creates a pathResolver with libraries loaded from the datastore.
+func newPathResolver(ctx context.Context, ds model.DataStore) (*pathResolver, error) {
+ libs, err := ds.Library(ctx).GetAll()
if err != nil {
- return nil, fmt.Errorf("compiling library paths `%s`: %w", combinedPattern, err)
+ return nil, err
}
- return re, nil
+ matcher := newLibraryMatcher(libs)
+ return &pathResolver{matcher: matcher}, nil
+}
+
+// resolvePath determines the absolute path and library path for a playlist entry.
+// For absolute paths, it uses them directly.
+// For relative paths, it resolves them relative to the playlist's folder location.
+// Example: playlist at /music/playlists/test.m3u with line "../songs/abc.mp3"
+//
+// resolves to /music/songs/abc.mp3
+func (r *pathResolver) resolvePath(line string, folder *model.Folder) pathResolution {
+ var absolutePath string
+ if folder != nil && !filepath.IsAbs(line) {
+ // Resolve relative path to absolute path based on playlist location
+ absolutePath = filepath.Clean(filepath.Join(folder.AbsolutePath(), line))
+ } else {
+ // Use absolute path directly after cleaning
+ absolutePath = filepath.Clean(line)
+ }
+
+ return r.findInLibraries(absolutePath)
+}
+
+// findInLibraries matches an absolute path against all known libraries and returns
+// a pathResolution with the library information. Returns an invalid resolution if
+// the path is not found in any library.
+func (r *pathResolver) findInLibraries(absolutePath string) pathResolution {
+ libID, libPath := r.matcher.findLibraryForPath(absolutePath)
+ if libID == 0 {
+ return pathResolution{valid: false}
+ }
+ return pathResolution{
+ absolutePath: absolutePath,
+ libraryPath: libPath,
+ libraryID: libID,
+ valid: true,
+ }
+}
+
+// resolvePaths converts playlist file paths to library-qualified paths (format: "libraryID:relativePath").
+// For relative paths, it resolves them to absolute paths first, then determines which
+// library they belong to. This allows playlists to reference files across library boundaries.
+func (s *playlists) resolvePaths(ctx context.Context, folder *model.Folder, lines []string) ([]string, error) {
+ resolver, err := newPathResolver(ctx, s.ds)
+ if err != nil {
+ return nil, err
+ }
+
+ results := make([]string, 0, len(lines))
+ for idx, line := range lines {
+ resolution := resolver.resolvePath(line, folder)
+
+ if !resolution.valid {
+ log.Warn(ctx, "Path in playlist not found in any library", "path", line, "line", idx)
+ continue
+ }
+
+ qualifiedPath, err := resolution.ToQualifiedString()
+ if err != nil {
+ log.Debug(ctx, "Error getting library-qualified path", "path", line,
+ "libPath", resolution.libraryPath, "filePath", resolution.absolutePath, err)
+ continue
+ }
+
+ results = append(results, qualifiedPath)
+ }
+
+ return results, nil
}
func (s *playlists) updatePlaylist(ctx context.Context, newPls *model.Playlist) error {
diff --git a/core/playlists_internal_test.go b/core/playlists_internal_test.go
new file mode 100644
index 000000000..88e36cc3a
--- /dev/null
+++ b/core/playlists_internal_test.go
@@ -0,0 +1,406 @@
+package core
+
+import (
+ "context"
+
+ "github.com/navidrome/navidrome/model"
+ "github.com/navidrome/navidrome/tests"
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+)
+
+var _ = Describe("libraryMatcher", func() {
+ var ds *tests.MockDataStore
+ var mockLibRepo *tests.MockLibraryRepo
+ ctx := context.Background()
+
+ BeforeEach(func() {
+ mockLibRepo = &tests.MockLibraryRepo{}
+ ds = &tests.MockDataStore{
+ MockedLibrary: mockLibRepo,
+ }
+ })
+
+ // Helper function to create a libraryMatcher from the mock datastore
+ createMatcher := func(ds model.DataStore) *libraryMatcher {
+ libs, err := ds.Library(ctx).GetAll()
+ Expect(err).ToNot(HaveOccurred())
+ return newLibraryMatcher(libs)
+ }
+
+ Describe("Longest library path matching", func() {
+ It("matches the longest library path when multiple libraries share a prefix", func() {
+ // Setup libraries with prefix conflicts
+ mockLibRepo.SetData([]model.Library{
+ {ID: 1, Path: "/music"},
+ {ID: 2, Path: "/music-classical"},
+ {ID: 3, Path: "/music-classical/opera"},
+ })
+
+ matcher := createMatcher(ds)
+
+ // Test that longest path matches first and returns correct library ID
+ testCases := []struct {
+ path string
+ expectedLibID int
+ expectedLibPath string
+ }{
+ {"/music-classical/opera/track.mp3", 3, "/music-classical/opera"},
+ {"/music-classical/track.mp3", 2, "/music-classical"},
+ {"/music/track.mp3", 1, "/music"},
+ {"/music-classical/opera/subdir/file.mp3", 3, "/music-classical/opera"},
+ }
+
+ for _, tc := range testCases {
+ libID, libPath := matcher.findLibraryForPath(tc.path)
+ Expect(libID).To(Equal(tc.expectedLibID), "Path %s should match library ID %d, but got %d", tc.path, tc.expectedLibID, libID)
+ Expect(libPath).To(Equal(tc.expectedLibPath), "Path %s should match library path %s, but got %s", tc.path, tc.expectedLibPath, libPath)
+ }
+ })
+
+ It("handles libraries with similar prefixes but different structures", func() {
+ mockLibRepo.SetData([]model.Library{
+ {ID: 1, Path: "/home/user/music"},
+ {ID: 2, Path: "/home/user/music-backup"},
+ })
+
+ matcher := createMatcher(ds)
+
+ // Test that music-backup library is matched correctly
+ libID, libPath := matcher.findLibraryForPath("/home/user/music-backup/track.mp3")
+ Expect(libID).To(Equal(2))
+ Expect(libPath).To(Equal("/home/user/music-backup"))
+
+ // Test that music library is still matched correctly
+ libID, libPath = matcher.findLibraryForPath("/home/user/music/track.mp3")
+ Expect(libID).To(Equal(1))
+ Expect(libPath).To(Equal("/home/user/music"))
+ })
+
+ It("matches path that is exactly the library root", func() {
+ mockLibRepo.SetData([]model.Library{
+ {ID: 1, Path: "/music"},
+ {ID: 2, Path: "/music-classical"},
+ })
+
+ matcher := createMatcher(ds)
+
+ // Exact library path should match
+ libID, libPath := matcher.findLibraryForPath("/music-classical")
+ Expect(libID).To(Equal(2))
+ Expect(libPath).To(Equal("/music-classical"))
+ })
+
+ It("handles complex nested library structures", func() {
+ mockLibRepo.SetData([]model.Library{
+ {ID: 1, Path: "/media"},
+ {ID: 2, Path: "/media/audio"},
+ {ID: 3, Path: "/media/audio/classical"},
+ {ID: 4, Path: "/media/audio/classical/baroque"},
+ })
+
+ matcher := createMatcher(ds)
+
+ testCases := []struct {
+ path string
+ expectedLibID int
+ expectedLibPath string
+ }{
+ {"/media/audio/classical/baroque/bach/track.mp3", 4, "/media/audio/classical/baroque"},
+ {"/media/audio/classical/mozart/track.mp3", 3, "/media/audio/classical"},
+ {"/media/audio/rock/track.mp3", 2, "/media/audio"},
+ {"/media/video/movie.mp4", 1, "/media"},
+ }
+
+ for _, tc := range testCases {
+ libID, libPath := matcher.findLibraryForPath(tc.path)
+ Expect(libID).To(Equal(tc.expectedLibID), "Path %s should match library ID %d", tc.path, tc.expectedLibID)
+ Expect(libPath).To(Equal(tc.expectedLibPath), "Path %s should match library path %s", tc.path, tc.expectedLibPath)
+ }
+ })
+ })
+
+ Describe("Edge cases", func() {
+ It("handles empty library list", func() {
+ mockLibRepo.SetData([]model.Library{})
+
+ matcher := createMatcher(ds)
+ Expect(matcher).ToNot(BeNil())
+
+ // Should not match anything
+ libID, libPath := matcher.findLibraryForPath("/music/track.mp3")
+ Expect(libID).To(Equal(0))
+ Expect(libPath).To(BeEmpty())
+ })
+
+ It("handles single library", func() {
+ mockLibRepo.SetData([]model.Library{
+ {ID: 1, Path: "/music"},
+ })
+
+ matcher := createMatcher(ds)
+
+ libID, libPath := matcher.findLibraryForPath("/music/track.mp3")
+ Expect(libID).To(Equal(1))
+ Expect(libPath).To(Equal("/music"))
+ })
+
+ It("handles libraries with special characters in paths", func() {
+ mockLibRepo.SetData([]model.Library{
+ {ID: 1, Path: "/music[test]"},
+ {ID: 2, Path: "/music(backup)"},
+ })
+
+ matcher := createMatcher(ds)
+ Expect(matcher).ToNot(BeNil())
+
+ // Special characters should match literally
+ libID, libPath := matcher.findLibraryForPath("/music[test]/track.mp3")
+ Expect(libID).To(Equal(1))
+ Expect(libPath).To(Equal("/music[test]"))
+ })
+ })
+
+ Describe("Path matching order", func() {
+ It("ensures longest paths match first", func() {
+ mockLibRepo.SetData([]model.Library{
+ {ID: 1, Path: "/a"},
+ {ID: 2, Path: "/ab"},
+ {ID: 3, Path: "/abc"},
+ })
+
+ matcher := createMatcher(ds)
+
+ // Verify that longer paths match correctly (not cut off by shorter prefix)
+ testCases := []struct {
+ path string
+ expectedLibID int
+ }{
+ {"/abc/file.mp3", 3},
+ {"/ab/file.mp3", 2},
+ {"/a/file.mp3", 1},
+ }
+
+ for _, tc := range testCases {
+ libID, _ := matcher.findLibraryForPath(tc.path)
+ Expect(libID).To(Equal(tc.expectedLibID), "Path %s should match library ID %d", tc.path, tc.expectedLibID)
+ }
+ })
+ })
+})
+
+var _ = Describe("pathResolver", func() {
+ var ds *tests.MockDataStore
+ var mockLibRepo *tests.MockLibraryRepo
+ var resolver *pathResolver
+ ctx := context.Background()
+
+ BeforeEach(func() {
+ mockLibRepo = &tests.MockLibraryRepo{}
+ ds = &tests.MockDataStore{
+ MockedLibrary: mockLibRepo,
+ }
+
+ // Setup test libraries
+ mockLibRepo.SetData([]model.Library{
+ {ID: 1, Path: "/music"},
+ {ID: 2, Path: "/music-classical"},
+ {ID: 3, Path: "/podcasts"},
+ })
+
+ var err error
+ resolver, err = newPathResolver(ctx, ds)
+ Expect(err).ToNot(HaveOccurred())
+ })
+
+ Describe("resolvePath", func() {
+ It("resolves absolute paths", func() {
+ resolution := resolver.resolvePath("/music/artist/album/track.mp3", nil)
+
+ Expect(resolution.valid).To(BeTrue())
+ Expect(resolution.libraryID).To(Equal(1))
+ Expect(resolution.libraryPath).To(Equal("/music"))
+ Expect(resolution.absolutePath).To(Equal("/music/artist/album/track.mp3"))
+ })
+
+ It("resolves relative paths when folder is provided", func() {
+ folder := &model.Folder{
+ Path: "playlists",
+ LibraryPath: "/music",
+ LibraryID: 1,
+ }
+
+ resolution := resolver.resolvePath("../artist/album/track.mp3", folder)
+
+ Expect(resolution.valid).To(BeTrue())
+ Expect(resolution.libraryID).To(Equal(1))
+ Expect(resolution.absolutePath).To(Equal("/music/artist/album/track.mp3"))
+ })
+
+ It("returns invalid resolution for paths outside any library", func() {
+ resolution := resolver.resolvePath("/outside/library/track.mp3", nil)
+
+ Expect(resolution.valid).To(BeFalse())
+ })
+ })
+
+ Describe("resolvePath", func() {
+ Context("With absolute paths", func() {
+ It("resolves path within a library", func() {
+ resolution := resolver.resolvePath("/music/track.mp3", nil)
+
+ Expect(resolution.valid).To(BeTrue())
+ Expect(resolution.libraryID).To(Equal(1))
+ Expect(resolution.libraryPath).To(Equal("/music"))
+ Expect(resolution.absolutePath).To(Equal("/music/track.mp3"))
+ })
+
+ It("resolves path to the longest matching library", func() {
+ resolution := resolver.resolvePath("/music-classical/track.mp3", nil)
+
+ Expect(resolution.valid).To(BeTrue())
+ Expect(resolution.libraryID).To(Equal(2))
+ Expect(resolution.libraryPath).To(Equal("/music-classical"))
+ })
+
+ It("returns invalid resolution for path outside libraries", func() {
+ resolution := resolver.resolvePath("/videos/movie.mp4", nil)
+
+ Expect(resolution.valid).To(BeFalse())
+ })
+
+ It("cleans the path before matching", func() {
+ resolution := resolver.resolvePath("/music//artist/../artist/track.mp3", nil)
+
+ Expect(resolution.valid).To(BeTrue())
+ Expect(resolution.absolutePath).To(Equal("/music/artist/track.mp3"))
+ })
+ })
+
+ Context("With relative paths", func() {
+ It("resolves relative path within same library", func() {
+ folder := &model.Folder{
+ Path: "playlists",
+ LibraryPath: "/music",
+ LibraryID: 1,
+ }
+
+ resolution := resolver.resolvePath("../songs/track.mp3", folder)
+
+ Expect(resolution.valid).To(BeTrue())
+ Expect(resolution.libraryID).To(Equal(1))
+ Expect(resolution.absolutePath).To(Equal("/music/songs/track.mp3"))
+ })
+
+ It("resolves relative path to different library", func() {
+ folder := &model.Folder{
+ Path: "playlists",
+ LibraryPath: "/music",
+ LibraryID: 1,
+ }
+
+ // Path goes up and into a different library
+ resolution := resolver.resolvePath("../../podcasts/episode.mp3", folder)
+
+ Expect(resolution.valid).To(BeTrue())
+ Expect(resolution.libraryID).To(Equal(3))
+ Expect(resolution.libraryPath).To(Equal("/podcasts"))
+ })
+
+ It("uses matcher to find correct library for resolved path", func() {
+ folder := &model.Folder{
+ Path: "playlists",
+ LibraryPath: "/music",
+ LibraryID: 1,
+ }
+
+ // This relative path resolves to music-classical library
+ resolution := resolver.resolvePath("../../music-classical/track.mp3", folder)
+
+ Expect(resolution.valid).To(BeTrue())
+ Expect(resolution.libraryID).To(Equal(2))
+ Expect(resolution.libraryPath).To(Equal("/music-classical"))
+ })
+
+ It("returns invalid for relative paths escaping all libraries", func() {
+ folder := &model.Folder{
+ Path: "playlists",
+ LibraryPath: "/music",
+ LibraryID: 1,
+ }
+
+ resolution := resolver.resolvePath("../../../../etc/passwd", folder)
+
+ Expect(resolution.valid).To(BeFalse())
+ })
+ })
+ })
+
+ Describe("Cross-library resolution scenarios", func() {
+ It("handles playlist in library A referencing file in library B", func() {
+ // Playlist is in /music/playlists
+ folder := &model.Folder{
+ Path: "playlists",
+ LibraryPath: "/music",
+ LibraryID: 1,
+ }
+
+ // Relative path that goes to /podcasts library
+ resolution := resolver.resolvePath("../../podcasts/show/episode.mp3", folder)
+
+ Expect(resolution.valid).To(BeTrue())
+ Expect(resolution.libraryID).To(Equal(3), "Should resolve to podcasts library")
+ Expect(resolution.libraryPath).To(Equal("/podcasts"))
+ })
+
+ It("prefers longer library paths when resolving", func() {
+ // Ensure /music-classical is matched instead of /music
+ resolution := resolver.resolvePath("/music-classical/baroque/track.mp3", nil)
+
+ Expect(resolution.valid).To(BeTrue())
+ Expect(resolution.libraryID).To(Equal(2), "Should match /music-classical, not /music")
+ })
+ })
+})
+
+var _ = Describe("pathResolution", func() {
+ Describe("ToQualifiedString", func() {
+ It("converts valid resolution to qualified string with forward slashes", func() {
+ resolution := pathResolution{
+ absolutePath: "/music/artist/album/track.mp3",
+ libraryPath: "/music",
+ libraryID: 1,
+ valid: true,
+ }
+
+ qualifiedStr, err := resolution.ToQualifiedString()
+
+ Expect(err).ToNot(HaveOccurred())
+ Expect(qualifiedStr).To(Equal("1:artist/album/track.mp3"))
+ })
+
+ It("handles Windows-style paths by converting to forward slashes", func() {
+ resolution := pathResolution{
+ absolutePath: "/music/artist/album/track.mp3",
+ libraryPath: "/music",
+ libraryID: 2,
+ valid: true,
+ }
+
+ qualifiedStr, err := resolution.ToQualifiedString()
+
+ Expect(err).ToNot(HaveOccurred())
+ // Should always use forward slashes regardless of OS
+ Expect(qualifiedStr).To(ContainSubstring("2:"))
+ Expect(qualifiedStr).ToNot(ContainSubstring("\\"))
+ })
+
+ It("returns error for invalid resolution", func() {
+ resolution := pathResolution{valid: false}
+
+ _, err := resolution.ToQualifiedString()
+
+ Expect(err).To(HaveOccurred())
+ })
+ })
+})
diff --git a/core/playlists_test.go b/core/playlists_test.go
index 399210ac8..6aa8aac9a 100644
--- a/core/playlists_test.go
+++ b/core/playlists_test.go
@@ -1,4 +1,4 @@
-package core
+package core_test
import (
"context"
@@ -9,6 +9,7 @@ import (
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/conf/configtest"
+ "github.com/navidrome/navidrome/core"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/criteria"
"github.com/navidrome/navidrome/model/request"
@@ -20,7 +21,7 @@ import (
var _ = Describe("Playlists", func() {
var ds *tests.MockDataStore
- var ps Playlists
+ var ps core.Playlists
var mockPlsRepo mockedPlaylistRepo
var mockLibRepo *tests.MockLibraryRepo
ctx := context.Background()
@@ -33,16 +34,16 @@ var _ = Describe("Playlists", func() {
MockedLibrary: mockLibRepo,
}
ctx = request.WithUser(ctx, model.User{ID: "123"})
- // Path should be libPath, but we want to match the root folder referenced in the m3u, which is `/`
- mockLibRepo.SetData([]model.Library{{ID: 1, Path: "/"}})
})
Describe("ImportFile", func() {
var folder *model.Folder
BeforeEach(func() {
- ps = NewPlaylists(ds)
+ ps = core.NewPlaylists(ds)
ds.MockedMediaFile = &mockedMediaFileRepo{}
libPath, _ := os.Getwd()
+ // Set up library with the actual library path that matches the folder
+ mockLibRepo.SetData([]model.Library{{ID: 1, Path: libPath}})
folder = &model.Folder{
ID: "1",
LibraryID: 1,
@@ -74,6 +75,24 @@ var _ = Describe("Playlists", func() {
Expect(err).ToNot(HaveOccurred())
Expect(pls.Tracks).To(HaveLen(2))
})
+
+ It("parses playlists with UTF-8 BOM marker", func() {
+ pls, err := ps.ImportFile(ctx, folder, "bom-test.m3u")
+ Expect(err).ToNot(HaveOccurred())
+ Expect(pls.OwnerID).To(Equal("123"))
+ Expect(pls.Name).To(Equal("Test Playlist"))
+ Expect(pls.Tracks).To(HaveLen(1))
+ Expect(pls.Tracks[0].Path).To(Equal("tests/fixtures/playlists/test.mp3"))
+ })
+
+ It("parses UTF-16 LE encoded playlists with BOM and converts to UTF-8", func() {
+ pls, err := ps.ImportFile(ctx, folder, "bom-test-utf16.m3u")
+ Expect(err).ToNot(HaveOccurred())
+ Expect(pls.OwnerID).To(Equal("123"))
+ Expect(pls.Name).To(Equal("UTF-16 Test Playlist"))
+ Expect(pls.Tracks).To(HaveLen(1))
+ Expect(pls.Tracks[0].Path).To(Equal("tests/fixtures/playlists/test.mp3"))
+ })
})
Describe("NSP", func() {
@@ -94,6 +113,224 @@ var _ = Describe("Playlists", func() {
Expect(err.Error()).To(ContainSubstring("line 19, column 1: invalid character '\\n'"))
})
})
+
+ Describe("Cross-library relative paths", func() {
+ var tmpDir, plsDir, songsDir string
+
+ BeforeEach(func() {
+ // Create temp directory structure
+ tmpDir = GinkgoT().TempDir()
+ plsDir = tmpDir + "/playlists"
+ songsDir = tmpDir + "/songs"
+ Expect(os.Mkdir(plsDir, 0755)).To(Succeed())
+ Expect(os.Mkdir(songsDir, 0755)).To(Succeed())
+
+ // Setup two different libraries with paths matching our temp structure
+ mockLibRepo.SetData([]model.Library{
+ {ID: 1, Path: songsDir},
+ {ID: 2, Path: plsDir},
+ })
+
+ // Create a mock media file repository that returns files for both libraries
+ // Note: The paths are relative to their respective library roots
+ ds.MockedMediaFile = &mockedMediaFileFromListRepo{
+ data: []string{
+ "abc.mp3", // This is songs/abc.mp3 relative to songsDir
+ "def.mp3", // This is playlists/def.mp3 relative to plsDir
+ },
+ }
+ ps = core.NewPlaylists(ds)
+ })
+
+ It("handles relative paths that reference files in other libraries", func() {
+ // Create a temporary playlist file with relative path
+ plsContent := "#PLAYLIST:Cross Library Test\n../songs/abc.mp3\ndef.mp3"
+ plsFile := plsDir + "/test.m3u"
+ Expect(os.WriteFile(plsFile, []byte(plsContent), 0600)).To(Succeed())
+
+ // Playlist is in the Playlists library folder
+ // Important: Path should be relative to LibraryPath, and Name is the folder name
+ plsFolder := &model.Folder{
+ ID: "2",
+ LibraryID: 2,
+ LibraryPath: plsDir,
+ Path: "",
+ Name: "",
+ }
+
+ pls, err := ps.ImportFile(ctx, plsFolder, "test.m3u")
+ Expect(err).ToNot(HaveOccurred())
+ Expect(pls.Tracks).To(HaveLen(2))
+ Expect(pls.Tracks[0].Path).To(Equal("abc.mp3")) // From songsDir library
+ Expect(pls.Tracks[1].Path).To(Equal("def.mp3")) // From plsDir library
+ })
+
+ It("ignores paths that point outside all libraries", func() {
+ // Create a temporary playlist file with path outside libraries
+ plsContent := "#PLAYLIST:Outside Test\n../../outside.mp3\nabc.mp3"
+ plsFile := plsDir + "/test.m3u"
+ Expect(os.WriteFile(plsFile, []byte(plsContent), 0600)).To(Succeed())
+
+ plsFolder := &model.Folder{
+ ID: "2",
+ LibraryID: 2,
+ LibraryPath: plsDir,
+ Path: "",
+ Name: "",
+ }
+
+ pls, err := ps.ImportFile(ctx, plsFolder, "test.m3u")
+ Expect(err).ToNot(HaveOccurred())
+ // Should only find abc.mp3, not outside.mp3
+ Expect(pls.Tracks).To(HaveLen(1))
+ Expect(pls.Tracks[0].Path).To(Equal("abc.mp3"))
+ })
+
+ It("handles relative paths with multiple '../' components", func() {
+ // Create a nested structure: tmpDir/playlists/subfolder/test.m3u
+ subFolder := plsDir + "/subfolder"
+ Expect(os.Mkdir(subFolder, 0755)).To(Succeed())
+
+ // Create the media file in the subfolder directory
+ // The mock will return it as "def.mp3" relative to plsDir
+ ds.MockedMediaFile = &mockedMediaFileFromListRepo{
+ data: []string{
+ "abc.mp3", // From songsDir library
+ "def.mp3", // From plsDir library root
+ },
+ }
+
+ // From subfolder, ../../songs/abc.mp3 should resolve to songs library
+ // ../def.mp3 should resolve to plsDir/def.mp3
+ plsContent := "#PLAYLIST:Nested Test\n../../songs/abc.mp3\n../def.mp3"
+ plsFile := subFolder + "/test.m3u"
+ Expect(os.WriteFile(plsFile, []byte(plsContent), 0600)).To(Succeed())
+
+ // The folder: AbsolutePath = LibraryPath + Path + Name
+ // So for /playlists/subfolder: LibraryPath=/playlists, Path="", Name="subfolder"
+ plsFolder := &model.Folder{
+ ID: "2",
+ LibraryID: 2,
+ LibraryPath: plsDir,
+ Path: "", // Empty because subfolder is directly under library root
+ Name: "subfolder", // The folder name
+ }
+
+ pls, err := ps.ImportFile(ctx, plsFolder, "test.m3u")
+ Expect(err).ToNot(HaveOccurred())
+ Expect(pls.Tracks).To(HaveLen(2))
+ Expect(pls.Tracks[0].Path).To(Equal("abc.mp3")) // From songsDir library
+ Expect(pls.Tracks[1].Path).To(Equal("def.mp3")) // From plsDir library root
+ })
+
+ It("correctly resolves libraries when one path is a prefix of another", func() {
+ // This tests the bug where /music would match before /music-classical
+ // Create temp directory structure with prefix conflict
+ tmpDir := GinkgoT().TempDir()
+ musicDir := tmpDir + "/music"
+ musicClassicalDir := tmpDir + "/music-classical"
+ Expect(os.Mkdir(musicDir, 0755)).To(Succeed())
+ Expect(os.Mkdir(musicClassicalDir, 0755)).To(Succeed())
+
+ // Setup two libraries where one is a prefix of the other
+ mockLibRepo.SetData([]model.Library{
+ {ID: 1, Path: musicDir}, // /tmp/xxx/music
+ {ID: 2, Path: musicClassicalDir}, // /tmp/xxx/music-classical
+ })
+
+ // Mock will return tracks from both libraries
+ ds.MockedMediaFile = &mockedMediaFileFromListRepo{
+ data: []string{
+ "rock.mp3", // From music library
+ "bach.mp3", // From music-classical library
+ },
+ }
+
+ // Create playlist in music library that references music-classical
+ plsContent := "#PLAYLIST:Cross Prefix Test\nrock.mp3\n../music-classical/bach.mp3"
+ plsFile := musicDir + "/test.m3u"
+ Expect(os.WriteFile(plsFile, []byte(plsContent), 0600)).To(Succeed())
+
+ plsFolder := &model.Folder{
+ ID: "1",
+ LibraryID: 1,
+ LibraryPath: musicDir,
+ Path: "",
+ Name: "",
+ }
+
+ pls, err := ps.ImportFile(ctx, plsFolder, "test.m3u")
+ Expect(err).ToNot(HaveOccurred())
+ Expect(pls.Tracks).To(HaveLen(2))
+ Expect(pls.Tracks[0].Path).To(Equal("rock.mp3")) // From music library
+ Expect(pls.Tracks[1].Path).To(Equal("bach.mp3")) // From music-classical library (not music!)
+ })
+
+ It("correctly handles identical relative paths from different libraries", func() {
+ // This tests the bug where two libraries have files at the same relative path
+ // and only one appears in the playlist
+ tmpDir := GinkgoT().TempDir()
+ musicDir := tmpDir + "/music"
+ classicalDir := tmpDir + "/classical"
+ Expect(os.Mkdir(musicDir, 0755)).To(Succeed())
+ Expect(os.Mkdir(classicalDir, 0755)).To(Succeed())
+ Expect(os.MkdirAll(musicDir+"/album", 0755)).To(Succeed())
+ Expect(os.MkdirAll(classicalDir+"/album", 0755)).To(Succeed())
+ // Create placeholder files so paths resolve correctly
+ Expect(os.WriteFile(musicDir+"/album/track.mp3", []byte{}, 0600)).To(Succeed())
+ Expect(os.WriteFile(classicalDir+"/album/track.mp3", []byte{}, 0600)).To(Succeed())
+
+ // Both libraries have a file at "album/track.mp3"
+ mockLibRepo.SetData([]model.Library{
+ {ID: 1, Path: musicDir},
+ {ID: 2, Path: classicalDir},
+ })
+
+ // Mock returns files with same relative path but different IDs and library IDs
+ // Keys use the library-qualified format: "libraryID:path"
+ ds.MockedMediaFile = &mockedMediaFileRepo{
+ data: map[string]model.MediaFile{
+ "1:album/track.mp3": {ID: "music-track", Path: "album/track.mp3", LibraryID: 1, Title: "Rock Song"},
+ "2:album/track.mp3": {ID: "classical-track", Path: "album/track.mp3", LibraryID: 2, Title: "Classical Piece"},
+ },
+ }
+ // Recreate playlists service to pick up new mock
+ ps = core.NewPlaylists(ds)
+
+ // Create playlist in music library that references both tracks
+ plsContent := "#PLAYLIST:Same Path Test\nalbum/track.mp3\n../classical/album/track.mp3"
+ plsFile := musicDir + "/test.m3u"
+ Expect(os.WriteFile(plsFile, []byte(plsContent), 0600)).To(Succeed())
+
+ plsFolder := &model.Folder{
+ ID: "1",
+ LibraryID: 1,
+ LibraryPath: musicDir,
+ Path: "",
+ Name: "",
+ }
+
+ pls, err := ps.ImportFile(ctx, plsFolder, "test.m3u")
+ Expect(err).ToNot(HaveOccurred())
+
+ // Should have BOTH tracks, not just one
+ Expect(pls.Tracks).To(HaveLen(2), "Playlist should contain both tracks with same relative path")
+
+ // Verify we got tracks from DIFFERENT libraries (the key fix!)
+ // Collect the library IDs
+ libIDs := make(map[int]bool)
+ for _, track := range pls.Tracks {
+ libIDs[track.LibraryID] = true
+ }
+ Expect(libIDs).To(HaveLen(2), "Tracks should come from two different libraries")
+ Expect(libIDs[1]).To(BeTrue(), "Should have track from library 1")
+ Expect(libIDs[2]).To(BeTrue(), "Should have track from library 2")
+
+ // Both tracks should have the same relative path
+ Expect(pls.Tracks[0].Path).To(Equal("album/track.mp3"))
+ Expect(pls.Tracks[1].Path).To(Equal("album/track.mp3"))
+ })
+ })
})
Describe("ImportM3U", func() {
@@ -101,7 +338,7 @@ var _ = Describe("Playlists", func() {
BeforeEach(func() {
repo = &mockedMediaFileFromListRepo{}
ds.MockedMediaFile = repo
- ps = NewPlaylists(ds)
+ ps = core.NewPlaylists(ds)
mockLibRepo.SetData([]model.Library{{ID: 1, Path: "/music"}, {ID: 2, Path: "/new"}})
ctx = request.WithUser(ctx, model.User{ID: "123"})
})
@@ -188,53 +425,23 @@ var _ = Describe("Playlists", func() {
Expect(pls.Tracks[0].Path).To(Equal("abc/tEsT1.Mp3"))
})
- It("handles Unicode normalization when comparing paths", func() {
- // Test case for Apple Music playlists that use NFC encoding vs macOS filesystem NFD
- // The character "è" can be represented as NFC (single codepoint) or NFD (e + combining accent)
-
- const pathWithAccents = "artist/Michèle Desrosiers/album/Noël.m4a"
-
- // Simulate a database entry with NFD encoding (as stored by macOS filesystem)
- nfdPath := norm.NFD.String(pathWithAccents)
+ It("handles Unicode normalization when comparing paths (NFD vs NFC)", func() {
+ // Simulate macOS filesystem: stores paths in NFD (decomposed) form
+ // "è" (U+00E8) in NFC becomes "e" + "◌̀" (U+0065 + U+0300) in NFD
+ nfdPath := "artist/Mich" + string([]rune{'e', '\u0300'}) + "le/song.mp3" // NFD: e + combining grave
repo.data = []string{nfdPath}
- // Simulate an Apple Music M3U playlist entry with NFC encoding
- nfcPath := norm.NFC.String("/music/" + pathWithAccents)
- m3u := strings.Join([]string{
- nfcPath,
- }, "\n")
+ // Simulate Apple Music M3U: uses NFC (composed) form
+ nfcPath := "/music/artist/Mich\u00E8le/song.mp3" // NFC: single è character
+ m3u := nfcPath + "\n"
f := strings.NewReader(m3u)
-
pls, err := ps.ImportM3U(ctx, f)
Expect(err).ToNot(HaveOccurred())
- Expect(pls.Tracks).To(HaveLen(1), "Should find the track despite Unicode normalization differences")
+ Expect(pls.Tracks).To(HaveLen(1))
+ // Should match despite different Unicode normalization forms
Expect(pls.Tracks[0].Path).To(Equal(nfdPath))
})
- })
- Describe("normalizePathForComparison", func() {
- It("normalizes Unicode characters to NFC form and converts to lowercase", func() {
- // Test with NFD (decomposed) input - as would come from macOS filesystem
- nfdPath := norm.NFD.String("Michèle") // Explicitly convert to NFD form
- normalized := normalizePathForComparison(nfdPath)
- Expect(normalized).To(Equal("michèle"))
-
- // Test with NFC (composed) input - as would come from Apple Music M3U
- nfcPath := "Michèle" // This might be in NFC form
- normalizedNfc := normalizePathForComparison(nfcPath)
-
- // Ensure the two paths are not equal in their original forms
- Expect(nfdPath).ToNot(Equal(nfcPath))
-
- // Both should normalize to the same result
- Expect(normalized).To(Equal(normalizedNfc))
- })
-
- It("handles paths with mixed case and Unicode characters", func() {
- path := "Artist/Noël Coward/Album/Song.mp3"
- normalized := normalizePathForComparison(path)
- Expect(normalized).To(Equal("artist/noël coward/album/song.mp3"))
- })
})
Describe("InPlaylistsPath", func() {
@@ -251,27 +458,27 @@ var _ = Describe("Playlists", func() {
It("returns true if PlaylistsPath is empty", func() {
conf.Server.PlaylistsPath = ""
- Expect(InPlaylistsPath(folder)).To(BeTrue())
+ Expect(core.InPlaylistsPath(folder)).To(BeTrue())
})
It("returns true if PlaylistsPath is any (**/**)", func() {
conf.Server.PlaylistsPath = "**/**"
- Expect(InPlaylistsPath(folder)).To(BeTrue())
+ Expect(core.InPlaylistsPath(folder)).To(BeTrue())
})
It("returns true if folder is in PlaylistsPath", func() {
conf.Server.PlaylistsPath = "other/**:playlists/**"
- Expect(InPlaylistsPath(folder)).To(BeTrue())
+ Expect(core.InPlaylistsPath(folder)).To(BeTrue())
})
It("returns false if folder is not in PlaylistsPath", func() {
conf.Server.PlaylistsPath = "other"
- Expect(InPlaylistsPath(folder)).To(BeFalse())
+ Expect(core.InPlaylistsPath(folder)).To(BeFalse())
})
It("returns true if for a playlist in root of MusicFolder if PlaylistsPath is '.'", func() {
conf.Server.PlaylistsPath = "."
- Expect(InPlaylistsPath(folder)).To(BeFalse())
+ Expect(core.InPlaylistsPath(folder)).To(BeFalse())
folder2 := model.Folder{
LibraryPath: "/music",
@@ -279,22 +486,47 @@ var _ = Describe("Playlists", func() {
Name: ".",
}
- Expect(InPlaylistsPath(folder2)).To(BeTrue())
+ Expect(core.InPlaylistsPath(folder2)).To(BeTrue())
})
})
})
-// mockedMediaFileRepo's FindByPaths method returns a list of MediaFiles with the same paths as the input
+// mockedMediaFileRepo's FindByPaths method returns MediaFiles for the given paths.
+// If data map is provided, looks up files by key; otherwise creates them from paths.
type mockedMediaFileRepo struct {
model.MediaFileRepository
+ data map[string]model.MediaFile
}
func (r *mockedMediaFileRepo) FindByPaths(paths []string) (model.MediaFiles, error) {
var mfs model.MediaFiles
+
+ // If data map provided, look up files
+ if r.data != nil {
+ for _, path := range paths {
+ if mf, ok := r.data[path]; ok {
+ mfs = append(mfs, mf)
+ }
+ }
+ return mfs, nil
+ }
+
+ // Otherwise, create MediaFiles from paths
for idx, path := range paths {
+ // Strip library qualifier if present (format: "libraryID:path")
+ actualPath := path
+ libraryID := 1
+ if parts := strings.SplitN(path, ":", 2); len(parts) == 2 {
+ if id, err := strconv.Atoi(parts[0]); err == nil {
+ libraryID = id
+ actualPath = parts[1]
+ }
+ }
+
mfs = append(mfs, model.MediaFile{
- ID: strconv.Itoa(idx),
- Path: path,
+ ID: strconv.Itoa(idx),
+ Path: actualPath,
+ LibraryID: libraryID,
})
}
return mfs, nil
@@ -306,13 +538,38 @@ type mockedMediaFileFromListRepo struct {
data []string
}
-func (r *mockedMediaFileFromListRepo) FindByPaths([]string) (model.MediaFiles, error) {
+func (r *mockedMediaFileFromListRepo) FindByPaths(paths []string) (model.MediaFiles, error) {
var mfs model.MediaFiles
- for idx, path := range r.data {
- mfs = append(mfs, model.MediaFile{
- ID: strconv.Itoa(idx),
- Path: path,
- })
+
+ for idx, dataPath := range r.data {
+ // Normalize the data path to NFD (simulates macOS filesystem storage)
+ normalizedDataPath := norm.NFD.String(dataPath)
+
+ for _, requestPath := range paths {
+ // Strip library qualifier if present (format: "libraryID:path")
+ actualPath := requestPath
+ libraryID := 1
+ if parts := strings.SplitN(requestPath, ":", 2); len(parts) == 2 {
+ if id, err := strconv.Atoi(parts[0]); err == nil {
+ libraryID = id
+ actualPath = parts[1]
+ }
+ }
+
+ // The request path should already be normalized to NFD by production code
+ // before calling FindByPaths (to match DB storage)
+ normalizedRequestPath := norm.NFD.String(actualPath)
+
+ // Case-insensitive comparison (like SQL's "collate nocase")
+ if strings.EqualFold(normalizedRequestPath, normalizedDataPath) {
+ mfs = append(mfs, model.MediaFile{
+ ID: strconv.Itoa(idx),
+ Path: dataPath, // Return original path from DB
+ LibraryID: libraryID,
+ })
+ break
+ }
+ }
}
return mfs, nil
}
diff --git a/core/scrobbler/buffered_scrobbler_test.go b/core/scrobbler/buffered_scrobbler_test.go
index c1440046d..9fbca6f71 100644
--- a/core/scrobbler/buffered_scrobbler_test.go
+++ b/core/scrobbler/buffered_scrobbler_test.go
@@ -38,9 +38,9 @@ var _ = Describe("BufferedScrobbler", func() {
It("forwards NowPlaying calls", func() {
track := &model.MediaFile{ID: "123", Title: "Test Track"}
Expect(bs.NowPlaying(ctx, "user1", track, 0)).To(Succeed())
- Expect(scr.NowPlayingCalled).To(BeTrue())
- Expect(scr.UserID).To(Equal("user1"))
- Expect(scr.Track).To(Equal(track))
+ Expect(scr.GetNowPlayingCalled()).To(BeTrue())
+ Expect(scr.GetUserID()).To(Equal("user1"))
+ Expect(scr.GetTrack()).To(Equal(track))
})
It("enqueues scrobbles to buffer", func() {
@@ -51,9 +51,10 @@ var _ = Describe("BufferedScrobbler", func() {
Expect(scr.ScrobbleCalled.Load()).To(BeFalse())
Expect(bs.Scrobble(ctx, "user1", scrobble)).To(Succeed())
- Expect(buffer.Length()).To(Equal(int64(1)))
- // Wait for the scrobble to be sent
+ // Wait for the background goroutine to process the scrobble.
+ // We don't check buffer.Length() here because the background goroutine
+ // may dequeue the entry before we can observe it.
Eventually(scr.ScrobbleCalled.Load).Should(BeTrue())
lastScrobble := scr.LastScrobble.Load()
diff --git a/core/scrobbler/play_tracker.go b/core/scrobbler/play_tracker.go
index 3b71a2100..bac9d220b 100644
--- a/core/scrobbler/play_tracker.go
+++ b/core/scrobbler/play_tracker.go
@@ -31,6 +31,12 @@ type Submission struct {
Timestamp time.Time
}
+type nowPlayingEntry struct {
+ userId string
+ track *model.MediaFile
+ position int
+}
+
type PlayTracker interface {
NowPlaying(ctx context.Context, playerId string, playerName string, trackId string, position int) error
GetNowPlaying(ctx context.Context) ([]NowPlayingInfo, error)
@@ -52,6 +58,11 @@ type playTracker struct {
pluginScrobblers map[string]Scrobbler
pluginLoader PluginLoader
mu sync.RWMutex
+ npQueue map[string]nowPlayingEntry
+ npMu sync.Mutex
+ npSignal chan struct{}
+ shutdown chan struct{}
+ workerDone chan struct{}
}
func GetPlayTracker(ds model.DataStore, broker events.Broker, pluginManager PluginLoader) PlayTracker {
@@ -71,6 +82,10 @@ func newPlayTracker(ds model.DataStore, broker events.Broker, pluginManager Plug
builtinScrobblers: make(map[string]Scrobbler),
pluginScrobblers: make(map[string]Scrobbler),
pluginLoader: pluginManager,
+ npQueue: make(map[string]nowPlayingEntry),
+ npSignal: make(chan struct{}, 1),
+ shutdown: make(chan struct{}),
+ workerDone: make(chan struct{}),
}
if conf.Server.EnableNowPlaying {
m.OnExpiration(func(_ string, _ NowPlayingInfo) {
@@ -90,9 +105,16 @@ func newPlayTracker(ds model.DataStore, broker events.Broker, pluginManager Plug
p.builtinScrobblers[name] = s
}
log.Debug("List of builtin scrobblers enabled", "names", enabled)
+ go p.nowPlayingWorker()
return p
}
+// stopNowPlayingWorker stops the background worker. This is primarily for testing.
+func (p *playTracker) stopNowPlayingWorker() {
+ close(p.shutdown)
+ <-p.workerDone // Wait for worker to finish
+}
+
// pluginNamesMatchScrobblers returns true if the set of pluginNames matches the keys in pluginScrobblers
func pluginNamesMatchScrobblers(pluginNames []string, scrobblers map[string]Scrobbler) bool {
if len(pluginNames) != len(scrobblers) {
@@ -198,11 +220,58 @@ func (p *playTracker) NowPlaying(ctx context.Context, playerId string, playerNam
}
player, _ := request.PlayerFrom(ctx)
if player.ScrobbleEnabled {
- p.dispatchNowPlaying(ctx, user.ID, mf, position)
+ p.enqueueNowPlaying(playerId, user.ID, mf, position)
}
return nil
}
+func (p *playTracker) enqueueNowPlaying(playerId string, userId string, track *model.MediaFile, position int) {
+ p.npMu.Lock()
+ defer p.npMu.Unlock()
+ p.npQueue[playerId] = nowPlayingEntry{
+ userId: userId,
+ track: track,
+ position: position,
+ }
+ p.sendNowPlayingSignal()
+}
+
+func (p *playTracker) sendNowPlayingSignal() {
+ // Don't block if the previous signal was not read yet
+ select {
+ case p.npSignal <- struct{}{}:
+ default:
+ }
+}
+
+func (p *playTracker) nowPlayingWorker() {
+ defer close(p.workerDone)
+ for {
+ select {
+ case <-p.shutdown:
+ return
+ case <-time.After(time.Second):
+ case <-p.npSignal:
+ }
+
+ p.npMu.Lock()
+ if len(p.npQueue) == 0 {
+ p.npMu.Unlock()
+ continue
+ }
+
+ // Keep a copy of the entries to process and clear the queue
+ entries := p.npQueue
+ p.npQueue = make(map[string]nowPlayingEntry)
+ p.npMu.Unlock()
+
+ // Process entries without holding lock
+ for _, entry := range entries {
+ p.dispatchNowPlaying(context.Background(), entry.userId, entry.track, entry.position)
+ }
+ }
+}
+
func (p *playTracker) dispatchNowPlaying(ctx context.Context, userId string, t *model.MediaFile, position int) {
if t.Artist == consts.UnknownArtist {
log.Debug(ctx, "Ignoring external NowPlaying update for track with unknown artist", "track", t.Title, "artist", t.Artist)
@@ -276,8 +345,14 @@ func (p *playTracker) incPlay(ctx context.Context, track *model.MediaFile, times
}
for _, artist := range track.Participants[model.RoleArtist] {
err = tx.Artist(ctx).IncPlayCount(artist.ID, timestamp)
+ if err != nil {
+ return err
+ }
}
- return err
+ if conf.Server.EnableScrobbleHistory {
+ return tx.Scrobble(ctx).RecordScrobble(track.ID, timestamp)
+ }
+ return nil
})
}
diff --git a/core/scrobbler/play_tracker_test.go b/core/scrobbler/play_tracker_test.go
index 7b4785bb5..6f66276c3 100644
--- a/core/scrobbler/play_tracker_test.go
+++ b/core/scrobbler/play_tracker_test.go
@@ -24,15 +24,26 @@ import (
// Moved to top-level scope to avoid linter issues
type mockPluginLoader struct {
+ mu sync.RWMutex
names []string
scrobblers map[string]Scrobbler
}
func (m *mockPluginLoader) PluginNames(service string) []string {
+ m.mu.RLock()
+ defer m.mu.RUnlock()
return m.names
}
+func (m *mockPluginLoader) SetNames(names []string) {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ m.names = names
+}
+
func (m *mockPluginLoader) LoadScrobbler(name string) (Scrobbler, bool) {
+ m.mu.RLock()
+ defer m.mu.RUnlock()
s, ok := m.scrobblers[name]
return s, ok
}
@@ -46,24 +57,24 @@ var _ = Describe("PlayTracker", func() {
var album model.Album
var artist1 model.Artist
var artist2 model.Artist
- var fake fakeScrobbler
+ var fake *fakeScrobbler
BeforeEach(func() {
DeferCleanup(configtest.SetupConfig())
- ctx = context.Background()
+ ctx = GinkgoT().Context()
ctx = request.WithUser(ctx, model.User{ID: "u-1"})
ctx = request.WithPlayer(ctx, model.Player{ScrobbleEnabled: true})
ds = &tests.MockDataStore{}
- fake = fakeScrobbler{Authorized: true}
+ fake = &fakeScrobbler{Authorized: true}
Register("fake", func(model.DataStore) Scrobbler {
- return &fake
+ return fake
})
Register("disabled", func(model.DataStore) Scrobbler {
return nil
})
eventBroker = &fakeEventBroker{}
tracker = newPlayTracker(ds, eventBroker, nil)
- tracker.(*playTracker).builtinScrobblers["fake"] = &fake // Bypass buffering for tests
+ tracker.(*playTracker).builtinScrobblers["fake"] = fake // Bypass buffering for tests
track = model.MediaFile{
ID: "123",
@@ -86,6 +97,11 @@ var _ = Describe("PlayTracker", func() {
_ = ds.Album(ctx).(*tests.MockAlbumRepo).Put(&album)
})
+ AfterEach(func() {
+ // Stop the worker goroutine to prevent data races between tests
+ tracker.(*playTracker).stopNowPlayingWorker()
+ })
+
It("does not register disabled scrobblers", func() {
Expect(tracker.(*playTracker).builtinScrobblers).To(HaveKey("fake"))
Expect(tracker.(*playTracker).builtinScrobblers).ToNot(HaveKey("disabled"))
@@ -95,10 +111,10 @@ var _ = Describe("PlayTracker", func() {
It("sends track to agent", func() {
err := tracker.NowPlaying(ctx, "player-1", "player-one", "123", 0)
Expect(err).ToNot(HaveOccurred())
- Expect(fake.NowPlayingCalled).To(BeTrue())
- Expect(fake.UserID).To(Equal("u-1"))
- Expect(fake.Track.ID).To(Equal("123"))
- Expect(fake.Track.Participants).To(Equal(track.Participants))
+ Eventually(func() bool { return fake.GetNowPlayingCalled() }).Should(BeTrue())
+ Expect(fake.GetUserID()).To(Equal("u-1"))
+ Expect(fake.GetTrack().ID).To(Equal("123"))
+ Expect(fake.GetTrack().Participants).To(Equal(track.Participants))
})
It("does not send track to agent if user has not authorized", func() {
fake.Authorized = false
@@ -106,7 +122,7 @@ var _ = Describe("PlayTracker", func() {
err := tracker.NowPlaying(ctx, "player-1", "player-one", "123", 0)
Expect(err).ToNot(HaveOccurred())
- Expect(fake.NowPlayingCalled).To(BeFalse())
+ Expect(fake.GetNowPlayingCalled()).To(BeFalse())
})
It("does not send track to agent if player is not enabled to send scrobbles", func() {
ctx = request.WithPlayer(ctx, model.Player{ScrobbleEnabled: false})
@@ -114,7 +130,7 @@ var _ = Describe("PlayTracker", func() {
err := tracker.NowPlaying(ctx, "player-1", "player-one", "123", 0)
Expect(err).ToNot(HaveOccurred())
- Expect(fake.NowPlayingCalled).To(BeFalse())
+ Expect(fake.GetNowPlayingCalled()).To(BeFalse())
})
It("does not send track to agent if artist is unknown", func() {
track.Artist = consts.UnknownArtist
@@ -122,7 +138,7 @@ var _ = Describe("PlayTracker", func() {
err := tracker.NowPlaying(ctx, "player-1", "player-one", "123", 0)
Expect(err).ToNot(HaveOccurred())
- Expect(fake.NowPlayingCalled).To(BeFalse())
+ Expect(fake.GetNowPlayingCalled()).To(BeFalse())
})
It("stores position when greater than zero", func() {
@@ -130,11 +146,12 @@ var _ = Describe("PlayTracker", func() {
err := tracker.NowPlaying(ctx, "player-1", "player-one", "123", pos)
Expect(err).ToNot(HaveOccurred())
+ Eventually(func() int { return fake.GetPosition() }).Should(Equal(pos))
+
playing, err := tracker.GetNowPlaying(ctx)
Expect(err).ToNot(HaveOccurred())
Expect(playing).To(HaveLen(1))
Expect(playing[0].Position).To(Equal(pos))
- Expect(fake.Position).To(Equal(pos))
})
It("sends event with count", func() {
@@ -160,9 +177,9 @@ var _ = Describe("PlayTracker", func() {
track2 := track
track2.ID = "456"
_ = ds.MediaFile(ctx).Put(&track2)
- ctx = request.WithUser(context.Background(), model.User{UserName: "user-1"})
+ ctx = request.WithUser(GinkgoT().Context(), model.User{UserName: "user-1"})
_ = tracker.NowPlaying(ctx, "player-1", "player-one", "123", 0)
- ctx = request.WithUser(context.Background(), model.User{UserName: "user-2"})
+ ctx = request.WithUser(GinkgoT().Context(), model.User{UserName: "user-2"})
_ = tracker.NowPlaying(ctx, "player-2", "player-two", "456", 0)
playing, err := tracker.GetNowPlaying(ctx)
@@ -210,7 +227,7 @@ var _ = Describe("PlayTracker", func() {
Expect(err).ToNot(HaveOccurred())
Expect(fake.ScrobbleCalled.Load()).To(BeTrue())
- Expect(fake.UserID).To(Equal("u-1"))
+ Expect(fake.GetUserID()).To(Equal("u-1"))
lastScrobble := fake.LastScrobble.Load()
Expect(lastScrobble.TimeStamp).To(BeTemporally("~", ts, 1*time.Second))
Expect(lastScrobble.ID).To(Equal("123"))
@@ -274,49 +291,82 @@ var _ = Describe("PlayTracker", func() {
Expect(artist1.PlayCount).To(Equal(int64(1)))
Expect(artist2.PlayCount).To(Equal(int64(1)))
})
+
+ Context("Scrobble History", func() {
+ It("records scrobble in repository", func() {
+ conf.Server.EnableScrobbleHistory = true
+ ctx = request.WithUser(ctx, model.User{ID: "u-1", UserName: "user-1"})
+ ts := time.Now()
+
+ err := tracker.Submit(ctx, []Submission{{TrackID: "123", Timestamp: ts}})
+
+ Expect(err).ToNot(HaveOccurred())
+
+ mockDS := ds.(*tests.MockDataStore)
+ mockScrobble := mockDS.Scrobble(ctx).(*tests.MockScrobbleRepo)
+ Expect(mockScrobble.RecordedScrobbles).To(HaveLen(1))
+ Expect(mockScrobble.RecordedScrobbles[0].MediaFileID).To(Equal("123"))
+ Expect(mockScrobble.RecordedScrobbles[0].UserID).To(Equal("u-1"))
+ Expect(mockScrobble.RecordedScrobbles[0].SubmissionTime).To(Equal(ts))
+ })
+
+ It("does not record scrobble when history is disabled", func() {
+ conf.Server.EnableScrobbleHistory = false
+ ctx = request.WithUser(ctx, model.User{ID: "u-1", UserName: "user-1"})
+ ts := time.Now()
+
+ err := tracker.Submit(ctx, []Submission{{TrackID: "123", Timestamp: ts}})
+
+ Expect(err).ToNot(HaveOccurred())
+ mockDS := ds.(*tests.MockDataStore)
+ mockScrobble := mockDS.Scrobble(ctx).(*tests.MockScrobbleRepo)
+ Expect(mockScrobble.RecordedScrobbles).To(HaveLen(0))
+ })
+ })
})
Describe("Plugin scrobbler logic", func() {
var pluginLoader *mockPluginLoader
- var pluginFake fakeScrobbler
+ var pluginFake *fakeScrobbler
BeforeEach(func() {
- pluginFake = fakeScrobbler{Authorized: true}
+ pluginFake = &fakeScrobbler{Authorized: true}
pluginLoader = &mockPluginLoader{
names: []string{"plugin1"},
- scrobblers: map[string]Scrobbler{"plugin1": &pluginFake},
+ scrobblers: map[string]Scrobbler{"plugin1": pluginFake},
}
tracker = newPlayTracker(ds, events.GetBroker(), pluginLoader)
// Bypass buffering for both built-in and plugin scrobblers
- tracker.(*playTracker).builtinScrobblers["fake"] = &fake
- tracker.(*playTracker).pluginScrobblers["plugin1"] = &pluginFake
+ tracker.(*playTracker).builtinScrobblers["fake"] = fake
+ tracker.(*playTracker).pluginScrobblers["plugin1"] = pluginFake
})
It("registers and uses plugin scrobbler for NowPlaying", func() {
err := tracker.NowPlaying(ctx, "player-1", "player-one", "123", 0)
Expect(err).ToNot(HaveOccurred())
- Expect(pluginFake.NowPlayingCalled).To(BeTrue())
+ Eventually(func() bool { return pluginFake.GetNowPlayingCalled() }).Should(BeTrue())
})
It("removes plugin scrobbler if not present anymore", func() {
// First call: plugin present
_ = tracker.NowPlaying(ctx, "player-1", "player-one", "123", 0)
- Expect(pluginFake.NowPlayingCalled).To(BeTrue())
- pluginFake.NowPlayingCalled = false
+ Eventually(func() bool { return pluginFake.GetNowPlayingCalled() }).Should(BeTrue())
+ pluginFake.nowPlayingCalled.Store(false)
// Remove plugin
- pluginLoader.names = []string{}
+ pluginLoader.SetNames([]string{})
_ = tracker.NowPlaying(ctx, "player-1", "player-one", "123", 0)
- Expect(pluginFake.NowPlayingCalled).To(BeFalse())
+ // Should not be called since plugin was removed
+ Consistently(func() bool { return pluginFake.GetNowPlayingCalled() }).Should(BeFalse())
})
It("calls both builtin and plugin scrobblers for NowPlaying", func() {
- fake.NowPlayingCalled = false
- pluginFake.NowPlayingCalled = false
+ fake.nowPlayingCalled.Store(false)
+ pluginFake.nowPlayingCalled.Store(false)
err := tracker.NowPlaying(ctx, "player-1", "player-one", "123", 0)
Expect(err).ToNot(HaveOccurred())
- Expect(fake.NowPlayingCalled).To(BeTrue())
- Expect(pluginFake.NowPlayingCalled).To(BeTrue())
+ Eventually(func() bool { return fake.GetNowPlayingCalled() }).Should(BeTrue())
+ Eventually(func() bool { return pluginFake.GetNowPlayingCalled() }).Should(BeTrue())
})
It("calls plugin scrobbler for Submit", func() {
@@ -334,7 +384,7 @@ var _ = Describe("PlayTracker", func() {
var mockedBS *mockBufferedScrobbler
BeforeEach(func() {
- ctx = context.Background()
+ ctx = GinkgoT().Context()
ctx = request.WithUser(ctx, model.User{ID: "u-1"})
ctx = request.WithPlayer(ctx, model.Player{ScrobbleEnabled: true})
ds = &tests.MockDataStore{}
@@ -359,7 +409,7 @@ var _ = Describe("PlayTracker", func() {
It("calls Stop on scrobblers when removing them", func() {
// Change the plugin names to simulate a plugin being removed
- mockPlugin.names = []string{}
+ mockPlugin.SetNames([]string{})
// Call refreshPluginScrobblers which should detect the removed plugin
pTracker.refreshPluginScrobblers()
@@ -375,32 +425,51 @@ var _ = Describe("PlayTracker", func() {
type fakeScrobbler struct {
Authorized bool
- NowPlayingCalled bool
+ nowPlayingCalled atomic.Bool
ScrobbleCalled atomic.Bool
- UserID string
- Track *model.MediaFile
- Position int
+ userID atomic.Pointer[string]
+ track atomic.Pointer[model.MediaFile]
+ position atomic.Int32
LastScrobble atomic.Pointer[Scrobble]
Error error
}
+func (f *fakeScrobbler) GetNowPlayingCalled() bool {
+ return f.nowPlayingCalled.Load()
+}
+
+func (f *fakeScrobbler) GetUserID() string {
+ if p := f.userID.Load(); p != nil {
+ return *p
+ }
+ return ""
+}
+
+func (f *fakeScrobbler) GetTrack() *model.MediaFile {
+ return f.track.Load()
+}
+
+func (f *fakeScrobbler) GetPosition() int {
+ return int(f.position.Load())
+}
+
func (f *fakeScrobbler) IsAuthorized(ctx context.Context, userId string) bool {
return f.Error == nil && f.Authorized
}
func (f *fakeScrobbler) NowPlaying(ctx context.Context, userId string, track *model.MediaFile, position int) error {
- f.NowPlayingCalled = true
+ f.nowPlayingCalled.Store(true)
if f.Error != nil {
return f.Error
}
- f.UserID = userId
- f.Track = track
- f.Position = position
+ f.userID.Store(&userId)
+ f.track.Store(track)
+ f.position.Store(int32(position))
return nil
}
func (f *fakeScrobbler) Scrobble(ctx context.Context, userId string, s Scrobble) error {
- f.UserID = userId
+ f.userID.Store(&userId)
f.LastScrobble.Store(&s)
f.ScrobbleCalled.Store(true)
if f.Error != nil {
diff --git a/core/share.go b/core/share.go
index 202c27d89..eb5e6679b 100644
--- a/core/share.go
+++ b/core/share.go
@@ -13,6 +13,7 @@ import (
"github.com/navidrome/navidrome/model"
. "github.com/navidrome/navidrome/utils/gg"
"github.com/navidrome/navidrome/utils/slice"
+ "github.com/navidrome/navidrome/utils/str"
)
type Share interface {
@@ -119,9 +120,8 @@ func (r *shareRepositoryWrapper) Save(entity interface{}) (string, error) {
log.Error(r.ctx, "Invalid Resource ID", "id", firstId)
return "", model.ErrNotFound
}
- if len(s.Contents) > 30 {
- s.Contents = s.Contents[:26] + "..."
- }
+
+ s.Contents = str.TruncateRunes(s.Contents, 30, "...")
id, err = r.Persistable.Save(s)
return id, err
diff --git a/core/share_test.go b/core/share_test.go
index 21069bb59..475d40ec9 100644
--- a/core/share_test.go
+++ b/core/share_test.go
@@ -38,6 +38,38 @@ var _ = Describe("Share", func() {
Expect(id).ToNot(BeEmpty())
Expect(entity.ID).To(Equal(id))
})
+
+ It("does not truncate ASCII labels shorter than 30 characters", func() {
+ _ = ds.MediaFile(ctx).Put(&model.MediaFile{ID: "456", Title: "Example Media File"})
+ entity := &model.Share{Description: "test", ResourceIDs: "456"}
+ _, err := repo.Save(entity)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(entity.Contents).To(Equal("Example Media File"))
+ })
+
+ It("truncates ASCII labels longer than 30 characters", func() {
+ _ = ds.MediaFile(ctx).Put(&model.MediaFile{ID: "789", Title: "Example Media File But The Title Is Really Long For Testing Purposes"})
+ entity := &model.Share{Description: "test", ResourceIDs: "789"}
+ _, err := repo.Save(entity)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(entity.Contents).To(Equal("Example Media File But The ..."))
+ })
+
+ It("does not truncate CJK labels shorter than 30 runes", func() {
+ _ = ds.MediaFile(ctx).Put(&model.MediaFile{ID: "456", Title: "青春コンプレックス"})
+ entity := &model.Share{Description: "test", ResourceIDs: "456"}
+ _, err := repo.Save(entity)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(entity.Contents).To(Equal("青春コンプレックス"))
+ })
+
+ It("truncates CJK labels longer than 30 runes", func() {
+ _ = ds.MediaFile(ctx).Put(&model.MediaFile{ID: "789", Title: "私の中の幻想的世界観及びその顕現を想起させたある現実での出来事に関する一考察"})
+ entity := &model.Share{Description: "test", ResourceIDs: "789"}
+ _, err := repo.Save(entity)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(entity.Contents).To(Equal("私の中の幻想的世界観及びその顕現を想起させたある現実で..."))
+ })
})
Describe("Update", func() {
diff --git a/core/wire_providers.go b/core/wire_providers.go
index ae365156a..16335645c 100644
--- a/core/wire_providers.go
+++ b/core/wire_providers.go
@@ -18,6 +18,7 @@ var Set = wire.NewSet(
NewShare,
NewPlaylists,
NewLibrary,
+ NewMaintenance,
agents.GetAgents,
external.NewProvider,
wire.Bind(new(external.Agents), new(*agents.Agents)),
diff --git a/db/db.go b/db/db.go
index cb1ebd9e3..71bc082b2 100644
--- a/db/db.go
+++ b/db/db.go
@@ -45,10 +45,12 @@ func Db() *sql.DB {
if err != nil {
log.Fatal("Error opening database", err)
}
- _, err = db.Exec("PRAGMA optimize=0x10002")
- if err != nil {
- log.Error("Error applying PRAGMA optimize", err)
- return nil
+ if conf.Server.DevOptimizeDB {
+ _, err = db.Exec("PRAGMA optimize=0x10002")
+ if err != nil {
+ log.Error("Error applying PRAGMA optimize", err)
+ return nil
+ }
}
return db
})
@@ -99,7 +101,7 @@ func Init(ctx context.Context) func() {
log.Fatal(ctx, "Failed to apply new migrations", err)
}
- if hasSchemaChanges {
+ if hasSchemaChanges && conf.Server.DevOptimizeDB {
log.Debug(ctx, "Applying PRAGMA optimize after schema changes")
_, err = db.ExecContext(ctx, "PRAGMA optimize")
if err != nil {
@@ -114,6 +116,9 @@ func Init(ctx context.Context) func() {
// Optimize runs PRAGMA optimize on each connection in the pool
func Optimize(ctx context.Context) {
+ if !conf.Server.DevOptimizeDB {
+ return
+ }
numConns := Db().Stats().OpenConnections
if numConns == 0 {
log.Debug(ctx, "No open connections to optimize")
diff --git a/db/migrations/20250823142158_make_playqueue_position_int.sql b/db/migrations/20250823142158_make_playqueue_position_int.sql
new file mode 100644
index 000000000..de20f0c79
--- /dev/null
+++ b/db/migrations/20250823142158_make_playqueue_position_int.sql
@@ -0,0 +1,9 @@
+-- +goose Up
+-- +goose StatementBegin
+ALTER TABLE playqueue ADD COLUMN position_int integer;
+UPDATE playqueue SET position_int = CAST(position as INTEGER) ;
+ALTER TABLE playqueue DROP COLUMN position;
+ALTER TABLE playqueue RENAME COLUMN position_int TO position;
+-- +goose StatementEnd
+
+-- +goose Down
diff --git a/db/migrations/20251109010105_add_annotation_rating_date.sql b/db/migrations/20251109010105_add_annotation_rating_date.sql
new file mode 100644
index 000000000..9dac46a5e
--- /dev/null
+++ b/db/migrations/20251109010105_add_annotation_rating_date.sql
@@ -0,0 +1,7 @@
+-- +goose Up
+-- +goose StatementBegin
+ALTER TABLE annotation ADD COLUMN rated_at datetime;
+-- +goose StatementEnd
+
+-- +goose Down
+
\ No newline at end of file
diff --git a/db/migrations/20251206013022_create_scrobbles_table.sql b/db/migrations/20251206013022_create_scrobbles_table.sql
new file mode 100644
index 000000000..9791c48e3
--- /dev/null
+++ b/db/migrations/20251206013022_create_scrobbles_table.sql
@@ -0,0 +1,20 @@
+-- +goose Up
+-- +goose StatementBegin
+CREATE TABLE scrobbles(
+ media_file_id VARCHAR(255) NOT NULL
+ REFERENCES media_file(id)
+ ON DELETE CASCADE
+ ON UPDATE CASCADE,
+ user_id VARCHAR(255) NOT NULL
+ REFERENCES user(id)
+ ON DELETE CASCADE
+ ON UPDATE CASCADE,
+ submission_time INTEGER NOT NULL
+);
+CREATE INDEX scrobbles_date ON scrobbles (submission_time);
+-- +goose StatementEnd
+
+-- +goose Down
+-- +goose StatementBegin
+DROP TABLE scrobbles;
+-- +goose StatementEnd
diff --git a/db/migrations/migration.go b/db/migrations/migration.go
index 8d8f8a91e..fde6f5817 100644
--- a/db/migrations/migration.go
+++ b/db/migrations/migration.go
@@ -7,6 +7,7 @@ import (
"strings"
"sync"
+ "github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/consts"
)
@@ -21,11 +22,13 @@ func notice(tx *sql.Tx, msg string) {
// Call this in migrations that requires a full rescan
func forceFullRescan(tx *sql.Tx) error {
// If a full scan is required, most probably the query optimizer is outdated, so we run `analyze`.
- _, err := tx.Exec(`ANALYZE;`)
- if err != nil {
- return err
+ if conf.Server.DevOptimizeDB {
+ _, err := tx.Exec(`ANALYZE;`)
+ if err != nil {
+ return err
+ }
}
- _, err = tx.Exec(fmt.Sprintf(`
+ _, err := tx.Exec(fmt.Sprintf(`
INSERT OR REPLACE into property (id, value) values ('%s', '1');
`, consts.FullScanAfterMigrationFlagKey))
return err
diff --git a/go.mod b/go.mod
index e1a827f1d..2abf5f3a1 100644
--- a/go.mod
+++ b/go.mod
@@ -1,15 +1,15 @@
module github.com/navidrome/navidrome
-go 1.24.5
+go 1.25
-// Fork to fix https://github.com/navidrome/navidrome/pull/3254
+// Fork to fix https://github.com/navidrome/navidrome/issues/3254
replace github.com/dhowden/tag v0.0.0-20240417053706-3d75831295e8 => github.com/deluan/tag v0.0.0-20241002021117-dfe5e6ea396d
require (
github.com/Masterminds/squirrel v1.5.4
github.com/RaveNoX/go-jsoncommentstrip v1.0.0
github.com/andybalholm/cascadia v1.3.3
- github.com/bmatcuk/doublestar/v4 v4.9.0
+ github.com/bmatcuk/doublestar/v4 v4.9.1
github.com/bradleyjkemp/cupaloy/v2 v2.8.0
github.com/deluan/rest v0.0.0-20211102003136-6260bc399cbf
github.com/deluan/sanitize v0.0.0-20241120162836-fdfd8fdfaa55
@@ -22,15 +22,15 @@ require (
github.com/djherbis/times v1.6.0
github.com/dustin/go-humanize v1.0.1
github.com/fatih/structs v1.1.0
- github.com/go-chi/chi/v5 v5.2.2
+ github.com/go-chi/chi/v5 v5.2.3
github.com/go-chi/cors v1.2.2
github.com/go-chi/httprate v0.15.0
github.com/go-chi/jwtauth/v5 v5.3.3
github.com/go-viper/encoding/ini v0.1.1
- github.com/gohugoio/hashstructure v0.5.0
+ github.com/gohugoio/hashstructure v0.6.0
github.com/google/go-pipeline v0.0.0-20230411140531-6cbedfc1d3fc
github.com/google/uuid v1.6.0
- github.com/google/wire v0.6.0
+ github.com/google/wire v0.7.0
github.com/gorilla/websocket v1.5.3
github.com/hashicorp/go-multierror v1.1.1
github.com/jellydator/ttlcache/v3 v3.4.0
@@ -39,40 +39,43 @@ require (
github.com/knqyf263/go-plugin v0.9.0
github.com/kr/pretty v0.3.1
github.com/lestrrat-go/jwx/v2 v2.1.6
+ github.com/maruel/natural v1.2.1
github.com/matoous/go-nanoid/v2 v2.1.0
- github.com/mattn/go-sqlite3 v1.14.29
+ github.com/mattn/go-sqlite3 v1.14.32
github.com/microcosm-cc/bluemonday v1.0.27
github.com/mileusna/useragent v1.3.5
- github.com/onsi/ginkgo/v2 v2.23.4
- github.com/onsi/gomega v1.38.0
+ github.com/onsi/ginkgo/v2 v2.27.2
+ github.com/onsi/gomega v1.38.2
github.com/pelletier/go-toml/v2 v2.2.4
github.com/pocketbase/dbx v1.11.0
- github.com/pressly/goose/v3 v3.24.3
- github.com/prometheus/client_golang v1.22.0
+ github.com/pressly/goose/v3 v3.26.0
+ github.com/prometheus/client_golang v1.23.2
github.com/rjeczalik/notify v0.9.3
github.com/robfig/cron/v3 v3.0.1
github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06
github.com/sirupsen/logrus v1.9.3
- github.com/spf13/cobra v1.9.1
- github.com/spf13/viper v1.20.1
- github.com/stretchr/testify v1.10.0
- github.com/tetratelabs/wazero v1.9.0
+ github.com/spf13/cobra v1.10.1
+ github.com/spf13/viper v1.21.0
+ github.com/stretchr/testify v1.11.1
+ github.com/tetratelabs/wazero v1.10.1
github.com/unrolled/secure v1.17.0
github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342
go.uber.org/goleak v1.3.0
- golang.org/x/exp v0.0.0-20250718183923-645b1fa84792
- golang.org/x/image v0.29.0
- golang.org/x/net v0.42.0
- golang.org/x/sync v0.16.0
- golang.org/x/sys v0.34.0
- golang.org/x/text v0.27.0
- golang.org/x/time v0.12.0
- google.golang.org/protobuf v1.36.6
+ golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6
+ golang.org/x/image v0.33.0
+ golang.org/x/net v0.47.0
+ golang.org/x/sync v0.18.0
+ golang.org/x/sys v0.38.0
+ golang.org/x/term v0.37.0
+ golang.org/x/text v0.31.0
+ golang.org/x/time v0.14.0
+ google.golang.org/protobuf v1.36.10
gopkg.in/yaml.v3 v3.0.1
)
require (
dario.cat/mergo v1.0.2 // indirect
+ github.com/Masterminds/semver/v3 v3.4.0 // indirect
github.com/atombender/go-jsonschema v0.20.0 // indirect
github.com/aymerick/douceur v0.2.0 // indirect
github.com/beorn7/perks v1.0.1 // indirect
@@ -86,9 +89,9 @@ require (
github.com/go-task/slim-sprig/v3 v3.0.0 // indirect
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
github.com/goccy/go-json v0.10.5 // indirect
- github.com/goccy/go-yaml v1.17.1 // indirect
+ github.com/goccy/go-yaml v1.18.0 // indirect
github.com/google/go-cmp v0.7.0 // indirect
- github.com/google/pprof v0.0.0-20250630185457-6e76a2b096b5 // indirect
+ github.com/google/pprof v0.0.0-20251114195745-4902fdda35c8 // indirect
github.com/google/subcommands v1.2.0 // indirect
github.com/gorilla/css v1.0.1 // indirect
github.com/hashicorp/errwrap v1.1.0 // indirect
@@ -108,27 +111,28 @@ require (
github.com/ogier/pflag v0.0.1 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
- github.com/prometheus/client_model v0.6.1 // indirect
- github.com/prometheus/common v0.62.0 // indirect
+ github.com/prometheus/client_model v0.6.2 // indirect
+ github.com/prometheus/common v0.66.1 // indirect
github.com/prometheus/procfs v0.16.1 // indirect
github.com/rogpeppe/go-internal v1.14.1 // indirect
- github.com/sagikazarmark/locafero v0.9.0 // indirect
+ github.com/sagikazarmark/locafero v0.12.0 // indirect
github.com/sanity-io/litter v1.5.8 // indirect
- github.com/segmentio/asm v1.2.0 // indirect
+ github.com/segmentio/asm v1.2.1 // indirect
github.com/sethvargo/go-retry v0.3.0 // indirect
github.com/sosodev/duration v1.3.1 // indirect
- github.com/sourcegraph/conc v0.3.0 // indirect
- github.com/spf13/afero v1.14.0 // indirect
- github.com/spf13/cast v1.9.2 // indirect
- github.com/spf13/pflag v1.0.7 // indirect
+ github.com/spf13/afero v1.15.0 // indirect
+ github.com/spf13/cast v1.10.0 // indirect
+ github.com/spf13/pflag v1.0.10 // indirect
github.com/stretchr/objx v0.5.2 // indirect
github.com/subosito/gotenv v1.6.0 // indirect
github.com/zeebo/xxh3 v1.0.2 // indirect
- go.uber.org/automaxprocs v1.6.0 // indirect
go.uber.org/multierr v1.11.0 // indirect
- golang.org/x/crypto v0.40.0 // indirect
- golang.org/x/mod v0.26.0 // indirect
- golang.org/x/tools v0.35.0 // indirect
+ go.yaml.in/yaml/v2 v2.4.2 // indirect
+ go.yaml.in/yaml/v3 v3.0.4 // indirect
+ golang.org/x/crypto v0.45.0 // indirect
+ golang.org/x/mod v0.30.0 // indirect
+ golang.org/x/telemetry v0.0.0-20251111182119-bc8e575c7b54 // indirect
+ golang.org/x/tools v0.39.0 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect
gopkg.in/natefinch/npipe.v2 v2.0.0-20160621034901-c1b8fa8bdcce // indirect
)
diff --git a/go.sum b/go.sum
index 36558f264..04e986f94 100644
--- a/go.sum
+++ b/go.sum
@@ -2,6 +2,8 @@ dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8=
dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA=
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
+github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0=
+github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
github.com/Masterminds/squirrel v1.5.4 h1:uUcX/aBc8O7Fg9kaISIUsHXdKuqehiXAMQTYX8afzqM=
github.com/Masterminds/squirrel v1.5.4/go.mod h1:NNaOrjSoIDfDA40n7sr2tPNZRfjzjA400rg+riTZj10=
github.com/RaveNoX/go-jsoncommentstrip v1.0.0 h1:t527LHHE3HmiHrq74QMpNPZpGCIJzTx+apLkMKt4HC0=
@@ -14,8 +16,8 @@ github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuP
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
-github.com/bmatcuk/doublestar/v4 v4.9.0 h1:DBvuZxjdKkRP/dr4GVV4w2fnmrk5Hxc90T51LZjv0JA=
-github.com/bmatcuk/doublestar/v4 v4.9.0/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
+github.com/bmatcuk/doublestar/v4 v4.9.1 h1:X8jg9rRZmJd4yRy7ZeNDRnM+T3ZfHv15JiBJ/avrEXE=
+github.com/bmatcuk/doublestar/v4 v4.9.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
github.com/bradleyjkemp/cupaloy/v2 v2.8.0 h1:any4BmKE+jGIaMpnU8YgH/I2LPiLBufr6oMMlVBbn9M=
github.com/bradleyjkemp/cupaloy/v2 v2.8.0/go.mod h1:bm7JXdkRd4BHJk9HpwqAI8BoAY1lps46Enkdqw6aRX0=
github.com/cespare/reflex v0.3.1 h1:N4Y/UmRrjwOkNT0oQQnYsdr6YBxvHqtSfPB4mqOyAKk=
@@ -60,8 +62,14 @@ github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7z
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
-github.com/go-chi/chi/v5 v5.2.2 h1:CMwsvRVTbXVytCk1Wd72Zy1LAsAh9GxMmSNWLHCG618=
-github.com/go-chi/chi/v5 v5.2.2/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops=
+github.com/gkampitakis/ciinfo v0.3.2 h1:JcuOPk8ZU7nZQjdUhctuhQofk7BGHuIy0c9Ez8BNhXs=
+github.com/gkampitakis/ciinfo v0.3.2/go.mod h1:1NIwaOcFChN4fa/B0hEBdAb6npDlFL8Bwx4dfRLRqAo=
+github.com/gkampitakis/go-diff v1.3.2 h1:Qyn0J9XJSDTgnsgHRdz9Zp24RaJeKMUHg2+PDZZdC4M=
+github.com/gkampitakis/go-diff v1.3.2/go.mod h1:LLgOrpqleQe26cte8s36HTWcTmMEur6OPYerdAAS9tk=
+github.com/gkampitakis/go-snaps v0.5.15 h1:amyJrvM1D33cPHwVrjo9jQxX8g/7E2wYdZ+01KS3zGE=
+github.com/gkampitakis/go-snaps v0.5.15/go.mod h1:HNpx/9GoKisdhw9AFOBT1N7DBs9DiHo/hGheFGBZ+mc=
+github.com/go-chi/chi/v5 v5.2.3 h1:WQIt9uxdsAbgIYgid+BpYc+liqQZGMHRaUwp0JUcvdE=
+github.com/go-chi/chi/v5 v5.2.3/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops=
github.com/go-chi/cors v1.2.2 h1:Jmey33TE+b+rB7fT8MUy1u0I4L+NARQlK6LhzKPSyQE=
github.com/go-chi/cors v1.2.2/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58=
github.com/go-chi/httprate v0.15.0 h1:j54xcWV9KGmPf/X4H32/aTH+wBlrvxL7P+SdnRqxh5g=
@@ -71,8 +79,8 @@ github.com/go-chi/jwtauth/v5 v5.3.3/go.mod h1:O4QvPRuZLZghl9WvfVaON+ARfGzpD2PBX/
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-sql-driver/mysql v1.4.1/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w=
-github.com/go-sql-driver/mysql v1.9.2 h1:4cNKDYQ1I84SXslGddlsrMhc8k4LeDVj6Ad6WRjiHuU=
-github.com/go-sql-driver/mysql v1.9.2/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=
+github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo=
+github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=
github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI=
github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8=
github.com/go-viper/encoding/ini v0.1.1 h1:MVWY7B2XNw7lnOqHutGRc97bF3rP7omOdgjdMPAJgbs=
@@ -81,25 +89,24 @@ github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9L
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
-github.com/goccy/go-yaml v1.17.1 h1:LI34wktB2xEE3ONG/2Ar54+/HJVBriAGJ55PHls4YuY=
-github.com/goccy/go-yaml v1.17.1/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
-github.com/gohugoio/hashstructure v0.5.0 h1:G2fjSBU36RdwEJBWJ+919ERvOVqAg9tfcYp47K9swqg=
-github.com/gohugoio/hashstructure v0.5.0/go.mod h1:Ser0TniXuu/eauYmrwM4o64EBvySxNzITEOLlm4igec=
+github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw=
+github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
+github.com/gohugoio/hashstructure v0.6.0 h1:7wMB/2CfXoThFYhdWRGv3u3rUM761Cq29CxUW+NltUg=
+github.com/gohugoio/hashstructure v0.6.0/go.mod h1:lapVLk9XidheHG1IQ4ZSbyYrXcaILU1ZEP/+vno5rBQ=
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
-github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/go-pipeline v0.0.0-20230411140531-6cbedfc1d3fc h1:hd+uUVsB1vdxohPneMrhGH2YfQuH5hRIK9u4/XCeUtw=
github.com/google/go-pipeline v0.0.0-20230411140531-6cbedfc1d3fc/go.mod h1:SL66SJVysrh7YbDCP9tH30b8a9o/N2HeiQNUm85EKhc=
-github.com/google/pprof v0.0.0-20250630185457-6e76a2b096b5 h1:xhMrHhTJ6zxu3gA4enFM9MLn9AY7613teCdFnlUVbSQ=
-github.com/google/pprof v0.0.0-20250630185457-6e76a2b096b5/go.mod h1:5hDyRhoBCxViHszMt12TnOpEI4VVi+U8Gm9iphldiMA=
+github.com/google/pprof v0.0.0-20251114195745-4902fdda35c8 h1:3DsUAV+VNEQa2CUVLxCY3f87278uWfIDhJnbdvDjvmE=
+github.com/google/pprof v0.0.0-20251114195745-4902fdda35c8/go.mod h1:I6V7YzU0XDpsHqbsyrghnFZLO1gwK6NPTNvmetQIk9U=
github.com/google/subcommands v1.2.0 h1:vWQspBTo2nEqTUFita5/KeEWlUL8kQObDFbub/EN9oE=
github.com/google/subcommands v1.2.0/go.mod h1:ZjhPrFU+Olkh9WazFPsl27BQ4UPiG37m3yTrtFlrHVk=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
-github.com/google/wire v0.6.0 h1:HBkoIh4BdSxoyo9PveV8giw7ZsaBOvzWKfcg/6MrVwI=
-github.com/google/wire v0.6.0/go.mod h1:F4QhpQ9EDIdJ1Mbop/NZBRB+5yrR6qg3BnctaoUk6NA=
+github.com/google/wire v0.7.0 h1:JxUKI6+CVBgCO2WToKy/nQk0sS+amI9z9EjVmdaocj4=
+github.com/google/wire v0.7.0/go.mod h1:n6YbUQD9cPKTnHXEBN2DXlOp/mVADhVErcMFb0v3J18=
github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1 h1:EGx4pi6eqNxGaHF6qqu48+N2wcFQ5qg5FXgOdqsJ5d8=
github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
@@ -115,6 +122,8 @@ github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/jellydator/ttlcache/v3 v3.4.0 h1:YS4P125qQS0tNhtL6aeYkheEaB/m8HCqdMMP4mnWdTY=
github.com/jellydator/ttlcache/v3 v3.4.0/go.mod h1:Hw9EgjymziQD3yGsQdf1FqFdpp7YjFMd4Srg5EJlgD4=
+github.com/joshdk/go-junit v1.0.0 h1:S86cUKIdwBHWwA6xCmFlf3RTLfVXYQfvanM5Uh+K6GE=
+github.com/joshdk/go-junit v1.0.0/go.mod h1:TiiV0PqkaNfFXjEiyjWM3XXrhVyCa1K4Zfga6W52ung=
github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo=
github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
github.com/kardianos/service v1.2.4 h1:XNlGtZOYNx2u91urOdg/Kfmc+gfmuIo1Dd3rEi2OgBk=
@@ -153,14 +162,18 @@ github.com/lestrrat-go/jwx/v2 v2.1.6 h1:hxM1gfDILk/l5ylers6BX/Eq1m/pnxe9NBwW6lVf
github.com/lestrrat-go/jwx/v2 v2.1.6/go.mod h1:Y722kU5r/8mV7fYDifjug0r8FK8mZdw0K0GpJw/l8pU=
github.com/lestrrat-go/option v1.0.1 h1:oAzP2fvZGQKWkvHa1/SAcFolBEca1oN+mQ7eooNBEYU=
github.com/lestrrat-go/option v1.0.1/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I=
+github.com/maruel/natural v1.2.1 h1:G/y4pwtTA07lbQsMefvsmEO0VN0NfqpxprxXDM4R/4o=
+github.com/maruel/natural v1.2.1/go.mod h1:v+Rfd79xlw1AgVBjbO0BEQmptqb5HvL/k9GRHB7ZKEg=
github.com/matoous/go-nanoid/v2 v2.1.0 h1:P64+dmq21hhWdtvZfEAofnvJULaRR1Yib0+PnU669bE=
github.com/matoous/go-nanoid/v2 v2.1.0/go.mod h1:KlbGNQ+FhrUNIHUxZdL63t7tl4LaPkZNpUULS8H4uVM=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
-github.com/mattn/go-sqlite3 v1.14.29 h1:1O6nRLJKvsi1H2Sj0Hzdfojwt8GiGKm+LOfLaBFaouQ=
-github.com/mattn/go-sqlite3 v1.14.29/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
+github.com/mattn/go-sqlite3 v1.14.32 h1:JD12Ag3oLy1zQA+BNn74xRgaBbdhbNIDYvQUEuuErjs=
+github.com/mattn/go-sqlite3 v1.14.32/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY=
github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg=
+github.com/mfridman/tparse v0.18.0 h1:wh6dzOKaIwkUGyKgOntDW4liXSo37qg5AXbIhkMV3vE=
+github.com/mfridman/tparse v0.18.0/go.mod h1:gEvqZTuCgEhPbYk/2lS3Kcxg1GmTxxU7kTC8DvP0i/A=
github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
github.com/mileusna/useragent v1.3.5 h1:SJM5NzBmh/hO+4LGeATKpaEX9+b4vcGg2qXGLiNGDws=
@@ -173,10 +186,10 @@ github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdh
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/ogier/pflag v0.0.1 h1:RW6JSWSu/RkSatfcLtogGfFgpim5p7ARQ10ECk5O750=
github.com/ogier/pflag v0.0.1/go.mod h1:zkFki7tvTa0tafRvTBIZTvzYyAu6kQhPZFnshFFPE+g=
-github.com/onsi/ginkgo/v2 v2.23.4 h1:ktYTpKJAVZnDT4VjxSbiBenUjmlL/5QkBEocaWXiQus=
-github.com/onsi/ginkgo/v2 v2.23.4/go.mod h1:Bt66ApGPBFzHyR+JO10Zbt0Gsp4uWxu5mIOTusL46e8=
-github.com/onsi/gomega v1.38.0 h1:c/WX+w8SLAinvuKKQFh77WEucCnPk4j2OTUr7lt7BeY=
-github.com/onsi/gomega v1.38.0/go.mod h1:OcXcwId0b9QsE7Y49u+BTrL4IdKOBOKnD6VQNTJEB6o=
+github.com/onsi/ginkgo/v2 v2.27.2 h1:LzwLj0b89qtIy6SSASkzlNvX6WktqurSHwkk2ipF/Ns=
+github.com/onsi/ginkgo/v2 v2.27.2/go.mod h1:ArE1D/XhNXBXCBkKOLkbsb2c81dQHCRcF5zwn/ykDRo=
+github.com/onsi/gomega v1.38.2 h1:eZCjf2xjZAqe+LeWvKb5weQ+NcPwX84kqJ0cZNxok2A=
+github.com/onsi/gomega v1.38.2/go.mod h1:W2MJcYxRGV63b418Ai34Ud0hEdTVXq9NW9+Sx6uXf3k=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
@@ -188,16 +201,14 @@ github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRI
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pocketbase/dbx v1.11.0 h1:LpZezioMfT3K4tLrqA55wWFw1EtH1pM4tzSVa7kgszU=
github.com/pocketbase/dbx v1.11.0/go.mod h1:xXRCIAKTHMgUCyCKZm55pUOdvFziJjQfXaWKhu2vhMs=
-github.com/prashantv/gostub v1.1.0 h1:BTyx3RfQjRHnUWaGF9oQos79AlQ5k8WNktv7VGvVH4g=
-github.com/prashantv/gostub v1.1.0/go.mod h1:A5zLQHz7ieHGG7is6LLXLz7I8+3LZzsrV0P1IAHhP5U=
-github.com/pressly/goose/v3 v3.24.3 h1:DSWWNwwggVUsYZ0X2VitiAa9sKuqtBfe+Jr9zFGwWlM=
-github.com/pressly/goose/v3 v3.24.3/go.mod h1:v9zYL4xdViLHCUUJh/mhjnm6JrK7Eul8AS93IxiZM4E=
-github.com/prometheus/client_golang v1.22.0 h1:rb93p9lokFEsctTys46VnV1kLCDpVZ0a/Y92Vm0Zc6Q=
-github.com/prometheus/client_golang v1.22.0/go.mod h1:R7ljNsLXhuQXYZYtw6GAE9AZg8Y7vEW5scdCXrWRXC0=
-github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E=
-github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY=
-github.com/prometheus/common v0.62.0 h1:xasJaQlnWAeyHdUBeGjXmutelfJHWMRr+Fg4QszZ2Io=
-github.com/prometheus/common v0.62.0/go.mod h1:vyBcEuLSvWos9B1+CyL7JZ2up+uFzXhkqml0W5zIY1I=
+github.com/pressly/goose/v3 v3.26.0 h1:KJakav68jdH0WDvoAcj8+n61WqOIaPGgH0bJWS6jpmM=
+github.com/pressly/goose/v3 v3.26.0/go.mod h1:4hC1KrritdCxtuFsqgs1R4AU5bWtTAf+cnWvfhf2DNY=
+github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
+github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
+github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
+github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
+github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9ZoGs=
+github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA=
github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg=
github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
@@ -212,12 +223,12 @@ github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06 h1:OkMGxebDjyw0ULyrTYWeN0UNCCkmCWfjPnIA2W6oviI=
github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06/go.mod h1:+ePHsJ1keEjQtpvf9HHw0f4ZeJ0TLRsxhunSI2hYJSs=
-github.com/sagikazarmark/locafero v0.9.0 h1:GbgQGNtTrEmddYDSAH9QLRyfAHY12md+8YFTqyMTC9k=
-github.com/sagikazarmark/locafero v0.9.0/go.mod h1:UBUyz37V+EdMS3hDF3QWIiVr/2dPrx49OMO0Bn0hJqk=
+github.com/sagikazarmark/locafero v0.12.0 h1:/NQhBAkUb4+fH1jivKHWusDYFjMOOKU88eegjfxfHb4=
+github.com/sagikazarmark/locafero v0.12.0/go.mod h1:sZh36u/YSZ918v0Io+U9ogLYQJ9tLLBmM4eneO6WwsI=
github.com/sanity-io/litter v1.5.8 h1:uM/2lKrWdGbRXDrIq08Lh9XtVYoeGtcQxk9rtQ7+rYg=
github.com/sanity-io/litter v1.5.8/go.mod h1:9gzJgR2i4ZpjZHsKvUXIRQVk7P+yM3e+jAF7bU2UI5U=
-github.com/segmentio/asm v1.2.0 h1:9BQrFxC+YOHJlTlHGkTrFWf59nbL3XnCoFLTwDCI7ys=
-github.com/segmentio/asm v1.2.0/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs=
+github.com/segmentio/asm v1.2.1 h1:DTNbBqs57ioxAD4PrArqftgypG4/qNpXoJx8TVXxPR0=
+github.com/segmentio/asm v1.2.1/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs=
github.com/sethvargo/go-retry v0.3.0 h1:EEt31A35QhrcRZtrYFDTBg91cqZVnFL2navjDrah2SE=
github.com/sethvargo/go-retry v0.3.0/go.mod h1:mNX17F0C/HguQMyMyJxcnU471gOZGxCLyYaFyAZraas=
github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE=
@@ -229,19 +240,17 @@ github.com/smartystreets/goconvey v1.6.4 h1:fv0U8FUIMPNf1L9lnHLvLhgicrIVChEkdzIK
github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA=
github.com/sosodev/duration v1.3.1 h1:qtHBDMQ6lvMQsL15g4aopM4HEfOaYuhWBw3NPTtlqq4=
github.com/sosodev/duration v1.3.1/go.mod h1:RQIBBX0+fMLc/D9+Jb/fwvVmo0eZvDDEERAikUR6SDg=
-github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo=
-github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0=
-github.com/spf13/afero v1.14.0 h1:9tH6MapGnn/j0eb0yIXiLjERO8RB6xIVZRDCX7PtqWA=
-github.com/spf13/afero v1.14.0/go.mod h1:acJQ8t0ohCGuMN3O+Pv0V0hgMxNYDlvdk+VTfyZmbYo=
-github.com/spf13/cast v1.9.2 h1:SsGfm7M8QOFtEzumm7UZrZdLLquNdzFYfIbEXntcFbE=
-github.com/spf13/cast v1.9.2/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
-github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo=
-github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0=
-github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
-github.com/spf13/pflag v1.0.7 h1:vN6T9TfwStFPFM5XzjsvmzZkLuaLX+HS+0SeFLRgU6M=
-github.com/spf13/pflag v1.0.7/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
-github.com/spf13/viper v1.20.1 h1:ZMi+z/lvLyPSCoNtFCpqjy0S4kPbirhpTMwl8BkW9X4=
-github.com/spf13/viper v1.20.1/go.mod h1:P9Mdzt1zoHIG8m2eZQinpiBjo6kCmZSKBClNNqjJvu4=
+github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
+github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=
+github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
+github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
+github.com/spf13/cobra v1.10.1 h1:lJeBwCfmrnXthfAupyUTzJ/J4Nc1RsHC/mSRU2dll/s=
+github.com/spf13/cobra v1.10.1/go.mod h1:7SmJGaTHFVBY0jW4NXGluQoLvhqFQM+6XSKD+P4XaB0=
+github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
+github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
+github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
+github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU=
+github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
@@ -252,12 +261,20 @@ github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81P
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
-github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
-github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
+github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
+github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
-github.com/tetratelabs/wazero v1.9.0 h1:IcZ56OuxrtaEz8UYNRHBrUa9bYeX9oVY93KspZZBf/I=
-github.com/tetratelabs/wazero v1.9.0/go.mod h1:TSbcXCfFP0L2FGkRPxHphadXPjo1T6W+CseNNY7EkjM=
+github.com/tetratelabs/wazero v1.10.1 h1:2DugeJf6VVk58KTPszlNfeeN8AhhpwcZqkJj2wwFuH8=
+github.com/tetratelabs/wazero v1.10.1/go.mod h1:DRm5twOQ5Gr1AoEdSi0CLjDQF1J9ZAuyqFIjl1KKfQU=
+github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
+github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
+github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
+github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
+github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
+github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
+github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
+github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
github.com/unrolled/secure v1.17.0 h1:Io7ifFgo99Bnh0J7+Q+qcMzWM6kaDPCA5FroFZEdbWU=
github.com/unrolled/secure v1.17.0/go.mod h1:BmF5hyM6tXczk3MpQkFf1hpKSRqCyhqcbiQtiAF7+40=
github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342 h1:FnBeRrxr7OU4VvAzt5X7s6266i6cSVkkFPS0TuXWbIg=
@@ -267,34 +284,34 @@ github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ=
github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0=
github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0=
github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA=
-go.uber.org/automaxprocs v1.6.0 h1:O3y2/QNTOdbF+e/dpXNNW7Rx2hZ4sTIPyybbxyNqTUs=
-go.uber.org/automaxprocs v1.6.0/go.mod h1:ifeIMSnPZuznNm6jmdzmU3/bfk01Fe2fotchwEFJ8r8=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
+go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI=
+go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU=
+go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
+go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
-golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1mg=
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
-golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM=
-golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY=
-golang.org/x/exp v0.0.0-20250718183923-645b1fa84792 h1:R9PFI6EUdfVKgwKjZef7QIwGcBKu86OEFpJ9nUEP2l4=
-golang.org/x/exp v0.0.0-20250718183923-645b1fa84792/go.mod h1:A+z0yzpGtvnG90cToK5n2tu8UJVP2XUATh+r+sfOOOc=
+golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
+golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
+golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6 h1:zfMcR1Cs4KNuomFFgGefv5N0czO2XZpUbxGUy8i8ug0=
+golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6/go.mod h1:46edojNIoXTNOhySWIWdix628clX9ODXwPsQuG6hsK0=
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
-golang.org/x/image v0.29.0 h1:HcdsyR4Gsuys/Axh0rDEmlBmB68rW1U9BUdB3UVHsas=
-golang.org/x/image v0.29.0/go.mod h1:RVJROnf3SLK8d26OW91j4FrIHGbsJ8QnbEocVTOWQDA=
+golang.org/x/image v0.33.0 h1:LXRZRnv1+zGd5XBUVRFmYEphyyKJjQjCRiOuAP3sZfQ=
+golang.org/x/image v0.33.0/go.mod h1:DD3OsTYT9chzuzTQt+zMcOlBHgfoKQb1gry8p76Y1sc=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
-golang.org/x/mod v0.14.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
-golang.org/x/mod v0.26.0 h1:EGMPT//Ezu+ylkCijjPc+f4Aih7sZvaAr+O3EHBxvZg=
-golang.org/x/mod v0.26.0/go.mod h1:/j6NAhSk8iQ723BGAUyoAcn7SlD7s15Dp9Nd/SfeaFQ=
+golang.org/x/mod v0.30.0 h1:fDEXFVZ/fmCKProc/yAXXUijritrDzahmwwefnjoPFk=
+golang.org/x/mod v0.30.0/go.mod h1:lAsf5O2EvJeSFMiBxXDki7sCgAxEUcZHXoXMKT4GJKc=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
@@ -303,12 +320,11 @@ golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
-golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
-golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs=
-golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8=
+golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
+golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -316,8 +332,8 @@ golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
-golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
-golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
+golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
+golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20180926160741-c2ed4eda69e7/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
@@ -331,22 +347,24 @@ golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
-golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA=
-golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
+golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
+golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
+golang.org/x/telemetry v0.0.0-20251111182119-bc8e575c7b54 h1:E2/AqCUMZGgd73TQkxUMcMla25GB9i/5HOdLr+uH7Vo=
+golang.org/x/telemetry v0.0.0-20251111182119-bc8e575c7b54/go.mod h1:hKdjCMrbv9skySur+Nek8Hd0uJ0GuxJIoIX2payrIdQ=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
-golang.org/x/term v0.16.0/go.mod h1:yn7UURbUtPyrVJPGPq404EukNFxcm/foM+bV/bfcDsY=
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
+golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU=
+golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
@@ -357,24 +375,23 @@ golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
-golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4=
-golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU=
-golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE=
-golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
+golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
+golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
+golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
+golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
-golang.org/x/tools v0.17.0/go.mod h1:xsh6VxdV005rRVaS6SSAf9oiAqljS7UZUacMZ8Bnsps=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
-golang.org/x/tools v0.35.0 h1:mBffYraMEf7aa0sB+NuKnuCy8qI/9Bughn8dC2Gu5r0=
-golang.org/x/tools v0.35.0/go.mod h1:NKdj5HkL/73byiZSJjqJgKn3ep7KjFkBOkR/Hps3VPw=
+golang.org/x/tools v0.39.0 h1:ik4ho21kwuQln40uelmciQPp9SipgNDdrafrYA4TmQQ=
+golang.org/x/tools v0.39.0/go.mod h1:JnefbkDPyD8UU2kI5fuf8ZX4/yUeh9W877ZeBONxUqQ=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
-google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
-google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
+google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
+google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
@@ -386,11 +403,11 @@ gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
-modernc.org/libc v1.65.0 h1:e183gLDnAp9VJh6gWKdTy0CThL9Pt7MfcR/0bgb7Y1Y=
-modernc.org/libc v1.65.0/go.mod h1:7m9VzGq7APssBTydds2zBcxGREwvIGpuUBaKTXdm2Qs=
+modernc.org/libc v1.66.3 h1:cfCbjTUcdsKyyZZfEUKfoHcP3S0Wkvz3jgSzByEWVCQ=
+modernc.org/libc v1.66.3/go.mod h1:XD9zO8kt59cANKvHPXpx7yS2ELPheAey0vjIuZOhOU8=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
-modernc.org/memory v1.10.0 h1:fzumd51yQ1DxcOxSO+S6X7+QTuVU+n8/Aj7swYjFfC4=
-modernc.org/memory v1.10.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
-modernc.org/sqlite v1.37.0 h1:s1TMe7T3Q3ovQiK2Ouz4Jwh7dw4ZDqbebSDTlSJdfjI=
-modernc.org/sqlite v1.37.0/go.mod h1:5YiWv+YviqGMuGw4V+PNplcyaJ5v+vQd7TQOgkACoJM=
+modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
+modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
+modernc.org/sqlite v1.38.2 h1:Aclu7+tgjgcQVShZqim41Bbw9Cho0y/7WzYptXqkEek=
+modernc.org/sqlite v1.38.2/go.mod h1:cPTJYSlgg3Sfg046yBShXENNtPrWrDX8bsbAQBzgQ5E=
diff --git a/log/log.go b/log/log.go
index 20119ab46..24f3dff6e 100644
--- a/log/log.go
+++ b/log/log.go
@@ -11,6 +11,7 @@ import (
"runtime"
"sort"
"strings"
+ "sync"
"time"
"github.com/sirupsen/logrus"
@@ -28,8 +29,8 @@ var redacted = &Hook{
"(Secret:\")[\\w]*",
"(Spotify.*ID:\")[\\w]*",
"(PasswordEncryptionKey:[\\s]*\")[^\"]*",
- "(ReverseProxyUserHeader:[\\s]*\")[^\"]*",
- "(ReverseProxyWhitelist:[\\s]*\")[^\"]*",
+ "(UserHeader:[\\s]*\")[^\"]*",
+ "(TrustedSources:[\\s]*\")[^\"]*",
"(MetricsPath:[\\s]*\")[^\"]*",
"(DevAutoCreateAdminPassword:[\\s]*\")[^\"]*",
"(DevAutoLoginUsername:[\\s]*\")[^\"]*",
@@ -70,6 +71,7 @@ type levelPath struct {
var (
currentLevel Level
+ loggerMu sync.RWMutex
defaultLogger = logrus.New()
logSourceLine = false
rootPath string
@@ -78,8 +80,10 @@ var (
// SetLevel sets the global log level used by the simple logger.
func SetLevel(l Level) {
+ loggerMu.Lock()
currentLevel = l
defaultLogger.Level = logrus.TraceLevel
+ loggerMu.Unlock()
logrus.SetLevel(logrus.Level(l))
}
@@ -110,6 +114,8 @@ func levelFromString(l string) Level {
// SetLogLevels sets the log levels for specific paths in the codebase.
func SetLogLevels(levels map[string]string) {
+ loggerMu.Lock()
+ defer loggerMu.Unlock()
logLevels = nil
for k, v := range levels {
logLevels = append(logLevels, levelPath{path: k, level: levelFromString(v)})
@@ -125,6 +131,8 @@ func SetLogSourceLine(enabled bool) {
func SetRedacting(enabled bool) {
if enabled {
+ loggerMu.Lock()
+ defer loggerMu.Unlock()
defaultLogger.AddHook(redacted)
}
}
@@ -133,6 +141,8 @@ func SetOutput(w io.Writer) {
if runtime.GOOS == "windows" {
w = CRLFWriter(w)
}
+ loggerMu.Lock()
+ defer loggerMu.Unlock()
defaultLogger.SetOutput(w)
}
@@ -158,10 +168,14 @@ func NewContext(ctx context.Context, keyValuePairs ...interface{}) context.Conte
}
func SetDefaultLogger(l *logrus.Logger) {
+ loggerMu.Lock()
+ defer loggerMu.Unlock()
defaultLogger = l
}
func CurrentLevel() Level {
+ loggerMu.RLock()
+ defer loggerMu.RUnlock()
return currentLevel
}
@@ -204,14 +218,21 @@ func log(level Level, args ...interface{}) {
}
func Writer() io.Writer {
+ loggerMu.RLock()
+ defer loggerMu.RUnlock()
return defaultLogger.Writer()
}
func shouldLog(requiredLevel Level, skip int) bool {
- if currentLevel >= requiredLevel {
+ loggerMu.RLock()
+ level := currentLevel
+ levels := logLevels
+ loggerMu.RUnlock()
+
+ if level >= requiredLevel {
return true
}
- if len(logLevels) == 0 {
+ if len(levels) == 0 {
return false
}
@@ -221,7 +242,7 @@ func shouldLog(requiredLevel Level, skip int) bool {
}
file = strings.TrimPrefix(file, rootPath)
- for _, lp := range logLevels {
+ for _, lp := range levels {
if strings.HasPrefix(file, lp.path) {
return lp.level >= requiredLevel
}
@@ -314,6 +335,8 @@ func extractLogger(ctx interface{}) (*logrus.Entry, error) {
func createNewLogger() *logrus.Entry {
//logrus.SetFormatter(&logrus.TextFormatter{ForceColors: true, DisableTimestamp: false, FullTimestamp: true})
//l.Formatter = &logrus.TextFormatter{ForceColors: true, DisableTimestamp: false, FullTimestamp: true}
+ loggerMu.RLock()
+ defer loggerMu.RUnlock()
logger := logrus.NewEntry(defaultLogger)
return logger
}
diff --git a/model/annotation.go b/model/annotation.go
index 2ec72c1b7..fbff5f178 100644
--- a/model/annotation.go
+++ b/model/annotation.go
@@ -6,6 +6,7 @@ type Annotations struct {
PlayCount int64 `structs:"play_count" json:"playCount,omitempty"`
PlayDate *time.Time `structs:"play_date" json:"playDate,omitempty" `
Rating int `structs:"rating" json:"rating,omitempty" `
+ RatedAt *time.Time `structs:"rated_at" json:"ratedAt,omitempty" `
Starred bool `structs:"starred" json:"starred,omitempty" `
StarredAt *time.Time `structs:"starred_at" json:"starredAt,omitempty"`
}
diff --git a/model/criteria/criteria.go b/model/criteria/criteria.go
index fa92c5aca..54ac59697 100644
--- a/model/criteria/criteria.go
+++ b/model/criteria/criteria.go
@@ -61,7 +61,12 @@ func (c Criteria) OrderBy() string {
if f.order != "" {
mapped = f.order
} else if f.isTag {
- mapped = "COALESCE(json_extract(media_file.tags, '$." + sortField + "[0].value'), '')"
+ // Use the actual field name (handles aliases like albumtype -> releasetype)
+ tagName := sortField
+ if f.field != "" {
+ tagName = f.field
+ }
+ mapped = "COALESCE(json_extract(media_file.tags, '$." + tagName + "[0].value'), '')"
} else if f.isRole {
mapped = "COALESCE(json_extract(media_file.participants, '$." + sortField + "[0].name'), '')"
} else {
diff --git a/model/criteria/criteria_test.go b/model/criteria/criteria_test.go
index 3792264a5..032ead5c8 100644
--- a/model/criteria/criteria_test.go
+++ b/model/criteria/criteria_test.go
@@ -118,6 +118,16 @@ var _ = Describe("Criteria", func() {
)
})
+ It("sorts by albumtype alias (resolves to releasetype)", func() {
+ AddTagNames([]string{"releasetype"})
+ goObj.Sort = "albumtype"
+ gomega.Expect(goObj.OrderBy()).To(
+ gomega.Equal(
+ "COALESCE(json_extract(media_file.tags, '$.releasetype[0].value'), '') asc",
+ ),
+ )
+ })
+
It("sorts by random", func() {
newObj := goObj
newObj.Sort = "random"
diff --git a/model/criteria/fields.go b/model/criteria/fields.go
index 3699eb14a..5381ae597 100644
--- a/model/criteria/fields.go
+++ b/model/criteria/fields.go
@@ -32,7 +32,6 @@ var fieldMap = map[string]*mappedField{
"sortalbum": {field: "media_file.sort_album_name"},
"sortartist": {field: "media_file.sort_artist_name"},
"sortalbumartist": {field: "media_file.sort_album_artist_name"},
- "albumtype": {field: "media_file.mbz_album_type", alias: "releasetype"},
"albumcomment": {field: "media_file.mbz_album_comment"},
"catalognumber": {field: "media_file.catalog_num"},
"filepath": {field: "media_file.path"},
@@ -45,6 +44,7 @@ var fieldMap = map[string]*mappedField{
"loved": {field: "COALESCE(annotation.starred, false)"},
"dateloved": {field: "annotation.starred_at"},
"lastplayed": {field: "annotation.play_date"},
+ "daterated": {field: "annotation.rated_at"},
"playcount": {field: "COALESCE(annotation.play_count, 0)"},
"rating": {field: "COALESCE(annotation.rating, 0)"},
"mbz_album_id": {field: "media_file.mbz_album_id"},
@@ -55,6 +55,9 @@ var fieldMap = map[string]*mappedField{
"mbz_release_group_id": {field: "media_file.mbz_release_group_id"},
"library_id": {field: "media_file.library_id", numeric: true},
+ // Backward compatibility: albumtype is an alias for releasetype tag
+ "albumtype": {field: "releasetype", isTag: true},
+
// special fields
"random": {field: "", order: "random()"}, // pseudo-field for random sorting
"value": {field: "value"}, // pseudo-field for tag and roles values
@@ -154,13 +157,19 @@ type tagCond struct {
func (e tagCond) ToSql() (string, []any, error) {
cond, args, err := e.cond.ToSql()
- // Check if this tag is marked as numeric in the fieldMap
- if fm, ok := fieldMap[e.tag]; ok && fm.numeric {
- cond = strings.ReplaceAll(cond, "value", "CAST(value AS REAL)")
+ // Resolve the actual tag name (handles aliases like albumtype -> releasetype)
+ tagName := e.tag
+ if fm, ok := fieldMap[e.tag]; ok {
+ if fm.field != "" {
+ tagName = fm.field
+ }
+ if fm.numeric {
+ cond = strings.ReplaceAll(cond, "value", "CAST(value AS REAL)")
+ }
}
cond = fmt.Sprintf("exists (select 1 from json_tree(tags, '$.%s') where key='value' and %s)",
- e.tag, cond)
+ tagName, cond)
if e.not {
cond = "not " + cond
}
diff --git a/model/criteria/operators_test.go b/model/criteria/operators_test.go
index ee716a9cd..4c1db1303 100644
--- a/model/criteria/operators_test.go
+++ b/model/criteria/operators_test.go
@@ -105,6 +105,40 @@ var _ = Describe("Operators", func() {
gomega.Expect(sql).To(gomega.BeEmpty())
gomega.Expect(args).To(gomega.BeEmpty())
})
+ It("supports releasetype as multi-valued tag", func() {
+ AddTagNames([]string{"releasetype"})
+ op := Contains{"releasetype": "soundtrack"}
+ sql, args, err := op.ToSql()
+ gomega.Expect(err).ToNot(gomega.HaveOccurred())
+ gomega.Expect(sql).To(gomega.Equal("exists (select 1 from json_tree(tags, '$.releasetype') where key='value' and value LIKE ?)"))
+ gomega.Expect(args).To(gomega.HaveExactElements("%soundtrack%"))
+ })
+ It("supports albumtype as alias for releasetype", func() {
+ AddTagNames([]string{"releasetype"})
+ op := Contains{"albumtype": "live"}
+ sql, args, err := op.ToSql()
+ gomega.Expect(err).ToNot(gomega.HaveOccurred())
+ gomega.Expect(sql).To(gomega.Equal("exists (select 1 from json_tree(tags, '$.releasetype') where key='value' and value LIKE ?)"))
+ gomega.Expect(args).To(gomega.HaveExactElements("%live%"))
+ })
+ It("supports albumtype alias with Is operator", func() {
+ AddTagNames([]string{"releasetype"})
+ op := Is{"albumtype": "album"}
+ sql, args, err := op.ToSql()
+ gomega.Expect(err).ToNot(gomega.HaveOccurred())
+ // Should query $.releasetype, not $.albumtype
+ gomega.Expect(sql).To(gomega.Equal("exists (select 1 from json_tree(tags, '$.releasetype') where key='value' and value = ?)"))
+ gomega.Expect(args).To(gomega.HaveExactElements("album"))
+ })
+ It("supports albumtype alias with IsNot operator", func() {
+ AddTagNames([]string{"releasetype"})
+ op := IsNot{"albumtype": "compilation"}
+ sql, args, err := op.ToSql()
+ gomega.Expect(err).ToNot(gomega.HaveOccurred())
+ // Should query $.releasetype, not $.albumtype
+ gomega.Expect(sql).To(gomega.Equal("not exists (select 1 from json_tree(tags, '$.releasetype') where key='value' and value = ?)"))
+ gomega.Expect(args).To(gomega.HaveExactElements("compilation"))
+ })
})
Describe("Custom Roles", func() {
diff --git a/model/datastore.go b/model/datastore.go
index 4290e2134..601fab2d3 100644
--- a/model/datastore.go
+++ b/model/datastore.go
@@ -38,10 +38,11 @@ type DataStore interface {
User(ctx context.Context) UserRepository
UserProps(ctx context.Context) UserPropsRepository
ScrobbleBuffer(ctx context.Context) ScrobbleBufferRepository
+ Scrobble(ctx context.Context) ScrobbleRepository
Resource(ctx context.Context, model interface{}) ResourceRepository
WithTx(block func(tx DataStore) error, scope ...string) error
WithTxImmediate(block func(tx DataStore) error, scope ...string) error
- GC(ctx context.Context) error
+ GC(ctx context.Context, libraryIDs ...int) error
}
diff --git a/model/folder.go b/model/folder.go
index f715f8c11..7a769735e 100644
--- a/model/folder.go
+++ b/model/folder.go
@@ -85,7 +85,7 @@ type FolderRepository interface {
GetByPath(lib Library, path string) (*Folder, error)
GetAll(...QueryOptions) ([]Folder, error)
CountAll(...QueryOptions) (int64, error)
- GetLastUpdates(lib Library) (map[string]FolderUpdateInfo, error)
+ GetFolderUpdateInfo(lib Library, targetPaths ...string) (map[string]FolderUpdateInfo, error)
Put(*Folder) error
MarkMissing(missing bool, ids ...string) error
GetTouchedWithPlaylists() (FolderCursor, error)
diff --git a/model/metadata/legacy_ids.go b/model/metadata/legacy_ids.go
index 0a3bf0bf3..18a273550 100644
--- a/model/metadata/legacy_ids.go
+++ b/model/metadata/legacy_ids.go
@@ -23,7 +23,7 @@ func legacyTrackID(mf model.MediaFile, prependLibId bool) string {
}
func legacyAlbumID(mf model.MediaFile, md Metadata, prependLibId bool) string {
- releaseDate := legacyReleaseDate(md)
+ _, _, releaseDate := md.mapDates()
albumPath := strings.ToLower(fmt.Sprintf("%s\\%s", legacyMapAlbumArtistName(md), legacyMapAlbumName(md)))
if !conf.Server.Scanner.GroupAlbumReleases {
if len(releaseDate) != 0 {
@@ -55,9 +55,3 @@ func legacyMapAlbumName(md Metadata) string {
consts.UnknownAlbum,
)
}
-
-// Keep the TaggedLikePicard logic for backwards compatibility
-func legacyReleaseDate(md Metadata) string {
- _, _, releaseDate := md.mapDates()
- return string(releaseDate)
-}
diff --git a/model/metadata/legacy_ids_test.go b/model/metadata/legacy_ids_test.go
deleted file mode 100644
index b6d096763..000000000
--- a/model/metadata/legacy_ids_test.go
+++ /dev/null
@@ -1,30 +0,0 @@
-package metadata
-
-import (
- . "github.com/onsi/ginkgo/v2"
- . "github.com/onsi/gomega"
-)
-
-var _ = Describe("legacyReleaseDate", func() {
-
- DescribeTable("legacyReleaseDate",
- func(recordingDate, originalDate, releaseDate, expected string) {
- md := New("", Info{
- Tags: map[string][]string{
- "DATE": {recordingDate},
- "ORIGINALDATE": {originalDate},
- "RELEASEDATE": {releaseDate},
- },
- })
-
- result := legacyReleaseDate(md)
- Expect(result).To(Equal(expected))
- },
- Entry("regular mapping", "2020-05-15", "2019-02-10", "2021-01-01", "2021-01-01"),
- Entry("legacy mapping", "2020-05-15", "2019-02-10", "", "2020-05-15"),
- Entry("legacy mapping, originalYear < year", "2018-05-15", "2019-02-10", "2021-01-01", "2021-01-01"),
- Entry("legacy mapping, originalYear empty", "2020-05-15", "", "2021-01-01", "2021-01-01"),
- Entry("legacy mapping, releaseYear", "2020-05-15", "2019-02-10", "2021-01-01", "2021-01-01"),
- Entry("legacy mapping, same dates", "2020-05-15", "2020-05-15", "", "2020-05-15"),
- )
-})
diff --git a/model/metadata/map_mediafile_test.go b/model/metadata/map_mediafile_test.go
index ddda39bc2..e3adf3fae 100644
--- a/model/metadata/map_mediafile_test.go
+++ b/model/metadata/map_mediafile_test.go
@@ -75,6 +75,23 @@ var _ = Describe("ToMediaFile", func() {
Expect(mf.OriginalYear).To(Equal(1966))
Expect(mf.ReleaseYear).To(Equal(2014))
})
+ DescribeTable("legacyReleaseDate (TaggedLikePicard old behavior)",
+ func(recordingDate, originalDate, releaseDate, expected string) {
+ mf := toMediaFile(model.RawTags{
+ "DATE": {recordingDate},
+ "ORIGINALDATE": {originalDate},
+ "RELEASEDATE": {releaseDate},
+ })
+
+ Expect(mf.ReleaseDate).To(Equal(expected))
+ },
+ Entry("regular mapping", "2020-05-15", "2019-02-10", "2021-01-01", "2021-01-01"),
+ Entry("legacy mapping", "2020-05-15", "2019-02-10", "", "2020-05-15"),
+ Entry("legacy mapping, originalYear < year", "2018-05-15", "2019-02-10", "2021-01-01", "2021-01-01"),
+ Entry("legacy mapping, originalYear empty", "2020-05-15", "", "2021-01-01", "2021-01-01"),
+ Entry("legacy mapping, releaseYear", "2020-05-15", "2019-02-10", "2021-01-01", "2021-01-01"),
+ Entry("legacy mapping, same dates", "2020-05-15", "2020-05-15", "", "2020-05-15"),
+ )
})
Describe("Lyrics", func() {
diff --git a/model/scanner.go b/model/scanner.go
new file mode 100644
index 000000000..389c77f87
--- /dev/null
+++ b/model/scanner.go
@@ -0,0 +1,81 @@
+package model
+
+import (
+ "context"
+ "fmt"
+ "strconv"
+ "strings"
+ "time"
+)
+
+// ScanTarget represents a specific folder within a library to be scanned.
+// NOTE: This struct is used as a map key, so it should only contain comparable types.
+type ScanTarget struct {
+ LibraryID int
+ FolderPath string // Relative path within the library, or "" for entire library
+}
+
+func (st ScanTarget) String() string {
+ return fmt.Sprintf("%d:%s", st.LibraryID, st.FolderPath)
+}
+
+// ScannerStatus holds information about the current scan status
+type ScannerStatus struct {
+ Scanning bool
+ LastScan time.Time
+ Count uint32
+ FolderCount uint32
+ LastError string
+ ScanType string
+ ElapsedTime time.Duration
+}
+
+type Scanner interface {
+ // ScanAll starts a scan of all libraries. This is a blocking operation.
+ ScanAll(ctx context.Context, fullScan bool) (warnings []string, err error)
+ // ScanFolders scans specific library/folder pairs, recursing into subdirectories.
+ // If targets is nil, it scans all libraries. This is a blocking operation.
+ ScanFolders(ctx context.Context, fullScan bool, targets []ScanTarget) (warnings []string, err error)
+ Status(context.Context) (*ScannerStatus, error)
+}
+
+// ParseTargets parses scan targets strings into ScanTarget structs.
+// Example: []string{"1:Music/Rock", "2:Classical"}
+func ParseTargets(libFolders []string) ([]ScanTarget, error) {
+ targets := make([]ScanTarget, 0, len(libFolders))
+
+ for _, part := range libFolders {
+ part = strings.TrimSpace(part)
+ if part == "" {
+ continue
+ }
+
+ // Split by the first colon
+ colonIdx := strings.Index(part, ":")
+ if colonIdx == -1 {
+ return nil, fmt.Errorf("invalid target format: %q (expected libraryID:folderPath)", part)
+ }
+
+ libIDStr := part[:colonIdx]
+ folderPath := part[colonIdx+1:]
+
+ libID, err := strconv.Atoi(libIDStr)
+ if err != nil {
+ return nil, fmt.Errorf("invalid library ID %q: %w", libIDStr, err)
+ }
+ if libID <= 0 {
+ return nil, fmt.Errorf("invalid library ID %q", libIDStr)
+ }
+
+ targets = append(targets, ScanTarget{
+ LibraryID: libID,
+ FolderPath: folderPath,
+ })
+ }
+
+ if len(targets) == 0 {
+ return nil, fmt.Errorf("no valid targets found")
+ }
+
+ return targets, nil
+}
diff --git a/model/scanner_test.go b/model/scanner_test.go
new file mode 100644
index 000000000..8ca0c53fa
--- /dev/null
+++ b/model/scanner_test.go
@@ -0,0 +1,89 @@
+package model_test
+
+import (
+ "github.com/navidrome/navidrome/model"
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+)
+
+var _ = Describe("ParseTargets", func() {
+ It("parses multiple entries in slice", func() {
+ targets, err := model.ParseTargets([]string{"1:Music/Rock", "1:Music/Jazz", "2:Classical"})
+ Expect(err).ToNot(HaveOccurred())
+ Expect(targets).To(HaveLen(3))
+ Expect(targets[0].LibraryID).To(Equal(1))
+ Expect(targets[0].FolderPath).To(Equal("Music/Rock"))
+ Expect(targets[1].LibraryID).To(Equal(1))
+ Expect(targets[1].FolderPath).To(Equal("Music/Jazz"))
+ Expect(targets[2].LibraryID).To(Equal(2))
+ Expect(targets[2].FolderPath).To(Equal("Classical"))
+ })
+
+ It("handles empty folder paths", func() {
+ targets, err := model.ParseTargets([]string{"1:", "2:"})
+ Expect(err).ToNot(HaveOccurred())
+ Expect(targets).To(HaveLen(2))
+ Expect(targets[0].FolderPath).To(Equal(""))
+ Expect(targets[1].FolderPath).To(Equal(""))
+ })
+
+ It("trims whitespace from entries", func() {
+ targets, err := model.ParseTargets([]string{" 1:Music/Rock", " 2:Classical "})
+ Expect(err).ToNot(HaveOccurred())
+ Expect(targets).To(HaveLen(2))
+ Expect(targets[0].LibraryID).To(Equal(1))
+ Expect(targets[0].FolderPath).To(Equal("Music/Rock"))
+ Expect(targets[1].LibraryID).To(Equal(2))
+ Expect(targets[1].FolderPath).To(Equal("Classical"))
+ })
+
+ It("skips empty strings", func() {
+ targets, err := model.ParseTargets([]string{"1:Music/Rock", "", "2:Classical"})
+ Expect(err).ToNot(HaveOccurred())
+ Expect(targets).To(HaveLen(2))
+ })
+
+ It("handles paths with colons", func() {
+ targets, err := model.ParseTargets([]string{"1:C:/Music/Rock", "2:/path:with:colons"})
+ Expect(err).ToNot(HaveOccurred())
+ Expect(targets).To(HaveLen(2))
+ Expect(targets[0].FolderPath).To(Equal("C:/Music/Rock"))
+ Expect(targets[1].FolderPath).To(Equal("/path:with:colons"))
+ })
+
+ It("returns error for invalid format without colon", func() {
+ _, err := model.ParseTargets([]string{"1Music/Rock"})
+ Expect(err).To(HaveOccurred())
+ Expect(err.Error()).To(ContainSubstring("invalid target format"))
+ })
+
+ It("returns error for non-numeric library ID", func() {
+ _, err := model.ParseTargets([]string{"abc:Music/Rock"})
+ Expect(err).To(HaveOccurred())
+ Expect(err.Error()).To(ContainSubstring("invalid library ID"))
+ })
+
+ It("returns error for negative library ID", func() {
+ _, err := model.ParseTargets([]string{"-1:Music/Rock"})
+ Expect(err).To(HaveOccurred())
+ Expect(err.Error()).To(ContainSubstring("invalid library ID"))
+ })
+
+ It("returns error for zero library ID", func() {
+ _, err := model.ParseTargets([]string{"0:Music/Rock"})
+ Expect(err).To(HaveOccurred())
+ Expect(err.Error()).To(ContainSubstring("invalid library ID"))
+ })
+
+ It("returns error for empty input", func() {
+ _, err := model.ParseTargets([]string{})
+ Expect(err).To(HaveOccurred())
+ Expect(err.Error()).To(ContainSubstring("no valid targets found"))
+ })
+
+ It("returns error for all empty strings", func() {
+ _, err := model.ParseTargets([]string{"", " ", ""})
+ Expect(err).To(HaveOccurred())
+ Expect(err.Error()).To(ContainSubstring("no valid targets found"))
+ })
+})
diff --git a/model/scrobble.go b/model/scrobble.go
new file mode 100644
index 000000000..e1567abc3
--- /dev/null
+++ b/model/scrobble.go
@@ -0,0 +1,13 @@
+package model
+
+import "time"
+
+type Scrobble struct {
+ MediaFileID string
+ UserID string
+ SubmissionTime time.Time
+}
+
+type ScrobbleRepository interface {
+ RecordScrobble(mediaFileID string, submissionTime time.Time) error
+}
diff --git a/model/user.go b/model/user.go
index aabedc096..c590ba260 100644
--- a/model/user.go
+++ b/model/user.go
@@ -42,7 +42,9 @@ func (u User) HasLibraryAccess(libraryID int) bool {
type Users []User
type UserRepository interface {
+ ResourceRepository
CountAll(...QueryOptions) (int64, error)
+ Delete(id string) error
Get(id string) (*User, error)
Put(*User) error
UpdateLastLoginAt(id string) error
diff --git a/persistence/album_repository.go b/persistence/album_repository.go
index 6f9bb3b48..dab255784 100644
--- a/persistence/album_repository.go
+++ b/persistence/album_repository.go
@@ -106,6 +106,7 @@ func NewAlbumRepository(ctx context.Context, db dbx.Builder) model.AlbumReposito
"random": "random",
"recently_added": recentlyAddedSort(),
"starred_at": "starred, starred_at",
+ "rated_at": "rating, rated_at",
})
return r
}
@@ -337,8 +338,12 @@ on conflict (user_id, item_id, item_type) do update
return r.executeSQL(query)
}
-func (r *albumRepository) purgeEmpty() error {
+func (r *albumRepository) purgeEmpty(libraryIDs ...int) error {
del := Delete(r.tableName).Where("id not in (select distinct(album_id) from media_file)")
+ // If libraryIDs are specified, only purge albums from those libraries
+ if len(libraryIDs) > 0 {
+ del = del.Where(Eq{"library_id": libraryIDs})
+ }
c, err := r.executeSQL(del)
if err != nil {
return fmt.Errorf("purging empty albums: %w", err)
diff --git a/persistence/album_repository_test.go b/persistence/album_repository_test.go
index 4be89bcb8..a062b4398 100644
--- a/persistence/album_repository_test.go
+++ b/persistence/album_repository_test.go
@@ -55,6 +55,7 @@ var _ = Describe("AlbumRepository", func() {
It("returns all records sorted", func() {
Expect(GetAll(model.QueryOptions{Sort: "name"})).To(Equal(model.Albums{
albumAbbeyRoad,
+ albumMultiDisc,
albumRadioactivity,
albumSgtPeppers,
}))
@@ -64,6 +65,7 @@ var _ = Describe("AlbumRepository", func() {
Expect(GetAll(model.QueryOptions{Sort: "name", Order: "desc"})).To(Equal(model.Albums{
albumSgtPeppers,
albumRadioactivity,
+ albumMultiDisc,
albumAbbeyRoad,
}))
})
diff --git a/persistence/artist_repository.go b/persistence/artist_repository.go
index a7cf9272a..c9e38a1ee 100644
--- a/persistence/artist_repository.go
+++ b/persistence/artist_repository.go
@@ -141,6 +141,7 @@ func NewArtistRepository(ctx context.Context, db dbx.Builder) model.ArtistReposi
r.setSortMappings(map[string]string{
"name": "order_artist_name",
"starred_at": "starred, starred_at",
+ "rated_at": "rating, rated_at",
"song_count": "stats->>'total'->>'m'",
"album_count": "stats->>'total'->>'a'",
"size": "stats->>'total'->>'s'",
@@ -400,23 +401,16 @@ func (r *artistRepository) RefreshStats(allArtists bool) (int64, error) {
// This now calculates per-library statistics and stores them in library_artist.stats
batchUpdateStatsSQL := `
WITH artist_role_counters AS (
- SELECT jt.atom AS artist_id,
+ SELECT mfa.artist_id,
mf.library_id,
- substr(
- replace(jt.path, '$.', ''),
- 1,
- CASE WHEN instr(replace(jt.path, '$.', ''), '[') > 0
- THEN instr(replace(jt.path, '$.', ''), '[') - 1
- ELSE length(replace(jt.path, '$.', ''))
- END
- ) AS role,
+ mfa.role,
count(DISTINCT mf.album_id) AS album_count,
- count(mf.id) AS count,
+ count(DISTINCT mf.id) AS count,
sum(mf.size) AS size
- FROM media_file mf
- JOIN json_tree(mf.participants) jt ON jt.key = 'id' AND jt.atom IS NOT NULL
- WHERE jt.atom IN (ROLE_IDS_PLACEHOLDER) -- Will replace with actual placeholders
- GROUP BY jt.atom, mf.library_id, role
+ FROM media_file_artists mfa
+ JOIN media_file mf ON mfa.media_file_id = mf.id
+ WHERE mfa.artist_id IN (ROLE_IDS_PLACEHOLDER) -- Will replace with actual placeholders
+ GROUP BY mfa.artist_id, mf.library_id, mfa.role
),
artist_total_counters AS (
SELECT mfa.artist_id,
@@ -445,24 +439,24 @@ func (r *artistRepository) RefreshStats(allArtists bool) (int64, error) {
),
combined_counters AS (
SELECT artist_id, library_id, role, album_count, count, size FROM artist_role_counters
- UNION
+ UNION ALL
SELECT artist_id, library_id, role, album_count, count, size FROM artist_total_counters
- UNION
+ UNION ALL
SELECT artist_id, library_id, role, album_count, count, size FROM artist_participant_counter
),
library_artist_counters AS (
SELECT artist_id,
library_id,
json_group_object(
- replace(role, '"', ''),
+ role,
json_object('a', album_count, 'm', count, 's', size)
) AS counters
FROM combined_counters
GROUP BY artist_id, library_id
)
UPDATE library_artist
- SET stats = coalesce((SELECT counters FROM library_artist_counters lac
- WHERE lac.artist_id = library_artist.artist_id
+ SET stats = coalesce((SELECT counters FROM library_artist_counters lac
+ WHERE lac.artist_id = library_artist.artist_id
AND lac.library_id = library_artist.library_id), '{}')
WHERE library_artist.artist_id IN (ROLE_IDS_PLACEHOLDER);` // Will replace with actual placeholders
diff --git a/persistence/folder_repository.go b/persistence/folder_repository.go
index 96a9bae82..a586746a0 100644
--- a/persistence/folder_repository.go
+++ b/persistence/folder_repository.go
@@ -4,7 +4,10 @@ import (
"context"
"encoding/json"
"fmt"
+ "os"
+ "path/filepath"
"slices"
+ "strings"
"time"
. "github.com/Masterminds/squirrel"
@@ -91,8 +94,47 @@ func (r folderRepository) CountAll(opt ...model.QueryOptions) (int64, error) {
return r.count(query)
}
-func (r folderRepository) GetLastUpdates(lib model.Library) (map[string]model.FolderUpdateInfo, error) {
- sq := r.newSelect().Columns("id", "updated_at", "hash").Where(Eq{"library_id": lib.ID, "missing": false})
+func (r folderRepository) GetFolderUpdateInfo(lib model.Library, targetPaths ...string) (map[string]model.FolderUpdateInfo, error) {
+ where := And{
+ Eq{"library_id": lib.ID},
+ Eq{"missing": false},
+ }
+
+ // If specific paths are requested, include those folders and all their descendants
+ if len(targetPaths) > 0 {
+ // Collect folder IDs for exact target folders and path conditions for descendants
+ folderIDs := make([]string, 0, len(targetPaths))
+ pathConditions := make(Or, 0, len(targetPaths)*2)
+
+ for _, targetPath := range targetPaths {
+ if targetPath == "" || targetPath == "." {
+ // Root path - include everything in this library
+ pathConditions = Or{}
+ folderIDs = nil
+ break
+ }
+ // Clean the path to normalize it. Paths stored in the folder table do not have leading/trailing slashes.
+ cleanPath := strings.TrimPrefix(targetPath, string(os.PathSeparator))
+ cleanPath = filepath.Clean(cleanPath)
+
+ // Include the target folder itself by ID
+ folderIDs = append(folderIDs, model.FolderID(lib, cleanPath))
+
+ // Include all descendants: folders whose path field equals or starts with the target path
+ // Note: Folder.Path is the directory path, so children have path = targetPath
+ pathConditions = append(pathConditions, Eq{"path": cleanPath})
+ pathConditions = append(pathConditions, Like{"path": cleanPath + "/%"})
+ }
+
+ // Combine conditions: exact folder IDs OR descendant path patterns
+ if len(folderIDs) > 0 {
+ where = append(where, Or{Eq{"id": folderIDs}, pathConditions})
+ } else if len(pathConditions) > 0 {
+ where = append(where, pathConditions)
+ }
+ }
+
+ sq := r.newSelect().Columns("id", "updated_at", "hash").Where(where)
var res []struct {
ID string
UpdatedAt time.Time
@@ -149,7 +191,7 @@ func (r folderRepository) GetTouchedWithPlaylists() (model.FolderCursor, error)
}, nil
}
-func (r folderRepository) purgeEmpty() error {
+func (r folderRepository) purgeEmpty(libraryIDs ...int) error {
sq := Delete(r.tableName).Where(And{
Eq{"num_audio_files": 0},
Eq{"num_playlists": 0},
@@ -157,6 +199,10 @@ func (r folderRepository) purgeEmpty() error {
ConcatExpr("id not in (select parent_id from folder)"),
ConcatExpr("id not in (select folder_id from media_file)"),
})
+ // If libraryIDs are specified, only purge folders from those libraries
+ if len(libraryIDs) > 0 {
+ sq = sq.Where(Eq{"library_id": libraryIDs})
+ }
c, err := r.executeSQL(sq)
if err != nil {
return fmt.Errorf("purging empty folders: %w", err)
diff --git a/persistence/folder_repository_test.go b/persistence/folder_repository_test.go
new file mode 100644
index 000000000..6c24741c9
--- /dev/null
+++ b/persistence/folder_repository_test.go
@@ -0,0 +1,213 @@
+package persistence
+
+import (
+ "context"
+ "fmt"
+
+ "github.com/navidrome/navidrome/log"
+ "github.com/navidrome/navidrome/model"
+ "github.com/navidrome/navidrome/model/request"
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+ "github.com/pocketbase/dbx"
+)
+
+var _ = Describe("FolderRepository", func() {
+ var repo model.FolderRepository
+ var ctx context.Context
+ var conn *dbx.DB
+ var testLib, otherLib model.Library
+
+ BeforeEach(func() {
+ ctx = request.WithUser(log.NewContext(context.TODO()), model.User{ID: "userid"})
+ conn = GetDBXBuilder()
+ repo = newFolderRepository(ctx, conn)
+
+ // Use existing library ID 1 from test fixtures
+ libRepo := NewLibraryRepository(ctx, conn)
+ lib, err := libRepo.Get(1)
+ Expect(err).ToNot(HaveOccurred())
+ testLib = *lib
+
+ // Create a second library with its own folder to verify isolation
+ otherLib = model.Library{Name: "Other Library", Path: "/other/path"}
+ Expect(libRepo.Put(&otherLib)).To(Succeed())
+ })
+
+ AfterEach(func() {
+ // Clean up only test folders created by our tests (paths starting with "Test")
+ // This prevents interference with fixture data needed by other tests
+ _, _ = conn.NewQuery("DELETE FROM folder WHERE library_id = 1 AND path LIKE 'Test%'").Execute()
+ _, _ = conn.NewQuery(fmt.Sprintf("DELETE FROM library WHERE id = %d", otherLib.ID)).Execute()
+ })
+
+ Describe("GetFolderUpdateInfo", func() {
+ Context("with no target paths", func() {
+ It("returns all folders in the library", func() {
+ // Create test folders with unique names to avoid conflicts
+ folder1 := model.NewFolder(testLib, "TestGetLastUpdates/Folder1")
+ folder2 := model.NewFolder(testLib, "TestGetLastUpdates/Folder2")
+
+ err := repo.Put(folder1)
+ Expect(err).ToNot(HaveOccurred())
+ err = repo.Put(folder2)
+ Expect(err).ToNot(HaveOccurred())
+
+ otherFolder := model.NewFolder(otherLib, "TestOtherLib/Folder")
+ err = repo.Put(otherFolder)
+ Expect(err).ToNot(HaveOccurred())
+
+ // Query all folders (no target paths) - should only return folders from testLib
+ results, err := repo.GetFolderUpdateInfo(testLib)
+ Expect(err).ToNot(HaveOccurred())
+ // Should include folders from testLib
+ Expect(results).To(HaveKey(folder1.ID))
+ Expect(results).To(HaveKey(folder2.ID))
+ // Should NOT include folders from other library
+ Expect(results).ToNot(HaveKey(otherFolder.ID))
+ })
+ })
+
+ Context("with specific target paths", func() {
+ It("returns folder info for existing folders", func() {
+ // Create test folders with unique names
+ folder1 := model.NewFolder(testLib, "TestSpecific/Rock")
+ folder2 := model.NewFolder(testLib, "TestSpecific/Jazz")
+ folder3 := model.NewFolder(testLib, "TestSpecific/Classical")
+
+ err := repo.Put(folder1)
+ Expect(err).ToNot(HaveOccurred())
+ err = repo.Put(folder2)
+ Expect(err).ToNot(HaveOccurred())
+ err = repo.Put(folder3)
+ Expect(err).ToNot(HaveOccurred())
+
+ // Query specific paths
+ results, err := repo.GetFolderUpdateInfo(testLib, "TestSpecific/Rock", "TestSpecific/Classical")
+ Expect(err).ToNot(HaveOccurred())
+ Expect(results).To(HaveLen(2))
+
+ // Verify folder IDs are in results
+ Expect(results).To(HaveKey(folder1.ID))
+ Expect(results).To(HaveKey(folder3.ID))
+ Expect(results).ToNot(HaveKey(folder2.ID))
+
+ // Verify update info is populated
+ Expect(results[folder1.ID].UpdatedAt).ToNot(BeZero())
+ Expect(results[folder1.ID].Hash).To(Equal(folder1.Hash))
+ })
+
+ It("includes all child folders when querying parent", func() {
+ // Create a parent folder with multiple children
+ parent := model.NewFolder(testLib, "TestParent/Music")
+ child1 := model.NewFolder(testLib, "TestParent/Music/Rock/Queen")
+ child2 := model.NewFolder(testLib, "TestParent/Music/Jazz")
+ otherParent := model.NewFolder(testLib, "TestParent2/Music/Jazz")
+
+ Expect(repo.Put(parent)).To(Succeed())
+ Expect(repo.Put(child1)).To(Succeed())
+ Expect(repo.Put(child2)).To(Succeed())
+
+ // Query the parent folder - should return parent and all children
+ results, err := repo.GetFolderUpdateInfo(testLib, "TestParent/Music")
+ Expect(err).ToNot(HaveOccurred())
+ Expect(results).To(HaveLen(3))
+ Expect(results).To(HaveKey(parent.ID))
+ Expect(results).To(HaveKey(child1.ID))
+ Expect(results).To(HaveKey(child2.ID))
+ Expect(results).ToNot(HaveKey(otherParent.ID))
+ })
+
+ It("excludes children from other libraries", func() {
+ // Create parent in testLib
+ parent := model.NewFolder(testLib, "TestIsolation/Parent")
+ child := model.NewFolder(testLib, "TestIsolation/Parent/Child")
+
+ Expect(repo.Put(parent)).To(Succeed())
+ Expect(repo.Put(child)).To(Succeed())
+
+ // Create similar path in other library
+ otherParent := model.NewFolder(otherLib, "TestIsolation/Parent")
+ otherChild := model.NewFolder(otherLib, "TestIsolation/Parent/Child")
+
+ Expect(repo.Put(otherParent)).To(Succeed())
+ Expect(repo.Put(otherChild)).To(Succeed())
+
+ // Query should only return folders from testLib
+ results, err := repo.GetFolderUpdateInfo(testLib, "TestIsolation/Parent")
+ Expect(err).ToNot(HaveOccurred())
+ Expect(results).To(HaveLen(2))
+ Expect(results).To(HaveKey(parent.ID))
+ Expect(results).To(HaveKey(child.ID))
+ Expect(results).ToNot(HaveKey(otherParent.ID))
+ Expect(results).ToNot(HaveKey(otherChild.ID))
+ })
+
+ It("excludes missing children when querying parent", func() {
+ // Create parent and children, mark one as missing
+ parent := model.NewFolder(testLib, "TestMissingChild/Parent")
+ child1 := model.NewFolder(testLib, "TestMissingChild/Parent/Child1")
+ child2 := model.NewFolder(testLib, "TestMissingChild/Parent/Child2")
+ child2.Missing = true
+
+ Expect(repo.Put(parent)).To(Succeed())
+ Expect(repo.Put(child1)).To(Succeed())
+ Expect(repo.Put(child2)).To(Succeed())
+
+ // Query parent - should only return parent and non-missing child
+ results, err := repo.GetFolderUpdateInfo(testLib, "TestMissingChild/Parent")
+ Expect(err).ToNot(HaveOccurred())
+ Expect(results).To(HaveLen(2))
+ Expect(results).To(HaveKey(parent.ID))
+ Expect(results).To(HaveKey(child1.ID))
+ Expect(results).ToNot(HaveKey(child2.ID))
+ })
+
+ It("handles mix of existing and non-existing target paths", func() {
+ // Create folders for one path but not the other
+ existingParent := model.NewFolder(testLib, "TestMixed/Exists")
+ existingChild := model.NewFolder(testLib, "TestMixed/Exists/Child")
+
+ Expect(repo.Put(existingParent)).To(Succeed())
+ Expect(repo.Put(existingChild)).To(Succeed())
+
+ // Query both existing and non-existing paths
+ results, err := repo.GetFolderUpdateInfo(testLib, "TestMixed/Exists", "TestMixed/DoesNotExist")
+ Expect(err).ToNot(HaveOccurred())
+ Expect(results).To(HaveLen(2))
+ Expect(results).To(HaveKey(existingParent.ID))
+ Expect(results).To(HaveKey(existingChild.ID))
+ })
+
+ It("handles empty folder path as root", func() {
+ // Test querying for root folder without creating it (fixtures should have one)
+ rootFolderID := model.FolderID(testLib, ".")
+
+ results, err := repo.GetFolderUpdateInfo(testLib, "")
+ Expect(err).ToNot(HaveOccurred())
+ // Should return the root folder if it exists
+ if len(results) > 0 {
+ Expect(results).To(HaveKey(rootFolderID))
+ }
+ })
+
+ It("returns empty map for non-existent folders", func() {
+ results, err := repo.GetFolderUpdateInfo(testLib, "NonExistent/Path")
+ Expect(err).ToNot(HaveOccurred())
+ Expect(results).To(BeEmpty())
+ })
+
+ It("skips missing folders", func() {
+ // Create a folder and mark it as missing
+ folder := model.NewFolder(testLib, "TestMissing/Folder")
+ folder.Missing = true
+ err := repo.Put(folder)
+ Expect(err).ToNot(HaveOccurred())
+
+ results, err := repo.GetFolderUpdateInfo(testLib, "TestMissing/Folder")
+ Expect(err).ToNot(HaveOccurred())
+ Expect(results).To(BeEmpty())
+ })
+ })
+ })
+})
diff --git a/persistence/library_repository.go b/persistence/library_repository.go
index 314b682bb..9349f3c4c 100644
--- a/persistence/library_repository.go
+++ b/persistence/library_repository.go
@@ -177,7 +177,11 @@ func (r *libraryRepository) ScanEnd(id int) error {
return err
}
// https://www.sqlite.org/pragma.html#pragma_optimize
- _, err = r.executeSQL(Expr("PRAGMA optimize=0x10012;"))
+ // Use mask 0x10000 to check table sizes without running ANALYZE
+ // Running ANALYZE can cause query planner issues with expression-based collation indexes
+ if conf.Server.DevOptimizeDB {
+ _, err = r.executeSQL(Expr("PRAGMA optimize=0x10000;"))
+ }
return err
}
diff --git a/persistence/library_repository_test.go b/persistence/library_repository_test.go
index 6f4df1beb..3e3972bdb 100644
--- a/persistence/library_repository_test.go
+++ b/persistence/library_repository_test.go
@@ -142,4 +142,62 @@ var _ = Describe("LibraryRepository", func() {
Expect(libAfter.TotalSize).To(Equal(sizeRes.Sum))
Expect(libAfter.TotalDuration).To(Equal(durationRes.Sum))
})
+
+ Describe("ScanBegin and ScanEnd", func() {
+ var lib *model.Library
+
+ BeforeEach(func() {
+ lib = &model.Library{
+ ID: 0,
+ Name: "Test Scan Library",
+ Path: "/music/test-scan",
+ }
+ err := repo.Put(lib)
+ Expect(err).ToNot(HaveOccurred())
+ })
+
+ DescribeTable("ScanBegin",
+ func(fullScan bool, expectedFullScanInProgress bool) {
+ err := repo.ScanBegin(lib.ID, fullScan)
+ Expect(err).ToNot(HaveOccurred())
+
+ updatedLib, err := repo.Get(lib.ID)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(updatedLib.LastScanStartedAt).ToNot(BeZero())
+ Expect(updatedLib.FullScanInProgress).To(Equal(expectedFullScanInProgress))
+ },
+ Entry("sets FullScanInProgress to true for full scan", true, true),
+ Entry("sets FullScanInProgress to false for quick scan", false, false),
+ )
+
+ Context("ScanEnd", func() {
+ BeforeEach(func() {
+ err := repo.ScanBegin(lib.ID, true)
+ Expect(err).ToNot(HaveOccurred())
+ })
+
+ It("sets LastScanAt and clears FullScanInProgress and LastScanStartedAt", func() {
+ err := repo.ScanEnd(lib.ID)
+ Expect(err).ToNot(HaveOccurred())
+
+ updatedLib, err := repo.Get(lib.ID)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(updatedLib.LastScanAt).ToNot(BeZero())
+ Expect(updatedLib.FullScanInProgress).To(BeFalse())
+ Expect(updatedLib.LastScanStartedAt).To(BeZero())
+ })
+
+ It("sets LastScanAt to be after LastScanStartedAt", func() {
+ libBefore, err := repo.Get(lib.ID)
+ Expect(err).ToNot(HaveOccurred())
+
+ err = repo.ScanEnd(lib.ID)
+ Expect(err).ToNot(HaveOccurred())
+
+ libAfter, err := repo.Get(lib.ID)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(libAfter.LastScanAt).To(BeTemporally(">=", libBefore.LastScanStartedAt))
+ })
+ })
+ })
})
diff --git a/persistence/mediafile_repository.go b/persistence/mediafile_repository.go
index e7883947a..4749bb0be 100644
--- a/persistence/mediafile_repository.go
+++ b/persistence/mediafile_repository.go
@@ -4,6 +4,8 @@ import (
"context"
"fmt"
"slices"
+ "strconv"
+ "strings"
"sync"
"time"
@@ -84,6 +86,7 @@ func NewMediaFileRepository(ctx context.Context, db dbx.Builder) model.MediaFile
"created_at": "media_file.created_at",
"recently_added": mediaFileRecentlyAddedSort(),
"starred_at": "starred, starred_at",
+ "rated_at": "rating, rated_at",
})
return r
}
@@ -192,12 +195,43 @@ func (r *mediaFileRepository) GetCursor(options ...model.QueryOptions) (model.Me
}, nil
}
+// FindByPaths finds media files by their paths.
+// The paths can be library-qualified (format: "libraryID:path") or unqualified ("path").
+// Library-qualified paths search within the specified library, while unqualified paths
+// search across all libraries for backward compatibility.
func (r *mediaFileRepository) FindByPaths(paths []string) (model.MediaFiles, error) {
- sel := r.newSelect().Columns("*").Where(Eq{"path collate nocase": paths})
+ query := Or{}
+
+ for _, path := range paths {
+ parts := strings.SplitN(path, ":", 2)
+ if len(parts) == 2 {
+ // Library-qualified path: "libraryID:path"
+ libraryID, err := strconv.Atoi(parts[0])
+ if err != nil {
+ // Invalid format, skip
+ continue
+ }
+ relativePath := parts[1]
+ query = append(query, And{
+ Eq{"path collate nocase": relativePath},
+ Eq{"library_id": libraryID},
+ })
+ } else {
+ // Unqualified path: search across all libraries
+ query = append(query, Eq{"path collate nocase": path})
+ }
+ }
+
+ if len(query) == 0 {
+ return model.MediaFiles{}, nil
+ }
+
+ sel := r.newSelect().Columns("*").Where(query)
var res dbMediaFiles
if err := r.queryAll(sel, &res); err != nil {
return nil, err
}
+
return res.toModels(), nil
}
diff --git a/persistence/mediafile_repository_test.go b/persistence/mediafile_repository_test.go
index 002b82499..ab926c00d 100644
--- a/persistence/mediafile_repository_test.go
+++ b/persistence/mediafile_repository_test.go
@@ -38,7 +38,7 @@ var _ = Describe("MediaRepository", func() {
})
It("counts the number of mediafiles in the DB", func() {
- Expect(mr.CountAll()).To(Equal(int64(6)))
+ Expect(mr.CountAll()).To(Equal(int64(10)))
})
It("returns songs ordered by lyrics with a specific title/artist", func() {
diff --git a/persistence/persistence.go b/persistence/persistence.go
index ac607f85f..9599de179 100644
--- a/persistence/persistence.go
+++ b/persistence/persistence.go
@@ -89,6 +89,10 @@ func (s *SQLStore) ScrobbleBuffer(ctx context.Context) model.ScrobbleBufferRepos
return NewScrobbleBufferRepository(ctx, s.getDBXBuilder())
}
+func (s *SQLStore) Scrobble(ctx context.Context) model.ScrobbleRepository {
+ return NewScrobbleRepository(ctx, s.getDBXBuilder())
+}
+
func (s *SQLStore) Resource(ctx context.Context, m interface{}) model.ResourceRepository {
switch m.(type) {
case model.User:
@@ -157,7 +161,7 @@ func (s *SQLStore) WithTxImmediate(block func(tx model.DataStore) error, scope .
}, scope...)
}
-func (s *SQLStore) GC(ctx context.Context) error {
+func (s *SQLStore) GC(ctx context.Context, libraryIDs ...int) error {
trace := func(ctx context.Context, msg string, f func() error) func() error {
return func() error {
start := time.Now()
@@ -167,11 +171,17 @@ func (s *SQLStore) GC(ctx context.Context) error {
}
}
+ // If libraryIDs are provided, scope operations to those libraries where possible
+ scoped := len(libraryIDs) > 0
+ if scoped {
+ log.Debug(ctx, "GC: Running selective garbage collection", "libraryIDs", libraryIDs)
+ }
+
err := run.Sequentially(
- trace(ctx, "purge empty albums", func() error { return s.Album(ctx).(*albumRepository).purgeEmpty() }),
+ trace(ctx, "purge empty albums", func() error { return s.Album(ctx).(*albumRepository).purgeEmpty(libraryIDs...) }),
trace(ctx, "purge empty artists", func() error { return s.Artist(ctx).(*artistRepository).purgeEmpty() }),
trace(ctx, "mark missing artists", func() error { return s.Artist(ctx).(*artistRepository).markMissing() }),
- trace(ctx, "purge empty folders", func() error { return s.Folder(ctx).(*folderRepository).purgeEmpty() }),
+ trace(ctx, "purge empty folders", func() error { return s.Folder(ctx).(*folderRepository).purgeEmpty(libraryIDs...) }),
trace(ctx, "clean album annotations", func() error { return s.Album(ctx).(*albumRepository).cleanAnnotations() }),
trace(ctx, "clean artist annotations", func() error { return s.Artist(ctx).(*artistRepository).cleanAnnotations() }),
trace(ctx, "clean media file annotations", func() error { return s.MediaFile(ctx).(*mediaFileRepository).cleanAnnotations() }),
diff --git a/persistence/persistence_suite_test.go b/persistence/persistence_suite_test.go
index 1007d84fe..f3cb4f3d0 100644
--- a/persistence/persistence_suite_test.go
+++ b/persistence/persistence_suite_test.go
@@ -69,10 +69,12 @@ var (
albumSgtPeppers = al(model.Album{ID: "101", Name: "Sgt Peppers", AlbumArtist: "The Beatles", OrderAlbumName: "sgt peppers", AlbumArtistID: "3", EmbedArtPath: p("/beatles/1/sgt/a day.mp3"), SongCount: 1, MaxYear: 1967})
albumAbbeyRoad = al(model.Album{ID: "102", Name: "Abbey Road", AlbumArtist: "The Beatles", OrderAlbumName: "abbey road", AlbumArtistID: "3", EmbedArtPath: p("/beatles/1/come together.mp3"), SongCount: 1, MaxYear: 1969})
albumRadioactivity = al(model.Album{ID: "103", Name: "Radioactivity", AlbumArtist: "Kraftwerk", OrderAlbumName: "radioactivity", AlbumArtistID: "2", EmbedArtPath: p("/kraft/radio/radio.mp3"), SongCount: 2})
+ albumMultiDisc = al(model.Album{ID: "104", Name: "Multi Disc Album", AlbumArtist: "Test Artist", OrderAlbumName: "multi disc album", AlbumArtistID: "1", EmbedArtPath: p("/test/multi/disc1/track1.mp3"), SongCount: 4})
testAlbums = model.Albums{
albumSgtPeppers,
albumAbbeyRoad,
albumRadioactivity,
+ albumMultiDisc,
}
)
@@ -94,13 +96,22 @@ var (
Lyrics: `[{"lang":"xxx","line":[{"value":"This is a set of lyrics"}],"synced":false}]`,
})
songAntenna2 = mf(model.MediaFile{ID: "1006", Title: "Antenna", ArtistID: "2", Artist: "Kraftwerk", AlbumID: "103"})
- testSongs = model.MediaFiles{
+ // Multi-disc album tracks (intentionally out of order to test sorting)
+ songDisc2Track11 = mf(model.MediaFile{ID: "2001", Title: "Disc 2 Track 11", ArtistID: "1", Artist: "Test Artist", AlbumID: "104", Album: "Multi Disc Album", DiscNumber: 2, TrackNumber: 11, Path: p("/test/multi/disc2/track11.mp3"), OrderAlbumName: "multi disc album", OrderArtistName: "test artist"})
+ songDisc1Track01 = mf(model.MediaFile{ID: "2002", Title: "Disc 1 Track 1", ArtistID: "1", Artist: "Test Artist", AlbumID: "104", Album: "Multi Disc Album", DiscNumber: 1, TrackNumber: 1, Path: p("/test/multi/disc1/track1.mp3"), OrderAlbumName: "multi disc album", OrderArtistName: "test artist"})
+ songDisc2Track01 = mf(model.MediaFile{ID: "2003", Title: "Disc 2 Track 1", ArtistID: "1", Artist: "Test Artist", AlbumID: "104", Album: "Multi Disc Album", DiscNumber: 2, TrackNumber: 1, Path: p("/test/multi/disc2/track1.mp3"), OrderAlbumName: "multi disc album", OrderArtistName: "test artist"})
+ songDisc1Track02 = mf(model.MediaFile{ID: "2004", Title: "Disc 1 Track 2", ArtistID: "1", Artist: "Test Artist", AlbumID: "104", Album: "Multi Disc Album", DiscNumber: 1, TrackNumber: 2, Path: p("/test/multi/disc1/track2.mp3"), OrderAlbumName: "multi disc album", OrderArtistName: "test artist"})
+ testSongs = model.MediaFiles{
songDayInALife,
songComeTogether,
songRadioactivity,
songAntenna,
songAntennaWithLyrics,
songAntenna2,
+ songDisc2Track11,
+ songDisc1Track01,
+ songDisc2Track01,
+ songDisc1Track02,
}
)
diff --git a/persistence/playlist_repository.go b/persistence/playlist_repository.go
index 046284e1f..3fdd19af2 100644
--- a/persistence/playlist_repository.go
+++ b/persistence/playlist_repository.go
@@ -264,6 +264,11 @@ func (r *playlistRepository) refreshSmartPlaylist(pls *model.Playlist) bool {
"annotation.item_id = media_file.id" +
" AND annotation.item_type = 'media_file'" +
" AND annotation.user_id = '" + usr.ID + "')")
+
+ // Only include media files from libraries the user has access to
+ sq = r.applyLibraryFilter(sq, "media_file")
+
+ // Apply the criteria rules
sq = r.addCriteria(sq, rules)
insSql := Insert("playlist_tracks").Columns("id", "playlist_id", "media_file_id").Select(sq)
_, err = r.executeSQL(insSql)
@@ -388,6 +393,7 @@ func (r *playlistRepository) loadTracks(sel SelectBuilder, id string) (model.Pla
"coalesce(play_count, 0) as play_count",
"play_date",
"coalesce(rating, 0) as rating",
+ "rated_at",
"f.*",
"playlist_tracks.*",
"library.path as library_path",
diff --git a/persistence/playlist_repository_test.go b/persistence/playlist_repository_test.go
index 15ae438d9..05a36352f 100644
--- a/persistence/playlist_repository_test.go
+++ b/persistence/playlist_repository_test.go
@@ -1,7 +1,6 @@
package persistence
import (
- "context"
"time"
"github.com/navidrome/navidrome/conf"
@@ -11,13 +10,14 @@ import (
"github.com/navidrome/navidrome/model/request"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
+ "github.com/pocketbase/dbx"
)
var _ = Describe("PlaylistRepository", func() {
var repo model.PlaylistRepository
BeforeEach(func() {
- ctx := log.NewContext(context.TODO())
+ ctx := log.NewContext(GinkgoT().Context())
ctx = request.WithUser(ctx, model.User{ID: "userid", UserName: "userid", IsAdmin: true})
repo = NewPlaylistRepository(ctx, GetDBXBuilder())
})
@@ -219,4 +219,283 @@ var _ = Describe("PlaylistRepository", func() {
})
})
})
+
+ Describe("Playlist Track Sorting", func() {
+ var testPlaylistID string
+
+ AfterEach(func() {
+ if testPlaylistID != "" {
+ Expect(repo.Delete(testPlaylistID)).To(BeNil())
+ testPlaylistID = ""
+ }
+ })
+
+ It("sorts tracks correctly by album (disc and track number)", func() {
+ By("creating a playlist with multi-disc album tracks in arbitrary order")
+ newPls := model.Playlist{Name: "Multi-Disc Test", OwnerID: "userid"}
+ // Add tracks in intentionally scrambled order
+ newPls.AddMediaFilesByID([]string{"2001", "2002", "2003", "2004"})
+ Expect(repo.Put(&newPls)).To(Succeed())
+ testPlaylistID = newPls.ID
+
+ By("retrieving tracks sorted by album")
+ tracksRepo := repo.Tracks(newPls.ID, false)
+ tracks, err := tracksRepo.GetAll(model.QueryOptions{Sort: "album", Order: "asc"})
+ Expect(err).ToNot(HaveOccurred())
+
+ By("verifying tracks are sorted by disc number then track number")
+ Expect(tracks).To(HaveLen(4))
+ // Expected order: Disc 1 Track 1, Disc 1 Track 2, Disc 2 Track 1, Disc 2 Track 11
+ Expect(tracks[0].MediaFileID).To(Equal("2002")) // Disc 1, Track 1
+ Expect(tracks[1].MediaFileID).To(Equal("2004")) // Disc 1, Track 2
+ Expect(tracks[2].MediaFileID).To(Equal("2003")) // Disc 2, Track 1
+ Expect(tracks[3].MediaFileID).To(Equal("2001")) // Disc 2, Track 11
+ })
+ })
+
+ Describe("Smart Playlists with Tag Criteria", func() {
+ var mfRepo model.MediaFileRepository
+ var testPlaylistID string
+ var songWithGrouping, songWithoutGrouping model.MediaFile
+
+ BeforeEach(func() {
+ ctx := log.NewContext(GinkgoT().Context())
+ ctx = request.WithUser(ctx, model.User{ID: "userid", UserName: "userid", IsAdmin: true})
+ mfRepo = NewMediaFileRepository(ctx, GetDBXBuilder())
+
+ // Register 'grouping' as a valid tag for smart playlists
+ criteria.AddTagNames([]string{"grouping"})
+
+ // Create a song with the grouping tag
+ songWithGrouping = model.MediaFile{
+ ID: "test-grouping-1",
+ Title: "Song With Grouping",
+ Artist: "Test Artist",
+ ArtistID: "1",
+ Album: "Test Album",
+ AlbumID: "101",
+ Path: "/test/grouping/song1.mp3",
+ Tags: model.Tags{
+ "grouping": []string{"My Crate"},
+ },
+ Participants: model.Participants{},
+ LibraryID: 1,
+ Lyrics: "[]",
+ }
+ Expect(mfRepo.Put(&songWithGrouping)).To(Succeed())
+
+ // Create a song without the grouping tag
+ songWithoutGrouping = model.MediaFile{
+ ID: "test-grouping-2",
+ Title: "Song Without Grouping",
+ Artist: "Test Artist",
+ ArtistID: "1",
+ Album: "Test Album",
+ AlbumID: "101",
+ Path: "/test/grouping/song2.mp3",
+ Tags: model.Tags{},
+ Participants: model.Participants{},
+ LibraryID: 1,
+ Lyrics: "[]",
+ }
+ Expect(mfRepo.Put(&songWithoutGrouping)).To(Succeed())
+ })
+
+ AfterEach(func() {
+ if testPlaylistID != "" {
+ _ = repo.Delete(testPlaylistID)
+ testPlaylistID = ""
+ }
+ // Clean up test media files
+ _, _ = GetDBXBuilder().Delete("media_file", dbx.HashExp{"id": "test-grouping-1"}).Execute()
+ _, _ = GetDBXBuilder().Delete("media_file", dbx.HashExp{"id": "test-grouping-2"}).Execute()
+ })
+
+ It("matches tracks with a tag value using 'contains' with empty string (issue #4728 workaround)", func() {
+ By("creating a smart playlist that checks if grouping tag has any value")
+ // This is the workaround for issue #4728: using 'contains' with empty string
+ // generates SQL: value LIKE '%%' which matches any non-empty string
+ rules := &criteria.Criteria{
+ Expression: criteria.All{
+ criteria.Contains{"grouping": ""},
+ },
+ }
+ newPls := model.Playlist{Name: "Tracks with Grouping", OwnerID: "userid", Rules: rules}
+ Expect(repo.Put(&newPls)).To(Succeed())
+ testPlaylistID = newPls.ID
+
+ By("refreshing the smart playlist")
+ conf.Server.SmartPlaylistRefreshDelay = -1 * time.Second // Force refresh
+ pls, err := repo.GetWithTracks(newPls.ID, true, false)
+ Expect(err).ToNot(HaveOccurred())
+
+ By("verifying only the track with grouping tag is matched")
+ Expect(pls.Tracks).To(HaveLen(1))
+ Expect(pls.Tracks[0].MediaFileID).To(Equal(songWithGrouping.ID))
+ })
+
+ It("excludes tracks with a tag value using 'notContains' with empty string", func() {
+ By("creating a smart playlist that checks if grouping tag is NOT set")
+ rules := &criteria.Criteria{
+ Expression: criteria.All{
+ criteria.NotContains{"grouping": ""},
+ },
+ }
+ newPls := model.Playlist{Name: "Tracks without Grouping", OwnerID: "userid", Rules: rules}
+ Expect(repo.Put(&newPls)).To(Succeed())
+ testPlaylistID = newPls.ID
+
+ By("refreshing the smart playlist")
+ conf.Server.SmartPlaylistRefreshDelay = -1 * time.Second // Force refresh
+ pls, err := repo.GetWithTracks(newPls.ID, true, false)
+ Expect(err).ToNot(HaveOccurred())
+
+ By("verifying the track with grouping is NOT in the playlist")
+ for _, track := range pls.Tracks {
+ Expect(track.MediaFileID).ToNot(Equal(songWithGrouping.ID))
+ }
+
+ By("verifying the track without grouping IS in the playlist")
+ var foundWithoutGrouping bool
+ for _, track := range pls.Tracks {
+ if track.MediaFileID == songWithoutGrouping.ID {
+ foundWithoutGrouping = true
+ break
+ }
+ }
+ Expect(foundWithoutGrouping).To(BeTrue())
+ })
+ })
+
+ Describe("Smart Playlists Library Filtering", func() {
+ var mfRepo model.MediaFileRepository
+ var testPlaylistID string
+ var lib2ID int
+ var restrictedUserID string
+ var uniqueLibPath string
+
+ BeforeEach(func() {
+ db := GetDBXBuilder()
+
+ // Generate unique IDs for this test run
+ uniqueSuffix := time.Now().Format("20060102150405.000")
+ restrictedUserID = "restricted-user-" + uniqueSuffix
+ uniqueLibPath = "/music/lib2-" + uniqueSuffix
+
+ // Create a second library with unique name and path to avoid conflicts with other tests
+ _, err := db.DB().Exec("INSERT INTO library (name, path, created_at, updated_at) VALUES (?, ?, datetime('now'), datetime('now'))", "Library 2-"+uniqueSuffix, uniqueLibPath)
+ Expect(err).ToNot(HaveOccurred())
+ err = db.DB().QueryRow("SELECT last_insert_rowid()").Scan(&lib2ID)
+ Expect(err).ToNot(HaveOccurred())
+
+ // Create a restricted user with access only to library 1
+ _, err = db.DB().Exec("INSERT INTO user (id, user_name, name, is_admin, password, created_at, updated_at) VALUES (?, ?, 'Restricted User', false, 'pass', datetime('now'), datetime('now'))", restrictedUserID, restrictedUserID)
+ Expect(err).ToNot(HaveOccurred())
+ _, err = db.DB().Exec("INSERT INTO user_library (user_id, library_id) VALUES (?, 1)", restrictedUserID)
+ Expect(err).ToNot(HaveOccurred())
+
+ // Create test media files in each library
+ ctx := log.NewContext(GinkgoT().Context())
+ ctx = request.WithUser(ctx, model.User{ID: "userid", UserName: "userid", IsAdmin: true})
+ mfRepo = NewMediaFileRepository(ctx, db)
+
+ // Song in library 1 (accessible by restricted user)
+ songLib1 := model.MediaFile{
+ ID: "lib1-song",
+ Title: "Song in Lib1",
+ Artist: "Test Artist",
+ ArtistID: "1",
+ Album: "Test Album",
+ AlbumID: "101",
+ Path: "/music/lib1/song.mp3",
+ LibraryID: 1,
+ Participants: model.Participants{},
+ Tags: model.Tags{},
+ Lyrics: "[]",
+ }
+ Expect(mfRepo.Put(&songLib1)).To(Succeed())
+
+ // Song in library 2 (NOT accessible by restricted user)
+ songLib2 := model.MediaFile{
+ ID: "lib2-song",
+ Title: "Song in Lib2",
+ Artist: "Test Artist",
+ ArtistID: "1",
+ Album: "Test Album",
+ AlbumID: "101",
+ Path: uniqueLibPath + "/song.mp3",
+ LibraryID: lib2ID,
+ Participants: model.Participants{},
+ Tags: model.Tags{},
+ Lyrics: "[]",
+ }
+ Expect(mfRepo.Put(&songLib2)).To(Succeed())
+ })
+
+ AfterEach(func() {
+ db := GetDBXBuilder()
+ if testPlaylistID != "" {
+ _ = repo.Delete(testPlaylistID)
+ testPlaylistID = ""
+ }
+ // Clean up test data
+ _, _ = db.Delete("media_file", dbx.HashExp{"id": "lib1-song"}).Execute()
+ _, _ = db.Delete("media_file", dbx.HashExp{"id": "lib2-song"}).Execute()
+ _, _ = db.Delete("user_library", dbx.HashExp{"user_id": restrictedUserID}).Execute()
+ _, _ = db.Delete("user", dbx.HashExp{"id": restrictedUserID}).Execute()
+ _, _ = db.DB().Exec("DELETE FROM library WHERE id = ?", lib2ID)
+ })
+
+ It("should only include tracks from libraries the user has access to (issue #4738)", func() {
+ db := GetDBXBuilder()
+ ctx := log.NewContext(GinkgoT().Context())
+
+ // Create the smart playlist as the restricted user
+ restrictedUser := model.User{ID: restrictedUserID, UserName: restrictedUserID, IsAdmin: false}
+ ctx = request.WithUser(ctx, restrictedUser)
+ restrictedRepo := NewPlaylistRepository(ctx, db)
+
+ // Create a smart playlist that matches all songs
+ rules := &criteria.Criteria{
+ Expression: criteria.All{
+ criteria.Gt{"playCount": -1}, // Matches everything
+ },
+ }
+ newPls := model.Playlist{Name: "All Songs", OwnerID: restrictedUserID, Rules: rules}
+ Expect(restrictedRepo.Put(&newPls)).To(Succeed())
+ testPlaylistID = newPls.ID
+
+ By("refreshing the smart playlist")
+ conf.Server.SmartPlaylistRefreshDelay = -1 * time.Second // Force refresh
+ pls, err := restrictedRepo.GetWithTracks(newPls.ID, true, false)
+ Expect(err).ToNot(HaveOccurred())
+
+ By("verifying only the track from library 1 is in the playlist")
+ var foundLib1Song, foundLib2Song bool
+ for _, track := range pls.Tracks {
+ if track.MediaFileID == "lib1-song" {
+ foundLib1Song = true
+ }
+ if track.MediaFileID == "lib2-song" {
+ foundLib2Song = true
+ }
+ }
+ Expect(foundLib1Song).To(BeTrue(), "Song from library 1 should be in the playlist")
+ Expect(foundLib2Song).To(BeFalse(), "Song from library 2 should NOT be in the playlist")
+
+ By("verifying playlist_tracks table only contains the accessible track")
+ var playlistTracksCount int
+ err = db.DB().QueryRow("SELECT count(*) FROM playlist_tracks WHERE playlist_id = ?", newPls.ID).Scan(&playlistTracksCount)
+ Expect(err).ToNot(HaveOccurred())
+ // Count should only include tracks visible to the user (lib1-song)
+ // The count may include other test songs from library 1, but NOT lib2-song
+ var lib2TrackCount int
+ err = db.DB().QueryRow("SELECT count(*) FROM playlist_tracks WHERE playlist_id = ? AND media_file_id = 'lib2-song'", newPls.ID).Scan(&lib2TrackCount)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(lib2TrackCount).To(Equal(0), "lib2-song should not be in playlist_tracks")
+
+ By("verifying SongCount matches visible tracks")
+ Expect(pls.SongCount).To(Equal(len(pls.Tracks)), "SongCount should match the number of visible tracks")
+ })
+ })
})
diff --git a/persistence/playlist_track_repository.go b/persistence/playlist_track_repository.go
index 01eec0d02..666f227e2 100644
--- a/persistence/playlist_track_repository.go
+++ b/persistence/playlist_track_repository.go
@@ -55,7 +55,7 @@ func (r *playlistRepository) Tracks(playlistId string, refreshSmartPlaylist bool
"id": "playlist_tracks.id",
"artist": "order_artist_name",
"album_artist": "order_album_artist_name",
- "album": "order_album_name, order_album_artist_name",
+ "album": "order_album_name, album_id, disc_number, track_number, order_artist_name, title",
"title": "order_title",
// To make sure these fields will be whitelisted
"duration": "duration",
@@ -97,6 +97,7 @@ func (r *playlistTrackRepository) Read(id string) (interface{}, error) {
"coalesce(rating, 0) as rating",
"starred_at",
"play_date",
+ "rated_at",
"f.*",
"playlist_tracks.*",
).
diff --git a/persistence/scrobble_repository.go b/persistence/scrobble_repository.go
new file mode 100644
index 000000000..dda98b763
--- /dev/null
+++ b/persistence/scrobble_repository.go
@@ -0,0 +1,34 @@
+package persistence
+
+import (
+ "context"
+ "time"
+
+ . "github.com/Masterminds/squirrel"
+ "github.com/navidrome/navidrome/model"
+ "github.com/pocketbase/dbx"
+)
+
+type scrobbleRepository struct {
+ sqlRepository
+}
+
+func NewScrobbleRepository(ctx context.Context, db dbx.Builder) model.ScrobbleRepository {
+ r := &scrobbleRepository{}
+ r.ctx = ctx
+ r.db = db
+ r.tableName = "scrobbles"
+ return r
+}
+
+func (r *scrobbleRepository) RecordScrobble(mediaFileID string, submissionTime time.Time) error {
+ userID := loggedUser(r.ctx).ID
+ values := map[string]interface{}{
+ "media_file_id": mediaFileID,
+ "user_id": userID,
+ "submission_time": submissionTime.Unix(),
+ }
+ insert := Insert(r.tableName).SetMap(values)
+ _, err := r.executeSQL(insert)
+ return err
+}
diff --git a/persistence/scrobble_repository_test.go b/persistence/scrobble_repository_test.go
new file mode 100644
index 000000000..d43848d03
--- /dev/null
+++ b/persistence/scrobble_repository_test.go
@@ -0,0 +1,84 @@
+package persistence
+
+import (
+ "context"
+ "time"
+
+ "github.com/navidrome/navidrome/log"
+ "github.com/navidrome/navidrome/model"
+ "github.com/navidrome/navidrome/model/id"
+ "github.com/navidrome/navidrome/model/request"
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+ "github.com/pocketbase/dbx"
+)
+
+var _ = Describe("ScrobbleRepository", func() {
+ var repo model.ScrobbleRepository
+ var rawRepo sqlRepository
+ var ctx context.Context
+ var fileID string
+ var userID string
+
+ BeforeEach(func() {
+ fileID = id.NewRandom()
+ userID = id.NewRandom()
+ ctx = request.WithUser(log.NewContext(GinkgoT().Context()), model.User{ID: userID, UserName: "johndoe", IsAdmin: true})
+ db := GetDBXBuilder()
+ repo = NewScrobbleRepository(ctx, db)
+
+ rawRepo = sqlRepository{
+ ctx: ctx,
+ tableName: "scrobbles",
+ db: db,
+ }
+ })
+
+ AfterEach(func() {
+ _, _ = rawRepo.db.Delete("scrobbles", dbx.HashExp{"media_file_id": fileID}).Execute()
+ _, _ = rawRepo.db.Delete("media_file", dbx.HashExp{"id": fileID}).Execute()
+ _, _ = rawRepo.db.Delete("user", dbx.HashExp{"id": userID}).Execute()
+ })
+
+ Describe("RecordScrobble", func() {
+ It("records a scrobble event", func() {
+ submissionTime := time.Now().UTC()
+
+ // Insert User
+ _, err := rawRepo.db.Insert("user", dbx.Params{
+ "id": userID,
+ "user_name": "user",
+ "password": "pw",
+ "created_at": time.Now(),
+ "updated_at": time.Now(),
+ }).Execute()
+ Expect(err).ToNot(HaveOccurred())
+
+ // Insert MediaFile
+ _, err = rawRepo.db.Insert("media_file", dbx.Params{
+ "id": fileID,
+ "path": "path",
+ "created_at": time.Now(),
+ "updated_at": time.Now(),
+ }).Execute()
+ Expect(err).ToNot(HaveOccurred())
+
+ err = repo.RecordScrobble(fileID, submissionTime)
+ Expect(err).ToNot(HaveOccurred())
+
+ // Verify insertion
+ var scrobble struct {
+ MediaFileID string `db:"media_file_id"`
+ UserID string `db:"user_id"`
+ SubmissionTime int64 `db:"submission_time"`
+ }
+ err = rawRepo.db.Select("*").From("scrobbles").
+ Where(dbx.HashExp{"media_file_id": fileID, "user_id": userID}).
+ One(&scrobble)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(scrobble.MediaFileID).To(Equal(fileID))
+ Expect(scrobble.UserID).To(Equal(userID))
+ Expect(scrobble.SubmissionTime).To(Equal(submissionTime.Unix()))
+ })
+ })
+})
diff --git a/persistence/sql_annotations.go b/persistence/sql_annotations.go
index 6691b553c..108e9be94 100644
--- a/persistence/sql_annotations.go
+++ b/persistence/sql_annotations.go
@@ -28,6 +28,7 @@ func (r sqlRepository) withAnnotation(query SelectBuilder, idField string) Selec
"coalesce(rating, 0) as rating",
"starred_at",
"play_date",
+ "rated_at",
)
if conf.Server.AlbumPlayCountMode == consts.AlbumPlayCountModeNormalized && r.tableName == "album" {
query = query.Columns(
@@ -77,7 +78,8 @@ func (r sqlRepository) SetStar(starred bool, ids ...string) error {
}
func (r sqlRepository) SetRating(rating int, itemID string) error {
- return r.annUpsert(map[string]interface{}{"rating": rating}, itemID)
+ ratedAt := time.Now()
+ return r.annUpsert(map[string]interface{}{"rating": rating, "rated_at": ratedAt}, itemID)
}
func (r sqlRepository) IncPlayCount(itemID string, ts time.Time) error {
@@ -119,7 +121,7 @@ func (r sqlRepository) cleanAnnotations() error {
del := Delete(annotationTable).Where(Eq{"item_type": r.tableName}).Where("item_id not in (select id from " + r.tableName + ")")
c, err := r.executeSQL(del)
if err != nil {
- return fmt.Errorf("error cleaning up annotations: %w", err)
+ return fmt.Errorf("error cleaning up %s annotations: %w", r.tableName, err)
}
if c > 0 {
log.Debug(r.ctx, "Clean-up annotations", "table", r.tableName, "totalDeleted", c)
diff --git a/persistence/sql_bookmarks.go b/persistence/sql_bookmarks.go
index 52c4b8e9c..9164aed9d 100644
--- a/persistence/sql_bookmarks.go
+++ b/persistence/sql_bookmarks.go
@@ -148,10 +148,10 @@ func (r sqlRepository) cleanBookmarks() error {
del := Delete(bookmarkTable).Where(Eq{"item_type": r.tableName}).Where("item_id not in (select id from " + r.tableName + ")")
c, err := r.executeSQL(del)
if err != nil {
- return fmt.Errorf("error cleaning up bookmarks: %w", err)
+ return fmt.Errorf("error cleaning up %s bookmarks: %w", r.tableName, err)
}
if c > 0 {
- log.Debug(r.ctx, "Clean-up bookmarks", "totalDeleted", c)
+ log.Debug(r.ctx, "Clean-up bookmarks", "totalDeleted", c, "itemType", r.tableName)
}
return nil
}
diff --git a/persistence/tag_library_filtering_test.go b/persistence/tag_library_filtering_test.go
index ab0d57d52..77b91847a 100644
--- a/persistence/tag_library_filtering_test.go
+++ b/persistence/tag_library_filtering_test.go
@@ -2,6 +2,7 @@ package persistence
import (
"context"
+ "time"
"github.com/deluan/rest"
"github.com/navidrome/navidrome/conf/configtest"
@@ -45,6 +46,9 @@ var _ = Describe("Tag Library Filtering", func() {
BeforeEach(func() {
DeferCleanup(configtest.SetupConfig())
+ // Generate unique path suffix to avoid conflicts with other tests
+ uniqueSuffix := time.Now().Format("20060102150405.000")
+
// Clean up database
db := GetDBXBuilder()
_, err := db.NewQuery("DELETE FROM library_tag").Execute()
@@ -57,12 +61,12 @@ var _ = Describe("Tag Library Filtering", func() {
_, err = db.NewQuery("DELETE FROM library WHERE id > 1").Execute()
Expect(err).ToNot(HaveOccurred())
- // Create test libraries
+ // Create test libraries with unique names and paths to avoid conflicts with other tests
_, err = db.NewQuery("INSERT INTO library (id, name, path) VALUES ({:id}, {:name}, {:path})").
- Bind(dbx.Params{"id": libraryID2, "name": "Library 2", "path": "/music/lib2"}).Execute()
+ Bind(dbx.Params{"id": libraryID2, "name": "Library 2-" + uniqueSuffix, "path": "/music/lib2-" + uniqueSuffix}).Execute()
Expect(err).ToNot(HaveOccurred())
_, err = db.NewQuery("INSERT INTO library (id, name, path) VALUES ({:id}, {:name}, {:path})").
- Bind(dbx.Params{"id": libraryID3, "name": "Library 3", "path": "/music/lib3"}).Execute()
+ Bind(dbx.Params{"id": libraryID3, "name": "Library 3-" + uniqueSuffix, "path": "/music/lib3-" + uniqueSuffix}).Execute()
Expect(err).ToNot(HaveOccurred())
// Give admin access to all libraries
diff --git a/persistence/tag_repository.go b/persistence/tag_repository.go
index b224450ab..5bb8b3832 100644
--- a/persistence/tag_repository.go
+++ b/persistence/tag_repository.go
@@ -88,10 +88,10 @@ func (r *tagRepository) purgeUnused() error {
`)
c, err := r.executeSQL(del)
if err != nil {
- return fmt.Errorf("error purging unused tags: %w", err)
+ return fmt.Errorf("error purging %s unused tags: %w", r.tableName, err)
}
if c > 0 {
- log.Debug(r.ctx, "Purged unused tags", "totalDeleted", c)
+ log.Debug(r.ctx, "Purged unused tags", "totalDeleted", c, "table", r.tableName)
}
return err
}
diff --git a/persistence/user_repository.go b/persistence/user_repository.go
index a7181b1a7..7baa8f6a8 100644
--- a/persistence/user_repository.go
+++ b/persistence/user_repository.go
@@ -57,6 +57,7 @@ func NewUserRepository(ctx context.Context, db dbx.Builder) model.UserRepository
r.db = db
r.tableName = "user"
r.registerModel(&model.User{}, map[string]filterFunc{
+ "id": idFilter(r.tableName),
"password": invalidFilter(ctx),
"name": r.withTableName(startsWithFilter),
})
diff --git a/persistence/user_repository_test.go b/persistence/user_repository_test.go
index 7c0707ecd..8abbf76a9 100644
--- a/persistence/user_repository_test.go
+++ b/persistence/user_repository_test.go
@@ -559,4 +559,15 @@ var _ = Describe("UserRepository", func() {
Expect(user.Libraries[0].ID).To(Equal(1))
})
})
+
+ Describe("filters", func() {
+ It("qualifies id filter with table name", func() {
+ r := repo.(*userRepository)
+ qo := r.parseRestOptions(r.ctx, rest.QueryOptions{Filters: map[string]any{"id": "123"}})
+ sel := r.selectUserWithLibraries(qo)
+ query, _, err := r.toSQL(sel)
+ Expect(err).NotTo(HaveOccurred())
+ Expect(query).To(ContainSubstring("user.id = {:p0}"))
+ })
+ })
})
diff --git a/plugins/adapter_media_agent_test.go b/plugins/adapter_media_agent_test.go
index 70b5d275a..e04baf832 100644
--- a/plugins/adapter_media_agent_test.go
+++ b/plugins/adapter_media_agent_test.go
@@ -3,6 +3,7 @@ package plugins
import (
"context"
"errors"
+ "time"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/conf/configtest"
@@ -23,6 +24,7 @@ var _ = Describe("Adapter Media Agent", func() {
// Ensure plugins folder is set to testdata
DeferCleanup(configtest.SetupConfig())
conf.Server.Plugins.Folder = testDataDir
+ conf.Server.DevPluginCompilationTimeout = 2 * time.Minute
mgr = createManager(nil, metrics.NewNoopInstance())
mgr.ScanPlugins()
diff --git a/plugins/host_subsonicapi.go b/plugins/host_subsonicapi.go
index d3008798a..937dd044f 100644
--- a/plugins/host_subsonicapi.go
+++ b/plugins/host_subsonicapi.go
@@ -93,8 +93,12 @@ func (s *subsonicAPIServiceImpl) Call(ctx context.Context, req *subsonicapi.Call
RawQuery: query.Encode(),
}
- // Create HTTP request with internal authentication
- httpReq, err := http.NewRequestWithContext(ctx, "GET", finalURL.String(), nil)
+ // Create HTTP request with a fresh context to avoid Chi RouteContext pollution.
+ // Using http.NewRequest (instead of http.NewRequestWithContext) ensures the internal
+ // SubsonicAPI call doesn't inherit routing information from the parent handler,
+ // which would cause Chi to invoke the wrong handler. Authentication context is
+ // explicitly added in the next step via request.WithInternalAuth.
+ httpReq, err := http.NewRequest("GET", finalURL.String(), nil)
if err != nil {
return &subsonicapi.CallResponse{
Error: fmt.Sprintf("failed to create HTTP request: %v", err),
diff --git a/plugins/manager_test.go b/plugins/manager_test.go
index 207908ebc..8b361f8b3 100644
--- a/plugins/manager_test.go
+++ b/plugins/manager_test.go
@@ -4,6 +4,7 @@ import (
"context"
"os"
"path/filepath"
+ "time"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/core/agents"
@@ -22,8 +23,11 @@ var _ = Describe("Plugin Manager", func() {
// but, as this is an integration test, we can't use configtest.SetupConfig() as it causes
// data races.
originalPluginsFolder := conf.Server.Plugins.Folder
+ originalTimeout := conf.Server.DevPluginCompilationTimeout
+ conf.Server.DevPluginCompilationTimeout = 2 * time.Minute
DeferCleanup(func() {
conf.Server.Plugins.Folder = originalPluginsFolder
+ conf.Server.DevPluginCompilationTimeout = originalTimeout
})
conf.Server.Plugins.Enabled = true
conf.Server.Plugins.Folder = testDataDir
diff --git a/release/goreleaser.yml b/release/goreleaser.yml
index f71c38f31..30c0d6f3b 100644
--- a/release/goreleaser.yml
+++ b/release/goreleaser.yml
@@ -83,6 +83,15 @@ nfpms:
owner: navidrome
group: navidrome
+ - src: release/linux/.package.rpm # contents: "rpm"
+ dst: /var/lib/navidrome/.package
+ type: "config|noreplace"
+ packager: rpm
+ - src: release/linux/.package.deb # contents: "deb"
+ dst: /var/lib/navidrome/.package
+ type: "config|noreplace"
+ packager: deb
+
scripts:
preinstall: "release/linux/preinstall.sh"
postinstall: "release/linux/postinstall.sh"
diff --git a/release/linux/.package.deb b/release/linux/.package.deb
new file mode 100644
index 000000000..811c85f42
--- /dev/null
+++ b/release/linux/.package.deb
@@ -0,0 +1 @@
+deb
\ No newline at end of file
diff --git a/release/linux/.package.rpm b/release/linux/.package.rpm
new file mode 100644
index 000000000..7c88ef3c0
--- /dev/null
+++ b/release/linux/.package.rpm
@@ -0,0 +1 @@
+rpm
\ No newline at end of file
diff --git a/release/wix/build_msi.sh b/release/wix/build_msi.sh
index 9fc008446..7e595311e 100755
--- a/release/wix/build_msi.sh
+++ b/release/wix/build_msi.sh
@@ -49,6 +49,9 @@ cp "${DOWNLOAD_FOLDER}"/extracted_ffmpeg/${FFMPEG_FILE}/bin/ffmpeg.exe "$MSI_OUT
cp "$WORKSPACE"/LICENSE "$WORKSPACE"/README.md "$MSI_OUTPUT_DIR"
cp "$BINARY" "$MSI_OUTPUT_DIR"
+# package type indicator file
+echo "msi" > "$MSI_OUTPUT_DIR/.package"
+
# workaround for wixl WixVariable not working to override bmp locations
cp "$WORKSPACE"/release/wix/bmp/banner.bmp /usr/share/wixl-*/ext/ui/bitmaps/bannrbmp.bmp
cp "$WORKSPACE"/release/wix/bmp/dialogue.bmp /usr/share/wixl-*/ext/ui/bitmaps/dlgbmp.bmp
diff --git a/release/wix/navidrome.wxs b/release/wix/navidrome.wxs
index ec8b164e8..8ebba4632 100644
--- a/release/wix/navidrome.wxs
+++ b/release/wix/navidrome.wxs
@@ -69,6 +69,12 @@
+
+
+
+
+
+
@@ -81,6 +87,7 @@
+
diff --git a/resources/i18n/bg.json b/resources/i18n/bg.json
index ea97d1d1b..dfe3f27ed 100644
--- a/resources/i18n/bg.json
+++ b/resources/i18n/bg.json
@@ -1,460 +1,634 @@
{
- "languageName": "Български",
- "resources": {
- "song": {
- "name": "Песен |||| Песни",
- "fields": {
- "albumArtist": "Изпълнител албум",
- "duration": "Време",
- "trackNumber": "#",
- "playCount": "Пускания",
- "title": "Заглавие",
- "artist": "Изпълнител",
- "album": "Албум",
- "path": "Път до файл",
- "genre": "Жанр",
- "compilation": "Компилация",
- "year": "Година",
- "size": "Размер на файла",
- "updatedAt": "Актуализирана",
- "bitRate": "Битрейт",
- "discSubtitle": "Субтитри на диска",
- "starred": "Любима",
- "comment": "Коментар",
- "rating": "Рейтинг",
- "quality": "Качество",
- "bpm": "BPM",
- "playDate": "Последно слушана",
- "channels": "Канала",
- "createdAt": "Добавено на"
- },
- "actions": {
- "addToQueue": "Пусни по-късно",
- "playNow": "Пусни сега",
- "addToPlaylist": "Добави към плейлист",
- "shuffleAll": "Разбъркай всички",
- "download": "Свали",
- "playNext": "Следваща",
- "info": "Информация"
- }
- },
- "album": {
- "name": "Албум |||| Албуми",
- "fields": {
- "albumArtist": "Изпълнител албум",
- "artist": "Изпълнител",
- "duration": "Време",
- "songCount": "Песни",
- "playCount": "Пускания",
- "name": "Име",
- "genre": "Жанр",
- "compilation": "Компилация",
- "year": "Година",
- "updatedAt": "Актуализиран",
- "comment": "Коментар",
- "rating": "Рейтинг",
- "createdAt": "Добавено на",
- "size": "Размер",
- "originalDate": "Оригинал",
- "releaseDate": "Издаден",
- "releases": "Издание |||| Издания",
- "released": "Издаден"
- },
- "actions": {
- "playAll": "Пусни",
- "playNext": "Пусни следваща",
- "addToQueue": "Пусни по-късно",
- "shuffle": "Разбъркай",
- "addToPlaylist": "Добави към плейлист",
- "download": "Свали",
- "info": "Информация",
- "share": "Сподели"
- },
- "lists": {
- "all": "Всички",
- "random": "Случайни",
- "recentlyAdded": "Последно добавени",
- "recentlyPlayed": "Последно слушани",
- "mostPlayed": "Най-слушани",
- "starred": "Любими",
- "topRated": "Най-висок рейтинг"
- }
- },
- "artist": {
- "name": "Изпълнител |||| Изпълнители",
- "fields": {
- "name": "Име",
- "albumCount": "Брой албуми",
- "songCount": "Брой песни",
- "playCount": "Пускания",
- "rating": "Рейтинг",
- "genre": "Жанр",
- "size": "Размер"
- }
- },
- "user": {
- "name": "Потребител |||| Потребители",
- "fields": {
- "userName": "Потребителско име",
- "isAdmin": "Администратор",
- "lastLoginAt": "Последен вход",
- "updatedAt": "Актуализиран",
- "name": "Име",
- "password": "Парола",
- "createdAt": "Създаден на",
- "changePassword": "Промяна на паролата?",
- "currentPassword": "Текуща парола",
- "newPassword": "Нова парола",
- "token": "Токен"
- },
- "helperTexts": {
- "name": "Промените в името ще бъдат отразени при следващото влизане"
- },
- "notifications": {
- "created": "Потребителят е създаден",
- "updated": "Потребителят е актуализиран",
- "deleted": "Потребителят е изтрит"
- },
- "message": {
- "listenBrainzToken": "Въведете Вашия токен за ListenBrainz.",
- "clickHereForToken": "Кликнете тук, за да получите Вашия токен"
- }
- },
- "player": {
- "name": "Плейър |||| Плейъри",
- "fields": {
- "name": "Име",
- "transcodingId": "Транскодиране",
- "maxBitRate": "Макс. битрейт",
- "client": "Клиент",
- "userName": "Потребителско име",
- "lastSeen": "Последно видян",
- "reportRealPath": "Докладвай реален път",
- "scrobbleEnabled": "Изпрати Scrobbles към външни услуги"
- }
- },
- "transcoding": {
- "name": "Транскодиране |||| Транскодинг",
- "fields": {
- "name": "Име",
- "targetFormat": "Целеви формат",
- "defaultBitRate": "Битрейт по подразбиране",
- "command": "Команда"
- }
- },
- "playlist": {
- "name": "Плейлист |||| Плейлисти",
- "fields": {
- "name": "Име",
- "duration": "Продължителност",
- "ownerName": "Собственик",
- "public": "Публичен",
- "updatedAt": "Актуализиран",
- "createdAt": "Създаден на",
- "songCount": "Песни",
- "comment": "Коментар",
- "sync": "Автоматично импортиране",
- "path": "Импортиране от"
- },
- "actions": {
- "selectPlaylist": "Изберете плейлист:",
- "addNewPlaylist": "Създай \"%{name}\"",
- "export": "Експорт",
- "makePublic": "Направи публичен",
- "makePrivate": "Направи личен"
- },
- "message": {
- "duplicate_song": "Добави дублирани песни",
- "song_exist": "Към плейлиста се добавят дублиращи. Желаете ли да ги добавите или предпочитате да ги пропуснете?"
- }
- },
- "radio": {
- "name": "Радиостанция |||| Радиостанции",
- "fields": {
- "name": "Име",
- "streamUrl": "Стрийм адрес",
- "homePageUrl": "Начална страница адрес",
- "updatedAt": "Актуализиранa на",
- "createdAt": "Създаденa на"
- },
- "actions": {
- "playNow": "Възпроизвеждане сега"
- }
- },
- "share": {
- "name": "Сподели |||| Споделени",
- "fields": {
- "username": "Споделено от",
- "url": "Адрес",
- "description": "Описание",
- "contents": "Съдържание",
- "expiresAt": "Изтича",
- "lastVisitedAt": "Последно посетен",
- "visitCount": "Посещения",
- "format": "Формат",
- "maxBitRate": "Макс. Bit Rate",
- "updatedAt": "Актуализирана на",
- "createdAt": "Създадена на",
- "downloadable": "Разреши изтегляния?"
- }
- }
+ "languageName": "Български",
+ "resources": {
+ "song": {
+ "name": "Песен |||| Песни",
+ "fields": {
+ "albumArtist": "Изпълнител албум",
+ "duration": "Време",
+ "trackNumber": "#",
+ "playCount": "Пускания",
+ "title": "Заглавие",
+ "artist": "Изпълнител",
+ "album": "Албум",
+ "path": "Път до файл",
+ "genre": "Жанр",
+ "compilation": "Компилация",
+ "year": "Година",
+ "size": "Размер на файла",
+ "updatedAt": "Актуализирана",
+ "bitRate": "Битрейт",
+ "discSubtitle": "Субтитри на диска",
+ "starred": "Любима",
+ "comment": "Коментар",
+ "rating": "Рейтинг",
+ "quality": "Качество",
+ "bpm": "BPM",
+ "playDate": "Последно слушана",
+ "channels": "Канала",
+ "createdAt": "Добавено на",
+ "grouping": "Групиране",
+ "mood": "Настроение",
+ "participants": "Допълнителни участници",
+ "tags": "Допълнителни етикети",
+ "mappedTags": "",
+ "rawTags": "",
+ "bitDepth": "Битова дълбочина",
+ "sampleRate": "",
+ "missing": "Липсва",
+ "libraryName": ""
+ },
+ "actions": {
+ "addToQueue": "Пусни по-късно",
+ "playNow": "Пусни сега",
+ "addToPlaylist": "Добави към плейлист",
+ "shuffleAll": "Разбъркай всички",
+ "download": "Свали",
+ "playNext": "Следваща",
+ "info": "Информация",
+ "showInPlaylist": ""
+ }
},
- "ra": {
- "auth": {
- "welcome1": "Благодаря, че инсталирахте Navidrome!",
- "welcome2": "За да започнете, създайте администраторски профил",
- "confirmPassword": "Потвърдете паролата",
- "buttonCreateAdmin": "Създaй администратор",
- "auth_check_error": "Моля, влезте за да продължите",
- "user_menu": "Профил",
- "username": "Потребителско име",
- "password": "Парола",
- "sign_in": "Вход",
- "sign_in_error": "Грешка при удостоверяването. Моля, опитайте отново",
- "logout": "Изход"
- },
- "validation": {
- "invalidChars": "Моля, използвайте само букви и цифри",
- "passwordDoesNotMatch": "Паролата не съвпада",
- "required": "Задължително",
- "minLength": "Трябва да съдържа поне %{min} знака",
- "maxLength": "Трябва да съдържа %{max} знака или по-малко",
- "minValue": "Трябва да е поне %{min}",
- "maxValue": "Трябва да бъде %{max} или по-малко",
- "number": "Трябва да е число",
- "email": "Трябва да е валиден имейл",
- "oneOf": "Трябва да е едно от: %{options}",
- "regex": "Трябва да съответства на конкретен формат (regexp): %{pattern}",
- "unique": "Трябва да е уникално",
- "url": "Трябва да бъде валиден адрес"
- },
- "action": {
- "add_filter": "Добави филтър",
- "add": "Добави",
- "back": "Назад",
- "bulk_actions": "Избран е 1 елемент |||| Избрани са %{smart_count} елемента",
- "cancel": "Отмени",
- "clear_input_value": "Изчисти въведеното",
- "clone": "Клонирай",
- "confirm": "Потвърди",
- "create": "Създай",
- "delete": "Изтрий",
- "edit": "Редактирай",
- "export": "Експорт",
- "list": "Списък",
- "refresh": "Обнови",
- "remove_filter": "Премахни този филтър",
- "remove": "Премахни",
- "save": "Запази",
- "search": "Търси",
- "show": "Покажи",
- "sort": "Сортирай",
- "undo": "Отмени",
- "expand": "Разгърни",
- "close": "Затвори",
- "open_menu": "Отвори меню",
- "close_menu": "Затвори меню",
- "unselect": "Премахни избора",
- "skip": "Пропусни",
- "bulk_actions_mobile": "1 |||| %{smart_count}",
- "share": "Споделяне",
- "download": "Сваляне"
- },
- "boolean": {
- "true": "Да",
- "false": "Не"
- },
- "page": {
- "create": "Създаване на %{name}",
- "dashboard": "Табло",
- "edit": "%{name} #%{id}",
- "error": "Нещо се обърка",
- "list": "%{name}",
- "loading": "Зареждане",
- "not_found": "Не е намерен",
- "show": "%{name} #%{id}",
- "empty": "Все още няма %{name}.",
- "invite": "Желаете ли да добавите?"
- },
- "input": {
- "file": {
- "upload_several": "Пуснете файл за да качите, или кликнете за да изберете.",
- "upload_single": "Пуснете файл за да качите, или кликнете за да изберете."
- },
- "image": {
- "upload_several": "Пуснете снимки за качване, или кликнете, за да изберете.",
- "upload_single": "Пуснете снимка за качване, или кликнете за да изберете."
- },
- "references": {
- "all_missing": "Не намирам свързаните данни.",
- "many_missing": "Изглежда, че поне една от свързаните препратки, вече не е налична.",
- "single_missing": "Изглежда, че връзката вече не е налична."
- },
- "password": {
- "toggle_visible": "Скрий паролата",
- "toggle_hidden": "Покажи паролата"
- }
- },
- "message": {
- "about": "Относно",
- "are_you_sure": "Сигурни ли сте?",
- "bulk_delete_content": "Наистина ли желаете да изтриете това %{name}? |||| Наистина ли желаете да изтриете тези %{smart_count} елементи?",
- "bulk_delete_title": "Изтрий %{name} |||| Изтрий %{smart_count} %{name}",
- "delete_content": "Наистина ли желаете да изтриете този елемент?",
- "delete_title": "Изтрий %{name} #%{id}",
- "details": "Описание",
- "error": "Възникна грешка с клиента и заявката Ви не може да бъде изпълнена.",
- "invalid_form": "Формата не е валидна. Моля, проверете за грешки",
- "loading": "Страницата се зарежда, моля изчакайте",
- "no": "Не",
- "not_found": "Или сте въвели грешен URL адрес, или сте следвали грешна връзка.",
- "yes": "Да",
- "unsaved_changes": "Някои от промените не бяха запазени. Сигурни ли сте, че желаете да ги игнорирате?"
- },
- "navigation": {
- "no_results": "Няма намерени резултати",
- "no_more_results": "Страница %{page} е извън границите. Опитайте предишната страница.",
- "page_out_of_boundaries": "Страница %{page} е извън границите",
- "page_out_from_end": "Не може да отидете след последната страница",
- "page_out_from_begin": "Не може да се премине преди страница 1",
- "page_range_info": "%{offsetBegin}-%{offsetEnd} от %{total}",
- "page_rows_per_page": "Елемента на страница:",
- "next": "Следваща",
- "prev": "Предишна",
- "skip_nav": "Премини към съдържанието"
- },
- "notification": {
- "updated": "Елементът е актуализиран |||| %{smart_count} елемента са актуализирани",
- "created": "Елементът е създаден",
- "deleted": "Елементът е изтрит |||| %{smart_count} елемента са изтрити",
- "bad_item": "Неправилен елемент",
- "item_doesnt_exist": "Елементът не съществува",
- "http_error": "Грешка в комуникацията със сървъра",
- "data_provider_error": "Грешка в доставчика на данни. Проверете конзолата за подробности.",
- "i18n_error": "Не мога да заредя преводите за посочения език",
- "canceled": "Действието е отменено",
- "logged_out": "Вашата сесия приключи. Моля, влезте отново.",
- "new_version": "Налична е нова версия! Моля, опреснете този прозорец."
- },
- "toggleFieldsMenu": {
- "columnsToDisplay": "Колони за показване",
- "layout": "Оформление",
- "grid": "Решетка",
- "table": "Таблица"
- }
+ "album": {
+ "name": "Албум |||| Албуми",
+ "fields": {
+ "albumArtist": "Изпълнител албум",
+ "artist": "Изпълнител",
+ "duration": "Време",
+ "songCount": "Песни",
+ "playCount": "Пускания",
+ "name": "Име",
+ "genre": "Жанр",
+ "compilation": "Компилация",
+ "year": "Година",
+ "updatedAt": "Актуализиран",
+ "comment": "Коментар",
+ "rating": "Рейтинг",
+ "createdAt": "Добавено на",
+ "size": "Размер",
+ "originalDate": "Оригинал",
+ "releaseDate": "Издаден",
+ "releases": "Издание |||| Издания",
+ "released": "Издаден",
+ "recordLabel": "Лейбъл",
+ "catalogNum": "Каталожен номер",
+ "releaseType": "Тип",
+ "grouping": "Групиране",
+ "media": "Медия",
+ "mood": "Настроение",
+ "date": "Дата на запис",
+ "missing": "Липсва",
+ "libraryName": ""
+ },
+ "actions": {
+ "playAll": "Пусни",
+ "playNext": "Пусни следваща",
+ "addToQueue": "Пусни по-късно",
+ "shuffle": "Разбъркай",
+ "addToPlaylist": "Добави към плейлист",
+ "download": "Свали",
+ "info": "Информация",
+ "share": "Сподели"
+ },
+ "lists": {
+ "all": "Всички",
+ "random": "Случайни",
+ "recentlyAdded": "Последно добавени",
+ "recentlyPlayed": "Последно слушани",
+ "mostPlayed": "Най-слушани",
+ "starred": "Любими",
+ "topRated": "Най-висок рейтинг"
+ }
},
- "message": {
- "note": "ЗАБЕЛЕЖКА",
- "transcodingDisabled": "Промяната на конфигурацията за транскодиране през уеб интерфейса е забранена от съображения за сигурност. Ако желаете да промените (редактирате или добавите) опциите за транскодиране, рестартирайте сървъра с конфигурационната опция %{config}.",
- "transcodingEnabled": "Navidrome в момента работи с %{config}, което прави възможно стартирането на системни команди от настройките за транскодиране с помощта на уеб интерфейса. Препоръчваме да го деактивирате от съображения за сигурност и да го активирате само при конфигуриране на опциите за транскодиране.",
- "songsAddedToPlaylist": "Добавена 1 песен към плейлиста |||| Добавени %{smart_count} песни към плейлиста",
- "noPlaylistsAvailable": "Няма налични",
- "delete_user_title": "Изтрий потребителя '%{name}'",
- "delete_user_content": "Наистина ли желаете да изтриете този потребител и всичките му данни (включително плейлисти и предпочитания)?",
- "notifications_blocked": "В настройките на браузъра сте блокирали известията за този сайт",
- "notifications_not_available": "Този браузър не поддържа известия на работния плот или нямате достъп до Navidrome през https",
- "lastfmLinkSuccess": "Връзката с Last.fm е успешна! Scrobbling е активиран",
- "lastfmLinkFailure": "Last.fm не можа да бъде свързан",
- "lastfmUnlinkSuccess": "Връзката с Last.fm е прекъсната! Scrobbling е деактивиран",
- "lastfmUnlinkFailure": "Last.fm връзката не можа да бъде премахната",
- "openIn": {
- "lastfm": "Отвори в Last.fm",
- "musicbrainz": "Отвори в MusicBrainz"
- },
- "lastfmLink": "Прочетете още...",
- "listenBrainzLinkSuccess": "Връзката с ListenBrainz е успешна! Scrobbling е активиран от името на потребителя: %{user}",
- "listenBrainzLinkFailure": "ListenBrainz не можа да бъде свързан: %{error}",
- "listenBrainzUnlinkSuccess": "Връзката с ListenBrainz е прекъсната! Scrobbling е деактивиран",
- "listenBrainzUnlinkFailure": "Връзката с ListenBrainz не можа да бъде прекратена",
- "downloadOriginalFormat": "Свали в оригиналния формат",
- "shareOriginalFormat": "Сподели в оригинален формат",
- "shareDialogTitle": "Сподели %{resource} '%{name}'",
- "shareBatchDialogTitle": "Сподели 1 %{resource} |||| Сподели %{smart_count} %{resource}",
- "shareSuccess": "Адресът е копиран в клипборда: %{url}",
- "shareFailure": "Грешка при копиране на адрес %{url} в клипборда",
- "downloadDialogTitle": "Сваляне %{resource} '%{name}' (%{size})",
- "shareCopyToClipboard": "Копиране в клипборда: Ctrl+C, Enter"
+ "artist": {
+ "name": "Изпълнител |||| Изпълнители",
+ "fields": {
+ "name": "Име",
+ "albumCount": "Брой албуми",
+ "songCount": "Брой песни",
+ "playCount": "Пускания",
+ "rating": "Рейтинг",
+ "genre": "Жанр",
+ "size": "Размер",
+ "role": "Роля",
+ "missing": "Липсва"
+ },
+ "roles": {
+ "albumartist": "Изпълнител на албума |||| Изпълнители на албума",
+ "artist": "Изпълнител |||| Изпълнители",
+ "composer": "Композитор |||| Композитори",
+ "conductor": "Диригент |||| Диригенти",
+ "lyricist": "Текстописец |||| Текстописци",
+ "arranger": "Аранжор |||| Аранжори",
+ "producer": "Продуцент |||| Продуценти",
+ "director": "Директор |||| Директори",
+ "engineer": "Инженер |||| Инженери",
+ "mixer": "Миксер |||| Миксери",
+ "remixer": "Ремиксер |||| Ремиксери",
+ "djmixer": "DJ миксер |||| DJ миксери",
+ "performer": "Изпълнител |||| Изпълнители",
+ "maincredit": ""
+ },
+ "actions": {
+ "shuffle": "",
+ "radio": "",
+ "topSongs": ""
+ }
},
- "menu": {
- "library": "Библиотека",
- "settings": "Настройки",
- "version": "Версия",
- "theme": "Тема",
- "personal": {
- "name": "Лични",
- "options": {
- "theme": "Тема",
- "language": "Език",
- "defaultView": "Изглед по подразбиране",
- "desktop_notifications": "Известия на работния плот",
- "lastfmScrobbling": "Scrobble към Last.fm",
- "listenBrainzScrobbling": "Scrobble към ListenBrainz",
- "replaygain": "Режим ReplayGain",
- "preAmp": "ReplayGain PreAmp (dB)",
- "gain": {
- "none": "Изключен",
- "album": "Използвай Album Gain",
- "track": "Използвай Track Gain"
- }
- }
- },
- "albumList": "Албуми",
- "about": "Относно",
- "playlists": "Плейлисти",
- "sharedPlaylists": "Споделени плейлисти"
+ "user": {
+ "name": "Потребител |||| Потребители",
+ "fields": {
+ "userName": "Потребителско име",
+ "isAdmin": "Администратор",
+ "lastLoginAt": "Последен вход",
+ "updatedAt": "Актуализиран",
+ "name": "Име",
+ "password": "Парола",
+ "createdAt": "Създаден на",
+ "changePassword": "Промяна на паролата?",
+ "currentPassword": "Текуща парола",
+ "newPassword": "Нова парола",
+ "token": "Токен",
+ "lastAccessAt": "Последен достъп",
+ "libraries": ""
+ },
+ "helperTexts": {
+ "name": "Промените в името ще бъдат отразени при следващото влизане",
+ "libraries": ""
+ },
+ "notifications": {
+ "created": "Потребителят е създаден",
+ "updated": "Потребителят е актуализиран",
+ "deleted": "Потребителят е изтрит"
+ },
+ "message": {
+ "listenBrainzToken": "Въведете Вашия токен за ListenBrainz.",
+ "clickHereForToken": "Кликнете тук, за да получите Вашия токен",
+ "selectAllLibraries": "",
+ "adminAutoLibraries": ""
+ },
+ "validation": {
+ "librariesRequired": ""
+ }
},
"player": {
- "playListsText": "Списък с песни",
- "openText": "Отвори",
- "closeText": "Затвори",
- "notContentText": "Няма песни",
- "clickToPlayText": "Пускане",
- "clickToPauseText": "Пауза",
- "nextTrackText": "Следваща песен",
- "previousTrackText": "Предишна песен",
- "reloadText": "Презареди",
- "volumeText": "Сила на звука",
- "toggleLyricText": "Текст на песен",
- "toggleMiniModeText": "Минимизирай",
- "destroyText": "Унищожи",
- "downloadText": "Свали",
- "removeAudioListsText": "Изтриване на плейлисти",
- "clickToDeleteText": "Кликнете, за да изтриете %{name}",
- "emptyLyricText": "Няма текст",
- "playModeText": {
- "order": "По ред",
- "orderLoop": "Повтаряй всички",
- "singleLoop": "Повтаряй същата",
- "shufflePlay": "Разбъркай"
- }
+ "name": "Плейър |||| Плейъри",
+ "fields": {
+ "name": "Име",
+ "transcodingId": "Транскодиране",
+ "maxBitRate": "Макс. битрейт",
+ "client": "Клиент",
+ "userName": "Потребителско име",
+ "lastSeen": "Последно видян",
+ "reportRealPath": "Докладвай реален път",
+ "scrobbleEnabled": "Изпрати Scrobbles към външни услуги"
+ }
},
- "about": {
- "links": {
- "homepage": "Начална страница",
- "source": "Програмен код",
- "featureRequests": "Заявете функционалност"
- }
+ "transcoding": {
+ "name": "Транскодиране |||| Транскодинг",
+ "fields": {
+ "name": "Име",
+ "targetFormat": "Целеви формат",
+ "defaultBitRate": "Битрейт по подразбиране",
+ "command": "Команда"
+ }
},
- "activity": {
- "title": "Действия",
- "totalScanned": "Сканирани папки",
- "quickScan": "Бързо сканиране",
- "fullScan": "Пълно сканиране",
- "serverUptime": "Сървърът работи",
- "serverDown": "ОФЛАЙН"
+ "playlist": {
+ "name": "Плейлист |||| Плейлисти",
+ "fields": {
+ "name": "Име",
+ "duration": "Продължителност",
+ "ownerName": "Собственик",
+ "public": "Публичен",
+ "updatedAt": "Актуализиран",
+ "createdAt": "Създаден на",
+ "songCount": "Песни",
+ "comment": "Коментар",
+ "sync": "Автоматично импортиране",
+ "path": "Импортиране от"
+ },
+ "actions": {
+ "selectPlaylist": "Изберете плейлист:",
+ "addNewPlaylist": "Създай \"%{name}\"",
+ "export": "Експорт",
+ "makePublic": "Направи публичен",
+ "makePrivate": "Направи личен",
+ "saveQueue": "",
+ "searchOrCreate": "",
+ "pressEnterToCreate": "",
+ "removeFromSelection": ""
+ },
+ "message": {
+ "duplicate_song": "Добави дублирани песни",
+ "song_exist": "Към плейлиста се добавят дублиращи. Желаете ли да ги добавите или предпочитате да ги пропуснете?",
+ "noPlaylistsFound": "",
+ "noPlaylists": ""
+ }
},
- "help": {
- "title": "Бързи клавиши на Navidrome",
- "hotkeys": {
- "show_help": "Показва този помощен текст",
- "toggle_menu": "Превключване на страничната меню лента",
- "toggle_play": "Пусни / Пауза",
- "prev_song": "Предишна песен",
- "next_song": "Следваща песен",
- "vol_up": "Увеличи звука",
- "vol_down": "Намали звука",
- "toggle_love": "Добави песента към любими",
- "current_song": "Премини към текущата песен"
- }
+ "radio": {
+ "name": "Радиостанция |||| Радиостанции",
+ "fields": {
+ "name": "Име",
+ "streamUrl": "Стрийм адрес",
+ "homePageUrl": "Начална страница адрес",
+ "updatedAt": "Актуализиранa на",
+ "createdAt": "Създаденa на"
+ },
+ "actions": {
+ "playNow": "Възпроизвеждане сега"
+ }
+ },
+ "share": {
+ "name": "Сподели |||| Споделени",
+ "fields": {
+ "username": "Споделено от",
+ "url": "Адрес",
+ "description": "Описание",
+ "contents": "Съдържание",
+ "expiresAt": "Изтича",
+ "lastVisitedAt": "Последно посетен",
+ "visitCount": "Посещения",
+ "format": "Формат",
+ "maxBitRate": "Макс. Bit Rate",
+ "updatedAt": "Актуализирана на",
+ "createdAt": "Създадена на",
+ "downloadable": "Разреши изтегляния?"
+ }
+ },
+ "missing": {
+ "name": "Липсващ файл |||| Липсващи файлове",
+ "fields": {
+ "path": "Път",
+ "size": "Размер",
+ "updatedAt": "Изчезнал на",
+ "libraryName": ""
+ },
+ "actions": {
+ "remove": "Премахни",
+ "remove_all": "Премахни всички"
+ },
+ "notifications": {
+ "removed": "Липсващите файлове са премахнати"
+ },
+ "empty": "Няма липсващи файлове"
+ },
+ "library": {
+ "name": "",
+ "fields": {
+ "name": "",
+ "path": "",
+ "remotePath": "",
+ "lastScanAt": "",
+ "songCount": "",
+ "albumCount": "",
+ "artistCount": "",
+ "totalSongs": "",
+ "totalAlbums": "",
+ "totalArtists": "",
+ "totalFolders": "",
+ "totalFiles": "",
+ "totalMissingFiles": "",
+ "totalSize": "",
+ "totalDuration": "",
+ "defaultNewUsers": "",
+ "createdAt": "",
+ "updatedAt": ""
+ },
+ "sections": {
+ "basic": "",
+ "statistics": ""
+ },
+ "actions": {
+ "scan": "",
+ "manageUsers": "",
+ "viewDetails": "",
+ "quickScan": "",
+ "fullScan": ""
+ },
+ "notifications": {
+ "created": "",
+ "updated": "",
+ "deleted": "",
+ "scanStarted": "",
+ "scanCompleted": "",
+ "quickScanStarted": "",
+ "fullScanStarted": "",
+ "scanError": ""
+ },
+ "validation": {
+ "nameRequired": "",
+ "pathRequired": "",
+ "pathNotDirectory": "",
+ "pathNotFound": "",
+ "pathNotAccessible": "",
+ "pathInvalid": ""
+ },
+ "messages": {
+ "deleteConfirm": "",
+ "scanInProgress": "",
+ "noLibrariesAssigned": ""
+ }
}
+ },
+ "ra": {
+ "auth": {
+ "welcome1": "Благодаря, че инсталирахте Navidrome!",
+ "welcome2": "За да започнете, създайте администраторски профил",
+ "confirmPassword": "Потвърдете паролата",
+ "buttonCreateAdmin": "Създaй администратор",
+ "auth_check_error": "Моля, влезте за да продължите",
+ "user_menu": "Профил",
+ "username": "Потребителско име",
+ "password": "Парола",
+ "sign_in": "Вход",
+ "sign_in_error": "Грешка при удостоверяването. Моля, опитайте отново",
+ "logout": "Изход",
+ "insightsCollectionNote": "Navidrome събира анонимни данни, за да помогне\nподобряването на проекта. Кликнете [тук], за да\nнаучите повече и да се откажете, ако желаете"
+ },
+ "validation": {
+ "invalidChars": "Моля, използвайте само букви и цифри",
+ "passwordDoesNotMatch": "Паролата не съвпада",
+ "required": "Задължително",
+ "minLength": "Трябва да съдържа поне %{min} знака",
+ "maxLength": "Трябва да съдържа %{max} знака или по-малко",
+ "minValue": "Трябва да е поне %{min}",
+ "maxValue": "Трябва да бъде %{max} или по-малко",
+ "number": "Трябва да е число",
+ "email": "Трябва да е валиден имейл",
+ "oneOf": "Трябва да е едно от: %{options}",
+ "regex": "Трябва да съответства на конкретен формат (regexp): %{pattern}",
+ "unique": "Трябва да е уникално",
+ "url": "Трябва да бъде валиден адрес"
+ },
+ "action": {
+ "add_filter": "Добави филтър",
+ "add": "Добави",
+ "back": "Назад",
+ "bulk_actions": "Избран е 1 елемент |||| Избрани са %{smart_count} елемента",
+ "cancel": "Отмени",
+ "clear_input_value": "Изчисти въведеното",
+ "clone": "Клонирай",
+ "confirm": "Потвърди",
+ "create": "Създай",
+ "delete": "Изтрий",
+ "edit": "Редактирай",
+ "export": "Експорт",
+ "list": "Списък",
+ "refresh": "Обнови",
+ "remove_filter": "Премахни този филтър",
+ "remove": "Премахни",
+ "save": "Запази",
+ "search": "Търси",
+ "show": "Покажи",
+ "sort": "Сортирай",
+ "undo": "Отмени",
+ "expand": "Разгърни",
+ "close": "Затвори",
+ "open_menu": "Отвори меню",
+ "close_menu": "Затвори меню",
+ "unselect": "Премахни избора",
+ "skip": "Пропусни",
+ "bulk_actions_mobile": "1 |||| %{smart_count}",
+ "share": "Споделяне",
+ "download": "Сваляне"
+ },
+ "boolean": {
+ "true": "Да",
+ "false": "Не"
+ },
+ "page": {
+ "create": "Създаване на %{name}",
+ "dashboard": "Табло",
+ "edit": "%{name} #%{id}",
+ "error": "Нещо се обърка",
+ "list": "%{name}",
+ "loading": "Зареждане",
+ "not_found": "Не е намерен",
+ "show": "%{name} #%{id}",
+ "empty": "Все още няма %{name}.",
+ "invite": "Желаете ли да добавите?"
+ },
+ "input": {
+ "file": {
+ "upload_several": "Пуснете файл за да качите, или кликнете за да изберете.",
+ "upload_single": "Пуснете файл за да качите, или кликнете за да изберете."
+ },
+ "image": {
+ "upload_several": "Пуснете снимки за качване, или кликнете, за да изберете.",
+ "upload_single": "Пуснете снимка за качване, или кликнете за да изберете."
+ },
+ "references": {
+ "all_missing": "Не намирам свързаните данни.",
+ "many_missing": "Изглежда, че поне една от свързаните препратки, вече не е налична.",
+ "single_missing": "Изглежда, че връзката вече не е налична."
+ },
+ "password": {
+ "toggle_visible": "Скрий паролата",
+ "toggle_hidden": "Покажи паролата"
+ }
+ },
+ "message": {
+ "about": "Относно",
+ "are_you_sure": "Сигурни ли сте?",
+ "bulk_delete_content": "Наистина ли желаете да изтриете това %{name}? |||| Наистина ли желаете да изтриете тези %{smart_count} елементи?",
+ "bulk_delete_title": "Изтрий %{name} |||| Изтрий %{smart_count} %{name}",
+ "delete_content": "Наистина ли желаете да изтриете този елемент?",
+ "delete_title": "Изтрий %{name} #%{id}",
+ "details": "Описание",
+ "error": "Възникна грешка с клиента и заявката Ви не може да бъде изпълнена.",
+ "invalid_form": "Формата не е валидна. Моля, проверете за грешки",
+ "loading": "Страницата се зарежда, моля изчакайте",
+ "no": "Не",
+ "not_found": "Или сте въвели грешен URL адрес, или сте следвали грешна връзка.",
+ "yes": "Да",
+ "unsaved_changes": "Някои от промените не бяха запазени. Сигурни ли сте, че желаете да ги игнорирате?"
+ },
+ "navigation": {
+ "no_results": "Няма намерени резултати",
+ "no_more_results": "Страница %{page} е извън границите. Опитайте предишната страница.",
+ "page_out_of_boundaries": "Страница %{page} е извън границите",
+ "page_out_from_end": "Не може да отидете след последната страница",
+ "page_out_from_begin": "Не може да се премине преди страница 1",
+ "page_range_info": "%{offsetBegin}-%{offsetEnd} от %{total}",
+ "page_rows_per_page": "Елемента на страница:",
+ "next": "Следваща",
+ "prev": "Предишна",
+ "skip_nav": "Премини към съдържанието"
+ },
+ "notification": {
+ "updated": "Елементът е актуализиран |||| %{smart_count} елемента са актуализирани",
+ "created": "Елементът е създаден",
+ "deleted": "Елементът е изтрит |||| %{smart_count} елемента са изтрити",
+ "bad_item": "Неправилен елемент",
+ "item_doesnt_exist": "Елементът не съществува",
+ "http_error": "Грешка в комуникацията със сървъра",
+ "data_provider_error": "Грешка в доставчика на данни. Проверете конзолата за подробности.",
+ "i18n_error": "Не мога да заредя преводите за посочения език",
+ "canceled": "Действието е отменено",
+ "logged_out": "Вашата сесия приключи. Моля, влезте отново.",
+ "new_version": "Налична е нова версия! Моля, опреснете този прозорец."
+ },
+ "toggleFieldsMenu": {
+ "columnsToDisplay": "Колони за показване",
+ "layout": "Оформление",
+ "grid": "Решетка",
+ "table": "Таблица"
+ }
+ },
+ "message": {
+ "note": "ЗАБЕЛЕЖКА",
+ "transcodingDisabled": "Промяната на конфигурацията за транскодиране през уеб интерфейса е забранена от съображения за сигурност. Ако желаете да промените (редактирате или добавите) опциите за транскодиране, рестартирайте сървъра с конфигурационната опция %{config}.",
+ "transcodingEnabled": "Navidrome в момента работи с %{config}, което прави възможно стартирането на системни команди от настройките за транскодиране с помощта на уеб интерфейса. Препоръчваме да го деактивирате от съображения за сигурност и да го активирате само при конфигуриране на опциите за транскодиране.",
+ "songsAddedToPlaylist": "Добавена 1 песен към плейлиста |||| Добавени %{smart_count} песни към плейлиста",
+ "noPlaylistsAvailable": "Няма налични",
+ "delete_user_title": "Изтрий потребителя '%{name}'",
+ "delete_user_content": "Наистина ли желаете да изтриете този потребител и всичките му данни (включително плейлисти и предпочитания)?",
+ "notifications_blocked": "В настройките на браузъра сте блокирали известията за този сайт",
+ "notifications_not_available": "Този браузър не поддържа известия на работния плот или нямате достъп до Navidrome през https",
+ "lastfmLinkSuccess": "Връзката с Last.fm е успешна! Scrobbling е активиран",
+ "lastfmLinkFailure": "Last.fm не можа да бъде свързан",
+ "lastfmUnlinkSuccess": "Връзката с Last.fm е прекъсната! Scrobbling е деактивиран",
+ "lastfmUnlinkFailure": "Last.fm връзката не можа да бъде премахната",
+ "openIn": {
+ "lastfm": "Отвори в Last.fm",
+ "musicbrainz": "Отвори в MusicBrainz"
+ },
+ "lastfmLink": "Прочетете още...",
+ "listenBrainzLinkSuccess": "Връзката с ListenBrainz е успешна! Scrobbling е активиран от името на потребителя: %{user}",
+ "listenBrainzLinkFailure": "ListenBrainz не можа да бъде свързан: %{error}",
+ "listenBrainzUnlinkSuccess": "Връзката с ListenBrainz е прекъсната! Scrobbling е деактивиран",
+ "listenBrainzUnlinkFailure": "Връзката с ListenBrainz не можа да бъде прекратена",
+ "downloadOriginalFormat": "Свали в оригиналния формат",
+ "shareOriginalFormat": "Сподели в оригинален формат",
+ "shareDialogTitle": "Сподели %{resource} '%{name}'",
+ "shareBatchDialogTitle": "Сподели 1 %{resource} |||| Сподели %{smart_count} %{resource}",
+ "shareSuccess": "Адресът е копиран в клипборда: %{url}",
+ "shareFailure": "Грешка при копиране на адрес %{url} в клипборда",
+ "downloadDialogTitle": "Сваляне %{resource} '%{name}' (%{size})",
+ "shareCopyToClipboard": "Копиране в клипборда: Ctrl+C, Enter",
+ "remove_missing_title": "Премахни липсващите файлове",
+ "remove_missing_content": "Сигурни ли сте, че желаете да премахнете избраните липсващи файлове от базата данни? Това ще премахне завинаги всички препратки към тях, включително броя на възпроизвежданията и оценките им.",
+ "remove_all_missing_title": "Премахни всички липсващи файлове",
+ "remove_all_missing_content": "Сигурни ли сте, че желаете да премахнете всички липсващи файлове от базата данни? Това ще премахне завинаги всички препратки към тях, включително броя на възпроизвежданията и оценките им.",
+ "noSimilarSongsFound": "",
+ "noTopSongsFound": ""
+ },
+ "menu": {
+ "library": "Библиотека",
+ "settings": "Настройки",
+ "version": "Версия",
+ "theme": "Тема",
+ "personal": {
+ "name": "Лични",
+ "options": {
+ "theme": "Тема",
+ "language": "Език",
+ "defaultView": "Изглед по подразбиране",
+ "desktop_notifications": "Известия на работния плот",
+ "lastfmScrobbling": "Scrobble към Last.fm",
+ "listenBrainzScrobbling": "Scrobble към ListenBrainz",
+ "replaygain": "Режим ReplayGain",
+ "preAmp": "ReplayGain PreAmp (dB)",
+ "gain": {
+ "none": "Изключен",
+ "album": "Използвай Album Gain",
+ "track": "Използвай Track Gain"
+ },
+ "lastfmNotConfigured": "API ключът на Last.fm не е конфигуриран"
+ }
+ },
+ "albumList": "Албуми",
+ "about": "Относно",
+ "playlists": "Плейлисти",
+ "sharedPlaylists": "Споделени плейлисти",
+ "librarySelector": {
+ "allLibraries": "",
+ "multipleLibraries": "",
+ "selectLibraries": "",
+ "none": ""
+ }
+ },
+ "player": {
+ "playListsText": "Списък с песни",
+ "openText": "Отвори",
+ "closeText": "Затвори",
+ "notContentText": "Няма песни",
+ "clickToPlayText": "Пускане",
+ "clickToPauseText": "Пауза",
+ "nextTrackText": "Следваща песен",
+ "previousTrackText": "Предишна песен",
+ "reloadText": "Презареди",
+ "volumeText": "Сила на звука",
+ "toggleLyricText": "Текст на песен",
+ "toggleMiniModeText": "Минимизирай",
+ "destroyText": "Унищожи",
+ "downloadText": "Свали",
+ "removeAudioListsText": "Изтриване на плейлисти",
+ "clickToDeleteText": "Кликнете, за да изтриете %{name}",
+ "emptyLyricText": "Няма текст",
+ "playModeText": {
+ "order": "По ред",
+ "orderLoop": "Повтаряй всички",
+ "singleLoop": "Повтаряй същата",
+ "shufflePlay": "Разбъркай"
+ }
+ },
+ "about": {
+ "links": {
+ "homepage": "Начална страница",
+ "source": "Програмен код",
+ "featureRequests": "Заявете функционалност",
+ "lastInsightsCollection": "",
+ "insights": {
+ "disabled": "Деактивиран",
+ "waiting": "Изчакване"
+ }
+ },
+ "tabs": {
+ "about": "Относно",
+ "config": "Конфигурация"
+ },
+ "config": {
+ "configName": "Име на конфигурация",
+ "environmentVariable": "Променлива на средата",
+ "currentValue": "Текуща стойност",
+ "configurationFile": "",
+ "exportToml": "Експортиране на конфигурация (TOML)",
+ "exportSuccess": "Конфигурация, експортирана в клипборда във формат TOML",
+ "exportFailed": "Неуспешно копиране на конфигурация",
+ "devFlagsHeader": "",
+ "devFlagsComment": ""
+ }
+ },
+ "activity": {
+ "title": "Действия",
+ "totalScanned": "Сканирани папки",
+ "quickScan": "Бързо сканиране",
+ "fullScan": "Пълно сканиране",
+ "serverUptime": "Сървърът работи",
+ "serverDown": "ОФЛАЙН",
+ "scanType": "Последно сканиране",
+ "status": "Грешка при сканиране",
+ "elapsedTime": "Изминало време",
+ "selectiveScan": ""
+ },
+ "help": {
+ "title": "Бързи клавиши на Navidrome",
+ "hotkeys": {
+ "show_help": "Показва този помощен текст",
+ "toggle_menu": "Превключване на страничната меню лента",
+ "toggle_play": "Пусни / Пауза",
+ "prev_song": "Предишна песен",
+ "next_song": "Следваща песен",
+ "vol_up": "Увеличи звука",
+ "vol_down": "Намали звука",
+ "toggle_love": "Добави песента към любими",
+ "current_song": "Премини към текущата песен"
+ }
+ },
+ "nowPlaying": {
+ "title": "",
+ "empty": "",
+ "minutesAgo": ""
+ }
}
\ No newline at end of file
diff --git a/resources/i18n/da.json b/resources/i18n/da.json
index 105a20732..550c8841a 100644
--- a/resources/i18n/da.json
+++ b/resources/i18n/da.json
@@ -83,7 +83,7 @@
"actions": {
"playAll": "Afspil",
"playNext": "Afspil næste",
- "addToQueue": "Afspil senere",
+ "addToQueue": "Føj til kø",
"shuffle": "Bland",
"addToPlaylist": "Føj til afspilningsliste",
"download": "Download",
@@ -301,14 +301,19 @@
"actions": {
"scan": "Scanningsbibliotek",
"manageUsers": "Administrer brugeradgang",
- "viewDetails": "Se detaljer"
+ "viewDetails": "Se detaljer",
+ "quickScan": "hurtig skanning",
+ "fullScan": "Fuld skanning"
},
"notifications": {
"created": "Bibliotek oprettet",
"updated": "Biblioteket er blevet opdateret",
"deleted": "Biblioteket er blevet slettet",
"scanStarted": "Biblioteksscanning startet",
- "scanCompleted": "Biblioteksscanning fuldført"
+ "scanCompleted": "Biblioteksscanning fuldført",
+ "quickScanStarted": "hurtig skanning startet",
+ "fullScanStarted": "Fuld skanning startet",
+ "scanError": "Kan ikke starte skanning. Tjek loggen"
},
"validation": {
"nameRequired": "Biblioteksnavn er påkrævet",
@@ -549,7 +554,7 @@
"closeText": "Luk",
"notContentText": "Ingen musik",
"clickToPlayText": "Tryk for at afspille",
- "clickToPauseText": "Tryk for at pause",
+ "clickToPauseText": "Tryk for at sætte på pause",
"nextTrackText": "Næste nummer",
"previousTrackText": "Forrige nummer",
"reloadText": "Genindlæs",
@@ -604,7 +609,8 @@
"serverDown": "OFFLINE",
"scanType": "Type",
"status": "Scanningsfejl",
- "elapsedTime": "Medgået tid"
+ "elapsedTime": "Medgået tid",
+ "selectiveScan": "Selektiv"
},
"help": {
"title": "Navidrome genvejstaster",
diff --git a/resources/i18n/de.json b/resources/i18n/de.json
index c9c7fa7f5..22e2fab44 100644
--- a/resources/i18n/de.json
+++ b/resources/i18n/de.json
@@ -301,14 +301,19 @@
"actions": {
"scan": "Bibliothek scannen",
"manageUsers": "Zugriff verwalten",
- "viewDetails": "Details ansehen"
+ "viewDetails": "Details ansehen",
+ "quickScan": "Schneller Scan",
+ "fullScan": "Kompletter Scan"
},
"notifications": {
"created": "Bibliothek erfolgreich erstellt",
"updated": "Bibliothek erfolgreich geändert",
"deleted": "Bibliothek erfolgreich gelöscht",
"scanStarted": "Bibliothek Scan gestartet",
- "scanCompleted": "Bibliothek Scan vollständig"
+ "scanCompleted": "Bibliothek Scan vollständig",
+ "quickScanStarted": "Schneller Scan gestartet",
+ "fullScanStarted": "Kompletter Scan gestartet",
+ "scanError": "Fehler beim Starten des Scans. Logs prüfen"
},
"validation": {
"nameRequired": "Bibliotheksname ist Pflichtfeld",
@@ -604,7 +609,8 @@
"serverDown": "OFFLINE",
"scanType": "Typ",
"status": "Scan Fehler",
- "elapsedTime": "Laufzeit"
+ "elapsedTime": "Laufzeit",
+ "selectiveScan": "Selektiver Scan"
},
"help": {
"title": "Navidrome Hotkeys",
diff --git a/resources/i18n/el.json b/resources/i18n/el.json
index 0d9ee05c5..4dd58e9cc 100644
--- a/resources/i18n/el.json
+++ b/resources/i18n/el.json
@@ -301,14 +301,19 @@
"actions": {
"scan": "Σάρωση βιβλιοθήκης",
"manageUsers": "Διαχείριση πρόσβασης χρήστη",
- "viewDetails": "Προβολή λεπτομερειών"
+ "viewDetails": "Προβολή λεπτομερειών",
+ "quickScan": "Γρήγορη σάρωση",
+ "fullScan": "Πλήρης σάρωση"
},
"notifications": {
"created": "Η βιβλιοθήκη δημιουργήθηκε με επιτυχία",
"updated": "Η βιβλιοθήκη ενημερώθηκε με επιτυχία",
"deleted": "Η βιβλιοθήκη διαγράφηκε με επιτυχία",
"scanStarted": "Ξεκίνησε η σάρωση της βιβλιοθήκης",
- "scanCompleted": "Η σάρωση της βιβλιοθήκης ολοκληρώθηκε"
+ "scanCompleted": "Η σάρωση της βιβλιοθήκης ολοκληρώθηκε",
+ "quickScanStarted": "Η Γρήγορη Σάρωση ξεκίνησε",
+ "fullScanStarted": "Η πλήρης σάρωση ξεκίνησε",
+ "scanError": "Σφάλμα κατά την έναρξη της σάρωσης. Ελέγξτε τα αρχεία καταγραφής."
},
"validation": {
"nameRequired": "Απαιτείται όνομα βιβλιοθήκης",
@@ -604,7 +609,8 @@
"serverDown": "ΕΚΤΟΣ ΣΥΝΔΕΣΗΣ",
"scanType": "Τύπος",
"status": "Σφάλμα σάρωσης",
- "elapsedTime": "Χρόνος που πέρασε"
+ "elapsedTime": "Χρόνος που πέρασε",
+ "selectiveScan": "Εκλεκτικός"
},
"help": {
"title": "Συντομεύσεις του Navidrome",
diff --git a/resources/i18n/eo.json b/resources/i18n/eo.json
index bdf143969..7a13c471d 100644
--- a/resources/i18n/eo.json
+++ b/resources/i18n/eo.json
@@ -27,15 +27,16 @@
"playDate": "Laste Ludita",
"channels": "Kanaloj",
"createdAt": "Dato de aligo",
- "grouping": "",
+ "grouping": "Grupo",
"mood": "Humoro",
- "participants": "",
+ "participants": "Aldonaj partoprenantoj",
"tags": "Aldonaj Etikedoj",
"mappedTags": "Mapigitaj etikedoj",
"rawTags": "Krudaj etikedoj",
- "bitDepth": "",
- "sampleRate": "",
- "missing": ""
+ "bitDepth": "Bitprofundo",
+ "sampleRate": "Elprena rapido",
+ "missing": "Mankaj",
+ "libraryName": "Biblioteko"
},
"actions": {
"addToQueue": "Ludi Poste",
@@ -44,7 +45,8 @@
"shuffleAll": "Miksu Ĉiujn",
"download": "Elŝuti",
"playNext": "Ludu Poste",
- "info": "Akiri Informon"
+ "info": "Akiri Informon",
+ "showInPlaylist": "Montri en Ludlisto"
}
},
"album": {
@@ -68,14 +70,15 @@
"releaseDate": "Publikiĝis",
"releases": "Publikiĝo |||| Publikiĝoj",
"released": "Publikiĝis",
- "recordLabel": "",
- "catalogNum": "",
+ "recordLabel": "Eldonejo",
+ "catalogNum": "Kataloga Numero",
"releaseType": "Tipo",
- "grouping": "",
- "media": "",
+ "grouping": "Grupo",
+ "media": "Aŭdvidaĵo",
"mood": "Humoro",
- "date": "",
- "missing": ""
+ "date": "Registraĵa Dato",
+ "missing": "Mankaj",
+ "libraryName": "Biblioteko"
},
"actions": {
"playAll": "Ludi",
@@ -107,8 +110,8 @@
"rating": "Takso",
"genre": "Ĝenro",
"size": "Grando",
- "role": "",
- "missing": ""
+ "role": "Rolo",
+ "missing": "Mankaj"
},
"roles": {
"albumartist": "Albuma Artisto |||| Albumaj Artistoj",
@@ -117,13 +120,19 @@
"conductor": "Dirigento |||| Dirigentoj",
"lyricist": "Kantoteksisto |||| Kantotekstistoj",
"arranger": "Aranĝisto |||| Aranĝistoj",
- "producer": "",
- "director": "",
- "engineer": "",
+ "producer": "Produktisto |||| Produktistoj",
+ "director": "Direktoro |||| Direktoroj",
+ "engineer": "Inĝeniero |||| Inĝenieroj",
"mixer": "Miksisto |||| Miksistoj",
"remixer": "Remiksisto |||| Remiksistoj",
- "djmixer": "",
- "performer": ""
+ "djmixer": "Dĵ-a Miksisto |||| Dĵ-a Miksistoj",
+ "performer": "Plenumisto |||| Plenumistoj",
+ "maincredit": "Albuma Artisto aŭ Artisto |||| Albumaj Artistoj aŭ Artistoj"
+ },
+ "actions": {
+ "shuffle": "Miksi",
+ "radio": "Radio",
+ "topSongs": "Plej Luditaj Kantoj"
}
},
"user": {
@@ -140,10 +149,12 @@
"currentPassword": "Nuna Pasvorto",
"newPassword": "Nova Pasvorto",
"token": "Ĵetono",
- "lastAccessAt": "Lasta Atingo"
+ "lastAccessAt": "Lasta Atingo",
+ "libraries": "Bibliotekoj"
},
"helperTexts": {
- "name": "Ŝanĝoj de via nomo nur ĝisdatiĝs je via sekvanta ensaluto"
+ "name": "Ŝanĝoj de via nomo nur ĝisdatiĝs je via sekvanta ensaluto",
+ "libraries": "Elekti specifajn bibliotekojn por ĉi tiu uzanto, aŭ lasi malplena por uzi defaŭltajn bibliotekojn"
},
"notifications": {
"created": "Uzanto farita",
@@ -152,7 +163,12 @@
},
"message": {
"listenBrainzToken": "Enigi vian uzantan ĵetonon de ListenBrainz.",
- "clickHereForToken": "Alkakli ĉi tie por akiri vian ĵetonon"
+ "clickHereForToken": "Alkakli ĉi tie por akiri vian ĵetonon",
+ "selectAllLibraries": "Elekti ĉiujn bibliotekojn",
+ "adminAutoLibraries": "Administrantoj aŭtomate havas aliron al ĉiuj bibliotekoj"
+ },
+ "validation": {
+ "librariesRequired": "Almenaŭ unu biblioteko devas esti elektita por neadministrantoj"
}
},
"player": {
@@ -197,11 +213,16 @@
"export": "Eksporti",
"makePublic": "Publikigi",
"makePrivate": "Malpublikigi",
- "saveQueue": ""
+ "saveQueue": "Konservi Ludvicon al Ludlisto",
+ "searchOrCreate": "Serĉi ludlistojn aŭ tajpi por krei novan...",
+ "pressEnterToCreate": "Premu je Enter por krei novan ludliston",
+ "removeFromSelection": "Forigi de elekto"
},
"message": {
"duplicate_song": "Aldoni duobligitajn kantojn",
- "song_exist": "Estas duoblaĵoj kiuj aldoniĝas al la kantolisto. Ĉu vi ŝatus aldoni la duoblaĵojn aŭ pasigi ilin?"
+ "song_exist": "Estas duoblaĵoj kiuj aldoniĝas al la kantolisto. Ĉu vi ŝatus aldoni la duoblaĵojn aŭ pasigi ilin?",
+ "noPlaylistsFound": "Neniuj ludlistoj trovitaj",
+ "noPlaylists": "Neniuj ludlistoj haveblaj"
}
},
"radio": {
@@ -235,20 +256,78 @@
}
},
"missing": {
- "name": "",
+ "name": "Manka Dosiero |||| Mankaj Dosieroj",
"fields": {
- "path": "",
- "size": "",
- "updatedAt": ""
+ "path": "Vojo",
+ "size": "Grando",
+ "updatedAt": "Malaperis je",
+ "libraryName": "Biblioteko"
},
"actions": {
- "remove": "",
- "remove_all": ""
+ "remove": "Forigi",
+ "remove_all": "Forigi Ĉiujn"
},
"notifications": {
- "removed": ""
+ "removed": "Manka(j) dosiero(j) forigite"
},
- "empty": ""
+ "empty": "Neniuj Mankaj Dosieroj"
+ },
+ "library": {
+ "name": "Biblioteko |||| Bibliotekoj",
+ "fields": {
+ "name": "Nomo",
+ "path": "Vojo",
+ "remotePath": "Fora Vojo",
+ "lastScanAt": "Plej Lasta Skano",
+ "songCount": "Kantoj",
+ "albumCount": "Albumoj",
+ "artistCount": "Artistoj",
+ "totalSongs": "Kantoj",
+ "totalAlbums": "Albumoj",
+ "totalArtists": "Artistoj",
+ "totalFolders": "Dosierujoj",
+ "totalFiles": "Dosieroj",
+ "totalMissingFiles": "Mankaj Dosieroj",
+ "totalSize": "Totala Grando",
+ "totalDuration": "Daŭro",
+ "defaultNewUsers": "Defaŭlto por Novaj Uzantoj",
+ "createdAt": "Farite je",
+ "updatedAt": "Ĝisdatiĝis je"
+ },
+ "sections": {
+ "basic": "Bazaj Informoj",
+ "statistics": "Statistikaĵoj"
+ },
+ "actions": {
+ "scan": "Skani Bibliotekon",
+ "manageUsers": "Agordi Uzantan Aliron",
+ "viewDetails": "Montri Informojn",
+ "quickScan": "Rapida Skano",
+ "fullScan": "Plena Skano"
+ },
+ "notifications": {
+ "created": "Biblioteko kreiĝis sukcese",
+ "updated": "Biblioteko ĝisdatiĝis sukcese",
+ "deleted": "Biblioteko foriĝis sukcese",
+ "scanStarted": "Biblioteka skano komenciĝis",
+ "scanCompleted": "Biblioteka skano finiĝis",
+ "quickScanStarted": "Rapida skano komenciĝis",
+ "fullScanStarted": "Plena skano komenciĝis",
+ "scanError": "Eraro de skana komenco. Kontrolu la protokolojn"
+ },
+ "validation": {
+ "nameRequired": "Biblioteka nomo estas necesa",
+ "pathRequired": "Biblioteka vojo estas necesa",
+ "pathNotDirectory": "Biblioteka vojo devas esti dosierujo",
+ "pathNotFound": "Biblioteka vojo ne trovite",
+ "pathNotAccessible": "Biblioteka vojo ne estas alirebla",
+ "pathInvalid": "Nevalida biblioteka vojo"
+ },
+ "messages": {
+ "deleteConfirm": "Ĉu vi certas, ke vi volas forigi ĉi tiun bibliotekon? Ĉi tio forigos ĉiujn rilatajn datumojn kaj uzantan aliron.",
+ "scanInProgress": "Skano progresas...",
+ "noLibrariesAssigned": "Neniuj bibliotekoj asignitaj por ĉi tiu uzanto"
+ }
}
},
"ra": {
@@ -427,10 +506,12 @@
"shareFailure": "Eraro de kopio de ligilo %{url} al la tondujo",
"downloadDialogTitle": "Elŝuti %{resource} '%{name}' (%{size})",
"shareCopyToClipboard": "Kopii al la tondujo: Ctrl+C, Enter",
- "remove_missing_title": "",
+ "remove_missing_title": "Forigi mankajn dosierojn",
"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_all_missing_title": "Forigi ĉiujn mankajn dosierojn",
+ "remove_all_missing_content": "Ĉu vi certas, ke vi volas forigi ĉiujn mankajn dosierojn de la datumbazo? Ĉi tio permanante forigos ĉiujn referencojn al ili, inkluzive iliajn ludnombrojn kaj taksojn.",
+ "noSimilarSongsFound": "Neniuj similaj kantoj trovitaj",
+ "noTopSongsFound": "Neniuj plej luditaj kantoj trovitaj"
},
"menu": {
"library": "Biblioteko",
@@ -453,13 +534,19 @@
"album": "Uzi Albuman Songajnon",
"track": "Uzi Kantan Songajnon"
},
- "lastfmNotConfigured": ""
+ "lastfmNotConfigured": "API-ŝlosilo de Last.fm ne agordita"
}
},
"albumList": "Albumoj",
"about": "Pri",
"playlists": "Ludlistoj",
- "sharedPlaylists": "Diskonigitaj Ludistoj"
+ "sharedPlaylists": "Diskonigitaj Ludistoj",
+ "librarySelector": {
+ "allLibraries": "Ĉiuj Bibliotekoj (%{count})",
+ "multipleLibraries": "%{selected} el %{total} Bibliotekoj",
+ "selectLibraries": "Elekti Bibliotekojn",
+ "none": "Neniu"
+ }
},
"player": {
"playListsText": "Atendovico",
@@ -491,11 +578,26 @@
"homepage": "Hejmpaĝo",
"source": "Fontkodo",
"featureRequests": "Trajta peto",
- "lastInsightsCollection": "",
+ "lastInsightsCollection": "Plej lasta kolekto de datumoj",
"insights": {
"disabled": "Malebligita",
- "waiting": ""
+ "waiting": "Atendante"
}
+ },
+ "tabs": {
+ "about": "Pri",
+ "config": "Agordo"
+ },
+ "config": {
+ "configName": "Agorda Nomo",
+ "environmentVariable": "Medivariablo",
+ "currentValue": "Nuna Valoro",
+ "configurationFile": "Agorda Dosiero",
+ "exportToml": "Eksporti Agordojn (TOML)",
+ "exportSuccess": "Agordoj eksportiĝis al la tondujo en TOML-a formato",
+ "exportFailed": "Malsukcesis kopii agordojn",
+ "devFlagsHeader": "Programadaj Flagoj (povas ŝanĝiĝi/foriĝi)",
+ "devFlagsComment": "Ĉi tiuj estas eksperimentaj agordoj kaj eble foriĝos en estontaj versioj"
}
},
"activity": {
@@ -505,9 +607,10 @@
"fullScan": "Plena Skanado",
"serverUptime": "Servila daŭro de funkciado",
"serverDown": "SENKONEKTA",
- "scanType": "",
- "status": "",
- "elapsedTime": ""
+ "scanType": "Plej Lasta Skano",
+ "status": "Skana Eraro",
+ "elapsedTime": "Pasinta Tempo",
+ "selectiveScan": "Selektema"
},
"help": {
"title": "Navidrome klavkomando",
@@ -519,8 +622,13 @@
"next_song": "Sekva kanto",
"vol_up": "Pli volumo",
"vol_down": "Malpli volumo",
- "toggle_love": "Baskuli la stelon de nuna kanto",
+ "toggle_love": "Aldoni ĉi tiun kanton al plej ŝatataj",
"current_song": "Iri al Nuna Kanto"
}
+ },
+ "nowPlaying": {
+ "title": "Nun Ludanta",
+ "empty": "Nenio ludas",
+ "minutesAgo": "Antaŭ %{smart_count} minuto |||| Antaŭ %{smart_count} minutoj"
}
}
\ No newline at end of file
diff --git a/resources/i18n/es.json b/resources/i18n/es.json
index 4c53b8986..c620d773f 100644
--- a/resources/i18n/es.json
+++ b/resources/i18n/es.json
@@ -36,7 +36,7 @@
"bitDepth": "Profundidad de bits",
"sampleRate": "Frecuencia de muestreo",
"missing": "Faltante",
- "libraryName": ""
+ "libraryName": "Biblioteca"
},
"actions": {
"addToQueue": "Reproducir después",
@@ -78,7 +78,7 @@
"mood": "Estado de ánimo",
"date": "Fecha de grabación",
"missing": "Faltante",
- "libraryName": ""
+ "libraryName": "Biblioteca"
},
"actions": {
"playAll": "Reproducir",
@@ -127,12 +127,12 @@
"remixer": "Remixer",
"djmixer": "DJ Mixer",
"performer": "Intérprete",
- "maincredit": ""
+ "maincredit": "Artista del álbum o Artista |||| Artistas del álbum o Artistas"
},
"actions": {
"shuffle": "Aleatorio",
"radio": "Radio",
- "topSongs": ""
+ "topSongs": "Más destacadas"
}
},
"user": {
@@ -150,11 +150,11 @@
"newPassword": "Nueva contraseña",
"token": "Token",
"lastAccessAt": "Último acceso",
- "libraries": ""
+ "libraries": "Bibliotecas"
},
"helperTexts": {
"name": "Los cambios a tu nombre se verán en el próximo inicio de sesión",
- "libraries": ""
+ "libraries": "Selecciona bibliotecas específicas para este usuario o déjalo vacío para usar las bibliotecas por defecto"
},
"notifications": {
"created": "Usuario creado",
@@ -164,11 +164,11 @@
"message": {
"listenBrainzToken": "Escribe tu token de usuario de ListenBrainz",
"clickHereForToken": "Click aquí para obtener tu token",
- "selectAllLibraries": "",
- "adminAutoLibraries": ""
+ "selectAllLibraries": "Seleccionar todas las bibliotecas",
+ "adminAutoLibraries": "Los usuarios administradores tienen acceso a todas las bibliotecas automáticamente"
},
"validation": {
- "librariesRequired": ""
+ "librariesRequired": "Se debe seleccionar al menos una biblioteca para los usuarios que no sean administradores"
}
},
"player": {
@@ -261,7 +261,7 @@
"path": "Ruta",
"size": "Tamaño",
"updatedAt": "Actualizado el",
- "libraryName": ""
+ "libraryName": "Biblioteca"
},
"actions": {
"remove": "Eliminar",
@@ -273,55 +273,60 @@
"empty": "No hay archivos perdidos"
},
"library": {
- "name": "",
+ "name": "Biblioteca |||| Bibliotecas",
"fields": {
- "name": "",
- "path": "",
- "remotePath": "",
- "lastScanAt": "",
- "songCount": "",
- "albumCount": "",
- "artistCount": "",
- "totalSongs": "",
- "totalAlbums": "",
- "totalArtists": "",
- "totalFolders": "",
- "totalFiles": "",
- "totalMissingFiles": "",
- "totalSize": "",
- "totalDuration": "",
- "defaultNewUsers": "",
- "createdAt": "",
- "updatedAt": ""
+ "name": "Nombre",
+ "path": "Ruta",
+ "remotePath": "Ruta remota",
+ "lastScanAt": "Último escaneo",
+ "songCount": "Canciones",
+ "albumCount": "Álbumes",
+ "artistCount": "Artistas",
+ "totalSongs": "Canciones",
+ "totalAlbums": "Álbumes",
+ "totalArtists": "Artistas",
+ "totalFolders": "Carpetas",
+ "totalFiles": "Archivos",
+ "totalMissingFiles": "Archivos faltantes",
+ "totalSize": "Tamaño total",
+ "totalDuration": "Duración",
+ "defaultNewUsers": "Valor por defecto para los nuevos usuarios",
+ "createdAt": "Creado",
+ "updatedAt": "Actualizado"
},
"sections": {
- "basic": "",
- "statistics": ""
+ "basic": "Información básica",
+ "statistics": "Estadísticas"
},
"actions": {
- "scan": "",
- "manageUsers": "",
- "viewDetails": ""
+ "scan": "Escanear biblioteca",
+ "manageUsers": "Gestionar el acceso de usarios",
+ "viewDetails": "Ver detalles",
+ "quickScan": "Escaneo rápido",
+ "fullScan": "Escaneo completo"
},
"notifications": {
- "created": "",
- "updated": "",
- "deleted": "",
- "scanStarted": "",
- "scanCompleted": ""
+ "created": "La biblioteca se creó correctamente",
+ "updated": "La biblioteca se actualizó correctamente",
+ "deleted": "La biblioteca se eliminó correctamente",
+ "scanStarted": "El escaneo de la biblioteca ha comenzado",
+ "scanCompleted": "El escaneo de la biblioteca se completó",
+ "quickScanStarted": "Escaneo rápido ha comenzado",
+ "fullScanStarted": "Escaneo completo ha comenzado",
+ "scanError": "Error al iniciar el escaneo. Revisa los registros"
},
"validation": {
- "nameRequired": "",
- "pathRequired": "",
- "pathNotDirectory": "",
- "pathNotFound": "",
- "pathNotAccessible": "",
- "pathInvalid": ""
+ "nameRequired": "El nombre de la biblioteca es obligatorio",
+ "pathRequired": "La ruta de la biblioteca es obligatoria",
+ "pathNotDirectory": "La ruta de la biblioteca debe ser un directorio",
+ "pathNotFound": "Ruta de la biblioteca no encontrada",
+ "pathNotAccessible": "La ruta de la biblioteca no es accesible",
+ "pathInvalid": "Ruta de la biblioteca no válida"
},
"messages": {
- "deleteConfirm": "",
- "scanInProgress": "",
- "noLibrariesAssigned": ""
+ "deleteConfirm": "¿Estás seguro/a de que quieres eliminar esta biblioteca? Esto eliminará todos los datos asociados y el acceso de les usuaries.",
+ "scanInProgress": "Escaneo en curso...",
+ "noLibrariesAssigned": "No hay bibliotecas asignadas a este usuario"
}
}
},
@@ -506,7 +511,7 @@
"remove_all_missing_title": "Eliminar todos los archivos perdidos",
"remove_all_missing_content": "¿Realmente desea eliminar todos los archivos faltantes de la base de datos? Esto eliminará permanentemente cualquier referencia a ellos, incluidas sus reproducciones y valoraciones.",
"noSimilarSongsFound": "No se encontraron canciones similares",
- "noTopSongsFound": ""
+ "noTopSongsFound": "No se encontraron canciones destacadas"
},
"menu": {
"library": "Biblioteca",
@@ -537,10 +542,10 @@
"playlists": "Playlists",
"sharedPlaylists": "Playlists Compartidas",
"librarySelector": {
- "allLibraries": "",
- "multipleLibraries": "",
- "selectLibraries": "",
- "none": ""
+ "allLibraries": "Todas las bibliotecas (%{count})",
+ "multipleLibraries": "%{selected} de %{total} bibliotecas",
+ "selectLibraries": "Seleccionar bibliotecas",
+ "none": "Ninguno"
}
},
"player": {
@@ -604,7 +609,8 @@
"serverDown": "OFFLINE",
"scanType": "Tipo",
"status": "Error de escaneo",
- "elapsedTime": "Tiempo transcurrido"
+ "elapsedTime": "Tiempo transcurrido",
+ "selectiveScan": "Selectivo"
},
"help": {
"title": "Atajos de teclado de Navidrome",
@@ -621,8 +627,8 @@
}
},
"nowPlaying": {
- "title": "",
- "empty": "",
- "minutesAgo": ""
+ "title": "En reproducción",
+ "empty": "Nada en reproducción",
+ "minutesAgo": "Hace %{smart_count} minuto |||| Hace %{smart_count} minutos"
}
}
\ No newline at end of file
diff --git a/resources/i18n/eu.json b/resources/i18n/eu.json
index cb5927a74..0c968e2c4 100644
--- a/resources/i18n/eu.json
+++ b/resources/i18n/eu.json
@@ -12,6 +12,7 @@
"artist": "Artista",
"album": "Albuma",
"path": "Fitxategiaren bidea",
+ "libraryName": "Liburutegia",
"genre": "Generoa",
"compilation": "Konpilazioa",
"year": "Urtea",
@@ -58,6 +59,7 @@
"playCount": "Erreprodukzioak",
"size": "Fitxategiaren tamaina",
"name": "Izena",
+ "libraryName": "Liburutegia",
"genre": "Generoa",
"compilation": "Konpilazioa",
"year": "Urtea",
@@ -147,19 +149,26 @@
"currentPassword": "Uneko pasahitza",
"newPassword": "Pasahitz berria",
"token": "Tokena",
- "lastAccessAt": "Azken sarbidea"
+ "lastAccessAt": "Azken sarbidea",
+ "libraries": "Liburutegiak"
},
"helperTexts": {
- "name": "Aldaketak saioa hasten duzun hurrengoan islatuko dira"
+ "name": "Aldaketak saioa hasten duzun hurrengoan islatuko dira",
+ "libraries": "Hautatu erabiltzaile honentzat liburutegi jakinak, edo utzi hutsik defektuzko liburutegiak erabiltzeko"
},
"notifications": {
"created": "Erabiltzailea sortu da",
"updated": "Erabiltzailea eguneratu da",
"deleted": "Erabiltzailea ezabatu da"
},
+ "validation": {
+ "librariesRequired": "Gutxienez liburutegi bat hautatu behar da administratzaile ez diren erabiltzaileentzat"
+ },
"message": {
"listenBrainzToken": "Idatzi zure ListenBrainz erabiltzailearen tokena",
- "clickHereForToken": "Egin klik hemen tokena lortzeko"
+ "clickHereForToken": "Egin klik hemen tokena lortzeko",
+ "selectAllLibraries": "Hautatu liburutegi guztiak",
+ "adminAutoLibraries": "Administratzaileek automatikoki dute liburutegi guztietara sarbidea"
}
},
"player": {
@@ -254,6 +263,7 @@
"fields": {
"path": "Bidea",
"size": "Tamaina",
+ "libraryName": "Liburutegia",
"updatedAt": "Desagertze-data:"
},
"actions": {
@@ -263,6 +273,58 @@
"notifications": {
"removed": "Aurkitzen ez ziren fitxategiak kendu dira"
}
+ },
+ "library": {
+ "name": "Liburutegia |||| Liburutegiak",
+ "fields": {
+ "name": "Izena",
+ "path": "Fitxategiaren bidea",
+ "remotePath": "Urruneko bidea",
+ "lastScanAt": "Azken araketa",
+ "songCount": "Abestiak",
+ "albumCount": "Albumak",
+ "artistCount": "Artistak",
+ "totalSongs": "Abestiak",
+ "totalAlbums": "Albumak",
+ "totalArtists": "Artistak",
+ "totalFolders": "Karpetak",
+ "totalFiles": "Fitxategiak",
+ "totalMissingFiles": "Fitxategiak faltan",
+ "totalSize": "Tamaina guztira",
+ "totalDuration": "Iraupena",
+ "defaultNewUsers": "Defektuz erabiltzaile berrientzat",
+ "createdAt": "Sortze-data",
+ "updatedAt": "Eguneratze-data"
+ },
+ "sections": {
+ "basic": "Oinarrizko informazioa",
+ "statistics": "Estatistikak"
+ },
+ "actions": {
+ "scan": "Arakatu liburutegia",
+ "manageUsers": "Kudeatu erabiltzaileen sarbidea",
+ "viewDetails": "Ikusi xehetasunak"
+ },
+ "notifications": {
+ "created": "Liburutegia ondo sortu da",
+ "updated": "Liburutegia ondo eguneratu da",
+ "deleted": "Liburutegia ondo ezabatu da",
+ "scanStarted": "Liburutegiaren araketa hasi da",
+ "scanCompleted": "Liburutegiaren araketa amaitu da"
+ },
+ "validation": {
+ "nameRequired": "Liburutegiaren izena beharrezkoa da",
+ "pathRequired": "Liburutegiaren bidea beharrezkoa da",
+ "pathNotDirectory": "Liburutegiaren bidea direktorio bat izan behar da",
+ "pathNotFound": "Ez da liburutegiaren bidea aurkitu",
+ "pathNotAccessible": "Liburutegiaren bidea ez dago eskuragai",
+ "pathInvalid": "Liburutegiaren bidea ez da baliozkoa"
+ },
+ "messages": {
+ "deleteConfirm": "Ziur liburutegia ezabatu nahi duzula? Erlazionatutako datu guztiak eta erabiltzaileen sarbidea kenduko ditu.",
+ "scanInProgress": "Araketa abian da…",
+ "noLibrariesAssigned": "Ez da liburutegirik egokitu erabiltzaile honentzat"
+ }
}
},
"ra": {
@@ -450,6 +512,12 @@
},
"menu": {
"library": "Liburutegia",
+ "librarySelector": {
+ "allLibraries": "Liburutegi guztiak (%{count})",
+ "multipleLibraries": "%{total} liburutegitik %{selected} hautatuta",
+ "selectLibraries": "Hautatu liburutegiak",
+ "none": "Bat ere ez"
+ },
"settings": "Ezarpenak",
"version": "Bertsioa",
"theme": "Itxura",
diff --git a/resources/i18n/fi.json b/resources/i18n/fi.json
index e5ecea2ce..fc2793389 100644
--- a/resources/i18n/fi.json
+++ b/resources/i18n/fi.json
@@ -31,7 +31,7 @@
"mood": "Tunnelma",
"participants": "Lisäosallistujat",
"tags": "Lisätunnisteet",
- "mappedTags": "Mäpättyt tunnisteet",
+ "mappedTags": "Mäpätyt tunnisteet",
"rawTags": "Raakatunnisteet",
"bitDepth": "Bittisyvyys",
"sampleRate": "Näytteenottotaajuus",
@@ -301,14 +301,19 @@
"actions": {
"scan": "Skannaa kirjasto",
"manageUsers": "Hallitse käyttäjien pääsyä",
- "viewDetails": "Näytä tiedot"
+ "viewDetails": "Näytä tiedot",
+ "quickScan": "Nopea skannaus",
+ "fullScan": "Täysi skannaus"
},
"notifications": {
"created": "Kirjasto luotu onnistuneesti",
"updated": "Kirjasto päivitetty onnistuneesti",
"deleted": "Kirjasto poistettu onnistuneesti",
"scanStarted": "Kirjaston skannaus aloitettu",
- "scanCompleted": "Kirjaston skannaus valmistunut"
+ "scanCompleted": "Kirjaston skannaus valmistunut",
+ "quickScanStarted": "Nopea skannaus aloitettu",
+ "fullScanStarted": "Täysi skannaus aloitettu",
+ "scanError": "Virhe skannauksen käynnistyksessä. Tarkista lokit"
},
"validation": {
"nameRequired": "Kirjaston nimi vaaditaan",
@@ -319,7 +324,7 @@
"pathInvalid": "Virheellinen kirjaston polku"
},
"messages": {
- "deleteConfirm": "Oletko varma, että haluat poistaa tämän kirjaston? Tämä poistaa kaikki liittyvät tiedot ja käyttäjien pääsyn.",
+ "deleteConfirm": "Haluatko varmasti poistaa tämän kirjaston? Kaikki siihen liittyvät tiedot ja käyttäjien pääsy poistetaan.",
"scanInProgress": "Skannaus käynnissä...",
"noLibrariesAssigned": "Tälle käyttäjälle ei ole määritetty kirjastoja"
}
@@ -336,7 +341,7 @@
"username": "Käyttäjänimi",
"password": "Salasana",
"sign_in": "Kirjaudu",
- "sign_in_error": "Autentikointi epäonnistui. Yritä uudelleen",
+ "sign_in_error": "Kirjautuminen epäonnistui. Yritä uudelleen",
"logout": "Kirjaudu ulos",
"insightsCollectionNote": "Navidrome kerää anonyymejä käyttötietoja auttaakseen parantamaan\nprojektia. Paina [tästä] saadaksesi lisätietoa\nja halutessasi kieltäytyä"
},
@@ -346,7 +351,7 @@
"required": "Pakollinen",
"minLength": "Pitää vähintään olla %{min} merkkiä",
"maxLength": "Saa olla enintään %{max} merkkiä",
- "minValue": "pitää olla vähintään %{min}",
+ "minValue": "Pitää olla vähintään %{min}",
"maxValue": "Saa olla enentään %{max}",
"number": "Pitää olla numero",
"email": "Pitää olla oikea sähköpostiosoite",
@@ -440,7 +445,7 @@
},
"navigation": {
"no_results": "Ei tuloksia",
- "no_more_results": "Sivunumero %{page} on rajojen ulkopuolella. Kokeile edellinen sivu.",
+ "no_more_results": "Sivunumeroa %{page} ei löydy. Yritä edellistä sivua.",
"page_out_of_boundaries": "Sivunumero %{page} on rajojen ulkopuolella",
"page_out_from_end": "Viimeinen sivu, ei voi edetä",
"page_out_from_begin": "Ensimmäinen sivu, ei voi palata",
@@ -522,7 +527,7 @@
"desktop_notifications": "Työpöytäilmoitukset",
"lastfmScrobbling": "Kuuntelutottumuksen lähetys Last.fm-palveluun",
"listenBrainzScrobbling": "Kuuntelutottumuksen lähetys ListenBrainz-palveluun",
- "replaygain": "RepleyGain -tila",
+ "replaygain": "ReplayGain -tila",
"preAmp": "ReplayGain esivahvistus (dB)",
"gain": {
"none": "Pois käytöstä",
@@ -554,7 +559,7 @@
"previousTrackText": "Edellinen kappale",
"reloadText": "Päivitä",
"volumeText": "Äänenvoimakkuus",
- "toggleLyricText": "Toggle lyric",
+ "toggleLyricText": "Näytä/piilota sanat",
"toggleMiniModeText": "Minimoi",
"destroyText": "Poista",
"downloadText": "Lataa",
@@ -604,7 +609,8 @@
"serverDown": "SAMMUTETTU",
"scanType": "Tyyppi",
"status": "Skannausvirhe",
- "elapsedTime": "Kulunut aika"
+ "elapsedTime": "Kulunut aika",
+ "selectiveScan": "Valikoiva"
},
"help": {
"title": "Navidrome pikapainikkeet",
@@ -612,7 +618,7 @@
"show_help": "Näytä tämä apuvalikko",
"toggle_menu": "Menuvalikko päälle ja pois",
"toggle_play": "Toista / Tauko",
- "prev_song": "Esellinen kappale",
+ "prev_song": "Edellinen kappale",
"next_song": "Seuraava kappale",
"vol_up": "Kovemmalle",
"vol_down": "Hiljemmalle",
diff --git a/resources/i18n/fr.json b/resources/i18n/fr.json
index af3a8dd31..070e63977 100644
--- a/resources/i18n/fr.json
+++ b/resources/i18n/fr.json
@@ -301,14 +301,19 @@
"actions": {
"scan": "Scanner la bibliothèque",
"manageUsers": "Gérer les accès utilisateurs",
- "viewDetails": "Voir les détails"
+ "viewDetails": "Voir les détails",
+ "quickScan": "Scan Rapide",
+ "fullScan": "Scan Complet"
},
"notifications": {
"created": "Bibliothèque créée avec succès",
"updated": "Bibliothèque mise à jour avec succès",
"deleted": "Bibliothèque supprimée avec succès",
"scanStarted": "Le scan de la bibliothèque a commencé",
- "scanCompleted": "Le scan de la bibliothèque est terminé"
+ "scanCompleted": "Le scan de la bibliothèque est terminé",
+ "quickScanStarted": "Scan rapide démarré",
+ "fullScanStarted": "Scan complet démarré",
+ "scanError": "Une erreur est survenue en démarrant le scan. Veuillez regarder les logs"
},
"validation": {
"nameRequired": "La bibliothèque doit obligatoirement avoir un nom",
@@ -604,7 +609,8 @@
"serverDown": "HORS LIGNE",
"scanType": "Type",
"status": "Erreur de scan",
- "elapsedTime": "Temps écoulé"
+ "elapsedTime": "Temps écoulé",
+ "selectiveScan": "Sélectif"
},
"help": {
"title": "Raccourcis Navidrome",
diff --git a/resources/i18n/gl.json b/resources/i18n/gl.json
index 8cde597cc..a5f7ce0ce 100644
--- a/resources/i18n/gl.json
+++ b/resources/i18n/gl.json
@@ -31,8 +31,12 @@
"mood": "Estado",
"participants": "Participantes adicionais",
"tags": "Etiquetas adicionais",
- "mappedTags": "",
- "rawTags": "Etiquetas en cru"
+ "mappedTags": "Etiquetas mapeadas",
+ "rawTags": "Etiquetas en cru",
+ "bitDepth": "Calidade de Bit",
+ "sampleRate": "Taxa de mostra",
+ "missing": "Falta",
+ "libraryName": "Biblioteca"
},
"actions": {
"addToQueue": "Ao final da cola",
@@ -41,7 +45,8 @@
"shuffleAll": "Remexer todo",
"download": "Descargar",
"playNext": "A continuación",
- "info": "Obter info"
+ "info": "Obter info",
+ "showInPlaylist": "Mostrar en Lista de reprodución"
}
},
"album": {
@@ -70,7 +75,10 @@
"releaseType": "Tipo",
"grouping": "Grupos",
"media": "Multimedia",
- "mood": "Estado"
+ "mood": "Estado",
+ "date": "Data de gravación",
+ "missing": "Falta",
+ "libraryName": "Biblioteca"
},
"actions": {
"playAll": "Reproducir",
@@ -102,7 +110,8 @@
"rating": "Valoración",
"genre": "Xénero",
"size": "Tamaño",
- "role": "Rol"
+ "role": "Rol",
+ "missing": "Falta"
},
"roles": {
"albumartist": "Artista do álbum |||| Artistas do álbum",
@@ -117,7 +126,13 @@
"mixer": "Mistura |||| Mistura",
"remixer": "Remezcla |||| Remezcla",
"djmixer": "Mezcla DJs |||| Mezcla DJs",
- "performer": "Intérprete |||| Intérpretes"
+ "performer": "Intérprete |||| Intérpretes",
+ "maincredit": "Artista do álbum ou Artista |||| Artistas do álbum ou Artistas"
+ },
+ "actions": {
+ "shuffle": "Barallar",
+ "radio": "Radio",
+ "topSongs": "Cancións destacadas"
}
},
"user": {
@@ -134,10 +149,12 @@
"currentPassword": "Contrasinal actual",
"newPassword": "Novo contrasinal",
"token": "Token",
- "lastAccessAt": "Último acceso"
+ "lastAccessAt": "Último acceso",
+ "libraries": "Bibliotecas"
},
"helperTexts": {
- "name": "Os cambios no nome aplicaranse a próxima vez que accedas"
+ "name": "Os cambios no nome aplicaranse a próxima vez que accedas",
+ "libraries": "Selecciona bibliotecas específicas para esta usuaria, ou deixa baleiro para usar as bibliotecas por defecto"
},
"notifications": {
"created": "Creouse a usuaria",
@@ -146,7 +163,12 @@
},
"message": {
"listenBrainzToken": "Escribe o token de usuaria de ListenBrainz",
- "clickHereForToken": "Preme aquí para obter o token"
+ "clickHereForToken": "Preme aquí para obter o token",
+ "selectAllLibraries": "Seleccionar todas as bibliotecas",
+ "adminAutoLibraries": "As usuarias Admin teñen acceso por defecto a todas as bibliotecas"
+ },
+ "validation": {
+ "librariesRequired": "Debes seleccionar polo menos unha biblioteca para usuarias non admins"
}
},
"player": {
@@ -190,11 +212,17 @@
"addNewPlaylist": "Crear \"%{name}\"",
"export": "Exportar",
"makePublic": "Facela Pública",
- "makePrivate": "Facela Privada"
+ "makePrivate": "Facela Privada",
+ "saveQueue": "Salvar a Cola como Lista de reprodución",
+ "searchOrCreate": "Buscar listas ou escribe para crear nova…",
+ "pressEnterToCreate": "Preme Enter para crear nova lista",
+ "removeFromSelection": "Retirar da selección"
},
"message": {
"duplicate_song": "Engadir cancións duplicadas",
- "song_exist": "Hai duplicadas que serán engadidas á lista de reprodución. Desexas engadir as duplicadas ou omitilas?"
+ "song_exist": "Hai duplicadas que serán engadidas á lista de reprodución. Desexas engadir as duplicadas ou omitilas?",
+ "noPlaylistsFound": "Sen listas de reprodución",
+ "noPlaylists": "Sen listas dispoñibles"
}
},
"radio": {
@@ -232,13 +260,73 @@
"fields": {
"path": "Ruta",
"size": "Tamaño",
- "updatedAt": "Desapareceu o"
+ "updatedAt": "Desapareceu o",
+ "libraryName": "Biblioteca"
},
"actions": {
- "remove": "Retirar"
+ "remove": "Retirar",
+ "remove_all": "Retirar todo"
},
"notifications": {
"removed": "Ficheiro(s) faltantes retirados"
+ },
+ "empty": "Sen ficheiros faltantes"
+ },
+ "library": {
+ "name": "Biblioteca |||| Bibliotecas",
+ "fields": {
+ "name": "Nome",
+ "path": "Ruta",
+ "remotePath": "Ruta remota",
+ "lastScanAt": "Último escaneado",
+ "songCount": "Cancións",
+ "albumCount": "Álbums",
+ "artistCount": "Artistas",
+ "totalSongs": "Cancións",
+ "totalAlbums": "Álbums",
+ "totalArtists": "Artistas",
+ "totalFolders": "Cartafoles",
+ "totalFiles": "Ficheiros",
+ "totalMissingFiles": "Ficheiros que faltan",
+ "totalSize": "Tamaño total",
+ "totalDuration": "Duración",
+ "defaultNewUsers": "Por defecto para novas usuarias",
+ "createdAt": "Creada",
+ "updatedAt": "Actualizada"
+ },
+ "sections": {
+ "basic": "Información básica",
+ "statistics": "Estatísticas"
+ },
+ "actions": {
+ "scan": "Escanear Biblioteca",
+ "manageUsers": "Xestionar acceso das usuarias",
+ "viewDetails": "Ver detalles",
+ "quickScan": "Escaneado rápido",
+ "fullScan": "Escaneado completo"
+ },
+ "notifications": {
+ "created": "Biblioteca creada correctamente",
+ "updated": "Biblioteca actualizada correctamente",
+ "deleted": "Biblioteca eliminada correctamente",
+ "scanStarted": "Comezou o escaneo da biblioteca",
+ "scanCompleted": "Completouse o escaneado da biblioteca",
+ "quickScanStarted": "Iniciado o escaneado rápido",
+ "fullScanStarted": "Iniciado o escaneado completo",
+ "scanError": "Erro ao escanear. Comproba o rexistro"
+ },
+ "validation": {
+ "nameRequired": "Requírese un nome para a biblioteca",
+ "pathRequired": "Requírese unha ruta para a biblioteca",
+ "pathNotDirectory": "A ruta á biblioteca ten que ser un directorio",
+ "pathNotFound": "Non se atopa a ruta á biblioteca",
+ "pathNotAccessible": "A ruta á biblioteca non é accesible",
+ "pathInvalid": "Ruta non válida á biblioteca"
+ },
+ "messages": {
+ "deleteConfirm": "Tes certeza de querer eliminar esta biblioteca? Isto eliminará todos os datos asociados e accesos de usuarias.",
+ "scanInProgress": "Escaneo en progreso…",
+ "noLibrariesAssigned": "Sen bibliotecas asignadas a esta usuaria"
}
}
},
@@ -419,7 +507,11 @@
"downloadDialogTitle": "Descargar %{resource} '%{name}' (%{size})",
"shareCopyToClipboard": "Copiar ao portapapeis: Ctrl+C, Enter",
"remove_missing_title": "Retirar ficheiros que faltan",
- "remove_missing_content": "Tes certeza de querer retirar da base de datos os ficheiros que faltan? Isto retirará de xeito permanente todas a referencias a eles, incluíndo a conta de reproducións e valoracións."
+ "remove_missing_content": "Tes certeza de querer retirar da base de datos os ficheiros que faltan? Isto retirará de xeito permanente todas a referencias a eles, incluíndo a conta de reproducións e valoracións.",
+ "remove_all_missing_title": "Retirar todos os ficheiros que faltan",
+ "remove_all_missing_content": "Tes certeza de querer retirar da base de datos todos os ficheiros que faltan? Isto eliminará todas as referencias a eles, incluíndo o número de reproducións e valoracións.",
+ "noSimilarSongsFound": "Sen cancións parecidas",
+ "noTopSongsFound": "Sen cancións destacadas"
},
"menu": {
"library": "Biblioteca",
@@ -448,7 +540,13 @@
"albumList": "Álbums",
"about": "Acerca de",
"playlists": "Listas de reprodución",
- "sharedPlaylists": "Listas compartidas"
+ "sharedPlaylists": "Listas compartidas",
+ "librarySelector": {
+ "allLibraries": "Todas as bibliotecas (%{count})",
+ "multipleLibraries": "%{selected} de %{total} Bibliotecas",
+ "selectLibraries": "Seleccionar Bibliotecas",
+ "none": "Ningunha"
+ }
},
"player": {
"playListsText": "Reproducir cola",
@@ -485,6 +583,21 @@
"disabled": "Desactivado",
"waiting": "Agardando"
}
+ },
+ "tabs": {
+ "about": "Sobre",
+ "config": "Configuración"
+ },
+ "config": {
+ "configName": "Nome",
+ "environmentVariable": "Variable de entorno",
+ "currentValue": "Valor actual",
+ "configurationFile": "Ficheiro de configuración",
+ "exportToml": "Exportar configuración (TOML)",
+ "exportSuccess": "Configuración exportada ao portapapeis no formato TOML",
+ "exportFailed": "Fallou a copia da configuración",
+ "devFlagsHeader": "Configuracións de Desenvolvemento (suxeitas a cambio/retirada)",
+ "devFlagsComment": "Son axustes experimentais e poden retirarse en futuras versións"
}
},
"activity": {
@@ -493,7 +606,11 @@
"quickScan": "Escaneo rápido",
"fullScan": "Escaneo completo",
"serverUptime": "Servidor a funcionar",
- "serverDown": "SEN CONEXIÓN"
+ "serverDown": "SEN CONEXIÓN",
+ "scanType": "Tipo",
+ "status": "Erro de escaneado",
+ "elapsedTime": "Tempo transcurrido",
+ "selectiveScan": "Selectivo"
},
"help": {
"title": "Atallos de Navidrome",
@@ -508,5 +625,10 @@
"toggle_love": "Engadir canción a favoritas",
"current_song": "Ir á Canción actual "
}
+ },
+ "nowPlaying": {
+ "title": "En reprodución",
+ "empty": "Sen reprodución",
+ "minutesAgo": "hai %{smart_count} minuto |||| hai %{smart_count} minutos"
}
}
\ No newline at end of file
diff --git a/resources/i18n/hu.json b/resources/i18n/hu.json
index a2037eb54..cbdd57109 100644
--- a/resources/i18n/hu.json
+++ b/resources/i18n/hu.json
@@ -300,7 +300,9 @@
},
"actions": {
"scan": "Könyvtár szkennelése",
- "manageUsers": "Elérés kezelése",
+ "quickScan": "Gyors szkennelés",
+ "fullScan": "Teljes szkennelés",
+ "manageUsers": "Hozzáférés kezelése",
"viewDetails": "Részletek"
},
"notifications": {
@@ -598,11 +600,12 @@
"activity": {
"title": "Aktivitás",
"totalScanned": "Összes beolvasott mappa:",
- "quickScan": "Gyors szkennelés",
- "fullScan": "Teljes szkennelés",
+ "quickScan": "Gyors",
+ "fullScan": "Teljes",
+ "selectiveScan": "Szelektív",
"serverUptime": "Szerver üzemidő",
"serverDown": "OFFLINE",
- "scanType": "Típus",
+ "scanType": "Legutóbbi szkennelés",
"status": "Szkennelési hiba",
"elapsedTime": "Eltelt idő"
},
diff --git a/resources/i18n/it.json b/resources/i18n/it.json
index 9d1c2bb74..11fadb46b 100644
--- a/resources/i18n/it.json
+++ b/resources/i18n/it.json
@@ -400,8 +400,8 @@
},
"albumList": "Album",
"about": "Info",
- "playlists": "Scalette",
- "sharedPlaylists": "Scalette Condivise"
+ "playlists": "Playlist",
+ "sharedPlaylists": "Playlist Condivise"
},
"player": {
"playListsText": "Coda",
@@ -457,4 +457,4 @@
"current_song": ""
}
}
-}
\ No newline at end of file
+}
diff --git a/resources/i18n/ja.json b/resources/i18n/ja.json
index fbf8cefd2..29975b92b 100644
--- a/resources/i18n/ja.json
+++ b/resources/i18n/ja.json
@@ -27,12 +27,16 @@
"playDate": "最後の再生",
"channels": "チャンネル",
"createdAt": "追加日",
- "grouping": "",
- "mood": "",
- "participants": "",
- "tags": "",
- "mappedTags": "",
- "rawTags": ""
+ "grouping": "グループ分け",
+ "mood": "ムード",
+ "participants": "追加参加者",
+ "tags": "追加タグ",
+ "mappedTags": "マッピング済みタグ",
+ "rawTags": "未処理タグ",
+ "bitDepth": "ビット深度",
+ "sampleRate": "サンプリングレート",
+ "missing": "不明",
+ "libraryName": "ライブラリ"
},
"actions": {
"addToQueue": "最後に再生",
@@ -41,7 +45,8 @@
"shuffleAll": "全曲シャッフル",
"download": "ダウンロード",
"playNext": "次に再生",
- "info": "詳細"
+ "info": "詳細",
+ "showInPlaylist": "含まれるプレイリスト"
}
},
"album": {
@@ -65,12 +70,15 @@
"releaseDate": "リリース日",
"releases": "リリース",
"released": "リリース",
- "recordLabel": "",
- "catalogNum": "",
- "releaseType": "",
- "grouping": "",
- "media": "",
- "mood": ""
+ "recordLabel": "ラベル",
+ "catalogNum": "カタログ番号",
+ "releaseType": "タイプ",
+ "grouping": "グループ分け",
+ "media": "メディア",
+ "mood": "ムード",
+ "date": "録音日",
+ "missing": "不明",
+ "libraryName": "ライブラリ"
},
"actions": {
"playAll": "再生",
@@ -102,22 +110,29 @@
"rating": "レート",
"genre": "ジャンル",
"size": "サイズ",
- "role": ""
+ "role": "役割",
+ "missing": "不明"
},
"roles": {
- "albumartist": "",
- "artist": "",
- "composer": "",
- "conductor": "",
- "lyricist": "",
- "arranger": "",
- "producer": "",
- "director": "",
- "engineer": "",
- "mixer": "",
- "remixer": "",
- "djmixer": "",
- "performer": ""
+ "albumartist": "アルバムアーティスト",
+ "artist": "アーティスト",
+ "composer": "作曲家",
+ "conductor": "指揮者",
+ "lyricist": "作詞家",
+ "arranger": "編曲者",
+ "producer": "プロデューサー",
+ "director": "ディレクター",
+ "engineer": "エンジニア",
+ "mixer": "ミキサー",
+ "remixer": "リミキサー",
+ "djmixer": "DJ ミキサー",
+ "performer": "演奏者",
+ "maincredit": "アルバムアーティストもしくはアーティスト"
+ },
+ "actions": {
+ "shuffle": "シャッフル",
+ "radio": "ラジオ",
+ "topSongs": "トップソング"
}
},
"user": {
@@ -134,10 +149,12 @@
"currentPassword": "現在のパスワード",
"newPassword": "新しいパスワード",
"token": "トークン",
- "lastAccessAt": "最終アクセス"
+ "lastAccessAt": "最終アクセス",
+ "libraries": "ライブラリ"
},
"helperTexts": {
- "name": "名前の変更は次回ログイン以降反映されます"
+ "name": "名前の変更は次回ログイン以降反映されます",
+ "libraries": "このユーザーに対して特定ライブラリを選択するか、デフォルトのライブラリを使用する場合は空欄のままにします"
},
"notifications": {
"created": "ユーザーが作成されました",
@@ -146,7 +163,12 @@
},
"message": {
"listenBrainzToken": "ListenBrainzユーザートークンを入力",
- "clickHereForToken": "ここをクリックしトークンを入手"
+ "clickHereForToken": "ここをクリックしトークンを入手",
+ "selectAllLibraries": "全てのライブラリを選択",
+ "adminAutoLibraries": "管理者ユーザーは自動的にすべてのライブラリにアクセスできます"
+ },
+ "validation": {
+ "librariesRequired": "管理者以外のユーザーには少なくとも1つのライブラリを選択する必要があります"
}
},
"player": {
@@ -190,11 +212,17 @@
"addNewPlaylist": "'%{name}' を作成",
"export": "エクスポート",
"makePublic": "公開する",
- "makePrivate": "非公開にする"
+ "makePrivate": "非公開にする",
+ "saveQueue": "キューをプレイリストに保存",
+ "searchOrCreate": "プレイリストを検索または入力して新規作成...",
+ "pressEnterToCreate": "Enterキーを押して新しいプレイリストを作成",
+ "removeFromSelection": "選択から削除"
},
"message": {
"duplicate_song": "重複する曲を追加",
- "song_exist": "既にプレイリストに存在する曲です。追加しますか?"
+ "song_exist": "既にプレイリストに存在する曲です。追加しますか?",
+ "noPlaylistsFound": "プレイリストが見つかりません",
+ "noPlaylists": "利用可能なプレイリストはありません"
}
},
"radio": {
@@ -228,17 +256,77 @@
}
},
"missing": {
- "name": "",
+ "name": "欠落したファイル",
"fields": {
- "path": "",
- "size": "",
- "updatedAt": ""
+ "path": "パス",
+ "size": "サイズ",
+ "updatedAt": "欠落日",
+ "libraryName": "ライブラリ"
},
"actions": {
- "remove": ""
+ "remove": "削除",
+ "remove_all": "全て削除"
},
"notifications": {
- "removed": ""
+ "removed": "欠落ファイルが削除されました"
+ },
+ "empty": "ファイルの欠落はありません"
+ },
+ "library": {
+ "name": "ライブラリ",
+ "fields": {
+ "name": "名前",
+ "path": "パス",
+ "remotePath": "リモートパス",
+ "lastScanAt": "最終スキャン",
+ "songCount": "曲数",
+ "albumCount": "アルバム数",
+ "artistCount": "アーティスト数",
+ "totalSongs": "曲数",
+ "totalAlbums": "アルバム数",
+ "totalArtists": "アーティスト数",
+ "totalFolders": "フォルダー数",
+ "totalFiles": "ファイル数",
+ "totalMissingFiles": "欠落したファイル",
+ "totalSize": "合計サイズ",
+ "totalDuration": "合計時間",
+ "defaultNewUsers": "新規ユーザーに対するデフォルト",
+ "createdAt": "作成日",
+ "updatedAt": "更新日"
+ },
+ "sections": {
+ "basic": "基本情報",
+ "statistics": "統計"
+ },
+ "actions": {
+ "scan": "ライブラリをスキャン",
+ "manageUsers": "ユーザーアクセス管理",
+ "viewDetails": "詳細を表示",
+ "quickScan": "クイックスキャン",
+ "fullScan": "フルスキャン"
+ },
+ "notifications": {
+ "created": "ライブラリが正常に作成されました",
+ "updated": "ライブラリが正常に更新されました",
+ "deleted": "ライブラリが正常に削除されました",
+ "scanStarted": "スキャンを開始しました",
+ "scanCompleted": "スキャンが完了しました",
+ "quickScanStarted": "クイックスキャンを開始しました",
+ "fullScanStarted": "フルスキャンを開始しました",
+ "scanError": "スキャン開始中にエラーが発生。ログを確認してください"
+ },
+ "validation": {
+ "nameRequired": "ライブラリの名前が必要です",
+ "pathRequired": "ライブラリのパスが必要です",
+ "pathNotDirectory": "ライブラリパスはディレクトリである必要があります",
+ "pathNotFound": "ライブラリのパスが見つかりません",
+ "pathNotAccessible": "ライブラリパスへアクセスできません",
+ "pathInvalid": "無効なライブラリパス"
+ },
+ "messages": {
+ "deleteConfirm": "このライブラリを削除しますか?関連する全てのデータとユーザーアクセスが削除されます。",
+ "scanInProgress": "スキャン中...",
+ "noLibrariesAssigned": "このユーザーに割り当てられているライブラリはありません"
}
}
},
@@ -418,8 +506,12 @@
"shareFailure": "コピーに失敗しました %{url}",
"downloadDialogTitle": "ダウンロード %{resource} '%{name}' (%{size})",
"shareCopyToClipboard": "クリップボードへコピー: Ctrl+C, Enter",
- "remove_missing_title": "",
- "remove_missing_content": ""
+ "remove_missing_title": "欠落ファイルを削除",
+ "remove_missing_content": "選択した欠落ファイルをデータベースから削除してもよろしいですか?これにより、再生数や評価を含むそれらのファイルへの参照が完全に削除されます。",
+ "remove_all_missing_title": "全ての欠落ファイルを削除",
+ "remove_all_missing_content": "データベースから欠落ファイルをすべて削除してもよろしいですか?これにより、再生数や評価を含むそれらのファイルへの参照が永久に削除されます。",
+ "noSimilarSongsFound": "類似の曲が見つかりませんでした",
+ "noTopSongsFound": "トップソングが見つかりません"
},
"menu": {
"library": "ライブラリ",
@@ -448,7 +540,13 @@
"albumList": "アルバム",
"about": "詳細",
"playlists": "プレイリスト",
- "sharedPlaylists": "共有プレイリスト"
+ "sharedPlaylists": "共有プレイリスト",
+ "librarySelector": {
+ "allLibraries": "全てのライブラリ( %{count} )",
+ "multipleLibraries": "%{selected} 個 / %{total} 個のライブラリ",
+ "selectLibraries": "ライブラリを選択",
+ "none": "無し"
+ }
},
"player": {
"playListsText": "再生リスト",
@@ -485,15 +583,34 @@
"disabled": "無効",
"waiting": "待機中"
}
+ },
+ "tabs": {
+ "about": "詳細",
+ "config": "設定"
+ },
+ "config": {
+ "configName": "設定名",
+ "environmentVariable": "環境変数",
+ "currentValue": "現在値",
+ "configurationFile": "設定ファイル",
+ "exportToml": "設定をエクスポート(TOML)",
+ "exportSuccess": "設定をTOML形式でクリップボードへエクスポートしました",
+ "exportFailed": "設定のコピーに失敗しました",
+ "devFlagsHeader": "開発フラグ(変更・削除の可能性あり)",
+ "devFlagsComment": "これらは実験的な設定であり、将来のバージョンで削除される可能性があります"
}
},
"activity": {
"title": "活動",
"totalScanned": "スキャン済みフォルダー",
- "quickScan": "クイックスキャン",
- "fullScan": "フルスキャン",
+ "quickScan": "クイック",
+ "fullScan": "フル",
"serverUptime": "サーバー稼働時間",
- "serverDown": "サーバーオフライン"
+ "serverDown": "サーバーオフライン",
+ "scanType": "最終スキャン",
+ "status": "スキャンエラー",
+ "elapsedTime": "経過時間",
+ "selectiveScan": "選択的スキャン"
},
"help": {
"title": "ホットキー",
@@ -508,5 +625,10 @@
"toggle_love": "星の付け外し",
"current_song": "現在の曲へ移動"
}
+ },
+ "nowPlaying": {
+ "title": "再生中",
+ "empty": "何も再生されていません",
+ "minutesAgo": "%{smart_count} 分前 |||| %{smart_count} 分前"
}
}
\ No newline at end of file
diff --git a/resources/i18n/ko.json b/resources/i18n/ko.json
index a8b26df6d..6b81e02d8 100644
--- a/resources/i18n/ko.json
+++ b/resources/i18n/ko.json
@@ -12,6 +12,7 @@
"artist": "아티스트",
"album": "앨범",
"path": "파일 경로",
+ "libraryName": "라이브러리",
"genre": "장르",
"compilation": "컴필레이션",
"year": "년",
@@ -34,7 +35,8 @@
"participants": "추가 참가자",
"tags": "추가 태그",
"mappedTags": "매핑된 태그",
- "rawTags": "원시 태그"
+ "rawTags": "원시 태그",
+ "missing": "누락"
},
"actions": {
"addToQueue": "나중에 재생",
@@ -56,6 +58,7 @@
"playCount": "재생 횟수",
"size": "크기",
"name": "이름",
+ "libraryName": "라이브러리",
"genre": "장르",
"compilation": "컴필레이션",
"year": "년",
@@ -73,7 +76,8 @@
"releaseType": "유형",
"grouping": "그룹",
"media": "미디어",
- "mood": "분위기"
+ "mood": "분위기",
+ "missing": "누락"
},
"actions": {
"playAll": "재생",
@@ -105,7 +109,8 @@
"playCount": "재생 횟수",
"rating": "평가",
"genre": "장르",
- "role": "역할"
+ "role": "역할",
+ "missing": "누락"
},
"roles": {
"albumartist": "앨범 아티스트 |||| 앨범 아티스트들",
@@ -120,7 +125,13 @@
"mixer": "믹서 |||| 믹서들",
"remixer": "리믹서 |||| 리믹서들",
"djmixer": "DJ 믹서 |||| DJ 믹서들",
- "performer": "공연자 |||| 공연자들"
+ "performer": "공연자 |||| 공연자들",
+ "maincredit": "앨범 아티스트 또는 아티스트 |||| 앨범 아티스트들 또는 아티스트들"
+ },
+ "actions": {
+ "topSongs": "인기곡",
+ "shuffle": "셔플",
+ "radio": "라디오"
}
},
"user": {
@@ -137,19 +148,26 @@
"changePassword": "비밀번호를 변경할까요?",
"currentPassword": "현재 비밀번호",
"newPassword": "새 비밀번호",
- "token": "토큰"
+ "token": "토큰",
+ "libraries": "라이브러리"
},
"helperTexts": {
- "name": "이름 변경 사항은 다음 로그인 시에만 반영됨"
+ "name": "이름 변경 사항은 다음 로그인 시에만 반영됨",
+ "libraries": "이 사용자에 대한 특정 라이브러리를 선택하거나 기본 라이브러리를 사용하려면 비움"
},
"notifications": {
"created": "사용자 생성됨",
"updated": "사용자 업데이트됨",
"deleted": "사용자 삭제됨"
},
+ "validation": {
+ "librariesRequired": "관리자가 아닌 사용자의 경우 최소한 하나의 라이브러리를 선택해야 함"
+ },
"message": {
"listenBrainzToken": "ListenBrainz 사용자 토큰을 입력하세요.",
- "clickHereForToken": "여기를 클릭하여 토큰을 얻으세요"
+ "clickHereForToken": "여기를 클릭하여 토큰을 얻으세요",
+ "selectAllLibraries": "모든 라이브러리 선택",
+ "adminAutoLibraries": "관리자 사용자는 자동으로 모든 라이브러리에 접속할 수 있음"
}
},
"player": {
@@ -192,12 +210,18 @@
"selectPlaylist": "재생목록 선택:",
"addNewPlaylist": "\"%{name}\" 만들기",
"export": "내보내기",
+ "saveQueue": "재생목록에 대기열 저장",
"makePublic": "공개 만들기",
- "makePrivate": "비공개 만들기"
+ "makePrivate": "비공개 만들기",
+ "searchOrCreate": "재생목록을 검색하거나 입력하여 새 재생목록을 만드세요...",
+ "pressEnterToCreate": "새 재생목록을 만드려면 Enter 키를 누름",
+ "removeFromSelection": "선택에서 제거"
},
"message": {
"duplicate_song": "중복된 노래 추가",
- "song_exist": "이미 재생목록에 존재하는 노래입니다. 중복을 추가할까요 아니면 건너뛸까요?"
+ "song_exist": "이미 재생목록에 존재하는 노래입니다. 중복을 추가할까요 아니면 건너뛸까요?",
+ "noPlaylistsFound": "재생목록을 찾을 수 없음",
+ "noPlaylists": "사용 가능한 재생 목록이 없음"
}
},
"radio": {
@@ -238,14 +262,68 @@
"fields": {
"path": "경로",
"size": "크기",
+ "libraryName": "라이브러리",
"updatedAt": "사라짐"
},
"actions": {
- "remove": "제거"
+ "remove": "제거",
+ "remove_all": "모두 제거"
},
"notifications": {
"removed": "누락된 파일이 제거되었음"
}
+ },
+ "library": {
+ "name": "라이브러리 |||| 라이브러리들",
+ "fields": {
+ "name": "이름",
+ "path": "경로",
+ "remotePath": "원격 경로",
+ "lastScanAt": "최근 스캔",
+ "songCount": "노래",
+ "albumCount": "앨범",
+ "artistCount": "아티스트",
+ "totalSongs": "노래",
+ "totalAlbums": "앨범",
+ "totalArtists": "아티스트",
+ "totalFolders": "폴더",
+ "totalFiles": "파일",
+ "totalMissingFiles": "누락된 파일",
+ "totalSize": "총 크기",
+ "totalDuration": "기간",
+ "defaultNewUsers": "신규 사용자 기본값",
+ "createdAt": "생성됨",
+ "updatedAt": "업데이트됨"
+ },
+ "sections": {
+ "basic": "기본 정보",
+ "statistics": "통계"
+ },
+ "actions": {
+ "scan": "라이브러리 스캔",
+ "manageUsers": "자용자 접속 관리",
+ "viewDetails": "상세 보기"
+ },
+ "notifications": {
+ "created": "라이브러리가 성공적으로 생성됨",
+ "updated": "라이브러리가 성공적으로 업데이트됨",
+ "deleted": "라이브러리가 성공적으로 삭제됨",
+ "scanStarted": "라이브러리 스캔 스작됨",
+ "scanCompleted": "라이브러리 스캔 완료됨"
+ },
+ "validation": {
+ "nameRequired": "라이브러리 이름이 필요함",
+ "pathRequired": "라이브러리 경로가 필요함",
+ "pathNotDirectory": "라이브러리 경로는 디렉터리여야 함",
+ "pathNotFound": "라이브러리 경로를 찾을 수 없음",
+ "pathNotAccessible": "라이브러리 경로에 접근할 수 없음",
+ "pathInvalid": "잘못된 라이브러리 경로"
+ },
+ "messages": {
+ "deleteConfirm": "이 라이브러리를 삭제할까요? 삭제하면 연결된 모든 데이터와 사용자 접속 권한이 제거됩니다.",
+ "scanInProgress": "스캔 진행 중...",
+ "noLibrariesAssigned": "이 사용자에게 할당된 라이브러리가 없음"
+ }
}
},
"ra": {
@@ -398,11 +476,15 @@
"transcodingDisabled": "웹 인터페이스를 통한 트랜스코딩 구성 변경은 보안상의 이유로 비활성화되어 있습니다. 트랜스코딩 옵션을 변경(편집 또는 추가)하려면, %{config} 구성 옵션으로 서버를 다시 시작하세요.",
"transcodingEnabled": "Navidrome은 현재 %{config}로 실행 중이므로 웹 인터페이스를 사용하여 트랜스코딩 설정에서 시스템 명령을 실행할 수 있습니다. 보안상의 이유로 비활성화하고 트랜스코딩 옵션을 구성할 때만 활성화하는 것이 좋습니다.",
"songsAddedToPlaylist": "1 개의 노래를 재생목록에 추가하였음 |||| %{smart_count} 개의 노래를 재생 목록에 추가하였음",
+ "noSimilarSongsFound": "비슷한 노래를 찾을 수 없음",
+ "noTopSongsFound": "인기곡을 찾을 수 없음",
"noPlaylistsAvailable": "사용 가능한 노래 없음",
"delete_user_title": "사용자 '%{name}' 삭제",
"delete_user_content": "이 사용자와 해당 사용자의 모든 데이터(재생 목록 및 환경 설정 포함)를 삭제할까요?",
"remove_missing_title": "누락된 파일들 제거",
"remove_missing_content": "선택한 누락된 파일을 데이터베이스에서 삭제할까요? 삭제하면 재생 횟수 및 평점을 포함하여 해당 파일에 대한 모든 참조가 영구적으로 삭제됩니다.",
+ "remove_all_missing_title": "누락된 모든 파일 제거",
+ "remove_all_missing_content": "데이터베이스에서 누락된 모든 파일을 제거할까요? 이렇게 하면 해당 게임의 플레이 횟수와 평점을 포함한 모든 참조 내용이 영구적으로 삭제됩니다.",
"notifications_blocked": "브라우저 설정에서 이 사이트의 알림을 차단하였음",
"notifications_not_available": "이 브라우저는 데스크톱 알림을 지원하지 않거나 https를 통해 Navidrome에 접속하고 있지 않음",
"lastfmLinkSuccess": "Last.fm이 성공적으로 연결되었고 스크로블링이 활성화되었음",
@@ -429,6 +511,12 @@
},
"menu": {
"library": "라이브러리",
+ "librarySelector": {
+ "allLibraries": "모든 라이브러리 (%{count})",
+ "multipleLibraries": "%{selected} / %{total} 라이브러리",
+ "selectLibraries": "라이브러리 선택",
+ "none": "없음"
+ },
"settings": "설정",
"version": "버전",
"theme": "테마",
@@ -491,6 +579,21 @@
"disabled": "비활성화",
"waiting": "대기중"
}
+ },
+ "tabs": {
+ "about": "정보",
+ "config": "구성"
+ },
+ "config": {
+ "configName": "구성 이름",
+ "environmentVariable": "환경 변수",
+ "currentValue": "현재 값",
+ "configurationFile": "구성 파일",
+ "exportToml": "구성 내보내기 (TOML)",
+ "exportSuccess": "TOML 형식으로 클립보드로 내보낸 구성",
+ "exportFailed": "구성 복사 실패",
+ "devFlagsHeader": "개발 플래그 (변경/삭제 가능)",
+ "devFlagsComment": "이는 실험적 설정이므로 향후 버전에서 제거될 수 있음"
}
},
"activity": {
@@ -499,7 +602,15 @@
"quickScan": "빠른 스캔",
"fullScan": "전체 스캔",
"serverUptime": "서버 가동 시간",
- "serverDown": "오프라인"
+ "serverDown": "오프라인",
+ "scanType": "유형",
+ "status": "스캔 오류",
+ "elapsedTime": "경과 시간"
+ },
+ "nowPlaying": {
+ "title": "현재 재생 중",
+ "empty": "재생 중인 콘텐츠 없음",
+ "minutesAgo": "%{smart_count} 분 전"
},
"help": {
"title": "Navidrome 단축키",
diff --git a/resources/i18n/nl.json b/resources/i18n/nl.json
index 4737cb33a..059d243cb 100644
--- a/resources/i18n/nl.json
+++ b/resources/i18n/nl.json
@@ -5,7 +5,7 @@
"name": "Nummer |||| Nummers",
"fields": {
"albumArtist": "Album Artiest",
- "duration": "Lengte",
+ "duration": "Afspeelduur",
"trackNumber": "Nummer #",
"playCount": "Aantal keren afgespeeld",
"title": "Titel",
@@ -35,7 +35,8 @@
"rawTags": "Onbewerkte tags",
"bitDepth": "Bit diepte",
"sampleRate": "Sample waarde",
- "missing": "Ontbrekend"
+ "missing": "Ontbrekend",
+ "libraryName": "Bibliotheek"
},
"actions": {
"addToQueue": "Voeg toe aan wachtrij",
@@ -44,7 +45,8 @@
"shuffleAll": "Shuffle alles",
"download": "Downloaden",
"playNext": "Volgende",
- "info": "Meer info"
+ "info": "Meer info",
+ "showInPlaylist": "Toon in afspeellijst"
}
},
"album": {
@@ -55,7 +57,7 @@
"duration": "Afspeelduur",
"songCount": "Nummers",
"playCount": "Aantal keren afgespeeld",
- "name": "Naam",
+ "name": "Titel",
"genre": "Genre",
"compilation": "Compilatie",
"year": "Jaar",
@@ -65,9 +67,9 @@
"createdAt": "Datum toegevoegd",
"size": "Grootte",
"originalDate": "Origineel",
- "releaseDate": "Uitgegeven",
+ "releaseDate": "Uitgave",
"releases": "Uitgave |||| Uitgaven",
- "released": "Uitgegeven",
+ "released": "Uitgave",
"recordLabel": "Label",
"catalogNum": "Catalogus nummer",
"releaseType": "Type",
@@ -75,7 +77,8 @@
"media": "Media",
"mood": "Sfeer",
"date": "Opnamedatum",
- "missing": "Ontbrekend"
+ "missing": "Ontbrekend",
+ "libraryName": "Bibliotheek"
},
"actions": {
"playAll": "Afspelen",
@@ -123,7 +126,13 @@
"mixer": "Mixer |||| Mixers",
"remixer": "Remixer |||| Remixers",
"djmixer": "DJ Mixer |||| DJ Mixers",
- "performer": "Performer |||| Performers"
+ "performer": "Performer |||| Performers",
+ "maincredit": "Album Artiest of Artiest |||| Album Artiesten or Artiesten"
+ },
+ "actions": {
+ "shuffle": "Shuffle",
+ "radio": "Radio",
+ "topSongs": "Beste nummers"
}
},
"user": {
@@ -132,7 +141,7 @@
"userName": "Gebruikersnaam",
"isAdmin": "Is beheerder",
"lastLoginAt": "Laatst ingelogd op",
- "updatedAt": "Laatst gewijzigd op",
+ "updatedAt": "Laatst bijgewerkt op",
"name": "Naam",
"password": "Wachtwoord",
"createdAt": "Aangemaakt op",
@@ -140,19 +149,26 @@
"currentPassword": "Huidig wachtwoord",
"newPassword": "Nieuw wachtwoord",
"token": "Token",
- "lastAccessAt": "Meest recente toegang"
+ "lastAccessAt": "Meest recente toegang",
+ "libraries": "Bibliotheken"
},
"helperTexts": {
- "name": "Naamswijziging wordt pas zichtbaar bij de volgende login"
+ "name": "Naamswijziging wordt pas zichtbaar bij de volgende login",
+ "libraries": "Selecteer specifieke bibliotheken voor deze gebruiker, of laat leeg om de standaardbiblliotheken te gebruiken"
},
"notifications": {
"created": "Aangemaakt door gebruiker",
- "updated": "Gewijzigd door gebruiker",
- "deleted": "Gewist door gebruiker"
+ "updated": "Bijgewerkt door gebruiker",
+ "deleted": "Gebruiker verwijderd"
},
"message": {
"listenBrainzToken": "Vul je ListenBrainz gebruikers-token in.",
- "clickHereForToken": "Klik hier voor je token"
+ "clickHereForToken": "Klik hier voor je token",
+ "selectAllLibraries": "Selecteer alle bibliotheken",
+ "adminAutoLibraries": "Admin gebruikers hebben automatisch toegang tot alle bibliotheken"
+ },
+ "validation": {
+ "librariesRequired": "Minstens één bibliotheek moet geselecteerd worden voor niet-admin gebruikers"
}
},
"player": {
@@ -181,10 +197,10 @@
"name": "Afspeellijst |||| Afspeellijsten",
"fields": {
"name": "Titel",
- "duration": "Lengte",
+ "duration": "Afspeelduur",
"ownerName": "Eigenaar",
"public": "Publiek",
- "updatedAt": "Laatst gewijzigd op",
+ "updatedAt": "Laatst bijgewerkt op",
"createdAt": "Aangemaakt op",
"songCount": "Nummers",
"comment": "Commentaar",
@@ -197,11 +213,16 @@
"export": "Exporteer",
"makePublic": "Openbaar maken",
"makePrivate": "Privé maken",
- "saveQueue": "Bewaar wachtrij als playlist"
+ "saveQueue": "Bewaar wachtrij als playlist",
+ "searchOrCreate": "Zoek afspeellijsten of typ om een nieuwe te starten...",
+ "pressEnterToCreate": "Druk Enter om nieuwe afspeellijst te maken",
+ "removeFromSelection": "Verwijder van selectie"
},
"message": {
"duplicate_song": "Dubbele nummers toevoegen",
- "song_exist": "Er komen nummers dubbel in de afspeellijst. Wil je de dubbele nummers toevoegen of overslaan?"
+ "song_exist": "Er komen nummers dubbel in de afspeellijst. Wil je de dubbele nummers toevoegen of overslaan?",
+ "noPlaylistsFound": "Geen playlists gevonden",
+ "noPlaylists": "Geen playlists beschikbaar"
}
},
"radio": {
@@ -210,8 +231,8 @@
"name": "Naam",
"streamUrl": "Stream URL",
"homePageUrl": "Hoofdpagina URL",
- "updatedAt": "Geüpdate op",
- "createdAt": "Gecreëerd op"
+ "updatedAt": "Bijgewerkt op",
+ "createdAt": "Aangemaakt op"
},
"actions": {
"playNow": "Speel nu"
@@ -229,8 +250,8 @@
"visitCount": "Bezocht",
"format": "Formaat",
"maxBitRate": "Max. bitrate",
- "updatedAt": "Geüpdatet op",
- "createdAt": "Gecreëerd op",
+ "updatedAt": "Bijgewerkt op",
+ "createdAt": "Aangemaakt op",
"downloadable": "Downloads toestaan?"
}
},
@@ -239,7 +260,8 @@
"fields": {
"path": "Pad",
"size": "Grootte",
- "updatedAt": "Verdwenen op"
+ "updatedAt": "Verdwenen op",
+ "libraryName": "Bibliotheek"
},
"actions": {
"remove": "Verwijder",
@@ -249,6 +271,63 @@
"removed": "Ontbrekende bestanden verwijderd"
},
"empty": "Geen ontbrekende bestanden"
+ },
+ "library": {
+ "name": "Bibliotheek |||| Bibliotheken",
+ "fields": {
+ "name": "Naam",
+ "path": "Pad",
+ "remotePath": "Extern pad",
+ "lastScanAt": "Laatste scan",
+ "songCount": "Nummers",
+ "albumCount": "Albums",
+ "artistCount": "Artiesten",
+ "totalSongs": "Nummers",
+ "totalAlbums": "Albums",
+ "totalArtists": "Artiesten",
+ "totalFolders": "Mappen",
+ "totalFiles": "Bestanden",
+ "totalMissingFiles": "Ontbrekende bestanden",
+ "totalSize": "Totale bestandsgrootte",
+ "totalDuration": "Afspeelduur",
+ "defaultNewUsers": "Standaard voor nieuwe gebruikers",
+ "createdAt": "Aangemaakt",
+ "updatedAt": "Bijgewerkt"
+ },
+ "sections": {
+ "basic": "Basisinformatie",
+ "statistics": "Statistieken"
+ },
+ "actions": {
+ "scan": "Scan bibliotheek",
+ "manageUsers": "Beheer gebruikerstoegang",
+ "viewDetails": "Bekijk details",
+ "quickScan": "Snelle scan",
+ "fullScan": "Volledige scan"
+ },
+ "notifications": {
+ "created": "Bibliotheek succesvol aangemaakt",
+ "updated": "Bibliotheek succesvol bijgewerkt",
+ "deleted": "Bibliotheek succesvol verwijderd",
+ "scanStarted": "Bibliotheekscan is gestart",
+ "scanCompleted": "Bibliotheekscan is voltooid",
+ "quickScanStarted": "Snelle scan gestart",
+ "fullScanStarted": "Volledige scan gestart",
+ "scanError": "Fout bij start van scan. Check de logs"
+ },
+ "validation": {
+ "nameRequired": "Bibliotheek naam is vereist",
+ "pathRequired": "Pad naar bibliotheek is vereist",
+ "pathNotDirectory": "Pad naar bibliotheek moet een map zijn",
+ "pathNotFound": "Pad naar bibliotheek niet gevonden",
+ "pathNotAccessible": "Pad naar bibliotheek is niet toegankelijk",
+ "pathInvalid": "Ongeldig pad naar bibliotheek"
+ },
+ "messages": {
+ "deleteConfirm": "Weet je zeker dat je deze bibliotheek wil verwijderen? Dit verwijdert ook alle gerelateerde data en gebruikerstoegang.",
+ "scanInProgress": "Scan is bezig...",
+ "noLibrariesAssigned": "Geen bibliotheken aan deze gebruiker toegewezen"
+ }
}
},
"ra": {
@@ -430,7 +509,9 @@
"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."
+ "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.",
+ "noSimilarSongsFound": "Geen vergelijkbare nummers gevonden",
+ "noTopSongsFound": "Geen beste nummers gevonden"
},
"menu": {
"library": "Bibliotheek",
@@ -459,7 +540,13 @@
"albumList": "Albums",
"about": "Over",
"playlists": "Afspeellijsten",
- "sharedPlaylists": "Gedeelde afspeellijsten"
+ "sharedPlaylists": "Gedeelde afspeellijsten",
+ "librarySelector": {
+ "allLibraries": "Alle bibliotheken (%{count})",
+ "multipleLibraries": "%{selected} van %{total} bibliotheken",
+ "selectLibraries": "Selecteer bibliotheken",
+ "none": "Geen"
+ }
},
"player": {
"playListsText": "Wachtrij",
@@ -468,7 +555,7 @@
"notContentText": "Geen muziek",
"clickToPlayText": "Klik om af te spelen",
"clickToPauseText": "Klik om te pauzeren",
- "nextTrackText": "Volgende",
+ "nextTrackText": "Volgend nummer",
"previousTrackText": "Vorige",
"reloadText": "Herladen",
"volumeText": "Volume",
@@ -496,18 +583,34 @@
"disabled": "Uitgeschakeld",
"waiting": "Wachten"
}
+ },
+ "tabs": {
+ "about": "Over",
+ "config": "Configuratie"
+ },
+ "config": {
+ "configName": "Config Naam",
+ "environmentVariable": "Omgevingsvariabele",
+ "currentValue": "Huidige waarde",
+ "configurationFile": "Configuratiebestand",
+ "exportToml": "Exporteer configuratie (TOML)",
+ "exportSuccess": "Configuratie geëxporteerd naar klembord in TOML formaat",
+ "exportFailed": "Kopiëren van configuratie mislukt",
+ "devFlagsHeader": "Ontwikkelaarsinstellingen (onder voorbehoud)",
+ "devFlagsComment": "Dit zijn experimentele instellingen en worden mogelijk in latere versies verwijderd"
}
},
"activity": {
"title": "Activiteit",
- "totalScanned": "Totaal gescande folders",
+ "totalScanned": "Totaal gescande mappen",
"quickScan": "Snelle scan",
"fullScan": "Volledige scan",
"serverUptime": "Server uptime",
"serverDown": "Offline",
"scanType": "Type",
"status": "Scan fout",
- "elapsedTime": "Verlopen tijd"
+ "elapsedTime": "Verlopen tijd",
+ "selectiveScan": "Selectief"
},
"help": {
"title": "Navidrome sneltoetsen",
@@ -522,5 +625,10 @@
"toggle_love": "Voeg toe aan favorieten",
"current_song": "Ga naar huidig nummer"
}
+ },
+ "nowPlaying": {
+ "title": "Speelt nu",
+ "empty": "Er wordt niets afgespeed",
+ "minutesAgo": "%{smart_count} minuut geleden |||| %{smart_count} minuten geleden"
}
}
\ No newline at end of file
diff --git a/resources/i18n/no.json b/resources/i18n/no.json
index 84198fca7..3b75bab25 100644
--- a/resources/i18n/no.json
+++ b/resources/i18n/no.json
@@ -18,8 +18,6 @@
"size": "Filstørrelse",
"updatedAt": "Oppdatert",
"bitRate": "Bit rate",
- "bitDepth": "Bit depth",
- "channels": "Kanaler",
"discSubtitle": "Disk Undertittel",
"starred": "Favoritt",
"comment": "Kommentar",
@@ -27,13 +25,18 @@
"quality": "Kvalitet",
"bpm": "BPM",
"playDate": "Sist Avspilt",
+ "channels": "Kanaler",
"createdAt": "Lagt til",
"grouping": "Gruppering",
"mood": "Stemning",
"participants": "Ytterlige deltakere",
"tags": "Ytterlige Tags",
"mappedTags": "Kartlagte tags",
- "rawTags": "Rå tags"
+ "rawTags": "Rå tags",
+ "bitDepth": "Bit depth",
+ "sampleRate": "",
+ "missing": "",
+ "libraryName": ""
},
"actions": {
"addToQueue": "Avspill senere",
@@ -42,7 +45,8 @@
"shuffleAll": "Shuffle Alle",
"download": "Last ned",
"playNext": "Avspill neste",
- "info": "Få Info"
+ "info": "Få Info",
+ "showInPlaylist": ""
}
},
"album": {
@@ -53,36 +57,38 @@
"duration": "Tid",
"songCount": "Sanger",
"playCount": "Avspillinger",
- "size": "Størrelse",
"name": "Navn",
"genre": "Sjanger",
"compilation": "Samling",
"year": "År",
- "date": "Inspillingsdato",
- "originalDate": "Original",
- "releaseDate": "Utgitt",
- "releases": "Utgivelse |||| Utgivelser",
- "released": "Utgitt",
"updatedAt": "Oppdatert",
"comment": "Kommentar",
"rating": "Rangering",
"createdAt": "Lagt Til",
+ "size": "Størrelse",
+ "originalDate": "Original",
+ "releaseDate": "Utgitt",
+ "releases": "Utgivelse |||| Utgivelser",
+ "released": "Utgitt",
"recordLabel": "Plateselskap",
"catalogNum": "Katalognummer",
"releaseType": "Type",
"grouping": "Gruppering",
"media": "Media",
- "mood": "Stemning"
+ "mood": "Stemning",
+ "date": "Inspillingsdato",
+ "missing": "",
+ "libraryName": ""
},
"actions": {
"playAll": "Avspill",
"playNext": "Avspill Neste",
"addToQueue": "Avspill Senere",
- "share": "Del",
"shuffle": "Shuffle",
"addToPlaylist": "Legg til i spilleliste",
"download": "Last ned",
- "info": "Få Info"
+ "info": "Få Info",
+ "share": "Del"
},
"lists": {
"all": "Alle",
@@ -100,11 +106,12 @@
"name": "Navn",
"albumCount": "Album Antall",
"songCount": "Song Antall",
- "size": "Størrelse",
"playCount": "Avspillinger",
"rating": "Rangering",
"genre": "Sjanger",
- "role": "Rolle"
+ "size": "Størrelse",
+ "role": "Rolle",
+ "missing": ""
},
"roles": {
"albumartist": "Album Artist |||| Album Artister",
@@ -119,7 +126,13 @@
"mixer": "Mixer |||| Mixers",
"remixer": "Remixer |||| Remixers",
"djmixer": "DJ Mixer |||| DJ Mixers",
- "performer": "Performer |||| Performers"
+ "performer": "Performer |||| Performers",
+ "maincredit": ""
+ },
+ "actions": {
+ "shuffle": "",
+ "radio": "",
+ "topSongs": ""
}
},
"user": {
@@ -128,7 +141,6 @@
"userName": "Brukernavn",
"isAdmin": "Admin",
"lastLoginAt": "Sist Pålogging",
- "lastAccessAt": "Sist Tilgang",
"updatedAt": "Oppdatert",
"name": "Navn",
"password": "Passord",
@@ -136,10 +148,13 @@
"changePassword": "Bytt Passord?",
"currentPassword": "Nåværende Passord",
"newPassword": "Nytt Passord",
- "token": "Token"
+ "token": "Token",
+ "lastAccessAt": "Sist Tilgang",
+ "libraries": ""
},
"helperTexts": {
- "name": "Navnendringer vil ikke være synlig før neste pålogging"
+ "name": "Navnendringer vil ikke være synlig før neste pålogging",
+ "libraries": ""
},
"notifications": {
"created": "Bruker opprettet",
@@ -148,7 +163,12 @@
},
"message": {
"listenBrainzToken": "Fyll inn din ListenBrainz bruker token.",
- "clickHereForToken": "Klikk her for å hente din token"
+ "clickHereForToken": "Klikk her for å hente din token",
+ "selectAllLibraries": "",
+ "adminAutoLibraries": ""
+ },
+ "validation": {
+ "librariesRequired": ""
}
},
"player": {
@@ -192,11 +212,17 @@
"addNewPlaylist": "Opprett \"%{name}\"",
"export": "Eksporter",
"makePublic": "Gjør Offentlig",
- "makePrivate": "Gjør Privat"
+ "makePrivate": "Gjør Privat",
+ "saveQueue": "",
+ "searchOrCreate": "",
+ "pressEnterToCreate": "",
+ "removeFromSelection": ""
},
"message": {
"duplicate_song": "Legg til Duplikater",
- "song_exist": "Duplikater har blitt lagt til i spillelisten. Ønsker du å legge til duplikater eller hoppe over de?"
+ "song_exist": "Duplikater har blitt lagt til i spillelisten. Ønsker du å legge til duplikater eller hoppe over de?",
+ "noPlaylistsFound": "",
+ "noPlaylists": ""
}
},
"radio": {
@@ -218,7 +244,6 @@
"username": "Delt Av",
"url": "URL",
"description": "Beskrivelse",
- "downloadable": "Tillat Nedlastinger?",
"contents": "Innhold",
"expiresAt": "Utløper",
"lastVisitedAt": "Sist Besøkt",
@@ -226,24 +251,82 @@
"format": "Format",
"maxBitRate": "Maks. Bit Rate",
"updatedAt": "Oppdatert",
- "createdAt": "Opprettet"
- },
- "notifications": {},
- "actions": {}
+ "createdAt": "Opprettet",
+ "downloadable": "Tillat Nedlastinger?"
+ }
},
"missing": {
"name": "Manglende Fil|||| Manglende Filer",
- "empty": "Ingen Manglende Filer",
"fields": {
"path": "Filsti",
"size": "Størrelse",
- "updatedAt": "Ble borte"
+ "updatedAt": "Ble borte",
+ "libraryName": ""
},
"actions": {
- "remove": "Fjern"
+ "remove": "Fjern",
+ "remove_all": ""
},
"notifications": {
"removed": "Manglende fil(er) fjernet"
+ },
+ "empty": "Ingen Manglende Filer"
+ },
+ "library": {
+ "name": "",
+ "fields": {
+ "name": "",
+ "path": "",
+ "remotePath": "",
+ "lastScanAt": "",
+ "songCount": "",
+ "albumCount": "",
+ "artistCount": "",
+ "totalSongs": "",
+ "totalAlbums": "",
+ "totalArtists": "",
+ "totalFolders": "",
+ "totalFiles": "",
+ "totalMissingFiles": "",
+ "totalSize": "",
+ "totalDuration": "",
+ "defaultNewUsers": "",
+ "createdAt": "",
+ "updatedAt": ""
+ },
+ "sections": {
+ "basic": "",
+ "statistics": ""
+ },
+ "actions": {
+ "scan": "",
+ "manageUsers": "",
+ "viewDetails": "",
+ "quickScan": "",
+ "fullScan": ""
+ },
+ "notifications": {
+ "created": "",
+ "updated": "",
+ "deleted": "Biblioteket slettet",
+ "scanStarted": "Skanning startet",
+ "scanCompleted": "",
+ "quickScanStarted": "",
+ "fullScanStarted": "",
+ "scanError": "Error starte skanning. Sjekk loggene"
+ },
+ "validation": {
+ "nameRequired": "",
+ "pathRequired": "",
+ "pathNotDirectory": "",
+ "pathNotFound": "",
+ "pathNotAccessible": "",
+ "pathInvalid": ""
+ },
+ "messages": {
+ "deleteConfirm": "",
+ "scanInProgress": "",
+ "noLibrariesAssigned": ""
}
}
},
@@ -282,7 +365,6 @@
"add": "Legg Til",
"back": "Tilbake",
"bulk_actions": "1 element valgt |||| %{smart_count} elementer valgt",
- "bulk_actions_mobile": "1 |||| %{smart_count}",
"cancel": "Avbryt",
"clear_input_value": "Nullstill verdi",
"clone": "Klone",
@@ -306,6 +388,7 @@
"close_menu": "Lukk meny",
"unselect": "Avvelg",
"skip": "Hopp over",
+ "bulk_actions_mobile": "1 |||| %{smart_count}",
"share": "Del",
"download": "Last Ned"
},
@@ -400,31 +483,35 @@
"noPlaylistsAvailable": "Ingen tilgjengelig",
"delete_user_title": "Slett bruker '%{name}'",
"delete_user_content": "Er du sikker på at du vil slette denne brukeren og all tilhørlig data (inkludert spillelister og preferanser)?",
- "remove_missing_title": "Fjern manglende filer",
- "remove_missing_content": "Er du sikker på at du ønsker å fjerne de valgte manglende filene fra databasen? Dette vil permanent fjerne alle referanser til de, inkludert antall avspillinger og rangeringer.",
"notifications_blocked": "Du har blokkert notifikasjoner for denne nettsiden i din nettleser.",
"notifications_not_available": "Denne nettleseren støtter ikke skrivebordsnotifikasjoner, eller så er du ikke tilkoblet Navidrome via https.",
"lastfmLinkSuccess": "Last.fm er tilkoblet og scrobbling er aktivert",
"lastfmLinkFailure": "Last.fm kunne ikke koble til",
"lastfmUnlinkSuccess": "Last.fm er avkoblet og scrobbling er deaktivert",
"lastfmUnlinkFailure": "Last.fm kunne ikke avkobles",
- "listenBrainzLinkSuccess": "ListenBrainz er koblet til og scrobbling er aktivert som bruker: %{user}",
- "listenBrainzLinkFailure": "ListenBrainz kunne ikke koble til: %{error}",
- "listenBrainzUnlinkSuccess": "ListenBrainz er avkoblet og scrobbling er deaktivert",
- "listenBrainzUnlinkFailure": "ListenBrainz kunne ikke avkobles",
"openIn": {
"lastfm": "Åpne i Last.fm",
"musicbrainz": "Åpne i MusicBrainz"
},
"lastfmLink": "Les Mer...",
+ "listenBrainzLinkSuccess": "ListenBrainz er koblet til og scrobbling er aktivert som bruker: %{user}",
+ "listenBrainzLinkFailure": "ListenBrainz kunne ikke koble til: %{error}",
+ "listenBrainzUnlinkSuccess": "ListenBrainz er avkoblet og scrobbling er deaktivert",
+ "listenBrainzUnlinkFailure": "ListenBrainz kunne ikke avkobles",
+ "downloadOriginalFormat": "Last ned i originalformat",
"shareOriginalFormat": "Del i originalformat",
"shareDialogTitle": "Del %{resource} '%{name}'",
"shareBatchDialogTitle": "Del 1 %{resource} |||| Del %{smart_count} %{resource}",
- "shareCopyToClipboard": "Kopier til utklippstavle: Ctrl+C, Enter",
"shareSuccess": "URL kopiert til utklippstavle: %{url}",
"shareFailure": "Error ved kopiering av URL %{url} til utklippstavle",
"downloadDialogTitle": "Last ned %{resource} '%{name}' (%{size})",
- "downloadOriginalFormat": "Last ned i originalformat"
+ "shareCopyToClipboard": "Kopier til utklippstavle: Ctrl+C, Enter",
+ "remove_missing_title": "Fjern manglende filer",
+ "remove_missing_content": "Er du sikker på at du ønsker å fjerne de valgte manglende filene fra databasen? Dette vil permanent fjerne alle referanser til de, inkludert antall avspillinger og rangeringer.",
+ "remove_all_missing_title": "",
+ "remove_all_missing_content": "",
+ "noSimilarSongsFound": "",
+ "noTopSongsFound": ""
},
"menu": {
"library": "Bibliotek",
@@ -438,7 +525,6 @@
"language": "Språk",
"defaultView": "Standardvisning",
"desktop_notifications": "Skrivebordsnotifikasjoner",
- "lastfmNotConfigured": "Last.fm API-Key er ikke konfigurert",
"lastfmScrobbling": "Scrobble til Last.fm",
"listenBrainzScrobbling": "Scrobble til ListenBrainz",
"replaygain": "ReplayGain Mode",
@@ -447,13 +533,20 @@
"none": "Deaktivert",
"album": "Bruk Album Gain",
"track": "Bruk Track Gain"
- }
+ },
+ "lastfmNotConfigured": "Last.fm API-Key er ikke konfigurert"
}
},
"albumList": "Album",
+ "about": "Om",
"playlists": "Spillelister",
"sharedPlaylists": "Delte Spillelister",
- "about": "Om"
+ "librarySelector": {
+ "allLibraries": "",
+ "multipleLibraries": "",
+ "selectLibraries": "",
+ "none": ""
+ }
},
"player": {
"playListsText": "Spill Av Kø",
@@ -490,6 +583,21 @@
"disabled": "Deaktivert",
"waiting": "Venter"
}
+ },
+ "tabs": {
+ "about": "",
+ "config": ""
+ },
+ "config": {
+ "configName": "",
+ "environmentVariable": "",
+ "currentValue": "",
+ "configurationFile": "",
+ "exportToml": "",
+ "exportSuccess": "",
+ "exportFailed": "",
+ "devFlagsHeader": "",
+ "devFlagsComment": ""
}
},
"activity": {
@@ -498,7 +606,11 @@
"quickScan": "Hurtigskann",
"fullScan": "Full Skann",
"serverUptime": "Server Oppetid",
- "serverDown": "OFFLINE"
+ "serverDown": "OFFLINE",
+ "scanType": "",
+ "status": "",
+ "elapsedTime": "",
+ "selectiveScan": "Utvalgt"
},
"help": {
"title": "Navidrome Hurtigtaster",
@@ -508,10 +620,15 @@
"toggle_play": "Avspill / Pause",
"prev_song": "Forrige Sang",
"next_song": "Neste Sang",
- "current_song": "Gå til Nåværende Sang",
"vol_up": "Volum Opp",
"vol_down": "Volum Ned",
- "toggle_love": "Legg til spor i favoritter"
+ "toggle_love": "Legg til spor i favoritter",
+ "current_song": "Gå til Nåværende Sang"
}
+ },
+ "nowPlaying": {
+ "title": "",
+ "empty": "",
+ "minutesAgo": ""
}
-}
+}
\ No newline at end of file
diff --git a/resources/i18n/pl.json b/resources/i18n/pl.json
index 4d78c7599..a9d6db88f 100644
--- a/resources/i18n/pl.json
+++ b/resources/i18n/pl.json
@@ -301,14 +301,19 @@
"actions": {
"scan": "Skanuj Bibliotekę",
"manageUsers": "Zarządzaj Dostępami Użytkownika",
- "viewDetails": "Zobacz Szczegóły"
+ "viewDetails": "Zobacz Szczegóły",
+ "quickScan": "Szybkie Skanowanie",
+ "fullScan": "Pełne Skanowanie"
},
"notifications": {
"created": "Biblioteka utworzona prawidłowo",
"updated": "Biblioteka zaktualizowana prawidłowo",
"deleted": "Biblioteka usunięta prawidłowo",
"scanStarted": "Rozpoczęto skan biblioteki",
- "scanCompleted": "Zakończono skan biblioteki"
+ "scanCompleted": "Zakończono skan biblioteki",
+ "quickScanStarted": "Szybkie skanowanie rozpoczęte",
+ "fullScanStarted": "Pełne skanowanie rozpoczęte",
+ "scanError": "Błąd podczas startu skanowania. Sprawdź logi"
},
"validation": {
"nameRequired": "Nazwa biblioteki jest wymagana",
@@ -604,7 +609,8 @@
"serverDown": "NIEDOSTĘPNY",
"scanType": "Typ",
"status": "Błąd Skanowania",
- "elapsedTime": "Upłynięty Czas"
+ "elapsedTime": "Upłynięty Czas",
+ "selectiveScan": "Selektywne"
},
"help": {
"title": "Skróty Klawiszowe Navidrome",
diff --git a/resources/i18n/pt-br.json b/resources/i18n/pt-br.json
index 9c22d509f..3f095b025 100644
--- a/resources/i18n/pt-br.json
+++ b/resources/i18n/pt-br.json
@@ -300,6 +300,8 @@
},
"actions": {
"scan": "Scanear Biblioteca",
+ "quickScan": "Scan Rápido",
+ "fullScan": "Scan Completo",
"manageUsers": "Gerenciar Acesso do Usuário",
"viewDetails": "Ver Detalhes"
},
@@ -308,6 +310,9 @@
"updated": "Biblioteca atualizada com sucesso",
"deleted": "Biblioteca excluída com sucesso",
"scanStarted": "Scan da biblioteca iniciada",
+ "quickScanStarted": "Scan rápido iniciado",
+ "fullScanStarted": "Scan completo iniciado",
+ "scanError": "Erro ao iniciar o scan. Verifique os logs",
"scanCompleted": "Scan da biblioteca concluída"
},
"validation": {
@@ -598,11 +603,12 @@
"activity": {
"title": "Atividade",
"totalScanned": "Total de pastas scaneadas",
- "quickScan": "Scan rápido",
- "fullScan": "Scan completo",
+ "quickScan": "Rápido",
+ "fullScan": "Completo",
+ "selectiveScan": "Seletivo",
"serverUptime": "Uptime do servidor",
"serverDown": "DESCONECTADO",
- "scanType": "Tipo",
+ "scanType": "Último Scan",
"status": "Erro",
"elapsedTime": "Duração"
},
diff --git a/resources/i18n/ru.json b/resources/i18n/ru.json
index e29996275..2d7ffd249 100644
--- a/resources/i18n/ru.json
+++ b/resources/i18n/ru.json
@@ -301,20 +301,25 @@
"actions": {
"scan": "Сканировать библиотеку",
"manageUsers": "Управление доступом пользователей",
- "viewDetails": "Просмотреть подробности"
+ "viewDetails": "Просмотреть подробности",
+ "quickScan": "Быстрое сканирование",
+ "fullScan": "Полное сканирование"
},
"notifications": {
"created": "Библиотека успешно создана",
"updated": "Библиотека успешно обновлена",
"deleted": "Библиотека успешно удалена",
"scanStarted": "Сканирование библиотеки начато",
- "scanCompleted": "Сканирование библиотеки закончено"
+ "scanCompleted": "Сканирование библиотеки закончено",
+ "quickScanStarted": "Быстрое сканирование началось",
+ "fullScanStarted": "Началось полное сканирование",
+ "scanError": "Ошибка при запуске сканирования. Проверьте логи"
},
"validation": {
"nameRequired": "Имя библиотеки обязательно",
"pathRequired": "Путь к библиотеке обязателен",
"pathNotDirectory": "Путь к библиотеке должен быть директорией",
- "pathNotFound": "Путь к библиотеке не найдено",
+ "pathNotFound": "Путь к библиотеке не найден",
"pathNotAccessible": "Путь к библиотеке недоступен",
"pathInvalid": "Неверный путь к библиотеке"
},
@@ -604,7 +609,8 @@
"serverDown": "Оффлайн",
"scanType": "Тип",
"status": "Ошибка сканирования",
- "elapsedTime": "Прошедшее время"
+ "elapsedTime": "Прошедшее время",
+ "selectiveScan": "Избирательный"
},
"help": {
"title": "Горячие клавиши Navidrome",
diff --git a/resources/i18n/sv.json b/resources/i18n/sv.json
index 521f997a8..30bf89ec4 100644
--- a/resources/i18n/sv.json
+++ b/resources/i18n/sv.json
@@ -301,14 +301,19 @@
"actions": {
"scan": "Scanna bibliotek",
"manageUsers": "Hantera användaråtkomst",
- "viewDetails": "Se detaljer"
+ "viewDetails": "Se detaljer",
+ "quickScan": "Snabbscan",
+ "fullScan": "Komplett scan"
},
"notifications": {
"created": "Biblioteket har skapats",
"updated": "Biblioteket har uppdaterats",
"deleted": "Biblioteket har raderats",
"scanStarted": "Biblioteksscan startad",
- "scanCompleted": "Biblioteksscan avslutad"
+ "scanCompleted": "Biblioteksscan avslutad",
+ "quickScanStarted": "Snabbscan startad",
+ "fullScanStarted": "Komplett scan startad",
+ "scanError": "Fel vid start av scan. Se loggarna"
},
"validation": {
"nameRequired": "Biblioteksnamn krävs",
@@ -604,7 +609,8 @@
"serverDown": "OFFLINE",
"scanType": "Typ",
"status": "Fel vid scanning",
- "elapsedTime": "Spelad tid"
+ "elapsedTime": "Spelad tid",
+ "selectiveScan": "Urval"
},
"help": {
"title": "Navidrome kortkommandon",
diff --git a/resources/i18n/th.json b/resources/i18n/th.json
index 2f96f4958..833a68ab9 100644
--- a/resources/i18n/th.json
+++ b/resources/i18n/th.json
@@ -26,7 +26,17 @@
"bpm": "BPM",
"playDate": "เล่นล่าสุด",
"channels": "ช่อง",
- "createdAt": "เพิ่มเมื่อ"
+ "createdAt": "เพิ่มเมื่อ",
+ "grouping": "จัดกลุ่ม",
+ "mood": "อารมณ์",
+ "participants": "ผู้มีส่วนร่วม",
+ "tags": "แทกเพิ่มเติม",
+ "mappedTags": "แมพแทก",
+ "rawTags": "แทกเริ่มต้น",
+ "bitDepth": "Bit depth",
+ "sampleRate": "แซมเปิ้ลเรต",
+ "missing": "หายไป",
+ "libraryName": "ห้องสมุด"
},
"actions": {
"addToQueue": "เพิ่มในคิว",
@@ -35,7 +45,8 @@
"shuffleAll": "สุ่มทั้งหมด",
"download": "ดาวน์โหลด",
"playNext": "เล่นถัดไป",
- "info": "ดูรายละเอียด"
+ "info": "ดูรายละเอียด",
+ "showInPlaylist": "แสดงในเพลย์ลิสต์"
}
},
"album": {
@@ -58,7 +69,16 @@
"originalDate": "วันที่เริ่ม",
"releaseDate": "เผยแพร่เมื่อ",
"releases": "เผยแพร่ |||| เผยแพร่",
- "released": "เผยแพร่เมื่อ"
+ "released": "เผยแพร่เมื่อ",
+ "recordLabel": "ป้าย",
+ "catalogNum": "หมายเลขแคตาล็อก",
+ "releaseType": "ประเภท",
+ "grouping": "จัดกลุ่ม",
+ "media": "มีเดีย",
+ "mood": "อารมณ์",
+ "date": "บันทึกเมื่อ",
+ "missing": "หายไป",
+ "libraryName": "ห้องสมุด"
},
"actions": {
"playAll": "เล่นทั้งหมด",
@@ -89,7 +109,30 @@
"playCount": "เล่นแล้ว",
"rating": "ความนิยม",
"genre": "ประเภท",
- "size": "ขนาด"
+ "size": "ขนาด",
+ "role": "Role",
+ "missing": "หายไป"
+ },
+ "roles": {
+ "albumartist": "ศิลปินอัลบั้ม |||| ศิลปินอัลบั้ม",
+ "artist": "ศิลปิน |||| ศิลปิน",
+ "composer": "ผู้แต่ง |||| ผู้แต่ง",
+ "conductor": "คอนดักเตอร์ |||| คอนดักเตอร์",
+ "lyricist": "เนื้อเพลง |||| เนื้อเพลง",
+ "arranger": "ผู้ดำเนินการ |||| ผู้ดำเนินการ",
+ "producer": "ผู้จัด |||| ผู้จัด",
+ "director": "ไดเรกเตอร์ |||| ไดเรกเตอร์",
+ "engineer": "วิศวกร |||| วิศวกร",
+ "mixer": "มิกเซอร์ |||| มิกเซอร์",
+ "remixer": "รีมิกเซอร์ |||| รีมิกเซอร์",
+ "djmixer": "ดีเจมิกเซอร์ |||| ดีเจมิกเซอร์",
+ "performer": "ผู้เล่น |||| ผู้เล่น",
+ "maincredit": "ศิลปิน |||| ศิลปิน"
+ },
+ "actions": {
+ "shuffle": "เล่นสุ่ม",
+ "radio": "วิทยุ",
+ "topSongs": "เพลงยอดนิยม"
}
},
"user": {
@@ -106,10 +149,12 @@
"currentPassword": "รหัสผ่านปัจจุบัน",
"newPassword": "รหัสผ่านใหม่",
"token": "โทเคน",
- "lastAccessAt": "เข้าใช้ล่าสุด"
+ "lastAccessAt": "เข้าใช้ล่าสุด",
+ "libraries": "ห้องสมุด"
},
"helperTexts": {
- "name": "การเปลี่ยนชื่อจะมีผลในการล็อกอินครั้งถัดไป"
+ "name": "การเปลี่ยนชื่อจะมีผลในการล็อกอินครั้งถัดไป",
+ "libraries": "เลือกห้องสมุดสำหรับผู้ใช้นี้หรือปล่อยว่างเพื่อใช้ห้องสมุดเริ่มต้น"
},
"notifications": {
"created": "สร้างชื่อผู้ใช้",
@@ -118,7 +163,12 @@
},
"message": {
"listenBrainzToken": "ใส่โทเคน ListenBrainz ของคุณ",
- "clickHereForToken": "กดที่นี่เพื่อรับโทเคนของคุณ"
+ "clickHereForToken": "กดที่นี่เพื่อรับโทเคนของคุณ",
+ "selectAllLibraries": "เลือกห้องสมุดทั้งหมด",
+ "adminAutoLibraries": "ผู้ดูแลเข้าถึงห้องสมุดทั้งหมดโดยอัตโนมัติ"
+ },
+ "validation": {
+ "librariesRequired": "ต้องเลือกห้องสมุด 1 ห้อง สำหรับผู้ใช้ที่ไม่ใช่ผู้ดูแล"
}
},
"player": {
@@ -162,11 +212,17 @@
"addNewPlaylist": "สร้าง \"%{name}\"",
"export": "ส่งออก",
"makePublic": "ทำเป็นสาธารณะ",
- "makePrivate": "ทำเป็นส่วนตัว"
+ "makePrivate": "ทำเป็นส่วนตัว",
+ "saveQueue": "บันทึกคิวลงเพลย์ลิสต์",
+ "searchOrCreate": "ค้นหาเพลย์ลิสต์หรือพิมพ์เพื่อสร้างใหม่",
+ "pressEnterToCreate": "กด Enter เพื่อสร้างเพลย์ลิสต์",
+ "removeFromSelection": "เอาออกจากที่เลือกไว้"
},
"message": {
"duplicate_song": "เพิ่มเพลงซ้ำ",
- "song_exist": "เพิ่มเพลงซ้ำกันในเพลย์ลิสต์ คุณจะเพิ่มเพลงต่อหรือข้าม"
+ "song_exist": "เพิ่มเพลงซ้ำกันในเพลย์ลิสต์ คุณจะเพิ่มเพลงต่อหรือข้าม",
+ "noPlaylistsFound": "ไม่พบเพลย์ลิสต์",
+ "noPlaylists": "ไม่มีเพลย์ลิสต์อยู่"
}
},
"radio": {
@@ -198,6 +254,80 @@
"createdAt": "สร้างเมื่อ",
"downloadable": "อนุญาตให้ดาวโหลด?"
}
+ },
+ "missing": {
+ "name": "ไฟล์ที่หายไป |||| ไฟล์ที่หายไป",
+ "fields": {
+ "path": "พาร์ท",
+ "size": "ขนาด",
+ "updatedAt": "หายไปจาก",
+ "libraryName": "ห้องสมุด"
+ },
+ "actions": {
+ "remove": "เอาออก",
+ "remove_all": "เอาออกทั้งหมด"
+ },
+ "notifications": {
+ "removed": "เอาไฟล์ที่หายไปออกแล้ว"
+ },
+ "empty": "ไม่มีไฟล์หาย"
+ },
+ "library": {
+ "name": "ห้องสมุด |||| ห้องสมุด",
+ "fields": {
+ "name": "ชื่อ",
+ "path": "พาร์ท",
+ "remotePath": "รีโมทพาร์ท",
+ "lastScanAt": "สแกนล่าสุด",
+ "songCount": "เพลง",
+ "albumCount": "อัลบัม",
+ "artistCount": "ศิลปิน",
+ "totalSongs": "เพลง",
+ "totalAlbums": "อัลบัม",
+ "totalArtists": "ศิลปิน",
+ "totalFolders": "แฟ้ม",
+ "totalFiles": "ไฟล์",
+ "totalMissingFiles": "ไฟล์ที่หายไป",
+ "totalSize": "ขนาดทั้งหมด",
+ "totalDuration": "ความยาว",
+ "defaultNewUsers": "ค่าเริ่มต้นผู้ใช้ใหม่",
+ "createdAt": "สร้าง",
+ "updatedAt": "อัพเดท"
+ },
+ "sections": {
+ "basic": "ข้อมูลเบื้องต้น",
+ "statistics": "สถิติ"
+ },
+ "actions": {
+ "scan": "สแกนห้องสมุด",
+ "manageUsers": "ตั้งค่าการเข้าถึง",
+ "viewDetails": "ดูรายละเอียด",
+ "quickScan": "สแกนแบบเร็ว",
+ "fullScan": "สแกนแบบเต็ม"
+ },
+ "notifications": {
+ "created": "สร้างห้องสมุดเรียบร้อย",
+ "updated": "อัพเดทห้องสมุดเรียบร้อย",
+ "deleted": "ลบห้องสมุดเพลงเรียบร้อยแล้ว",
+ "scanStarted": "เริ่มสแกนห้องสมุด",
+ "scanCompleted": "สแกนห้องสมุดเสร็จแล้ว",
+ "quickScanStarted": "เริ่มสแกนแบบเร็ว",
+ "fullScanStarted": "เริ่มสแกนแบบเต็ม",
+ "scanError": "การเริ่มสแกนผิดพลาด ดูในบันทึก"
+ },
+ "validation": {
+ "nameRequired": "ต้องใส่ชื่อห้องสมุดเพลง",
+ "pathRequired": "ต้องใส่พาร์ทของห้องสมุด",
+ "pathNotDirectory": "พาร์ทของห้องสมุดต้องเป็นแฟ้ม",
+ "pathNotFound": "ไม่เจอพาร์ทของห้องสมุด",
+ "pathNotAccessible": "ไม่สามารถเข้าพาร์ทของห้องสมุด",
+ "pathInvalid": "พาร์ทห้องสมุดไม่ถูก"
+ },
+ "messages": {
+ "deleteConfirm": "คุณแน่ใจว่าจะลบห้องสมุดนี้? นี่จะลบข้อมูลและการเข้าถึงของผู้ใช้ที่เกี่ยวข้องทั้งหมด",
+ "scanInProgress": "กำลังสแกน...",
+ "noLibrariesAssigned": "ไม่มีห้องสมุดสำหรับผู้ใช้นี้"
+ }
}
},
"ra": {
@@ -375,7 +505,13 @@
"shareSuccess": "คัดลอก URL ไปคลิปบอร์ด: %{url}",
"shareFailure": "คัดลอก URL %{url} ไปคลิปบอร์ดผิดพลาด",
"downloadDialogTitle": "ดาวโหลด %{resource} '%{name}' (%{size})",
- "shareCopyToClipboard": "คัดลอกไปคลิปบอร์ด: Ctrl+C, Enter"
+ "shareCopyToClipboard": "คัดลอกไปคลิปบอร์ด: Ctrl+C, Enter",
+ "remove_missing_title": "ลบรายการไฟล์ที่หายไป",
+ "remove_missing_content": "คุณแน่ใจว่าจะเอารายการไฟล์ที่หายไปออกจากดาต้าเบส นี่จะเป็นการลบข้อมูลอ้างอิงทั้งหมดของไฟล์ออกอย่างถาวร",
+ "remove_all_missing_title": "เอารายการไฟล์ที่หายไปออกทั้งหมด",
+ "remove_all_missing_content": "คุณแน่ใจว่าจะเอารายการไฟล์ที่หายไปออกจากดาต้าเบส นี่จะเป็นการลบข้อมูลอ้างอิงทั้งหมดของไฟล์ออกอย่างถาวร",
+ "noSimilarSongsFound": "ไม่มีเพลงคล้ายกัน",
+ "noTopSongsFound": "ไม่พบเพลงยอดนิยม"
},
"menu": {
"library": "ห้องสมุดเพลง",
@@ -404,7 +540,13 @@
"albumList": "อัลบั้ม",
"about": "เกี่ยวกับ",
"playlists": "เพลย์ลิสต์",
- "sharedPlaylists": "เพลย์ลิสต์ที่แบ่งปัน"
+ "sharedPlaylists": "เพลย์ลิสต์ที่แบ่งปัน",
+ "librarySelector": {
+ "allLibraries": "ห้องสมุด (%{count}) ห้อง",
+ "multipleLibraries": "%{selected} ของ %{total} ห้องสมุด",
+ "selectLibraries": "เลือกห้องสมุด",
+ "none": "ไม่มี"
+ }
},
"player": {
"playListsText": "คิวเล่น",
@@ -441,6 +583,21 @@
"disabled": "ปิดการทำงาน",
"waiting": "รอ"
}
+ },
+ "tabs": {
+ "about": "เกี่ยวกับ",
+ "config": "การตั้งค่า"
+ },
+ "config": {
+ "configName": "ชื่อการตั้งค่า",
+ "environmentVariable": "ค่าทั่วไป",
+ "currentValue": "ค่าปัจจุบัน",
+ "configurationFile": "ไฟล์การตั้งค่า",
+ "exportToml": "นำออกการตั้งค่า (TOML)",
+ "exportSuccess": "นำออกการตั้งค่าไปยังคลิปบอร์ดในรูปแบบ TOML แล้ว",
+ "exportFailed": "คัดลอกการตั้งค่าล้มเหลว",
+ "devFlagsHeader": "ปักธงการพัฒนา (อาจมีการเปลี่ยน/เอาออก)",
+ "devFlagsComment": "การตั้งค่านี้อยู่ในช่วงทดลองและอาจจะมีการเอาออกในเวอร์ชั่นหลัง"
}
},
"activity": {
@@ -449,7 +606,11 @@
"quickScan": "สแกนแบบเร็ว",
"fullScan": "สแกนทั้งหมด",
"serverUptime": "เซิร์ฟเวอร์ออนไลน์นาน",
- "serverDown": "ออฟไลน์"
+ "serverDown": "ออฟไลน์",
+ "scanType": "ประเภท",
+ "status": "สแกนผิดพลาด",
+ "elapsedTime": "เวลาที่ใช้",
+ "selectiveScan": "เลือก"
},
"help": {
"title": "คีย์ลัด Navidrome",
@@ -464,5 +625,10 @@
"toggle_love": "เพิ่มเพลงนี้ไปยังรายการโปรด",
"current_song": "ไปยังเพลงปัจจุบัน"
}
+ },
+ "nowPlaying": {
+ "title": "กำลังเล่น",
+ "empty": "ไม่มีเพลงเล่น",
+ "minutesAgo": "%{smart_count} นาทีที่แล้ว |||| %{smart_count} นาทีที่แล้ว"
}
}
\ No newline at end of file
diff --git a/resources/i18n/tr.json b/resources/i18n/tr.json
index 7c1a82c08..d1fdb2ed4 100644
--- a/resources/i18n/tr.json
+++ b/resources/i18n/tr.json
@@ -301,14 +301,19 @@
"actions": {
"scan": "Kütüphaneyi Tara",
"manageUsers": "Kullanıcı Erişimini Yönet",
- "viewDetails": "Ayrıntıları Görüntüle"
+ "viewDetails": "Ayrıntıları Görüntüle",
+ "quickScan": "Hızlı Tarama",
+ "fullScan": "Tam Tarama"
},
"notifications": {
"created": "Kütüphane başarıyla oluşturuldu",
"updated": "Kütüphane başarıyla güncellendi",
"deleted": "Kütüphane başarıyla silindi",
"scanStarted": "Kütüphane taraması başladı",
- "scanCompleted": "Kütüphane taraması tamamlandı"
+ "scanCompleted": "Kütüphane taraması tamamlandı",
+ "quickScanStarted": "Hızlı tarama başlatıldı",
+ "fullScanStarted": "Tam tarama başlatıldı",
+ "scanError": "Tarama başlatılırken hata oluştu. Günlükleri kontrol edin."
},
"validation": {
"nameRequired": "Kütüphane adı gereklidir",
@@ -604,7 +609,8 @@
"serverDown": "ÇEVRİMDIŞI",
"scanType": "Tür",
"status": "Tarama Hatası",
- "elapsedTime": "Geçen Süre"
+ "elapsedTime": "Geçen Süre",
+ "selectiveScan": "Seçmeli"
},
"help": {
"title": "Navidrome Kısayolları",
diff --git a/resources/i18n/uk.json b/resources/i18n/uk.json
index c500a7457..2c74c890a 100644
--- a/resources/i18n/uk.json
+++ b/resources/i18n/uk.json
@@ -301,14 +301,19 @@
"actions": {
"scan": "Сканувати бібліотеку",
"manageUsers": "Керування доступом користувачів",
- "viewDetails": "Переглянути подробиці"
+ "viewDetails": "Переглянути подробиці",
+ "quickScan": "Швидке сканування",
+ "fullScan": "Повне сканування"
},
"notifications": {
"created": "Бібліотеку успішно створено",
"updated": "Бібліотеку успішно оновлено",
"deleted": "Бібліотеку успішно видалено",
"scanStarted": "Сканування бібліотеки розпочато",
- "scanCompleted": "Сканування бібліотеки закінчено"
+ "scanCompleted": "Сканування бібліотеки закінчено",
+ "quickScanStarted": "Швидке сканування виконується",
+ "fullScanStarted": "Повне сканування виконується",
+ "scanError": "Помилка при виконанні сканування. Перевірте лоґи"
},
"validation": {
"nameRequired": "Ім'я бібліотеки обов'язкове",
@@ -604,7 +609,8 @@
"serverDown": "Оффлайн",
"scanType": "Тип",
"status": "Помилка сканування",
- "elapsedTime": "Пройдений час"
+ "elapsedTime": "Пройдений час",
+ "selectiveScan": "Вибірковий"
},
"help": {
"title": "Гарячі клавіші Navidrome",
diff --git a/resources/i18n/zh-Hans.json b/resources/i18n/zh-Hans.json
index c447f7d72..cde28c4f3 100644
--- a/resources/i18n/zh-Hans.json
+++ b/resources/i18n/zh-Hans.json
@@ -13,12 +13,14 @@
"album": "专辑",
"path": "文件路径",
"genre": "流派",
+ "libraryName": "媒体库",
"compilation": "合辑",
"year": "发行年份",
"size": "文件大小",
"updatedAt": "更新于",
"bitRate": "比特率",
"bitDepth": "比特深度",
+ "sampleRate": "采样率",
"channels": "声道",
"discSubtitle": "字幕",
"starred": "收藏",
@@ -33,12 +35,14 @@
"participants": "其他参与人员",
"tags": "附加标签",
"mappedTags": "映射标签",
- "rawTags": "原始标签"
+ "rawTags": "原始标签",
+ "missing": "缺失"
},
"actions": {
"addToQueue": "加入播放列表",
"playNow": "立即播放",
"addToPlaylist": "加入歌单",
+ "showInPlaylist": "定位到播放列表",
"shuffleAll": "全部随机播放",
"download": "下载",
"playNext": "下一首播放",
@@ -56,6 +60,7 @@
"size": "文件大小",
"name": "名称",
"genre": "流派",
+ "libraryName": "媒体库",
"compilation": "合辑",
"year": "发行年份",
"date": "录制日期",
@@ -72,7 +77,8 @@
"releaseType": "发行类型",
"grouping": "分组",
"media": "媒体类型",
- "mood": "情绪"
+ "mood": "情绪",
+ "missing": "缺失"
},
"actions": {
"playAll": "立即播放",
@@ -104,7 +110,8 @@
"playCount": "播放次数",
"rating": "评分",
"genre": "流派",
- "role": "参与角色"
+ "role": "参与角色",
+ "missing": "缺失"
},
"roles": {
"albumartist": "专辑歌手",
@@ -119,7 +126,13 @@
"mixer": "混音师",
"remixer": "重混师",
"djmixer": "DJ混音师",
- "performer": "演奏家"
+ "performer": "演奏家",
+ "maincredit": "主要艺术家"
+ },
+ "actions": {
+ "topSongs": "热门歌曲",
+ "shuffle": "随机播放",
+ "radio": "电台"
}
},
"user": {
@@ -136,19 +149,26 @@
"changePassword": "修改密码?",
"currentPassword": "当前密码",
"newPassword": "新密码",
- "token": "令牌"
+ "token": "令牌",
+ "libraries": "媒体库"
},
"helperTexts": {
- "name": "名称的更改将在下次登录时生效"
+ "name": "名称的更改将在下次登录时生效",
+ "libraries": "为该用户选择指定媒体库,留空则使用默认媒体库"
},
"notifications": {
"created": "用户已创建",
"updated": "用户已更新",
"deleted": "用户已删除"
},
+ "validation": {
+ "librariesRequired": "普通用户必须至少选择一个媒体库"
+ },
"message": {
"listenBrainzToken": "输入您的 ListenBrainz 用户令牌",
- "clickHereForToken": "点击这里来获得你的 ListenBrainz 令牌"
+ "clickHereForToken": "点击这里来获得你的 ListenBrainz 令牌",
+ "selectAllLibraries": "选择全部媒体库",
+ "adminAutoLibraries": "管理员默认可访问所有媒体库"
}
},
"player": {
@@ -191,12 +211,18 @@
"selectPlaylist": "选择歌单",
"addNewPlaylist": "新建 %{name}",
"export": "导出",
+ "saveQueue": "保存为歌单",
"makePublic": "设为公开",
- "makePrivate": "设为私有"
+ "makePrivate": "设为私有",
+ "searchOrCreate": "搜索歌单,或输入名称新建…",
+ "pressEnterToCreate": "按 Enter 键新建歌单",
+ "removeFromSelection": "移除选中项"
},
"message": {
"duplicate_song": "添加重复的歌曲",
- "song_exist": "部分选定的歌曲已存在歌单中,继续添加或是跳过它们?"
+ "song_exist": "部分选定的歌曲已存在歌单中,继续添加或是跳过它们?",
+ "noPlaylistsFound": "未找到歌单",
+ "noPlaylists": "暂无可用歌单"
}
},
"radio": {
@@ -237,14 +263,68 @@
"fields": {
"path": "路径",
"size": "文件大小",
+ "libraryName": "媒体库",
"updatedAt": "丢失于"
},
"actions": {
- "remove": "移除"
+ "remove": "移除",
+ "remove_all": "移除所有"
},
"notifications": {
"removed": "丢失文件已移除"
}
+ },
+ "library": {
+ "name": "媒体库",
+ "fields": {
+ "name": "名称",
+ "path": "路径",
+ "remotePath": "远程路径",
+ "lastScanAt": "上次扫描",
+ "songCount": "歌曲",
+ "albumCount": "专辑",
+ "artistCount": "艺术家",
+ "totalSongs": "歌曲",
+ "totalAlbums": "专辑",
+ "totalArtists": "艺术家",
+ "totalFolders": "目录",
+ "totalFiles": "文件",
+ "totalMissingFiles": "缺失的文件",
+ "totalSize": "总大小",
+ "totalDuration": "时长",
+ "defaultNewUsers": "新用户默认",
+ "createdAt": "创建于",
+ "updatedAt": "更新于"
+ },
+ "sections": {
+ "basic": "基本信息",
+ "statistics": "统计数据"
+ },
+ "actions": {
+ "scan": "扫描媒体库",
+ "manageUsers": "管理用户权限",
+ "viewDetails": "查看详情"
+ },
+ "notifications": {
+ "created": "媒体库已创建",
+ "updated": "媒体库已更新",
+ "deleted": "媒体库已删除",
+ "scanStarted": "开始扫描媒体库",
+ "scanCompleted": "媒体库扫描已完成"
+ },
+ "validation": {
+ "nameRequired": "媒体库名称不能为空!",
+ "pathRequired": "媒体库路径不能为空!",
+ "pathNotDirectory": "媒体库路径必须为目录!",
+ "pathNotFound": "媒体库路径不存在!",
+ "pathNotAccessible": "媒体库路径无法访问!",
+ "pathInvalid": "媒体库路径无效!"
+ },
+ "messages": {
+ "deleteConfirm": "您确定要删除此媒体库吗?此操作将删除所有关联数据及用户访问权限!",
+ "scanInProgress": "正在扫描...",
+ "noLibrariesAssigned": "该用户未分配任何媒体库!"
+ }
}
},
"ra": {
@@ -397,11 +477,15 @@
"transcodingDisabled": "出于安全原因,从 Web 界面更改转码配置的功能已被禁用。要更改(编辑或新增)转码选项,请在启用 %{config} 选项的情况下重新启动服务器。",
"transcodingEnabled": "Navidrome 当前与 %{config} 一起使用,可以通过配置转码选项来执行任意命令,建议仅在配置转码选项时启用此功能。",
"songsAddedToPlaylist": "已添加 %{smart_count} 首歌到歌单",
+ "noSimilarSongsFound": "未找到相似歌曲",
+ "noTopSongsFound": "未找到热门歌曲",
"noPlaylistsAvailable": "没有有效的歌单",
"delete_user_title": "删除用户 %{name}",
"delete_user_content": "您确定要删除该用户及其相关数据(包括歌单和用户配置)吗?",
"remove_missing_title": "移除丢失文件",
"remove_missing_content": "您确定要将选中的丢失文件从数据库中永久移除吗?此操作将删除所有相关信息,包括播放次数和评分。",
+ "remove_all_missing_title": "删除所有丢失文件",
+ "remove_all_missing_content": "您确定要从数据库中删除所有丢失文件吗?这将永久删除对它们的所有引用,包括它们的播放次数和评分。",
"notifications_blocked": "您已在浏览器的设置中屏蔽了此网站的通知",
"notifications_not_available": "此浏览器不支持桌面通知",
"lastfmLinkSuccess": "Last.fm 已关联并启用喜好记录",
@@ -428,6 +512,12 @@
},
"menu": {
"library": "曲库",
+ "librarySelector": {
+ "allLibraries": "全部媒体库 (%{count})",
+ "multipleLibraries": "已选 %{selected} 共 %{total} 媒体库",
+ "selectLibraries": "选择媒体库",
+ "none": "无"
+ },
"settings": "设置",
"version": "版本",
"theme": "主题",
@@ -490,6 +580,21 @@
"disabled": "禁用",
"waiting": "等待"
}
+ },
+ "tabs": {
+ "about": "关于",
+ "config": "配置"
+ },
+ "config": {
+ "configName": "配置名称",
+ "environmentVariable": "环境变量",
+ "currentValue": "当前值",
+ "configurationFile": "配置文件",
+ "exportToml": "导出配置(TOML)",
+ "exportSuccess": "配置以 TOML 格式导出到剪贴板",
+ "exportFailed": "复制配置失败",
+ "devFlagsHeader": "开发标志(可能会更改/删除)",
+ "devFlagsComment": "这些是实验性设置,可能会在未来版本中删除"
}
},
"activity": {
@@ -498,7 +603,15 @@
"quickScan": "快速扫描",
"fullScan": "完全扫描",
"serverUptime": "服务器已运行",
- "serverDown": "服务器已离线"
+ "serverDown": "服务器已离线",
+ "scanType": "扫描类型",
+ "status": "扫描状态",
+ "elapsedTime": "用时"
+ },
+ "nowPlaying": {
+ "title": "正在播放",
+ "empty": "无播放内容",
+ "minutesAgo": "%{smart_count} 分钟前"
},
"help": {
"title": "Navidrome 快捷键",
@@ -514,4 +627,4 @@
"toggle_love": "添加/移除星标"
}
}
-}
\ No newline at end of file
+}
diff --git a/resources/i18n/zh-Hant.json b/resources/i18n/zh-Hant.json
index 3d6bbd268..7d8ce2872 100644
--- a/resources/i18n/zh-Hant.json
+++ b/resources/i18n/zh-Hant.json
@@ -1,463 +1,630 @@
{
- "languageName": "繁體中文",
- "resources": {
- "song": {
- "name": "歌曲 |||| 歌曲",
- "fields": {
- "albumArtist": "專輯藝人",
- "duration": "長度",
- "trackNumber": "#",
- "playCount": "播放次數",
- "title": "標題",
- "artist": "藝人",
- "album": "專輯",
- "path": "文件路徑",
- "genre": "類型",
- "compilation": "合輯",
- "year": "發行年份",
- "size": "檔案大小",
- "updatedAt": "更新於",
- "bitRate": "位元率",
- "discSubtitle": "字幕",
- "starred": "收藏",
- "comment": "註解",
- "rating": "評分",
- "quality": "品質",
- "bpm": "BPM",
- "playDate": "上次播放",
- "channels": "聲道",
- "createdAt": "創建於"
- },
- "actions": {
- "addToQueue": "加入至播放佇列",
- "playNow": "立即播放",
- "addToPlaylist": "加入至播放清單",
- "shuffleAll": "全部隨機播放",
- "download": "下載",
- "playNext": "下一首播放",
- "info": "取得資訊"
- }
- },
- "album": {
- "name": "專輯 |||| 專輯",
- "fields": {
- "albumArtist": "專輯藝人",
- "artist": "藝人",
- "duration": "長度",
- "songCount": "歌曲數量",
- "playCount": "播放次數",
- "name": "名稱",
- "genre": "類型",
- "compilation": "合輯",
- "year": "發行年份",
- "updatedAt": "更新於",
- "comment": "註解",
- "rating": "評分",
- "createdAt": "創建於",
- "size": "檔案大小",
- "originalDate": "原始日期",
- "releaseDate": "發行日期",
- "releases": "發行",
- "released": "已發行"
- },
- "actions": {
- "playAll": "立即播放",
- "playNext": "下首播放",
- "addToQueue": "加入至播放佇列",
- "shuffle": "隨機播放",
- "addToPlaylist": "加入播放清單",
- "download": "下載",
- "info": "取得資訊",
- "share": "分享"
- },
- "lists": {
- "all": "所有",
- "random": "隨機",
- "recentlyAdded": "最近加入",
- "recentlyPlayed": "最近播放",
- "mostPlayed": "最多播放的",
- "starred": "收藏",
- "topRated": "最高評分"
- }
- },
- "artist": {
- "name": "藝人 |||| 藝人",
- "fields": {
- "name": "名稱",
- "albumCount": "專輯數",
- "songCount": "歌曲數",
- "playCount": "播放次數",
- "rating": "評分",
- "genre": "類型",
- "size": "檔案大小"
- }
- },
- "user": {
- "name": "使用者 |||| 使用者",
- "fields": {
- "userName": "使用者名稱",
- "isAdmin": "是否管理員",
- "lastLoginAt": "上次登入",
- "lastAccessAt": "上此訪問",
- "updatedAt": "更新於",
- "name": "名稱",
- "password": "密碼",
- "createdAt": "創建於",
- "changePassword": "變更密碼?",
- "currentPassword": "現在的密碼",
- "newPassword": "新密碼",
- "token": "權杖"
- },
- "helperTexts": {
- "name": "你的名稱會在下次登入時生效"
- },
- "notifications": {
- "created": "使用者已創建",
- "updated": "使用者已更新",
- "deleted": "使用者已刪除"
- },
- "message": {
- "listenBrainzToken": "輸入您的 ListenBrainz 使用者權杖",
- "clickHereForToken": "點擊此處來獲得你的 ListenBrainz 權杖"
- }
- },
- "player": {
- "name": "用戶端 |||| 用戶端",
- "fields": {
- "name": "名稱",
- "transcodingId": "轉碼",
- "maxBitRate": "最大位元率",
- "client": "用戶端",
- "userName": "使用者名稱",
- "lastSeen": "上次瀏覽",
- "reportRealPath": "回報實際路徑",
- "scrobbleEnabled": "傳送音樂記錄至外部服務"
- }
- },
- "transcoding": {
- "name": "轉碼 |||| 轉碼",
- "fields": {
- "name": "名稱",
- "targetFormat": "目標格式",
- "defaultBitRate": "預設位元率",
- "command": "命令"
- }
- },
- "playlist": {
- "name": "播放清單 |||| 播放清單",
- "fields": {
- "name": "名稱",
- "duration": "長度",
- "ownerName": "擁有者",
- "public": "公開",
- "updatedAt": "更新於",
- "createdAt": "創建於",
- "songCount": "歌曲數",
- "comment": "註解",
- "sync": "自動導入",
- "path": "導入"
- },
- "actions": {
- "selectPlaylist": "選擇播放清單",
- "addNewPlaylist": "創建 %{name}",
- "export": "導出",
- "makePublic": "設為公開",
- "makePrivate": "設為私人"
- },
- "message": {
- "duplicate_song": "加入重複的歌曲",
- "song_exist": "有重複歌曲正在播放清單裡,您要加入或略過重複歌曲?"
- }
- },
- "radio": {
- "name": "電台",
- "fields": {
- "name": "名稱",
- "streamUrl": "串流網址",
- "homePageUrl": "首頁網址",
- "updatedAt": "更新於",
- "createdAt": "創建於"
- },
- "actions": {
- "playNow": "立即播放"
- }
- },
- "share": {
- "name": "分享",
- "fields": {
- "username": "使用者名稱",
- "url": "網址",
- "description": "描述",
- "contents": "內容",
- "expiresAt": "過期時間",
- "lastVisitedAt": "上次訪問時間",
- "visitCount": "訪問次數",
- "format": "格式",
- "maxBitRate": "最大位元率",
- "updatedAt": "更新於",
- "createdAt": "創建於",
- "downloadable": "可下載"
- },
- "notifications": {},
- "actions": {}
- }
+ "languageName": "繁體中文",
+ "resources": {
+ "song": {
+ "name": "歌曲 |||| 歌曲",
+ "fields": {
+ "albumArtist": "專輯藝人",
+ "duration": "長度",
+ "trackNumber": "#",
+ "playCount": "播放次數",
+ "title": "標題",
+ "artist": "藝人",
+ "album": "專輯",
+ "path": "檔案路徑",
+ "libraryName": "媒體庫",
+ "genre": "曲風",
+ "compilation": "合輯",
+ "year": "發行年份",
+ "size": "檔案大小",
+ "updatedAt": "更新於",
+ "bitRate": "位元率",
+ "bitDepth": "位元深度",
+ "sampleRate": "取樣率",
+ "channels": "聲道",
+ "discSubtitle": "光碟副標題",
+ "starred": "收藏",
+ "comment": "註解",
+ "rating": "評分",
+ "quality": "品質",
+ "bpm": "BPM",
+ "playDate": "上次播放",
+ "createdAt": "建立於",
+ "grouping": "分組",
+ "mood": "情緒",
+ "participants": "其他參與人員",
+ "tags": "額外標籤",
+ "mappedTags": "分類後標籤",
+ "rawTags": "原始標籤",
+ "missing": "遺失"
+ },
+ "actions": {
+ "addToQueue": "加入至播放佇列",
+ "playNow": "立即播放",
+ "addToPlaylist": "加入至播放清單",
+ "showInPlaylist": "在播放清單中顯示",
+ "shuffleAll": "全部隨機播放",
+ "download": "下載",
+ "playNext": "下一首播放",
+ "info": "取得資訊"
+ }
},
- "ra": {
- "auth": {
- "welcome1": "感謝您安裝 Navidrome!",
- "welcome2": "開始前,請創建一個管理員帳戶",
- "confirmPassword": "確認密碼",
- "buttonCreateAdmin": "創建管理員",
- "auth_check_error": "請登入以訪問更多內容",
- "user_menu": "配置",
- "username": "使用者名稱",
- "password": "密碼",
- "sign_in": "登入",
- "sign_in_error": "驗證失敗,請重試",
- "logout": "登出"
- },
- "validation": {
- "invalidChars": "請使用字母和數字",
- "passwordDoesNotMatch": "密碼不相符",
- "required": "必填",
- "minLength": "必須不少於 %{min} 個字元",
- "maxLength": "必須不多於 %{max} 個字元",
- "minValue": "必須不小於 %{min}",
- "maxValue": "必須不大於 %{max}",
- "number": "必須為數字",
- "email": "必須是有效的電子郵件",
- "oneOf": "必須為: %{options}其中一項",
- "regex": "必須符合指定的格式(正規表達式):%{pattern}",
- "unique": "必須是唯一的",
- "url": "網址"
- },
- "action": {
- "add_filter": "加入篩選",
- "add": "加入",
- "back": "返回",
- "bulk_actions": "選中 %{smart_count} 項",
- "cancel": "取消",
- "clear_input_value": "清除",
- "clone": "複製",
- "confirm": "確認",
- "create": "創建",
- "delete": "刪除",
- "edit": "編輯",
- "export": "匯出",
- "list": "列表",
- "refresh": "重新整理",
- "remove_filter": "清除此條件",
- "remove": "清除",
- "save": "保存",
- "search": "搜尋",
- "show": "顯示",
- "sort": "排序",
- "undo": "撤銷",
- "expand": "展開",
- "close": "關閉",
- "open_menu": "打開選單",
- "close_menu": "關閉選單",
- "unselect": "未選擇",
- "skip": "略過",
- "bulk_actions_mobile": "%{smart_count}",
- "share": "分享",
- "download": "下載"
- },
- "boolean": {
- "true": "是",
- "false": "否"
- },
- "page": {
- "create": "創建 %{name}",
- "dashboard": "儀表板",
- "edit": "%{name} #%{id}",
- "error": "發生錯誤",
- "list": "%{name}",
- "loading": "載入中",
- "not_found": "未發現",
- "show": "%{name} #%{id}",
- "empty": "還沒有 %{name}。",
- "invite": "你要創建一個嗎?"
- },
- "input": {
- "file": {
- "upload_several": "拖拽多個文件上傳或點擊選擇一個",
- "upload_single": "拖拽單個文件上傳或點擊選擇一個"
- },
- "image": {
- "upload_several": "拖拽多個圖片上傳或點擊選擇一個",
- "upload_single": "拖拽單個圖片上傳或點擊選擇一個"
- },
- "references": {
- "all_missing": "未找到參考數據",
- "many_missing": "至少有一條參考數據不再可用",
- "single_missing": "關聯的參考數據不再可用"
- },
- "password": {
- "toggle_visible": "隱藏密碼",
- "toggle_hidden": "顯示密碼"
- }
- },
- "message": {
- "about": "關於",
- "are_you_sure": "確定進行此操作?",
- "bulk_delete_content": "您確定要刪除 %{name}? |||| 您確定要刪除 %{smart_count} 項?",
- "bulk_delete_title": "刪除 %{name} |||| 刪除 %{smart_count} 項 %{name}",
- "delete_content": "您確定要刪除該項目?",
- "delete_title": "刪除 %{name} #%{id}",
- "details": "詳細資訊",
- "error": "發生一個用戶端錯誤,您的請求無法完成",
- "invalid_form": "提交內容無效,請檢查錯誤",
- "loading": "正在載入頁面,請稍候",
- "no": "否",
- "not_found": "您輸入的連結格式不對或連結遺失",
- "yes": "是",
- "unsaved_changes": "某些更改尚未保存,您確定要離開此頁面嗎?"
- },
- "navigation": {
- "no_results": "無內容",
- "no_more_results": "頁碼 %{page} 超出邊界,嘗試返回上一頁",
- "page_out_of_boundaries": "頁碼 %{page} 超出邊界",
- "page_out_from_end": "已經最後一頁",
- "page_out_from_begin": "已經是第一頁",
- "page_range_info": "%{offsetBegin}-%{offsetEnd} / %{total}",
- "page_rows_per_page": "每頁行數:",
- "next": "下一頁",
- "prev": "上一頁",
- "skip_nav": "跳過"
- },
- "notification": {
- "updated": "項已更新 |||| %{smart_count} 項已更新",
- "created": "項已創建",
- "deleted": "項已刪除 |||| %{smart_count} 項已刪除",
- "bad_item": "不確定的項",
- "item_doesnt_exist": "項不存在",
- "http_error": "伺服器通訊錯誤",
- "data_provider_error": "資料來源錯誤,請檢查控制台的詳細資訊",
- "i18n_error": "無法載入所選語言",
- "canceled": "操作已取消",
- "logged_out": "您的會話已結束,請重新登入",
- "new_version": "發現新版本!請重新整理視窗"
- },
- "toggleFieldsMenu": {
- "columnsToDisplay": "顯示欄目",
- "layout": "版面",
- "grid": "框格",
- "table": "表格"
- }
+ "album": {
+ "name": "專輯 |||| 專輯",
+ "fields": {
+ "albumArtist": "專輯藝人",
+ "artist": "藝人",
+ "duration": "長度",
+ "songCount": "歌曲數",
+ "playCount": "播放次數",
+ "size": "檔案大小",
+ "name": "名稱",
+ "libraryName": "媒體庫",
+ "genre": "曲風",
+ "compilation": "合輯",
+ "year": "發行年份",
+ "date": "錄製日期",
+ "originalDate": "原始日期",
+ "releaseDate": "發行日期",
+ "releases": "發行",
+ "released": "已發行",
+ "updatedAt": "更新於",
+ "comment": "註解",
+ "rating": "評分",
+ "createdAt": "建立於",
+ "recordLabel": "唱片公司",
+ "catalogNum": "目錄編號",
+ "releaseType": "發行類型",
+ "grouping": "分組",
+ "media": "媒體類型",
+ "mood": "情緒",
+ "missing": "遺失"
+ },
+ "actions": {
+ "playAll": "播放全部",
+ "playNext": "下一首播放",
+ "addToQueue": "加入至播放佇列",
+ "share": "分享",
+ "shuffle": "隨機播放",
+ "addToPlaylist": "加入至播放清單",
+ "download": "下載",
+ "info": "取得資訊"
+ },
+ "lists": {
+ "all": "所有",
+ "random": "隨機",
+ "recentlyAdded": "最近加入",
+ "recentlyPlayed": "最近播放",
+ "mostPlayed": "最常播放",
+ "starred": "收藏",
+ "topRated": "最高評分"
+ }
},
- "message": {
- "note": "註解",
- "transcodingDisabled": "出於安全原因,禁用了從 Web 介面更改參數。要更改(編輯或新增)轉檔選項,請在啟用 %{config} 選項的情況下重新啟動伺服器。",
- "transcodingEnabled": "Navidrome 當前與 %{config} 一起使用,可以通過配置轉檔參數執行任意命令,建議僅在配置轉檔選項時啟用此功能。",
- "songsAddedToPlaylist": "已加入一首歌到播放清單 |||| 已添加 %{smart_count} 首歌到播放清單",
- "noPlaylistsAvailable": "沒有可用的播放清單",
- "delete_user_title": "刪除使用者 %{name}",
- "delete_user_content": "您確定要刪除該使用者及其相關數據(包括播放清單和使用者配置)嗎?",
- "notifications_blocked": "您已在瀏覽器的設置中封鎖了此網站的通知",
- "notifications_not_available": "此瀏覽器不支援桌面通知",
- "lastfmLinkSuccess": "Last.fm 成功連接並開啟音樂記錄",
- "lastfmLinkFailure": "Last.fm 無法連接",
- "lastfmUnlinkSuccess": "Last.fm 已無連接並停用音樂記錄",
- "lastfmUnlinkFailure": "Last.fm 無法取消連接",
- "openIn": {
- "lastfm": "在 Last.fm 打開",
- "musicbrainz": "在 MusicBrainz 打開"
- },
- "lastfmLink": "繼續閱讀…",
- "listenBrainzLinkSuccess": "ListenBrainz 成功連接並開啟音樂記錄",
- "listenBrainzLinkFailure": "ListenBrainz 無法連接:%{error}",
- "listenBrainzUnlinkSuccess": "ListenBrainz 已無連接並停用音樂記錄",
- "listenBrainzUnlinkFailure": "ListenBrainz 無法取消連接",
- "downloadOriginalFormat": "下載原始格式",
- "shareOriginalFormat": "分享原始格式",
- "shareDialogTitle": "分享",
- "shareBatchDialogTitle": "批次分享",
- "shareSuccess": "分享成功",
- "shareFailure": "分享失敗",
- "downloadDialogTitle": "下載",
- "shareCopyToClipboard": "複製到剪貼簿"
+ "artist": {
+ "name": "藝人 |||| 藝人",
+ "fields": {
+ "name": "名稱",
+ "albumCount": "專輯數",
+ "songCount": "歌曲數",
+ "size": "檔案大小",
+ "playCount": "播放次數",
+ "rating": "評分",
+ "genre": "曲風",
+ "role": "參與角色",
+ "missing": "遺失"
+ },
+ "roles": {
+ "albumartist": "專輯藝人 |||| 專輯藝人",
+ "artist": "藝人 |||| 藝人",
+ "composer": "作曲 |||| 作曲",
+ "conductor": "指揮 |||| 指揮",
+ "lyricist": "作詞 |||| 作詞",
+ "arranger": "編曲 |||| 編曲",
+ "producer": "製作人 |||| 製作人",
+ "director": "導演 |||| 導演",
+ "engineer": "工程師 |||| 工程師",
+ "mixer": "混音師 |||| 混音師",
+ "remixer": "重混師 |||| 重混師",
+ "djmixer": "DJ 混音師 |||| DJ 混音師",
+ "performer": "表演者 |||| 表演者",
+ "maincredit": "專輯藝人或藝人 |||| 專輯藝人或藝人"
+ },
+ "actions": {
+ "topSongs": "熱門歌曲",
+ "shuffle": "隨機播放",
+ "radio": "電台"
+ }
},
- "menu": {
- "library": "音樂庫",
- "settings": "設定",
- "version": "版本",
- "theme": "主題",
- "personal": {
- "name": "個人化",
- "options": {
- "theme": "主題",
- "language": "語言",
- "defaultView": "預設畫面",
- "desktop_notifications": "桌面通知",
- "lastfmScrobbling": "啟用 Last.fm 音樂記錄",
- "listenBrainzScrobbling": "啟用 ListenBrainz 音樂記錄",
- "replaygain": "重播增益",
- "preAmp": "前置放大器 (dB)",
- "gain": {
- "none": "無",
- "album": "專輯增益",
- "track": "曲目增益"
- }
- }
- },
- "albumList": "專輯",
- "about": "關於",
- "playlists": "播放清單",
- "sharedPlaylists": "分享的播放清單"
+ "user": {
+ "name": "使用者 |||| 使用者",
+ "fields": {
+ "userName": "使用者名稱",
+ "isAdmin": "管理員",
+ "lastLoginAt": "上次登入",
+ "lastAccessAt": "上次存取",
+ "updatedAt": "更新於",
+ "name": "名稱",
+ "password": "密碼",
+ "createdAt": "建立於",
+ "changePassword": "變更密碼?",
+ "currentPassword": "目前密碼",
+ "newPassword": "新密碼",
+ "token": "權杖",
+ "libraries": "媒體庫"
+ },
+ "helperTexts": {
+ "name": "您的名稱會在下次登入時生效",
+ "libraries": "為該使用者選擇指定媒體庫,留空則使用預設媒體庫"
+ },
+ "notifications": {
+ "created": "使用者已建立",
+ "updated": "使用者已更新",
+ "deleted": "使用者已刪除"
+ },
+ "validation": {
+ "librariesRequired": "非管理員使用者必須至少選擇一個媒體庫"
+ },
+ "message": {
+ "listenBrainzToken": "輸入您的 ListenBrainz 使用者權杖",
+ "clickHereForToken": "點擊此處來獲得您的 ListenBrainz 權杖",
+ "selectAllLibraries": "選取全部媒體庫",
+ "adminAutoLibraries": "管理員預設可存取所有媒體庫"
+ }
},
"player": {
- "playListsText": "播放佇列",
- "openText": "打開",
- "closeText": "關閉",
- "notContentText": "沒有音樂",
- "clickToPlayText": "點擊播放",
- "clickToPauseText": "點擊暫停",
- "nextTrackText": "下一首",
- "previousTrackText": "上一首",
- "reloadText": "重新播放",
- "volumeText": "音量",
- "toggleLyricText": "切換歌詞",
- "toggleMiniModeText": "最小化",
- "destroyText": "關閉",
- "downloadText": "下載",
- "removeAudioListsText": "清空播放佇列",
- "clickToDeleteText": "點擊刪除 %{name}",
- "emptyLyricText": "無歌詞",
- "playModeText": {
- "order": "順序播放",
- "orderLoop": "列表循環",
- "singleLoop": "單曲循環",
- "shufflePlay": "隨機播放"
- }
+ "name": "播放器 |||| 播放器",
+ "fields": {
+ "name": "名稱",
+ "transcodingId": "轉碼",
+ "maxBitRate": "最大位元率",
+ "client": "客戶端",
+ "userName": "使用者名稱",
+ "lastSeen": "上次上線",
+ "reportRealPath": "回報實際路徑",
+ "scrobbleEnabled": "傳送音樂記錄至外部服務"
+ }
},
- "about": {
- "links": {
- "homepage": "主頁",
- "source": "原始碼",
- "featureRequests": "功能請求"
- }
+ "transcoding": {
+ "name": "轉碼 |||| 轉碼",
+ "fields": {
+ "name": "名稱",
+ "targetFormat": "目標格式",
+ "defaultBitRate": "預設位元率",
+ "command": "指令"
+ }
},
- "activity": {
- "title": "運作狀況",
- "totalScanned": "已完成掃描的目錄",
- "quickScan": "快速掃描",
- "fullScan": "完全掃描",
- "serverUptime": "伺服器已運作時間",
- "serverDown": "伺服器離線"
+ "playlist": {
+ "name": "播放清單 |||| 播放清單",
+ "fields": {
+ "name": "名稱",
+ "duration": "長度",
+ "ownerName": "擁有者",
+ "public": "公開",
+ "updatedAt": "更新於",
+ "createdAt": "建立於",
+ "songCount": "歌曲數",
+ "comment": "註解",
+ "sync": "自動匯入",
+ "path": "匯入來源"
+ },
+ "actions": {
+ "selectPlaylist": "選取播放清單:",
+ "addNewPlaylist": "建立「%{name}」",
+ "export": "匯出",
+ "saveQueue": "將播放佇列儲存到播放清單",
+ "makePublic": "設為公開",
+ "makePrivate": "設為私人",
+ "searchOrCreate": "搜尋播放清單,或輸入名稱來新建…",
+ "pressEnterToCreate": "按 Enter 鍵建立新的播放清單",
+ "removeFromSelection": "移除選取項目"
+ },
+ "message": {
+ "duplicate_song": "加入重複的歌曲",
+ "song_exist": "有重複歌曲正要加入播放清單,您要加入或略過重複歌曲?",
+ "noPlaylistsFound": "找不到播放清單",
+ "noPlaylists": "暫無播放清單"
+ }
},
- "help": {
- "title": "Navidrome 快捷鍵",
- "hotkeys": {
- "show_help": "顯示此幫助",
- "toggle_menu": "顯示/隱藏選單側欄",
- "toggle_play": "播放/暫停",
- "prev_song": "上一首歌",
- "next_song": "下一首歌",
- "vol_up": "提高音量",
- "vol_down": "降低音量",
- "toggle_love": "添加或移除星標",
- "current_song": "目前歌曲"
- }
+ "radio": {
+ "name": "電台 |||| 電台",
+ "fields": {
+ "name": "名稱",
+ "streamUrl": "串流網址",
+ "homePageUrl": "首頁網址",
+ "updatedAt": "更新於",
+ "createdAt": "建立於"
+ },
+ "actions": {
+ "playNow": "立即播放"
+ }
+ },
+ "share": {
+ "name": "分享 |||| 分享",
+ "fields": {
+ "username": "分享者",
+ "url": "網址",
+ "description": "描述",
+ "downloadable": "允許下載?",
+ "contents": "內容",
+ "expiresAt": "過期時間",
+ "lastVisitedAt": "上次造訪時間",
+ "visitCount": "造訪次數",
+ "format": "格式",
+ "maxBitRate": "最大位元率",
+ "updatedAt": "更新於",
+ "createdAt": "建立於"
+ },
+ "notifications": {},
+ "actions": {}
+ },
+ "missing": {
+ "name": "遺失檔案 |||| 遺失檔案",
+ "empty": "無遺失檔案",
+ "fields": {
+ "path": "路徑",
+ "size": "檔案大小",
+ "libraryName": "媒體庫",
+ "updatedAt": "遺失於"
+ },
+ "actions": {
+ "remove": "刪除",
+ "remove_all": "刪除所有"
+ },
+ "notifications": {
+ "removed": "遺失檔案已刪除"
+ }
+ },
+ "library": {
+ "name": "媒體庫 |||| 媒體庫",
+ "fields": {
+ "name": "名稱",
+ "path": "路徑",
+ "remotePath": "遠端路徑",
+ "lastScanAt": "上次掃描",
+ "songCount": "歌曲",
+ "albumCount": "專輯",
+ "artistCount": "藝人",
+ "totalSongs": "歌曲",
+ "totalAlbums": "專輯",
+ "totalArtists": "藝人",
+ "totalFolders": "資料夾",
+ "totalFiles": "檔案",
+ "totalMissingFiles": "遺失檔案",
+ "totalSize": "總大小",
+ "totalDuration": "時長",
+ "defaultNewUsers": "新使用者預設媒體庫",
+ "createdAt": "建立於",
+ "updatedAt": "更新於"
+ },
+ "sections": {
+ "basic": "基本資訊",
+ "statistics": "統計"
+ },
+ "actions": {
+ "scan": "掃描媒體庫",
+ "manageUsers": "管理使用者權限",
+ "viewDetails": "查看詳細資料"
+ },
+ "notifications": {
+ "created": "成功建立媒體庫",
+ "updated": "成功更新媒體庫",
+ "deleted": "成功刪除媒體庫",
+ "scanStarted": "開始掃描媒體庫",
+ "scanCompleted": "媒體庫掃描完成"
+ },
+ "validation": {
+ "nameRequired": "請輸入媒體庫名稱",
+ "pathRequired": "請提供媒體庫路徑",
+ "pathNotDirectory": "媒體庫路徑必須為目錄",
+ "pathNotFound": "媒體庫路徑不存在",
+ "pathNotAccessible": "無法存取媒體庫路徑",
+ "pathInvalid": "媒體庫路徑無效"
+ },
+ "messages": {
+ "deleteConfirm": "您確定要刪除此媒體庫嗎?這將刪除所有相關資料和使用者存取權限。",
+ "scanInProgress": "正在掃描...",
+ "noLibrariesAssigned": "沒有為該使用者指派任何媒體庫"
+ }
}
+ },
+ "ra": {
+ "auth": {
+ "welcome1": "感謝您安裝 Navidrome!",
+ "welcome2": "開始前,請先建立一個管理員帳號",
+ "confirmPassword": "確認密碼",
+ "buttonCreateAdmin": "建立管理員",
+ "auth_check_error": "請登入以繼續",
+ "user_menu": "個人檔案",
+ "username": "使用者名稱",
+ "password": "密碼",
+ "sign_in": "登入",
+ "sign_in_error": "驗證失敗,請重試",
+ "logout": "登出",
+ "insightsCollectionNote": "Navidrome 會收集匿名使用資料以協助改善項目。\n點擊[此處]了解更多資訊或選擇退出。"
+ },
+ "validation": {
+ "invalidChars": "請使用字母和數字",
+ "passwordDoesNotMatch": "密碼不相符",
+ "required": "必填",
+ "minLength": "必須不少於 %{min} 個字元",
+ "maxLength": "必須不多於 %{max} 個字元",
+ "minValue": "必須不小於 %{min}",
+ "maxValue": "必須不大於 %{max}",
+ "number": "必須為數字",
+ "email": "必須為有效的電子郵件",
+ "oneOf": "必須為以下其中一項:%{options}",
+ "regex": "必須符合指定的格式(正規表達式):%{pattern}",
+ "unique": "必須是唯一的",
+ "url": "必須為有效的網址"
+ },
+ "action": {
+ "add_filter": "加入篩選",
+ "add": "加入",
+ "back": "返回",
+ "bulk_actions": "選中 1 項 |||| 選中 %{smart_count} 項",
+ "bulk_actions_mobile": "1 |||| %{smart_count}",
+ "cancel": "取消",
+ "clear_input_value": "清除",
+ "clone": "複製",
+ "confirm": "確認",
+ "create": "建立",
+ "delete": "刪除",
+ "edit": "編輯",
+ "export": "匯出",
+ "list": "列表",
+ "refresh": "重新整理",
+ "remove_filter": "清除此條件",
+ "remove": "移除",
+ "save": "儲存",
+ "search": "搜尋",
+ "show": "顯示",
+ "sort": "排序",
+ "undo": "復原",
+ "expand": "展開",
+ "close": "關閉",
+ "open_menu": "開啟選單",
+ "close_menu": "關閉選單",
+ "unselect": "取消選取",
+ "skip": "略過",
+ "share": "分享",
+ "download": "下載"
+ },
+ "boolean": {
+ "true": "是",
+ "false": "否"
+ },
+ "page": {
+ "create": "建立 %{name}",
+ "dashboard": "儀表板",
+ "edit": "%{name} #%{id}",
+ "error": "發生錯誤",
+ "list": "%{name}",
+ "loading": "載入中",
+ "not_found": "找不到",
+ "show": "%{name} #%{id}",
+ "empty": "還沒有 %{name}。",
+ "invite": "您要建立一個嗎?"
+ },
+ "input": {
+ "file": {
+ "upload_several": "拖曳多個檔案上傳或點擊選擇一個",
+ "upload_single": "拖曳單個檔案上傳或點擊選擇一個"
+ },
+ "image": {
+ "upload_several": "拖曳多個圖片上傳或點擊選擇一個",
+ "upload_single": "拖曳單個圖片上傳或點擊選擇一個"
+ },
+ "references": {
+ "all_missing": "未找到參考數據",
+ "many_missing": "至少有一條參考數據不再可用",
+ "single_missing": "關聯的參考數據不再可用"
+ },
+ "password": {
+ "toggle_visible": "隱藏密碼",
+ "toggle_hidden": "顯示密碼"
+ }
+ },
+ "message": {
+ "about": "關於",
+ "are_you_sure": "您確定嗎?",
+ "bulk_delete_content": "您確定要刪除 %{name}? |||| 您確定要刪除這 %{smart_count} 個項目嗎?",
+ "bulk_delete_title": "刪除 %{name} |||| 刪除 %{smart_count} 項 %{name}",
+ "delete_content": "您確定要刪除該項目?",
+ "delete_title": "刪除 %{name} #%{id}",
+ "details": "詳細資訊",
+ "error": "發生客戶端錯誤,您的請求無法完成",
+ "invalid_form": "提交內容無效,請檢查錯誤",
+ "loading": "正在載入頁面,請稍候",
+ "no": "否",
+ "not_found": "您輸入了錯誤的連結或連結遺失",
+ "yes": "是",
+ "unsaved_changes": "某些更改尚未儲存,您確定要離開此頁面嗎?"
+ },
+ "navigation": {
+ "no_results": "沒有找到結果",
+ "no_more_results": "頁碼 %{page} 超出邊界,嘗試返回上一頁",
+ "page_out_of_boundaries": "頁碼 %{page} 超出邊界",
+ "page_out_from_end": "已經是最後一頁",
+ "page_out_from_begin": "已經是第一頁",
+ "page_range_info": "%{offsetBegin}-%{offsetEnd} / %{total}",
+ "page_rows_per_page": "每頁項目數:",
+ "next": "下一頁",
+ "prev": "上一頁",
+ "skip_nav": "跳至內容"
+ },
+ "notification": {
+ "updated": "項目已更新 |||| %{smart_count} 項已更新",
+ "created": "項目已建立",
+ "deleted": "項目已刪除 |||| %{smart_count} 項已刪除",
+ "bad_item": "項目不正確",
+ "item_doesnt_exist": "項目不存在",
+ "http_error": "伺服器通訊錯誤",
+ "data_provider_error": "資料來源錯誤,請檢查控制台的詳細資訊",
+ "i18n_error": "無法載入所選語言",
+ "canceled": "操作已取消",
+ "logged_out": "您的工作階段已結束,請重新登入",
+ "new_version": "發現新版本!請重新整理視窗"
+ },
+ "toggleFieldsMenu": {
+ "columnsToDisplay": "顯示欄位",
+ "layout": "版面",
+ "grid": "網格",
+ "table": "表格"
+ }
+ },
+ "message": {
+ "note": "注意",
+ "transcodingDisabled": "出於安全原因,已停用了從 Web 介面更改參數。要更改(編輯或新增)轉碼選項,請在啟用 %{config} 選項的情況下重新啟動伺服器。",
+ "transcodingEnabled": "Navidrome 目前與 %{config} 一起使用,因此可以透過 Web 介面從轉碼設定中執行系統命令。出於安全考慮,我們建議停用此功能,並僅在設定轉碼選項時啟用。",
+ "songsAddedToPlaylist": "已加入一首歌到播放清單 |||| 已新增 %{smart_count} 首歌到播放清單",
+ "noSimilarSongsFound": "找不到相似歌曲",
+ "noTopSongsFound": "找不到熱門歌曲",
+ "noPlaylistsAvailable": "沒有可用的播放清單",
+ "delete_user_title": "刪除使用者「%{name}」",
+ "delete_user_content": "您確定要刪除此使用者及其所有資料(包括播放清單和偏好設定)嗎?",
+ "remove_missing_title": "刪除遺失檔案",
+ "remove_missing_content": "您確定要從媒體庫中刪除所選的遺失的檔案嗎?這將永久刪除它們的所有相關資訊,包括其播放次數和評分。",
+ "remove_all_missing_title": "刪除所有遺失檔案",
+ "remove_all_missing_content": "您確定要從媒體庫中刪除所有遺失的檔案嗎?這將永久刪除它們的所有相關資訊,包括它們的播放次數和評分。",
+ "notifications_blocked": "您已在瀏覽器設定中封鎖了此網站的通知",
+ "notifications_not_available": "此瀏覽器不支援桌面通知,或您並非透過 HTTPS 存取 Navidrome",
+ "lastfmLinkSuccess": "已成功連接 Last.fm 並開啟音樂記錄",
+ "lastfmLinkFailure": "無法連接 Last.fm",
+ "lastfmUnlinkSuccess": "已取消 Last.fm 的連接並停用音樂記錄",
+ "lastfmUnlinkFailure": "無法取消 Last.fm 的連接",
+ "listenBrainzLinkSuccess": "已成功以 %{user} 身份連接 ListenBrainz 並開啟音樂記錄",
+ "listenBrainzLinkFailure": "無法連接 ListenBrainz:%{error}",
+ "listenBrainzUnlinkSuccess": "已取消 ListenBrainz 的連接並停用音樂記錄",
+ "listenBrainzUnlinkFailure": "無法取消 ListenBrainz 的連接",
+ "openIn": {
+ "lastfm": "在 Last.fm 中開啟",
+ "musicbrainz": "在 MusicBrainz 中開啟"
+ },
+ "lastfmLink": "查看更多…",
+ "shareOriginalFormat": "分享原始格式",
+ "shareDialogTitle": "分享 %{resource} '%{name}'",
+ "shareBatchDialogTitle": "分享 1 個%{resource} |||| 分享 %{smart_count} 個%{resource}",
+ "shareCopyToClipboard": "複製到剪貼簿:Ctrl+C, Enter",
+ "shareSuccess": "分享成功,連結已複製到剪貼簿:%{url}",
+ "shareFailure": "分享連結複製失敗:%{url}",
+ "downloadDialogTitle": "下載 %{resource} '%{name}' (%{size})",
+ "downloadOriginalFormat": "下載原始格式"
+ },
+ "menu": {
+ "library": "媒體庫",
+ "librarySelector": {
+ "allLibraries": "所有媒體庫 (%{count})",
+ "multipleLibraries": "已選 %{selected} 共 %{total} 媒體庫",
+ "selectLibraries": "選取媒體庫",
+ "none": "無"
+ },
+ "settings": "設定",
+ "version": "版本",
+ "theme": "主題",
+ "personal": {
+ "name": "個人化",
+ "options": {
+ "theme": "主題",
+ "language": "語言",
+ "defaultView": "預設畫面",
+ "desktop_notifications": "桌面通知",
+ "lastfmNotConfigured": "Last.fm API 金鑰未設定",
+ "lastfmScrobbling": "啟用 Last.fm 音樂記錄",
+ "listenBrainzScrobbling": "啟用 ListenBrainz 音樂記錄",
+ "replaygain": "重播增益模式",
+ "preAmp": "重播增益前置放大器 (dB)",
+ "gain": {
+ "none": "無",
+ "album": "專輯增益",
+ "track": "曲目增益"
+ }
+ }
+ },
+ "albumList": "專輯",
+ "playlists": "播放清單",
+ "sharedPlaylists": "分享的播放清單",
+ "about": "關於"
+ },
+ "player": {
+ "playListsText": "播放佇列",
+ "openText": "開啟",
+ "closeText": "關閉",
+ "notContentText": "沒有音樂",
+ "clickToPlayText": "點擊播放",
+ "clickToPauseText": "點擊暫停",
+ "nextTrackText": "下一首",
+ "previousTrackText": "上一首",
+ "reloadText": "重新載入",
+ "volumeText": "音量",
+ "toggleLyricText": "切換歌詞",
+ "toggleMiniModeText": "最小化",
+ "destroyText": "關閉",
+ "downloadText": "下載",
+ "removeAudioListsText": "清空播放佇列",
+ "clickToDeleteText": "點擊刪除 %{name}",
+ "emptyLyricText": "無歌詞",
+ "playModeText": {
+ "order": "順序播放",
+ "orderLoop": "循環播放",
+ "singleLoop": "單曲循環",
+ "shufflePlay": "隨機播放"
+ }
+ },
+ "about": {
+ "links": {
+ "homepage": "首頁",
+ "source": "原始碼",
+ "featureRequests": "功能請求",
+ "lastInsightsCollection": "最近一次洞察資料收集",
+ "insights": {
+ "disabled": "已停用",
+ "waiting": "等待中"
+ }
+ },
+ "tabs": {
+ "about": "關於",
+ "config": "設定"
+ },
+ "config": {
+ "configName": "設定名稱",
+ "environmentVariable": "環境變數",
+ "currentValue": "目前值",
+ "configurationFile": "設定檔案",
+ "exportToml": "匯出設定(TOML 格式)",
+ "exportSuccess": "設定已以 TOML 格式匯出至剪貼簿",
+ "exportFailed": "設定複製失敗",
+ "devFlagsHeader": "開發旗標(可能會更改/刪除)",
+ "devFlagsComment": "這些是實驗性設定,可能會在未來版本中刪除"
+ }
+ },
+ "activity": {
+ "title": "運作狀況",
+ "totalScanned": "已掃描的資料夾總數",
+ "quickScan": "快速掃描",
+ "fullScan": "完全掃描",
+ "serverUptime": "伺服器運作時間",
+ "serverDown": "伺服器已離線",
+ "scanType": "掃描類型",
+ "status": "掃描錯誤",
+ "elapsedTime": "經過時間"
+ },
+ "nowPlaying": {
+ "title": "正在播放",
+ "empty": "無播放內容",
+ "minutesAgo": "1 分鐘前 |||| %{smart_count} 分鐘前"
+ },
+ "help": {
+ "title": "Navidrome 快捷鍵",
+ "hotkeys": {
+ "show_help": "顯示此說明",
+ "toggle_menu": "顯示/隱藏選單側欄",
+ "toggle_play": "播放/暫停",
+ "prev_song": "上一首歌",
+ "next_song": "下一首歌",
+ "current_song": "前往目前歌曲",
+ "vol_up": "提高音量",
+ "vol_down": "降低音量",
+ "toggle_love": "新增此歌曲至收藏"
+ }
+ }
}
diff --git a/scanner/controller.go b/scanner/controller.go
index c1347077a..b42246a50 100644
--- a/scanner/controller.go
+++ b/scanner/controller.go
@@ -26,24 +26,8 @@ var (
ErrAlreadyScanning = errors.New("already scanning")
)
-type Scanner interface {
- // ScanAll starts a full scan of the music library. This is a blocking operation.
- ScanAll(ctx context.Context, fullScan bool) (warnings []string, err error)
- Status(context.Context) (*StatusInfo, error)
-}
-
-type StatusInfo struct {
- Scanning bool
- LastScan time.Time
- Count uint32
- FolderCount uint32
- LastError string
- ScanType string
- ElapsedTime time.Duration
-}
-
func New(rootCtx context.Context, ds model.DataStore, cw artwork.CacheWarmer, broker events.Broker,
- pls core.Playlists, m metrics.Metrics) Scanner {
+ pls core.Playlists, m metrics.Metrics) model.Scanner {
c := &controller{
rootCtx: rootCtx,
ds: ds,
@@ -65,9 +49,10 @@ func (s *controller) getScanner() scanner {
return &scannerImpl{ds: s.ds, cw: s.cw, pls: s.pls}
}
-// CallScan starts an in-process scan of the music library.
+// CallScan starts an in-process scan of specific library/folder pairs.
+// If targets is empty, it scans all libraries.
// This is meant to be called from the command line (see cmd/scan.go).
-func CallScan(ctx context.Context, ds model.DataStore, pls core.Playlists, fullScan bool) (<-chan *ProgressInfo, error) {
+func CallScan(ctx context.Context, ds model.DataStore, pls core.Playlists, fullScan bool, targets []model.ScanTarget) (<-chan *ProgressInfo, error) {
release, err := lockScan(ctx)
if err != nil {
return nil, err
@@ -79,7 +64,7 @@ func CallScan(ctx context.Context, ds model.DataStore, pls core.Playlists, fullS
go func() {
defer close(progress)
scanner := &scannerImpl{ds: ds, cw: artwork.NoopCacheWarmer(), pls: pls}
- scanner.scanAll(ctx, fullScan, progress)
+ scanner.scanFolders(ctx, fullScan, targets, progress)
}()
return progress, nil
}
@@ -99,8 +84,11 @@ type ProgressInfo struct {
ForceUpdate bool
}
+// scanner defines the interface for different scanner implementations.
+// This allows for swapping between in-process and external scanners.
type scanner interface {
- scanAll(ctx context.Context, fullScan bool, progress chan<- *ProgressInfo)
+ // scanFolders performs the actual scanning of folders. If targets is nil, it scans all libraries.
+ scanFolders(ctx context.Context, fullScan bool, targets []model.ScanTarget, progress chan<- *ProgressInfo)
}
type controller struct {
@@ -158,7 +146,7 @@ func (s *controller) getScanInfo(ctx context.Context) (scanType string, elapsed
return scanType, elapsed, lastErr
}
-func (s *controller) Status(ctx context.Context) (*StatusInfo, error) {
+func (s *controller) Status(ctx context.Context) (*model.ScannerStatus, error) {
lastScanTime, err := s.getLastScanTime(ctx)
if err != nil {
return nil, fmt.Errorf("getting last scan time: %w", err)
@@ -167,7 +155,7 @@ func (s *controller) Status(ctx context.Context) (*StatusInfo, error) {
scanType, elapsed, lastErr := s.getScanInfo(ctx)
if running.Load() {
- status := &StatusInfo{
+ status := &model.ScannerStatus{
Scanning: true,
LastScan: lastScanTime,
Count: s.count.Load(),
@@ -183,7 +171,7 @@ func (s *controller) Status(ctx context.Context) (*StatusInfo, error) {
if err != nil {
return nil, fmt.Errorf("getting library stats: %w", err)
}
- return &StatusInfo{
+ return &model.ScannerStatus{
Scanning: false,
LastScan: lastScanTime,
Count: uint32(count),
@@ -208,6 +196,10 @@ func (s *controller) getCounters(ctx context.Context) (int64, int64, error) {
}
func (s *controller) ScanAll(requestCtx context.Context, fullScan bool) ([]string, error) {
+ return s.ScanFolders(requestCtx, fullScan, nil)
+}
+
+func (s *controller) ScanFolders(requestCtx context.Context, fullScan bool, targets []model.ScanTarget) ([]string, error) {
release, err := lockScan(requestCtx)
if err != nil {
return nil, err
@@ -224,7 +216,7 @@ func (s *controller) ScanAll(requestCtx context.Context, fullScan bool) ([]strin
go func() {
defer close(progress)
scanner := s.getScanner()
- scanner.scanAll(ctx, fullScan, progress)
+ scanner.scanFolders(ctx, fullScan, targets, progress)
}()
// Wait for the scan to finish, sending progress events to all connected clients
diff --git a/scanner/controller_test.go b/scanner/controller_test.go
index e551e15b1..f5ccabc86 100644
--- a/scanner/controller_test.go
+++ b/scanner/controller_test.go
@@ -9,6 +9,7 @@ import (
"github.com/navidrome/navidrome/core/artwork"
"github.com/navidrome/navidrome/core/metrics"
"github.com/navidrome/navidrome/db"
+ "github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/persistence"
"github.com/navidrome/navidrome/scanner"
"github.com/navidrome/navidrome/server/events"
@@ -20,7 +21,7 @@ import (
var _ = Describe("Controller", func() {
var ctx context.Context
var ds *tests.MockDataStore
- var ctrl scanner.Scanner
+ var ctrl model.Scanner
Describe("Status", func() {
BeforeEach(func() {
diff --git a/scanner/external.go b/scanner/external.go
index c4a29efa3..f5a117e48 100644
--- a/scanner/external.go
+++ b/scanner/external.go
@@ -11,7 +11,7 @@ import (
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/log"
- . "github.com/navidrome/navidrome/utils/gg"
+ "github.com/navidrome/navidrome/model"
)
// scannerExternal is a scanner that runs an external process to do the scanning. It is used to avoid
@@ -23,19 +23,42 @@ import (
// process will forward them to the caller.
type scannerExternal struct{}
-func (s *scannerExternal) scanAll(ctx context.Context, fullScan bool, progress chan<- *ProgressInfo) {
+func (s *scannerExternal) scanFolders(ctx context.Context, fullScan bool, targets []model.ScanTarget, progress chan<- *ProgressInfo) {
+ s.scan(ctx, fullScan, targets, progress)
+}
+
+func (s *scannerExternal) scan(ctx context.Context, fullScan bool, targets []model.ScanTarget, progress chan<- *ProgressInfo) {
exe, err := os.Executable()
if err != nil {
progress <- &ProgressInfo{Error: fmt.Sprintf("failed to get executable path: %s", err)}
return
}
- log.Debug(ctx, "Spawning external scanner process", "fullScan", fullScan, "path", exe)
- cmd := exec.CommandContext(ctx, exe, "scan",
+
+ // Build command arguments
+ args := []string{
+ "scan",
"--nobanner", "--subprocess",
"--configfile", conf.Server.ConfigFile,
"--datafolder", conf.Server.DataFolder,
"--cachefolder", conf.Server.CacheFolder,
- If(fullScan, "--full", ""))
+ }
+
+ // Add targets if provided
+ if len(targets) > 0 {
+ for _, target := range targets {
+ args = append(args, "-t", target.String())
+ }
+ log.Debug(ctx, "Spawning external scanner process with targets", "fullScan", fullScan, "path", exe, "targets", targets)
+ } else {
+ log.Debug(ctx, "Spawning external scanner process", "fullScan", fullScan, "path", exe)
+ }
+
+ // Add full scan flag if needed
+ if fullScan {
+ args = append(args, "--full")
+ }
+
+ cmd := exec.CommandContext(ctx, exe, args...)
in, out := io.Pipe()
defer in.Close()
diff --git a/scanner/folder_entry.go b/scanner/folder_entry.go
index fc68cb561..9d8d0c571 100644
--- a/scanner/folder_entry.go
+++ b/scanner/folder_entry.go
@@ -15,9 +15,7 @@ import (
"github.com/navidrome/navidrome/utils/chrono"
)
-func newFolderEntry(job *scanJob, path string) *folderEntry {
- id := model.FolderID(job.lib, path)
- info := job.popLastUpdate(id)
+func newFolderEntry(job *scanJob, id, path string, updTime time.Time, hash string) *folderEntry {
f := &folderEntry{
id: id,
job: job,
@@ -25,8 +23,8 @@ func newFolderEntry(job *scanJob, path string) *folderEntry {
audioFiles: make(map[string]fs.DirEntry),
imageFiles: make(map[string]fs.DirEntry),
albumIDMap: make(map[string]string),
- updTime: info.UpdatedAt,
- prevHash: info.Hash,
+ updTime: updTime,
+ prevHash: hash,
}
return f
}
diff --git a/scanner/folder_entry_test.go b/scanner/folder_entry_test.go
index c6d1b2ce4..0328c6653 100644
--- a/scanner/folder_entry_test.go
+++ b/scanner/folder_entry_test.go
@@ -40,9 +40,8 @@ var _ = Describe("folder_entry", func() {
UpdatedAt: time.Now().Add(-30 * time.Minute),
Hash: "previous-hash",
}
- job.lastUpdates[folderID] = updateInfo
- entry := newFolderEntry(job, path)
+ entry := newFolderEntry(job, folderID, path, updateInfo.UpdatedAt, updateInfo.Hash)
Expect(entry.id).To(Equal(folderID))
Expect(entry.job).To(Equal(job))
@@ -53,15 +52,10 @@ var _ = Describe("folder_entry", func() {
Expect(entry.updTime).To(Equal(updateInfo.UpdatedAt))
Expect(entry.prevHash).To(Equal(updateInfo.Hash))
})
+ })
- It("creates a new folder entry with zero time when no previous update exists", func() {
- entry := newFolderEntry(job, path)
-
- Expect(entry.updTime).To(BeZero())
- Expect(entry.prevHash).To(BeEmpty())
- })
-
- It("removes the lastUpdate from the job after popping", func() {
+ Describe("createFolderEntry", func() {
+ It("removes the lastUpdate from the job after creation", func() {
folderID := model.FolderID(lib, path)
updateInfo := model.FolderUpdateInfo{
UpdatedAt: time.Now().Add(-30 * time.Minute),
@@ -69,8 +63,10 @@ var _ = Describe("folder_entry", func() {
}
job.lastUpdates[folderID] = updateInfo
- newFolderEntry(job, path)
+ entry := job.createFolderEntry(path)
+ Expect(entry.updTime).To(Equal(updateInfo.UpdatedAt))
+ Expect(entry.prevHash).To(Equal(updateInfo.Hash))
Expect(job.lastUpdates).ToNot(HaveKey(folderID))
})
})
@@ -79,7 +75,8 @@ var _ = Describe("folder_entry", func() {
var entry *folderEntry
BeforeEach(func() {
- entry = newFolderEntry(job, path)
+ folderID := model.FolderID(lib, path)
+ entry = newFolderEntry(job, folderID, path, time.Time{}, "")
})
Describe("hasNoFiles", func() {
@@ -458,7 +455,9 @@ var _ = Describe("folder_entry", func() {
Describe("integration scenarios", func() {
It("handles complete folder lifecycle", func() {
// Create new folder entry
- entry := newFolderEntry(job, "music/rock/album")
+ folderPath := "music/rock/album"
+ folderID := model.FolderID(lib, folderPath)
+ entry := newFolderEntry(job, folderID, folderPath, time.Time{}, "")
// Initially new and has no files
Expect(entry.isNew()).To(BeTrue())
diff --git a/scanner/ignore_checker.go b/scanner/ignore_checker.go
new file mode 100644
index 000000000..da74293fa
--- /dev/null
+++ b/scanner/ignore_checker.go
@@ -0,0 +1,163 @@
+package scanner
+
+import (
+ "bufio"
+ "context"
+ "io/fs"
+ "path"
+ "strings"
+
+ "github.com/navidrome/navidrome/consts"
+ "github.com/navidrome/navidrome/log"
+ ignore "github.com/sabhiram/go-gitignore"
+)
+
+// IgnoreChecker manages .ndignore patterns using a stack-based approach.
+// Use Push() to add patterns when entering a folder, Pop() when leaving,
+// and ShouldIgnore() to check if a path should be ignored.
+type IgnoreChecker struct {
+ fsys fs.FS
+ patternStack [][]string // Stack of patterns for each folder level
+ currentPatterns []string // Flattened current patterns
+ matcher *ignore.GitIgnore // Compiled matcher for current patterns
+}
+
+// newIgnoreChecker creates a new IgnoreChecker for the given filesystem.
+func newIgnoreChecker(fsys fs.FS) *IgnoreChecker {
+ return &IgnoreChecker{
+ fsys: fsys,
+ patternStack: make([][]string, 0),
+ }
+}
+
+// Push loads .ndignore patterns from the specified folder and adds them to the pattern stack.
+// Use this when entering a folder during directory tree traversal.
+func (ic *IgnoreChecker) Push(ctx context.Context, folder string) error {
+ patterns := ic.loadPatternsFromFolder(ctx, folder)
+ ic.patternStack = append(ic.patternStack, patterns)
+ ic.rebuildCurrentPatterns()
+ return nil
+}
+
+// Pop removes the most recent patterns from the stack.
+// Use this when leaving a folder during directory tree traversal.
+func (ic *IgnoreChecker) Pop() {
+ if len(ic.patternStack) > 0 {
+ ic.patternStack = ic.patternStack[:len(ic.patternStack)-1]
+ ic.rebuildCurrentPatterns()
+ }
+}
+
+// PushAllParents pushes patterns from root down to the target path.
+// This is a convenience method for when you need to check a specific path
+// without recursively walking the tree. It handles the common pattern of
+// pushing all parent directories from root to the target.
+// This method is optimized to compile patterns only once at the end.
+func (ic *IgnoreChecker) PushAllParents(ctx context.Context, targetPath string) error {
+ if targetPath == "." || targetPath == "" {
+ // Simple case: just push root
+ return ic.Push(ctx, ".")
+ }
+
+ // Load patterns for root
+ patterns := ic.loadPatternsFromFolder(ctx, ".")
+ ic.patternStack = append(ic.patternStack, patterns)
+
+ // Load patterns for each parent directory
+ currentPath := "."
+ parts := strings.Split(path.Clean(targetPath), "/")
+ for _, part := range parts {
+ if part == "." || part == "" {
+ continue
+ }
+ currentPath = path.Join(currentPath, part)
+ patterns = ic.loadPatternsFromFolder(ctx, currentPath)
+ ic.patternStack = append(ic.patternStack, patterns)
+ }
+
+ // Rebuild and compile patterns only once at the end
+ ic.rebuildCurrentPatterns()
+ return nil
+}
+
+// ShouldIgnore checks if the given path should be ignored based on the current patterns.
+// Returns true if the path matches any ignore pattern, false otherwise.
+func (ic *IgnoreChecker) ShouldIgnore(ctx context.Context, relPath string) bool {
+ // Handle root/empty path - never ignore
+ if relPath == "" || relPath == "." {
+ return false
+ }
+
+ // If no patterns loaded, nothing to ignore
+ if ic.matcher == nil {
+ return false
+ }
+
+ matches := ic.matcher.MatchesPath(relPath)
+ if matches {
+ log.Trace(ctx, "Scanner: Ignoring entry matching .ndignore", "path", relPath)
+ }
+ return matches
+}
+
+// loadPatternsFromFolder reads the .ndignore file in the specified folder and returns the patterns.
+// If the file doesn't exist, returns an empty slice.
+// If the file exists but is empty, returns a pattern to ignore everything ("**/*").
+func (ic *IgnoreChecker) loadPatternsFromFolder(ctx context.Context, folder string) []string {
+ ignoreFilePath := path.Join(folder, consts.ScanIgnoreFile)
+ var patterns []string
+
+ // Check if .ndignore file exists
+ if _, err := fs.Stat(ic.fsys, ignoreFilePath); err != nil {
+ // No .ndignore file in this folder
+ return patterns
+ }
+
+ // Read and parse the .ndignore file
+ ignoreFile, err := ic.fsys.Open(ignoreFilePath)
+ if err != nil {
+ log.Warn(ctx, "Scanner: Error opening .ndignore file", "path", ignoreFilePath, err)
+ return patterns
+ }
+ defer ignoreFile.Close()
+
+ lineScanner := bufio.NewScanner(ignoreFile)
+ for lineScanner.Scan() {
+ line := strings.TrimSpace(lineScanner.Text())
+ if line == "" || strings.HasPrefix(line, "#") {
+ continue // Skip empty lines, whitespace-only lines, and comments
+ }
+ patterns = append(patterns, line)
+ }
+
+ if err := lineScanner.Err(); err != nil {
+ log.Warn(ctx, "Scanner: Error reading .ndignore file", "path", ignoreFilePath, err)
+ return patterns
+ }
+
+ // If the .ndignore file is empty, ignore everything
+ if len(patterns) == 0 {
+ log.Trace(ctx, "Scanner: .ndignore file is empty, ignoring everything", "path", folder)
+ patterns = []string{"**/*"}
+ }
+
+ return patterns
+}
+
+// rebuildCurrentPatterns flattens the pattern stack into currentPatterns and recompiles the matcher.
+func (ic *IgnoreChecker) rebuildCurrentPatterns() {
+ ic.currentPatterns = make([]string, 0)
+ for _, patterns := range ic.patternStack {
+ ic.currentPatterns = append(ic.currentPatterns, patterns...)
+ }
+ ic.compilePatterns()
+}
+
+// compilePatterns compiles the current patterns into a GitIgnore matcher.
+func (ic *IgnoreChecker) compilePatterns() {
+ if len(ic.currentPatterns) == 0 {
+ ic.matcher = nil
+ return
+ }
+ ic.matcher = ignore.CompileIgnoreLines(ic.currentPatterns...)
+}
diff --git a/scanner/ignore_checker_test.go b/scanner/ignore_checker_test.go
new file mode 100644
index 000000000..5378ed4fa
--- /dev/null
+++ b/scanner/ignore_checker_test.go
@@ -0,0 +1,313 @@
+package scanner
+
+import (
+ "context"
+ "testing/fstest"
+
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+)
+
+var _ = Describe("IgnoreChecker", func() {
+ Describe("loadPatternsFromFolder", func() {
+ var ic *IgnoreChecker
+ var ctx context.Context
+
+ BeforeEach(func() {
+ ctx = context.Background()
+ })
+
+ Context("when .ndignore file does not exist", func() {
+ It("should return empty patterns", func() {
+ fsys := fstest.MapFS{}
+ ic = newIgnoreChecker(fsys)
+ patterns := ic.loadPatternsFromFolder(ctx, ".")
+ Expect(patterns).To(BeEmpty())
+ })
+ })
+
+ Context("when .ndignore file is empty", func() {
+ It("should return wildcard to ignore everything", func() {
+ fsys := fstest.MapFS{
+ ".ndignore": &fstest.MapFile{Data: []byte("")},
+ }
+ ic = newIgnoreChecker(fsys)
+ patterns := ic.loadPatternsFromFolder(ctx, ".")
+ Expect(patterns).To(Equal([]string{"**/*"}))
+ })
+ })
+
+ DescribeTable("parsing .ndignore content",
+ func(content string, expectedPatterns []string) {
+ fsys := fstest.MapFS{
+ ".ndignore": &fstest.MapFile{Data: []byte(content)},
+ }
+ ic = newIgnoreChecker(fsys)
+ patterns := ic.loadPatternsFromFolder(ctx, ".")
+ Expect(patterns).To(Equal(expectedPatterns))
+ },
+ Entry("single pattern", "*.txt", []string{"*.txt"}),
+ Entry("multiple patterns", "*.txt\n*.log", []string{"*.txt", "*.log"}),
+ Entry("with comments", "# comment\n*.txt\n# another\n*.log", []string{"*.txt", "*.log"}),
+ Entry("with empty lines", "*.txt\n\n*.log\n\n", []string{"*.txt", "*.log"}),
+ Entry("mixed content", "# header\n\n*.txt\n# middle\n*.log\n\n", []string{"*.txt", "*.log"}),
+ Entry("only comments and empty lines", "# comment\n\n# another\n", []string{"**/*"}),
+ Entry("trailing newline", "*.txt\n*.log\n", []string{"*.txt", "*.log"}),
+ Entry("directory pattern", "temp/", []string{"temp/"}),
+ Entry("wildcard pattern", "**/*.mp3", []string{"**/*.mp3"}),
+ Entry("multiple wildcards", "**/*.mp3\n**/*.flac\n*.log", []string{"**/*.mp3", "**/*.flac", "*.log"}),
+ Entry("negation pattern", "!important.txt", []string{"!important.txt"}),
+ Entry("comment with hash not at start is pattern", "not#comment", []string{"not#comment"}),
+ Entry("whitespace-only lines skipped", "*.txt\n \n*.log\n\t\n", []string{"*.txt", "*.log"}),
+ Entry("patterns with whitespace trimmed", " *.txt \n\t*.log\t", []string{"*.txt", "*.log"}),
+ )
+ })
+
+ Describe("Push and Pop", func() {
+ var ic *IgnoreChecker
+ var fsys fstest.MapFS
+ var ctx context.Context
+
+ BeforeEach(func() {
+ ctx = context.Background()
+ fsys = fstest.MapFS{
+ ".ndignore": &fstest.MapFile{Data: []byte("*.txt")},
+ "folder1/.ndignore": &fstest.MapFile{Data: []byte("*.mp3")},
+ "folder2/.ndignore": &fstest.MapFile{Data: []byte("*.flac")},
+ }
+ ic = newIgnoreChecker(fsys)
+ })
+
+ Context("Push", func() {
+ It("should add patterns to stack", func() {
+ err := ic.Push(ctx, ".")
+ Expect(err).ToNot(HaveOccurred())
+ Expect(len(ic.patternStack)).To(Equal(1))
+ Expect(ic.currentPatterns).To(ContainElement("*.txt"))
+ })
+
+ It("should compile matcher after push", func() {
+ err := ic.Push(ctx, ".")
+ Expect(err).ToNot(HaveOccurred())
+ Expect(ic.matcher).ToNot(BeNil())
+ })
+
+ It("should accumulate patterns from multiple levels", func() {
+ err := ic.Push(ctx, ".")
+ Expect(err).ToNot(HaveOccurred())
+ err = ic.Push(ctx, "folder1")
+ Expect(err).ToNot(HaveOccurred())
+ Expect(len(ic.patternStack)).To(Equal(2))
+ Expect(ic.currentPatterns).To(ConsistOf("*.txt", "*.mp3"))
+ })
+
+ It("should handle push when no .ndignore exists", func() {
+ err := ic.Push(ctx, "nonexistent")
+ Expect(err).ToNot(HaveOccurred())
+ Expect(len(ic.patternStack)).To(Equal(1))
+ Expect(ic.currentPatterns).To(BeEmpty())
+ })
+ })
+
+ Context("Pop", func() {
+ It("should remove most recent patterns", func() {
+ err := ic.Push(ctx, ".")
+ Expect(err).ToNot(HaveOccurred())
+ err = ic.Push(ctx, "folder1")
+ Expect(err).ToNot(HaveOccurred())
+ ic.Pop()
+ Expect(len(ic.patternStack)).To(Equal(1))
+ Expect(ic.currentPatterns).To(Equal([]string{"*.txt"}))
+ })
+
+ It("should handle Pop on empty stack gracefully", func() {
+ Expect(func() { ic.Pop() }).ToNot(Panic())
+ Expect(ic.patternStack).To(BeEmpty())
+ })
+
+ It("should set matcher to nil when all patterns popped", func() {
+ err := ic.Push(ctx, ".")
+ Expect(err).ToNot(HaveOccurred())
+ Expect(ic.matcher).ToNot(BeNil())
+ ic.Pop()
+ Expect(ic.matcher).To(BeNil())
+ })
+
+ It("should update matcher after pop", func() {
+ err := ic.Push(ctx, ".")
+ Expect(err).ToNot(HaveOccurred())
+ err = ic.Push(ctx, "folder1")
+ Expect(err).ToNot(HaveOccurred())
+ matcher1 := ic.matcher
+ ic.Pop()
+ matcher2 := ic.matcher
+ Expect(matcher1).ToNot(Equal(matcher2))
+ })
+ })
+
+ Context("multiple Push/Pop cycles", func() {
+ It("should maintain correct state through cycles", func() {
+ err := ic.Push(ctx, ".")
+ Expect(err).ToNot(HaveOccurred())
+ Expect(ic.currentPatterns).To(Equal([]string{"*.txt"}))
+
+ err = ic.Push(ctx, "folder1")
+ Expect(err).ToNot(HaveOccurred())
+ Expect(ic.currentPatterns).To(ConsistOf("*.txt", "*.mp3"))
+
+ ic.Pop()
+ Expect(ic.currentPatterns).To(Equal([]string{"*.txt"}))
+
+ err = ic.Push(ctx, "folder2")
+ Expect(err).ToNot(HaveOccurred())
+ Expect(ic.currentPatterns).To(ConsistOf("*.txt", "*.flac"))
+
+ ic.Pop()
+ Expect(ic.currentPatterns).To(Equal([]string{"*.txt"}))
+
+ ic.Pop()
+ Expect(ic.currentPatterns).To(BeEmpty())
+ })
+ })
+ })
+
+ Describe("PushAllParents", func() {
+ var ic *IgnoreChecker
+ var ctx context.Context
+
+ BeforeEach(func() {
+ ctx = context.Background()
+ fsys := fstest.MapFS{
+ ".ndignore": &fstest.MapFile{Data: []byte("root.txt")},
+ "folder1/.ndignore": &fstest.MapFile{Data: []byte("level1.txt")},
+ "folder1/folder2/.ndignore": &fstest.MapFile{Data: []byte("level2.txt")},
+ "folder1/folder2/folder3/.ndignore": &fstest.MapFile{Data: []byte("level3.txt")},
+ }
+ ic = newIgnoreChecker(fsys)
+ })
+
+ DescribeTable("loading parent patterns",
+ func(targetPath string, expectedStackDepth int, expectedPatterns []string) {
+ err := ic.PushAllParents(ctx, targetPath)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(len(ic.patternStack)).To(Equal(expectedStackDepth))
+ Expect(ic.currentPatterns).To(ConsistOf(expectedPatterns))
+ },
+ Entry("root path", ".", 1, []string{"root.txt"}),
+ Entry("empty path", "", 1, []string{"root.txt"}),
+ Entry("single level", "folder1", 2, []string{"root.txt", "level1.txt"}),
+ Entry("two levels", "folder1/folder2", 3, []string{"root.txt", "level1.txt", "level2.txt"}),
+ Entry("three levels", "folder1/folder2/folder3", 4, []string{"root.txt", "level1.txt", "level2.txt", "level3.txt"}),
+ )
+
+ It("should only compile patterns once at the end", func() {
+ // This is more of a behavioral test - we verify the matcher is not nil after PushAllParents
+ err := ic.PushAllParents(ctx, "folder1/folder2")
+ Expect(err).ToNot(HaveOccurred())
+ Expect(ic.matcher).ToNot(BeNil())
+ })
+
+ It("should handle paths with dot", func() {
+ err := ic.PushAllParents(ctx, "./folder1")
+ Expect(err).ToNot(HaveOccurred())
+ Expect(len(ic.patternStack)).To(Equal(2))
+ })
+
+ Context("when some parent folders have no .ndignore", func() {
+ BeforeEach(func() {
+ fsys := fstest.MapFS{
+ ".ndignore": &fstest.MapFile{Data: []byte("root.txt")},
+ "folder1/folder2/.ndignore": &fstest.MapFile{Data: []byte("level2.txt")},
+ }
+ ic = newIgnoreChecker(fsys)
+ })
+
+ It("should still push all parent levels", func() {
+ err := ic.PushAllParents(ctx, "folder1/folder2")
+ Expect(err).ToNot(HaveOccurred())
+ Expect(len(ic.patternStack)).To(Equal(3)) // root, folder1 (empty), folder2
+ Expect(ic.currentPatterns).To(ConsistOf("root.txt", "level2.txt"))
+ })
+ })
+ })
+
+ Describe("ShouldIgnore", func() {
+ var ic *IgnoreChecker
+ var ctx context.Context
+
+ BeforeEach(func() {
+ ctx = context.Background()
+ })
+
+ Context("with no patterns loaded", func() {
+ It("should not ignore any path", func() {
+ fsys := fstest.MapFS{}
+ ic = newIgnoreChecker(fsys)
+ Expect(ic.ShouldIgnore(ctx, "anything.txt")).To(BeFalse())
+ Expect(ic.ShouldIgnore(ctx, "folder/file.mp3")).To(BeFalse())
+ })
+ })
+
+ Context("special paths", func() {
+ BeforeEach(func() {
+ fsys := fstest.MapFS{
+ ".ndignore": &fstest.MapFile{Data: []byte("**/*")},
+ }
+ ic = newIgnoreChecker(fsys)
+ err := ic.Push(ctx, ".")
+ Expect(err).ToNot(HaveOccurred())
+ })
+
+ It("should never ignore root or empty paths", func() {
+ Expect(ic.ShouldIgnore(ctx, "")).To(BeFalse())
+ Expect(ic.ShouldIgnore(ctx, ".")).To(BeFalse())
+ })
+
+ It("should ignore all other paths with wildcard", func() {
+ Expect(ic.ShouldIgnore(ctx, "file.txt")).To(BeTrue())
+ Expect(ic.ShouldIgnore(ctx, "folder/file.mp3")).To(BeTrue())
+ })
+ })
+
+ DescribeTable("pattern matching",
+ func(pattern string, path string, shouldMatch bool) {
+ fsys := fstest.MapFS{
+ ".ndignore": &fstest.MapFile{Data: []byte(pattern)},
+ }
+ ic = newIgnoreChecker(fsys)
+ err := ic.Push(ctx, ".")
+ Expect(err).ToNot(HaveOccurred())
+ Expect(ic.ShouldIgnore(ctx, path)).To(Equal(shouldMatch))
+ },
+ Entry("glob match", "*.txt", "file.txt", true),
+ Entry("glob no match", "*.txt", "file.mp3", false),
+ Entry("directory pattern match", "tmp/", "tmp/file.txt", true),
+ Entry("directory pattern no match", "tmp/", "temporary/file.txt", false),
+ Entry("nested glob match", "**/*.log", "deep/nested/file.log", true),
+ Entry("nested glob no match", "**/*.log", "deep/nested/file.txt", false),
+ Entry("specific file match", "ignore.me", "ignore.me", true),
+ Entry("specific file no match", "ignore.me", "keep.me", false),
+ Entry("wildcard all", "**/*", "any/path/file.txt", true),
+ Entry("nested specific match", "temp/*", "temp/cache.db", true),
+ Entry("nested specific no match", "temp/*", "temporary/cache.db", false),
+ )
+
+ Context("with multiple patterns", func() {
+ BeforeEach(func() {
+ fsys := fstest.MapFS{
+ ".ndignore": &fstest.MapFile{Data: []byte("*.txt\n*.log\ntemp/")},
+ }
+ ic = newIgnoreChecker(fsys)
+ err := ic.Push(ctx, ".")
+ Expect(err).ToNot(HaveOccurred())
+ })
+
+ It("should match any of the patterns", func() {
+ Expect(ic.ShouldIgnore(ctx, "file.txt")).To(BeTrue())
+ Expect(ic.ShouldIgnore(ctx, "debug.log")).To(BeTrue())
+ Expect(ic.ShouldIgnore(ctx, "temp/cache")).To(BeTrue())
+ Expect(ic.ShouldIgnore(ctx, "music.mp3")).To(BeFalse())
+ })
+ })
+ })
+})
diff --git a/scanner/phase_1_folders.go b/scanner/phase_1_folders.go
index e04f10c70..329029951 100644
--- a/scanner/phase_1_folders.go
+++ b/scanner/phase_1_folders.go
@@ -26,58 +26,46 @@ import (
"github.com/navidrome/navidrome/utils/slice"
)
-func createPhaseFolders(ctx context.Context, state *scanState, ds model.DataStore, cw artwork.CacheWarmer, libs []model.Library) *phaseFolders {
+func createPhaseFolders(ctx context.Context, state *scanState, ds model.DataStore, cw artwork.CacheWarmer) *phaseFolders {
var jobs []*scanJob
- var updatedLibs []model.Library
- for _, lib := range libs {
- if lib.LastScanStartedAt.IsZero() {
- err := ds.Library(ctx).ScanBegin(lib.ID, state.fullScan)
- if err != nil {
- log.Error(ctx, "Scanner: Error updating last scan started at", "lib", lib.Name, err)
- state.sendWarning(err.Error())
- continue
- }
- // Reload library to get updated state
- l, err := ds.Library(ctx).Get(lib.ID)
- if err != nil {
- log.Error(ctx, "Scanner: Error reloading library", "lib", lib.Name, err)
- state.sendWarning(err.Error())
- continue
- }
- lib = *l
- } else {
- log.Debug(ctx, "Scanner: Resuming previous scan", "lib", lib.Name, "lastScanStartedAt", lib.LastScanStartedAt, "fullScan", lib.FullScanInProgress)
+
+ // Create scan jobs for all libraries
+ for _, lib := range state.libraries {
+ // Get target folders for this library if selective scan
+ var targetFolders []string
+ if state.isSelectiveScan() {
+ targetFolders = state.targets[lib.ID]
}
- job, err := newScanJob(ctx, ds, cw, lib, state.fullScan)
+
+ job, err := newScanJob(ctx, ds, cw, lib, state.fullScan, targetFolders)
if err != nil {
log.Error(ctx, "Scanner: Error creating scan context", "lib", lib.Name, err)
state.sendWarning(err.Error())
continue
}
jobs = append(jobs, job)
- updatedLibs = append(updatedLibs, lib)
}
- // Update the state with the libraries that have been processed and have their scan timestamps set
- state.libraries = updatedLibs
-
return &phaseFolders{jobs: jobs, ctx: ctx, ds: ds, state: state}
}
type scanJob struct {
- lib model.Library
- fs storage.MusicFS
- cw artwork.CacheWarmer
- lastUpdates map[string]model.FolderUpdateInfo
- lock sync.Mutex
- numFolders atomic.Int64
+ lib model.Library
+ fs storage.MusicFS
+ cw artwork.CacheWarmer
+ lastUpdates map[string]model.FolderUpdateInfo // Holds last update info for all (DB) folders in this library
+ targetFolders []string // Specific folders to scan (including all descendants)
+ lock sync.Mutex
+ numFolders atomic.Int64
}
-func newScanJob(ctx context.Context, ds model.DataStore, cw artwork.CacheWarmer, lib model.Library, fullScan bool) (*scanJob, error) {
- lastUpdates, err := ds.Folder(ctx).GetLastUpdates(lib)
+func newScanJob(ctx context.Context, ds model.DataStore, cw artwork.CacheWarmer, lib model.Library, fullScan bool, targetFolders []string) (*scanJob, error) {
+ // Get folder updates, optionally filtered to specific target folders
+ lastUpdates, err := ds.Folder(ctx).GetFolderUpdateInfo(lib, targetFolders...)
if err != nil {
return nil, fmt.Errorf("getting last updates: %w", err)
}
+
fileStore, err := storage.For(lib.Path)
if err != nil {
log.Error(ctx, "Error getting storage for library", "library", lib.Name, "path", lib.Path, err)
@@ -88,15 +76,17 @@ func newScanJob(ctx context.Context, ds model.DataStore, cw artwork.CacheWarmer,
log.Error(ctx, "Error getting fs for library", "library", lib.Name, "path", lib.Path, err)
return nil, fmt.Errorf("getting fs for library: %w", err)
}
- lib.FullScanInProgress = lib.FullScanInProgress || fullScan
return &scanJob{
- lib: lib,
- fs: fsys,
- cw: cw,
- lastUpdates: lastUpdates,
+ lib: lib,
+ fs: fsys,
+ cw: cw,
+ lastUpdates: lastUpdates,
+ targetFolders: targetFolders,
}, nil
}
+// popLastUpdate retrieves and removes the last update info for the given folder ID
+// This is used to track which folders have been found during the walk_dir_tree
func (j *scanJob) popLastUpdate(folderID string) model.FolderUpdateInfo {
j.lock.Lock()
defer j.lock.Unlock()
@@ -106,6 +96,15 @@ func (j *scanJob) popLastUpdate(folderID string) model.FolderUpdateInfo {
return lastUpdate
}
+// createFolderEntry creates a new folderEntry for the given path, using the last update info from the job
+// to populate the previous update time and hash. It also removes the folder from the job's lastUpdates map.
+// This is used to track which folders have been found during the walk_dir_tree.
+func (j *scanJob) createFolderEntry(path string) *folderEntry {
+ id := model.FolderID(j.lib, path)
+ info := j.popLastUpdate(id)
+ return newFolderEntry(j, id, path, info.UpdatedAt, info.Hash)
+}
+
// phaseFolders represents the first phase of the scanning process, which is responsible
// for scanning all libraries and importing new or updated files. This phase involves
// traversing the directory tree of each library, identifying new or modified media files,
@@ -144,7 +143,8 @@ func (p *phaseFolders) producer() ppl.Producer[*folderEntry] {
if utils.IsCtxDone(p.ctx) {
break
}
- outputChan, err := walkDirTree(p.ctx, job)
+
+ outputChan, err := walkDirTree(p.ctx, job, job.targetFolders...)
if err != nil {
log.Warn(p.ctx, "Scanner: Error scanning library", "lib", job.lib.Name, err)
}
@@ -324,6 +324,9 @@ func (p *phaseFolders) persistChanges(entry *folderEntry) (*folderEntry, error)
defer p.measure(entry)()
p.state.changesDetected.Store(true)
+ // Collect artwork IDs to pre-cache after the transaction commits
+ var artworkIDs []model.ArtworkID
+
err := p.ds.WithTx(func(tx model.DataStore) error {
// Instantiate all repositories just once per folder
folderRepo := tx.Folder(p.ctx)
@@ -362,7 +365,7 @@ func (p *phaseFolders) persistChanges(entry *folderEntry) (*folderEntry, error)
return err
}
if entry.artists[i].Name != consts.UnknownArtist && entry.artists[i].Name != consts.VariousArtists {
- entry.job.cw.PreCache(entry.artists[i].CoverArtID())
+ artworkIDs = append(artworkIDs, entry.artists[i].CoverArtID())
}
}
@@ -374,7 +377,7 @@ func (p *phaseFolders) persistChanges(entry *folderEntry) (*folderEntry, error)
return err
}
if entry.albums[i].Name != consts.UnknownAlbum {
- entry.job.cw.PreCache(entry.albums[i].CoverArtID())
+ artworkIDs = append(artworkIDs, entry.albums[i].CoverArtID())
}
}
@@ -411,6 +414,14 @@ func (p *phaseFolders) persistChanges(entry *folderEntry) (*folderEntry, error)
if err != nil {
log.Error(p.ctx, "Scanner: Error persisting changes to DB", "folder", entry.path, err)
}
+
+ // Pre-cache artwork after the transaction commits successfully
+ if err == nil {
+ for _, artID := range artworkIDs {
+ entry.job.cw.PreCache(artID)
+ }
+ }
+
return entry, err
}
diff --git a/scanner/phase_2_missing_tracks.go b/scanner/phase_2_missing_tracks.go
index a6c0e261e..de93ed6ee 100644
--- a/scanner/phase_2_missing_tracks.go
+++ b/scanner/phase_2_missing_tracks.go
@@ -69,9 +69,6 @@ func (p *phaseMissingTracks) produce(put func(tracks *missingTracks)) error {
}
}
for _, lib := range p.state.libraries {
- if lib.LastScanStartedAt.IsZero() {
- continue
- }
log.Debug(p.ctx, "Scanner: Checking missing tracks", "libraryId", lib.ID, "libraryName", lib.Name)
cursor, err := p.ds.MediaFile(p.ctx).GetMissingAndMatching(lib.ID)
if err != nil {
diff --git a/scanner/phase_3_refresh_albums.go b/scanner/phase_3_refresh_albums.go
index f51aa8f4b..33e0fed01 100644
--- a/scanner/phase_3_refresh_albums.go
+++ b/scanner/phase_3_refresh_albums.go
@@ -27,14 +27,13 @@ import (
type phaseRefreshAlbums struct {
ds model.DataStore
ctx context.Context
- libs model.Libraries
refreshed atomic.Uint32
skipped atomic.Uint32
state *scanState
}
-func createPhaseRefreshAlbums(ctx context.Context, state *scanState, ds model.DataStore, libs model.Libraries) *phaseRefreshAlbums {
- return &phaseRefreshAlbums{ctx: ctx, ds: ds, libs: libs, state: state}
+func createPhaseRefreshAlbums(ctx context.Context, state *scanState, ds model.DataStore) *phaseRefreshAlbums {
+ return &phaseRefreshAlbums{ctx: ctx, ds: ds, state: state}
}
func (p *phaseRefreshAlbums) description() string {
@@ -47,7 +46,7 @@ func (p *phaseRefreshAlbums) producer() ppl.Producer[*model.Album] {
func (p *phaseRefreshAlbums) produce(put func(album *model.Album)) error {
count := 0
- for _, lib := range p.libs {
+ for _, lib := range p.state.libraries {
cursor, err := p.ds.Album(p.ctx).GetTouchedAlbums(lib.ID)
if err != nil {
return fmt.Errorf("loading touched albums: %w", err)
diff --git a/scanner/phase_3_refresh_albums_test.go b/scanner/phase_3_refresh_albums_test.go
index dea2556f0..1f0baf428 100644
--- a/scanner/phase_3_refresh_albums_test.go
+++ b/scanner/phase_3_refresh_albums_test.go
@@ -32,8 +32,8 @@ var _ = Describe("phaseRefreshAlbums", func() {
{ID: 1, Name: "Library 1"},
{ID: 2, Name: "Library 2"},
}
- state = &scanState{}
- phase = createPhaseRefreshAlbums(ctx, state, ds, libs)
+ state = &scanState{libraries: libs}
+ phase = createPhaseRefreshAlbums(ctx, state, ds)
})
Describe("description", func() {
diff --git a/scanner/scanner.go b/scanner/scanner.go
index 04a5c2456..20f3f5da8 100644
--- a/scanner/scanner.go
+++ b/scanner/scanner.go
@@ -3,6 +3,8 @@ package scanner
import (
"context"
"fmt"
+ "maps"
+ "slices"
"sync/atomic"
"time"
@@ -15,6 +17,7 @@ import (
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/utils/run"
+ "github.com/navidrome/navidrome/utils/slice"
)
type scannerImpl struct {
@@ -28,7 +31,8 @@ type scanState struct {
progress chan<- *ProgressInfo
fullScan bool
changesDetected atomic.Bool
- libraries model.Libraries // Store libraries list for consistency across phases
+ libraries model.Libraries // Store libraries list for consistency across phases
+ targets map[int][]string // Optional: map[libraryID][]folderPaths for selective scans
}
func (s *scanState) sendProgress(info *ProgressInfo) {
@@ -37,6 +41,10 @@ func (s *scanState) sendProgress(info *ProgressInfo) {
}
}
+func (s *scanState) isSelectiveScan() bool {
+ return len(s.targets) > 0
+}
+
func (s *scanState) sendWarning(msg string) {
s.sendProgress(&ProgressInfo{Warning: msg})
}
@@ -45,7 +53,7 @@ func (s *scanState) sendError(err error) {
s.sendProgress(&ProgressInfo{Error: err.Error()})
}
-func (s *scannerImpl) scanAll(ctx context.Context, fullScan bool, progress chan<- *ProgressInfo) {
+func (s *scannerImpl) scanFolders(ctx context.Context, fullScan bool, targets []model.ScanTarget, progress chan<- *ProgressInfo) {
startTime := time.Now()
state := scanState{
@@ -59,38 +67,75 @@ func (s *scannerImpl) scanAll(ctx context.Context, fullScan bool, progress chan<
state.changesDetected.Store(true)
}
- libs, err := s.ds.Library(ctx).GetAll()
+ // Get libraries and optionally filter by targets
+ allLibs, err := s.ds.Library(ctx).GetAll()
if err != nil {
state.sendWarning(fmt.Sprintf("getting libraries: %s", err))
return
}
- state.libraries = libs
- log.Info(ctx, "Scanner: Starting scan", "fullScan", state.fullScan, "numLibraries", len(libs))
+ if len(targets) > 0 {
+ // Selective scan: filter libraries and build targets map
+ state.targets = make(map[int][]string)
+
+ for _, target := range targets {
+ folderPath := target.FolderPath
+ if folderPath == "" {
+ folderPath = "."
+ }
+ state.targets[target.LibraryID] = append(state.targets[target.LibraryID], folderPath)
+ }
+
+ // Filter libraries to only those in targets
+ state.libraries = slice.Filter(allLibs, func(lib model.Library) bool {
+ return len(state.targets[lib.ID]) > 0
+ })
+
+ log.Info(ctx, "Scanner: Starting selective scan", "fullScan", state.fullScan, "numLibraries", len(state.libraries), "numTargets", len(targets))
+ } else {
+ // Full library scan
+ state.libraries = allLibs
+ log.Info(ctx, "Scanner: Starting scan", "fullScan", state.fullScan, "numLibraries", len(state.libraries))
+ }
// Store scan type and start time
scanType := "quick"
if state.fullScan {
scanType = "full"
}
+ if state.isSelectiveScan() {
+ scanType += "-selective"
+ }
_ = s.ds.Property(ctx).Put(consts.LastScanTypeKey, scanType)
_ = s.ds.Property(ctx).Put(consts.LastScanStartTimeKey, startTime.Format(time.RFC3339))
// if there was a full scan in progress, force a full scan
if !state.fullScan {
- for _, lib := range libs {
+ for _, lib := range state.libraries {
if lib.FullScanInProgress {
log.Info(ctx, "Scanner: Interrupted full scan detected", "lib", lib.Name)
state.fullScan = true
- _ = s.ds.Property(ctx).Put(consts.LastScanTypeKey, "full")
+ if state.isSelectiveScan() {
+ _ = s.ds.Property(ctx).Put(consts.LastScanTypeKey, "full-selective")
+ } else {
+ _ = s.ds.Property(ctx).Put(consts.LastScanTypeKey, "full")
+ }
break
}
}
}
+ // Prepare libraries for scanning (initialize LastScanStartedAt if needed)
+ err = s.prepareLibrariesForScan(ctx, &state)
+ if err != nil {
+ log.Error(ctx, "Scanner: Error preparing libraries for scan", err)
+ state.sendError(err)
+ return
+ }
+
err = run.Sequentially(
// Phase 1: Scan all libraries and import new/updated files
- runPhase[*folderEntry](ctx, 1, createPhaseFolders(ctx, &state, s.ds, s.cw, libs)),
+ runPhase[*folderEntry](ctx, 1, createPhaseFolders(ctx, &state, s.ds, s.cw)),
// Phase 2: Process missing files, checking for moves
runPhase[*missingTracks](ctx, 2, createPhaseMissingTracks(ctx, &state, s.ds)),
@@ -98,7 +143,7 @@ func (s *scannerImpl) scanAll(ctx context.Context, fullScan bool, progress chan<
// Phases 3 and 4 can be run in parallel
run.Parallel(
// Phase 3: Refresh all new/changed albums and update artists
- runPhase[*model.Album](ctx, 3, createPhaseRefreshAlbums(ctx, &state, s.ds, libs)),
+ runPhase[*model.Album](ctx, 3, createPhaseRefreshAlbums(ctx, &state, s.ds)),
// Phase 4: Import/update playlists
runPhase[*model.Folder](ctx, 4, createPhasePlaylists(ctx, &state, s.ds, s.pls, s.cw)),
@@ -131,7 +176,53 @@ func (s *scannerImpl) scanAll(ctx context.Context, fullScan bool, progress chan<
state.sendProgress(&ProgressInfo{ChangesDetected: true})
}
- log.Info(ctx, "Scanner: Finished scanning all libraries", "duration", time.Since(startTime))
+ if state.isSelectiveScan() {
+ log.Info(ctx, "Scanner: Finished scanning selected folders", "duration", time.Since(startTime), "numTargets", len(targets))
+ } else {
+ log.Info(ctx, "Scanner: Finished scanning all libraries", "duration", time.Since(startTime))
+ }
+}
+
+// prepareLibrariesForScan initializes the scan for all libraries in the state.
+// It calls ScanBegin for libraries that haven't started scanning yet (LastScanStartedAt is zero),
+// reloads them to get the updated state, and filters out any libraries that fail to initialize.
+func (s *scannerImpl) prepareLibrariesForScan(ctx context.Context, state *scanState) error {
+ var successfulLibs []model.Library
+
+ for _, lib := range state.libraries {
+ if lib.LastScanStartedAt.IsZero() {
+ // This is a new scan - mark it as started
+ err := s.ds.Library(ctx).ScanBegin(lib.ID, state.fullScan)
+ if err != nil {
+ log.Error(ctx, "Scanner: Error marking scan start", "lib", lib.Name, err)
+ state.sendWarning(err.Error())
+ continue
+ }
+
+ // Reload library to get updated state (timestamps, etc.)
+ reloadedLib, err := s.ds.Library(ctx).Get(lib.ID)
+ if err != nil {
+ log.Error(ctx, "Scanner: Error reloading library", "lib", lib.Name, err)
+ state.sendWarning(err.Error())
+ continue
+ }
+ lib = *reloadedLib
+ } else {
+ // This is a resumed scan
+ log.Debug(ctx, "Scanner: Resuming previous scan", "lib", lib.Name,
+ "lastScanStartedAt", lib.LastScanStartedAt, "fullScan", lib.FullScanInProgress)
+ }
+
+ successfulLibs = append(successfulLibs, lib)
+ }
+
+ if len(successfulLibs) == 0 {
+ return fmt.Errorf("no libraries available for scanning")
+ }
+
+ // Update state with only successfully initialized libraries
+ state.libraries = successfulLibs
+ return nil
}
func (s *scannerImpl) runGC(ctx context.Context, state *scanState) func() error {
@@ -140,7 +231,15 @@ func (s *scannerImpl) runGC(ctx context.Context, state *scanState) func() error
return s.ds.WithTx(func(tx model.DataStore) error {
if state.changesDetected.Load() {
start := time.Now()
- err := tx.GC(ctx)
+
+ // For selective scans, extract library IDs to scope GC operations
+ var libraryIDs []int
+ if state.isSelectiveScan() {
+ libraryIDs = slices.Collect(maps.Keys(state.targets))
+ log.Debug(ctx, "Scanner: Running selective GC", "libraryIDs", libraryIDs)
+ }
+
+ err := tx.GC(ctx, libraryIDs...)
if err != nil {
log.Error(ctx, "Scanner: Error running GC", err)
return fmt.Errorf("running GC: %w", err)
diff --git a/scanner/scanner_multilibrary_test.go b/scanner/scanner_multilibrary_test.go
index f27ad52fc..66db62edf 100644
--- a/scanner/scanner_multilibrary_test.go
+++ b/scanner/scanner_multilibrary_test.go
@@ -32,7 +32,7 @@ var _ = Describe("Scanner - Multi-Library", Ordered, func() {
var ctx context.Context
var lib1, lib2 model.Library
var ds *tests.MockDataStore
- var s scanner.Scanner
+ var s model.Scanner
createFS := func(path string, files fstest.MapFS) storagetest.FakeFS {
fs := storagetest.FakeFS{}
diff --git a/scanner/scanner_selective_test.go b/scanner/scanner_selective_test.go
new file mode 100644
index 000000000..629826db4
--- /dev/null
+++ b/scanner/scanner_selective_test.go
@@ -0,0 +1,293 @@
+package scanner_test
+
+import (
+ "context"
+ "path/filepath"
+ "testing/fstest"
+
+ "github.com/Masterminds/squirrel"
+ "github.com/navidrome/navidrome/conf"
+ "github.com/navidrome/navidrome/conf/configtest"
+ "github.com/navidrome/navidrome/core"
+ "github.com/navidrome/navidrome/core/artwork"
+ "github.com/navidrome/navidrome/core/metrics"
+ "github.com/navidrome/navidrome/core/storage/storagetest"
+ "github.com/navidrome/navidrome/db"
+ "github.com/navidrome/navidrome/log"
+ "github.com/navidrome/navidrome/model"
+ "github.com/navidrome/navidrome/model/request"
+ "github.com/navidrome/navidrome/persistence"
+ "github.com/navidrome/navidrome/scanner"
+ "github.com/navidrome/navidrome/server/events"
+ "github.com/navidrome/navidrome/tests"
+ "github.com/navidrome/navidrome/utils/slice"
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+)
+
+var _ = Describe("ScanFolders", Ordered, func() {
+ var ctx context.Context
+ var lib model.Library
+ var ds model.DataStore
+ var s model.Scanner
+ var fsys storagetest.FakeFS
+
+ BeforeAll(func() {
+ ctx = request.WithUser(GinkgoT().Context(), model.User{ID: "123", IsAdmin: true})
+ tmpDir := GinkgoT().TempDir()
+ conf.Server.DbPath = filepath.Join(tmpDir, "test-selective-scan.db?_journal_mode=WAL")
+ log.Warn("Using DB at " + conf.Server.DbPath)
+ db.Db().SetMaxOpenConns(1)
+ })
+
+ BeforeEach(func() {
+ DeferCleanup(configtest.SetupConfig())
+ conf.Server.MusicFolder = "fake:///music"
+ conf.Server.DevExternalScanner = false
+
+ db.Init(ctx)
+ DeferCleanup(func() {
+ Expect(tests.ClearDB()).To(Succeed())
+ })
+
+ ds = persistence.New(db.Db())
+
+ // Create the admin user in the database to match the context
+ adminUser := model.User{
+ ID: "123",
+ UserName: "admin",
+ Name: "Admin User",
+ IsAdmin: true,
+ NewPassword: "password",
+ }
+ Expect(ds.User(ctx).Put(&adminUser)).To(Succeed())
+
+ s = scanner.New(ctx, ds, artwork.NoopCacheWarmer(), events.NoopBroker(),
+ core.NewPlaylists(ds), metrics.NewNoopInstance())
+
+ lib = model.Library{ID: 1, Name: "Fake Library", Path: "fake:///music"}
+ Expect(ds.Library(ctx).Put(&lib)).To(Succeed())
+
+ // Initialize fake filesystem
+ fsys = storagetest.FakeFS{}
+ storagetest.Register("fake", &fsys)
+ })
+
+ Describe("Adding tracks to the library", func() {
+ It("scans specified folders recursively including all subdirectories", func() {
+ rock := template(_t{"albumartist": "Rock Artist", "album": "Rock Album"})
+ jazz := template(_t{"albumartist": "Jazz Artist", "album": "Jazz Album"})
+ pop := template(_t{"albumartist": "Pop Artist", "album": "Pop Album"})
+ createFS(fstest.MapFS{
+ "rock/track1.mp3": rock(track(1, "Rock Track 1")),
+ "rock/track2.mp3": rock(track(2, "Rock Track 2")),
+ "rock/subdir/track3.mp3": rock(track(3, "Rock Track 3")),
+ "jazz/track4.mp3": jazz(track(1, "Jazz Track 1")),
+ "jazz/subdir/track5.mp3": jazz(track(2, "Jazz Track 2")),
+ "pop/track6.mp3": pop(track(1, "Pop Track 1")),
+ })
+
+ // Scan only the "rock" and "jazz" folders (including their subdirectories)
+ targets := []model.ScanTarget{
+ {LibraryID: lib.ID, FolderPath: "rock"},
+ {LibraryID: lib.ID, FolderPath: "jazz"},
+ }
+
+ warnings, err := s.ScanFolders(ctx, false, targets)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(warnings).To(BeEmpty())
+
+ // Verify all tracks in rock and jazz folders (including subdirectories) were imported
+ allFiles, err := ds.MediaFile(ctx).GetAll()
+ Expect(err).ToNot(HaveOccurred())
+
+ // Should have 5 tracks (all rock and jazz tracks including subdirectories)
+ Expect(allFiles).To(HaveLen(5))
+
+ // Get the file paths
+ paths := slice.Map(allFiles, func(mf model.MediaFile) string {
+ return filepath.ToSlash(mf.Path)
+ })
+
+ // Verify the correct files were scanned (including subdirectories)
+ Expect(paths).To(ContainElements(
+ "rock/track1.mp3",
+ "rock/track2.mp3",
+ "rock/subdir/track3.mp3",
+ "jazz/track4.mp3",
+ "jazz/subdir/track5.mp3",
+ ))
+
+ // Verify files in the pop folder were NOT scanned
+ Expect(paths).ToNot(ContainElement("pop/track6.mp3"))
+ })
+ })
+
+ Describe("Deleting folders", func() {
+ Context("when a child folder is deleted", func() {
+ var (
+ revolver, help func(...map[string]any) *fstest.MapFile
+ artistFolderID string
+ album1FolderID string
+ album2FolderID string
+ album1TrackIDs []string
+ album2TrackIDs []string
+ )
+
+ BeforeEach(func() {
+ // Setup template functions for creating test files
+ revolver = storagetest.Template(_t{"albumartist": "The Beatles", "album": "Revolver", "year": 1966})
+ help = storagetest.Template(_t{"albumartist": "The Beatles", "album": "Help!", "year": 1965})
+
+ // Initial filesystem with nested folders
+ fsys.SetFiles(fstest.MapFS{
+ "The Beatles/Revolver/01 - Taxman.mp3": revolver(storagetest.Track(1, "Taxman")),
+ "The Beatles/Revolver/02 - Eleanor Rigby.mp3": revolver(storagetest.Track(2, "Eleanor Rigby")),
+ "The Beatles/Help!/01 - Help!.mp3": help(storagetest.Track(1, "Help!")),
+ "The Beatles/Help!/02 - The Night Before.mp3": help(storagetest.Track(2, "The Night Before")),
+ })
+
+ // First scan - import everything
+ _, err := s.ScanAll(ctx, true)
+ Expect(err).ToNot(HaveOccurred())
+
+ // Verify initial state - all folders exist
+ folders, err := ds.Folder(ctx).GetAll(model.QueryOptions{Filters: squirrel.Eq{"library_id": lib.ID}})
+ Expect(err).ToNot(HaveOccurred())
+ Expect(folders).To(HaveLen(4)) // root, Artist, Album1, Album2
+
+ // Store folder IDs for later verification
+ for _, f := range folders {
+ switch f.Name {
+ case "The Beatles":
+ artistFolderID = f.ID
+ case "Revolver":
+ album1FolderID = f.ID
+ case "Help!":
+ album2FolderID = f.ID
+ }
+ }
+
+ // Verify all tracks exist
+ allTracks, err := ds.MediaFile(ctx).GetAll()
+ Expect(err).ToNot(HaveOccurred())
+ Expect(allTracks).To(HaveLen(4))
+
+ // Store track IDs for later verification
+ for _, t := range allTracks {
+ if t.Album == "Revolver" {
+ album1TrackIDs = append(album1TrackIDs, t.ID)
+ } else if t.Album == "Help!" {
+ album2TrackIDs = append(album2TrackIDs, t.ID)
+ }
+ }
+
+ // Verify no tracks are missing initially
+ for _, t := range allTracks {
+ Expect(t.Missing).To(BeFalse())
+ }
+ })
+
+ It("should mark child folder and its tracks as missing when parent is scanned", func() {
+ // Delete the child folder (Help!) from the filesystem
+ fsys.SetFiles(fstest.MapFS{
+ "The Beatles/Revolver/01 - Taxman.mp3": revolver(storagetest.Track(1, "Taxman")),
+ "The Beatles/Revolver/02 - Eleanor Rigby.mp3": revolver(storagetest.Track(2, "Eleanor Rigby")),
+ // "The Beatles/Help!" folder and its contents are DELETED
+ })
+
+ // Run selective scan on the parent folder (Artist)
+ // This simulates what the watcher does when a child folder is deleted
+ _, err := s.ScanFolders(ctx, false, []model.ScanTarget{
+ {LibraryID: lib.ID, FolderPath: "The Beatles"},
+ })
+ Expect(err).ToNot(HaveOccurred())
+
+ // Verify the deleted child folder is now marked as missing
+ deletedFolder, err := ds.Folder(ctx).Get(album2FolderID)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(deletedFolder.Missing).To(BeTrue(), "Deleted child folder should be marked as missing")
+
+ // Verify the deleted folder's tracks are marked as missing
+ for _, trackID := range album2TrackIDs {
+ track, err := ds.MediaFile(ctx).Get(trackID)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(track.Missing).To(BeTrue(), "Track in deleted folder should be marked as missing")
+ }
+
+ // Verify the parent folder is still present and not marked as missing
+ parentFolder, err := ds.Folder(ctx).Get(artistFolderID)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(parentFolder.Missing).To(BeFalse(), "Parent folder should not be marked as missing")
+
+ // Verify the sibling folder and its tracks are still present and not missing
+ siblingFolder, err := ds.Folder(ctx).Get(album1FolderID)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(siblingFolder.Missing).To(BeFalse(), "Sibling folder should not be marked as missing")
+
+ for _, trackID := range album1TrackIDs {
+ track, err := ds.MediaFile(ctx).Get(trackID)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(track.Missing).To(BeFalse(), "Track in sibling folder should not be marked as missing")
+ }
+ })
+
+ It("should mark deeply nested child folders as missing", func() {
+ // Add a deeply nested folder structure
+ fsys.SetFiles(fstest.MapFS{
+ "The Beatles/Revolver/01 - Taxman.mp3": revolver(storagetest.Track(1, "Taxman")),
+ "The Beatles/Revolver/02 - Eleanor Rigby.mp3": revolver(storagetest.Track(2, "Eleanor Rigby")),
+ "The Beatles/Help!/01 - Help!.mp3": help(storagetest.Track(1, "Help!")),
+ "The Beatles/Help!/02 - The Night Before.mp3": help(storagetest.Track(2, "The Night Before")),
+ "The Beatles/Help!/Bonus/01 - Bonus Track.mp3": help(storagetest.Track(99, "Bonus Track")),
+ "The Beatles/Help!/Bonus/Nested/01 - Deep Track.mp3": help(storagetest.Track(100, "Deep Track")),
+ })
+
+ // Rescan to import the new nested structure
+ _, err := s.ScanAll(ctx, true)
+ Expect(err).ToNot(HaveOccurred())
+
+ // Verify nested folders were created
+ allFolders, err := ds.Folder(ctx).GetAll(model.QueryOptions{Filters: squirrel.Eq{"library_id": lib.ID}})
+ Expect(err).ToNot(HaveOccurred())
+ Expect(len(allFolders)).To(BeNumerically(">", 4), "Should have more folders with nested structure")
+
+ // Now delete the entire Help! folder including nested children
+ fsys.SetFiles(fstest.MapFS{
+ "The Beatles/Revolver/01 - Taxman.mp3": revolver(storagetest.Track(1, "Taxman")),
+ "The Beatles/Revolver/02 - Eleanor Rigby.mp3": revolver(storagetest.Track(2, "Eleanor Rigby")),
+ // All Help! subfolders are deleted
+ })
+
+ // Run selective scan on parent
+ _, err = s.ScanFolders(ctx, false, []model.ScanTarget{
+ {LibraryID: lib.ID, FolderPath: "The Beatles"},
+ })
+ Expect(err).ToNot(HaveOccurred())
+
+ // Verify all Help! folders (including nested ones) are marked as missing
+ missingFolders, err := ds.Folder(ctx).GetAll(model.QueryOptions{
+ Filters: squirrel.And{
+ squirrel.Eq{"library_id": lib.ID},
+ squirrel.Eq{"missing": true},
+ },
+ })
+ Expect(err).ToNot(HaveOccurred())
+ Expect(len(missingFolders)).To(BeNumerically(">", 0), "At least one folder should be marked as missing")
+
+ // Verify all tracks in deleted folders are marked as missing
+ allTracks, err := ds.MediaFile(ctx).GetAll()
+ Expect(err).ToNot(HaveOccurred())
+ Expect(allTracks).To(HaveLen(6))
+
+ for _, track := range allTracks {
+ if track.Album == "Help!" {
+ Expect(track.Missing).To(BeTrue(), "All tracks in deleted Help! folder should be marked as missing")
+ } else if track.Album == "Revolver" {
+ Expect(track.Missing).To(BeFalse(), "Tracks in Revolver folder should not be marked as missing")
+ }
+ }
+ })
+ })
+ })
+})
diff --git a/scanner/scanner_test.go b/scanner/scanner_test.go
index e7e354f21..873065aa3 100644
--- a/scanner/scanner_test.go
+++ b/scanner/scanner_test.go
@@ -34,19 +34,19 @@ type _t = map[string]any
var template = storagetest.Template
var track = storagetest.Track
+func createFS(files fstest.MapFS) storagetest.FakeFS {
+ fs := storagetest.FakeFS{}
+ fs.SetFiles(files)
+ storagetest.Register("fake", &fs)
+ return fs
+}
+
var _ = Describe("Scanner", Ordered, func() {
var ctx context.Context
var lib model.Library
var ds *tests.MockDataStore
var mfRepo *mockMediaFileRepo
- var s scanner.Scanner
-
- createFS := func(files fstest.MapFS) storagetest.FakeFS {
- fs := storagetest.FakeFS{}
- fs.SetFiles(files)
- storagetest.Register("fake", &fs)
- return fs
- }
+ var s model.Scanner
BeforeAll(func() {
ctx = request.WithUser(GinkgoT().Context(), model.User{ID: "123", IsAdmin: true})
@@ -478,6 +478,56 @@ var _ = Describe("Scanner", Ordered, func() {
Expect(mf.Missing).To(BeFalse())
})
+ It("marks tracks as missing when scanning a deleted folder with ScanFolders", func() {
+ By("Adding a third track to Revolver to have more test data")
+ fsys.Add("The Beatles/Revolver/03 - I'm Only Sleeping.mp3", revolver(track(3, "I'm Only Sleeping")))
+ Expect(runScanner(ctx, false)).To(Succeed())
+
+ By("Verifying initial state has 5 tracks")
+ Expect(ds.MediaFile(ctx).CountAll(model.QueryOptions{
+ Filters: squirrel.Eq{"missing": false},
+ })).To(Equal(int64(5)))
+
+ By("Removing the entire Revolver folder from filesystem")
+ fsys.Remove("The Beatles/Revolver/01 - Taxman.mp3")
+ fsys.Remove("The Beatles/Revolver/02 - Eleanor Rigby.mp3")
+ fsys.Remove("The Beatles/Revolver/03 - I'm Only Sleeping.mp3")
+
+ By("Scanning the parent folder (simulating watcher behavior)")
+ targets := []model.ScanTarget{
+ {LibraryID: lib.ID, FolderPath: "The Beatles"},
+ }
+ _, err := s.ScanFolders(ctx, false, targets)
+ Expect(err).To(Succeed())
+
+ By("Checking all Revolver tracks are marked as missing")
+ mf, err := findByPath("The Beatles/Revolver/01 - Taxman.mp3")
+ Expect(err).ToNot(HaveOccurred())
+ Expect(mf.Missing).To(BeTrue())
+
+ mf, err = findByPath("The Beatles/Revolver/02 - Eleanor Rigby.mp3")
+ Expect(err).ToNot(HaveOccurred())
+ Expect(mf.Missing).To(BeTrue())
+
+ mf, err = findByPath("The Beatles/Revolver/03 - I'm Only Sleeping.mp3")
+ Expect(err).ToNot(HaveOccurred())
+ Expect(mf.Missing).To(BeTrue())
+
+ By("Checking the Help! tracks are not affected")
+ mf, err = findByPath("The Beatles/Help!/01 - Help!.mp3")
+ Expect(err).ToNot(HaveOccurred())
+ Expect(mf.Missing).To(BeFalse())
+
+ mf, err = findByPath("The Beatles/Help!/02 - The Night Before.mp3")
+ Expect(err).ToNot(HaveOccurred())
+ Expect(mf.Missing).To(BeFalse())
+
+ By("Verifying only 2 non-missing tracks remain (Help! tracks)")
+ Expect(ds.MediaFile(ctx).CountAll(model.QueryOptions{
+ Filters: squirrel.Eq{"missing": false},
+ })).To(Equal(int64(2)))
+ })
+
It("does not override artist fields when importing an undertagged file", func() {
By("Making sure artist in the DB contains MBID and sort name")
aa, err := ds.Artist(ctx).GetAll(model.QueryOptions{
diff --git a/scanner/walk_dir_tree.go b/scanner/walk_dir_tree.go
index 63854d262..e6a694f2b 100644
--- a/scanner/walk_dir_tree.go
+++ b/scanner/walk_dir_tree.go
@@ -1,7 +1,6 @@
package scanner
import (
- "bufio"
"context"
"io/fs"
"maps"
@@ -11,37 +10,69 @@ import (
"strings"
"github.com/navidrome/navidrome/conf"
- "github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/utils"
- ignore "github.com/sabhiram/go-gitignore"
)
-func walkDirTree(ctx context.Context, job *scanJob) (<-chan *folderEntry, error) {
+// walkDirTree recursively walks the directory tree starting from the given targetFolders.
+// If no targetFolders are provided, it starts from the root folder (".").
+// It returns a channel of folderEntry pointers representing each folder found.
+func walkDirTree(ctx context.Context, job *scanJob, targetFolders ...string) (<-chan *folderEntry, error) {
results := make(chan *folderEntry)
+ folders := targetFolders
+ if len(targetFolders) == 0 {
+ // No specific folders provided, scan the root folder
+ folders = []string{"."}
+ }
go func() {
defer close(results)
- err := walkFolder(ctx, job, ".", nil, results)
- if err != nil {
- log.Error(ctx, "Scanner: There were errors reading directories from filesystem", "path", job.lib.Path, err)
- return
+ for _, folderPath := range folders {
+ if utils.IsCtxDone(ctx) {
+ return
+ }
+
+ // Check if target folder exists before walking it
+ // If it doesn't exist (e.g., deleted between watcher detection and scan execution),
+ // skip it so it remains in job.lastUpdates and gets handled in following steps
+ _, err := fs.Stat(job.fs, folderPath)
+ if err != nil {
+ log.Warn(ctx, "Scanner: Target folder does not exist.", "path", folderPath, err)
+ continue
+ }
+
+ // Create checker and push patterns from root to this folder
+ checker := newIgnoreChecker(job.fs)
+ err = checker.PushAllParents(ctx, folderPath)
+ if err != nil {
+ log.Error(ctx, "Scanner: Error pushing ignore patterns for target folder", "path", folderPath, err)
+ continue
+ }
+
+ // Recursively walk this folder and all its children
+ err = walkFolder(ctx, job, folderPath, checker, results)
+ if err != nil {
+ log.Error(ctx, "Scanner: Error walking target folder", "path", folderPath, err)
+ continue
+ }
}
- log.Debug(ctx, "Scanner: Finished reading folders", "lib", job.lib.Name, "path", job.lib.Path, "numFolders", job.numFolders.Load())
+ log.Debug(ctx, "Scanner: Finished reading target folders", "lib", job.lib.Name, "path", job.lib.Path, "numFolders", job.numFolders.Load())
}()
return results, nil
}
-func walkFolder(ctx context.Context, job *scanJob, currentFolder string, ignorePatterns []string, results chan<- *folderEntry) error {
- ignorePatterns = loadIgnoredPatterns(ctx, job.fs, currentFolder, ignorePatterns)
+func walkFolder(ctx context.Context, job *scanJob, currentFolder string, checker *IgnoreChecker, results chan<- *folderEntry) error {
+ // Push patterns for this folder onto the stack
+ _ = checker.Push(ctx, currentFolder)
+ defer checker.Pop() // Pop patterns when leaving this folder
- folder, children, err := loadDir(ctx, job, currentFolder, ignorePatterns)
+ folder, children, err := loadDir(ctx, job, currentFolder, checker)
if err != nil {
log.Warn(ctx, "Scanner: Error loading dir. Skipping", "path", currentFolder, err)
return nil
}
for _, c := range children {
- err := walkFolder(ctx, job, c, ignorePatterns, results)
+ err := walkFolder(ctx, job, c, checker, results)
if err != nil {
return err
}
@@ -59,50 +90,17 @@ func walkFolder(ctx context.Context, job *scanJob, currentFolder string, ignoreP
return nil
}
-func loadIgnoredPatterns(ctx context.Context, fsys fs.FS, currentFolder string, currentPatterns []string) []string {
- ignoreFilePath := path.Join(currentFolder, consts.ScanIgnoreFile)
- var newPatterns []string
- if _, err := fs.Stat(fsys, ignoreFilePath); err == nil {
- // Read and parse the .ndignore file
- ignoreFile, err := fsys.Open(ignoreFilePath)
- if err != nil {
- log.Warn(ctx, "Scanner: Error opening .ndignore file", "path", ignoreFilePath, err)
- // Continue with previous patterns
- } else {
- defer ignoreFile.Close()
- scanner := bufio.NewScanner(ignoreFile)
- for scanner.Scan() {
- line := scanner.Text()
- if line == "" || strings.HasPrefix(line, "#") {
- continue // Skip empty lines and comments
- }
- newPatterns = append(newPatterns, line)
- }
- if err := scanner.Err(); err != nil {
- log.Warn(ctx, "Scanner: Error reading .ignore file", "path", ignoreFilePath, err)
- }
- }
- // If the .ndignore file is empty, mimic the current behavior and ignore everything
- if len(newPatterns) == 0 {
- log.Trace(ctx, "Scanner: .ndignore file is empty, ignoring everything", "path", currentFolder)
- newPatterns = []string{"**/*"}
- } else {
- log.Trace(ctx, "Scanner: .ndignore file found ", "path", ignoreFilePath, "patterns", newPatterns)
- }
- }
- // Combine the patterns from the .ndignore file with the ones passed as argument
- combinedPatterns := append([]string{}, currentPatterns...)
- return append(combinedPatterns, newPatterns...)
-}
-
-func loadDir(ctx context.Context, job *scanJob, dirPath string, ignorePatterns []string) (folder *folderEntry, children []string, err error) {
- folder = newFolderEntry(job, dirPath)
-
+func loadDir(ctx context.Context, job *scanJob, dirPath string, checker *IgnoreChecker) (folder *folderEntry, children []string, err error) {
+ // Check if directory exists before creating the folder entry
+ // This is important to avoid removing the folder from lastUpdates if it doesn't exist
dirInfo, err := fs.Stat(job.fs, dirPath)
if err != nil {
log.Warn(ctx, "Scanner: Error stating dir", "path", dirPath, err)
return nil, nil, err
}
+
+ // Now that we know the folder exists, create the entry (which removes it from lastUpdates)
+ folder = job.createFolderEntry(dirPath)
folder.modTime = dirInfo.ModTime()
dir, err := job.fs.Open(dirPath)
@@ -117,12 +115,11 @@ func loadDir(ctx context.Context, job *scanJob, dirPath string, ignorePatterns [
return folder, children, err
}
- ignoreMatcher := ignore.CompileIgnoreLines(ignorePatterns...)
entries := fullReadDir(ctx, dirFile)
children = make([]string, 0, len(entries))
for _, entry := range entries {
entryPath := path.Join(dirPath, entry.Name())
- if len(ignorePatterns) > 0 && isScanIgnored(ctx, ignoreMatcher, entryPath) {
+ if checker.ShouldIgnore(ctx, entryPath) {
log.Trace(ctx, "Scanner: Ignoring entry", "path", entryPath)
continue
}
@@ -234,6 +231,7 @@ func isDirReadable(ctx context.Context, fsys fs.FS, dirPath string) bool {
var ignoredDirs = []string{
"$RECYCLE.BIN",
"#snapshot",
+ "@Recycle",
"@Recently-Snapshot",
".streams",
"lost+found",
@@ -254,11 +252,3 @@ func isDirIgnored(name string) bool {
func isEntryIgnored(name string) bool {
return strings.HasPrefix(name, ".") && !strings.HasPrefix(name, "..")
}
-
-func isScanIgnored(ctx context.Context, matcher *ignore.GitIgnore, entryPath string) bool {
- matches := matcher.MatchesPath(entryPath)
- if matches {
- log.Trace(ctx, "Scanner: Ignoring entry matching .ndignore: ", "path", entryPath)
- }
- return matches
-}
diff --git a/scanner/walk_dir_tree_test.go b/scanner/walk_dir_tree_test.go
index c4278ef82..c9add0bd1 100644
--- a/scanner/walk_dir_tree_test.go
+++ b/scanner/walk_dir_tree_test.go
@@ -25,82 +25,196 @@ var _ = Describe("walk_dir_tree", func() {
ctx context.Context
)
- BeforeEach(func() {
- DeferCleanup(configtest.SetupConfig())
- ctx = GinkgoT().Context()
- fsys = &mockMusicFS{
- FS: fstest.MapFS{
- "root/a/.ndignore": {Data: []byte("ignored/*")},
- "root/a/f1.mp3": {},
- "root/a/f2.mp3": {},
- "root/a/ignored/bad.mp3": {},
- "root/b/cover.jpg": {},
- "root/c/f3": {},
- "root/d": {},
- "root/d/.ndignore": {},
- "root/d/f1.mp3": {},
- "root/d/f2.mp3": {},
- "root/d/f3.mp3": {},
- "root/e/original/f1.mp3": {},
- "root/e/symlink": {Mode: fs.ModeSymlink, Data: []byte("root/e/original")},
+ Context("full library", func() {
+ BeforeEach(func() {
+ DeferCleanup(configtest.SetupConfig())
+ ctx = GinkgoT().Context()
+ fsys = &mockMusicFS{
+ FS: fstest.MapFS{
+ "root/a/.ndignore": {Data: []byte("ignored/*")},
+ "root/a/f1.mp3": {},
+ "root/a/f2.mp3": {},
+ "root/a/ignored/bad.mp3": {},
+ "root/b/cover.jpg": {},
+ "root/c/f3": {},
+ "root/d": {},
+ "root/d/.ndignore": {},
+ "root/d/f1.mp3": {},
+ "root/d/f2.mp3": {},
+ "root/d/f3.mp3": {},
+ "root/e/original/f1.mp3": {},
+ "root/e/symlink": {Mode: fs.ModeSymlink, Data: []byte("original")},
+ },
+ }
+ job = &scanJob{
+ fs: fsys,
+ lib: model.Library{Path: "/music"},
+ }
+ })
+
+ // Helper function to call walkDirTree and collect folders from the results channel
+ getFolders := func() map[string]*folderEntry {
+ results, err := walkDirTree(ctx, job)
+ Expect(err).ToNot(HaveOccurred())
+
+ folders := map[string]*folderEntry{}
+ g := errgroup.Group{}
+ g.Go(func() error {
+ for folder := range results {
+ folders[folder.path] = folder
+ }
+ return nil
+ })
+ _ = g.Wait()
+ return folders
+ }
+
+ DescribeTable("symlink handling",
+ func(followSymlinks bool, expectedFolderCount int) {
+ conf.Server.Scanner.FollowSymlinks = followSymlinks
+ folders := getFolders()
+
+ Expect(folders).To(HaveLen(expectedFolderCount + 2)) // +2 for `.` and `root`
+
+ // Basic folder structure checks
+ Expect(folders["root/a"].audioFiles).To(SatisfyAll(
+ HaveLen(2),
+ HaveKey("f1.mp3"),
+ HaveKey("f2.mp3"),
+ ))
+ Expect(folders["root/a"].imageFiles).To(BeEmpty())
+ Expect(folders["root/b"].audioFiles).To(BeEmpty())
+ Expect(folders["root/b"].imageFiles).To(SatisfyAll(
+ HaveLen(1),
+ HaveKey("cover.jpg"),
+ ))
+ Expect(folders["root/c"].audioFiles).To(BeEmpty())
+ Expect(folders["root/c"].imageFiles).To(BeEmpty())
+ Expect(folders).ToNot(HaveKey("root/d"))
+
+ // Symlink specific checks
+ if followSymlinks {
+ Expect(folders["root/e/symlink"].audioFiles).To(HaveLen(1))
+ } else {
+ Expect(folders).ToNot(HaveKey("root/e/symlink"))
+ }
},
- }
- job = &scanJob{
- fs: fsys,
- lib: model.Library{Path: "/music"},
- }
+ Entry("with symlinks enabled", true, 7),
+ Entry("with symlinks disabled", false, 6),
+ )
})
- // Helper function to call walkDirTree and collect folders from the results channel
- getFolders := func() map[string]*folderEntry {
- results, err := walkDirTree(ctx, job)
- Expect(err).ToNot(HaveOccurred())
-
- folders := map[string]*folderEntry{}
- g := errgroup.Group{}
- g.Go(func() error {
- for folder := range results {
- folders[folder.path] = folder
+ Context("with target folders", func() {
+ BeforeEach(func() {
+ DeferCleanup(configtest.SetupConfig())
+ ctx = GinkgoT().Context()
+ fsys = &mockMusicFS{
+ FS: fstest.MapFS{
+ "Artist/Album1/track1.mp3": {},
+ "Artist/Album1/track2.mp3": {},
+ "Artist/Album2/track1.mp3": {},
+ "Artist/Album2/track2.mp3": {},
+ "Artist/Album2/Sub/track3.mp3": {},
+ "OtherArtist/Album3/track1.mp3": {},
+ },
+ }
+ job = &scanJob{
+ fs: fsys,
+ lib: model.Library{Path: "/music"},
}
- return nil
})
- _ = g.Wait()
- return folders
- }
- DescribeTable("symlink handling",
- func(followSymlinks bool, expectedFolderCount int) {
- conf.Server.Scanner.FollowSymlinks = followSymlinks
- folders := getFolders()
+ It("should recursively walk all subdirectories of target folders", func() {
+ results, err := walkDirTree(ctx, job, "Artist")
+ Expect(err).ToNot(HaveOccurred())
- Expect(folders).To(HaveLen(expectedFolderCount + 2)) // +2 for `.` and `root`
+ folders := map[string]*folderEntry{}
+ g := errgroup.Group{}
+ g.Go(func() error {
+ for folder := range results {
+ folders[folder.path] = folder
+ }
+ return nil
+ })
+ _ = g.Wait()
- // Basic folder structure checks
- Expect(folders["root/a"].audioFiles).To(SatisfyAll(
- HaveLen(2),
- HaveKey("f1.mp3"),
- HaveKey("f2.mp3"),
+ // Should include the target folder and all its descendants
+ Expect(folders).To(SatisfyAll(
+ HaveKey("Artist"),
+ HaveKey("Artist/Album1"),
+ HaveKey("Artist/Album2"),
+ HaveKey("Artist/Album2/Sub"),
))
- Expect(folders["root/a"].imageFiles).To(BeEmpty())
- Expect(folders["root/b"].audioFiles).To(BeEmpty())
- Expect(folders["root/b"].imageFiles).To(SatisfyAll(
- HaveLen(1),
- HaveKey("cover.jpg"),
- ))
- Expect(folders["root/c"].audioFiles).To(BeEmpty())
- Expect(folders["root/c"].imageFiles).To(BeEmpty())
- Expect(folders).ToNot(HaveKey("root/d"))
- // Symlink specific checks
- if followSymlinks {
- Expect(folders["root/e/symlink"].audioFiles).To(HaveLen(1))
- } else {
- Expect(folders).ToNot(HaveKey("root/e/symlink"))
+ // Should not include folders outside the target
+ Expect(folders).ToNot(HaveKey("OtherArtist"))
+ Expect(folders).ToNot(HaveKey("OtherArtist/Album3"))
+
+ // Verify audio files are present
+ Expect(folders["Artist/Album1"].audioFiles).To(HaveLen(2))
+ Expect(folders["Artist/Album2"].audioFiles).To(HaveLen(2))
+ Expect(folders["Artist/Album2/Sub"].audioFiles).To(HaveLen(1))
+ })
+
+ It("should handle multiple target folders", func() {
+ results, err := walkDirTree(ctx, job, "Artist/Album1", "OtherArtist")
+ Expect(err).ToNot(HaveOccurred())
+
+ folders := map[string]*folderEntry{}
+ g := errgroup.Group{}
+ g.Go(func() error {
+ for folder := range results {
+ folders[folder.path] = folder
+ }
+ return nil
+ })
+ _ = g.Wait()
+
+ // Should include both target folders and their descendants
+ Expect(folders).To(SatisfyAll(
+ HaveKey("Artist/Album1"),
+ HaveKey("OtherArtist"),
+ HaveKey("OtherArtist/Album3"),
+ ))
+
+ // Should not include other folders
+ Expect(folders).ToNot(HaveKey("Artist"))
+ Expect(folders).ToNot(HaveKey("Artist/Album2"))
+ Expect(folders).ToNot(HaveKey("Artist/Album2/Sub"))
+ })
+
+ It("should skip non-existent target folders and preserve them in lastUpdates", func() {
+ // Setup job with lastUpdates for both existing and non-existing folders
+ job.lastUpdates = map[string]model.FolderUpdateInfo{
+ model.FolderID(job.lib, "Artist/Album1"): {},
+ model.FolderID(job.lib, "NonExistent/DeletedFolder"): {},
+ model.FolderID(job.lib, "OtherArtist/Album3"): {},
}
- },
- Entry("with symlinks enabled", true, 7),
- Entry("with symlinks disabled", false, 6),
- )
+
+ // Try to scan existing folder and non-existing folder
+ results, err := walkDirTree(ctx, job, "Artist/Album1", "NonExistent/DeletedFolder")
+ Expect(err).ToNot(HaveOccurred())
+
+ // Collect results
+ folders := map[string]struct{}{}
+ for folder := range results {
+ folders[folder.path] = struct{}{}
+ }
+
+ // Should only include the existing folder
+ Expect(folders).To(HaveKey("Artist/Album1"))
+ Expect(folders).ToNot(HaveKey("NonExistent/DeletedFolder"))
+
+ // The non-existent folder should still be in lastUpdates (not removed by popLastUpdate)
+ Expect(job.lastUpdates).To(HaveKey(model.FolderID(job.lib, "NonExistent/DeletedFolder")))
+
+ // The existing folder should have been removed from lastUpdates
+ Expect(job.lastUpdates).ToNot(HaveKey(model.FolderID(job.lib, "Artist/Album1")))
+
+ // Folders not in targets should remain in lastUpdates
+ Expect(job.lastUpdates).To(HaveKey(model.FolderID(job.lib, "OtherArtist/Album3")))
+ })
+ })
})
Describe("helper functions", func() {
diff --git a/scanner/watcher.go b/scanner/watcher.go
index 37cfb5e22..3efebaacc 100644
--- a/scanner/watcher.go
+++ b/scanner/watcher.go
@@ -24,9 +24,9 @@ type Watcher interface {
type watcher struct {
mainCtx context.Context
ds model.DataStore
- scanner Scanner
+ scanner model.Scanner
triggerWait time.Duration
- watcherNotify chan model.Library
+ watcherNotify chan scanNotification
libraryWatchers map[int]*libraryWatcherInstance
mu sync.RWMutex
}
@@ -36,14 +36,19 @@ type libraryWatcherInstance struct {
cancel context.CancelFunc
}
+type scanNotification struct {
+ Library *model.Library
+ FolderPath string
+}
+
// GetWatcher returns the watcher singleton
-func GetWatcher(ds model.DataStore, s Scanner) Watcher {
+func GetWatcher(ds model.DataStore, s model.Scanner) Watcher {
return singleton.GetInstance(func() *watcher {
return &watcher{
ds: ds,
scanner: s,
triggerWait: conf.Server.Scanner.WatcherWait,
- watcherNotify: make(chan model.Library, 1),
+ watcherNotify: make(chan scanNotification, 1),
libraryWatchers: make(map[int]*libraryWatcherInstance),
}
})
@@ -68,11 +73,11 @@ func (w *watcher) Run(ctx context.Context) error {
// Main scan triggering loop
trigger := time.NewTimer(w.triggerWait)
trigger.Stop()
- waiting := false
+ targets := make(map[model.ScanTarget]struct{})
for {
select {
case <-trigger.C:
- log.Info("Watcher: Triggering scan")
+ log.Info("Watcher: Triggering scan for changed folders", "numTargets", len(targets))
status, err := w.scanner.Status(ctx)
if err != nil {
log.Error(ctx, "Watcher: Error retrieving Scanner status", err)
@@ -83,9 +88,23 @@ func (w *watcher) Run(ctx context.Context) error {
trigger.Reset(w.triggerWait * 3)
continue
}
- waiting = false
+
+ // Convert targets map to slice
+ targetSlice := make([]model.ScanTarget, 0, len(targets))
+ for target := range targets {
+ targetSlice = append(targetSlice, target)
+ }
+
+ // Clear targets for next batch
+ targets = make(map[model.ScanTarget]struct{})
+
go func() {
- _, err := w.scanner.ScanAll(ctx, false)
+ var err error
+ if conf.Server.DevSelectiveWatcher {
+ _, err = w.scanner.ScanFolders(ctx, false, targetSlice)
+ } else {
+ _, err = w.scanner.ScanAll(ctx, false)
+ }
if err != nil {
log.Error(ctx, "Watcher: Error scanning", err)
} else {
@@ -102,13 +121,22 @@ func (w *watcher) Run(ctx context.Context) error {
w.libraryWatchers = make(map[int]*libraryWatcherInstance)
w.mu.Unlock()
return nil
- case lib := <-w.watcherNotify:
- if !waiting {
- log.Debug(ctx, "Watcher: Detected changes. Waiting for more changes before triggering scan",
- "libraryID", lib.ID, "name", lib.Name, "path", lib.Path)
- waiting = true
- }
+ case notification := <-w.watcherNotify:
+ // Reset the trigger timer for debounce
trigger.Reset(w.triggerWait)
+
+ lib := notification.Library
+ folderPath := notification.FolderPath
+
+ // If already scheduled for scan, skip
+ target := model.ScanTarget{LibraryID: lib.ID, FolderPath: folderPath}
+ if _, exists := targets[target]; exists {
+ continue
+ }
+ targets[target] = struct{}{}
+
+ log.Debug(ctx, "Watcher: Detected changes. Waiting for more changes before triggering scan",
+ "libraryID", lib.ID, "name", lib.Name, "path", lib.Path, "folderPath", folderPath)
}
}
}
@@ -199,13 +227,18 @@ func (w *watcher) watchLibrary(ctx context.Context, lib *model.Library) error {
log.Info(ctx, "Watcher started for library", "libraryID", lib.ID, "name", lib.Name, "path", lib.Path, "absoluteLibPath", absLibPath)
+ return w.processLibraryEvents(ctx, lib, fsys, c, absLibPath)
+}
+
+// processLibraryEvents processes filesystem events for a library.
+func (w *watcher) processLibraryEvents(ctx context.Context, lib *model.Library, fsys storage.MusicFS, events <-chan string, absLibPath string) error {
for {
select {
case <-ctx.Done():
log.Debug(ctx, "Watcher stopped due to context cancellation", "libraryID", lib.ID, "name", lib.Name)
return nil
- case path := <-c:
- path, err = filepath.Rel(absLibPath, path)
+ case path := <-events:
+ path, err := filepath.Rel(absLibPath, path)
if err != nil {
log.Error(ctx, "Error getting relative path", "libraryID", lib.ID, "absolutePath", absLibPath, "path", path, err)
continue
@@ -215,12 +248,27 @@ func (w *watcher) watchLibrary(ctx context.Context, lib *model.Library) error {
log.Trace(ctx, "Ignoring change", "libraryID", lib.ID, "path", path)
continue
}
-
log.Trace(ctx, "Detected change", "libraryID", lib.ID, "path", path, "absoluteLibPath", absLibPath)
+ // Check if the original path (before resolution) matches .ndignore patterns
+ // This is crucial for deleted folders - if a deleted folder matches .ndignore,
+ // we should ignore it BEFORE resolveFolderPath walks up to the parent
+ if w.shouldIgnoreFolderPath(ctx, fsys, path) {
+ log.Debug(ctx, "Ignoring change matching .ndignore pattern", "libraryID", lib.ID, "path", path)
+ continue
+ }
+
+ // Find the folder to scan - validate path exists as directory, walk up if needed
+ folderPath := resolveFolderPath(fsys, path)
+ // Double-check after resolution in case the resolved path is different and also matches patterns
+ if folderPath != path && w.shouldIgnoreFolderPath(ctx, fsys, folderPath) {
+ log.Trace(ctx, "Ignoring change in folder matching .ndignore pattern", "libraryID", lib.ID, "folderPath", folderPath)
+ continue
+ }
+
// Notify the main watcher of changes
select {
- case w.watcherNotify <- *lib:
+ case w.watcherNotify <- scanNotification{Library: lib, FolderPath: folderPath}:
default:
// Channel is full, notification already pending
}
@@ -228,6 +276,47 @@ func (w *watcher) watchLibrary(ctx context.Context, lib *model.Library) error {
}
}
+// resolveFolderPath takes a path (which may be a file or directory) and returns
+// the folder path to scan. If the path is a file, it walks up to find the parent
+// directory. Returns empty string if the path should scan the library root.
+func resolveFolderPath(fsys fs.FS, path string) string {
+ // Handle root paths immediately
+ if path == "." || path == "" {
+ return ""
+ }
+
+ folderPath := path
+ for {
+ info, err := fs.Stat(fsys, folderPath)
+ if err == nil && info.IsDir() {
+ // Found a valid directory
+ return folderPath
+ }
+ if folderPath == "." || folderPath == "" {
+ // Reached root, scan entire library
+ return ""
+ }
+ // Walk up the tree
+ dir, _ := filepath.Split(folderPath)
+ if dir == "" || dir == "." {
+ return ""
+ }
+ // Remove trailing slash
+ folderPath = filepath.Clean(dir)
+ }
+}
+
+// shouldIgnoreFolderPath checks if the given folderPath should be ignored based on .ndignore patterns
+// in the library. It pushes all parent folders onto the IgnoreChecker stack before checking.
+func (w *watcher) shouldIgnoreFolderPath(ctx context.Context, fsys storage.MusicFS, folderPath string) bool {
+ checker := newIgnoreChecker(fsys)
+ err := checker.PushAllParents(ctx, folderPath)
+ if err != nil {
+ log.Warn(ctx, "Watcher: Error pushing ignore patterns for folder", "path", folderPath, err)
+ }
+ return checker.ShouldIgnore(ctx, folderPath)
+}
+
func isIgnoredPath(_ context.Context, _ fs.FS, path string) bool {
baseDir, name := filepath.Split(path)
switch {
diff --git a/scanner/watcher_test.go b/scanner/watcher_test.go
new file mode 100644
index 000000000..01bfb2491
--- /dev/null
+++ b/scanner/watcher_test.go
@@ -0,0 +1,491 @@
+package scanner
+
+import (
+ "context"
+ "io/fs"
+ "path/filepath"
+ "testing/fstest"
+ "time"
+
+ "github.com/navidrome/navidrome/conf"
+ "github.com/navidrome/navidrome/conf/configtest"
+ "github.com/navidrome/navidrome/model"
+ "github.com/navidrome/navidrome/tests"
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+)
+
+var _ = Describe("Watcher", func() {
+ var ctx context.Context
+ var cancel context.CancelFunc
+ var mockScanner *tests.MockScanner
+ var mockDS *tests.MockDataStore
+ var w *watcher
+ var lib *model.Library
+
+ BeforeEach(func() {
+ DeferCleanup(configtest.SetupConfig())
+ conf.Server.Scanner.WatcherWait = 50 * time.Millisecond // Short wait for tests
+
+ ctx, cancel = context.WithCancel(context.Background())
+ DeferCleanup(cancel)
+
+ lib = &model.Library{
+ ID: 1,
+ Name: "Test Library",
+ Path: "/test/library",
+ }
+
+ // Set up mocks
+ mockScanner = tests.NewMockScanner()
+ mockDS = &tests.MockDataStore{}
+ mockLibRepo := &tests.MockLibraryRepo{}
+ mockLibRepo.SetData(model.Libraries{*lib})
+ mockDS.MockedLibrary = mockLibRepo
+
+ // Create a new watcher instance (not singleton) for testing
+ w = &watcher{
+ ds: mockDS,
+ scanner: mockScanner,
+ triggerWait: conf.Server.Scanner.WatcherWait,
+ watcherNotify: make(chan scanNotification, 10),
+ libraryWatchers: make(map[int]*libraryWatcherInstance),
+ mainCtx: ctx,
+ }
+ })
+
+ Describe("Target Collection and Deduplication", func() {
+ BeforeEach(func() {
+ // Start watcher in background
+ go func() {
+ _ = w.Run(ctx)
+ }()
+
+ // Give watcher time to initialize
+ time.Sleep(10 * time.Millisecond)
+ })
+
+ It("creates separate targets for different folders", func() {
+ // Send notifications for different folders
+ w.watcherNotify <- scanNotification{Library: lib, FolderPath: "artist1"}
+ time.Sleep(10 * time.Millisecond)
+ w.watcherNotify <- scanNotification{Library: lib, FolderPath: "artist2"}
+
+ // Wait for watcher to process and trigger scan
+ Eventually(func() int {
+ return mockScanner.GetScanFoldersCallCount()
+ }, 200*time.Millisecond, 10*time.Millisecond).Should(Equal(1))
+
+ // Verify two targets
+ calls := mockScanner.GetScanFoldersCalls()
+ Expect(calls).To(HaveLen(1))
+ Expect(calls[0].Targets).To(HaveLen(2))
+
+ // Extract folder paths
+ folderPaths := make(map[string]bool)
+ for _, target := range calls[0].Targets {
+ Expect(target.LibraryID).To(Equal(1))
+ folderPaths[target.FolderPath] = true
+ }
+ Expect(folderPaths).To(HaveKey("artist1"))
+ Expect(folderPaths).To(HaveKey("artist2"))
+ })
+
+ It("handles different folder paths correctly", func() {
+ // Send notification for nested folder
+ w.watcherNotify <- scanNotification{Library: lib, FolderPath: "artist1/album1"}
+
+ // Wait for watcher to process and trigger scan
+ Eventually(func() int {
+ return mockScanner.GetScanFoldersCallCount()
+ }, 200*time.Millisecond, 10*time.Millisecond).Should(Equal(1))
+
+ // Verify the target
+ calls := mockScanner.GetScanFoldersCalls()
+ Expect(calls).To(HaveLen(1))
+ Expect(calls[0].Targets).To(HaveLen(1))
+ Expect(calls[0].Targets[0].FolderPath).To(Equal("artist1/album1"))
+ })
+
+ It("deduplicates folder and file within same folder", func() {
+ // Send notification for a folder
+ w.watcherNotify <- scanNotification{Library: lib, FolderPath: "artist1/album1"}
+ time.Sleep(10 * time.Millisecond)
+ // Send notification for same folder (as if file change was detected there)
+ // In practice, watchLibrary() would walk up from file path to folder
+ w.watcherNotify <- scanNotification{Library: lib, FolderPath: "artist1/album1"}
+ time.Sleep(10 * time.Millisecond)
+ // Send another for same folder
+ w.watcherNotify <- scanNotification{Library: lib, FolderPath: "artist1/album1"}
+
+ // Wait for watcher to process and trigger scan
+ Eventually(func() int {
+ return mockScanner.GetScanFoldersCallCount()
+ }, 200*time.Millisecond, 10*time.Millisecond).Should(Equal(1))
+
+ // Verify only one target despite multiple file/folder changes
+ calls := mockScanner.GetScanFoldersCalls()
+ Expect(calls).To(HaveLen(1))
+ Expect(calls[0].Targets).To(HaveLen(1))
+ Expect(calls[0].Targets[0].FolderPath).To(Equal("artist1/album1"))
+ })
+ })
+
+ Describe("Timer Behavior", func() {
+ BeforeEach(func() {
+ // Start watcher in background
+ go func() {
+ _ = w.Run(ctx)
+ }()
+
+ // Give watcher time to initialize
+ time.Sleep(10 * time.Millisecond)
+ })
+
+ It("resets timer on each change (debouncing)", func() {
+ // Send first notification
+ w.watcherNotify <- scanNotification{Library: lib, FolderPath: "artist1"}
+
+ // Wait a bit less than half the watcher wait time to ensure timer doesn't fire
+ time.Sleep(20 * time.Millisecond)
+
+ // No scan should have been triggered yet
+ Expect(mockScanner.GetScanFoldersCallCount()).To(Equal(0))
+
+ // Send another notification (resets timer)
+ w.watcherNotify <- scanNotification{Library: lib, FolderPath: "artist1"}
+
+ // Wait a bit less than half the watcher wait time again
+ time.Sleep(20 * time.Millisecond)
+
+ // Still no scan
+ Expect(mockScanner.GetScanFoldersCallCount()).To(Equal(0))
+
+ // Wait for full timer to expire after last notification (plus margin)
+ time.Sleep(60 * time.Millisecond)
+
+ // Now scan should have been triggered
+ Eventually(func() int {
+ return mockScanner.GetScanFoldersCallCount()
+ }, 100*time.Millisecond, 10*time.Millisecond).Should(Equal(1))
+ })
+
+ It("triggers scan after quiet period", func() {
+ // Send notification
+ w.watcherNotify <- scanNotification{Library: lib, FolderPath: "artist1"}
+
+ // No scan immediately
+ Expect(mockScanner.GetScanFoldersCallCount()).To(Equal(0))
+
+ // Wait for quiet period
+ Eventually(func() int {
+ return mockScanner.GetScanFoldersCallCount()
+ }, 200*time.Millisecond, 10*time.Millisecond).Should(Equal(1))
+ })
+ })
+
+ Describe("Empty and Root Paths", func() {
+ BeforeEach(func() {
+ // Start watcher in background
+ go func() {
+ _ = w.Run(ctx)
+ }()
+
+ // Give watcher time to initialize
+ time.Sleep(10 * time.Millisecond)
+ })
+
+ It("handles empty folder path (library root)", func() {
+ // Send notification with empty folder path
+ w.watcherNotify <- scanNotification{Library: lib, FolderPath: ""}
+
+ // Wait for scan
+ Eventually(func() int {
+ return mockScanner.GetScanFoldersCallCount()
+ }, 200*time.Millisecond, 10*time.Millisecond).Should(Equal(1))
+
+ // Should scan the library root
+ calls := mockScanner.GetScanFoldersCalls()
+ Expect(calls).To(HaveLen(1))
+ Expect(calls[0].Targets).To(HaveLen(1))
+ Expect(calls[0].Targets[0].FolderPath).To(Equal(""))
+ })
+
+ It("deduplicates empty and dot paths", func() {
+ // Send notifications with empty and dot paths
+ w.watcherNotify <- scanNotification{Library: lib, FolderPath: ""}
+ time.Sleep(10 * time.Millisecond)
+ w.watcherNotify <- scanNotification{Library: lib, FolderPath: ""}
+
+ // Wait for scan
+ Eventually(func() int {
+ return mockScanner.GetScanFoldersCallCount()
+ }, 200*time.Millisecond, 10*time.Millisecond).Should(Equal(1))
+
+ // Should have only one target
+ calls := mockScanner.GetScanFoldersCalls()
+ Expect(calls).To(HaveLen(1))
+ Expect(calls[0].Targets).To(HaveLen(1))
+ })
+ })
+
+ Describe("Multiple Libraries", func() {
+ var lib2 *model.Library
+
+ BeforeEach(func() {
+ // Create second library
+ lib2 = &model.Library{
+ ID: 2,
+ Name: "Test Library 2",
+ Path: "/test/library2",
+ }
+
+ mockLibRepo := mockDS.MockedLibrary.(*tests.MockLibraryRepo)
+ mockLibRepo.SetData(model.Libraries{*lib, *lib2})
+
+ // Start watcher in background
+ go func() {
+ _ = w.Run(ctx)
+ }()
+
+ // Give watcher time to initialize
+ time.Sleep(10 * time.Millisecond)
+ })
+
+ It("creates separate targets for different libraries", func() {
+ // Send notifications for both libraries
+ w.watcherNotify <- scanNotification{Library: lib, FolderPath: "artist1"}
+ time.Sleep(10 * time.Millisecond)
+ w.watcherNotify <- scanNotification{Library: lib2, FolderPath: "artist2"}
+
+ // Wait for scan
+ Eventually(func() int {
+ return mockScanner.GetScanFoldersCallCount()
+ }, 200*time.Millisecond, 10*time.Millisecond).Should(Equal(1))
+
+ // Verify two targets for different libraries
+ calls := mockScanner.GetScanFoldersCalls()
+ Expect(calls).To(HaveLen(1))
+ Expect(calls[0].Targets).To(HaveLen(2))
+
+ // Verify library IDs are different
+ libraryIDs := make(map[int]bool)
+ for _, target := range calls[0].Targets {
+ libraryIDs[target.LibraryID] = true
+ }
+ Expect(libraryIDs).To(HaveKey(1))
+ Expect(libraryIDs).To(HaveKey(2))
+ })
+ })
+
+ Describe(".ndignore handling", func() {
+ var ctx context.Context
+ var cancel context.CancelFunc
+ var w *watcher
+ var mockFS *mockMusicFS
+ var lib *model.Library
+ var eventChan chan string
+ var absLibPath string
+
+ BeforeEach(func() {
+ ctx, cancel = context.WithCancel(GinkgoT().Context())
+ DeferCleanup(cancel)
+
+ // Set up library
+ var err error
+ absLibPath, err = filepath.Abs(".")
+ Expect(err).NotTo(HaveOccurred())
+
+ lib = &model.Library{
+ ID: 1,
+ Name: "Test Library",
+ Path: absLibPath,
+ }
+
+ // Create watcher with notification channel
+ w = &watcher{
+ watcherNotify: make(chan scanNotification, 10),
+ }
+
+ eventChan = make(chan string, 10)
+ })
+
+ // Helper to send an event - converts relative path to absolute
+ sendEvent := func(relativePath string) {
+ path := filepath.Join(absLibPath, relativePath)
+ eventChan <- path
+ }
+
+ // Helper to start the real event processing loop
+ startEventProcessing := func() {
+ go func() {
+ defer GinkgoRecover()
+ // Call the actual processLibraryEvents method - testing the real implementation!
+ _ = w.processLibraryEvents(ctx, lib, mockFS, eventChan, absLibPath)
+ }()
+ }
+
+ Context("when a folder matching .ndignore is deleted", func() {
+ BeforeEach(func() {
+ // Create filesystem with .ndignore containing _TEMP pattern
+ // The deleted folder (_TEMP) will NOT exist in the filesystem
+ mockFS = &mockMusicFS{
+ FS: fstest.MapFS{
+ "rock": &fstest.MapFile{Mode: fs.ModeDir},
+ "rock/.ndignore": &fstest.MapFile{Data: []byte("_TEMP\n")},
+ "rock/valid_album": &fstest.MapFile{Mode: fs.ModeDir},
+ "rock/valid_album/track.mp3": &fstest.MapFile{Data: []byte("audio")},
+ },
+ }
+ })
+
+ It("should NOT send scan notification when deleted folder matches .ndignore", func() {
+ startEventProcessing()
+
+ // Simulate deletion event for rock/_TEMP
+ sendEvent("rock/_TEMP")
+
+ // Wait a bit to ensure event is processed
+ time.Sleep(50 * time.Millisecond)
+
+ // No notification should have been sent
+ Consistently(eventChan, 100*time.Millisecond).Should(BeEmpty())
+ })
+
+ It("should send scan notification for valid folder deletion", func() {
+ startEventProcessing()
+
+ // Simulate deletion event for rock/other_folder (not in .ndignore and doesn't exist)
+ // Since it doesn't exist in mockFS, resolveFolderPath will walk up to "rock"
+ sendEvent("rock/other_folder")
+
+ // Should receive notification for parent folder
+ Eventually(w.watcherNotify, 200*time.Millisecond).Should(Receive(Equal(scanNotification{
+ Library: lib,
+ FolderPath: "rock",
+ })))
+ })
+ })
+
+ Context("with nested folder patterns", func() {
+ BeforeEach(func() {
+ mockFS = &mockMusicFS{
+ FS: fstest.MapFS{
+ "music": &fstest.MapFile{Mode: fs.ModeDir},
+ "music/.ndignore": &fstest.MapFile{Data: []byte("**/temp\n**/cache\n")},
+ "music/rock": &fstest.MapFile{Mode: fs.ModeDir},
+ "music/rock/artist": &fstest.MapFile{Mode: fs.ModeDir},
+ },
+ }
+ })
+
+ It("should NOT send notification when nested ignored folder is deleted", func() {
+ startEventProcessing()
+
+ // Simulate deletion of music/rock/artist/temp (matches **/temp)
+ sendEvent("music/rock/artist/temp")
+
+ // Wait to ensure event is processed
+ time.Sleep(50 * time.Millisecond)
+
+ // No notification should be sent
+ Expect(w.watcherNotify).To(BeEmpty(), "Expected no scan notification for nested ignored folder")
+ })
+
+ It("should send notification for non-ignored nested folder", func() {
+ startEventProcessing()
+
+ // Simulate change in music/rock/artist (doesn't match any pattern)
+ sendEvent("music/rock/artist")
+
+ // Should receive notification
+ Eventually(w.watcherNotify, 200*time.Millisecond).Should(Receive(Equal(scanNotification{
+ Library: lib,
+ FolderPath: "music/rock/artist",
+ })))
+ })
+ })
+
+ Context("with file events in ignored folders", func() {
+ BeforeEach(func() {
+ mockFS = &mockMusicFS{
+ FS: fstest.MapFS{
+ "rock": &fstest.MapFile{Mode: fs.ModeDir},
+ "rock/.ndignore": &fstest.MapFile{Data: []byte("_TEMP\n")},
+ },
+ }
+ })
+
+ It("should NOT send notification for file changes in ignored folders", func() {
+ startEventProcessing()
+
+ // Simulate file change in rock/_TEMP/file.mp3
+ sendEvent("rock/_TEMP/file.mp3")
+
+ // Wait to ensure event is processed
+ time.Sleep(50 * time.Millisecond)
+
+ // No notification should be sent
+ Expect(w.watcherNotify).To(BeEmpty(), "Expected no scan notification for file in ignored folder")
+ })
+ })
+ })
+})
+
+var _ = Describe("resolveFolderPath", func() {
+ var mockFS fs.FS
+
+ BeforeEach(func() {
+ // Create a mock filesystem with some directories and files
+ mockFS = fstest.MapFS{
+ "artist1": &fstest.MapFile{Mode: fs.ModeDir},
+ "artist1/album1": &fstest.MapFile{Mode: fs.ModeDir},
+ "artist1/album1/track1.mp3": &fstest.MapFile{Data: []byte("audio")},
+ "artist1/album1/track2.mp3": &fstest.MapFile{Data: []byte("audio")},
+ "artist1/album2": &fstest.MapFile{Mode: fs.ModeDir},
+ "artist1/album2/song.flac": &fstest.MapFile{Data: []byte("audio")},
+ "artist2": &fstest.MapFile{Mode: fs.ModeDir},
+ "artist2/cover.jpg": &fstest.MapFile{Data: []byte("image")},
+ }
+ })
+
+ It("returns directory path when given a directory", func() {
+ result := resolveFolderPath(mockFS, "artist1/album1")
+ Expect(result).To(Equal("artist1/album1"))
+ })
+
+ It("walks up to parent directory when given a file path", func() {
+ result := resolveFolderPath(mockFS, "artist1/album1/track1.mp3")
+ Expect(result).To(Equal("artist1/album1"))
+ })
+
+ It("walks up multiple levels if needed", func() {
+ result := resolveFolderPath(mockFS, "artist1/album1/nonexistent/file.mp3")
+ Expect(result).To(Equal("artist1/album1"))
+ })
+
+ It("returns empty string for non-existent paths at root", func() {
+ result := resolveFolderPath(mockFS, "nonexistent/path/file.mp3")
+ Expect(result).To(Equal(""))
+ })
+
+ It("returns empty string for dot path", func() {
+ result := resolveFolderPath(mockFS, ".")
+ Expect(result).To(Equal(""))
+ })
+
+ It("returns empty string for empty path", func() {
+ result := resolveFolderPath(mockFS, "")
+ Expect(result).To(Equal(""))
+ })
+
+ It("handles nested file paths correctly", func() {
+ result := resolveFolderPath(mockFS, "artist1/album2/song.flac")
+ Expect(result).To(Equal("artist1/album2"))
+ })
+
+ It("resolves to top-level directory", func() {
+ result := resolveFolderPath(mockFS, "artist2/cover.jpg")
+ Expect(result).To(Equal("artist2"))
+ })
+})
diff --git a/server/auth.go b/server/auth.go
index ed43974dd..8588549ab 100644
--- a/server/auth.go
+++ b/server/auth.go
@@ -193,24 +193,24 @@ func UsernameFromToken(r *http.Request) string {
return token.Subject()
}
-func UsernameFromReverseProxyHeader(r *http.Request) string {
- if conf.Server.ReverseProxyWhitelist == "" {
+func UsernameFromExtAuthHeader(r *http.Request) string {
+ if conf.Server.ExtAuth.TrustedSources == "" {
return ""
}
reverseProxyIp, ok := request.ReverseProxyIpFrom(r.Context())
if !ok {
- log.Error("ReverseProxyWhitelist enabled but no proxy IP found in request context. Please report this error.")
+ log.Error("ExtAuth enabled but no proxy IP found in request context. Please report this error.")
return ""
}
- if !validateIPAgainstList(reverseProxyIp, conf.Server.ReverseProxyWhitelist) {
- log.Warn(r.Context(), "IP is not whitelisted for reverse proxy login", "proxy-ip", reverseProxyIp, "client-ip", r.RemoteAddr)
+ if !validateIPAgainstList(reverseProxyIp, conf.Server.ExtAuth.TrustedSources) {
+ log.Warn(r.Context(), "IP is not whitelisted for external authentication", "proxy-ip", reverseProxyIp, "client-ip", r.RemoteAddr)
return ""
}
- username := r.Header.Get(conf.Server.ReverseProxyUserHeader)
+ username := r.Header.Get(conf.Server.ExtAuth.UserHeader)
if username == "" {
return ""
}
- log.Trace(r, "Found username in ReverseProxyUserHeader", "username", username)
+ log.Trace(r, "Found username in ExtAuth.UserHeader", "username", username)
return username
}
@@ -256,7 +256,7 @@ func authenticateRequest(ds model.DataStore, r *http.Request, findUsernameFns ..
func Authenticator(ds model.DataStore) func(next http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- ctx, err := authenticateRequest(ds, r, UsernameFromConfig, UsernameFromToken, UsernameFromReverseProxyHeader)
+ ctx, err := authenticateRequest(ds, r, UsernameFromConfig, UsernameFromToken, UsernameFromExtAuthHeader)
if err != nil {
_ = rest.RespondWithError(w, http.StatusUnauthorized, "Not authenticated")
return
@@ -291,7 +291,7 @@ func JWTRefresher(next http.Handler) http.Handler {
func handleLoginFromHeaders(ds model.DataStore, r *http.Request) map[string]interface{} {
username := UsernameFromConfig(r)
if username == "" {
- username = UsernameFromReverseProxyHeader(r)
+ username = UsernameFromExtAuthHeader(r)
if username == "" {
return nil
}
diff --git a/server/auth_test.go b/server/auth_test.go
index 06ca2ea39..633299096 100644
--- a/server/auth_test.go
+++ b/server/auth_test.go
@@ -80,7 +80,7 @@ var _ = Describe("Auth", func() {
req.Header.Add("Remote-User", "janedoe")
resp = httptest.NewRecorder()
conf.Server.UILoginBackgroundURL = ""
- conf.Server.ReverseProxyWhitelist = "192.168.0.0/16,2001:4860:4860::/48"
+ conf.Server.ExtAuth.TrustedSources = "192.168.0.0/16,2001:4860:4860::/48"
})
It("sets auth data if IPv4 matches whitelist", func() {
@@ -155,7 +155,7 @@ var _ = Describe("Auth", func() {
It("does not set auth data when listening on unix socket without whitelist", func() {
conf.Server.Address = "unix:/tmp/navidrome-test"
- conf.Server.ReverseProxyWhitelist = ""
+ conf.Server.ExtAuth.TrustedSources = ""
// No ReverseProxyIp in request context
serveIndex(ds, fs, nil)(resp, req)
@@ -176,7 +176,7 @@ var _ = Describe("Auth", func() {
It("sets auth data when listening on unix socket with correct whitelist", func() {
conf.Server.Address = "unix:/tmp/navidrome-test"
- conf.Server.ReverseProxyWhitelist = conf.Server.ReverseProxyWhitelist + ",@"
+ conf.Server.ExtAuth.TrustedSources = conf.Server.ExtAuth.TrustedSources + ",@"
req = req.WithContext(request.WithReverseProxyIp(req.Context(), "@"))
serveIndex(ds, fs, nil)(resp, req)
@@ -302,8 +302,8 @@ var _ = Describe("Auth", func() {
ds = &tests.MockDataStore{}
req = httptest.NewRequest("GET", "/", nil)
req = req.WithContext(request.WithReverseProxyIp(req.Context(), trustedIP))
- conf.Server.ReverseProxyWhitelist = "192.168.0.0/16"
- conf.Server.ReverseProxyUserHeader = "Remote-User"
+ conf.Server.ExtAuth.TrustedSources = "192.168.0.0/16"
+ conf.Server.ExtAuth.UserHeader = "Remote-User"
})
It("makes the first user an admin", func() {
diff --git a/server/middlewares.go b/server/middlewares.go
index 2afe09a5a..21f897931 100644
--- a/server/middlewares.go
+++ b/server/middlewares.go
@@ -168,7 +168,7 @@ func clientUniqueIDMiddleware(next http.Handler) http.Handler {
// realIPMiddleware applies middleware.RealIP, and additionally saves the request's original RemoteAddr to the request's
// context if navidrome is behind a trusted reverse proxy.
func realIPMiddleware(next http.Handler) http.Handler {
- if conf.Server.ReverseProxyWhitelist != "" {
+ if conf.Server.ExtAuth.TrustedSources != "" {
return chi.Chain(
reqToCtx(request.ReverseProxyIp, func(r *http.Request) any { return r.RemoteAddr }),
middleware.RealIP,
diff --git a/server/nativeapi/config_test.go b/server/nativeapi/config_test.go
index 60f7c3394..d9c722955 100644
--- a/server/nativeapi/config_test.go
+++ b/server/nativeapi/config_test.go
@@ -29,7 +29,7 @@ var _ = Describe("Config API", func() {
conf.Server.DevUIShowConfig = true // Enable config endpoint for tests
ds = &tests.MockDataStore{}
auth.Init(ds)
- nativeRouter := New(ds, nil, nil, nil, core.NewMockLibraryService())
+ nativeRouter := New(ds, nil, nil, nil, core.NewMockLibraryService(), nil)
router = server.JWTVerifier(nativeRouter)
// Create test users
diff --git a/server/nativeapi/library.go b/server/nativeapi/library.go
index f081eca78..1636e1dbb 100644
--- a/server/nativeapi/library.go
+++ b/server/nativeapi/library.go
@@ -13,11 +13,11 @@ import (
)
// User-library association endpoints (admin only)
-func (n *Router) addUserLibraryRoute(r chi.Router) {
+func (api *Router) addUserLibraryRoute(r chi.Router) {
r.Route("/user/{id}/library", func(r chi.Router) {
r.Use(parseUserIDMiddleware)
- r.Get("/", getUserLibraries(n.libs))
- r.Put("/", setUserLibraries(n.libs))
+ r.Get("/", getUserLibraries(api.libs))
+ r.Put("/", setUserLibraries(api.libs))
})
}
diff --git a/server/nativeapi/library_test.go b/server/nativeapi/library_test.go
index 4e6d34582..950338492 100644
--- a/server/nativeapi/library_test.go
+++ b/server/nativeapi/library_test.go
@@ -30,7 +30,7 @@ var _ = Describe("Library API", func() {
DeferCleanup(configtest.SetupConfig())
ds = &tests.MockDataStore{}
auth.Init(ds)
- nativeRouter := New(ds, nil, nil, nil, core.NewMockLibraryService())
+ nativeRouter := New(ds, nil, nil, nil, core.NewMockLibraryService(), nil)
router = server.JWTVerifier(nativeRouter)
// Create test users
diff --git a/server/nativeapi/missing.go b/server/nativeapi/missing.go
index 0d311f492..2b455e622 100644
--- a/server/nativeapi/missing.go
+++ b/server/nativeapi/missing.go
@@ -8,9 +8,9 @@ import (
"github.com/Masterminds/squirrel"
"github.com/deluan/rest"
+ "github.com/navidrome/navidrome/core"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
- "github.com/navidrome/navidrome/model/request"
"github.com/navidrome/navidrome/utils/req"
)
@@ -63,45 +63,32 @@ func (r *missingRepository) EntityName() string {
return "missing_files"
}
-func deleteMissingFiles(ds model.DataStore, w http.ResponseWriter, r *http.Request) {
- ctx := r.Context()
- p := req.Params(r)
- ids, _ := p.Strings("id")
- err := ds.WithTx(func(tx model.DataStore) error {
+func deleteMissingFiles(maintenance core.Maintenance) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ ctx := r.Context()
+
+ p := req.Params(r)
+ ids, _ := p.Strings("id")
+
+ var err error
if len(ids) == 0 {
- _, err := tx.MediaFile(ctx).DeleteAllMissing()
- return err
- }
- return tx.MediaFile(ctx).DeleteMissing(ids)
- })
- if len(ids) == 1 && errors.Is(err, model.ErrNotFound) {
- log.Warn(ctx, "Missing file not found", "id", ids[0])
- http.Error(w, "not found", http.StatusNotFound)
- return
- }
- if err != nil {
- log.Error(ctx, "Error deleting missing tracks from DB", "ids", ids, err)
- http.Error(w, err.Error(), http.StatusInternalServerError)
- return
- }
- err = ds.GC(ctx)
- if err != nil {
- log.Error(ctx, "Error running GC after deleting missing tracks", err)
- http.Error(w, err.Error(), http.StatusInternalServerError)
- return
- }
-
- // Refresh artist stats in background after deleting missing files
- go func() {
- bgCtx := request.AddValues(context.Background(), r.Context())
- if _, err := ds.Artist(bgCtx).RefreshStats(true); err != nil {
- log.Error(bgCtx, "Error refreshing artist stats after deleting missing files", err)
+ err = maintenance.DeleteAllMissingFiles(ctx)
} else {
- log.Debug(bgCtx, "Successfully refreshed artist stats after deleting missing files")
+ err = maintenance.DeleteMissingFiles(ctx, ids)
}
- }()
- writeDeleteManyResponse(w, r, ids)
+ if len(ids) == 1 && errors.Is(err, model.ErrNotFound) {
+ log.Warn(ctx, "Missing file not found", "id", ids[0])
+ http.Error(w, "not found", http.StatusNotFound)
+ return
+ }
+ if err != nil {
+ http.Error(w, "failed to delete missing files", http.StatusInternalServerError)
+ return
+ }
+
+ writeDeleteManyResponse(w, r, ids)
+ }
}
var _ model.ResourceRepository = &missingRepository{}
diff --git a/server/nativeapi/native_api.go b/server/nativeapi/native_api.go
index 370bdbd1e..969650e0a 100644
--- a/server/nativeapi/native_api.go
+++ b/server/nativeapi/native_api.go
@@ -22,70 +22,71 @@ import (
type Router struct {
http.Handler
- ds model.DataStore
- share core.Share
- playlists core.Playlists
- insights metrics.Insights
- libs core.Library
+ ds model.DataStore
+ share core.Share
+ playlists core.Playlists
+ insights metrics.Insights
+ libs core.Library
+ maintenance core.Maintenance
}
-func New(ds model.DataStore, share core.Share, playlists core.Playlists, insights metrics.Insights, libraryService core.Library) *Router {
- r := &Router{ds: ds, share: share, playlists: playlists, insights: insights, libs: libraryService}
+func New(ds model.DataStore, share core.Share, playlists core.Playlists, insights metrics.Insights, libraryService core.Library, maintenance core.Maintenance) *Router {
+ r := &Router{ds: ds, share: share, playlists: playlists, insights: insights, libs: libraryService, maintenance: maintenance}
r.Handler = r.routes()
return r
}
-func (n *Router) routes() http.Handler {
+func (api *Router) routes() http.Handler {
r := chi.NewRouter()
// Public
- n.RX(r, "/translation", newTranslationRepository, false)
+ api.RX(r, "/translation", newTranslationRepository, false)
// Protected
r.Group(func(r chi.Router) {
- r.Use(server.Authenticator(n.ds))
+ r.Use(server.Authenticator(api.ds))
r.Use(server.JWTRefresher)
- r.Use(server.UpdateLastAccessMiddleware(n.ds))
- n.R(r, "/user", model.User{}, true)
- n.R(r, "/song", model.MediaFile{}, false)
- n.R(r, "/album", model.Album{}, false)
- n.R(r, "/artist", model.Artist{}, false)
- n.R(r, "/genre", model.Genre{}, false)
- n.R(r, "/player", model.Player{}, true)
- n.R(r, "/transcoding", model.Transcoding{}, conf.Server.EnableTranscodingConfig)
- n.R(r, "/radio", model.Radio{}, true)
- n.R(r, "/tag", model.Tag{}, true)
+ r.Use(server.UpdateLastAccessMiddleware(api.ds))
+ api.R(r, "/user", model.User{}, true)
+ api.R(r, "/song", model.MediaFile{}, false)
+ api.R(r, "/album", model.Album{}, false)
+ api.R(r, "/artist", model.Artist{}, false)
+ api.R(r, "/genre", model.Genre{}, false)
+ api.R(r, "/player", model.Player{}, true)
+ api.R(r, "/transcoding", model.Transcoding{}, conf.Server.EnableTranscodingConfig)
+ api.R(r, "/radio", model.Radio{}, true)
+ api.R(r, "/tag", model.Tag{}, true)
if conf.Server.EnableSharing {
- n.RX(r, "/share", n.share.NewRepository, true)
+ api.RX(r, "/share", api.share.NewRepository, true)
}
- n.addPlaylistRoute(r)
- n.addPlaylistTrackRoute(r)
- n.addSongPlaylistsRoute(r)
- n.addQueueRoute(r)
- n.addMissingFilesRoute(r)
- n.addKeepAliveRoute(r)
- n.addInsightsRoute(r)
+ api.addPlaylistRoute(r)
+ api.addPlaylistTrackRoute(r)
+ api.addSongPlaylistsRoute(r)
+ api.addQueueRoute(r)
+ api.addMissingFilesRoute(r)
+ api.addKeepAliveRoute(r)
+ api.addInsightsRoute(r)
r.With(adminOnlyMiddleware).Group(func(r chi.Router) {
- n.addInspectRoute(r)
- n.addConfigRoute(r)
- n.addUserLibraryRoute(r)
- n.RX(r, "/library", n.libs.NewRepository, true)
+ api.addInspectRoute(r)
+ api.addConfigRoute(r)
+ api.addUserLibraryRoute(r)
+ api.RX(r, "/library", api.libs.NewRepository, true)
})
})
return r
}
-func (n *Router) R(r chi.Router, pathPrefix string, model interface{}, persistable bool) {
+func (api *Router) R(r chi.Router, pathPrefix string, model interface{}, persistable bool) {
constructor := func(ctx context.Context) rest.Repository {
- return n.ds.Resource(ctx, model)
+ return api.ds.Resource(ctx, model)
}
- n.RX(r, pathPrefix, constructor, persistable)
+ api.RX(r, pathPrefix, constructor, persistable)
}
-func (n *Router) RX(r chi.Router, pathPrefix string, constructor rest.RepositoryConstructor, persistable bool) {
+func (api *Router) RX(r chi.Router, pathPrefix string, constructor rest.RepositoryConstructor, persistable bool) {
r.Route(pathPrefix, func(r chi.Router) {
r.Get("/", rest.GetAll(constructor))
if persistable {
@@ -102,9 +103,9 @@ func (n *Router) RX(r chi.Router, pathPrefix string, constructor rest.Repository
})
}
-func (n *Router) addPlaylistRoute(r chi.Router) {
+func (api *Router) addPlaylistRoute(r chi.Router) {
constructor := func(ctx context.Context) rest.Repository {
- return n.ds.Resource(ctx, model.Playlist{})
+ return api.ds.Resource(ctx, model.Playlist{})
}
r.Route("/playlist", func(r chi.Router) {
@@ -114,7 +115,7 @@ func (n *Router) addPlaylistRoute(r chi.Router) {
rest.Post(constructor)(w, r)
return
}
- createPlaylistFromM3U(n.playlists)(w, r)
+ createPlaylistFromM3U(api.playlists)(w, r)
})
r.Route("/{id}", func(r chi.Router) {
@@ -126,55 +127,53 @@ func (n *Router) addPlaylistRoute(r chi.Router) {
})
}
-func (n *Router) addPlaylistTrackRoute(r chi.Router) {
+func (api *Router) addPlaylistTrackRoute(r chi.Router) {
r.Route("/playlist/{playlistId}/tracks", func(r chi.Router) {
r.Get("/", func(w http.ResponseWriter, r *http.Request) {
- getPlaylist(n.ds)(w, r)
+ getPlaylist(api.ds)(w, r)
})
r.With(server.URLParamsMiddleware).Route("/", func(r chi.Router) {
r.Delete("/", func(w http.ResponseWriter, r *http.Request) {
- deleteFromPlaylist(n.ds)(w, r)
+ deleteFromPlaylist(api.ds)(w, r)
})
r.Post("/", func(w http.ResponseWriter, r *http.Request) {
- addToPlaylist(n.ds)(w, r)
+ addToPlaylist(api.ds)(w, r)
})
})
r.Route("/{id}", func(r chi.Router) {
r.Use(server.URLParamsMiddleware)
r.Get("/", func(w http.ResponseWriter, r *http.Request) {
- getPlaylistTrack(n.ds)(w, r)
+ getPlaylistTrack(api.ds)(w, r)
})
r.Put("/", func(w http.ResponseWriter, r *http.Request) {
- reorderItem(n.ds)(w, r)
+ reorderItem(api.ds)(w, r)
})
r.Delete("/", func(w http.ResponseWriter, r *http.Request) {
- deleteFromPlaylist(n.ds)(w, r)
+ deleteFromPlaylist(api.ds)(w, r)
})
})
})
}
-func (n *Router) addSongPlaylistsRoute(r chi.Router) {
+func (api *Router) addSongPlaylistsRoute(r chi.Router) {
r.With(server.URLParamsMiddleware).Get("/song/{id}/playlists", func(w http.ResponseWriter, r *http.Request) {
- getSongPlaylists(n.ds)(w, r)
+ getSongPlaylists(api.ds)(w, r)
})
}
-func (n *Router) addQueueRoute(r chi.Router) {
+func (api *Router) addQueueRoute(r chi.Router) {
r.Route("/queue", func(r chi.Router) {
- r.Get("/", getQueue(n.ds))
- r.Post("/", saveQueue(n.ds))
- r.Put("/", updateQueue(n.ds))
- r.Delete("/", clearQueue(n.ds))
+ r.Get("/", getQueue(api.ds))
+ r.Post("/", saveQueue(api.ds))
+ r.Put("/", updateQueue(api.ds))
+ r.Delete("/", clearQueue(api.ds))
})
}
-func (n *Router) addMissingFilesRoute(r chi.Router) {
+func (api *Router) addMissingFilesRoute(r chi.Router) {
r.Route("/missing", func(r chi.Router) {
- n.RX(r, "/", newMissingRepository(n.ds), false)
- r.Delete("/", func(w http.ResponseWriter, r *http.Request) {
- deleteMissingFiles(n.ds, w, r)
- })
+ api.RX(r, "/", newMissingRepository(api.ds), false)
+ r.Delete("/", deleteMissingFiles(api.maintenance))
})
}
@@ -198,7 +197,7 @@ func writeDeleteManyResponse(w http.ResponseWriter, r *http.Request, ids []strin
}
}
-func (n *Router) addInspectRoute(r chi.Router) {
+func (api *Router) addInspectRoute(r chi.Router) {
if conf.Server.Inspect.Enabled {
r.Group(func(r chi.Router) {
if conf.Server.Inspect.MaxRequests > 0 {
@@ -207,26 +206,26 @@ func (n *Router) addInspectRoute(r chi.Router) {
conf.Server.Inspect.BacklogTimeout)
r.Use(middleware.ThrottleBacklog(conf.Server.Inspect.MaxRequests, conf.Server.Inspect.BacklogLimit, time.Duration(conf.Server.Inspect.BacklogTimeout)))
}
- r.Get("/inspect", inspect(n.ds))
+ r.Get("/inspect", inspect(api.ds))
})
}
}
-func (n *Router) addConfigRoute(r chi.Router) {
+func (api *Router) addConfigRoute(r chi.Router) {
if conf.Server.DevUIShowConfig {
r.Get("/config/*", getConfig)
}
}
-func (n *Router) addKeepAliveRoute(r chi.Router) {
+func (api *Router) addKeepAliveRoute(r chi.Router) {
r.Get("/keepalive/*", func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(`{"response":"ok", "id":"keepalive"}`))
})
}
-func (n *Router) addInsightsRoute(r chi.Router) {
+func (api *Router) addInsightsRoute(r chi.Router) {
r.Get("/insights/*", func(w http.ResponseWriter, r *http.Request) {
- last, success := n.insights.LastRun(r.Context())
+ last, success := api.insights.LastRun(r.Context())
if conf.Server.EnableInsightsCollector {
_, _ = w.Write([]byte(`{"id":"insights_status", "lastRun":"` + last.Format("2006-01-02 15:04:05") + `", "success":` + strconv.FormatBool(success) + `}`))
} else {
diff --git a/server/nativeapi/native_api_song_test.go b/server/nativeapi/native_api_song_test.go
index d7209a164..b52042643 100644
--- a/server/nativeapi/native_api_song_test.go
+++ b/server/nativeapi/native_api_song_test.go
@@ -95,7 +95,7 @@ var _ = Describe("Song Endpoints", func() {
mfRepo.SetData(testSongs)
// Create the native API router and wrap it with the JWTVerifier middleware
- nativeRouter := New(ds, nil, nil, nil, core.NewMockLibraryService())
+ nativeRouter := New(ds, nil, nil, nil, core.NewMockLibraryService(), nil)
router = server.JWTVerifier(nativeRouter)
w = httptest.NewRecorder()
})
diff --git a/server/server.go b/server/server.go
index 49391e2b6..39475a225 100644
--- a/server/server.go
+++ b/server/server.go
@@ -1,8 +1,11 @@
package server
import (
+ "bytes"
"cmp"
"context"
+ "crypto/tls"
+ "encoding/pem"
"errors"
"fmt"
"net"
@@ -69,6 +72,13 @@ func (s *Server) Run(ctx context.Context, addr string, port int, tlsCert string,
// Determine if TLS is enabled
tlsEnabled := tlsCert != "" && tlsKey != ""
+ // Validate TLS certificates before starting the server
+ if tlsEnabled {
+ if err := validateTLSCertificates(tlsCert, tlsKey); err != nil {
+ return err
+ }
+ }
+
// Create a listener based on the address type (either Unix socket or TCP)
var listener net.Listener
var err error
@@ -89,17 +99,17 @@ func (s *Server) Run(ctx context.Context, addr string, port int, tlsCert string,
// Start the server in a new goroutine and send an error signal to errC if there's an error
errC := make(chan error)
go func() {
+ var err error
if tlsEnabled {
// Start the HTTPS server
log.Info("Starting server with TLS (HTTPS) enabled", "tlsCert", tlsCert, "tlsKey", tlsKey)
- if err := server.ServeTLS(listener, tlsCert, tlsKey); !errors.Is(err, http.ErrServerClosed) {
- errC <- err
- }
+ err = server.ServeTLS(listener, tlsCert, tlsKey)
} else {
// Start the HTTP server
- if err := server.Serve(listener); !errors.Is(err, http.ErrServerClosed) {
- errC <- err
- }
+ err = server.Serve(listener)
+ }
+ if !errors.Is(err, http.ErrServerClosed) {
+ errC <- err
}
}()
@@ -249,3 +259,56 @@ func AbsoluteURL(r *http.Request, u string, params url.Values) string {
}
return buildUrl.String()
}
+
+// validateTLSCertificates validates the TLS certificate and key files before starting the server.
+// It provides detailed error messages for common issues like encrypted private keys.
+func validateTLSCertificates(certFile, keyFile string) error {
+ // Read the key file to check for encryption
+ keyData, err := os.ReadFile(keyFile)
+ if err != nil {
+ return fmt.Errorf("reading TLS key file: %w", err)
+ }
+
+ // Parse PEM blocks and check for encryption
+ block, _ := pem.Decode(keyData)
+ if block == nil {
+ return errors.New("TLS key file does not contain a valid PEM block")
+ }
+
+ // Check for encrypted private key indicators
+ if isEncryptedPEM(block, keyData) {
+ return errors.New("TLS private key is encrypted (password-protected). " +
+ "Navidrome does not support encrypted private keys. " +
+ "Please decrypt your key using: openssl pkey -in -out ")
+ }
+
+ // Try to load the certificate pair to validate it
+ _, err = tls.LoadX509KeyPair(certFile, keyFile)
+ if err != nil {
+ return fmt.Errorf("loading TLS certificate/key pair: %w", err)
+ }
+
+ return nil
+}
+
+// isEncryptedPEM checks if a PEM block represents an encrypted private key.
+func isEncryptedPEM(block *pem.Block, rawData []byte) bool {
+ // Check for PKCS#8 encrypted format (BEGIN ENCRYPTED PRIVATE KEY)
+ if block.Type == "ENCRYPTED PRIVATE KEY" {
+ return true
+ }
+
+ // Check for legacy encrypted format with Proc-Type header
+ if block.Headers != nil {
+ if procType, ok := block.Headers["Proc-Type"]; ok && strings.Contains(procType, "ENCRYPTED") {
+ return true
+ }
+ }
+
+ // Also check raw data for DEK-Info header (in case pem.Decode doesn't parse headers correctly)
+ if bytes.Contains(rawData, []byte("DEK-Info:")) || bytes.Contains(rawData, []byte("Proc-Type: 4,ENCRYPTED")) {
+ return true
+ }
+
+ return false
+}
diff --git a/server/server_test.go b/server/server_test.go
index f9a43a802..5ca03bf7e 100644
--- a/server/server_test.go
+++ b/server/server_test.go
@@ -1,13 +1,20 @@
package server
import (
+ "context"
+ "crypto/tls"
+ "crypto/x509"
+ "fmt"
"io/fs"
"net/http"
"net/url"
"os"
"path/filepath"
+ "time"
"github.com/navidrome/navidrome/conf"
+ "github.com/navidrome/navidrome/conf/configtest"
+ "github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
@@ -107,3 +114,146 @@ var _ = Describe("createUnixSocketFile", func() {
})
})
})
+
+var _ = Describe("TLS support", func() {
+ Describe("validateTLSCertificates", func() {
+ const testDataDir = "server/testdata"
+
+ When("certificate and key are valid and unencrypted", func() {
+ It("returns nil", func() {
+ certFile := filepath.Join(testDataDir, "test_cert.pem")
+ keyFile := filepath.Join(testDataDir, "test_key.pem")
+ err := validateTLSCertificates(certFile, keyFile)
+ Expect(err).ToNot(HaveOccurred())
+ })
+ })
+
+ When("private key is encrypted with PKCS#8 format", func() {
+ It("returns an error with helpful message", func() {
+ certFile := filepath.Join(testDataDir, "test_cert_encrypted.pem")
+ keyFile := filepath.Join(testDataDir, "test_key_encrypted.pem")
+ err := validateTLSCertificates(certFile, keyFile)
+ Expect(err).To(HaveOccurred())
+ Expect(err.Error()).To(ContainSubstring("encrypted"))
+ Expect(err.Error()).To(ContainSubstring("openssl"))
+ })
+ })
+
+ When("private key is encrypted with legacy format (Proc-Type header)", func() {
+ It("returns an error with helpful message", func() {
+ certFile := filepath.Join(testDataDir, "test_cert.pem")
+ keyFile := filepath.Join(testDataDir, "test_key_encrypted_legacy.pem")
+ err := validateTLSCertificates(certFile, keyFile)
+ Expect(err).To(HaveOccurred())
+ Expect(err.Error()).To(ContainSubstring("encrypted"))
+ Expect(err.Error()).To(ContainSubstring("openssl"))
+ })
+ })
+
+ When("key file does not exist", func() {
+ It("returns an error", func() {
+ certFile := filepath.Join(testDataDir, "test_cert.pem")
+ keyFile := filepath.Join(testDataDir, "nonexistent.pem")
+ err := validateTLSCertificates(certFile, keyFile)
+ Expect(err).To(HaveOccurred())
+ Expect(err.Error()).To(ContainSubstring("reading TLS key file"))
+ })
+ })
+
+ When("key file does not contain valid PEM", func() {
+ It("returns an error", func() {
+ // Create a temp file with invalid PEM content
+ tmpFile, err := os.CreateTemp("", "invalid_key*.pem")
+ Expect(err).ToNot(HaveOccurred())
+ DeferCleanup(func() {
+ _ = os.Remove(tmpFile.Name())
+ })
+ _, err = tmpFile.WriteString("not a valid PEM file")
+ Expect(err).ToNot(HaveOccurred())
+ _ = tmpFile.Close()
+
+ certFile := filepath.Join(testDataDir, "test_cert.pem")
+ err = validateTLSCertificates(certFile, tmpFile.Name())
+ Expect(err).To(HaveOccurred())
+ Expect(err.Error()).To(ContainSubstring("valid PEM block"))
+ })
+ })
+
+ When("certificate file does not exist", func() {
+ It("returns an error from tls.LoadX509KeyPair", func() {
+ certFile := filepath.Join(testDataDir, "nonexistent_cert.pem")
+ keyFile := filepath.Join(testDataDir, "test_key.pem")
+ err := validateTLSCertificates(certFile, keyFile)
+ Expect(err).To(HaveOccurred())
+ Expect(err.Error()).To(ContainSubstring("loading TLS certificate/key pair"))
+ })
+ })
+ })
+
+ Describe("Server TLS", func() {
+ const testDataDir = "server/testdata"
+
+ When("server is started with valid TLS certificates", func() {
+ It("accepts HTTPS connections", func() {
+ DeferCleanup(configtest.SetupConfig())
+
+ // Create server with mock dependencies
+ ds := &tests.MockDataStore{}
+ server := New(ds, nil, nil)
+
+ // Load the test certificate to create a trusted CA pool
+ certFile := filepath.Join(testDataDir, "test_cert.pem")
+ keyFile := filepath.Join(testDataDir, "test_key.pem")
+ caCert, err := os.ReadFile(certFile)
+ Expect(err).ToNot(HaveOccurred())
+
+ caCertPool := x509.NewCertPool()
+ caCertPool.AppendCertsFromPEM(caCert)
+
+ // Create an HTTPS client that trusts our test certificate
+ httpClient := &http.Client{
+ Timeout: 5 * time.Second,
+ Transport: &http.Transport{
+ TLSClientConfig: &tls.Config{
+ RootCAs: caCertPool,
+ MinVersion: tls.VersionTLS12,
+ },
+ },
+ }
+
+ // Start the server in a goroutine
+ ctx, cancel := context.WithCancel(GinkgoT().Context())
+ defer cancel()
+
+ errChan := make(chan error, 1)
+ go func() {
+ errChan <- server.Run(ctx, "127.0.0.1", 14534, certFile, keyFile)
+ }()
+
+ Eventually(func() error {
+ // Make an HTTPS request to the server
+ resp, err := httpClient.Get("https://127.0.0.1:14534/ping")
+ if err != nil {
+ return err
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode != http.StatusOK {
+ return fmt.Errorf("unexpected status code: %d", resp.StatusCode)
+ }
+ return nil
+ }, 2*time.Second, 100*time.Millisecond).Should(Succeed())
+
+ // Stop the server
+ cancel()
+
+ // Wait for server to stop (with timeout)
+ select {
+ case <-errChan:
+ // Server stopped
+ case <-time.After(2 * time.Second):
+ Fail("Server did not stop in time")
+ }
+ })
+ })
+ })
+})
diff --git a/server/subsonic/api.go b/server/subsonic/api.go
index bb3d20e5c..f0e73c3d2 100644
--- a/server/subsonic/api.go
+++ b/server/subsonic/api.go
@@ -18,7 +18,6 @@ import (
"github.com/navidrome/navidrome/core/scrobbler"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
- "github.com/navidrome/navidrome/scanner"
"github.com/navidrome/navidrome/server"
"github.com/navidrome/navidrome/server/events"
"github.com/navidrome/navidrome/server/subsonic/responses"
@@ -39,7 +38,7 @@ type Router struct {
players core.Players
provider external.Provider
playlists core.Playlists
- scanner scanner.Scanner
+ scanner model.Scanner
broker events.Broker
scrobbler scrobbler.PlayTracker
share core.Share
@@ -48,7 +47,7 @@ type Router struct {
}
func New(ds model.DataStore, artwork artwork.Artwork, streamer core.MediaStreamer, archiver core.Archiver,
- players core.Players, provider external.Provider, scanner scanner.Scanner, broker events.Broker,
+ players core.Players, provider external.Provider, scanner model.Scanner, broker events.Broker,
playlists core.Playlists, scrobbler scrobbler.PlayTracker, share core.Share, playback playback.PlaybackServer,
metrics metrics.Metrics,
) *Router {
@@ -148,7 +147,9 @@ func (api *Router) routes() http.Handler {
h(r, "createBookmark", api.CreateBookmark)
h(r, "deleteBookmark", api.DeleteBookmark)
h(r, "getPlayQueue", api.GetPlayQueue)
+ h(r, "getPlayQueueByIndex", api.GetPlayQueueByIndex)
h(r, "savePlayQueue", api.SavePlayQueue)
+ h(r, "savePlayQueueByIndex", api.SavePlayQueueByIndex)
})
r.Group(func(r chi.Router) {
r.Use(getPlayer(api.players))
diff --git a/server/subsonic/bookmarks.go b/server/subsonic/bookmarks.go
index d7286c20c..b1e71b1c7 100644
--- a/server/subsonic/bookmarks.go
+++ b/server/subsonic/bookmarks.go
@@ -91,7 +91,7 @@ func (api *Router) GetPlayQueue(r *http.Request) (*responses.Subsonic, error) {
Current: currentID,
Position: pq.Position,
Username: user.UserName,
- Changed: &pq.UpdatedAt,
+ Changed: pq.UpdatedAt,
ChangedBy: pq.ChangedBy,
}
return response, nil
@@ -135,3 +135,74 @@ func (api *Router) SavePlayQueue(r *http.Request) (*responses.Subsonic, error) {
}
return newResponse(), nil
}
+
+func (api *Router) GetPlayQueueByIndex(r *http.Request) (*responses.Subsonic, error) {
+ user, _ := request.UserFrom(r.Context())
+
+ repo := api.ds.PlayQueue(r.Context())
+ pq, err := repo.RetrieveWithMediaFiles(user.ID)
+ if err != nil && !errors.Is(err, model.ErrNotFound) {
+ return nil, err
+ }
+ if pq == nil || len(pq.Items) == 0 {
+ return newResponse(), nil
+ }
+
+ response := newResponse()
+
+ var index *int
+ if len(pq.Items) > 0 {
+ index = &pq.Current
+ }
+
+ response.PlayQueueByIndex = &responses.PlayQueueByIndex{
+ Entry: slice.MapWithArg(pq.Items, r.Context(), childFromMediaFile),
+ CurrentIndex: index,
+ Position: pq.Position,
+ Username: user.UserName,
+ Changed: pq.UpdatedAt,
+ ChangedBy: pq.ChangedBy,
+ }
+ return response, nil
+}
+
+func (api *Router) SavePlayQueueByIndex(r *http.Request) (*responses.Subsonic, error) {
+ p := req.Params(r)
+ ids, _ := p.Strings("id")
+
+ position := p.Int64Or("position", 0)
+
+ var err error
+ var currentIndex int
+
+ if len(ids) > 0 {
+ currentIndex, err = p.Int("currentIndex")
+ if err != nil || currentIndex < 0 || currentIndex >= len(ids) {
+ return nil, newError(responses.ErrorMissingParameter, "missing parameter index, err: %s", err)
+ }
+ }
+
+ items := slice.Map(ids, func(id string) model.MediaFile {
+ return model.MediaFile{ID: id}
+ })
+
+ user, _ := request.UserFrom(r.Context())
+ client, _ := request.ClientFrom(r.Context())
+
+ pq := &model.PlayQueue{
+ UserID: user.ID,
+ Current: currentIndex,
+ Position: position,
+ ChangedBy: client,
+ Items: items,
+ CreatedAt: time.Time{},
+ UpdatedAt: time.Time{},
+ }
+
+ repo := api.ds.PlayQueue(r.Context())
+ err = repo.Store(pq)
+ if err != nil {
+ return nil, err
+ }
+ return newResponse(), nil
+}
diff --git a/server/subsonic/library_scanning.go b/server/subsonic/library_scanning.go
index b6ccb9ae6..c9dd64968 100644
--- a/server/subsonic/library_scanning.go
+++ b/server/subsonic/library_scanning.go
@@ -1,10 +1,13 @@
package subsonic
import (
+ "fmt"
"net/http"
+ "slices"
"time"
"github.com/navidrome/navidrome/log"
+ "github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/request"
"github.com/navidrome/navidrome/server/subsonic/responses"
"github.com/navidrome/navidrome/utils/req"
@@ -44,15 +47,56 @@ func (api *Router) StartScan(r *http.Request) (*responses.Subsonic, error) {
p := req.Params(r)
fullScan := p.BoolOr("fullScan", false)
+ // Parse optional target parameters for selective scanning
+ var targets []model.ScanTarget
+ if targetParams, err := p.Strings("target"); err == nil && len(targetParams) > 0 {
+ targets, err = model.ParseTargets(targetParams)
+ if err != nil {
+ return nil, newError(responses.ErrorGeneric, fmt.Sprintf("Invalid target parameter: %v", err))
+ }
+
+ // Validate all libraries in targets exist and user has access to them
+ userLibraries, err := api.ds.User(ctx).GetUserLibraries(loggedUser.ID)
+ if err != nil {
+ return nil, newError(responses.ErrorGeneric, "Internal error")
+ }
+
+ // Check each target library
+ for _, target := range targets {
+ if !slices.ContainsFunc(userLibraries, func(lib model.Library) bool { return lib.ID == target.LibraryID }) {
+ return nil, newError(responses.ErrorDataNotFound, fmt.Sprintf("Library with ID %d not found", target.LibraryID))
+ }
+ }
+
+ // Special case: if single library with empty path and it's the only library in DB, call ScanAll
+ if len(targets) == 1 && targets[0].FolderPath == "" {
+ allLibs, err := api.ds.Library(ctx).GetAll()
+ if err != nil {
+ return nil, newError(responses.ErrorGeneric, "Internal error")
+ }
+ if len(allLibs) == 1 {
+ targets = nil // This will trigger ScanAll below
+ }
+ }
+ }
+
go func() {
start := time.Now()
- log.Info(ctx, "Triggering manual scan", "fullScan", fullScan, "user", loggedUser.UserName)
- _, err := api.scanner.ScanAll(ctx, fullScan)
+ var err error
+
+ if len(targets) > 0 {
+ log.Info(ctx, "Triggering on-demand scan", "fullScan", fullScan, "targets", len(targets), "user", loggedUser.UserName)
+ _, err = api.scanner.ScanFolders(ctx, fullScan, targets)
+ } else {
+ log.Info(ctx, "Triggering on-demand scan", "fullScan", fullScan, "user", loggedUser.UserName)
+ _, err = api.scanner.ScanAll(ctx, fullScan)
+ }
+
if err != nil {
log.Error(ctx, "Error scanning", err)
return
}
- log.Info(ctx, "Manual scan complete", "user", loggedUser.UserName, "elapsed", time.Since(start))
+ log.Info(ctx, "On-demand scan complete", "user", loggedUser.UserName, "elapsed", time.Since(start))
}()
return api.GetScanStatus(r)
diff --git a/server/subsonic/library_scanning_test.go b/server/subsonic/library_scanning_test.go
new file mode 100644
index 000000000..d8eba296b
--- /dev/null
+++ b/server/subsonic/library_scanning_test.go
@@ -0,0 +1,396 @@
+package subsonic
+
+import (
+ "context"
+ "errors"
+ "net/http/httptest"
+
+ "github.com/navidrome/navidrome/model"
+ "github.com/navidrome/navidrome/model/request"
+ "github.com/navidrome/navidrome/server/subsonic/responses"
+ "github.com/navidrome/navidrome/tests"
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+)
+
+var _ = Describe("LibraryScanning", func() {
+ var api *Router
+ var ms *tests.MockScanner
+
+ BeforeEach(func() {
+ ms = tests.NewMockScanner()
+ api = &Router{scanner: ms}
+ })
+
+ Describe("StartScan", func() {
+ It("requires admin authentication", func() {
+ // Create non-admin user
+ ctx := request.WithUser(context.Background(), model.User{
+ ID: "user-id",
+ IsAdmin: false,
+ })
+
+ // Create request
+ r := httptest.NewRequest("GET", "/rest/startScan", nil)
+ r = r.WithContext(ctx)
+
+ // Call endpoint
+ response, err := api.StartScan(r)
+
+ // Should return authorization error
+ Expect(err).To(HaveOccurred())
+ Expect(response).To(BeNil())
+ var subErr subError
+ ok := errors.As(err, &subErr)
+ Expect(ok).To(BeTrue())
+ Expect(subErr.code).To(Equal(responses.ErrorAuthorizationFail))
+ })
+
+ It("triggers a full scan with no parameters", func() {
+ // Create admin user
+ ctx := request.WithUser(context.Background(), model.User{
+ ID: "admin-id",
+ IsAdmin: true,
+ })
+
+ // Create request with no parameters
+ r := httptest.NewRequest("GET", "/rest/startScan", nil)
+ r = r.WithContext(ctx)
+
+ // Call endpoint
+ response, err := api.StartScan(r)
+
+ // Should succeed
+ Expect(err).ToNot(HaveOccurred())
+ Expect(response).ToNot(BeNil())
+
+ // Verify ScanAll was called (eventually, since it's in a goroutine)
+ Eventually(func() int {
+ return ms.GetScanAllCallCount()
+ }).Should(BeNumerically(">", 0))
+ calls := ms.GetScanAllCalls()
+ Expect(calls).To(HaveLen(1))
+ Expect(calls[0].FullScan).To(BeFalse())
+ })
+
+ It("triggers a full scan with fullScan=true", func() {
+ // Create admin user
+ ctx := request.WithUser(context.Background(), model.User{
+ ID: "admin-id",
+ IsAdmin: true,
+ })
+
+ // Create request with fullScan parameter
+ r := httptest.NewRequest("GET", "/rest/startScan?fullScan=true", nil)
+ r = r.WithContext(ctx)
+
+ // Call endpoint
+ response, err := api.StartScan(r)
+
+ // Should succeed
+ Expect(err).ToNot(HaveOccurred())
+ Expect(response).ToNot(BeNil())
+
+ // Verify ScanAll was called with fullScan=true
+ Eventually(func() int {
+ return ms.GetScanAllCallCount()
+ }).Should(BeNumerically(">", 0))
+ calls := ms.GetScanAllCalls()
+ Expect(calls).To(HaveLen(1))
+ Expect(calls[0].FullScan).To(BeTrue())
+ })
+
+ It("triggers a selective scan with single target parameter", func() {
+ // Setup mocks
+ mockUserRepo := tests.CreateMockUserRepo()
+ _ = mockUserRepo.SetUserLibraries("admin-id", []int{1, 2})
+ mockDS := &tests.MockDataStore{MockedUser: mockUserRepo}
+ api.ds = mockDS
+
+ // Create admin user
+ ctx := request.WithUser(context.Background(), model.User{
+ ID: "admin-id",
+ IsAdmin: true,
+ })
+
+ // Create request with single target parameter
+ r := httptest.NewRequest("GET", "/rest/startScan?target=1:Music/Rock", nil)
+ r = r.WithContext(ctx)
+
+ // Call endpoint
+ response, err := api.StartScan(r)
+
+ // Should succeed
+ Expect(err).ToNot(HaveOccurred())
+ Expect(response).ToNot(BeNil())
+
+ // Verify ScanFolders was called with correct targets
+ Eventually(func() int {
+ return ms.GetScanFoldersCallCount()
+ }).Should(BeNumerically(">", 0))
+ calls := ms.GetScanFoldersCalls()
+ Expect(calls).To(HaveLen(1))
+ targets := calls[0].Targets
+ Expect(targets).To(HaveLen(1))
+ Expect(targets[0].LibraryID).To(Equal(1))
+ Expect(targets[0].FolderPath).To(Equal("Music/Rock"))
+ })
+
+ It("triggers a selective scan with multiple target parameters", func() {
+ // Setup mocks
+ mockUserRepo := tests.CreateMockUserRepo()
+ _ = mockUserRepo.SetUserLibraries("admin-id", []int{1, 2})
+ mockDS := &tests.MockDataStore{MockedUser: mockUserRepo}
+ api.ds = mockDS
+
+ // Create admin user
+ ctx := request.WithUser(context.Background(), model.User{
+ ID: "admin-id",
+ IsAdmin: true,
+ })
+
+ // Create request with multiple target parameters
+ r := httptest.NewRequest("GET", "/rest/startScan?target=1:Music/Reggae&target=2:Classical/Bach", nil)
+ r = r.WithContext(ctx)
+
+ // Call endpoint
+ response, err := api.StartScan(r)
+
+ // Should succeed
+ Expect(err).ToNot(HaveOccurred())
+ Expect(response).ToNot(BeNil())
+
+ // Verify ScanFolders was called with correct targets
+ Eventually(func() int {
+ return ms.GetScanFoldersCallCount()
+ }).Should(BeNumerically(">", 0))
+ calls := ms.GetScanFoldersCalls()
+ Expect(calls).To(HaveLen(1))
+ targets := calls[0].Targets
+ Expect(targets).To(HaveLen(2))
+ Expect(targets[0].LibraryID).To(Equal(1))
+ Expect(targets[0].FolderPath).To(Equal("Music/Reggae"))
+ Expect(targets[1].LibraryID).To(Equal(2))
+ Expect(targets[1].FolderPath).To(Equal("Classical/Bach"))
+ })
+
+ It("triggers a selective full scan with target and fullScan parameters", func() {
+ // Setup mocks
+ mockUserRepo := tests.CreateMockUserRepo()
+ _ = mockUserRepo.SetUserLibraries("admin-id", []int{1})
+ mockDS := &tests.MockDataStore{MockedUser: mockUserRepo}
+ api.ds = mockDS
+
+ // Create admin user
+ ctx := request.WithUser(context.Background(), model.User{
+ ID: "admin-id",
+ IsAdmin: true,
+ })
+
+ // Create request with target and fullScan parameters
+ r := httptest.NewRequest("GET", "/rest/startScan?target=1:Music/Jazz&fullScan=true", nil)
+ r = r.WithContext(ctx)
+
+ // Call endpoint
+ response, err := api.StartScan(r)
+
+ // Should succeed
+ Expect(err).ToNot(HaveOccurred())
+ Expect(response).ToNot(BeNil())
+
+ // Verify ScanFolders was called with fullScan=true
+ Eventually(func() int {
+ return ms.GetScanFoldersCallCount()
+ }).Should(BeNumerically(">", 0))
+ calls := ms.GetScanFoldersCalls()
+ Expect(calls).To(HaveLen(1))
+ Expect(calls[0].FullScan).To(BeTrue())
+ targets := calls[0].Targets
+ Expect(targets).To(HaveLen(1))
+ })
+
+ It("returns error for invalid target format", func() {
+ // Create admin user
+ ctx := request.WithUser(context.Background(), model.User{
+ ID: "admin-id",
+ IsAdmin: true,
+ })
+
+ // Create request with invalid target format (missing colon)
+ r := httptest.NewRequest("GET", "/rest/startScan?target=1MusicRock", nil)
+ r = r.WithContext(ctx)
+
+ // Call endpoint
+ response, err := api.StartScan(r)
+
+ // Should return error
+ Expect(err).To(HaveOccurred())
+ Expect(response).To(BeNil())
+ var subErr subError
+ ok := errors.As(err, &subErr)
+ Expect(ok).To(BeTrue())
+ Expect(subErr.code).To(Equal(responses.ErrorGeneric))
+ })
+
+ It("returns error for invalid library ID in target", func() {
+ // Create admin user
+ ctx := request.WithUser(context.Background(), model.User{
+ ID: "admin-id",
+ IsAdmin: true,
+ })
+
+ // Create request with invalid library ID
+ r := httptest.NewRequest("GET", "/rest/startScan?target=0:Music/Rock", nil)
+ r = r.WithContext(ctx)
+
+ // Call endpoint
+ response, err := api.StartScan(r)
+
+ // Should return error
+ Expect(err).To(HaveOccurred())
+ Expect(response).To(BeNil())
+ var subErr subError
+ ok := errors.As(err, &subErr)
+ Expect(ok).To(BeTrue())
+ Expect(subErr.code).To(Equal(responses.ErrorGeneric))
+ })
+
+ It("returns error when library does not exist", func() {
+ // Setup mocks - user has access to library 1 and 2 only
+ mockUserRepo := tests.CreateMockUserRepo()
+ _ = mockUserRepo.SetUserLibraries("admin-id", []int{1, 2})
+ mockDS := &tests.MockDataStore{MockedUser: mockUserRepo}
+ api.ds = mockDS
+
+ // Create admin user
+ ctx := request.WithUser(context.Background(), model.User{
+ ID: "admin-id",
+ IsAdmin: true,
+ })
+
+ // Create request with library ID that doesn't exist
+ r := httptest.NewRequest("GET", "/rest/startScan?target=999:Music/Rock", nil)
+ r = r.WithContext(ctx)
+
+ // Call endpoint
+ response, err := api.StartScan(r)
+
+ // Should return ErrorDataNotFound
+ Expect(err).To(HaveOccurred())
+ Expect(response).To(BeNil())
+ var subErr subError
+ ok := errors.As(err, &subErr)
+ Expect(ok).To(BeTrue())
+ Expect(subErr.code).To(Equal(responses.ErrorDataNotFound))
+ })
+
+ It("calls ScanAll when single library with empty path and only one library exists", func() {
+ // Setup mocks - single library in DB
+ mockUserRepo := tests.CreateMockUserRepo()
+ _ = mockUserRepo.SetUserLibraries("admin-id", []int{1})
+ mockLibraryRepo := &tests.MockLibraryRepo{}
+ mockLibraryRepo.SetData(model.Libraries{
+ {ID: 1, Name: "Music Library", Path: "/music"},
+ })
+ mockDS := &tests.MockDataStore{
+ MockedUser: mockUserRepo,
+ MockedLibrary: mockLibraryRepo,
+ }
+ api.ds = mockDS
+
+ // Create admin user
+ ctx := request.WithUser(context.Background(), model.User{
+ ID: "admin-id",
+ IsAdmin: true,
+ })
+
+ // Create request with single library and empty path
+ r := httptest.NewRequest("GET", "/rest/startScan?target=1:", nil)
+ r = r.WithContext(ctx)
+
+ // Call endpoint
+ response, err := api.StartScan(r)
+
+ // Should succeed
+ Expect(err).ToNot(HaveOccurred())
+ Expect(response).ToNot(BeNil())
+
+ // Verify ScanAll was called instead of ScanFolders
+ Eventually(func() int {
+ return ms.GetScanAllCallCount()
+ }).Should(BeNumerically(">", 0))
+ Expect(ms.GetScanFoldersCallCount()).To(Equal(0))
+ })
+
+ It("calls ScanFolders when single library with empty path but multiple libraries exist", func() {
+ // Setup mocks - multiple libraries in DB
+ mockUserRepo := tests.CreateMockUserRepo()
+ _ = mockUserRepo.SetUserLibraries("admin-id", []int{1, 2})
+ mockLibraryRepo := &tests.MockLibraryRepo{}
+ mockLibraryRepo.SetData(model.Libraries{
+ {ID: 1, Name: "Music Library", Path: "/music"},
+ {ID: 2, Name: "Audiobooks", Path: "/audiobooks"},
+ })
+ mockDS := &tests.MockDataStore{
+ MockedUser: mockUserRepo,
+ MockedLibrary: mockLibraryRepo,
+ }
+ api.ds = mockDS
+
+ // Create admin user
+ ctx := request.WithUser(context.Background(), model.User{
+ ID: "admin-id",
+ IsAdmin: true,
+ })
+
+ // Create request with single library and empty path
+ r := httptest.NewRequest("GET", "/rest/startScan?target=1:", nil)
+ r = r.WithContext(ctx)
+
+ // Call endpoint
+ response, err := api.StartScan(r)
+
+ // Should succeed
+ Expect(err).ToNot(HaveOccurred())
+ Expect(response).ToNot(BeNil())
+
+ // Verify ScanFolders was called (not ScanAll)
+ Eventually(func() int {
+ return ms.GetScanFoldersCallCount()
+ }).Should(BeNumerically(">", 0))
+ calls := ms.GetScanFoldersCalls()
+ Expect(calls).To(HaveLen(1))
+ targets := calls[0].Targets
+ Expect(targets).To(HaveLen(1))
+ Expect(targets[0].LibraryID).To(Equal(1))
+ Expect(targets[0].FolderPath).To(Equal(""))
+ })
+ })
+
+ Describe("GetScanStatus", func() {
+ It("returns scan status", func() {
+ // Setup mock scanner status
+ ms.SetStatusResponse(&model.ScannerStatus{
+ Scanning: false,
+ Count: 100,
+ FolderCount: 10,
+ })
+
+ // Create request
+ ctx := context.Background()
+ r := httptest.NewRequest("GET", "/rest/getScanStatus", nil)
+ r = r.WithContext(ctx)
+
+ // Call endpoint
+ response, err := api.GetScanStatus(r)
+
+ // Should succeed
+ Expect(err).ToNot(HaveOccurred())
+ Expect(response).ToNot(BeNil())
+ Expect(response.ScanStatus).ToNot(BeNil())
+ Expect(response.ScanStatus.Scanning).To(BeFalse())
+ Expect(response.ScanStatus.Count).To(Equal(int64(100)))
+ Expect(response.ScanStatus.FolderCount).To(Equal(int64(10)))
+ })
+ })
+})
diff --git a/server/subsonic/middlewares.go b/server/subsonic/middlewares.go
index af1ba448f..d984bac42 100644
--- a/server/subsonic/middlewares.go
+++ b/server/subsonic/middlewares.go
@@ -56,7 +56,7 @@ func fromInternalOrProxyAuth(r *http.Request) (string, bool) {
return username, true
}
- return server.UsernameFromReverseProxyHeader(r), false
+ return server.UsernameFromExtAuthHeader(r), false
}
func checkRequiredParameters(next http.Handler) http.Handler {
diff --git a/server/subsonic/middlewares_test.go b/server/subsonic/middlewares_test.go
index a30d5b3af..aba14a0aa 100644
--- a/server/subsonic/middlewares_test.go
+++ b/server/subsonic/middlewares_test.go
@@ -95,8 +95,8 @@ var _ = Describe("Middlewares", func() {
})
It("passes when all required params are available (reverse-proxy case)", func() {
- conf.Server.ReverseProxyWhitelist = "127.0.0.234/32"
- conf.Server.ReverseProxyUserHeader = "Remote-User"
+ conf.Server.ExtAuth.TrustedSources = "127.0.0.234/32"
+ conf.Server.ExtAuth.UserHeader = "Remote-User"
r := newGetRequest("v=1.15", "c=test")
r.Header.Add("Remote-User", "user")
@@ -254,8 +254,8 @@ var _ = Describe("Middlewares", func() {
When("using reverse proxy authentication", func() {
BeforeEach(func() {
DeferCleanup(configtest.SetupConfig())
- conf.Server.ReverseProxyWhitelist = "192.168.1.1/24"
- conf.Server.ReverseProxyUserHeader = "Remote-User"
+ conf.Server.ExtAuth.TrustedSources = "192.168.1.1/24"
+ conf.Server.ExtAuth.UserHeader = "Remote-User"
})
It("passes authentication with correct IP and header", func() {
diff --git a/server/subsonic/opensubsonic.go b/server/subsonic/opensubsonic.go
index 17ce3c2b0..a364651c5 100644
--- a/server/subsonic/opensubsonic.go
+++ b/server/subsonic/opensubsonic.go
@@ -12,6 +12,7 @@ func (api *Router) GetOpenSubsonicExtensions(_ *http.Request) (*responses.Subson
{Name: "transcodeOffset", Versions: []int32{1}},
{Name: "formPost", Versions: []int32{1}},
{Name: "songLyrics", Versions: []int32{1}},
+ {Name: "indexBasedQueue", Versions: []int32{1}},
}
return response, nil
}
diff --git a/server/subsonic/opensubsonic_test.go b/server/subsonic/opensubsonic_test.go
index 3cc680afe..58dca682c 100644
--- a/server/subsonic/opensubsonic_test.go
+++ b/server/subsonic/opensubsonic_test.go
@@ -35,10 +35,11 @@ var _ = Describe("GetOpenSubsonicExtensions", func() {
err := json.Unmarshal(w.Body.Bytes(), &response)
Expect(err).NotTo(HaveOccurred())
Expect(*response.Subsonic.OpenSubsonicExtensions).To(SatisfyAll(
- HaveLen(3),
+ HaveLen(4),
ContainElement(responses.OpenSubsonicExtension{Name: "transcodeOffset", Versions: []int32{1}}),
ContainElement(responses.OpenSubsonicExtension{Name: "formPost", Versions: []int32{1}}),
ContainElement(responses.OpenSubsonicExtension{Name: "songLyrics", Versions: []int32{1}}),
+ ContainElement(responses.OpenSubsonicExtension{Name: "indexBasedQueue", Versions: []int32{1}}),
))
})
})
diff --git a/server/subsonic/responses/.snapshots/Responses PlayQueue without data should match .JSON b/server/subsonic/responses/.snapshots/Responses PlayQueue without data should match .JSON
index 88eebb276..70b10c059 100644
--- a/server/subsonic/responses/.snapshots/Responses PlayQueue without data should match .JSON
+++ b/server/subsonic/responses/.snapshots/Responses PlayQueue without data should match .JSON
@@ -6,6 +6,7 @@
"openSubsonic": true,
"playQueue": {
"username": "",
+ "changed": "0001-01-01T00:00:00Z",
"changedBy": ""
}
}
diff --git a/server/subsonic/responses/.snapshots/Responses PlayQueue without data should match .XML b/server/subsonic/responses/.snapshots/Responses PlayQueue without data should match .XML
index 5af3d9157..597781cbd 100644
--- a/server/subsonic/responses/.snapshots/Responses PlayQueue without data should match .XML
+++ b/server/subsonic/responses/.snapshots/Responses PlayQueue without data should match .XML
@@ -1,3 +1,3 @@
-
+
diff --git a/server/subsonic/responses/.snapshots/Responses PlayQueueByIndex with data should match .JSON b/server/subsonic/responses/.snapshots/Responses PlayQueueByIndex with data should match .JSON
new file mode 100644
index 000000000..efc032ca6
--- /dev/null
+++ b/server/subsonic/responses/.snapshots/Responses PlayQueueByIndex with data should match .JSON
@@ -0,0 +1,22 @@
+{
+ "status": "ok",
+ "version": "1.16.1",
+ "type": "navidrome",
+ "serverVersion": "v0.55.0",
+ "openSubsonic": true,
+ "playQueueByIndex": {
+ "entry": [
+ {
+ "id": "1",
+ "isDir": false,
+ "title": "title",
+ "isVideo": false
+ }
+ ],
+ "currentIndex": 0,
+ "position": 243,
+ "username": "user1",
+ "changed": "0001-01-01T00:00:00Z",
+ "changedBy": "a_client"
+ }
+}
diff --git a/server/subsonic/responses/.snapshots/Responses PlayQueueByIndex with data should match .XML b/server/subsonic/responses/.snapshots/Responses PlayQueueByIndex with data should match .XML
new file mode 100644
index 000000000..1d31b334e
--- /dev/null
+++ b/server/subsonic/responses/.snapshots/Responses PlayQueueByIndex with data should match .XML
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/server/subsonic/responses/.snapshots/Responses PlayQueueByIndex without data should match .JSON b/server/subsonic/responses/.snapshots/Responses PlayQueueByIndex without data should match .JSON
new file mode 100644
index 000000000..ad49a35e5
--- /dev/null
+++ b/server/subsonic/responses/.snapshots/Responses PlayQueueByIndex without data should match .JSON
@@ -0,0 +1,12 @@
+{
+ "status": "ok",
+ "version": "1.16.1",
+ "type": "navidrome",
+ "serverVersion": "v0.55.0",
+ "openSubsonic": true,
+ "playQueueByIndex": {
+ "username": "",
+ "changed": "0001-01-01T00:00:00Z",
+ "changedBy": ""
+ }
+}
diff --git a/server/subsonic/responses/.snapshots/Responses PlayQueueByIndex without data should match .XML b/server/subsonic/responses/.snapshots/Responses PlayQueueByIndex without data should match .XML
new file mode 100644
index 000000000..d99681f4c
--- /dev/null
+++ b/server/subsonic/responses/.snapshots/Responses PlayQueueByIndex without data should match .XML
@@ -0,0 +1,3 @@
+
+
+
diff --git a/server/subsonic/responses/responses.go b/server/subsonic/responses/responses.go
index ffda2aa43..0724d2fff 100644
--- a/server/subsonic/responses/responses.go
+++ b/server/subsonic/responses/responses.go
@@ -60,6 +60,7 @@ type Subsonic struct {
// OpenSubsonic extensions
OpenSubsonicExtensions *OpenSubsonicExtensions `xml:"openSubsonicExtensions,omitempty" json:"openSubsonicExtensions,omitempty"`
LyricsList *LyricsList `xml:"lyricsList,omitempty" json:"lyricsList,omitempty"`
+ PlayQueueByIndex *PlayQueueByIndex `xml:"playQueueByIndex,omitempty" json:"playQueueByIndex,omitempty"`
}
const (
@@ -439,12 +440,21 @@ type TopSongs struct {
}
type PlayQueue struct {
- Entry []Child `xml:"entry,omitempty" json:"entry,omitempty"`
- Current string `xml:"current,attr,omitempty" json:"current,omitempty"`
- Position int64 `xml:"position,attr,omitempty" json:"position,omitempty"`
- Username string `xml:"username,attr" json:"username"`
- Changed *time.Time `xml:"changed,attr,omitempty" json:"changed,omitempty"`
- ChangedBy string `xml:"changedBy,attr" json:"changedBy"`
+ Entry []Child `xml:"entry,omitempty" json:"entry,omitempty"`
+ Current string `xml:"current,attr,omitempty" json:"current,omitempty"`
+ Position int64 `xml:"position,attr,omitempty" json:"position,omitempty"`
+ Username string `xml:"username,attr" json:"username"`
+ Changed time.Time `xml:"changed,attr" json:"changed"`
+ ChangedBy string `xml:"changedBy,attr" json:"changedBy"`
+}
+
+type PlayQueueByIndex struct {
+ Entry []Child `xml:"entry,omitempty" json:"entry,omitempty"`
+ CurrentIndex *int `xml:"currentIndex,attr,omitempty" json:"currentIndex,omitempty"`
+ Position int64 `xml:"position,attr,omitempty" json:"position,omitempty"`
+ Username string `xml:"username,attr" json:"username"`
+ Changed time.Time `xml:"changed,attr" json:"changed"`
+ ChangedBy string `xml:"changedBy,attr" json:"changedBy"`
}
type Bookmark struct {
diff --git a/server/subsonic/responses/responses_test.go b/server/subsonic/responses/responses_test.go
index 7238665cf..2ee8e080d 100644
--- a/server/subsonic/responses/responses_test.go
+++ b/server/subsonic/responses/responses_test.go
@@ -768,7 +768,7 @@ var _ = Describe("Responses", func() {
response.PlayQueue.Username = "user1"
response.PlayQueue.Current = "111"
response.PlayQueue.Position = 243
- response.PlayQueue.Changed = &time.Time{}
+ response.PlayQueue.Changed = time.Time{}
response.PlayQueue.ChangedBy = "a_client"
child := make([]Child, 1)
child[0] = Child{Id: "1", Title: "title", IsDir: false}
@@ -783,6 +783,40 @@ var _ = Describe("Responses", func() {
})
})
+ Describe("PlayQueueByIndex", func() {
+ BeforeEach(func() {
+ response.PlayQueueByIndex = &PlayQueueByIndex{}
+ })
+
+ Context("without data", func() {
+ It("should match .XML", func() {
+ Expect(xml.MarshalIndent(response, "", " ")).To(MatchSnapshot())
+ })
+ It("should match .JSON", func() {
+ Expect(json.MarshalIndent(response, "", " ")).To(MatchSnapshot())
+ })
+ })
+
+ Context("with data", func() {
+ BeforeEach(func() {
+ response.PlayQueueByIndex.Username = "user1"
+ response.PlayQueueByIndex.CurrentIndex = gg.P(0)
+ response.PlayQueueByIndex.Position = 243
+ response.PlayQueueByIndex.Changed = time.Time{}
+ response.PlayQueueByIndex.ChangedBy = "a_client"
+ child := make([]Child, 1)
+ child[0] = Child{Id: "1", Title: "title", IsDir: false}
+ response.PlayQueueByIndex.Entry = child
+ })
+ It("should match .XML", func() {
+ Expect(xml.MarshalIndent(response, "", " ")).To(MatchSnapshot())
+ })
+ It("should match .JSON", func() {
+ Expect(json.MarshalIndent(response, "", " ")).To(MatchSnapshot())
+ })
+ })
+ })
+
Describe("Shares", func() {
BeforeEach(func() {
response.Shares = &Shares{}
diff --git a/server/testdata/test_cert.pem b/server/testdata/test_cert.pem
new file mode 100644
index 000000000..1dfa573d6
--- /dev/null
+++ b/server/testdata/test_cert.pem
@@ -0,0 +1,23 @@
+-----BEGIN CERTIFICATE-----
+MIIDwzCCAqugAwIBAgIUXqdUxUOo8kmsDe71iTR+Vr7btP8wDQYJKoZIhvcNAQEL
+BQAwYjELMAkGA1UEBhMCVVMxDTALBgNVBAgMBFRlc3QxDTALBgNVBAcMBFRlc3Qx
+EjAQBgNVBAoMCU5hdmlkcm9tZTENMAsGA1UECwwEVGVzdDESMBAGA1UEAwwJbG9j
+YWxob3N0MCAXDTI1MTEyODE5NTkxNVoYDzIxMjUxMTA0MTk1OTE1WjBiMQswCQYD
+VQQGEwJVUzENMAsGA1UECAwEVGVzdDENMAsGA1UEBwwEVGVzdDESMBAGA1UECgwJ
+TmF2aWRyb21lMQ0wCwYDVQQLDARUZXN0MRIwEAYDVQQDDAlsb2NhbGhvc3QwggEi
+MA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCkB/TQgl5ei5KRSHt5OJim8rKS
+MzRlkK4BjSEM4D9ESbebdpEVjX48QuBYACrCvgvVp7mQGF5anl8Hm89trvd8ooVQ
+x9IPQQ6gRKM+4gLrt9FHvFGGzZQS8UTQXN5oBi11E+8/Vs47HLUNXC2TRtRLCMyK
+LYXQIXbhdp9anImlt+IHUxIQUchK6Zkld/gCm56X1bbzN/Zq91PQLpx2FZ0eZTjN
+KaNgztLa+K/BDnTuk3iTTs9GEp6VCvqQE/6fk/UN/tkk2dLwKIFvPVR/YeAhVdz/
+OHC4L3B36QN3+VQ2yDjsp1PVAPX07UnzXO3Oj7uGYnMQxwprGMEubm3nADDxAgMB
+AAGjbzBtMB0GA1UdDgQWBBRAZHUVuLyzc0CfuZR9ApqMbawIqzAfBgNVHSMEGDAW
+gBRAZHUVuLyzc0CfuZR9ApqMbawIqzAPBgNVHRMBAf8EBTADAQH/MBoGA1UdEQQT
+MBGCCWxvY2FsaG9zdIcEfwAAATANBgkqhkiG9w0BAQsFAAOCAQEAmDLXcPx9LNHs
+GxQIE6Q5BXbVO7c8qrWmJf5FK5VWaifNZ9U+IBi+VlB4jCLK/OkwsviN/jOnwRYx
+owjq0QG0YdRT4uD9fEMrAj+EwbnrQYZQvT0yGEWA+KW5TW08wt+/qnGJDwEgbjYJ
+HTdICVMhs/e8Ex48fAgO8WSsdTDekOrhuwzIfeJ1LU4ZptLsD2ePFxuzutdIuW51
+/mspQGsjXqZ1qnLsavLXh/lds2g602rTpYBNZVjV9WiOvaQS8vviOxBN6f+9vgRz
+a8SEbHqBG6jeyVqVZ7MjxcYxaIkxeBwMyMwgb+wwDfVXo2FZzX2TVeB7ZppI+IKv
+TXYurWPYsQ==
+-----END CERTIFICATE-----
diff --git a/server/testdata/test_cert_encrypted.pem b/server/testdata/test_cert_encrypted.pem
new file mode 100644
index 000000000..6f8de623a
--- /dev/null
+++ b/server/testdata/test_cert_encrypted.pem
@@ -0,0 +1,22 @@
+-----BEGIN CERTIFICATE-----
+MIIDpzCCAo+gAwIBAgIUEa7gEJYwJqYEJjTY7otQ+oUyELwwDQYJKoZIhvcNAQEL
+BQAwYjELMAkGA1UEBhMCVVMxDTALBgNVBAgMBFRlc3QxDTALBgNVBAcMBFRlc3Qx
+EjAQBgNVBAoMCU5hdmlkcm9tZTENMAsGA1UECwwEVGVzdDESMBAGA1UEAwwJbG9j
+YWxob3N0MCAXDTI1MTEyODE5NTI0OVoYDzIxMjUxMTA0MTk1MjQ5WjBiMQswCQYD
+VQQGEwJVUzENMAsGA1UECAwEVGVzdDENMAsGA1UEBwwEVGVzdDESMBAGA1UECgwJ
+TmF2aWRyb21lMQ0wCwYDVQQLDARUZXN0MRIwEAYDVQQDDAlsb2NhbGhvc3QwggEi
+MA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDBHgqJ1d9EnNxqoSZ6xXrIz/mV
+Y0nWJW16/qIAvCdovSeTZhG9iqG8dUqcuu2BdD9MMHndJ2oFn3iD8EJR92dH8KBA
+8xOmtZ0BEEWgXPBivywZVd1ChIflEWj6m5wwLNjb57SPpUiwaLxBQB8ByEaAAZE/
+bLqvHI3vW/4s5apky17SPIqmkmqEYlRcg97tlRXsPuwoAVM9cvLMMEqtIR1CB/72
+gboY2Gi2r/plLF/Rg3Dom6QljMWi57XXWJFwGYSXaZuM0gvn04e3oLu+1E+WMoq/
+9rExWij2DlsmXd/RiScliFp6R4H84wQUyqrAUNytvgRO+oVnRjEA0l3oCYdRAgMB
+AAGjUzBRMB0GA1UdDgQWBBQQKpB1UaKm98FnBdl8uKdRscrVTzAfBgNVHSMEGDAW
+gBQQKpB1UaKm98FnBdl8uKdRscrVTzAPBgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3
+DQEBCwUAA4IBAQBP07l+2LmpFtcxqMGmsiNYwFuHpQCxJd4YRZHjLX7O+oJExMgR
+2yP4mpMKurgKOv7unTDLwvjQRa6ZTYJCsYtvC6hbyqlGc7AfNTu6DKz8r35/2/V5
+hPsG5lNb91HhvHE839mLAvpi02LoFH2Sr8BR7s6qxfNKYcP8PUOJQXltJ6yAa8YJ
+syeXQQ3RIyGsJANeaC06S3UdkBM5H5BLfIHnHu3GybJjwL51va4WCdHe8QV6GI0g
+RDiThDVkBSXAr136vnMdlrYCxMoxY56itJ0zbYg2ELQKU9o1w/ZJQo9uvmy9jCoZ
+Hy1L5a2vUDbsdONdvRkYZRHqMpG4bdD8D3j2
+-----END CERTIFICATE-----
diff --git a/server/testdata/test_key.pem b/server/testdata/test_key.pem
new file mode 100644
index 000000000..bac61f4a4
--- /dev/null
+++ b/server/testdata/test_key.pem
@@ -0,0 +1,28 @@
+-----BEGIN PRIVATE KEY-----
+MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCkB/TQgl5ei5KR
+SHt5OJim8rKSMzRlkK4BjSEM4D9ESbebdpEVjX48QuBYACrCvgvVp7mQGF5anl8H
+m89trvd8ooVQx9IPQQ6gRKM+4gLrt9FHvFGGzZQS8UTQXN5oBi11E+8/Vs47HLUN
+XC2TRtRLCMyKLYXQIXbhdp9anImlt+IHUxIQUchK6Zkld/gCm56X1bbzN/Zq91PQ
+Lpx2FZ0eZTjNKaNgztLa+K/BDnTuk3iTTs9GEp6VCvqQE/6fk/UN/tkk2dLwKIFv
+PVR/YeAhVdz/OHC4L3B36QN3+VQ2yDjsp1PVAPX07UnzXO3Oj7uGYnMQxwprGMEu
+bm3nADDxAgMBAAECggEABqJFvesP2v4FEvgd+kSWM+ZL34rPmy3zQ5/MDuPA20ep
+89EjQ/5hdRl1TknPcOnTu7PZVuENa9fM2xdrl7GEU9eU0bQLJE/KwiOUgJYObS8V
+eTO+DlghHXUBhfXDjux1CS+htOuTUqOyFNS+CR9Lta8o6ou1xjmcP7kW78i17mxF
+TuH5SZlS8W9PFLXHCInbMtqGFaT2ss09kvoPk2FDvHfxEdy6M9tKkguz02g+4bqI
+aAMp2N7AOfmRpC0HvVa1ZfZo5Z8/KMoNcIm3pV9DEVM369J9EzhnMNpkGben90aT
+FqO2JNsy52wmXFZUc9xe8uPdfDahALCkBGncLyLNmQKBgQDZREjocjdzOoPSlCdx
+mRNe9suHz2FpUpsHCPOCotG63hFVKpah/ZvpHSsQx5rXs/mawDTmzGY9GQiBrSvg
+OhfHIyT3NOhVaNcMxTqJX7rs7OG8D0MBacD9ASSeZ89MUn8q1EHZr5qxLtXl5Ikw
+mHtiGRdiKGFFrG9H0zncbGhy7QKBgQDBRhQ9RAasTdmUiNQly9GVFkXto4T/9UHx
+rVU44htCI2IVZUMTGlNfclfxpByDrzyA56rMzN9SAkiIp4nPpMDs5hayXaaPoojs
+CPzV7r2OjemZ6CTeQ1ODImRL8L/E3jJSgWd6YYoHSQ5hjEX4yT6ft0u0tZUfdMKd
+VENWIJ/hlQKBgQCo2hXjeOi5R8+tN3EUKwhP9HOnX7dv+D/9jqpZa5qdpPpJeyjI
+SmYCHKYci1Q+sWOaLiiu+km20B65UVFZGSzjmd+fs+GghzMifKGKo/iNK2ggFKhZ
+j8vplRrVdQ45XZ/xNDbdLEmHzEN2QE+Skd7KFYADzCgU0vdFFdbRBPuD3QKBgGIq
+fQctMRJ9LCE0akSURGwr9vKflmMHKCpfdqTAu0WZgS0K1Mm0GlqlUiPKzizYaauz
+f14sRNV7kWnPZsDPlqn8p9SKmpnj3RW97uWeMCtiyx6/+VHm8ljts/GaY1zT2s1r
+KqrPNfNDWQmU3MljNeqbh9lOTWK/xEVy0gzB31MNAoGAQNWrZvVdAbL95XW6STUu
+JmQlqJTlluuqS0Rrd/uVEQwW0Vd1dZjRQcFAFiSiCQWTbtId5gFZd6hiIQl53Xz0
+5cd+9mcyA/TaoCJYbMOFYsKbZMCBhefsovJlVQXedqJrIY6BdeGlet4GTAH5Qyl0
+ytEIUnvn5YmmbI7PDz80XpU=
+-----END PRIVATE KEY-----
diff --git a/server/testdata/test_key_encrypted.pem b/server/testdata/test_key_encrypted.pem
new file mode 100644
index 000000000..0ac715890
--- /dev/null
+++ b/server/testdata/test_key_encrypted.pem
@@ -0,0 +1,30 @@
+-----BEGIN ENCRYPTED PRIVATE KEY-----
+MIIFNTBfBgkqhkiG9w0BBQ0wUjAxBgkqhkiG9w0BBQwwJAQQPH9PYzryCI3smm81
+J8rm+QICCAAwDAYIKoZIhvcNAgkFADAdBglghkgBZQMEASoEEI+9XxNfKSiMYIVB
+UfcGfncEggTQVw7tPslGy3mlofCNnhBSnMViv9kj6M11smD6Y8vHG0k9Kq+6g+Dx
+mQE9ILrSZBzM0uS3y484u+vkdqlT4KehhjIx0IiezurOcM45UdTAwLFLPzeEDlHI
+lOWQ3gOTB3J5AxiUQOa6QsDIM7AZilidQG0BxQYWyRBA5B8evJwJoAvdzzA9wGSm
+2YdNm3tA6rU5U8cVG+qTJP9pjbtRx0medC/CBZdxGkrWBQH+aySfahJdU8X1JI2e
+SY4WJRw1rLCow+DnHjZS/IVHFJivJSRYvnvw8fwjOMVtkf+dAVctKlb1Fj9X+RdG
+T1sq3i6zwFLE/RRz4qM4DKZ6UaD9wRFLow8FmNWVuJJiPgCLx2rrNMe32quS/kQP
+iOsXAUeA/Yg1fdMCJORxl0nWDmLYcNtBghCmS1lyk+t+AKWwJudrds5tQQe8ha2t
+Q41is+tDKwGDC1wt4WXJvBhgAJzuqFtr30H0M1eBhwwDdaDd9v0Zr3r8V49WZM2c
+i3qkwPPYkQD+pOcR12xBV8ptvDxaUl7RGlVqnEWHagT51BaIaXQ9teUrG6UPt8o2
+LELJXF6CiwkbN6Y9sYx5XiKrIGxVhlQSZ1nB3XSFRHbu6e7VHPjnVwUeeg87J2Am
+MEwqDzPU5sjKRn84+M91Y4uFAIeinaOJAQ0/tZVrf1iSeCMQyMUhW/8m7JPfG19F
+NbJSPRXQuKmYKbWfXcMW2UFbp0zDs7s7p4zzbfde9IbVdq/o2nv3ZrNbrLak6O7y
+FVt9q/xG4Tty6hSK6xtqtNZWcmfiMcTlk1Qcz2STvScbXtqgcgR6WUZfkLuzi09I
+EDYFnzU5JNSY3U3VTv2hAPeU4xjTNM6kjF7L9JFGvdjH8Ko9UdxG9RZMd8xhBM/n
+hxdzdVba4bDDz2z+0A2blSObrPrNsKr/3ZbnfuUiSs5NmqmUOifZ1t1PqGGO2Y5S
+/cDKtrPk226hGomsUBfHtiIJPG1VRl4UaZiduqK3GGhtF491KU1mAfYzueok3TPq
+JhLtLDIvEaFgmOmitFzROI/ifm6s4ssUvcvtbjwJumbjkU38OxYZFwbhwbe268G2
+vgspJamlEGJNdGDzrCFQlA2+A9kazCttztikfh5QGV6WFfkc3Bt1XTPL51vtliQy
+MS2gUnJUY2fuYCfz8rxLH1kQmyYsHQz5rUYyBkeDffrG9MzarmzSJXR63FRzVMf1
+LQ7BSzei7dF6+J4KVCxjbGWF3GUGmGeOP5g5vJ3xb3YPJNJLT4Vai103pay59TGP
+tESM2Vn0gJEvYApi707noFH5uFTW1cp7lloF41ddIUkL/QO7j+sjvBww+4DqBB7J
+BmvLMnswa23yw9egYRG5jOXyCgIr+1rnNcph1HGJsvxvgJ2gwwo5NKCG8SC6LcZQ
+fbDjX+ssmobLE3ktN03FZPMp32/ciexzuZoamfyiPXh7xE++ckifNEKJlNhx+kCG
+mSR2wh+UGigQkgp/JxOzl6C4fhUbrEZr17oBqGim2p8h+GE0zD5JSHcn1rP86gGU
+8JG/ilG4I8uMxUwhGj7amrWXUlJBd1by7e1EAL+utCo14/Tx3otB9/JtqY+lm9Ey
+1ptPhMRQxvDNWrCmYM2kyrGghdNfEMir6GKDWI6PY9cwAFv/PLOxr1c=
+-----END ENCRYPTED PRIVATE KEY-----
diff --git a/server/testdata/test_key_encrypted_legacy.pem b/server/testdata/test_key_encrypted_legacy.pem
new file mode 100644
index 000000000..4b9215cdf
--- /dev/null
+++ b/server/testdata/test_key_encrypted_legacy.pem
@@ -0,0 +1,30 @@
+-----BEGIN RSA PRIVATE KEY-----
+Proc-Type: 4,ENCRYPTED
+DEK-Info: AES-256-CBC,3C969050EAB73F121B7F0E6B75C42525
+
+V6pSaAsrn9CQNo4p88QshJLbg8zkQJEom81dPbYSVqQSZa9YlPtpLZ9YtuLj/Ay0
+TScEKIj/gzQ32wNl6nhcSNIL9yy+X11r5gNv1kIHkecf+EbDW20VOiJsfD+6LUyW
+hA96AIbPOwc76iCuvsKHPKU9MlEmjGipmk/C2RQLHCZJ3WkiDRgCM8KQ7vKhfACT
+w908yj4cB1e/P0JPq8t/3F7kPJ+6SVM1vMEffHl0otQR3rAyrK8QikwJ0K9qX62d
+cqchTVlEyyZBYovR8DrRRUDbsXS5j1ZmX3NQpvTSTFowr+33fMrY+4Oz8sdR4yx1
+CQc0A0sHHxSEIr2xu4KzczwOYVJN8PVdU0pgvFj9KEm66N6EY5CSFIBHyO/ycOt9
+U+wpkRjf3zS6ZaUU0NKdOcop4YX33i99/tZF2RNR1i7ETLYph+/LCf09286Bi3u/
+UCCuWedyECPdz0c6j0s27Fdfc/HEK90OEzeWh/fc+H2gJZhqJYK9V47HPTQNNMnB
+U1a6FsJlrKE3E6nfSnTLxrSx9m/XTV7HV+HkgX+q8VhN7Q2VHUqkPzE7ZOPYpZ+A
+dQzsm1TmEMxym6osYqFzQScXR1NZasrV2MTQ2J16dUgCdGAM2YMUD9JaoJR+u77M
+WAjYzDiRg84rLr/KbJPAwHbsfo2KpiapJGSBBEDhz4W1/LOrFhsjaqIMSy4yZDGm
+1KqXGHIlqmuHI7v4fD8vuzhj7GUujRx85HSZWakE/uc6s5WrhkSeVKYJWPfpsxTv
+dT3oLOGJ+nRzWxM3aFtuJghX0nIGdKxT4EAUNXz0/vLT3OP1QCZR+oELrriFzmtj
++O30bGH2SAFZEQJ/uTQg6celoNh89IzH4DJkcn67hqpX6mUiU9CrIr/eR9C/en8Q
+smTbbC1C1pDUaCwR26Z+zgM90amh4yfOFKK2geO2Kj+TmwFHUvi6ZnSzMzCvty3t
++wdIrUtf55Lw51JCpLGl70mg4b/zBj5hqBkU2YvAAnz/htjfH/wrD6ZAF1TCdlRO
+gyODrJjGRnLd/v0XLk0wp+RkAjBcSlRlkUvZY5BtugL7dIdwiNGGQPcOni9IVeG0
+6vDUEQnDOLYDj4d/JcckTLuHdrP+SW+0RQl2HK5+/w1hScGXN4O48gccu7yR/MN8
+DmpCg5rD/nq8sxJosmSt07GrN36KppYt8LCXQbSg3NG2Ad715caS2C+0Qtdm5MPD
+rM1UyTXQYSJXgUN9yZS/pmzlguCywnnvsBPU6j3ljZwcoD41QJ/1OU09/W6sIMQR
+IAiM35JHiLJiccFgxSE1qx5F1UZqX4P47jF0Wzi/sE/DYXg5qw2DoauqXNzqnumH
+71UDGK1V6wQIV7UCZDa0WUfFzu470XpuFb8VmMOuHSQxkZESc9cz8k/ueAuO438Q
+jnlkF1Ge2EEPuaK2zeaTj/lGyYA1AUfHRRgt/EMUQSBntmhlpnwVPYTVvYtHO2N5
+wp7/y39KirnlTl99i3XiOJ4WF4gIU2IaSlqMo4+e/A32h2JFi9QfNyfItXe6Fm1X
+d0j2XGHzwMfHEFKdWyrgtVZwc38/1d6xWYAhs02b2basV/0AQhFTaKf5Z268eBNJ
+-----END RSA PRIVATE KEY-----
diff --git a/tests/fixtures/bom-test.lrc b/tests/fixtures/bom-test.lrc
new file mode 100644
index 000000000..223c37de0
--- /dev/null
+++ b/tests/fixtures/bom-test.lrc
@@ -0,0 +1,4 @@
+[00:00.00] 作曲 : 柏大輔
+NOTE: This file intentionally contains a UTF-8 BOM (Byte Order Mark) at byte 0.
+This tests BOM handling in lyrics parsing (GitHub issue #4631).
+The BOM bytes are: 0xEF 0xBB 0xBF
\ No newline at end of file
diff --git a/tests/fixtures/bom-utf16-test.lrc b/tests/fixtures/bom-utf16-test.lrc
new file mode 100644
index 000000000..e40ea3255
Binary files /dev/null and b/tests/fixtures/bom-utf16-test.lrc differ
diff --git a/tests/fixtures/deezer.artist.bio.json b/tests/fixtures/deezer.artist.bio.json
new file mode 100644
index 000000000..80e439bae
--- /dev/null
+++ b/tests/fixtures/deezer.artist.bio.json
@@ -0,0 +1,9 @@
+{
+ "data": {
+ "artist": {
+ "bio": {
+ "full": "
Schoolmates Thomas and Guy-Manuel began their career in 1992 with the indie rock trio Darlin' (named after The Beach Boys song) but were scathingly dismissed by Melody Maker magazine as \"daft punk.\" Turning to house-inspired electronica, they used the put down as a name for their DJ-ing partnership and became a hugely successful and influential dance act.