Skip to content

Commit

Permalink
WIP - Storage
Browse files Browse the repository at this point in the history
  • Loading branch information
deluan committed May 19, 2024
1 parent 7920b59 commit b17e63a
Show file tree
Hide file tree
Showing 17 changed files with 356 additions and 92 deletions.
9 changes: 5 additions & 4 deletions adapters/taglib/taglib.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"strings"
"time"

"github.com/navidrome/navidrome/core/storage/local"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model/tag"
"github.com/navidrome/navidrome/scanner/metadata"
Expand Down Expand Up @@ -63,7 +64,7 @@ func (e extractor) extractMetadata(filePath string) (*tag.Properties, error) {

// Adjust some ID3 tags
parseTIPL(tags)
delete(tags, "tmcl") // TMCL is already parsed by extractor
delete(tags, "tmcl") // TMCL is already parsed by TagLib

return &tag.Properties{
Tags: tags,
Expand All @@ -82,7 +83,7 @@ var tiplMapping = map[string]string{
"dj-mix": "djmixer",
}

// parseTIPL parses the ID3v2.4 TIPL frame string, which is received from extractor in the format
// parseTIPL parses the ID3v2.4 TIPL frame string, which is received from TagLib in the format:
//
// "arranger Andrew Powell engineer Chris Blair engineer Pat Stapley producer Eric Woolfson".
//
Expand Down Expand Up @@ -117,10 +118,10 @@ func parseTIPL(tags metadata.ParsedTags) {
delete(tags, "tipl")
}

var _ tag.Extractor = (*extractor)(nil)
var _ local.Extractor = (*extractor)(nil)

func init() {
tag.RegisterExtractor("taglib", func(_ fs.FS, baseDir string) tag.Extractor {
local.RegisterExtractor("taglib", func(_ fs.FS, baseDir string) local.Extractor {
// ignores fs, as taglib extractor only works with local files
return &extractor{baseDir}
})
Expand Down
5 changes: 5 additions & 0 deletions cmd/registry.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package cmd

import (
_ "github.com/navidrome/navidrome/adapters/taglib"
)
28 changes: 28 additions & 0 deletions core/storage/interface.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package storage

import (
"io/fs"

"github.com/navidrome/navidrome/model/tag"
)

type Storage interface {
FS() (MusicFS, error)
}

// MusicFS is an interface that extends the fs.FS interface with the ability to read tags from files
type MusicFS interface {
fs.FS
ReadTags(path ...string) (map[string]tag.Properties, error)
}

// WatcherFS is an interface that extends the fs.FS interface with the ability to start and stop a fs watcher.
type WatcherFS interface {
fs.FS

// StartWatcher starts a watcher on the whole FS and returns a channel to send detected changes
StartWatcher() (chan<- string, error)

// StopWatcher stops the watcher
StopWatcher()
}
29 changes: 29 additions & 0 deletions core/storage/local/extractors.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
package local

import (
"io/fs"
"sync"

"github.com/navidrome/navidrome/model/tag"
)

// Extractor is an interface that defines the methods that an tag/metadata extractor must implement
type Extractor interface {
Parse(files ...string) (map[string]tag.Properties, error)
Version() string
}

type extractorConstructor func(fs.FS, string) Extractor

var (
extractors = map[string]extractorConstructor{}
lock sync.RWMutex
)

// RegisterExtractor registers a new extractor, so it can be used by the local storage. The one to be used is
// defined with the configuration option Scanner.Extractor.
func RegisterExtractor(id string, f extractorConstructor) {
lock.Lock()
defer lock.Unlock()
extractors[id] = f
}
79 changes: 79 additions & 0 deletions core/storage/local/local.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
package local

import (
"fmt"
"io/fs"
"net/url"
"os"
"time"

"github.com/djherbis/times"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/core/storage"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model/tag"
)

// localStorage implements a Storage that reads the files from the local filesystem and uses registered extractors
// to extract the metadata and tags from the files.
type localStorage struct {
u url.URL
extractor Extractor
}

func newLocalStorage(u url.URL) storage.Storage {
newExtractor, ok := extractors[conf.Server.Scanner.Extractor]
if !ok || newExtractor == nil {
log.Fatal("Extractor not found: %s", conf.Server.Scanner.Extractor)
}
return localStorage{u: u, extractor: newExtractor(os.DirFS(u.Path), u.Path)}
}

func (s localStorage) FS() (storage.MusicFS, error) {
path := s.u.Path
if _, err := os.Stat(path); err != nil {
return nil, fmt.Errorf("%w: %s", err, path)
}
return localFS{FS: os.DirFS(path), extractor: s.extractor}, nil
}

type localFS struct {
fs.FS
extractor Extractor
}

func (lfs localFS) ReadTags(path ...string) (map[string]tag.Properties, error) {
res, err := lfs.extractor.Parse(path...)
if err != nil {
return nil, err
}
for path, v := range res {
if v.FileInfo == nil {
info, err := fs.Stat(lfs, path)
if err != nil {
return nil, err
}
v.FileInfo = localFileInfo{info}
res[path] = v
}
}
return res, nil
}

// localFileInfo is a wrapper around fs.FileInfo that adds a BirthTime method, to make it compatible
// with tag.FileInfo
type localFileInfo struct {
fs.FileInfo
}

func (lfi localFileInfo) BirthTime() time.Time {
if ts := times.Get(lfi.FileInfo); ts.HasBirthTime() {
return ts.BirthTime()
}

return time.Time{}
}

func init() {
storage.Register(storage.LocalSchemaID, newLocalStorage)
}
46 changes: 46 additions & 0 deletions core/storage/storage.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
package storage

import (
"errors"
"net/url"
"path/filepath"
"strings"
"sync"
)

const LocalSchemaID = "file"

type constructor func(url.URL) Storage

var (
registry = map[string]constructor{}
lock sync.RWMutex
)

func Register(schema string, c constructor) {
lock.Lock()
defer lock.Unlock()
registry[schema] = c
}

func For(uri string) (Storage, error) {
lock.RLock()
defer lock.RUnlock()
parts := strings.Split(uri, "://")

// Paths without schema are treated as file:// and use the default LocalStorage implementation
if len(parts) < 2 {
uri, _ = filepath.Abs(uri)
uri = LocalSchemaID + "://" + uri
}

u, err := url.Parse(uri)
if err != nil {
return nil, err
}
c, ok := registry[u.Scheme]
if !ok {
return nil, errors.New("schema '" + u.Scheme + "' not registered")
}
return c(*u), nil
}
78 changes: 78 additions & 0 deletions core/storage/storage_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
package storage

import (
"net/url"
"os"
"path/filepath"
"testing"

. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)

func TestApp(t *testing.T) {
RegisterFailHandler(Fail)
RunSpecs(t, "Storage Test Suite")
}

var _ = Describe("Storage", func() {
When("schema is not registered", func() {
BeforeEach(func() {
registry = map[string]constructor{}
})

It("should return error", func() {
_, err := For("file:///tmp")
Expect(err).To(HaveOccurred())
})
})
When("schema is registered", func() {
BeforeEach(func() {
registry = map[string]constructor{}
Register("file", func(url url.URL) Storage { return &fakeLocalStorage{u: url} })
Register("s3", func(url url.URL) Storage { return &fakeS3Storage{u: url} })
})

It("should return correct implementation", func() {
s, err := For("file:///tmp")
Expect(err).ToNot(HaveOccurred())
Expect(s).To(BeAssignableToTypeOf(&fakeLocalStorage{}))
Expect(s.(*fakeLocalStorage).u.Scheme).To(Equal("file"))
Expect(s.(*fakeLocalStorage).u.Path).To(Equal("/tmp"))

s, err = For("s3:///bucket")
Expect(err).ToNot(HaveOccurred())
Expect(s).To(BeAssignableToTypeOf(&fakeS3Storage{}))
Expect(s.(*fakeS3Storage).u.Scheme).To(Equal("s3"))
Expect(s.(*fakeS3Storage).u.Path).To(Equal("/bucket"))
})
It("should return a file implementation when schema is not specified", func() {
s, err := For("/tmp")
Expect(err).ToNot(HaveOccurred())
Expect(s).To(BeAssignableToTypeOf(&fakeLocalStorage{}))
Expect(s.(*fakeLocalStorage).u.Scheme).To(Equal("file"))
Expect(s.(*fakeLocalStorage).u.Path).To(Equal("/tmp"))
})
It("should return a file implementation for a relative folder", func() {
s, err := For("tmp")
Expect(err).ToNot(HaveOccurred())
cwd, _ := os.Getwd()
Expect(s).To(BeAssignableToTypeOf(&fakeLocalStorage{}))
Expect(s.(*fakeLocalStorage).u.Scheme).To(Equal("file"))
Expect(s.(*fakeLocalStorage).u.Path).To(Equal(filepath.Join(cwd, "tmp")))
})
It("should return error if schema is unregistered", func() {
_, err := For("webdav:///tmp")
Expect(err).To(HaveOccurred())
})
})
})

type fakeLocalStorage struct {
Storage
u url.URL
}
type fakeS3Storage struct {
Storage
u url.URL
}
1 change: 0 additions & 1 deletion main.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ package main
import (
_ "net/http/pprof" //nolint:gosec

_ "github.com/navidrome/navidrome/adapters/taglib"
"github.com/navidrome/navidrome/cmd"
)

Expand Down
36 changes: 0 additions & 36 deletions model/tag/extractors.go

This file was deleted.

0 comments on commit b17e63a

Please sign in to comment.