diff --git a/server/app/translations.go b/server/app/translations.go index a4a51cd1b..3e8ab28b8 100644 --- a/server/app/translations.go +++ b/server/app/translations.go @@ -10,9 +10,11 @@ import ( "strings" "sync" + "github.com/deluan/navidrome/conf" "github.com/deluan/navidrome/consts" "github.com/deluan/navidrome/log" "github.com/deluan/navidrome/resources" + "github.com/deluan/navidrome/utils" "github.com/deluan/rest" ) @@ -28,7 +30,10 @@ var ( ) func newTranslationRepository(context.Context) rest.Repository { - dir := resources.AssetFile() + dir := utils.NewMergeFS( + resources.AssetFile(), + http.Dir(filepath.Join(conf.Server.DataFolder, "resources")), + ) if err := loadTranslations(dir); err != nil { log.Error("Error loading translation files", err) } diff --git a/utils/merge_fs.go b/utils/merge_fs.go new file mode 100644 index 000000000..ee746a8fd --- /dev/null +++ b/utils/merge_fs.go @@ -0,0 +1,108 @@ +package utils + +import ( + "fmt" + "io" + "net/http" + "os" + "sort" +) + +type MergeFS struct { + base http.FileSystem + overlay http.FileSystem +} + +func NewMergeFS(base, overlay http.FileSystem) http.FileSystem { + return &MergeFS{ + base: base, + overlay: overlay, + } +} + +func (m MergeFS) Open(name string) (http.File, error) { + f, err := m.overlay.Open(name) + if err != nil { + return m.base.Open(name) + } + + info, err := f.Stat() + if err != nil { + _ = f.Close() + return m.base.Open(name) + } + + if !info.IsDir() { + return f, nil + } + + baseDir, _ := m.base.Open(name) + defer func() { + _ = baseDir.Close() + _ = f.Close() + }() + return m.mergeDirs(name, info, baseDir, f) +} + +func (m MergeFS) mergeDirs(name string, info os.FileInfo, baseDir http.File, overlayDir http.File) (http.File, error) { + merged := map[string]os.FileInfo{} + + baseFiles, err := baseDir.Readdir(-1) + if err != nil { + return nil, err + } + sort.Slice(baseFiles, func(i, j int) bool { return baseFiles[i].Name() < baseFiles[j].Name() }) + + overlayFiles, err := overlayDir.Readdir(-1) + if err != nil { + overlayFiles = nil + } + sort.Slice(overlayFiles, func(i, j int) bool { return overlayFiles[i].Name() < overlayFiles[j].Name() }) + + for _, f := range baseFiles { + merged[f.Name()] = f + } + for _, f := range overlayFiles { + merged[f.Name()] = f + } + + var entries []os.FileInfo + for _, i := range merged { + entries = append(entries, i) + } + + sort.Slice(entries, func(i, j int) bool { return entries[i].Name() < entries[j].Name() }) + return &mergedDir{ + name: name, + info: info, + entries: entries, + }, nil +} + +type mergedDir struct { + name string + info os.FileInfo + entries []os.FileInfo + pos int +} + +func (d mergedDir) Readdir(count int) ([]os.FileInfo, error) { + if d.pos >= len(d.entries) && count > 0 { + return nil, io.EOF + } + if count <= 0 || count > len(d.entries)-d.pos { + count = len(d.entries) - d.pos + } + e := d.entries[d.pos : d.pos+count] + d.pos += count + return e, nil +} + +func (d mergedDir) Close() error { return nil } +func (d mergedDir) Stat() (os.FileInfo, error) { return d.info, nil } +func (d mergedDir) Read(p []byte) (n int, err error) { + return 0, fmt.Errorf("cannot Read from directory %s", d.name) +} +func (d mergedDir) Seek(offset int64, whence int) (int64, error) { + return 0, fmt.Errorf("unsupported Seek in directory %s", d.name) +} diff --git a/utils/merge_fs_test.go b/utils/merge_fs_test.go new file mode 100644 index 000000000..9b7b21786 --- /dev/null +++ b/utils/merge_fs_test.go @@ -0,0 +1,89 @@ +package utils_test + +import ( + "io/ioutil" + "net/http" + "os" + "path/filepath" + + "github.com/deluan/navidrome/utils" + . "github.com/onsi/ginkgo" + . "github.com/onsi/gomega" +) + +var _ = Describe("MergeFS", func() { + var baseName, overlayName string + var baseDir, overlayDir, mergedDir http.FileSystem + + BeforeEach(func() { + baseName, _ = ioutil.TempDir("", "merge_fs_base_test") + overlayName, _ = ioutil.TempDir("", "merge_fs_overlay_test") + baseDir = http.Dir(baseName) + overlayDir = http.Dir(overlayName) + mergedDir = utils.NewMergeFS(baseDir, overlayDir) + }) + + It("reads from base dir if not found in overlay", func() { + _f(baseName, "a.json") + file, err := mergedDir.Open("a.json") + Expect(err).To(BeNil()) + + stat, err := file.Stat() + Expect(err).To(BeNil()) + + Expect(stat.Name()).To(Equal("a.json")) + }) + + It("reads overridden file", func() { + _f(baseName, "b.json", "original") + _f(baseName, "b.json", "overridden") + + file, err := mergedDir.Open("b.json") + Expect(err).To(BeNil()) + + content, err := ioutil.ReadAll(file) + Expect(err).To(BeNil()) + Expect(string(content)).To(Equal("overridden")) + }) + + It("reads only files from base if overlay is empty", func() { + _f(baseName, "test.txt") + + dir, err := mergedDir.Open(".") + Expect(err).To(BeNil()) + + list, err := dir.Readdir(-1) + Expect(err).To(BeNil()) + + Expect(list).To(HaveLen(1)) + Expect(list[0].Name()).To(Equal("test.txt")) + }) + + It("reads merged dirs", func() { + _f(baseName, "1111.txt") + _f(overlayName, "2222.json") + + dir, err := mergedDir.Open(".") + Expect(err).To(BeNil()) + + list, err := dir.Readdir(-1) + Expect(err).To(BeNil()) + + Expect(list).To(HaveLen(2)) + Expect(list[0].Name()).To(Equal("1111.txt")) + Expect(list[1].Name()).To(Equal("2222.json")) + }) +}) + +func _f(dir, name string, content ...string) string { + path := filepath.Join(dir, name) + file, err := os.Create(path) + if err != nil { + panic(err) + } + if len(content) > 0 { + _, _ = file.WriteString(content[0]) + } + _ = file.Close() + return path +}