mirror of
https://github.com/navidrome/navidrome.git
synced 2026-08-31 07:30:32 +00:00
Merge branch 'master' into feat-podcast-ux-improvements/5420
This commit is contained in:
commit
f5903dee70
@ -12,8 +12,10 @@ import (
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/id"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
"github.com/navidrome/navidrome/utils/ioutils"
|
||||
"github.com/zeebo/xxh3"
|
||||
"golang.org/x/text/unicode/norm"
|
||||
)
|
||||
|
||||
@ -57,10 +59,12 @@ func (s *playlists) ImportFile(ctx context.Context, absolutePath string, sync bo
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
reader := ioutils.UTF8Reader(file)
|
||||
hasher := xxh3.New()
|
||||
reader := io.TeeReader(ioutils.UTF8Reader(file), hasher)
|
||||
if err := s.parseM3U(ctx, pls, nil, reader); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
pls.ImportedHash = fingerprint(hasher)
|
||||
if err := s.updatePlaylist(ctx, pls, sync); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@ -138,7 +142,9 @@ func (s *playlists) parsePlaylist(ctx context.Context, playlistFile string, fold
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
reader := ioutils.UTF8Reader(file)
|
||||
// Hash the bytes the parser consumes, giving every imported playlist a content fingerprint
|
||||
hasher := xxh3.New()
|
||||
reader := io.TeeReader(ioutils.UTF8Reader(file), hasher)
|
||||
extension := strings.ToLower(filepath.Ext(playlistFile))
|
||||
switch extension {
|
||||
case ".nsp":
|
||||
@ -146,7 +152,15 @@ func (s *playlists) parsePlaylist(ctx context.Context, playlistFile string, fold
|
||||
default:
|
||||
err = s.parseM3U(ctx, pls, folder, reader)
|
||||
}
|
||||
return pls, err
|
||||
if err != nil {
|
||||
return pls, err
|
||||
}
|
||||
pls.ImportedHash = fingerprint(hasher)
|
||||
return pls, nil
|
||||
}
|
||||
|
||||
func fingerprint(h *xxh3.Hasher) string {
|
||||
return id.Encode(h.Sum128().Bytes())
|
||||
}
|
||||
|
||||
// findByPathNormalized looks up a playlist by path, trying both NFC and NFD Unicode
|
||||
@ -179,6 +193,12 @@ func (s *playlists) updatePlaylist(ctx context.Context, newPls *model.Playlist,
|
||||
}
|
||||
|
||||
if err == nil {
|
||||
// Only smart playlists skip on an unchanged file; M3U must re-run so newly-added tracks resolve.
|
||||
if !forceSync && newPls.IsSmartPlaylist() && newPls.ImportedHash != "" && newPls.ImportedHash == pls.ImportedHash {
|
||||
log.Trace(ctx, "Playlist file unchanged since last import, skipping", "playlist", pls.Name, "path", pls.Path)
|
||||
*newPls = *pls // callers must see the stored record, so e.g. ImportFile can still flip Sync
|
||||
return nil
|
||||
}
|
||||
log.Info(ctx, "Updating synced playlist", "playlist", pls.Name, "path", newPls.Path)
|
||||
newPls.ID = pls.ID
|
||||
newPls.Name = pls.Name
|
||||
@ -187,6 +207,12 @@ func (s *playlists) updatePlaylist(ctx context.Context, newPls *model.Playlist,
|
||||
newPls.Public = pls.Public
|
||||
newPls.UploadedImage = pls.UploadedImage // Preserve manual upload
|
||||
newPls.EvaluatedAt = nil // force re-evaluation on next read
|
||||
if newPls.IsSmartPlaylist() {
|
||||
// Tracks aren't materialized at parse time; carry the stored counters so callers see real values
|
||||
newPls.SongCount = pls.SongCount
|
||||
newPls.Duration = pls.Duration
|
||||
newPls.Size = pls.Size
|
||||
}
|
||||
} else {
|
||||
log.Info(ctx, "Adding synced playlist", "playlist", newPls.Name, "path", newPls.Path, "owner", owner.UserName)
|
||||
newPls.OwnerID = owner.ID
|
||||
|
||||
@ -15,10 +15,12 @@ import (
|
||||
"github.com/navidrome/navidrome/core/playlists"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/criteria"
|
||||
"github.com/navidrome/navidrome/model/id"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
"github.com/zeebo/xxh3"
|
||||
"golang.org/x/text/unicode/norm"
|
||||
)
|
||||
|
||||
@ -307,6 +309,33 @@ var _ = Describe("Playlists - Import", func() {
|
||||
Expect(pls.ID).To(BeEmpty())
|
||||
})
|
||||
|
||||
It("stores a content hash but re-imports unchanged M3U playlists", func() {
|
||||
tmpDir := GinkgoT().TempDir()
|
||||
mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}})
|
||||
ds.MockedMediaFile = &mockedMediaFileFromListRepo{data: []string{"test.mp3"}}
|
||||
ps = playlists.NewPlaylists(ds, artwork.NewUploader(ds))
|
||||
|
||||
plsFile := filepath.Join(tmpDir, "test.m3u")
|
||||
Expect(os.WriteFile(plsFile, []byte("test.mp3\n"), 0600)).To(Succeed())
|
||||
plsFolder := &model.Folder{ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: ""}
|
||||
|
||||
first, err := ps.ImportFromFolder(ctx, plsFolder, "test.m3u")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(first.ImportedHash).ToNot(BeEmpty())
|
||||
|
||||
// Re-import with a matching stored hash: M3U must still be re-imported, not skipped.
|
||||
existingPls := &model.Playlist{
|
||||
ID: "m3u-id", Name: "Test", Path: plsFile, Sync: true,
|
||||
OwnerID: "123", ImportedHash: first.ImportedHash,
|
||||
}
|
||||
mockPlsRepo.PathMap = map[string]*model.Playlist{plsFile: existingPls}
|
||||
mockPlsRepo.Last = nil
|
||||
|
||||
_, err = ps.ImportFromFolder(ctx, plsFolder, "test.m3u")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(mockPlsRepo.Last).ToNot(BeNil())
|
||||
})
|
||||
|
||||
It("clears ExternalImageURL on re-scan when directive is removed", func() {
|
||||
tmpDir := GinkgoT().TempDir()
|
||||
mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}})
|
||||
@ -371,6 +400,85 @@ var _ = Describe("Playlists - Import", func() {
|
||||
Expect(pls.Name).To(Equal("Recently Played"))
|
||||
Expect(pls.Public).To(BeTrue()) // Should be true since server default is true
|
||||
})
|
||||
|
||||
It("preserves counters when re-importing an existing smart playlist", func() {
|
||||
tmpDir := GinkgoT().TempDir()
|
||||
mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}})
|
||||
ps = playlists.NewPlaylists(ds, artwork.NewUploader(ds))
|
||||
|
||||
nsp := `{"name":"My Smart","all":[{"is":{"loved":true}}],"sort":"title","order":"asc"}`
|
||||
plsFile := filepath.Join(tmpDir, "smart.nsp")
|
||||
Expect(os.WriteFile(plsFile, []byte(nsp), 0600)).To(Succeed())
|
||||
|
||||
existingPls := &model.Playlist{
|
||||
ID: "smart-id",
|
||||
Name: "My Smart",
|
||||
Path: plsFile,
|
||||
Sync: true,
|
||||
OwnerID: "123",
|
||||
SongCount: 42,
|
||||
Duration: 123.4,
|
||||
Size: 5000,
|
||||
}
|
||||
mockPlsRepo.PathMap = map[string]*model.Playlist{plsFile: existingPls}
|
||||
|
||||
plsFolder := &model.Folder{ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: ""}
|
||||
_, err := ps.ImportFromFolder(ctx, plsFolder, "smart.nsp")
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(mockPlsRepo.Last).ToNot(BeNil())
|
||||
Expect(mockPlsRepo.Last.IsSmartPlaylist()).To(BeTrue())
|
||||
Expect(mockPlsRepo.Last.SongCount).To(Equal(42))
|
||||
Expect(mockPlsRepo.Last.Duration).To(Equal(float32(123.4)))
|
||||
Expect(mockPlsRepo.Last.Size).To(Equal(int64(5000)))
|
||||
})
|
||||
|
||||
It("skips re-import when the smart playlist file content is unchanged", func() {
|
||||
tmpDir := GinkgoT().TempDir()
|
||||
mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}})
|
||||
ps = playlists.NewPlaylists(ds, artwork.NewUploader(ds))
|
||||
|
||||
nsp := `{"name":"My Smart","all":[{"is":{"loved":true}}]}`
|
||||
plsFile := filepath.Join(tmpDir, "smart.nsp")
|
||||
Expect(os.WriteFile(plsFile, []byte(nsp), 0600)).To(Succeed())
|
||||
|
||||
existingPls := &model.Playlist{
|
||||
ID: "smart-id", Name: "My Smart", Path: plsFile, Sync: true,
|
||||
OwnerID: "123", SongCount: 42,
|
||||
ImportedHash: hashOf(nsp),
|
||||
}
|
||||
mockPlsRepo.PathMap = map[string]*model.Playlist{plsFile: existingPls}
|
||||
|
||||
plsFolder := &model.Folder{ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: ""}
|
||||
_, err := ps.ImportFromFolder(ctx, plsFolder, "smart.nsp")
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(mockPlsRepo.Last).To(BeNil()) // Put never called: nothing re-written
|
||||
})
|
||||
|
||||
It("re-imports when the smart playlist file content changed", func() {
|
||||
tmpDir := GinkgoT().TempDir()
|
||||
mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}})
|
||||
ps = playlists.NewPlaylists(ds, artwork.NewUploader(ds))
|
||||
|
||||
nsp := `{"name":"My Smart","all":[{"is":{"loved":true}}]}`
|
||||
plsFile := filepath.Join(tmpDir, "smart.nsp")
|
||||
Expect(os.WriteFile(plsFile, []byte(nsp), 0600)).To(Succeed())
|
||||
|
||||
existingPls := &model.Playlist{
|
||||
ID: "smart-id", Name: "My Smart", Path: plsFile, Sync: true,
|
||||
OwnerID: "123", SongCount: 42,
|
||||
ImportedHash: hashOf("old content"),
|
||||
}
|
||||
mockPlsRepo.PathMap = map[string]*model.Playlist{plsFile: existingPls}
|
||||
|
||||
plsFolder := &model.Folder{ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: ""}
|
||||
_, err := ps.ImportFromFolder(ctx, plsFolder, "smart.nsp")
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(mockPlsRepo.Last).ToNot(BeNil()) // Put called: file changed
|
||||
Expect(mockPlsRepo.Last.ImportedHash).To(Equal(hashOf(nsp)))
|
||||
})
|
||||
})
|
||||
|
||||
DescribeTable("Playlist filename Unicode normalization (regression fix-playlist-filename-normalization)",
|
||||
@ -760,6 +868,37 @@ var _ = Describe("Playlists - Import", func() {
|
||||
Expect(pls.ID).To(Equal("existing-id"))
|
||||
Expect(pls.Sync).To(BeTrue())
|
||||
})
|
||||
|
||||
It("unsyncs a synced smart playlist with sync=false even when content is unchanged", func() {
|
||||
tmpDir := GinkgoT().TempDir()
|
||||
mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}})
|
||||
|
||||
mockFolderRepo := &mockFolderRepoForImport{
|
||||
folder: &model.Folder{
|
||||
ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: "",
|
||||
},
|
||||
}
|
||||
ds.MockedFolder = mockFolderRepo
|
||||
ps = playlists.NewPlaylists(ds, artwork.NewUploader(ds))
|
||||
|
||||
nsp := `{"name":"My Smart","all":[{"is":{"loved":true}}]}`
|
||||
plsFile := filepath.Join(tmpDir, "smart.nsp")
|
||||
Expect(os.WriteFile(plsFile, []byte(nsp), 0600)).To(Succeed())
|
||||
|
||||
existingPls := &model.Playlist{
|
||||
ID: "smart-id", Name: "My Smart", Path: plsFile, Sync: true,
|
||||
OwnerID: "123", SongCount: 42,
|
||||
ImportedHash: hashOf(nsp),
|
||||
}
|
||||
mockPlsRepo.PathMap = map[string]*model.Playlist{plsFile: existingPls}
|
||||
|
||||
pls, err := ps.ImportFile(ctx, plsFile, false)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(pls.ID).To(Equal("smart-id"))
|
||||
Expect(pls.Sync).To(BeFalse())
|
||||
Expect(mockPlsRepo.Last).ToNot(BeNil())
|
||||
Expect(mockPlsRepo.Last.Sync).To(BeFalse())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("ImportM3U", func() {
|
||||
@ -1083,3 +1222,7 @@ func (m *mockFolderRepoForImport) GetByPath(_ model.Library, _ string) (*model.F
|
||||
}
|
||||
return nil, model.ErrNotFound
|
||||
}
|
||||
|
||||
func hashOf(content string) string {
|
||||
return id.Encode(xxh3.Hash128([]byte(content)).Bytes())
|
||||
}
|
||||
|
||||
@ -136,6 +136,7 @@ func (s *playlists) applyContentUpdate(ctx context.Context, current, entity *mod
|
||||
if rulesChanged {
|
||||
current.Rules = entity.Rules
|
||||
current.EvaluatedAt = nil // force re-evaluation on next read
|
||||
current.ImportedHash = "" // rules no longer match the source file; next scan must re-import it
|
||||
}
|
||||
if sent("sync") && current.Path != "" && current.Sync != entity.Sync {
|
||||
current.Sync = entity.Sync
|
||||
|
||||
@ -160,6 +160,25 @@ var _ = Describe("REST Adapter", func() {
|
||||
Expect(mockPlsRepo.Last.Rules).To(Equal(newRules))
|
||||
})
|
||||
|
||||
It("invalidates the imported hash when rules change, so the next scan re-syncs the file", func() {
|
||||
mockPlsRepo.Data["smart-1"] = &model.Playlist{
|
||||
ID: "smart-1",
|
||||
Name: "Smart Playlist",
|
||||
OwnerID: "user-1",
|
||||
Path: "/music/smart.nsp",
|
||||
Sync: true,
|
||||
ImportedHash: hashOf("file content"),
|
||||
Rules: &criteria.Criteria{Expression: criteria.Contains{"title": "old"}},
|
||||
}
|
||||
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
|
||||
repo = ps.NewRepository(ctx).(rest.Persistable)
|
||||
newRules := &criteria.Criteria{Expression: criteria.Contains{"title": "new"}}
|
||||
pls := &model.Playlist{Rules: newRules}
|
||||
err := repo.Update("smart-1", pls, "rules")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(mockPlsRepo.Last.ImportedHash).To(BeEmpty())
|
||||
})
|
||||
|
||||
It("allows toggling sync for file-backed playlists", func() {
|
||||
originalTime := time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||
mockPlsRepo.Data["file-pls"] = &model.Playlist{
|
||||
|
||||
@ -0,0 +1,5 @@
|
||||
-- +goose Up
|
||||
alter table playlist add imported_hash varchar default '' not null;
|
||||
|
||||
-- +goose Down
|
||||
alter table playlist drop column imported_hash;
|
||||
@ -31,6 +31,7 @@ type Playlist struct {
|
||||
ExternalImageURL string `structs:"external_image_url" json:"externalImageUrl,omitempty"`
|
||||
CreatedAt time.Time `structs:"created_at" json:"createdAt"`
|
||||
UpdatedAt time.Time `structs:"updated_at" json:"updatedAt"`
|
||||
ImportedHash string `structs:"imported_hash" json:"-"`
|
||||
|
||||
// SmartPlaylist attributes
|
||||
Rules *criteria.Criteria `structs:"rules" json:"rules"`
|
||||
|
||||
@ -39,6 +39,10 @@ func (p dbPlaylist) PostMapArgs(args map[string]any) error {
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid criteria expression: %w", err)
|
||||
}
|
||||
// Smart playlist counters are owned by refreshCounters (evaluation), never by callers
|
||||
delete(args, "song_count")
|
||||
delete(args, "duration")
|
||||
delete(args, "size")
|
||||
return nil
|
||||
}
|
||||
delete(args, "rules")
|
||||
|
||||
@ -7,6 +7,7 @@ import (
|
||||
"github.com/deluan/rest"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/criteria"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
"github.com/navidrome/navidrome/utils/slice"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
@ -230,6 +231,32 @@ var _ = Describe("PlaylistRepository", func() {
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Put", func() {
|
||||
It("does not overwrite counters when saving a smart playlist", func() {
|
||||
pls := model.Playlist{Name: "Smart Counters", OwnerID: "userid", Rules: &criteria.Criteria{
|
||||
Expression: criteria.All{criteria.Contains{"title": "love"}},
|
||||
}}
|
||||
Expect(repo.Put(&pls)).To(Succeed())
|
||||
DeferCleanup(func() { Expect(repo.Delete(pls.ID)).To(Succeed()) })
|
||||
|
||||
// Simulate a previous evaluation having stored the counters
|
||||
_, err := GetDBXBuilder().NewQuery("update playlist set song_count = 42, duration = 123, size = 456 where id = {:id}").
|
||||
Bind(dbx.Params{"id": pls.ID}).Execute()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
pls.SongCount = 0
|
||||
pls.Duration = 0
|
||||
pls.Size = 0
|
||||
Expect(repo.Put(&pls)).To(Succeed())
|
||||
|
||||
saved, err := repo.Get(pls.ID)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(saved.SongCount).To(Equal(42))
|
||||
Expect(saved.Duration).To(Equal(float32(123)))
|
||||
Expect(saved.Size).To(Equal(int64(456)))
|
||||
})
|
||||
})
|
||||
|
||||
It("Put/Exists/Delete", func() {
|
||||
By("saves the playlist to the DB")
|
||||
newPls := model.Playlist{Name: "Great!", OwnerID: "userid"}
|
||||
|
||||
@ -58,6 +58,41 @@ var _ = Describe("PlaylistRepository - Smart Playlists", func() {
|
||||
})
|
||||
})
|
||||
|
||||
Context("re-imported from disk", func() {
|
||||
// The scanner re-imports every playlist in a touched folder, and a freshly parsed
|
||||
// .nsp carries no counters — saving it must not wipe the ones already evaluated.
|
||||
It("keeps the stored counters when a freshly parsed playlist is saved over it", func() {
|
||||
rules = &criteria.Criteria{
|
||||
Expression: criteria.All{
|
||||
criteria.Contains{"title": "Antenna"},
|
||||
},
|
||||
}
|
||||
pls := model.Playlist{Name: "Smart", OwnerID: "userid", Rules: rules, Path: "/music/smart.nsp", Sync: true}
|
||||
Expect(repo.Put(&pls)).To(Succeed())
|
||||
DeferCleanup(func() { _ = repo.Delete(pls.ID) })
|
||||
|
||||
evaluated, err := repo.GetWithTracks(pls.ID, true, false)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(evaluated.SongCount).To(BeNumerically(">", 0))
|
||||
|
||||
stored, err := repo.Get(pls.ID)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(stored.SongCount).To(Equal(evaluated.SongCount))
|
||||
|
||||
reimported := model.Playlist{
|
||||
ID: pls.ID, Name: pls.Name, OwnerID: "userid", Rules: rules,
|
||||
Path: pls.Path, Sync: true,
|
||||
}
|
||||
Expect(repo.Put(&reimported)).To(Succeed())
|
||||
|
||||
afterImport, err := repo.Get(pls.ID)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(afterImport.SongCount).To(Equal(stored.SongCount))
|
||||
Expect(afterImport.Duration).To(Equal(stored.Duration))
|
||||
Expect(afterImport.Size).To(Equal(stored.Size))
|
||||
})
|
||||
})
|
||||
|
||||
Context("child smart playlists", func() {
|
||||
BeforeEach(func() {
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
|
||||
@ -17,9 +17,10 @@ import (
|
||||
)
|
||||
|
||||
type translation struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Data string `json:"data"`
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Data string `json:"data"`
|
||||
TermCount int `json:"termCount"`
|
||||
}
|
||||
|
||||
func newTranslationRepository(context.Context) rest.Repository {
|
||||
@ -97,27 +98,46 @@ func loadTranslation(fsys fs.FS, fileName string) (translation translation, err
|
||||
// Load translation from json file
|
||||
file, err := fsys.Open(filePath)
|
||||
if err != nil {
|
||||
return
|
||||
return translation, err
|
||||
}
|
||||
data, err := io.ReadAll(file)
|
||||
if err != nil {
|
||||
return
|
||||
return translation, err
|
||||
}
|
||||
var out map[string]any
|
||||
if err = json.Unmarshal(data, &out); err != nil {
|
||||
return
|
||||
return translation, err
|
||||
}
|
||||
|
||||
// Compress JSON
|
||||
buf := new(bytes.Buffer)
|
||||
if err = json.Compact(buf, data); err != nil {
|
||||
return
|
||||
return translation, err
|
||||
}
|
||||
|
||||
translation.Data = buf.String()
|
||||
translation.Name = out["languageName"].(string)
|
||||
translation.ID = id
|
||||
return
|
||||
translation.TermCount = countTranslatedTerms(out)
|
||||
return translation, nil
|
||||
}
|
||||
|
||||
// countTranslatedTerms counts non-empty leaf values, matching the UI's notion of a translated term
|
||||
func countTranslatedTerms(obj map[string]any) int {
|
||||
count := 0
|
||||
for _, v := range obj {
|
||||
switch v := v.(type) {
|
||||
case map[string]any:
|
||||
count += countTranslatedTerms(v)
|
||||
case string:
|
||||
if v != "" {
|
||||
count++
|
||||
}
|
||||
default:
|
||||
count++
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
var _ rest.Repository = (*translationRepository)(nil)
|
||||
|
||||
@ -6,6 +6,7 @@ import (
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing/fstest"
|
||||
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/resources"
|
||||
@ -45,5 +46,16 @@ var _ = Describe("Translations", func() {
|
||||
var out map[string]any
|
||||
Expect(json.Unmarshal([]byte(tr.Data), &out)).To(BeNil())
|
||||
})
|
||||
|
||||
It("counts only non-empty leaf terms", func() {
|
||||
fsys := fstest.MapFS{
|
||||
"i18n/test.json": &fstest.MapFile{
|
||||
Data: []byte(`{"languageName":"Test","a":"x","b":"","nested":{"c":"y","d":""}}`),
|
||||
},
|
||||
}
|
||||
tr, err := loadTranslation(fsys, "test.json")
|
||||
Expect(err).To(BeNil())
|
||||
Expect(tr.TermCount).To(Equal(3))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -42,13 +42,14 @@ const removeEmpty = (obj) => {
|
||||
|
||||
const prepareLanguage = (lang) => {
|
||||
removeEmpty(lang)
|
||||
// Aliases below go on the merged copy: mutating `en` would corrupt the completion baseline
|
||||
const merged = deepmerge(en, lang)
|
||||
// Make "albumSong" and "playlistTrack" resource use the same translations as "song"
|
||||
lang.resources.albumSong = lang.resources.song
|
||||
lang.resources.playlistTrack = lang.resources.song
|
||||
merged.resources.albumSong = merged.resources.song
|
||||
merged.resources.playlistTrack = merged.resources.song
|
||||
// ra.boolean.null should always be empty
|
||||
lang.ra.boolean.null = ''
|
||||
// Fallback to english translations
|
||||
return deepmerge(en, lang)
|
||||
merged.ra.boolean.null = ''
|
||||
return merged
|
||||
}
|
||||
|
||||
export default polyglotI18nProvider((locale) => {
|
||||
|
||||
19
ui/src/i18n/provider.test.js
Normal file
19
ui/src/i18n/provider.test.js
Normal file
@ -0,0 +1,19 @@
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import en from './en.json'
|
||||
|
||||
vi.mock('../dataProvider', () => ({ default: { getOne: vi.fn() } }))
|
||||
|
||||
const countLeaves = (obj) =>
|
||||
Object.values(obj).reduce(
|
||||
(sum, v) =>
|
||||
sum + (typeof v === 'object' && v !== null ? countLeaves(v) : v ? 1 : 0),
|
||||
0,
|
||||
)
|
||||
|
||||
describe('i18n provider', () => {
|
||||
it('does not mutate the bundled English translations', async () => {
|
||||
const before = countLeaves(en)
|
||||
await import('./provider')
|
||||
expect(countLeaves(en)).toEqual(before)
|
||||
})
|
||||
})
|
||||
@ -1,5 +1,22 @@
|
||||
// React Hook to get a list of all languages available. English is hardcoded
|
||||
import { useGetList } from 'react-admin'
|
||||
import en from './en.json'
|
||||
|
||||
const countLeaves = (obj) =>
|
||||
Object.values(obj).reduce(
|
||||
(sum, v) =>
|
||||
sum + (typeof v === 'object' && v !== null ? countLeaves(v) : v ? 1 : 0),
|
||||
0,
|
||||
)
|
||||
|
||||
const enTermCount = countLeaves(en)
|
||||
|
||||
const withPercentage = ({ id, name, termCount }) => {
|
||||
if (!termCount) return { id, name }
|
||||
const pct = Math.min(100, Math.round((100 * termCount) / enTermCount))
|
||||
// Isolate the percentage, or it renders as "(%61)" next to a right-to-left name
|
||||
return { id, name: `${name} (${pct}%)` }
|
||||
}
|
||||
|
||||
const useGetLanguageChoices = () => {
|
||||
const { ids, data, loaded, loading } = useGetList(
|
||||
@ -9,13 +26,19 @@ const useGetLanguageChoices = () => {
|
||||
{},
|
||||
)
|
||||
|
||||
const choices = [{ id: 'en', name: 'English' }]
|
||||
const languages = [{ id: 'en', name: 'English', termCount: enTermCount }]
|
||||
if (loaded) {
|
||||
ids.forEach((id) => choices.push({ id: id, name: data[id].name }))
|
||||
ids.forEach((id) =>
|
||||
languages.push({
|
||||
id,
|
||||
name: data[id].name,
|
||||
termCount: data[id].termCount,
|
||||
}),
|
||||
)
|
||||
}
|
||||
choices.sort((a, b) => a.name.localeCompare(b.name))
|
||||
languages.sort((a, b) => a.name.localeCompare(b.name))
|
||||
|
||||
return { choices, loaded, loading }
|
||||
return { choices: languages.map(withPercentage), loaded, loading }
|
||||
}
|
||||
|
||||
export default useGetLanguageChoices
|
||||
|
||||
92
ui/src/i18n/useGetLanguageChoices.test.jsx
Normal file
92
ui/src/i18n/useGetLanguageChoices.test.jsx
Normal file
@ -0,0 +1,92 @@
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { renderHook } from '@testing-library/react-hooks'
|
||||
import { useGetList } from 'react-admin'
|
||||
import en from './en.json'
|
||||
import useGetLanguageChoices from './useGetLanguageChoices'
|
||||
|
||||
vi.mock('react-admin', () => ({
|
||||
useGetList: vi.fn(),
|
||||
}))
|
||||
|
||||
const countLeaves = (obj) =>
|
||||
Object.values(obj).reduce(
|
||||
(sum, v) =>
|
||||
sum + (typeof v === 'object' && v !== null ? countLeaves(v) : v ? 1 : 0),
|
||||
0,
|
||||
)
|
||||
const enTermCount = countLeaves(en)
|
||||
|
||||
// The percentage is wrapped in a left-to-right isolate, so it reads the same
|
||||
// next to right-to-left language names
|
||||
const label = (pct) => `(${pct}%)`
|
||||
|
||||
const mockLanguages = (languages) => {
|
||||
const data = {}
|
||||
languages.forEach((l) => (data[l.id] = l))
|
||||
useGetList.mockReturnValue({
|
||||
ids: languages.map((l) => l.id),
|
||||
data,
|
||||
loaded: true,
|
||||
loading: false,
|
||||
})
|
||||
}
|
||||
|
||||
const choiceFor = (id) => {
|
||||
const { result } = renderHook(() => useGetLanguageChoices())
|
||||
return result.current.choices.find((c) => c.id === id)
|
||||
}
|
||||
|
||||
describe('useGetLanguageChoices', () => {
|
||||
it('appends the completion percentage to incomplete languages', () => {
|
||||
const termCount = Math.round(enTermCount * 0.62)
|
||||
mockLanguages([{ id: 'cs', name: 'Čeština', termCount }])
|
||||
|
||||
const pct = Math.round((100 * termCount) / enTermCount)
|
||||
expect(choiceFor('cs').name).toEqual(`Čeština ${label(pct)}`)
|
||||
})
|
||||
|
||||
it('shows 100% for a complete language', () => {
|
||||
mockLanguages([{ id: 'de', name: 'Deutsch', termCount: enTermCount }])
|
||||
|
||||
expect(choiceFor('de').name).toEqual(`Deutsch ${label(100)}`)
|
||||
})
|
||||
|
||||
it('caps the percentage at 100 when a language has extra terms', () => {
|
||||
mockLanguages([
|
||||
{ id: 'pt', name: 'Português', termCount: enTermCount + 20 },
|
||||
])
|
||||
|
||||
expect(choiceFor('pt').name).toEqual(`Português ${label(100)}`)
|
||||
})
|
||||
|
||||
it('isolates the percentage next to a right-to-left name', () => {
|
||||
const termCount = Math.round(enTermCount * 0.61)
|
||||
mockLanguages([{ id: 'ar', name: 'العربية', termCount }])
|
||||
|
||||
const pct = Math.round((100 * termCount) / enTermCount)
|
||||
expect(choiceFor('ar').name).toEqual(`العربية (${pct}%)`)
|
||||
})
|
||||
|
||||
it('omits the percentage when the server does not send a term count', () => {
|
||||
mockLanguages([{ id: 'fr', name: 'Français' }])
|
||||
|
||||
expect(choiceFor('fr').name).toEqual('Français')
|
||||
})
|
||||
|
||||
it('shows 100% for the bundled English', () => {
|
||||
mockLanguages([])
|
||||
|
||||
expect(choiceFor('en').name).toEqual(`English ${label(100)}`)
|
||||
})
|
||||
|
||||
it('sorts by language name, ignoring the percentage', () => {
|
||||
mockLanguages([
|
||||
{ id: 'no', name: 'Norsk', termCount: enTermCount },
|
||||
{ id: 'da', name: 'Dansk', termCount: 1 },
|
||||
])
|
||||
|
||||
const { result } = renderHook(() => useGetLanguageChoices())
|
||||
|
||||
expect(result.current.choices.map((c) => c.id)).toEqual(['da', 'en', 'no'])
|
||||
})
|
||||
})
|
||||
Loading…
x
Reference in New Issue
Block a user