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 \
|
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 \
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 \
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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"
|
||||||
)
|
)
|
||||||
|
|||||||
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"
|
"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 {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
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",
|
"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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user