feat(subsonic): make it easier to add more tag reading backends

related https://github.com/sentriz/gonic/issues/379
related https://github.com/sentriz/gonic/issues/324
related https://github.com/sentriz/gonic/issues/244
This commit is contained in:
sentriz
2023-10-02 20:02:38 +01:00
parent ae82153d79
commit 8382f6123c
18 changed files with 370 additions and 383 deletions

View File

@@ -21,7 +21,8 @@ RUN apk add -U --no-cache \
mpv \ mpv \
ca-certificates \ ca-certificates \
tzdata \ tzdata \
tini tini \
shared-mime-info
COPY --from=builder \ COPY --from=builder \
/usr/lib/libgcc_s.so.1 \ /usr/lib/libgcc_s.so.1 \

View File

@@ -5,5 +5,6 @@ RUN apk add -U --no-cache \
git \ git \
sqlite \ sqlite \
taglib-dev \ taglib-dev \
zlib-dev zlib-dev \
shared-mime-info
WORKDIR /src WORKDIR /src

View File

@@ -18,7 +18,8 @@ FROM alpine:3.18
RUN apk add -U --no-cache \ RUN apk add -U --no-cache \
ffmpeg \ ffmpeg \
mpv \ mpv \
ca-certificates ca-certificates \
shared-mime-info
COPY --from=builder \ COPY --from=builder \
/usr/lib/libgcc_s.so.1 \ /usr/lib/libgcc_s.so.1 \
/usr/lib/libstdc++.so.6 \ /usr/lib/libstdc++.so.6 \

View File

