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

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

View File

@@ -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:

View File

@@ -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
})
}

View File

@@ -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()

View File

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

View File

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

View File

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