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:
@@ -21,7 +21,8 @@ RUN apk add -U --no-cache \
|
||||
mpv \
|
||||
ca-certificates \
|
||||
tzdata \
|
||||
tini
|
||||
tini \
|
||||
shared-mime-info
|
||||
|
||||
COPY --from=builder \
|
||||
/usr/lib/libgcc_s.so.1 \
|
||||
|
||||
@@ -5,5 +5,6 @@ RUN apk add -U --no-cache \
|
||||
git \
|
||||
sqlite \
|
||||
taglib-dev \
|
||||
zlib-dev
|
||||
zlib-dev \
|
||||
shared-mime-info
|
||||
WORKDIR /src
|
||||
|
||||
@@ -18,7 +18,8 @@ FROM alpine:3.18
|
||||
RUN apk add -U --no-cache \
|
||||
ffmpeg \
|
||||
mpv \
|
||||
ca-certificates
|
||||
ca-certificates \
|
||||
shared-mime-info
|
||||
COPY --from=builder \
|
||||
/usr/lib/libgcc_s.so.1 \
|
||||
/usr/lib/libstdc++.so.6 \
|
||||
|
||||
@@ -35,7 +35,8 @@ import (
|
||||
"go.senan.xyz/gonic/playlist"
|
||||
"go.senan.xyz/gonic/podcasts"
|
||||
"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/server/ctrladmin"
|
||||
"go.senan.xyz/gonic/server/ctrlsubsonic"
|
||||
@@ -167,7 +168,12 @@ func main() {
|
||||
log.Printf(" %-25s %s\n", f.Name, value)
|
||||
})
|
||||
|
||||
tagger := &tags.TagReader{}
|
||||
tagReader := tagcommon.ChainReader{
|
||||
taglib.TagLib{},
|
||||
// ffprobe reader?
|
||||
// nfo reader?
|
||||
}
|
||||
|
||||
scannr := scanner.New(
|
||||
ctrlsubsonic.MusicPaths(musicPaths),
|
||||
dbc,
|
||||
@@ -175,10 +181,10 @@ func main() {
|
||||
scanner.Genre: scanner.MultiValueSetting(confMultiValueGenre),
|
||||
scanner.AlbumArtist: scanner.MultiValueSetting(confMultiValueAlbumArtist),
|
||||
},
|
||||
tagger,
|
||||
tagReader,
|
||||
*confExcludePatterns,
|
||||
)
|
||||
podcast := podcasts.New(dbc, *confPodcastPath, tagger)
|
||||
podcast := podcasts.New(dbc, *confPodcastPath, tagReader)
|
||||
transcoder := transcode.NewCachingTranscoder(
|
||||
transcode.NewFFmpegTranscoder(),
|
||||
cacheDirAudio,
|
||||
|
||||
@@ -3,13 +3,12 @@ package db
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"mime"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"go.senan.xyz/gonic/mime"
|
||||
|
||||
// TODO: remove this dep
|
||||
"go.senan.xyz/gonic/server/ctrlsubsonic/specid"
|
||||
)
|
||||
|
||||
43
mime/mime.go
43
mime/mime.go
@@ -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",
|
||||
}
|
||||
@@ -15,7 +15,7 @@ import (
|
||||
"github.com/mattn/go-sqlite3"
|
||||
"go.senan.xyz/gonic/db"
|
||||
"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")
|
||||
@@ -69,7 +69,7 @@ func newMockFS(tb testing.TB, dirs []string, excludePattern string) *MockFS {
|
||||
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)
|
||||
|
||||
return &MockFS{
|
||||
@@ -83,9 +83,11 @@ func newMockFS(tb testing.TB, dirs []string, excludePattern string) *MockFS {
|
||||
|
||||
func (m *MockFS) DB() *db.DB { return m.db }
|
||||
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 {
|
||||
m.t.Helper()
|
||||
|
||||
ctx, err := m.scanner.ScanAndClean(scanner.ScanOptions{})
|
||||
if err != nil {
|
||||
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) {
|
||||
m.t.Helper()
|
||||
|
||||
return m.scanner.ScanAndClean(scanner.ScanOptions{})
|
||||
}
|
||||
|
||||
@@ -126,12 +130,11 @@ func (m *MockFS) addItems(prefix string, onlyGlob string, covers bool) {
|
||||
}
|
||||
|
||||
m.AddTrack(path)
|
||||
m.SetTags(path, func(tags *Tags) error {
|
||||
m.SetTags(path, func(tags *TagInfo) {
|
||||
tags.RawArtist = fmt.Sprintf("artist-%d", ar)
|
||||
tags.RawAlbumArtist = fmt.Sprintf("artist-%d", ar)
|
||||
tags.RawAlbum = fmt.Sprintf("album-%d", al)
|
||||
tags.RawTitle = fmt.Sprintf("title-%d", tr)
|
||||
return nil
|
||||
})
|
||||
}
|
||||
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 {
|
||||
m.t.Fatalf("symlink: %v", err)
|
||||
}
|
||||
m.SetTags(path, func(tags *Tags) error {
|
||||
m.SetTags(path, func(tags *TagInfo) {
|
||||
tags.RawLength = length
|
||||
tags.RawBitrate = 0
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
@@ -288,18 +290,15 @@ func (m *MockFS) AddCover(path string) {
|
||||
defer f.Close()
|
||||
}
|
||||
|
||||
func (m *MockFS) SetTags(path string, cb func(*Tags) error) {
|
||||
abspath := filepath.Join(m.dir, path)
|
||||
if err := os.Chtimes(abspath, time.Time{}, time.Now()); err != nil {
|
||||
func (m *MockFS) SetTags(path string, cb func(*TagInfo)) {
|
||||
absPath := filepath.Join(m.dir, path)
|
||||
if err := os.Chtimes(absPath, time.Time{}, time.Now()); err != nil {
|
||||
m.t.Fatalf("touch track: %v", err)
|
||||
}
|
||||
r := m.tagReader
|
||||
if _, ok := r.paths[abspath]; !ok {
|
||||
r.paths[abspath] = &tagReaderResult{tags: &Tags{}}
|
||||
}
|
||||
if err := cb(r.paths[abspath].tags); err != nil {
|
||||
r.paths[abspath].err = err
|
||||
if _, ok := m.tagReader.paths[absPath]; !ok {
|
||||
m.tagReader.paths[absPath] = &TagInfo{}
|
||||
}
|
||||
cb(m.tagReader.paths[absPath])
|
||||
}
|
||||
|
||||
func (m *MockFS) DumpDB(suffix ...string) {
|
||||
@@ -353,54 +352,54 @@ func (m *MockFS) DumpDB(suffix ...string) {
|
||||
m.t.Error(destPath)
|
||||
}
|
||||
|
||||
type tagReaderResult struct {
|
||||
tags *Tags
|
||||
err error
|
||||
}
|
||||
|
||||
type tagReader struct {
|
||||
paths map[string]*tagReaderResult
|
||||
paths map[string]*TagInfo
|
||||
}
|
||||
|
||||
func (m *tagReader) Read(abspath string) (tags.Parser, error) {
|
||||
p, ok := m.paths[abspath]
|
||||
func (m *tagReader) CanRead(absPath string) bool {
|
||||
stat, _ := os.Stat(absPath)
|
||||
return stat.Mode().IsRegular()
|
||||
}
|
||||
|
||||
func (m *tagReader) Read(absPath string) (tagcommon.Info, error) {
|
||||
p, ok := m.paths[absPath]
|
||||
if !ok {
|
||||
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 Tags struct {
|
||||
type TagInfo struct {
|
||||
RawTitle string
|
||||
RawArtist string
|
||||
RawAlbum string
|
||||
RawAlbumArtist string
|
||||
RawAlbumArtists []string
|
||||
RawGenre string
|
||||
|
||||
RawBitrate int
|
||||
RawLength int
|
||||
Error error
|
||||
}
|
||||
|
||||
func (m *Tags) Title() string { return m.RawTitle }
|
||||
func (m *Tags) BrainzID() string { return "" }
|
||||
func (m *Tags) Artist() string { return m.RawArtist }
|
||||
func (m *Tags) Album() string { return m.RawAlbum }
|
||||
func (m *Tags) AlbumArtist() string { return m.RawAlbumArtist }
|
||||
func (m *Tags) AlbumArtists() []string { return m.RawAlbumArtists }
|
||||
func (m *Tags) AlbumBrainzID() string { return "" }
|
||||
func (m *Tags) Genre() string { return m.RawGenre }
|
||||
func (m *Tags) Genres() []string { return []string{m.RawGenre} }
|
||||
func (m *Tags) TrackNumber() int { return 1 }
|
||||
func (m *Tags) DiscNumber() int { return 1 }
|
||||
func (m *Tags) Year() int { return 2021 }
|
||||
func (i *TagInfo) Title() string { return i.RawTitle }
|
||||
func (i *TagInfo) BrainzID() string { return "" }
|
||||
func (i *TagInfo) Artist() string { return i.RawArtist }
|
||||
func (i *TagInfo) Album() string { return i.RawAlbum }
|
||||
func (i *TagInfo) AlbumArtist() string { return i.RawAlbumArtist }
|
||||
func (i *TagInfo) AlbumArtists() []string { return i.RawAlbumArtists }
|
||||
func (i *TagInfo) AlbumBrainzID() string { return "" }
|
||||
func (i *TagInfo) Genre() string { return i.RawGenre }
|
||||
func (i *TagInfo) Genres() []string { return []string{i.RawGenre} }
|
||||
func (i *TagInfo) TrackNumber() int { return 1 }
|
||||
func (i *TagInfo) DiscNumber() int { return 1 }
|
||||
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) }
|
||||
func (m *Tags) Bitrate() int { return firstInt(100, m.RawBitrate) }
|
||||
|
||||
var _ tags.Parser = (*Tags)(nil)
|
||||
var _ tagcommon.Reader = (*tagReader)(nil)
|
||||
|
||||
func firstInt(or int, ints ...int) int {
|
||||
for _, int := range ints {
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"mime"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
@@ -19,8 +20,7 @@ import (
|
||||
|
||||
"go.senan.xyz/gonic/db"
|
||||
"go.senan.xyz/gonic/fileutil"
|
||||
"go.senan.xyz/gonic/mime"
|
||||
"go.senan.xyz/gonic/scanner/tags"
|
||||
"go.senan.xyz/gonic/scanner/tags/tagcommon"
|
||||
)
|
||||
|
||||
var ErrNoAudioInFeedItem = errors.New("no audio in feed item")
|
||||
@@ -33,14 +33,14 @@ const (
|
||||
type Podcasts struct {
|
||||
db *db.DB
|
||||
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{
|
||||
db: db,
|
||||
baseDir: base,
|
||||
tagger: tagger,
|
||||
tagReader: tagReader,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -239,13 +239,12 @@ func (p *Podcasts) AddEpisode(podcastID int, item *gofeed.Item) (*db.PodcastEpis
|
||||
return nil, ErrNoAudioInFeedItem
|
||||
}
|
||||
|
||||
func isAudio(rawItemURL string) (bool, error) {
|
||||
func (p *Podcasts) isAudio(rawItemURL string) (bool, error) {
|
||||
itemURL, err := url.Parse(rawItemURL)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
return mime.TypeByAudioExtension(path.Ext(itemURL.Path)) != "", nil
|
||||
return p.tagReader.CanRead(itemURL.Path), nil
|
||||
}
|
||||
|
||||
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) {
|
||||
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
|
||||
}
|
||||
size, _ := strconv.Atoi(enc.Length)
|
||||
@@ -280,7 +279,7 @@ func (p *Podcasts) findMediaAudio(podcastID, duration int, item *gofeed.Item) (*
|
||||
return nil, false
|
||||
}
|
||||
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
|
||||
}
|
||||
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 {
|
||||
return fmt.Errorf("parse image url: %w", err)
|
||||
}
|
||||
ext := path.Ext(imageURL.Path)
|
||||
client := &http.Client{}
|
||||
req, err := http.NewRequest("GET", podcast.ImageURL, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create http request: %w", err)
|
||||
}
|
||||
req.Header.Add("User-Agent", fetchUserAgent)
|
||||
// nolint: bodyclose
|
||||
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("fetch image url: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
ext := path.Ext(imageURL.Path)
|
||||
if ext == "" {
|
||||
contentHeader := resp.Header.Get("content-disposition")
|
||||
filename, _ := getContentDispositionFilename(contentHeader)
|
||||
ext = filepath.Ext(filename)
|
||||
}
|
||||
|
||||
cover := "cover" + ext
|
||||
coverFile, err := os.Create(filepath.Join(podcast.RootDir, cover))
|
||||
if err != nil {
|
||||
return fmt.Errorf("creating podcast cover: %w", err)
|
||||
}
|
||||
defer coverFile.Close()
|
||||
|
||||
if _, err := io.Copy(coverFile, resp.Body); err != nil {
|
||||
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)
|
||||
}
|
||||
defer file.Close()
|
||||
stat, _ := file.Stat()
|
||||
podcastTags, err := p.tagger.Read(podcastEpisode.AbsPath())
|
||||
|
||||
podcastTags, err := p.tagReader.Read(podcastEpisode.AbsPath())
|
||||
if err != nil {
|
||||
log.Printf("error parsing podcast audio: %e", err)
|
||||
podcastEpisode.Status = db.PodcastEpisodeStatusError
|
||||
p.db.Save(podcastEpisode)
|
||||
return nil
|
||||
}
|
||||
|
||||
stat, _ := file.Stat()
|
||||
podcastEpisode.Bitrate = podcastTags.Bitrate()
|
||||
podcastEpisode.Status = db.PodcastEpisodeStatusCompleted
|
||||
podcastEpisode.Length = podcastTags.Length()
|
||||
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 {
|
||||
|
||||
@@ -19,8 +19,7 @@ import (
|
||||
"github.com/rainycape/unidecode"
|
||||
|
||||
"go.senan.xyz/gonic/db"
|
||||
"go.senan.xyz/gonic/mime"
|
||||
"go.senan.xyz/gonic/scanner/tags"
|
||||
"go.senan.xyz/gonic/scanner/tags/tagcommon"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -32,7 +31,7 @@ type Scanner struct {
|
||||
db *db.DB
|
||||
musicDirs []string
|
||||
multiValueSettings map[Tag]MultiValueSetting
|
||||
tagger tags.Reader
|
||||
tagReader tagcommon.Reader
|
||||
excludePattern *regexp.Regexp
|
||||
scanning *int32
|
||||
watcher *fsnotify.Watcher
|
||||
@@ -40,7 +39,7 @@ type Scanner struct {
|
||||
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
|
||||
if excludePattern != "" {
|
||||
excludePatternRegExp = regexp.MustCompile(excludePattern)
|
||||
@@ -50,7 +49,7 @@ func New(musicDirs []string, db *db.DB, multiValueSettings map[Tag]MultiValueSet
|
||||
db: db,
|
||||
musicDirs: musicDirs,
|
||||
multiValueSettings: multiValueSettings,
|
||||
tagger: tagger,
|
||||
tagReader: tagReader,
|
||||
excludePattern: excludePatternRegExp,
|
||||
scanning: new(int32),
|
||||
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 cover string
|
||||
for _, item := range items {
|
||||
fullpath := filepath.Join(absPath, item.Name())
|
||||
if s.excludePattern != nil && s.excludePattern.MatchString(fullpath) {
|
||||
log.Printf("excluding path `%s`", fullpath)
|
||||
absPath := filepath.Join(absPath, item.Name())
|
||||
if s.excludePattern != nil && s.excludePattern.MatchString(absPath) {
|
||||
log.Printf("excluding path `%s`", absPath)
|
||||
continue
|
||||
}
|
||||
if item.IsDir() {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -292,7 +294,7 @@ func (s *Scanner) scanDir(tx *db.DB, c *Context, musicDir string, absPath string
|
||||
cover = item.Name()
|
||||
continue
|
||||
}
|
||||
if mime := mime.TypeByAudioExtension(filepath.Ext(item.Name())); mime != "" {
|
||||
if s.tagReader.CanRead(absPath) {
|
||||
tracks = append(tracks, item.Name())
|
||||
continue
|
||||
}
|
||||
@@ -342,12 +344,12 @@ func (s *Scanner) populateTrackAndAlbumArtists(tx *db.DB, c *Context, i int, alb
|
||||
return nil
|
||||
}
|
||||
|
||||
trags, err := s.tagger.Read(absPath)
|
||||
trags, err := s.tagReader.Read(absPath)
|
||||
if err != nil {
|
||||
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)
|
||||
if err != nil {
|
||||
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
|
||||
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
|
||||
for _, albumArtistName := range albumArtistNames {
|
||||
albumArtist, err := populateArtist(tx, albumArtistName)
|
||||
@@ -390,8 +392,8 @@ func (s *Scanner) populateTrackAndAlbumArtists(tx *db.DB, c *Context, i int, alb
|
||||
return nil
|
||||
}
|
||||
|
||||
func populateAlbum(tx *db.DB, album *db.Album, trags tags.Parser, modTime time.Time) error {
|
||||
albumName := tags.MustAlbum(trags)
|
||||
func populateAlbum(tx *db.DB, album *db.Album, trags tagcommon.Info, modTime time.Time) error {
|
||||
albumName := tagcommon.MustAlbum(trags)
|
||||
album.TagTitle = albumName
|
||||
album.TagTitleUDec = decoded(albumName)
|
||||
album.TagBrainzID = trags.AlbumBrainzID()
|
||||
@@ -431,7 +433,7 @@ func populateAlbumBasics(tx *db.DB, musicDir string, parent, album *db.Album, di
|
||||
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)
|
||||
track.Filename = basename
|
||||
track.FilenameUDec = decoded(basename)
|
||||
@@ -684,7 +686,7 @@ type MultiValueSetting struct {
|
||||
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
|
||||
switch setting.Mode {
|
||||
case Multi:
|
||||
|
||||
@@ -33,9 +33,8 @@ func FuzzScanner(f *testing.F) {
|
||||
for i := 0; i < toAdd; i++ {
|
||||
path := fmt.Sprintf("artist-%d/album-%d/track-%d.flac", i/6, i/3, i)
|
||||
m.AddTrack(path)
|
||||
m.SetTags(path, func(tags *mockfs.Tags) error {
|
||||
m.SetTags(path, func(tags *mockfs.TagInfo) {
|
||||
fuzzStruct(i, data, seed, tags)
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"github.com/jinzhu/gorm"
|
||||
_ "github.com/jinzhu/gorm/dialects/sqlite"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"go.senan.xyz/gonic/db"
|
||||
"go.senan.xyz/gonic/mockfs"
|
||||
@@ -126,12 +127,11 @@ func TestUpdatedTags(t *testing.T) {
|
||||
m := mockfs.New(t)
|
||||
|
||||
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.RawAlbumArtist = "album-artist"
|
||||
tags.RawAlbum = "album"
|
||||
tags.RawTitle = "title"
|
||||
return nil
|
||||
})
|
||||
|
||||
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.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.RawAlbumArtist = "album-artist-upd"
|
||||
tags.RawAlbum = "album-upd"
|
||||
tags.RawTitle = "title-upd"
|
||||
return nil
|
||||
})
|
||||
|
||||
m.ScanAndClean()
|
||||
@@ -174,9 +173,8 @@ func TestUpdatedAlbumGenre(t *testing.T) {
|
||||
m := mockfs.New(t)
|
||||
|
||||
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"
|
||||
return nil
|
||||
})
|
||||
|
||||
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.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"
|
||||
return nil
|
||||
})
|
||||
|
||||
m.ScanAndClean()
|
||||
@@ -274,10 +271,10 @@ func TestGenres(t *testing.T) {
|
||||
}
|
||||
|
||||
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-1.flac", func(tags *mockfs.Tags) error { tags.RawGenre = "genre-c;genre-d"; return nil })
|
||||
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-1.flac", func(tags *mockfs.Tags) error { tags.RawGenre = "genre-g;genre-h"; 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.TagInfo) { tags.RawGenre = "genre-c;genre-d" })
|
||||
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.TagInfo) { tags.RawGenre = "genre-g;genre-h" })
|
||||
m.ScanAndClean()
|
||||
|
||||
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-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()
|
||||
|
||||
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++ {
|
||||
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.RawAlbumArtist = "artist-2"
|
||||
tags.RawAlbum = "new-album"
|
||||
tags.RawTitle = fmt.Sprintf("title-%d", tr)
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
@@ -385,22 +381,20 @@ func TestMultiFolderWithSharedArtist(t *testing.T) {
|
||||
const artistName = "artist-a"
|
||||
|
||||
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.RawAlbumArtist = artistName
|
||||
tags.RawAlbum = "album-a"
|
||||
tags.RawTitle = "track-1"
|
||||
return nil
|
||||
})
|
||||
m.ScanAndClean()
|
||||
|
||||
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.RawAlbumArtist = artistName
|
||||
tags.RawAlbum = "album-a"
|
||||
tags.RawTitle = "track-1"
|
||||
return nil
|
||||
})
|
||||
m.ScanAndClean()
|
||||
|
||||
@@ -441,15 +435,15 @@ func TestSymlinkedAlbum(t *testing.T) {
|
||||
m.LogAlbums()
|
||||
|
||||
var track db.Track
|
||||
assert.NoError(t, m.DB().Preload("Album.Parent").Find(&track).Error) // track exists
|
||||
assert.NotNil(t, track.Album) // track has album
|
||||
assert.NotZero(t, track.Album.Cover) // album has cover
|
||||
assert.Equal(t, "artist-sym", track.Album.Parent.RightPath) // artist is sym
|
||||
require.NoError(t, m.DB().Preload("Album.Parent").Find(&track).Error) // track exists
|
||||
require.NotNil(t, track.Album) // track has album
|
||||
require.NotZero(t, track.Album.Cover) // album has cover
|
||||
require.Equal(t, "artist-sym", track.Album.Parent.RightPath) // artist is sym
|
||||
|
||||
info, err := os.Stat(track.AbsPath())
|
||||
assert.NoError(t, err) // track resolves
|
||||
assert.False(t, info.IsDir()) // track resolves
|
||||
assert.NotZero(t, info.ModTime()) // track resolves
|
||||
require.NoError(t, err) // track resolves
|
||||
require.False(t, info.IsDir()) // track resolves
|
||||
require.NotZero(t, info.ModTime()) // track resolves
|
||||
}
|
||||
|
||||
func TestSymlinkedSubdiscs(t *testing.T) {
|
||||
@@ -459,12 +453,11 @@ func TestSymlinkedSubdiscs(t *testing.T) {
|
||||
addItem := func(prefix, artist, album, disc, track string) {
|
||||
p := fmt.Sprintf("%s/%s/%s/%s/%s", prefix, artist, album, disc, track)
|
||||
m.AddTrack(p)
|
||||
m.SetTags(p, func(tags *mockfs.Tags) error {
|
||||
m.SetTags(p, func(tags *mockfs.TagInfo) {
|
||||
tags.RawArtist = artist
|
||||
tags.RawAlbumArtist = artist
|
||||
tags.RawAlbum = album
|
||||
tags.RawTitle = track
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
@@ -499,11 +492,11 @@ func TestTagErrors(t *testing.T) {
|
||||
m := mockfs.New(t)
|
||||
|
||||
m.AddItemsWithCovers()
|
||||
m.SetTags("artist-1/album-0/track-0.flac", func(tags *mockfs.Tags) error {
|
||||
return scanner.ErrReadingTags
|
||||
m.SetTags("artist-1/album-0/track-0.flac", func(tags *mockfs.TagInfo) {
|
||||
tags.Error = scanner.ErrReadingTags
|
||||
})
|
||||
m.SetTags("artist-1/album-1/track-0.flac", func(tags *mockfs.Tags) error {
|
||||
return scanner.ErrReadingTags
|
||||
m.SetTags("artist-1/album-1/track-0.flac", func(tags *mockfs.TagInfo) {
|
||||
tags.Error = scanner.ErrReadingTags
|
||||
})
|
||||
|
||||
ctx, err := m.ScanAndCleanErr()
|
||||
@@ -537,12 +530,11 @@ func TestCompilationAlbumWithoutAlbumArtist(t *testing.T) {
|
||||
for i := 0; i < toAdd; i++ {
|
||||
p := fmt.Sprintf("%s/%s/track-%d.flac", pathArtist, pathAlbum, i)
|
||||
m.AddTrack(p)
|
||||
m.SetTags(p, func(tags *mockfs.Tags) error {
|
||||
m.SetTags(p, func(tags *mockfs.TagInfo) {
|
||||
// don't set an album artist
|
||||
tags.RawTitle = fmt.Sprintf("track %d", i)
|
||||
tags.RawArtist = fmt.Sprintf("artist %d", i)
|
||||
tags.RawAlbum = pathArtist
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
@@ -590,7 +582,7 @@ func TestAlbumAndArtistSameNameWeirdness(t *testing.T) {
|
||||
|
||||
add := func(path string, a ...interface{}) {
|
||||
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)
|
||||
@@ -610,10 +602,10 @@ func TestNoOrphanedGenres(t *testing.T) {
|
||||
m := mockfs.New(t)
|
||||
|
||||
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-1.flac", func(tags *mockfs.Tags) error { tags.RawGenre = "genre-c;genre-d"; return nil })
|
||||
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-1.flac", func(tags *mockfs.Tags) error { tags.RawGenre = "genre-g;genre-h"; 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.TagInfo) { tags.RawGenre = "genre-c;genre-d" })
|
||||
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.TagInfo) { tags.RawGenre = "genre-g;genre-h" })
|
||||
m.ScanAndClean()
|
||||
|
||||
m.RemoveAll("artist-0")
|
||||
@@ -631,20 +623,17 @@ func TestMultiArtistSupport(t *testing.T) {
|
||||
m := mockfs.New(t)
|
||||
|
||||
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.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.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.RawAlbumArtist = "Mercury Rev"
|
||||
return nil
|
||||
})
|
||||
|
||||
m.ScanAndClean()
|
||||
@@ -682,10 +671,9 @@ func TestMultiArtistSupport(t *testing.T) {
|
||||
state())
|
||||
|
||||
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.RawAlbumArtists = []string{"Alan Vega"}
|
||||
return nil
|
||||
})
|
||||
|
||||
m.ScanAndClean()
|
||||
@@ -709,20 +697,17 @@ func TestMultiArtistPreload(t *testing.T) {
|
||||
m := mockfs.New(t)
|
||||
|
||||
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.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.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.RawAlbumArtist = "Mercury Rev"
|
||||
return nil
|
||||
})
|
||||
|
||||
m.ScanAndClean()
|
||||
|
||||
91
scanner/tags/tagcommon/tagcommmon.go
Normal file
91
scanner/tags/tagcommon/tagcommmon.go
Normal 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
|
||||
}
|
||||
82
scanner/tags/taglib/taglib.go
Normal file
82
scanner/tags/taglib/taglib.go
Normal 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
|
||||
}
|
||||
@@ -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)}
|
||||
}
|
||||
@@ -28,7 +28,7 @@
|
||||
"artist": "artist-0",
|
||||
"artistId": "ar-1",
|
||||
"bitRate": 100,
|
||||
"contentType": "audio/x-flac",
|
||||
"contentType": "audio/flac",
|
||||
"coverArt": "al-3",
|
||||
"created": "2019-11-30T00:00:00Z",
|
||||
"duration": 100,
|
||||
@@ -50,7 +50,7 @@
|
||||
"artist": "artist-0",
|
||||
"artistId": "ar-1",
|
||||
"bitRate": 100,
|
||||
"contentType": "audio/x-flac",
|
||||
"contentType": "audio/flac",
|
||||
"coverArt": "al-3",
|
||||
"created": "2019-11-30T00:00:00Z",
|
||||
"duration": 100,
|
||||
@@ -72,7 +72,7 @@
|
||||
"artist": "artist-0",
|
||||
"artistId": "ar-1",
|
||||
"bitRate": 100,
|
||||
"contentType": "audio/x-flac",
|
||||
"contentType": "audio/flac",
|
||||
"coverArt": "al-3",
|
||||
"created": "2019-11-30T00:00:00Z",
|
||||
"duration": 100,
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
"album": "album-0",
|
||||
"artist": "artist-0",
|
||||
"bitRate": 100,
|
||||
"contentType": "audio/x-flac",
|
||||
"contentType": "audio/flac",
|
||||
"coverArt": "al-3",
|
||||
"created": "2019-11-30T00:00:00Z",
|
||||
"duration": 100,
|
||||
@@ -35,7 +35,7 @@
|
||||
"album": "album-0",
|
||||
"artist": "artist-0",
|
||||
"bitRate": 100,
|
||||
"contentType": "audio/x-flac",
|
||||
"contentType": "audio/flac",
|
||||
"coverArt": "al-3",
|
||||
"created": "2019-11-30T00:00:00Z",
|
||||
"duration": 100,
|
||||
@@ -55,7 +55,7 @@
|
||||
"album": "album-0",
|
||||
"artist": "artist-0",
|
||||
"bitRate": 100,
|
||||
"contentType": "audio/x-flac",
|
||||
"contentType": "audio/flac",
|
||||
"coverArt": "al-3",
|
||||
"created": "2019-11-30T00:00:00Z",
|
||||
"duration": 100,
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
"artist": "artist-0",
|
||||
"artistId": "ar-1",
|
||||
"bitRate": 100,
|
||||
"contentType": "audio/x-flac",
|
||||
"contentType": "audio/flac",
|
||||
"coverArt": "al-3",
|
||||
"created": "2019-11-30T00:00:00Z",
|
||||
"duration": 100,
|
||||
@@ -37,7 +37,7 @@
|
||||
"artist": "artist-0",
|
||||
"artistId": "ar-1",
|
||||
"bitRate": 100,
|
||||
"contentType": "audio/x-flac",
|
||||
"contentType": "audio/flac",
|
||||
"coverArt": "al-3",
|
||||
"created": "2019-11-30T00:00:00Z",
|
||||
"duration": 100,
|
||||
@@ -60,7 +60,7 @@
|
||||
"artist": "artist-0",
|
||||
"artistId": "ar-1",
|
||||
"bitRate": 100,
|
||||
"contentType": "audio/x-flac",
|
||||
"contentType": "audio/flac",
|
||||
"coverArt": "al-3",
|
||||
"created": "2019-11-30T00:00:00Z",
|
||||
"duration": 100,
|
||||
@@ -83,7 +83,7 @@
|
||||
"artist": "artist-0",
|
||||
"artistId": "ar-1",
|
||||
"bitRate": 100,
|
||||
"contentType": "audio/x-flac",
|
||||
"contentType": "audio/flac",
|
||||
"coverArt": "al-4",
|
||||
"created": "2019-11-30T00:00:00Z",
|
||||
"duration": 100,
|
||||
@@ -106,7 +106,7 @@
|
||||
"artist": "artist-0",
|
||||
"artistId": "ar-1",
|
||||
"bitRate": 100,
|
||||
"contentType": "audio/x-flac",
|
||||
"contentType": "audio/flac",
|
||||
"coverArt": "al-4",
|
||||
"created": "2019-11-30T00:00:00Z",
|
||||
"duration": 100,
|
||||
@@ -129,7 +129,7 @@
|
||||
"artist": "artist-0",
|
||||
"artistId": "ar-1",
|
||||
"bitRate": 100,
|
||||
"contentType": "audio/x-flac",
|
||||
"contentType": "audio/flac",
|
||||
"coverArt": "al-4",
|
||||
"created": "2019-11-30T00:00:00Z",
|
||||
"duration": 100,
|
||||
@@ -152,7 +152,7 @@
|
||||
"artist": "artist-0",
|
||||
"artistId": "ar-1",
|
||||
"bitRate": 100,
|
||||
"contentType": "audio/x-flac",
|
||||
"contentType": "audio/flac",
|
||||
"coverArt": "al-5",
|
||||
"created": "2019-11-30T00:00:00Z",
|
||||
"duration": 100,
|
||||
@@ -175,7 +175,7 @@
|
||||
"artist": "artist-0",
|
||||
"artistId": "ar-1",
|
||||
"bitRate": 100,
|
||||
"contentType": "audio/x-flac",
|
||||
"contentType": "audio/flac",
|
||||
"coverArt": "al-5",
|
||||
"created": "2019-11-30T00:00:00Z",
|
||||
"duration": 100,
|
||||
@@ -198,7 +198,7 @@
|
||||
"artist": "artist-0",
|
||||
"artistId": "ar-1",
|
||||
"bitRate": 100,
|
||||
"contentType": "audio/x-flac",
|
||||
"contentType": "audio/flac",
|
||||
"coverArt": "al-5",
|
||||
"created": "2019-11-30T00:00:00Z",
|
||||
"duration": 100,
|
||||
@@ -221,7 +221,7 @@
|
||||
"artist": "artist-1",
|
||||
"artistId": "ar-2",
|
||||
"bitRate": 100,
|
||||
"contentType": "audio/x-flac",
|
||||
"contentType": "audio/flac",
|
||||
"coverArt": "al-7",
|
||||
"created": "2019-11-30T00:00:00Z",
|
||||
"duration": 100,
|
||||
@@ -244,7 +244,7 @@
|
||||
"artist": "artist-1",
|
||||
"artistId": "ar-2",
|
||||
"bitRate": 100,
|
||||
"contentType": "audio/x-flac",
|
||||
"contentType": "audio/flac",
|
||||
"coverArt": "al-7",
|
||||
"created": "2019-11-30T00:00:00Z",
|
||||
"duration": 100,
|
||||
@@ -267,7 +267,7 @@
|
||||
"artist": "artist-1",
|
||||
"artistId": "ar-2",
|
||||
"bitRate": 100,
|
||||
"contentType": "audio/x-flac",
|
||||
"contentType": "audio/flac",
|
||||
"coverArt": "al-7",
|
||||
"created": "2019-11-30T00:00:00Z",
|
||||
"duration": 100,
|
||||
@@ -290,7 +290,7 @@
|
||||
"artist": "artist-1",
|
||||
"artistId": "ar-2",
|
||||
"bitRate": 100,
|
||||
"contentType": "audio/x-flac",
|
||||
"contentType": "audio/flac",
|
||||
"coverArt": "al-8",
|
||||
"created": "2019-11-30T00:00:00Z",
|
||||
"duration": 100,
|
||||
@@ -313,7 +313,7 @@
|
||||
"artist": "artist-1",
|
||||
"artistId": "ar-2",
|
||||
"bitRate": 100,
|
||||
"contentType": "audio/x-flac",
|
||||
"contentType": "audio/flac",
|
||||
"coverArt": "al-8",
|
||||
"created": "2019-11-30T00:00:00Z",
|
||||
"duration": 100,
|
||||
@@ -336,7 +336,7 @@
|
||||
"artist": "artist-1",
|
||||
"artistId": "ar-2",
|
||||
"bitRate": 100,
|
||||
"contentType": "audio/x-flac",
|
||||
"contentType": "audio/flac",
|
||||
"coverArt": "al-8",
|
||||
"created": "2019-11-30T00:00:00Z",
|
||||
"duration": 100,
|
||||
@@ -359,7 +359,7 @@
|
||||
"artist": "artist-1",
|
||||
"artistId": "ar-2",
|
||||
"bitRate": 100,
|
||||
"contentType": "audio/x-flac",
|
||||
"contentType": "audio/flac",
|
||||
"coverArt": "al-9",
|
||||
"created": "2019-11-30T00:00:00Z",
|
||||
"duration": 100,
|
||||
@@ -382,7 +382,7 @@
|
||||
"artist": "artist-1",
|
||||
"artistId": "ar-2",
|
||||
"bitRate": 100,
|
||||
"contentType": "audio/x-flac",
|
||||
"contentType": "audio/flac",
|
||||
"coverArt": "al-9",
|
||||
"created": "2019-11-30T00:00:00Z",
|
||||
"duration": 100,
|
||||
@@ -405,7 +405,7 @@
|
||||
"artist": "artist-1",
|
||||
"artistId": "ar-2",
|
||||
"bitRate": 100,
|
||||
"contentType": "audio/x-flac",
|
||||
"contentType": "audio/flac",
|
||||
"coverArt": "al-9",
|
||||
"created": "2019-11-30T00:00:00Z",
|
||||
"duration": 100,
|
||||
@@ -428,7 +428,7 @@
|
||||
"artist": "artist-2",
|
||||
"artistId": "ar-3",
|
||||
"bitRate": 100,
|
||||
"contentType": "audio/x-flac",
|
||||
"contentType": "audio/flac",
|
||||
"coverArt": "al-11",
|
||||
"created": "2019-11-30T00:00:00Z",
|
||||
"duration": 100,
|
||||
@@ -451,7 +451,7 @@
|
||||
"artist": "artist-2",
|
||||
"artistId": "ar-3",
|
||||
"bitRate": 100,
|
||||
"contentType": "audio/x-flac",
|
||||
"contentType": "audio/flac",
|
||||
"coverArt": "al-11",
|
||||
"created": "2019-11-30T00:00:00Z",
|
||||
"duration": 100,
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
"album": "album-0",
|
||||
"artist": "artist-0",
|
||||
"bitRate": 100,
|
||||
"contentType": "audio/x-flac",
|
||||
"contentType": "audio/flac",
|
||||
"coverArt": "al-3",
|
||||
"created": "2019-11-30T00:00:00Z",
|
||||
"duration": 100,
|
||||
@@ -32,7 +32,7 @@
|
||||
"album": "album-0",
|
||||
"artist": "artist-0",
|
||||
"bitRate": 100,
|
||||
"contentType": "audio/x-flac",
|
||||
"contentType": "audio/flac",
|
||||
"coverArt": "al-3",
|
||||
"created": "2019-11-30T00:00:00Z",
|
||||
"duration": 100,
|
||||
@@ -52,7 +52,7 @@
|
||||
"album": "album-0",
|
||||
"artist": "artist-0",
|
||||
"bitRate": 100,
|
||||
"contentType": "audio/x-flac",
|
||||
"contentType": "audio/flac",
|
||||
"coverArt": "al-3",
|
||||
"created": "2019-11-30T00:00:00Z",
|
||||
"duration": 100,
|
||||
@@ -72,7 +72,7 @@
|
||||
"album": "album-1",
|
||||
"artist": "artist-0",
|
||||
"bitRate": 100,
|
||||
"contentType": "audio/x-flac",
|
||||
"contentType": "audio/flac",
|
||||
"coverArt": "al-4",
|
||||
"created": "2019-11-30T00:00:00Z",
|
||||
"duration": 100,
|
||||
@@ -92,7 +92,7 @@
|
||||
"album": "album-1",
|
||||
"artist": "artist-0",
|
||||
"bitRate": 100,
|
||||
"contentType": "audio/x-flac",
|
||||
"contentType": "audio/flac",
|
||||
"coverArt": "al-4",
|
||||
"created": "2019-11-30T00:00:00Z",
|
||||
"duration": 100,
|
||||
@@ -112,7 +112,7 @@
|
||||
"album": "album-1",
|
||||
"artist": "artist-0",
|
||||
"bitRate": 100,
|
||||
"contentType": "audio/x-flac",
|
||||
"contentType": "audio/flac",
|
||||
"coverArt": "al-4",
|
||||
"created": "2019-11-30T00:00:00Z",
|
||||
"duration": 100,
|
||||
@@ -132,7 +132,7 @@
|
||||
"album": "album-2",
|
||||
"artist": "artist-0",
|
||||
"bitRate": 100,
|
||||
"contentType": "audio/x-flac",
|
||||
"contentType": "audio/flac",
|
||||
"coverArt": "al-5",
|
||||
"created": "2019-11-30T00:00:00Z",
|
||||
"duration": 100,
|
||||
@@ -152,7 +152,7 @@
|
||||
"album": "album-2",
|
||||
"artist": "artist-0",
|
||||
"bitRate": 100,
|
||||
"contentType": "audio/x-flac",
|
||||
"contentType": "audio/flac",
|
||||
"coverArt": "al-5",
|
||||
"created": "2019-11-30T00:00:00Z",
|
||||
"duration": 100,
|
||||
@@ -172,7 +172,7 @@
|
||||
"album": "album-2",
|
||||
"artist": "artist-0",
|
||||
"bitRate": 100,
|
||||
"contentType": "audio/x-flac",
|
||||
"contentType": "audio/flac",
|
||||
"coverArt": "al-5",
|
||||
"created": "2019-11-30T00:00:00Z",
|
||||
"duration": 100,
|
||||
@@ -192,7 +192,7 @@
|
||||
"album": "album-0",
|
||||
"artist": "artist-1",
|
||||
"bitRate": 100,
|
||||
"contentType": "audio/x-flac",
|
||||
"contentType": "audio/flac",
|
||||
"coverArt": "al-7",
|
||||
"created": "2019-11-30T00:00:00Z",
|
||||
"duration": 100,
|
||||
@@ -212,7 +212,7 @@
|
||||
"album": "album-0",
|
||||
"artist": "artist-1",
|
||||
"bitRate": 100,
|
||||
"contentType": "audio/x-flac",
|
||||
"contentType": "audio/flac",
|
||||
"coverArt": "al-7",
|
||||
"created": "2019-11-30T00:00:00Z",
|
||||
"duration": 100,
|
||||
@@ -232,7 +232,7 @@
|
||||
"album": "album-0",
|
||||
"artist": "artist-1",
|
||||
"bitRate": 100,
|
||||
"contentType": "audio/x-flac",
|
||||
"contentType": "audio/flac",
|
||||
"coverArt": "al-7",
|
||||
"created": "2019-11-30T00:00:00Z",
|
||||
"duration": 100,
|
||||
@@ -252,7 +252,7 @@
|
||||
"album": "album-1",
|
||||
"artist": "artist-1",
|
||||
"bitRate": 100,
|
||||
"contentType": "audio/x-flac",
|
||||
"contentType": "audio/flac",
|
||||
"coverArt": "al-8",
|
||||
"created": "2019-11-30T00:00:00Z",
|
||||
"duration": 100,
|
||||
@@ -272,7 +272,7 @@
|
||||
"album": "album-1",
|
||||
"artist": "artist-1",
|
||||
"bitRate": 100,
|
||||
"contentType": "audio/x-flac",
|
||||
"contentType": "audio/flac",
|
||||
"coverArt": "al-8",
|
||||
"created": "2019-11-30T00:00:00Z",
|
||||
"duration": 100,
|
||||
@@ -292,7 +292,7 @@
|
||||
"album": "album-1",
|
||||
"artist": "artist-1",
|
||||
"bitRate": 100,
|
||||
"contentType": "audio/x-flac",
|
||||
"contentType": "audio/flac",
|
||||
"coverArt": "al-8",
|
||||
"created": "2019-11-30T00:00:00Z",
|
||||
"duration": 100,
|
||||
@@ -312,7 +312,7 @@
|
||||
"album": "album-2",
|
||||
"artist": "artist-1",
|
||||
"bitRate": 100,
|
||||
"contentType": "audio/x-flac",
|
||||
"contentType": "audio/flac",
|
||||
"coverArt": "al-9",
|
||||
"created": "2019-11-30T00:00:00Z",
|
||||
"duration": 100,
|
||||
@@ -332,7 +332,7 @@
|
||||
"album": "album-2",
|
||||
"artist": "artist-1",
|
||||
"bitRate": 100,
|
||||
"contentType": "audio/x-flac",
|
||||
"contentType": "audio/flac",
|
||||
"coverArt": "al-9",
|
||||
"created": "2019-11-30T00:00:00Z",
|
||||
"duration": 100,
|
||||
@@ -352,7 +352,7 @@
|
||||
"album": "album-2",
|
||||
"artist": "artist-1",
|
||||
"bitRate": 100,
|
||||
"contentType": "audio/x-flac",
|
||||
"contentType": "audio/flac",
|
||||
"coverArt": "al-9",
|
||||
"created": "2019-11-30T00:00:00Z",
|
||||
"duration": 100,
|
||||
@@ -372,7 +372,7 @@
|
||||
"album": "album-0",
|
||||
"artist": "artist-2",
|
||||
"bitRate": 100,
|
||||
"contentType": "audio/x-flac",
|
||||
"contentType": "audio/flac",
|
||||
"coverArt": "al-11",
|
||||
"created": "2019-11-30T00:00:00Z",
|
||||
"duration": 100,
|
||||
@@ -392,7 +392,7 @@
|
||||
"album": "album-0",
|
||||
"artist": "artist-2",
|
||||
"bitRate": 100,
|
||||
"contentType": "audio/x-flac",
|
||||
"contentType": "audio/flac",
|
||||
"coverArt": "al-11",
|
||||
"created": "2019-11-30T00:00:00Z",
|
||||
"duration": 100,
|
||||
|
||||
Reference in New Issue
Block a user