@@ -35,7 +35,8 @@ import (
"go.senan.xyz/gonic/playlist" "go.senan.xyz/gonic/playlist"
"go.senan.xyz/gonic/podcasts" "go.senan.xyz/gonic/podcasts"
"go.senan.xyz/gonic/scanner" "go.senan.xyz/gonic/scanner"
"go.senan.xyz/gonic/scanner/tags" "go.senan.xyz/gonic/scanner/tags/tagcommon"
"go.senan.xyz/gonic/scanner/tags/taglib"
"go.senan.xyz/gonic/scrobble" "go.senan.xyz/gonic/scrobble"
"go.senan.xyz/gonic/server/ctrladmin" "go.senan.xyz/gonic/server/ctrladmin"
"go.senan.xyz/gonic/server/ctrlsubsonic" "go.senan.xyz/gonic/server/ctrlsubsonic"
@@ -167,7 +168,12 @@ func main() {
log.Printf(" %-25s %s\n", f.Name, value) log.Printf(" %-25s %s\n", f.Name, value)
}) })
tagger := &tags.TagReader{} tagReader := tagcommon.ChainReader{
taglib.TagLib{},
// ffprobe reader?
// nfo reader?
}
scannr := scanner.New( scannr := scanner.New(
ctrlsubsonic.MusicPaths(musicPaths), ctrlsubsonic.MusicPaths(musicPaths),
dbc, dbc,
@@ -175,10 +181,10 @@ func main() {
scanner.Genre: scanner.MultiValueSetting(confMultiValueGenre), scanner.Genre: scanner.MultiValueSetting(confMultiValueGenre),
scanner.AlbumArtist: scanner.MultiValueSetting(confMultiValueAlbumArtist), scanner.AlbumArtist: scanner.MultiValueSetting(confMultiValueAlbumArtist),
}, },
tagger, tagReader,
*confExcludePatterns, *confExcludePatterns,
) )
podcast := podcasts.New(dbc, *confPodcastPath, tagger) podcast := podcasts.New(dbc, *confPodcastPath, tagReader)
transcoder := transcode.NewCachingTranscoder( transcoder := transcode.NewCachingTranscoder(
transcode.NewFFmpegTranscoder(), transcode.NewFFmpegTranscoder(),
cacheDirAudio, cacheDirAudio,

View File

@@ -3,13 +3,12 @@ package db
import ( import (
"fmt" "fmt"
"mime"
"path/filepath" "path/filepath"
"sort" "sort"
"strings" "strings"
"time" "time"
"go.senan.xyz/gonic/mime"
// TODO: remove this dep // TODO: remove this dep
"go.senan.xyz/gonic/server/ctrlsubsonic/specid" "go.senan.xyz/gonic/server/ctrlsubsonic/specid"
) )

View File

@@ -1,43 +0,0 @@
//nolint:gochecknoglobals
package mime
import (
"log"
stdmime "mime"
"strings"
)
//nolint:gochecknoinits
func init() {
for ext, mime := range supportedAudioTypes {
if err := stdmime.AddExtensionType(ext, mime); err != nil {
log.Fatalf("adding audio type mime for ext %q: %v", ext, err)
}
}
}
var (
TypeByExtension = stdmime.TypeByExtension
ParseMediaType = stdmime.ParseMediaType
FormatMediaType = stdmime.FormatMediaType
)
func TypeByAudioExtension(ext string) string {
if _, ok := supportedAudioTypes[strings.ToLower(ext)]; !ok {
return ""
}
return stdmime.TypeByExtension(ext)
}
var supportedAudioTypes = map[string]string{
".mp3": "audio/mpeg",
".flac": "audio/x-flac",
".aac": "audio/x-aac",
".m4a": "audio/m4a",
".m4b": "audio/m4b",
".ogg": "audio/ogg",
".opus": "audio/ogg",
".wma": "audio/x-ms-wma",
".wav": "audio/x-wav",
".wv": "audio/x-wavpack",
}

View File

@@ -15,7 +15,7 @@ import (
"github.com/mattn/go-sqlite3" "github.com/mattn/go-sqlite3"
"go.senan.xyz/gonic/db" "go.senan.xyz/gonic/db"
"go.senan.xyz/gonic/scanner" "go.senan.xyz/gonic/scanner"
"go.senan.xyz/gonic/scanner/tags" "go.senan.xyz/gonic/scanner/tags/tagcommon"
) )
var ErrPathNotFound = errors.New("path not found") var ErrPathNotFound = errors.New("path not found")
@@ -69,7 +69,7 @@ func newMockFS(tb testing.TB, dirs []string, excludePattern string) *MockFS {
scanner.AlbumArtist: {Mode: scanner.Multi}, scanner.AlbumArtist: {Mode: scanner.Multi},
} }
tagReader := &tagReader{paths: map[string]*tagReaderResult{}} tagReader := &tagReader{paths: map[string]*TagInfo{}}
scanner := scanner.New(absDirs, dbc, multiValueSettings, tagReader, excludePattern) scanner := scanner.New(absDirs, dbc, multiValueSettings, tagReader, excludePattern)
return &MockFS{ return &MockFS{
@@ -81,11 +81,13 @@ func newMockFS(tb testing.TB, dirs []string, excludePattern string) *MockFS {
} }
} }
func (m *MockFS) DB() *db.DB { return m.db } func (m *MockFS) DB() *db.DB { return m.db }
func (m *MockFS) TmpDir() string { return m.dir } func (m *MockFS) TmpDir() string { return m.dir }
func (m *MockFS) TagReader() tags.Reader { return m.tagReader } func (m *MockFS) TagReader() tagcommon.Reader { return m.tagReader }
func (m *MockFS) ScanAndClean() *scanner.Context { func (m *MockFS) ScanAndClean() *scanner.Context {
m.t.Helper()
ctx, err := m.scanner.ScanAndClean(scanner.ScanOptions{}) ctx, err := m.scanner.ScanAndClean(scanner.ScanOptions{})
if err != nil { if err != nil {
m.t.Fatalf("error scan and cleaning: %v", err) m.t.Fatalf("error scan and cleaning: %v", err)
@@ -94,6 +96,8 @@ func (m *MockFS) ScanAndClean() *scanner.Context {
} }
func (m *MockFS) ScanAndCleanErr() (*scanner.Context, error) { func (m *MockFS) ScanAndCleanErr() (*scanner.Context, error) {
m.t.Helper()
return m.scanner.ScanAndClean(scanner.ScanOptions{}) return m.scanner.ScanAndClean(scanner.ScanOptions{})
} }
@@ -126,12 +130,11 @@ func (m *MockFS) addItems(prefix string, onlyGlob string, covers bool) {
} }
m.AddTrack(path) m.AddTrack(path)
m.SetTags(path, func(tags *Tags) error { m.SetTags(path, func(tags *TagInfo) {
tags.RawArtist = fmt.Sprintf("artist-%d", ar) tags.RawArtist = fmt.Sprintf("artist-%d", ar)
tags.RawAlbumArtist = fmt.Sprintf("artist-%d", ar) tags.RawAlbumArtist = fmt.Sprintf("artist-%d", ar)
tags.RawAlbum = fmt.Sprintf("album-%d", al) tags.RawAlbum = fmt.Sprintf("album-%d", al)
tags.RawTitle = fmt.Sprintf("title-%d", tr) tags.RawTitle = fmt.Sprintf("title-%d", tr)
return nil
}) })
} }
if covers { if covers {
@@ -180,10 +183,9 @@ func (m *MockFS) SetRealAudio(path string, length int, audioPath string) {
if err := os.Symlink(filepath.Join(wd, audioPath), abspath); err != nil { if err := os.Symlink(filepath.Join(wd, audioPath), abspath); err != nil {
m.t.Fatalf("symlink: %v", err) m.t.Fatalf("symlink: %v", err)
} }
m.SetTags(path, func(tags *Tags) error { m.SetTags(path, func(tags *TagInfo) {
tags.RawLength = length tags.RawLength = length
tags.RawBitrate = 0 tags.RawBitrate = 0
return nil
}) })
} }
@@ -288,18 +290,15 @@ func (m *MockFS) AddCover(path string) {
defer f.Close() defer f.Close()
} }
func (m *MockFS) SetTags(path string, cb func(*Tags) error) { func (m *MockFS) SetTags(path string, cb func(*TagInfo)) {
abspath := filepath.Join(m.dir, path) absPath := filepath.Join(m.dir, path)
if err := os.Chtimes(abspath, time.Time{}, time.Now()); err != nil { if err := os.Chtimes(absPath, time.Time{}, time.Now()); err != nil {
m.t.Fatalf("touch track: %v", err) m.t.Fatalf("touch track: %v", err)
} }
r := m.tagReader if _, ok := m.tagReader.paths[absPath]; !ok {
if _, ok := r.paths[abspath]; !ok { m.tagReader.paths[absPath] = &TagInfo{}
r.paths[abspath] = &tagReaderResult{tags: &Tags{}}
}
if err := cb(r.paths[abspath].tags); err != nil {
r.paths[abspath].err = err
} }
cb(m.tagReader.paths[absPath])
} }
func (m *MockFS) DumpDB(suffix ...string) { func (m *MockFS) DumpDB(suffix ...string) {
@@ -353,54 +352,54 @@ func (m *MockFS) DumpDB(suffix ...string) {
m.t.Error(destPath) m.t.Error(destPath)
} }
type tagReaderResult struct {
tags *Tags
err error
}
type tagReader struct { type tagReader struct {
paths map[string]*tagReaderResult paths map[string]*TagInfo
} }
func (m *tagReader) Read(abspath string) (tags.Parser, error) { func (m *tagReader) CanRead(absPath string) bool {
p, ok := m.paths[abspath] stat, _ := os.Stat(absPath)
return stat.Mode().IsRegular()
}
func (m *tagReader) Read(absPath string) (tagcommon.Info, error) {
p, ok := m.paths[absPath]
if !ok { if !ok {
return nil, ErrPathNotFound return nil, ErrPathNotFound
} }
return p.tags, p.err if p.Error != nil {
return nil, p.Error
}
return p, nil
} }
var _ tags.Reader = (*tagReader)(nil) type TagInfo struct {
type Tags struct {
RawTitle string RawTitle string
RawArtist string RawArtist string
RawAlbum string RawAlbum string
RawAlbumArtist string RawAlbumArtist string
RawAlbumArtists []string RawAlbumArtists []string
RawGenre string RawGenre string
RawBitrate int
RawBitrate int RawLength int
RawLength int Error error
} }
func (m *Tags) Title() string { return m.RawTitle } func (i *TagInfo) Title() string { return i.RawTitle }
func (m *Tags) BrainzID() string { return "" } func (i *TagInfo) BrainzID() string { return "" }
func (m *Tags) Artist() string { return m.RawArtist } func (i *TagInfo) Artist() string { return i.RawArtist }
func (m *Tags) Album() string { return m.RawAlbum } func (i *TagInfo) Album() string { return i.RawAlbum }
func (m *Tags) AlbumArtist() string { return m.RawAlbumArtist } func (i *TagInfo) AlbumArtist() string { return i.RawAlbumArtist }
func (m *Tags) AlbumArtists() []string { return m.RawAlbumArtists } func (i *TagInfo) AlbumArtists() []string { return i.RawAlbumArtists }
func (m *Tags) AlbumBrainzID() string { return "" } func (i *TagInfo) AlbumBrainzID() string { return "" }
func (m *Tags) Genre() string { return m.RawGenre } func (i *TagInfo) Genre() string { return i.RawGenre }
func (m *Tags) Genres() []string { return []string{m.RawGenre} } func (i *TagInfo) Genres() []string { return []string{i.RawGenre} }
func (m *Tags) TrackNumber() int { return 1 } func (i *TagInfo) TrackNumber() int { return 1 }
func (m *Tags) DiscNumber() int { return 1 } func (i *TagInfo) DiscNumber() int { return 1 }
func (m *Tags) Year() int { return 2021 } func (i *TagInfo) Year() int { return 2021 }
func (i *TagInfo) Length() int { return firstInt(100, i.RawLength) }
func (i *TagInfo) Bitrate() int { return firstInt(100, i.RawBitrate) }
func (m *Tags) Length() int { return firstInt(100, m.RawLength) } var _ tagcommon.Reader = (*tagReader)(nil)
func (m *Tags) Bitrate() int { return firstInt(100, m.RawBitrate) }
var _ tags.Parser = (*Tags)(nil)
func firstInt(or int, ints ...int) int { func firstInt(or int, ints ...int) int {
for _, int := range ints { for _, int := range ints {

View File

@@ -5,6 +5,7 @@ import (
"fmt" "fmt"
"io" "io"
"log" "log"
"mime"
"net/http" "net/http"
"net/url" "net/url"
"os" "os"
@@ -19,8 +20,7 @@ import (
"go.senan.xyz/gonic/db" "go.senan.xyz/gonic/db"
"go.senan.xyz/gonic/fileutil" "go.senan.xyz/gonic/fileutil"
"go.senan.xyz/gonic/mime" "go.senan.xyz/gonic/scanner/tags/tagcommon"
"go.senan.xyz/gonic/scanner/tags"
) )
var ErrNoAudioInFeedItem = errors.New("no audio in feed item") var ErrNoAudioInFeedItem = errors.New("no audio in feed item")
@@ -31,16 +31,16 @@ const (
) )
type Podcasts struct { type Podcasts struct {
db *db.DB db *db.DB
baseDir string baseDir string
tagger tags.Reader tagReader tagcommon.Reader
} }
func New(db *db.DB, base string, tagger tags.Reader) *Podcasts { func New(db *db.DB, base string, tagReader tagcommon.Reader) *Podcasts {
return &Podcasts{ return &Podcasts{
db: db, db: db,
baseDir: base, baseDir: base,
tagger: tagger, tagReader: tagReader,
} }
} }
@@ -239,13 +239,12 @@ func (p *Podcasts) AddEpisode(podcastID int, item *gofeed.Item) (*db.PodcastEpis
return nil, ErrNoAudioInFeedItem return nil, ErrNoAudioInFeedItem
} }
func isAudio(rawItemURL string) (bool, error) { func (p *Podcasts) isAudio(rawItemURL string) (bool, error) {
itemURL, err := url.Parse(rawItemURL) itemURL, err := url.Parse(rawItemURL)
if err != nil { if err != nil {
return false, err return false, err
} }
return p.tagReader.CanRead(itemURL.Path), nil
return mime.TypeByAudioExtension(path.Ext(itemURL.Path)) != "", nil
} }
func itemToEpisode(podcastID, size, duration int, audio string, func itemToEpisode(podcastID, size, duration int, audio string,
@@ -265,7 +264,7 @@ func itemToEpisode(podcastID, size, duration int, audio string,
func (p *Podcasts) findEnclosureAudio(podcastID, duration int, item *gofeed.Item) (*db.PodcastEpisode, bool) { func (p *Podcasts) findEnclosureAudio(podcastID, duration int, item *gofeed.Item) (*db.PodcastEpisode, bool) {
for _, enc := range item.Enclosures { for _, enc := range item.Enclosures {
if t, err := isAudio(enc.URL); !t || err != nil { if t, err := p.isAudio(enc.URL); !t || err != nil {
continue continue
} }
size, _ := strconv.Atoi(enc.Length) size, _ := strconv.Atoi(enc.Length)
@@ -280,7 +279,7 @@ func (p *Podcasts) findMediaAudio(podcastID, duration int, item *gofeed.Item) (*
return nil, false return nil, false
} }
for _, ext := range extensions { for _, ext := range extensions {
if t, err := isAudio(ext.Attrs["url"]); !t || err != nil { if t, err := p.isAudio(ext.Attrs["url"]); !t || err != nil {
continue continue
} }
return itemToEpisode(podcastID, 0, duration, ext.Attrs["url"], item), true return itemToEpisode(podcastID, 0, duration, ext.Attrs["url"], item), true
@@ -415,29 +414,33 @@ func (p *Podcasts) downloadPodcastCover(podcast *db.Podcast) error {
if err != nil { if err != nil {
return fmt.Errorf("parse image url: %w", err) return fmt.Errorf("parse image url: %w", err)
} }
ext := path.Ext(imageURL.Path)
client := &http.Client{}
req, err := http.NewRequest("GET", podcast.ImageURL, nil) req, err := http.NewRequest("GET", podcast.ImageURL, nil)
if err != nil { if err != nil {
return fmt.Errorf("create http request: %w", err) return fmt.Errorf("create http request: %w", err)
} }
req.Header.Add("User-Agent", fetchUserAgent) req.Header.Add("User-Agent", fetchUserAgent)
// nolint: bodyclose
client := &http.Client{}
resp, err := client.Do(req) resp, err := client.Do(req)
if err != nil { if err != nil {
return fmt.Errorf("fetch image url: %w", err) return fmt.Errorf("fetch image url: %w", err)
} }
defer resp.Body.Close()
ext := path.Ext(imageURL.Path)
if ext == "" { if ext == "" {
contentHeader := resp.Header.Get("content-disposition") contentHeader := resp.Header.Get("content-disposition")
filename, _ := getContentDispositionFilename(contentHeader) filename, _ := getContentDispositionFilename(contentHeader)
ext = filepath.Ext(filename) ext = filepath.Ext(filename)
} }
cover := "cover" + ext cover := "cover" + ext
coverFile, err := os.Create(filepath.Join(podcast.RootDir, cover)) coverFile, err := os.Create(filepath.Join(podcast.RootDir, cover))
if err != nil { if err != nil {
return fmt.Errorf("creating podcast cover: %w", err) return fmt.Errorf("creating podcast cover: %w", err)
} }
defer coverFile.Close() defer coverFile.Close()
if _, err := io.Copy(coverFile, resp.Body); err != nil { if _, err := io.Copy(coverFile, resp.Body); err != nil {
return fmt.Errorf("writing podcast cover: %w", err) return fmt.Errorf("writing podcast cover: %w", err)
} }
@@ -453,19 +456,25 @@ func (p *Podcasts) doPodcastDownload(podcastEpisode *db.PodcastEpisode, file *os
return fmt.Errorf("writing podcast episode: %w", err) return fmt.Errorf("writing podcast episode: %w", err)
} }
defer file.Close() defer file.Close()
stat, _ := file.Stat()
podcastTags, err := p.tagger.Read(podcastEpisode.AbsPath()) podcastTags, err := p.tagReader.Read(podcastEpisode.AbsPath())
if err != nil { if err != nil {
log.Printf("error parsing podcast audio: %e", err) log.Printf("error parsing podcast audio: %e", err)
podcastEpisode.Status = db.PodcastEpisodeStatusError podcastEpisode.Status = db.PodcastEpisodeStatusError
p.db.Save(podcastEpisode) p.db.Save(podcastEpisode)
return nil return nil
} }
stat, _ := file.Stat()
podcastEpisode.Bitrate = podcastTags.Bitrate() podcastEpisode.Bitrate = podcastTags.Bitrate()
podcastEpisode.Status = db.PodcastEpisodeStatusCompleted podcastEpisode.Status = db.PodcastEpisodeStatusCompleted
podcastEpisode.Length = podcastTags.Length() podcastEpisode.Length = podcastTags.Length()
podcastEpisode.Size = int(stat.Size()) podcastEpisode.Size = int(stat.Size())
return p.db.Save(podcastEpisode).Error
if err := p.db.Save(podcastEpisode).Error; err != nil {
return fmt.Errorf("save podcast episode: %w", err)
}
return nil
} }
func (p *Podcasts) DeletePodcast(podcastID int) error { func (p *Podcasts) DeletePodcast(podcastID int) error {

View File

@@ -19,8 +19,7 @@ import (
"github.com/rainycape/unidecode" "github.com/rainycape/unidecode"
"go.senan.xyz/gonic/db" "go.senan.xyz/gonic/db"
"go.senan.xyz/gonic/mime" "go.senan.xyz/gonic/scanner/tags/tagcommon"
"go.senan.xyz/gonic/scanner/tags"
) )
var ( var (
@@ -32,7 +31,7 @@ type Scanner struct {
db *db.DB db *db.DB
musicDirs []string musicDirs []string
multiValueSettings map[Tag]MultiValueSetting multiValueSettings map[Tag]MultiValueSetting
tagger tags.Reader tagReader tagcommon.Reader
excludePattern *regexp.Regexp excludePattern *regexp.Regexp
scanning *int32 scanning *int32
watcher *fsnotify.Watcher watcher *fsnotify.Watcher
@@ -40,7 +39,7 @@ type Scanner struct {
watchDone chan bool watchDone chan bool
} }
func New(musicDirs []string, db *db.DB, multiValueSettings map[Tag]MultiValueSetting, tagger tags.Reader, excludePattern string) *Scanner { func New(musicDirs []string, db *db.DB, multiValueSettings map[Tag]MultiValueSetting, tagReader tagcommon.Reader, excludePattern string) *Scanner {
var excludePatternRegExp *regexp.Regexp var excludePatternRegExp *regexp.Regexp
if excludePattern != "" { if excludePattern != "" {
excludePatternRegExp = regexp.MustCompile(excludePattern) excludePatternRegExp = regexp.MustCompile(excludePattern)
@@ -50,7 +49,7 @@ func New(musicDirs []string, db *db.DB, multiValueSettings map[Tag]MultiValueSet
db: db, db: db,
musicDirs: musicDirs, musicDirs: musicDirs,
multiValueSettings: multiValueSettings, multiValueSettings: multiValueSettings,
tagger: tagger, tagReader: tagReader,
excludePattern: excludePatternRegExp, excludePattern: excludePatternRegExp,
scanning: new(int32), scanning: new(int32),
watchMap: make(map[string]string), watchMap: make(map[string]string),
@@ -282,9 +281,12 @@ func (s *Scanner) scanDir(tx *db.DB, c *Context, musicDir string, absPath string
var tracks []string var tracks []string
var cover string var cover string
for _, item := range items { for _, item := range items {
fullpath := filepath.Join(absPath, item.Name()) absPath := filepath.Join(absPath, item.Name())
if s.excludePattern != nil && s.excludePattern.MatchString(fullpath) { if s.excludePattern != nil && s.excludePattern.MatchString(absPath) {
log.Printf("excluding path `%s`", fullpath) log.Printf("excluding path `%s`", absPath)
continue
}
if item.IsDir() {
continue continue
} }
@@ -292,7 +294,7 @@ func (s *Scanner) scanDir(tx *db.DB, c *Context, musicDir string, absPath string
cover = item.Name() cover = item.Name()
continue continue
} }
if mime := mime.TypeByAudioExtension(filepath.Ext(item.Name())); mime != "" { if s.tagReader.CanRead(absPath) {
tracks = append(tracks, item.Name()) tracks = append(tracks, item.Name())
continue continue
} }
@@ -342,12 +344,12 @@ func (s *Scanner) populateTrackAndAlbumArtists(tx *db.DB, c *Context, i int, alb
return nil return nil
} }
trags, err := s.tagger.Read(absPath) trags, err := s.tagReader.Read(absPath)
if err != nil { if err != nil {
return fmt.Errorf("%w: %w", err, ErrReadingTags) return fmt.Errorf("%w: %w", err, ErrReadingTags)
} }
genreNames := parseMulti(trags, s.multiValueSettings[Genre], tags.MustGenres, tags.MustGenre) genreNames := parseMulti(trags, s.multiValueSettings[Genre], tagcommon.MustGenres, tagcommon.MustGenre)
genreIDs, err := populateGenres(tx, genreNames) genreIDs, err := populateGenres(tx, genreNames)
if err != nil { if err != nil {
return fmt.Errorf("populate genres: %w", err) return fmt.Errorf("populate genres: %w", err)
@@ -355,7 +357,7 @@ func (s *Scanner) populateTrackAndAlbumArtists(tx *db.DB, c *Context, i int, alb
// metadata for the album table comes only from the first track's tags // metadata for the album table comes only from the first track's tags
if i == 0 { if i == 0 {
albumArtistNames := parseMulti(trags, s.multiValueSettings[AlbumArtist], tags.MustAlbumArtists, tags.MustAlbumArtist) albumArtistNames := parseMulti(trags, s.multiValueSettings[AlbumArtist], tagcommon.MustAlbumArtists, tagcommon.MustAlbumArtist)
var albumArtistIDs []int var albumArtistIDs []int
for _, albumArtistName := range albumArtistNames { for _, albumArtistName := range albumArtistNames {
albumArtist, err := populateArtist(tx, albumArtistName) albumArtist, err := populateArtist(tx, albumArtistName)
@@ -390,8 +392,8 @@ func (s *Scanner) populateTrackAndAlbumArtists(tx *db.DB, c *Context, i int, alb
return nil return nil
} }
func populateAlbum(tx *db.DB, album *db.Album, trags tags.Parser, modTime time.Time) error { func populateAlbum(tx *db.DB, album *db.Album, trags tagcommon.Info, modTime time.Time) error {
albumName := tags.MustAlbum(trags) albumName := tagcommon.MustAlbum(trags)
album.TagTitle = albumName album.TagTitle = albumName
album.TagTitleUDec = decoded(albumName) album.TagTitleUDec = decoded(albumName)
album.TagBrainzID = trags.AlbumBrainzID() album.TagBrainzID = trags.AlbumBrainzID()
@@ -431,7 +433,7 @@ func populateAlbumBasics(tx *db.DB, musicDir string, parent, album *db.Album, di
return nil return nil
} }
func populateTrack(tx *db.DB, album *db.Album, track *db.Track, trags tags.Parser, absPath string, size int) error { func populateTrack(tx *db.DB, album *db.Album, track *db.Track, trags tagcommon.Info, absPath string, size int) error {
basename := filepath.Base(absPath) basename := filepath.Base(absPath)
track.Filename = basename track.Filename = basename
track.FilenameUDec = decoded(basename) track.FilenameUDec = decoded(basename)
@@ -684,7 +686,7 @@ type MultiValueSetting struct {
Delim string Delim string
} }
func parseMulti(parser tags.Parser, setting MultiValueSetting, getMulti func(tags.Parser) []string, get func(tags.Parser) string) []string { func parseMulti(parser tagcommon.Info, setting MultiValueSetting, getMulti func(tagcommon.Info) []string, get func(tagcommon.Info) string) []string {
var parts []string var parts []string
switch setting.Mode { switch setting.Mode {
case Multi: case Multi:

View File

@@ -33,9 +33,8 @@ func FuzzScanner(f *testing.F) {
for i := 0; i < toAdd; i++ { for i := 0; i < toAdd; i++ {
path := fmt.Sprintf("artist-%d/album-%d/track-%d.flac", i/6, i/3, i) path := fmt.Sprintf("artist-%d/album-%d/track-%d.flac", i/6, i/3, i)
m.AddTrack(path) m.AddTrack(path)
m.SetTags(path, func(tags *mockfs.Tags) error { m.SetTags(path, func(tags *mockfs.TagInfo) {
fuzzStruct(i, data, seed, tags) fuzzStruct(i, data, seed, tags)
return nil
}) })
} }

View File

@@ -12,6 +12,7 @@ import (
"github.com/jinzhu/gorm" "github.com/jinzhu/gorm"
_ "github.com/jinzhu/gorm/dialects/sqlite" _ "github.com/jinzhu/gorm/dialects/sqlite"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.senan.xyz/gonic/db" "go.senan.xyz/gonic/db"
"go.senan.xyz/gonic/mockfs" "go.senan.xyz/gonic/mockfs"
@@ -126,12 +127,11 @@ func TestUpdatedTags(t *testing.T) {
m := mockfs.New(t) m := mockfs.New(t)
m.AddTrack("artist-10/album-10/track-10.flac") m.AddTrack("artist-10/album-10/track-10.flac")
m.SetTags("artist-10/album-10/track-10.flac", func(tags *mockfs.Tags) error { m.SetTags("artist-10/album-10/track-10.flac", func(tags *mockfs.TagInfo) {
tags.RawArtist = "artist" tags.RawArtist = "artist"
tags.RawAlbumArtist = "album-artist" tags.RawAlbumArtist = "album-artist"
tags.RawAlbum = "album" tags.RawAlbum = "album"
tags.RawTitle = "title" tags.RawTitle = "title"
return nil
}) })
m.ScanAndClean() m.ScanAndClean()
@@ -146,12 +146,11 @@ func TestUpdatedTags(t *testing.T) {
assert.NoError(t, m.DB().Joins("JOIN album_artists ON album_artists.artist_id=artists.id").Where("album_artists.album_id=?", track.AlbumID).Limit(1).Find(&trackArtistA).Error) // updated has tags assert.NoError(t, m.DB().Joins("JOIN album_artists ON album_artists.artist_id=artists.id").Where("album_artists.album_id=?", track.AlbumID).Limit(1).Find(&trackArtistA).Error) // updated has tags
assert.Equal(t, "album-artist", trackArtistA.Name) // track has tags assert.Equal(t, "album-artist", trackArtistA.Name) // track has tags
m.SetTags("artist-10/album-10/track-10.flac", func(tags *mockfs.Tags) error { m.SetTags("artist-10/album-10/track-10.flac", func(tags *mockfs.TagInfo) {
tags.RawArtist = "artist-upd" tags.RawArtist = "artist-upd"
tags.RawAlbumArtist = "album-artist-upd" tags.RawAlbumArtist = "album-artist-upd"
tags.RawAlbum = "album-upd" tags.RawAlbum = "album-upd"
tags.RawTitle = "title-upd" tags.RawTitle = "title-upd"
return nil
}) })
m.ScanAndClean() m.ScanAndClean()
@@ -174,9 +173,8 @@ func TestUpdatedAlbumGenre(t *testing.T) {
m := mockfs.New(t) m := mockfs.New(t)
m.AddItems() m.AddItems()
m.SetTags("artist-0/album-0/track-0.flac", func(tags *mockfs.Tags) error { m.SetTags("artist-0/album-0/track-0.flac", func(tags *mockfs.TagInfo) {
tags.RawGenre = "gen-a;gen-b" tags.RawGenre = "gen-a;gen-b"
return nil
}) })
m.ScanAndClean() m.ScanAndClean()
@@ -185,9 +183,8 @@ func TestUpdatedAlbumGenre(t *testing.T) {
assert.NoError(t, m.DB().Preload("Genres").Where("left_path=? AND right_path=?", "artist-0/", "album-0").Find(&album).Error) assert.NoError(t, m.DB().Preload("Genres").Where("left_path=? AND right_path=?", "artist-0/", "album-0").Find(&album).Error)
assert.Equal(t, []string{"gen-a", "gen-b"}, album.GenreStrings()) assert.Equal(t, []string{"gen-a", "gen-b"}, album.GenreStrings())
m.SetTags("artist-0/album-0/track-0.flac", func(tags *mockfs.Tags) error { m.SetTags("artist-0/album-0/track-0.flac", func(tags *mockfs.TagInfo) {
tags.RawGenre = "gen-a-upd;gen-b-upd" tags.RawGenre = "gen-a-upd;gen-b-upd"
return nil
}) })
m.ScanAndClean() m.ScanAndClean()
@@ -274,10 +271,10 @@ func TestGenres(t *testing.T) {
} }
m.AddItems() m.AddItems()
m.SetTags("artist-0/album-0/track-0.flac", func(tags *mockfs.Tags) error { tags.RawGenre = "genre-a;genre-b"; return nil }) m.SetTags("artist-0/album-0/track-0.flac", func(tags *mockfs.TagInfo) { tags.RawGenre = "genre-a;genre-b" })
m.SetTags("artist-0/album-0/track-1.flac", func(tags *mockfs.Tags) error { tags.RawGenre = "genre-c;genre-d"; return nil }) m.SetTags("artist-0/album-0/track-1.flac", func(tags *mockfs.TagInfo) { tags.RawGenre = "genre-c;genre-d" })
m.SetTags("artist-1/album-2/track-0.flac", func(tags *mockfs.Tags) error { tags.RawGenre = "genre-e;genre-f"; return nil }) m.SetTags("artist-1/album-2/track-0.flac", func(tags *mockfs.TagInfo) { tags.RawGenre = "genre-e;genre-f" })
m.SetTags("artist-1/album-2/track-1.flac", func(tags *mockfs.Tags) error { tags.RawGenre = "genre-g;genre-h"; return nil }) m.SetTags("artist-1/album-2/track-1.flac", func(tags *mockfs.TagInfo) { tags.RawGenre = "genre-g;genre-h" })
m.ScanAndClean() m.ScanAndClean()
isGenre("genre-a") // genre exists isGenre("genre-a") // genre exists
@@ -297,7 +294,7 @@ func TestGenres(t *testing.T) {
isAlbumGenre("artist-0/", "album-0", "genre-a") // album genre exists isAlbumGenre("artist-0/", "album-0", "genre-a") // album genre exists
isAlbumGenre("artist-0/", "album-0", "genre-b") // album genre exists isAlbumGenre("artist-0/", "album-0", "genre-b") // album genre exists
m.SetTags("artist-0/album-0/track-0.flac", func(tags *mockfs.Tags) error { tags.RawGenre = "genre-aa;genre-bb"; return nil }) m.SetTags("artist-0/album-0/track-0.flac", func(tags *mockfs.TagInfo) { tags.RawGenre = "genre-aa;genre-bb" })
m.ScanAndClean() m.ScanAndClean()
isTrackGenre("artist-0/", "album-0", "track-0.flac", "genre-aa") // updated track genre exists isTrackGenre("artist-0/", "album-0", "track-0.flac", "genre-aa") // updated track genre exists
@@ -360,12 +357,11 @@ func TestNewAlbumForExistingArtist(t *testing.T) {
for tr := 0; tr < 3; tr++ { for tr := 0; tr < 3; tr++ {
m.AddTrack(fmt.Sprintf("artist-2/new-album/track-%d.mp3", tr)) m.AddTrack(fmt.Sprintf("artist-2/new-album/track-%d.mp3", tr))
m.SetTags(fmt.Sprintf("artist-2/new-album/track-%d.mp3", tr), func(tags *mockfs.Tags) error { m.SetTags(fmt.Sprintf("artist-2/new-album/track-%d.mp3", tr), func(tags *mockfs.TagInfo) {
tags.RawArtist = "artist-2" tags.RawArtist = "artist-2"
tags.RawAlbumArtist = "artist-2" tags.RawAlbumArtist = "artist-2"
tags.RawAlbum = "new-album" tags.RawAlbum = "new-album"
tags.RawTitle = fmt.Sprintf("title-%d", tr) tags.RawTitle = fmt.Sprintf("title-%d", tr)
return nil
}) })
} }
@@ -385,22 +381,20 @@ func TestMultiFolderWithSharedArtist(t *testing.T) {
const artistName = "artist-a" const artistName = "artist-a"
m.AddTrack(fmt.Sprintf("m-0/%s/album-a/track-1.flac", artistName)) m.AddTrack(fmt.Sprintf("m-0/%s/album-a/track-1.flac", artistName))
m.SetTags(fmt.Sprintf("m-0/%s/album-a/track-1.flac", artistName), func(tags *mockfs.Tags) error { m.SetTags(fmt.Sprintf("m-0/%s/album-a/track-1.flac", artistName), func(tags *mockfs.TagInfo) {
tags.RawArtist = artistName tags.RawArtist = artistName
tags.RawAlbumArtist = artistName tags.RawAlbumArtist = artistName
tags.RawAlbum = "album-a" tags.RawAlbum = "album-a"
tags.RawTitle = "track-1" tags.RawTitle = "track-1"
return nil
}) })
m.ScanAndClean() m.ScanAndClean()
m.AddTrack(fmt.Sprintf("m-1/%s/album-a/track-1.flac", artistName)) m.AddTrack(fmt.Sprintf("m-1/%s/album-a/track-1.flac", artistName))
m.SetTags(fmt.Sprintf("m-1/%s/album-a/track-1.flac", artistName), func(tags *mockfs.Tags) error { m.SetTags(fmt.Sprintf("m-1/%s/album-a/track-1.flac", artistName), func(tags *mockfs.TagInfo) {
tags.RawArtist = artistName tags.RawArtist = artistName
tags.RawAlbumArtist = artistName tags.RawAlbumArtist = artistName
tags.RawAlbum = "album-a" tags.RawAlbum = "album-a"
tags.RawTitle = "track-1" tags.RawTitle = "track-1"
return nil
}) })
m.ScanAndClean() m.ScanAndClean()
@@ -441,15 +435,15 @@ func TestSymlinkedAlbum(t *testing.T) {
m.LogAlbums() m.LogAlbums()
var track db.Track var track db.Track
assert.NoError(t, m.DB().Preload("Album.Parent").Find(&track).Error) // track exists require.NoError(t, m.DB().Preload("Album.Parent").Find(&track).Error) // track exists
assert.NotNil(t, track.Album) // track has album require.NotNil(t, track.Album) // track has album
assert.NotZero(t, track.Album.Cover) // album has cover require.NotZero(t, track.Album.Cover) // album has cover
assert.Equal(t, "artist-sym", track.Album.Parent.RightPath) // artist is sym require.Equal(t, "artist-sym", track.Album.Parent.RightPath) // artist is sym
info, err := os.Stat(track.AbsPath()) info, err := os.Stat(track.AbsPath())
assert.NoError(t, err) // track resolves require.NoError(t, err) // track resolves
assert.False(t, info.IsDir()) // track resolves require.False(t, info.IsDir()) // track resolves
assert.NotZero(t, info.ModTime()) // track resolves require.NotZero(t, info.ModTime()) // track resolves
} }
func TestSymlinkedSubdiscs(t *testing.T) { func TestSymlinkedSubdiscs(t *testing.T) {
@@ -459,12 +453,11 @@ func TestSymlinkedSubdiscs(t *testing.T) {
addItem := func(prefix, artist, album, disc, track string) { addItem := func(prefix, artist, album, disc, track string) {
p := fmt.Sprintf("%s/%s/%s/%s/%s", prefix, artist, album, disc, track) p := fmt.Sprintf("%s/%s/%s/%s/%s", prefix, artist, album, disc, track)
m.AddTrack(p) m.AddTrack(p)
m.SetTags(p, func(tags *mockfs.Tags) error { m.SetTags(p, func(tags *mockfs.TagInfo) {
tags.RawArtist = artist tags.RawArtist = artist
tags.RawAlbumArtist = artist tags.RawAlbumArtist = artist
tags.RawAlbum = album tags.RawAlbum = album
tags.RawTitle = track tags.RawTitle = track
return nil
}) })
} }
@@ -499,11 +492,11 @@ func TestTagErrors(t *testing.T) {
m := mockfs.New(t) m := mockfs.New(t)
m.AddItemsWithCovers() m.AddItemsWithCovers()
m.SetTags("artist-1/album-0/track-0.flac", func(tags *mockfs.Tags) error { m.SetTags("artist-1/album-0/track-0.flac", func(tags *mockfs.TagInfo) {
return scanner.ErrReadingTags tags.Error = scanner.ErrReadingTags
}) })
m.SetTags("artist-1/album-1/track-0.flac", func(tags *mockfs.Tags) error { m.SetTags("artist-1/album-1/track-0.flac", func(tags *mockfs.TagInfo) {
return scanner.ErrReadingTags tags.Error = scanner.ErrReadingTags
}) })
ctx, err := m.ScanAndCleanErr() ctx, err := m.ScanAndCleanErr()
@@ -537,12 +530,11 @@ func TestCompilationAlbumWithoutAlbumArtist(t *testing.T) {
for i := 0; i < toAdd; i++ { for i := 0; i < toAdd; i++ {
p := fmt.Sprintf("%s/%s/track-%d.flac", pathArtist, pathAlbum, i) p := fmt.Sprintf("%s/%s/track-%d.flac", pathArtist, pathAlbum, i)
m.AddTrack(p) m.AddTrack(p)
m.SetTags(p, func(tags *mockfs.Tags) error { m.SetTags(p, func(tags *mockfs.TagInfo) {
// don't set an album artist // don't set an album artist
tags.RawTitle = fmt.Sprintf("track %d", i) tags.RawTitle = fmt.Sprintf("track %d", i)
tags.RawArtist = fmt.Sprintf("artist %d", i) tags.RawArtist = fmt.Sprintf("artist %d", i)
tags.RawAlbum = pathArtist tags.RawAlbum = pathArtist
return nil
}) })
} }
@@ -590,7 +582,7 @@ func TestAlbumAndArtistSameNameWeirdness(t *testing.T) {
add := func(path string, a ...interface{}) { add := func(path string, a ...interface{}) {
m.AddTrack(fmt.Sprintf(path, a...)) m.AddTrack(fmt.Sprintf(path, a...))
m.SetTags(fmt.Sprintf(path, a...), func(tags *mockfs.Tags) error { return nil }) m.SetTags(fmt.Sprintf(path, a...), func(tags *mockfs.TagInfo) {})
} }
add("an-artist/%s/track-1.flac", name) add("an-artist/%s/track-1.flac", name)
@@ -610,10 +602,10 @@ func TestNoOrphanedGenres(t *testing.T) {
m := mockfs.New(t) m := mockfs.New(t)
m.AddItems() m.AddItems()
m.SetTags("artist-0/album-0/track-0.flac", func(tags *mockfs.Tags) error { tags.RawGenre = "genre-a;genre-b"; return nil }) m.SetTags("artist-0/album-0/track-0.flac", func(tags *mockfs.TagInfo) { tags.RawGenre = "genre-a;genre-b" })
m.SetTags("artist-0/album-0/track-1.flac", func(tags *mockfs.Tags) error { tags.RawGenre = "genre-c;genre-d"; return nil }) m.SetTags("artist-0/album-0/track-1.flac", func(tags *mockfs.TagInfo) { tags.RawGenre = "genre-c;genre-d" })
m.SetTags("artist-1/album-2/track-0.flac", func(tags *mockfs.Tags) error { tags.RawGenre = "genre-e;genre-f"; return nil }) m.SetTags("artist-1/album-2/track-0.flac", func(tags *mockfs.TagInfo) { tags.RawGenre = "genre-e;genre-f" })
m.SetTags("artist-1/album-2/track-1.flac", func(tags *mockfs.Tags) error { tags.RawGenre = "genre-g;genre-h"; return nil }) m.SetTags("artist-1/album-2/track-1.flac", func(tags *mockfs.TagInfo) { tags.RawGenre = "genre-g;genre-h" })
m.ScanAndClean() m.ScanAndClean()
m.RemoveAll("artist-0") m.RemoveAll("artist-0")
@@ -631,20 +623,17 @@ func TestMultiArtistSupport(t *testing.T) {
m := mockfs.New(t) m := mockfs.New(t)
m.AddItemsGlob("artist-0/album-[012]/track-0.*") m.AddItemsGlob("artist-0/album-[012]/track-0.*")
m.SetTags("artist-0/album-0/track-0.flac", func(tags *mockfs.Tags) error { m.SetTags("artist-0/album-0/track-0.flac", func(tags *mockfs.TagInfo) {
tags.RawAlbum = "Mutator" tags.RawAlbum = "Mutator"
tags.RawAlbumArtists = []string{"Alan Vega", "Liz Lamere"} tags.RawAlbumArtists = []string{"Alan Vega", "Liz Lamere"}
return nil
}) })
m.SetTags("artist-0/album-1/track-0.flac", func(tags *mockfs.Tags) error { m.SetTags("artist-0/album-1/track-0.flac", func(tags *mockfs.TagInfo) {
tags.RawAlbum = "Dead Man" tags.RawAlbum = "Dead Man"
tags.RawAlbumArtists = []string{"Alan Vega", "Mercury Rev"} tags.RawAlbumArtists = []string{"Alan Vega", "Mercury Rev"}
return nil
}) })
m.SetTags("artist-0/album-2/track-0.flac", func(tags *mockfs.Tags) error { m.SetTags("artist-0/album-2/track-0.flac", func(tags *mockfs.TagInfo) {
tags.RawAlbum = "Yerself Is Steam" tags.RawAlbum = "Yerself Is Steam"
tags.RawAlbumArtist = "Mercury Rev" tags.RawAlbumArtist = "Mercury Rev"
return nil
}) })
m.ScanAndClean() m.ScanAndClean()
@@ -682,10 +671,9 @@ func TestMultiArtistSupport(t *testing.T) {
state()) state())
m.RemoveAll("artist-0/album-2") m.RemoveAll("artist-0/album-2")
m.SetTags("artist-0/album-1/track-0.flac", func(tags *mockfs.Tags) error { m.SetTags("artist-0/album-1/track-0.flac", func(tags *mockfs.TagInfo) {
tags.RawAlbum = "Dead Man" tags.RawAlbum = "Dead Man"
tags.RawAlbumArtists = []string{"Alan Vega"} tags.RawAlbumArtists = []string{"Alan Vega"}
return nil
}) })
m.ScanAndClean() m.ScanAndClean()
@@ -709,20 +697,17 @@ func TestMultiArtistPreload(t *testing.T) {
m := mockfs.New(t) m := mockfs.New(t)
m.AddItemsGlob("artist-0/album-[012]/track-0.*") m.AddItemsGlob("artist-0/album-[012]/track-0.*")
m.SetTags("artist-0/album-0/track-0.flac", func(tags *mockfs.Tags) error { m.SetTags("artist-0/album-0/track-0.flac", func(tags *mockfs.TagInfo) {
tags.RawAlbum = "Mutator" tags.RawAlbum = "Mutator"
tags.RawAlbumArtists = []string{"Alan Vega", "Liz Lamere"} tags.RawAlbumArtists = []string{"Alan Vega", "Liz Lamere"}
return nil
}) })
m.SetTags("artist-0/album-1/track-0.flac", func(tags *mockfs.Tags) error { m.SetTags("artist-0/album-1/track-0.flac", func(tags *mockfs.TagInfo) {
tags.RawAlbum = "Dead Man" tags.RawAlbum = "Dead Man"
tags.RawAlbumArtists = []string{"Alan Vega", "Mercury Rev"} tags.RawAlbumArtists = []string{"Alan Vega", "Mercury Rev"}
return nil
}) })
m.SetTags("artist-0/album-2/track-0.flac", func(tags *mockfs.Tags) error { m.SetTags("artist-0/album-2/track-0.flac", func(tags *mockfs.TagInfo) {
tags.RawAlbum = "Yerself Is Steam" tags.RawAlbum = "Yerself Is Steam"
tags.RawAlbumArtist = "Mercury Rev" tags.RawAlbumArtist = "Mercury Rev"
return nil
}) })
m.ScanAndClean() m.ScanAndClean()

View File

@@ -0,0 +1,91 @@
package tagcommon
import (
"errors"
)
var ErrUnsupported = errors.New("filetype unsupported")
type Reader interface {
CanRead(absPath string) bool
Read(absPath string) (Info, error)
}
type Info interface {
Title() string
BrainzID() string
Artist() string
Album() string
AlbumArtist() string
AlbumArtists() []string
AlbumBrainzID() string
Genre() string
Genres() []string
TrackNumber() int
DiscNumber() int
Length() int
Bitrate() int
Year() int
}
func MustAlbum(p Info) string {
if r := p.Album(); r != "" {
return r
}
return "Unknown Album"
}
func MustArtist(p Info) string {
if r := p.Artist(); r != "" {
return r
}
return "Unknown Artist"
}
func MustAlbumArtist(p Info) string {
if r := p.AlbumArtist(); r != "" {
return r
}
return MustArtist(p)
}
func MustAlbumArtists(p Info) []string {
if r := p.AlbumArtists(); len(r) > 0 {
return r
}
return []string{MustAlbumArtist(p)}
}
func MustGenre(p Info) string {
if r := p.Genre(); r != "" {
return r
}
return "Unknown Genre"
}
func MustGenres(p Info) []string {
if r := p.Genres(); len(r) > 0 {
return r
}
return []string{MustGenre(p)}
}
type ChainReader []Reader
func (cr ChainReader) CanRead(absPath string) bool {
for _, reader := range cr {
if reader.CanRead(absPath) {
return true
}
}
return false
}
func (cr ChainReader) Read(absPath string) (Info, error) {
for _, reader := range cr {
if reader.CanRead(absPath) {
return reader.Read(absPath)
}
}
return nil, ErrUnsupported
}

View File

@@ -0,0 +1,82 @@
package taglib
import (
"path/filepath"
"strconv"
"strings"
"github.com/sentriz/audiotags"
"go.senan.xyz/gonic/scanner/tags/tagcommon"
)
type TagLib struct{}
func (TagLib) CanRead(absPath string) bool {
switch ext := filepath.Ext(absPath); ext {
case ".mp3", ".flac", ".aac", ".m4a", ".m4b", ".ogg", ".opus", ".wma", ".wav", ".wv":
return true
}
return false
}
func (TagLib) Read(absPath string) (tagcommon.Info, error) {
raw, props, err := audiotags.Read(absPath)
return &info{raw, props}, err
}
type info struct {
raw map[string][]string
props *audiotags.AudioProperties
}
// https://picard-docs.musicbrainz.org/downloads/MusicBrainz_Picard_Tag_Map.html
func (i *info) Title() string { return first(find(i.raw, "title")) }
func (i *info) BrainzID() string { return first(find(i.raw, "musicbrainz_trackid")) } // musicbrainz recording ID
func (i *info) Artist() string { return first(find(i.raw, "artist")) }
func (i *info) Album() string { return first(find(i.raw, "album")) }
func (i *info) AlbumArtist() string { return first(find(i.raw, "albumartist", "album artist")) }
func (i *info) AlbumArtists() []string { return find(i.raw, "albumartists", "album_artists") }
func (i *info) AlbumBrainzID() string { return first(find(i.raw, "musicbrainz_albumid")) } // musicbrainz release ID
func (i *info) Genre() string { return first(find(i.raw, "genre")) }
func (i *info) Genres() []string { return find(i.raw, "genres") }
func (i *info) TrackNumber() int { return intSep("/", first(find(i.raw, "tracknumber"))) } // eg. 5/12
func (i *info) DiscNumber() int { return intSep("/", first(find(i.raw, "discnumber"))) } // eg. 1/2
func (i *info) Year() int { return intSep("-", first(find(i.raw, "originaldate", "date", "year"))) } // eg. 2023-12-01
func (i *info) Length() int { return i.props.Length }
func (i *info) Bitrate() int { return i.props.Bitrate }
func first[T comparable](is []T) T {
var z T
for _, i := range is {
if i != z {
return i
}
}
return z
}
func find(m map[string][]string, keys ...string) []string {
for _, k := range keys {
if r := filterStr(m[k]); len(r) > 0 {
return r
}
}
return nil
}
func filterStr(ss []string) []string {
var r []string
for _, s := range ss {
if strings.TrimSpace(s) != "" {
r = append(r, s)
}
}
return r
}
func intSep(sep, in string) int {
start, _, _ := strings.Cut(in, sep)
out, _ := strconv.Atoi(start)
return out
}

View File

@@ -1,145 +0,0 @@
package tags
import (
"strconv"
"strings"
"github.com/sentriz/audiotags"
)
type TagReader struct{}
func (*TagReader) Read(abspath string) (Parser, error) {
raw, props, err := audiotags.Read(abspath)
return &Tagger{raw, props}, err
}
type Tagger struct {
raw map[string][]string
props *audiotags.AudioProperties
}
// https://picard-docs.musicbrainz.org/downloads/MusicBrainz_Picard_Tag_Map.html
func (t *Tagger) Title() string { return first(find(t.raw, "title")) }
func (t *Tagger) BrainzID() string { return first(find(t.raw, "musicbrainz_trackid")) } // musicbrainz recording ID
func (t *Tagger) Artist() string { return first(find(t.raw, "artist")) }
func (t *Tagger) Album() string { return first(find(t.raw, "album")) }
func (t *Tagger) AlbumArtist() string { return first(find(t.raw, "albumartist", "album artist")) }
func (t *Tagger) AlbumArtists() []string { return find(t.raw, "albumartists", "album_artists") }
func (t *Tagger) AlbumBrainzID() string { return first(find(t.raw, "musicbrainz_albumid")) } // musicbrainz release ID
func (t *Tagger) Genre() string { return first(find(t.raw, "genre")) }
func (t *Tagger) Genres() []string { return find(t.raw, "genres") }
func (t *Tagger) TrackNumber() int {
return intSep("/" /* eg. 5/12 */, first(find(t.raw, "tracknumber")))
}
func (t *Tagger) DiscNumber() int {
return intSep("/" /* eg. 1/2 */, first(find(t.raw, "discnumber")))
}
func (t *Tagger) Year() int {
return intSep("-" /* 2023-12-01 */, first(find(t.raw, "originaldate", "date", "year")))
}
func (t *Tagger) Length() int { return t.props.Length }
func (t *Tagger) Bitrate() int { return t.props.Bitrate }
type Reader interface {
Read(abspath string) (Parser, error)
}
type Parser interface {
Title() string
BrainzID() string
Artist() string
Album() string
AlbumArtist() string
AlbumArtists() []string
AlbumBrainzID() string
Genre() string
Genres() []string
TrackNumber() int
DiscNumber() int
Length() int
Bitrate() int
Year() int
}
func first[T comparable](is []T) T {
var z T
for _, i := range is {
if i != z {
return i
}
}
return z
}
func find(m map[string][]string, keys ...string) []string {
for _, k := range keys {
if r := filterStr(m[k]); len(r) > 0 {
return r
}
}
return nil
}
func filterStr(ss []string) []string {
var r []string
for _, s := range ss {
if strings.TrimSpace(s) != "" {
r = append(r, s)
}
}
return r
}
func intSep(sep, in string) int {
start, _, _ := strings.Cut(in, sep)
out, _ := strconv.Atoi(start)
return out
}
func MustAlbum(p Parser) string {
if r := p.Album(); r != "" {
return r
}
return "Unknown Album"
}
func MustArtist(p Parser) string {
if r := p.Artist(); r != "" {
return r
}
return "Unknown Artist"
}
func MustAlbumArtist(p Parser) string {
if r := p.AlbumArtist(); r != "" {
return r
}
return MustArtist(p)
}
func MustAlbumArtists(p Parser) []string {
if r := p.AlbumArtists(); len(r) > 0 {
return r
}
return []string{MustAlbumArtist(p)}
}
func MustGenre(p Parser) string {
if r := p.Genre(); r != "" {
return r
}
return "Unknown Genre"
}
func MustGenres(p Parser) []string {
if r := p.Genres(); len(r) > 0 {
return r
}
return []string{MustGenre(p)}
}

View File

@@ -28,7 +28,7 @@
"artist": "artist-0", "artist": "artist-0",
"artistId": "ar-1", "artistId": "ar-1",
"bitRate": 100, "bitRate": 100,
"contentType": "audio/x-flac", "contentType": "audio/flac",
"coverArt": "al-3", "coverArt": "al-3",
"created": "2019-11-30T00:00:00Z", "created": "2019-11-30T00:00:00Z",
"duration": 100, "duration": 100,
@@ -50,7 +50,7 @@
"artist": "artist-0", "artist": "artist-0",
"artistId": "ar-1", "artistId": "ar-1",
"bitRate": 100, "bitRate": 100,
"contentType": "audio/x-flac", "contentType": "audio/flac",
"coverArt": "al-3", "coverArt": "al-3",
"created": "2019-11-30T00:00:00Z", "created": "2019-11-30T00:00:00Z",
"duration": 100, "duration": 100,
@@ -72,7 +72,7 @@
"artist": "artist-0", "artist": "artist-0",
"artistId": "ar-1", "artistId": "ar-1",
"bitRate": 100, "bitRate": 100,
"contentType": "audio/x-flac", "contentType": "audio/flac",
"coverArt": "al-3", "coverArt": "al-3",
"created": "2019-11-30T00:00:00Z", "created": "2019-11-30T00:00:00Z",
"duration": 100, "duration": 100,

View File

@@ -15,7 +15,7 @@
"album": "album-0", "album": "album-0",
"artist": "artist-0", "artist": "artist-0",
"bitRate": 100, "bitRate": 100,
"contentType": "audio/x-flac", "contentType": "audio/flac",
"coverArt": "al-3", "coverArt": "al-3",
"created": "2019-11-30T00:00:00Z", "created": "2019-11-30T00:00:00Z",
"duration": 100, "duration": 100,
@@ -35,7 +35,7 @@
"album": "album-0", "album": "album-0",
"artist": "artist-0", "artist": "artist-0",
"bitRate": 100, "bitRate": 100,
"contentType": "audio/x-flac", "contentType": "audio/flac",
"coverArt": "al-3", "coverArt": "al-3",
"created": "2019-11-30T00:00:00Z", "created": "2019-11-30T00:00:00Z",
"duration": 100, "duration": 100,
@@ -55,7 +55,7 @@
"album": "album-0", "album": "album-0",
"artist": "artist-0", "artist": "artist-0",
"bitRate": 100, "bitRate": 100,
"contentType": "audio/x-flac", "contentType": "audio/flac",
"coverArt": "al-3", "coverArt": "al-3",
"created": "2019-11-30T00:00:00Z", "created": "2019-11-30T00:00:00Z",
"duration": 100, "duration": 100,

View File

@@ -14,7 +14,7 @@
"artist": "artist-0", "artist": "artist-0",
"artistId": "ar-1", "artistId": "ar-1",
"bitRate": 100, "bitRate": 100,
"contentType": "audio/x-flac", "contentType": "audio/flac",
"coverArt": "al-3", "coverArt": "al-3",
"created": "2019-11-30T00:00:00Z", "created": "2019-11-30T00:00:00Z",
"duration": 100, "duration": 100,
@@ -37,7 +37,7 @@
"artist": "artist-0", "artist": "artist-0",
"artistId": "ar-1", "artistId": "ar-1",
"bitRate": 100, "bitRate": 100,
"contentType": "audio/x-flac", "contentType": "audio/flac",
"coverArt": "al-3", "coverArt": "al-3",
"created": "2019-11-30T00:00:00Z", "created": "2019-11-30T00:00:00Z",
"duration": 100, "duration": 100,
@@ -60,7 +60,7 @@
"artist": "artist-0", "artist": "artist-0",
"artistId": "ar-1", "artistId": "ar-1",
"bitRate": 100, "bitRate": 100,
"contentType": "audio/x-flac", "contentType": "audio/flac",
"coverArt": "al-3", "coverArt": "al-3",
"created": "2019-11-30T00:00:00Z", "created": "2019-11-30T00:00:00Z",
"duration": 100, "duration": 100,
@@ -83,7 +83,7 @@
"artist": "artist-0", "artist": "artist-0",
"artistId": "ar-1", "artistId": "ar-1",
"bitRate": 100, "bitRate": 100,
"contentType": "audio/x-flac", "contentType": "audio/flac",
"coverArt": "al-4", "coverArt": "al-4",
"created": "2019-11-30T00:00:00Z", "created": "2019-11-30T00:00:00Z",
"duration": 100, "duration": 100,
@@ -106,7 +106,7 @@
"artist": "artist-0", "artist": "artist-0",
"artistId": "ar-1", "artistId": "ar-1",
"bitRate": 100, "bitRate": 100,
"contentType": "audio/x-flac", "contentType": "audio/flac",
"coverArt": "al-4", "coverArt": "al-4",
"created": "2019-11-30T00:00:00Z", "created": "2019-11-30T00:00:00Z",
"duration": 100, "duration": 100,
@@ -129,7 +129,7 @@
"artist": "artist-0", "artist": "artist-0",
"artistId": "ar-1", "artistId": "ar-1",
"bitRate": 100, "bitRate": 100,
"contentType": "audio/x-flac", "contentType": "audio/flac",
"coverArt": "al-4", "coverArt": "al-4",
"created": "2019-11-30T00:00:00Z", "created": "2019-11-30T00:00:00Z",
"duration": 100, "duration": 100,
@@ -152,7 +152,7 @@
"artist": "artist-0", "artist": "artist-0",
"artistId": "ar-1", "artistId": "ar-1",
"bitRate": 100, "bitRate": 100,
"contentType": "audio/x-flac", "contentType": "audio/flac",
"coverArt": "al-5", "coverArt": "al-5",
"created": "2019-11-30T00:00:00Z", "created": "2019-11-30T00:00:00Z",
"duration": 100, "duration": 100,
@@ -175,7 +175,7 @@
"artist": "artist-0", "artist": "artist-0",
"artistId": "ar-1", "artistId": "ar-1",
"bitRate": 100, "bitRate": 100,
"contentType": "audio/x-flac", "contentType": "audio/flac",
"coverArt": "al-5", "coverArt": "al-5",
"created": "2019-11-30T00:00:00Z", "created": "2019-11-30T00:00:00Z",
"duration": 100, "duration": 100,
@@ -198,7 +198,7 @@
"artist": "artist-0", "artist": "artist-0",
"artistId": "ar-1", "artistId": "ar-1",
"bitRate": 100, "bitRate": 100,
"contentType": "audio/x-flac", "contentType": "audio/flac",
"coverArt": "al-5", "coverArt": "al-5",
"created": "2019-11-30T00:00:00Z", "created": "2019-11-30T00:00:00Z",
"duration": 100, "duration": 100,
@@ -221,7 +221,7 @@
"artist": "artist-1", "artist": "artist-1",
"artistId": "ar-2", "artistId": "ar-2",
"bitRate": 100, "bitRate": 100,
"contentType": "audio/x-flac", "contentType": "audio/flac",
"coverArt": "al-7", "coverArt": "al-7",
"created": "2019-11-30T00:00:00Z", "created": "2019-11-30T00:00:00Z",
"duration": 100, "duration": 100,
@@ -244,7 +244,7 @@
"artist": "artist-1", "artist": "artist-1",
"artistId": "ar-2", "artistId": "ar-2",
"bitRate": 100, "bitRate": 100,
"contentType": "audio/x-flac", "contentType": "audio/flac",
"coverArt": "al-7", "coverArt": "al-7",
"created": "2019-11-30T00:00:00Z", "created": "2019-11-30T00:00:00Z",
"duration": 100, "duration": 100,
@@ -267,7 +267,7 @@
"artist": "artist-1", "artist": "artist-1",
"artistId": "ar-2", "artistId": "ar-2",
"bitRate": 100, "bitRate": 100,
"contentType": "audio/x-flac", "contentType": "audio/flac",
"coverArt": "al-7", "coverArt": "al-7",
"created": "2019-11-30T00:00:00Z", "created": "2019-11-30T00:00:00Z",
"duration": 100, "duration": 100,
@@ -290,7 +290,7 @@
"artist": "artist-1", "artist": "artist-1",
"artistId": "ar-2", "artistId": "ar-2",
"bitRate": 100, "bitRate": 100,
"contentType": "audio/x-flac", "contentType": "audio/flac",
"coverArt": "al-8", "coverArt": "al-8",
"created": "2019-11-30T00:00:00Z", "created": "2019-11-30T00:00:00Z",
"duration": 100, "duration": 100,
@@ -313,7 +313,7 @@
"artist": "artist-1", "artist": "artist-1",
"artistId": "ar-2", "artistId": "ar-2",
"bitRate": 100, "bitRate": 100,
"contentType": "audio/x-flac", "contentType": "audio/flac",
"coverArt": "al-8", "coverArt": "al-8",
"created": "2019-11-30T00:00:00Z", "created": "2019-11-30T00:00:00Z",
"duration": 100, "duration": 100,
@@ -336,7 +336,7 @@
"artist": "artist-1", "artist": "artist-1",
"artistId": "ar-2", "artistId": "ar-2",
"bitRate": 100, "bitRate": 100,
"contentType": "audio/x-flac", "contentType": "audio/flac",
"coverArt": "al-8", "coverArt": "al-8",
"created": "2019-11-30T00:00:00Z", "created": "2019-11-30T00:00:00Z",
"duration": 100, "duration": 100,
@@ -359,7 +359,7 @@
"artist": "artist-1", "artist": "artist-1",
"artistId": "ar-2", "artistId": "ar-2",
"bitRate": 100, "bitRate": 100,
"contentType": "audio/x-flac", "contentType": "audio/flac",
"coverArt": "al-9", "coverArt": "al-9",
"created": "2019-11-30T00:00:00Z", "created": "2019-11-30T00:00:00Z",
"duration": 100, "duration": 100,
@@ -382,7 +382,7 @@
"artist": "artist-1", "artist": "artist-1",
"artistId": "ar-2", "artistId": "ar-2",
"bitRate": 100, "bitRate": 100,
"contentType": "audio/x-flac", "contentType": "audio/flac",
"coverArt": "al-9", "coverArt": "al-9",
"created": "2019-11-30T00:00:00Z", "created": "2019-11-30T00:00:00Z",
"duration": 100, "duration": 100,
@@ -405,7 +405,7 @@
"artist": "artist-1", "artist": "artist-1",
"artistId": "ar-2", "artistId": "ar-2",
"bitRate": 100, "bitRate": 100,
"contentType": "audio/x-flac", "contentType": "audio/flac",
"coverArt": "al-9", "coverArt": "al-9",
"created": "2019-11-30T00:00:00Z", "created": "2019-11-30T00:00:00Z",
"duration": 100, "duration": 100,
@@ -428,7 +428,7 @@
"artist": "artist-2", "artist": "artist-2",
"artistId": "ar-3", "artistId": "ar-3",
"bitRate": 100, "bitRate": 100,
"contentType": "audio/x-flac", "contentType": "audio/flac",
"coverArt": "al-11", "coverArt": "al-11",
"created": "2019-11-30T00:00:00Z", "created": "2019-11-30T00:00:00Z",
"duration": 100, "duration": 100,
@@ -451,7 +451,7 @@
"artist": "artist-2", "artist": "artist-2",
"artistId": "ar-3", "artistId": "ar-3",
"bitRate": 100, "bitRate": 100,
"contentType": "audio/x-flac", "contentType": "audio/flac",
"coverArt": "al-11", "coverArt": "al-11",
"created": "2019-11-30T00:00:00Z", "created": "2019-11-30T00:00:00Z",
"duration": 100, "duration": 100,

View File

@@ -12,7 +12,7 @@
"album": "album-0", "album": "album-0",
"artist": "artist-0", "artist": "artist-0",
"bitRate": 100, "bitRate": 100,
"contentType": "audio/x-flac", "contentType": "audio/flac",
"coverArt": "al-3", "coverArt": "al-3",
"created": "2019-11-30T00:00:00Z", "created": "2019-11-30T00:00:00Z",
"duration": 100, "duration": 100,
@@ -32,7 +32,7 @@
"album": "album-0", "album": "album-0",
"artist": "artist-0", "artist": "artist-0",
"bitRate": 100, "bitRate": 100,
"contentType": "audio/x-flac", "contentType": "audio/flac",
"coverArt": "al-3", "coverArt": "al-3",
"created": "2019-11-30T00:00:00Z", "created": "2019-11-30T00:00:00Z",
"duration": 100, "duration": 100,
@@ -52,7 +52,7 @@
"album": "album-0", "album": "album-0",
"artist": "artist-0", "artist": "artist-0",
"bitRate": 100, "bitRate": 100,
"contentType": "audio/x-flac", "contentType": "audio/flac",
"coverArt": "al-3", "coverArt": "al-3",
"created": "2019-11-30T00:00:00Z", "created": "2019-11-30T00:00:00Z",
"duration": 100, "duration": 100,
@@ -72,7 +72,7 @@
"album": "album-1", "album": "album-1",
"artist": "artist-0", "artist": "artist-0",
"bitRate": 100, "bitRate": 100,
"contentType": "audio/x-flac", "contentType": "audio/flac",
"coverArt": "al-4", "coverArt": "al-4",
"created": "2019-11-30T00:00:00Z", "created": "2019-11-30T00:00:00Z",
"duration": 100, "duration": 100,
@@ -92,7 +92,7 @@
"album": "album-1", "album": "album-1",
"artist": "artist-0", "artist": "artist-0",
"bitRate": 100, "bitRate": 100,
"contentType": "audio/x-flac", "contentType": "audio/flac",
"coverArt": "al-4", "coverArt": "al-4",
"created": "2019-11-30T00:00:00Z", "created": "2019-11-30T00:00:00Z",
"duration": 100, "duration": 100,
@@ -112,7 +112,7 @@
"album": "album-1", "album": "album-1",
"artist": "artist-0", "artist": "artist-0",
"bitRate": 100, "bitRate": 100,
"contentType": "audio/x-flac", "contentType": "audio/flac",
"coverArt": "al-4", "coverArt": "al-4",
"created": "2019-11-30T00:00:00Z", "created": "2019-11-30T00:00:00Z",
"duration": 100, "duration": 100,
@@ -132,7 +132,7 @@
"album": "album-2", "album": "album-2",
"artist": "artist-0", "artist": "artist-0",
"bitRate": 100, "bitRate": 100,
"contentType": "audio/x-flac", "contentType": "audio/flac",
"coverArt": "al-5", "coverArt": "al-5",
"created": "2019-11-30T00:00:00Z", "created": "2019-11-30T00:00:00Z",
"duration": 100, "duration": 100,
@@ -152,7 +152,7 @@
"album": "album-2", "album": "album-2",
"artist": "artist-0", "artist": "artist-0",
"bitRate": 100, "bitRate": 100,
"contentType": "audio/x-flac", "contentType": "audio/flac",
"coverArt": "al-5", "coverArt": "al-5",
"created": "2019-11-30T00:00:00Z", "created": "2019-11-30T00:00:00Z",
"duration": 100, "duration": 100,
@@ -172,7 +172,7 @@
"album": "album-2", "album": "album-2",
"artist": "artist-0", "artist": "artist-0",
"bitRate": 100, "bitRate": 100,
"contentType": "audio/x-flac", "contentType": "audio/flac",
"coverArt": "al-5", "coverArt": "al-5",
"created": "2019-11-30T00:00:00Z", "created": "2019-11-30T00:00:00Z",
"duration": 100, "duration": 100,
@@ -192,7 +192,7 @@
"album": "album-0", "album": "album-0",
"artist": "artist-1", "artist": "artist-1",
"bitRate": 100, "bitRate": 100,
"contentType": "audio/x-flac", "contentType": "audio/flac",
"coverArt": "al-7", "coverArt": "al-7",
"created": "2019-11-30T00:00:00Z", "created": "2019-11-30T00:00:00Z",
"duration": 100, "duration": 100,
@@ -212,7 +212,7 @@
"album": "album-0", "album": "album-0",
"artist": "artist-1", "artist": "artist-1",
"bitRate": 100, "bitRate": 100,
"contentType": "audio/x-flac", "contentType": "audio/flac",
"coverArt": "al-7", "coverArt": "al-7",
"created": "2019-11-30T00:00:00Z", "created": "2019-11-30T00:00:00Z",
"duration": 100, "duration": 100,
@@ -232,7 +232,7 @@
"album": "album-0", "album": "album-0",
"artist": "artist-1", "artist": "artist-1",
"bitRate": 100, "bitRate": 100,
"contentType": "audio/x-flac", "contentType": "audio/flac",
"coverArt": "al-7", "coverArt": "al-7",
"created": "2019-11-30T00:00:00Z", "created": "2019-11-30T00:00:00Z",
"duration": 100, "duration": 100,
@@ -252,7 +252,7 @@
"album": "album-1", "album": "album-1",
"artist": "artist-1", "artist": "artist-1",
"bitRate": 100, "bitRate": 100,
"contentType": "audio/x-flac", "contentType": "audio/flac",
"coverArt": "al-8", "coverArt": "al-8",
"created": "2019-11-30T00:00:00Z", "created": "2019-11-30T00:00:00Z",
"duration": 100, "duration": 100,
@@ -272,7 +272,7 @@
"album": "album-1", "album": "album-1",
"artist": "artist-1", "artist": "artist-1",
"bitRate": 100, "bitRate": 100,
"contentType": "audio/x-flac", "contentType": "audio/flac",
"coverArt": "al-8", "coverArt": "al-8",
"created": "2019-11-30T00:00:00Z", "created": "2019-11-30T00:00:00Z",
"duration": 100, "duration": 100,
@@ -292,7 +292,7 @@
"album": "album-1", "album": "album-1",
"artist": "artist-1", "artist": "artist-1",
"bitRate": 100, "bitRate": 100,
"contentType": "audio/x-flac", "contentType": "audio/flac",
"coverArt": "al-8", "coverArt": "al-8",
"created": "2019-11-30T00:00:00Z", "created": "2019-11-30T00:00:00Z",
"duration": 100, "duration": 100,
@@ -312,7 +312,7 @@
"album": "album-2", "album": "album-2",
"artist": "artist-1", "artist": "artist-1",
"bitRate": 100, "bitRate": 100,
"contentType": "audio/x-flac", "contentType": "audio/flac",
"coverArt": "al-9", "coverArt": "al-9",
"created": "2019-11-30T00:00:00Z", "created": "2019-11-30T00:00:00Z",
"duration": 100, "duration": 100,
@@ -332,7 +332,7 @@
"album": "album-2", "album": "album-2",
"artist": "artist-1", "artist": "artist-1",
"bitRate": 100, "bitRate": 100,
"contentType": "audio/x-flac", "contentType": "audio/flac",
"coverArt": "al-9", "coverArt": "al-9",
"created": "2019-11-30T00:00:00Z", "created": "2019-11-30T00:00:00Z",
"duration": 100, "duration": 100,
@@ -352,7 +352,7 @@
"album": "album-2", "album": "album-2",
"artist": "artist-1", "artist": "artist-1",
"bitRate": 100, "bitRate": 100,
"contentType": "audio/x-flac", "contentType": "audio/flac",
"coverArt": "al-9", "coverArt": "al-9",
"created": "2019-11-30T00:00:00Z", "created": "2019-11-30T00:00:00Z",
"duration": 100, "duration": 100,
@@ -372,7 +372,7 @@
"album": "album-0", "album": "album-0",
"artist": "artist-2", "artist": "artist-2",
"bitRate": 100, "bitRate": 100,
"contentType": "audio/x-flac", "contentType": "audio/flac",
"coverArt": "al-11", "coverArt": "al-11",
"created": "2019-11-30T00:00:00Z", "created": "2019-11-30T00:00:00Z",
"duration": 100, "duration": 100,
@@ -392,7 +392,7 @@
"album": "album-0", "album": "album-0",
"artist": "artist-2", "artist": "artist-2",
"bitRate": 100, "bitRate": 100,
"contentType": "audio/x-flac", "contentType": "audio/flac",
"coverArt": "al-11", "coverArt": "al-11",
"created": "2019-11-30T00:00:00Z", "created": "2019-11-30T00:00:00Z",
"duration": 100, "duration": 100,