From 8382f6123c2ac39d12b266730d76301faaf168f4 Mon Sep 17 00:00:00 2001 From: sentriz Date: Mon, 2 Oct 2023 20:02:38 +0100 Subject: [PATCH] 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 --- Dockerfile | 3 +- Dockerfile.debug | 3 +- Dockerfile.dev | 3 +- cmd/gonic/gonic.go | 14 +- db/model.go | 3 +- mime/mime.go | 43 ------ mockfs/mockfs.go | 97 ++++++------ podcasts/podcasts.go | 49 +++--- scanner/scanner.go | 34 ++-- scanner/scanner_fuzz_test.go | 3 +- scanner/scanner_test.go | 91 +++++------ scanner/tags/tagcommon/tagcommmon.go | 91 +++++++++++ scanner/tags/taglib/taglib.go | 82 ++++++++++ scanner/tags/tags.go | 145 ------------------ .../testdata/test_get_album_with_cover | 6 +- .../test_get_music_directory_with_tracks | 6 +- .../testdata/test_search_three_q_tra | 40 ++--- .../testdata/test_search_two_q_tra | 40 ++--- 18 files changed, 370 insertions(+), 383 deletions(-) delete mode 100644 mime/mime.go create mode 100644 scanner/tags/tagcommon/tagcommmon.go create mode 100644 scanner/tags/taglib/taglib.go delete mode 100644 scanner/tags/tags.go diff --git a/Dockerfile b/Dockerfile index 29da8bda..aeb3728b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 \ diff --git a/Dockerfile.debug b/Dockerfile.debug index 9adde2ab..39f94ea3 100644 --- a/Dockerfile.debug +++ b/Dockerfile.debug @@ -5,5 +5,6 @@ RUN apk add -U --no-cache \ git \ sqlite \ taglib-dev \ - zlib-dev + zlib-dev \ + shared-mime-info WORKDIR /src diff --git a/Dockerfile.dev b/Dockerfile.dev index 97358188..e9d915f2 100644 --- a/Dockerfile.dev +++ b/Dockerfile.dev @@ -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 \ diff --git a/cmd/gonic/gonic.go b/cmd/gonic/gonic.go index 66f17a01..b405448c 100644 --- a/cmd/gonic/gonic.go +++ b/cmd/gonic/gonic.go @@ -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, diff --git a/db/model.go b/db/model.go index fceb3ed1..20a8885c 100644 --- a/db/model.go +++ b/db/model.go @@ -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" ) diff --git a/mime/mime.go b/mime/mime.go deleted file mode 100644 index 8e750de4..00000000 --- a/mime/mime.go +++ /dev/null @@ -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", -} diff --git a/mockfs/mockfs.go b/mockfs/mockfs.go index de9383e7..c433989a 100644 --- a/mockfs/mockfs.go +++ b/mockfs/mockfs.go @@ -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{ @@ -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) TmpDir() string { return m.dir } -func (m *MockFS) TagReader() tags.Reader { return m.tagReader } +func (m *MockFS) DB() *db.DB { return m.db } +func (m *MockFS) TmpDir() string { return m.dir } +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]*TagInfo } -type tagReader struct { - paths map[string]*tagReaderResult +func (m *tagReader) CanRead(absPath string) bool { + stat, _ := os.Stat(absPath) + return stat.Mode().IsRegular() } -func (m *tagReader) Read(abspath string) (tags.Parser, error) { - p, ok := m.paths[abspath] +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 + 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 (m *Tags) Length() int { return firstInt(100, m.RawLength) } -func (m *Tags) Bitrate() int { return firstInt(100, m.RawBitrate) } - -var _ tags.Parser = (*Tags)(nil) +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) } + +var _ tagcommon.Reader = (*tagReader)(nil) func firstInt(or int, ints ...int) int { for _, int := range ints { diff --git a/podcasts/podcasts.go b/podcasts/podcasts.go index e0c872fa..966305b6 100644 --- a/podcasts/podcasts.go +++ b/podcasts/podcasts.go @@ -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") @@ -31,16 +31,16 @@ const ( ) type Podcasts struct { - db *db.DB - baseDir string - tagger tags.Reader + db *db.DB + baseDir string + 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, + db: db, + baseDir: base, + 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 { diff --git a/scanner/scanner.go b/scanner/scanner.go index d5322317..455d18e6 100644 --- a/scanner/scanner.go +++ b/scanner/scanner.go @@ -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: diff --git a/scanner/scanner_fuzz_test.go b/scanner/scanner_fuzz_test.go index 99113263..daf8e022 100644 --- a/scanner/scanner_fuzz_test.go +++ b/scanner/scanner_fuzz_test.go @@ -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 }) } diff --git a/scanner/scanner_test.go b/scanner/scanner_test.go index 0f0b1b6b..fde2201d 100644 --- a/scanner/scanner_test.go +++ b/scanner/scanner_test.go @@ -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() diff --git a/scanner/tags/tagcommon/tagcommmon.go b/scanner/tags/tagcommon/tagcommmon.go new file mode 100644 index 00000000..df20553a --- /dev/null +++ b/scanner/tags/tagcommon/tagcommmon.go @@ -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 +} diff --git a/scanner/tags/taglib/taglib.go b/scanner/tags/taglib/taglib.go new file mode 100644 index 00000000..ffdfa358 --- /dev/null +++ b/scanner/tags/taglib/taglib.go @@ -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 +} diff --git a/scanner/tags/tags.go b/scanner/tags/tags.go deleted file mode 100644 index 660b6a55..00000000 --- a/scanner/tags/tags.go +++ /dev/null @@ -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)} -} diff --git a/server/ctrlsubsonic/testdata/test_get_album_with_cover b/server/ctrlsubsonic/testdata/test_get_album_with_cover index 2bee5848..0481e27e 100644 --- a/server/ctrlsubsonic/testdata/test_get_album_with_cover +++ b/server/ctrlsubsonic/testdata/test_get_album_with_cover @@ -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, diff --git a/server/ctrlsubsonic/testdata/test_get_music_directory_with_tracks b/server/ctrlsubsonic/testdata/test_get_music_directory_with_tracks index 2f3c8ed2..32430f7f 100644 --- a/server/ctrlsubsonic/testdata/test_get_music_directory_with_tracks +++ b/server/ctrlsubsonic/testdata/test_get_music_directory_with_tracks @@ -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, diff --git a/server/ctrlsubsonic/testdata/test_search_three_q_tra b/server/ctrlsubsonic/testdata/test_search_three_q_tra index b59172d2..d804c73d 100644 --- a/server/ctrlsubsonic/testdata/test_search_three_q_tra +++ b/server/ctrlsubsonic/testdata/test_search_three_q_tra @@ -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, diff --git a/server/ctrlsubsonic/testdata/test_search_two_q_tra b/server/ctrlsubsonic/testdata/test_search_two_q_tra index bc8e5664..6ea777e5 100644 --- a/server/ctrlsubsonic/testdata/test_search_two_q_tra +++ b/server/ctrlsubsonic/testdata/test_search_two_q_tra @@ -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,