mirror of
https://github.com/navidrome/navidrome.git
synced 2025-04-17 04:22:23 +03:00
Foundational work to enable multi-valued tags
This commit is contained in:
parent
519c89345e
commit
cd242695ba
@ -25,7 +25,7 @@ func newMediaFileMapper(rootFolder string) *mediaFileMapper {
|
||||
return &mediaFileMapper{rootFolder: rootFolder, policy: bluemonday.UGCPolicy()}
|
||||
}
|
||||
|
||||
func (s *mediaFileMapper) toMediaFile(md metadata.Metadata) model.MediaFile {
|
||||
func (s *mediaFileMapper) toMediaFile(md *metadata.Tags) model.MediaFile {
|
||||
mf := &model.MediaFile{}
|
||||
mf.ID = s.trackID(md)
|
||||
mf.Title = s.mapTrackTitle(md)
|
||||
@ -76,7 +76,7 @@ func sanitizeFieldForSorting(originalValue string) string {
|
||||
return utils.NoArticle(v)
|
||||
}
|
||||
|
||||
func (s *mediaFileMapper) mapTrackTitle(md metadata.Metadata) string {
|
||||
func (s *mediaFileMapper) mapTrackTitle(md *metadata.Tags) string {
|
||||
if md.Title() == "" {
|
||||
s := strings.TrimPrefix(md.FilePath(), s.rootFolder+string(os.PathSeparator))
|
||||
e := filepath.Ext(s)
|
||||
@ -85,7 +85,7 @@ func (s *mediaFileMapper) mapTrackTitle(md metadata.Metadata) string {
|
||||
return md.Title()
|
||||
}
|
||||
|
||||
func (s *mediaFileMapper) mapAlbumArtistName(md metadata.Metadata) string {
|
||||
func (s *mediaFileMapper) mapAlbumArtistName(md *metadata.Tags) string {
|
||||
switch {
|
||||
case md.Compilation():
|
||||
return consts.VariousArtists
|
||||
@ -98,14 +98,14 @@ func (s *mediaFileMapper) mapAlbumArtistName(md metadata.Metadata) string {
|
||||
}
|
||||
}
|
||||
|
||||
func (s *mediaFileMapper) mapArtistName(md metadata.Metadata) string {
|
||||
func (s *mediaFileMapper) mapArtistName(md *metadata.Tags) string {
|
||||
if md.Artist() != "" {
|
||||
return md.Artist()
|
||||
}
|
||||
return consts.UnknownArtist
|
||||
}
|
||||
|
||||
func (s *mediaFileMapper) mapAlbumName(md metadata.Metadata) string {
|
||||
func (s *mediaFileMapper) mapAlbumName(md *metadata.Tags) string {
|
||||
name := md.Album()
|
||||
if name == "" {
|
||||
return "[Unknown Album]"
|
||||
@ -113,19 +113,19 @@ func (s *mediaFileMapper) mapAlbumName(md metadata.Metadata) string {
|
||||
return name
|
||||
}
|
||||
|
||||
func (s *mediaFileMapper) trackID(md metadata.Metadata) string {
|
||||
func (s *mediaFileMapper) trackID(md *metadata.Tags) string {
|
||||
return fmt.Sprintf("%x", md5.Sum([]byte(md.FilePath())))
|
||||
}
|
||||
|
||||
func (s *mediaFileMapper) albumID(md metadata.Metadata) string {
|
||||
func (s *mediaFileMapper) albumID(md *metadata.Tags) string {
|
||||
albumPath := strings.ToLower(fmt.Sprintf("%s\\%s", s.mapAlbumArtistName(md), s.mapAlbumName(md)))
|
||||
return fmt.Sprintf("%x", md5.Sum([]byte(albumPath)))
|
||||
}
|
||||
|
||||
func (s *mediaFileMapper) artistID(md metadata.Metadata) string {
|
||||
func (s *mediaFileMapper) artistID(md *metadata.Tags) string {
|
||||
return fmt.Sprintf("%x", md5.Sum([]byte(strings.ToLower(s.mapArtistName(md)))))
|
||||
}
|
||||
|
||||
func (s *mediaFileMapper) albumArtistID(md metadata.Metadata) string {
|
||||
func (s *mediaFileMapper) albumArtistID(md *metadata.Tags) string {
|
||||
return fmt.Sprintf("%x", md5.Sum([]byte(strings.ToLower(s.mapAlbumArtistName(md)))))
|
||||
}
|
||||
|
@ -3,54 +3,37 @@ package metadata
|
||||
import (
|
||||
"bufio"
|
||||
"errors"
|
||||
"os"
|
||||
"os/exec"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
)
|
||||
|
||||
type ffmpegMetadata struct {
|
||||
baseMetadata
|
||||
}
|
||||
|
||||
func (m *ffmpegMetadata) Duration() float32 { return m.parseDuration("duration") }
|
||||
func (m *ffmpegMetadata) BitRate() int { return m.parseInt("bitrate") }
|
||||
func (m *ffmpegMetadata) HasPicture() bool {
|
||||
return m.getTag("has_picture", "metadata_block_picture") != ""
|
||||
}
|
||||
func (m *ffmpegMetadata) DiscNumber() (int, int) { return m.parseTuple("tpa", "disc", "discnumber") }
|
||||
func (m *ffmpegMetadata) Comment() string {
|
||||
comment := m.baseMetadata.Comment()
|
||||
if comment == "Cover (front)" {
|
||||
return ""
|
||||
}
|
||||
return comment
|
||||
}
|
||||
|
||||
type ffmpegExtractor struct{}
|
||||
|
||||
func (e *ffmpegExtractor) Extract(files ...string) (map[string]Metadata, error) {
|
||||
func (e *ffmpegExtractor) Extract(files ...string) (map[string]*Tags, error) {
|
||||
args := e.createProbeCommand(files)
|
||||
|
||||
log.Trace("Executing command", "args", args)
|
||||
cmd := exec.Command(args[0], args[1:]...) // #nosec
|
||||
output, _ := cmd.CombinedOutput()
|
||||
mds := map[string]Metadata{}
|
||||
fileTags := map[string]*Tags{}
|
||||
if len(output) == 0 {
|
||||
return mds, errors.New("error extracting metadata files")
|
||||
return fileTags, errors.New("error extracting metadata files")
|
||||
}
|
||||
infos := e.parseOutput(string(output))
|
||||
for file, info := range infos {
|
||||
md, err := e.extractMetadata(file, info)
|
||||
tags, err := e.extractMetadata(file, info)
|
||||
// Skip files with errors
|
||||
if err == nil {
|
||||
mds[file] = md
|
||||
fileTags[file] = tags
|
||||
}
|
||||
}
|
||||
return mds, nil
|
||||
return fileTags, nil
|
||||
}
|
||||
|
||||
var (
|
||||
@ -95,26 +78,23 @@ func (e *ffmpegExtractor) parseOutput(output string) map[string]string {
|
||||
return outputs
|
||||
}
|
||||
|
||||
func (e *ffmpegExtractor) extractMetadata(filePath, info string) (*ffmpegMetadata, error) {
|
||||
m := &ffmpegMetadata{}
|
||||
m.filePath = filePath
|
||||
m.tags = map[string]string{}
|
||||
var err error
|
||||
m.fileInfo, err = os.Stat(filePath)
|
||||
if err != nil {
|
||||
log.Warn("Error stating file. Skipping", "filePath", filePath, err)
|
||||
return nil, errors.New("error stating file")
|
||||
}
|
||||
|
||||
m.parseInfo(info)
|
||||
if len(m.tags) == 0 {
|
||||
func (e *ffmpegExtractor) extractMetadata(filePath, info string) (*Tags, error) {
|
||||
parsedTags := e.parseInfo(info)
|
||||
if len(parsedTags) == 0 {
|
||||
log.Trace("Not a media file. Skipping", "filePath", filePath)
|
||||
return nil, errors.New("not a media file")
|
||||
}
|
||||
return m, nil
|
||||
|
||||
tags := NewTag(filePath, parsedTags, map[string][]string{
|
||||
"disc": {"tpa"},
|
||||
"has_picture": {"metadata_block_picture"},
|
||||
})
|
||||
return tags, nil
|
||||
}
|
||||
|
||||
func (m *ffmpegMetadata) parseInfo(info string) {
|
||||
func (e *ffmpegExtractor) parseInfo(info string) map[string][]string {
|
||||
tags := map[string][]string{}
|
||||
|
||||
reader := strings.NewReader(info)
|
||||
scanner := bufio.NewScanner(reader)
|
||||
lastTag := ""
|
||||
@ -128,11 +108,8 @@ func (m *ffmpegMetadata) parseInfo(info string) {
|
||||
tagName := strings.TrimSpace(strings.ToLower(match[1]))
|
||||
if tagName != "" {
|
||||
tagValue := strings.TrimSpace(match[2])
|
||||
// Skip when the tag was previously found
|
||||
if _, ok := m.tags[tagName]; !ok {
|
||||
m.tags[tagName] = tagValue
|
||||
lastTag = tagName
|
||||
}
|
||||
tags[tagName] = append(tags[tagName], tagValue)
|
||||
lastTag = tagName
|
||||
continue
|
||||
}
|
||||
}
|
||||
@ -140,8 +117,11 @@ func (m *ffmpegMetadata) parseInfo(info string) {
|
||||
if lastTag != "" {
|
||||
match = continuationRx.FindStringSubmatch(line)
|
||||
if len(match) > 0 {
|
||||
tagValue := m.tags[lastTag]
|
||||
m.tags[lastTag] = tagValue + "\n" + strings.TrimSpace(match[1])
|
||||
if tags[lastTag] == nil {
|
||||
tags[lastTag] = []string{""}
|
||||
}
|
||||
tagValue := tags[lastTag][0]
|
||||
tags[lastTag][0] = tagValue + "\n" + strings.TrimSpace(match[1])
|
||||
continue
|
||||
}
|
||||
}
|
||||
@ -149,24 +129,41 @@ func (m *ffmpegMetadata) parseInfo(info string) {
|
||||
lastTag = ""
|
||||
match = coverRx.FindStringSubmatch(line)
|
||||
if len(match) > 0 {
|
||||
m.tags["has_picture"] = "true"
|
||||
tags["has_picture"] = []string{"true"}
|
||||
continue
|
||||
}
|
||||
|
||||
match = durationRx.FindStringSubmatch(line)
|
||||
if len(match) > 0 {
|
||||
m.tags["duration"] = match[1]
|
||||
tags["duration"] = []string{e.parseDuration(match[1])}
|
||||
if len(match) > 1 {
|
||||
m.tags["bitrate"] = match[2]
|
||||
tags["bitrate"] = []string{match[2]}
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
match = bitRateRx.FindStringSubmatch(line)
|
||||
if len(match) > 0 {
|
||||
m.tags["bitrate"] = match[2]
|
||||
tags["bitrate"] = []string{match[2]}
|
||||
}
|
||||
}
|
||||
|
||||
comment := tags["comment"]
|
||||
if len(comment) > 0 && comment[0] == "Cover (front)" {
|
||||
delete(tags, "comment")
|
||||
}
|
||||
|
||||
return tags
|
||||
}
|
||||
|
||||
var zeroTime = time.Date(0000, time.January, 1, 0, 0, 0, 0, time.UTC)
|
||||
|
||||
func (e *ffmpegExtractor) parseDuration(tag string) string {
|
||||
d, err := time.Parse("15:04:05", tag)
|
||||
if err != nil {
|
||||
return "0"
|
||||
}
|
||||
return strconv.FormatFloat(d.Sub(zeroTime).Seconds(), 'f', 2, 32)
|
||||
}
|
||||
|
||||
// Inputs will always be absolute paths
|
||||
|
@ -22,7 +22,6 @@ var _ = Describe("ffmpegExtractor", func() {
|
||||
Expect(m.Album()).To(Equal("Album"))
|
||||
Expect(m.Artist()).To(Equal("Artist"))
|
||||
Expect(m.AlbumArtist()).To(Equal("Album Artist"))
|
||||
Expect(m.Composer()).To(Equal("Composer"))
|
||||
Expect(m.Compilation()).To(BeTrue())
|
||||
Expect(m.Genre()).To(Equal("Rock"))
|
||||
Expect(m.Year()).To(Equal(2014))
|
||||
@ -33,21 +32,21 @@ var _ = Describe("ffmpegExtractor", func() {
|
||||
Expect(n).To(Equal(1))
|
||||
Expect(t).To(Equal(2))
|
||||
Expect(m.HasPicture()).To(BeTrue())
|
||||
Expect(m.Duration()).To(Equal(1))
|
||||
Expect(m.BitRate()).To(Equal(476))
|
||||
Expect(m.Duration()).To(BeNumerically("~", 1.03, 0.001))
|
||||
Expect(m.BitRate()).To(Equal(192))
|
||||
Expect(m.FilePath()).To(Equal("tests/fixtures/test.mp3"))
|
||||
Expect(m.Suffix()).To(Equal("mp3"))
|
||||
Expect(m.Size()).To(Equal(60845))
|
||||
Expect(m.Size()).To(Equal(int64(51876)))
|
||||
|
||||
m = mds["tests/fixtures/test.ogg"]
|
||||
Expect(err).To(BeNil())
|
||||
Expect(m.Title()).To(BeEmpty())
|
||||
Expect(m.HasPicture()).To(BeFalse())
|
||||
Expect(m.Duration()).To(Equal(3))
|
||||
Expect(m.BitRate()).To(Equal(9))
|
||||
Expect(m.Duration()).To(BeNumerically("~", 1.04, 0.001))
|
||||
Expect(m.BitRate()).To(Equal(16))
|
||||
Expect(m.Suffix()).To(Equal("ogg"))
|
||||
Expect(m.FilePath()).To(Equal("tests/fixtures/test.ogg"))
|
||||
Expect(m.Size()).To(Equal(4408))
|
||||
Expect(m.Size()).To(Equal(int64(5065)))
|
||||
})
|
||||
})
|
||||
|
||||
|
@ -16,10 +16,10 @@ import (
|
||||
)
|
||||
|
||||
type Extractor interface {
|
||||
Extract(files ...string) (map[string]Metadata, error)
|
||||
Extract(files ...string) (map[string]*Tags, error)
|
||||
}
|
||||
|
||||
func Extract(files ...string) (map[string]Metadata, error) {
|
||||
func Extract(files ...string) (map[string]*Tags, error) {
|
||||
var e Extractor
|
||||
|
||||
switch conf.Server.Scanner.Extractor {
|
||||
@ -35,167 +35,97 @@ func Extract(files ...string) (map[string]Metadata, error) {
|
||||
return e.Extract(files...)
|
||||
}
|
||||
|
||||
type Metadata interface {
|
||||
Title() string
|
||||
Album() string
|
||||
Artist() string
|
||||
AlbumArtist() string
|
||||
SortTitle() string
|
||||
SortAlbum() string
|
||||
SortArtist() string
|
||||
SortAlbumArtist() string
|
||||
Composer() string
|
||||
Genre() string
|
||||
Year() int
|
||||
TrackNumber() (int, int)
|
||||
DiscNumber() (int, int)
|
||||
DiscSubtitle() string
|
||||
HasPicture() bool
|
||||
Comment() string
|
||||
Lyrics() string
|
||||
Compilation() bool
|
||||
CatalogNum() string
|
||||
MbzTrackID() string
|
||||
MbzAlbumID() string
|
||||
MbzArtistID() string
|
||||
MbzAlbumArtistID() string
|
||||
MbzAlbumType() string
|
||||
MbzAlbumComment() string
|
||||
Duration() float32
|
||||
BitRate() int
|
||||
ModificationTime() time.Time
|
||||
FilePath() string
|
||||
Suffix() string
|
||||
Size() int64
|
||||
Bpm() int
|
||||
}
|
||||
|
||||
type baseMetadata struct {
|
||||
type Tags struct {
|
||||
filePath string
|
||||
suffix string
|
||||
fileInfo os.FileInfo
|
||||
tags map[string]string
|
||||
tags map[string][]string
|
||||
custom map[string][]string
|
||||
}
|
||||
|
||||
func (m *baseMetadata) Title() string { return m.getTag("title", "sort_name", "titlesort") }
|
||||
func (m *baseMetadata) Album() string { return m.getTag("album", "sort_album", "albumsort") }
|
||||
func (m *baseMetadata) Artist() string { return m.getTag("artist", "sort_artist", "artistsort") }
|
||||
func (m *baseMetadata) AlbumArtist() string {
|
||||
return m.getTag("album_artist", "album artist", "albumartist")
|
||||
}
|
||||
func (m *baseMetadata) SortTitle() string { return m.getSortTag("", "title", "name") }
|
||||
func (m *baseMetadata) SortAlbum() string { return m.getSortTag("", "album") }
|
||||
func (m *baseMetadata) SortArtist() string { return m.getSortTag("", "artist") }
|
||||
func (m *baseMetadata) SortAlbumArtist() string {
|
||||
return m.getSortTag("tso2", "albumartist", "album_artist")
|
||||
}
|
||||
func (m *baseMetadata) Composer() string { return m.getTag("composer", "tcm", "sort_composer") }
|
||||
func (m *baseMetadata) Genre() string { return m.getTag("genre") }
|
||||
func (m *baseMetadata) Year() int { return m.parseYear("date") }
|
||||
func (m *baseMetadata) Comment() string { return m.getTag("comment") }
|
||||
func (m *baseMetadata) Lyrics() string { return m.getTag("lyrics", "lyrics-eng") }
|
||||
func (m *baseMetadata) Compilation() bool { return m.parseBool("tcmp", "compilation") }
|
||||
func (m *baseMetadata) TrackNumber() (int, int) { return m.parseTuple("track", "tracknumber") }
|
||||
func (m *baseMetadata) DiscNumber() (int, int) { return m.parseTuple("disc", "discnumber") }
|
||||
func (m *baseMetadata) DiscSubtitle() string {
|
||||
return m.getTag("tsst", "discsubtitle", "setsubtitle")
|
||||
}
|
||||
func (m *baseMetadata) CatalogNum() string { return m.getTag("catalognumber") }
|
||||
func (m *baseMetadata) MbzTrackID() string {
|
||||
return m.getMbzID("musicbrainz_trackid", "musicbrainz track id")
|
||||
}
|
||||
func (m *baseMetadata) MbzAlbumID() string {
|
||||
return m.getMbzID("musicbrainz_albumid", "musicbrainz album id")
|
||||
}
|
||||
func (m *baseMetadata) MbzArtistID() string {
|
||||
return m.getMbzID("musicbrainz_artistid", "musicbrainz artist id")
|
||||
}
|
||||
func (m *baseMetadata) MbzAlbumArtistID() string {
|
||||
return m.getMbzID("musicbrainz_albumartistid", "musicbrainz album artist id")
|
||||
}
|
||||
func (m *baseMetadata) MbzAlbumType() string {
|
||||
return m.getTag("musicbrainz_albumtype", "musicbrainz album type")
|
||||
}
|
||||
func (m *baseMetadata) MbzAlbumComment() string {
|
||||
return m.getTag("musicbrainz_albumcomment", "musicbrainz album comment")
|
||||
}
|
||||
func NewTag(filePath string, tags, custom map[string][]string) *Tags {
|
||||
fileInfo, err := os.Stat(filePath)
|
||||
if err != nil {
|
||||
log.Warn("Error stating file. Skipping", "filePath", filePath, err)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *baseMetadata) ModificationTime() time.Time { return m.fileInfo.ModTime() }
|
||||
func (m *baseMetadata) Size() int64 { return m.fileInfo.Size() }
|
||||
func (m *baseMetadata) FilePath() string { return m.filePath }
|
||||
func (m *baseMetadata) Suffix() string {
|
||||
return strings.ToLower(strings.TrimPrefix(path.Ext(m.FilePath()), "."))
|
||||
}
|
||||
|
||||
func (m *baseMetadata) Duration() float32 { panic("not implemented") }
|
||||
func (m *baseMetadata) BitRate() int { panic("not implemented") }
|
||||
func (m *baseMetadata) HasPicture() bool { panic("not implemented") }
|
||||
func (m *baseMetadata) Bpm() int {
|
||||
var bpmStr = m.getTag("tbpm", "bpm", "fbpm")
|
||||
var bpmFloat, err = strconv.ParseFloat(bpmStr, 64)
|
||||
if err == nil {
|
||||
return (int)(math.Round(bpmFloat))
|
||||
} else {
|
||||
return 0
|
||||
return &Tags{
|
||||
filePath: filePath,
|
||||
suffix: strings.ToLower(strings.TrimPrefix(path.Ext(filePath), ".")),
|
||||
fileInfo: fileInfo,
|
||||
tags: tags,
|
||||
custom: custom,
|
||||
}
|
||||
}
|
||||
|
||||
func (m *baseMetadata) parseInt(tagName string) int {
|
||||
if v, ok := m.tags[tagName]; ok {
|
||||
i, _ := strconv.Atoi(v)
|
||||
return i
|
||||
}
|
||||
return 0
|
||||
// Common tags
|
||||
|
||||
func (t *Tags) Title() string { return t.getTag("title", "sort_name", "titlesort") }
|
||||
func (t *Tags) Album() string { return t.getTag("album", "sort_album", "albumsort") }
|
||||
func (t *Tags) Artist() string { return t.getTag("artist", "sort_artist", "artistsort") }
|
||||
func (t *Tags) AlbumArtist() string { return t.getTag("album_artist", "album artist", "albumartist") }
|
||||
func (t *Tags) SortTitle() string { return t.getSortTag("", "title", "name") }
|
||||
func (t *Tags) SortAlbum() string { return t.getSortTag("", "album") }
|
||||
func (t *Tags) SortArtist() string { return t.getSortTag("", "artist") }
|
||||
func (t *Tags) SortAlbumArtist() string { return t.getSortTag("tso2", "albumartist", "album_artist") }
|
||||
func (t *Tags) Genre() string { return t.getTag("genre") }
|
||||
func (t *Tags) Year() int { return t.getYear("date") }
|
||||
func (t *Tags) Comment() string { return t.getTag("comment") }
|
||||
func (t *Tags) Lyrics() string { return t.getTag("lyrics", "lyrics-eng") }
|
||||
func (t *Tags) Compilation() bool { return t.getBool("tcmp", "compilation") }
|
||||
func (t *Tags) TrackNumber() (int, int) { return t.getTuple("track", "tracknumber") }
|
||||
func (t *Tags) DiscNumber() (int, int) { return t.getTuple("disc", "discnumber") }
|
||||
func (t *Tags) DiscSubtitle() string { return t.getTag("tsst", "discsubtitle", "setsubtitle") }
|
||||
func (t *Tags) CatalogNum() string { return t.getTag("catalognumber") }
|
||||
func (t *Tags) Bpm() int { return (int)(math.Round(t.getFloat("tbpm", "bpm", "fbpm"))) }
|
||||
func (t *Tags) HasPicture() bool { return t.getTag("has_picture") != "" }
|
||||
|
||||
// MusicBrainz Identifiers
|
||||
|
||||
func (t *Tags) MbzTrackID() string { return t.getMbzID("musicbrainz_trackid", "musicbrainz track id") }
|
||||
func (t *Tags) MbzAlbumID() string { return t.getMbzID("musicbrainz_albumid", "musicbrainz album id") }
|
||||
func (t *Tags) MbzArtistID() string {
|
||||
return t.getMbzID("musicbrainz_artistid", "musicbrainz artist id")
|
||||
}
|
||||
func (t *Tags) MbzAlbumArtistID() string {
|
||||
return t.getMbzID("musicbrainz_albumartistid", "musicbrainz album artist id")
|
||||
}
|
||||
func (t *Tags) MbzAlbumType() string {
|
||||
return t.getTag("musicbrainz_albumtype", "musicbrainz album type")
|
||||
}
|
||||
func (t *Tags) MbzAlbumComment() string {
|
||||
return t.getTag("musicbrainz_albumcomment", "musicbrainz album comment")
|
||||
}
|
||||
|
||||
func (m *baseMetadata) parseFloat(tagName string) float32 {
|
||||
if v, ok := m.tags[tagName]; ok {
|
||||
f, _ := strconv.ParseFloat(v, 32)
|
||||
return float32(f)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
// File properties
|
||||
|
||||
var dateRegex = regexp.MustCompile(`([12]\d\d\d)`)
|
||||
func (t *Tags) Duration() float32 { return float32(t.getFloat("duration")) }
|
||||
func (t *Tags) BitRate() int { return t.getInt("bitrate") }
|
||||
func (t *Tags) ModificationTime() time.Time { return t.fileInfo.ModTime() }
|
||||
func (t *Tags) Size() int64 { return t.fileInfo.Size() }
|
||||
func (t *Tags) FilePath() string { return t.filePath }
|
||||
func (t *Tags) Suffix() string { return t.suffix }
|
||||
|
||||
func (m *baseMetadata) parseYear(tags ...string) int {
|
||||
for _, t := range tags {
|
||||
if v, ok := m.tags[t]; ok {
|
||||
match := dateRegex.FindStringSubmatch(v)
|
||||
if len(match) == 0 {
|
||||
log.Warn("Error parsing year date field", "file", m.filePath, "date", v)
|
||||
return 0
|
||||
}
|
||||
year, _ := strconv.Atoi(match[1])
|
||||
return year
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (m *baseMetadata) getMbzID(tags ...string) string {
|
||||
var value string
|
||||
for _, t := range tags {
|
||||
if v, ok := m.tags[t]; ok {
|
||||
value = v
|
||||
break
|
||||
}
|
||||
}
|
||||
if _, err := uuid.Parse(value); err != nil {
|
||||
return ""
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func (m *baseMetadata) getTag(tags ...string) string {
|
||||
for _, t := range tags {
|
||||
if v, ok := m.tags[t]; ok {
|
||||
func (t *Tags) getTags(tags ...string) []string {
|
||||
allTags := append(tags, t.custom[tags[0]]...)
|
||||
for _, tag := range allTags {
|
||||
if v, ok := t.tags[tag]; ok {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (t *Tags) getTag(tags ...string) string {
|
||||
ts := t.getTags(tags...)
|
||||
if len(ts) > 0 {
|
||||
return ts[0]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (m *baseMetadata) getSortTag(originalTag string, tags ...string) string {
|
||||
func (t *Tags) getSortTag(originalTag string, tags ...string) string {
|
||||
formats := []string{"sort%s", "sort_%s", "sort-%s", "%ssort", "%s_sort", "%s-sort"}
|
||||
all := []string{originalTag}
|
||||
for _, tag := range tags {
|
||||
@ -204,45 +134,70 @@ func (m *baseMetadata) getSortTag(originalTag string, tags ...string) string {
|
||||
all = append(all, name)
|
||||
}
|
||||
}
|
||||
return m.getTag(all...)
|
||||
return t.getTag(all...)
|
||||
}
|
||||
|
||||
func (m *baseMetadata) parseTuple(tags ...string) (int, int) {
|
||||
for _, tagName := range tags {
|
||||
if v, ok := m.tags[tagName]; ok {
|
||||
tuple := strings.Split(v, "/")
|
||||
t1, t2 := 0, 0
|
||||
t1, _ = strconv.Atoi(tuple[0])
|
||||
if len(tuple) > 1 {
|
||||
t2, _ = strconv.Atoi(tuple[1])
|
||||
} else {
|
||||
t2, _ = strconv.Atoi(m.tags[tagName+"total"])
|
||||
}
|
||||
return t1, t2
|
||||
}
|
||||
var dateRegex = regexp.MustCompile(`([12]\d\d\d)`)
|
||||
|
||||
func (t *Tags) getYear(tags ...string) int {
|
||||
tag := t.getTag(tags...)
|
||||
if tag == "" {
|
||||
return 0
|
||||
}
|
||||
return 0, 0
|
||||
}
|
||||
|
||||
func (m *baseMetadata) parseBool(tags ...string) bool {
|
||||
for _, tagName := range tags {
|
||||
if v, ok := m.tags[tagName]; ok {
|
||||
i, _ := strconv.Atoi(strings.TrimSpace(v))
|
||||
return i == 1
|
||||
}
|
||||
match := dateRegex.FindStringSubmatch(tag)
|
||||
if len(match) == 0 {
|
||||
log.Warn("Error parsing year date field", "file", t.filePath, "date", tag)
|
||||
return 0
|
||||
}
|
||||
return false
|
||||
year, _ := strconv.Atoi(match[1])
|
||||
return year
|
||||
}
|
||||
|
||||
var zeroTime = time.Date(0000, time.January, 1, 0, 0, 0, 0, time.UTC)
|
||||
|
||||
func (m *baseMetadata) parseDuration(tagName string) float32 {
|
||||
if v, ok := m.tags[tagName]; ok {
|
||||
d, err := time.Parse("15:04:05", v)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return float32(d.Sub(zeroTime).Seconds())
|
||||
func (t *Tags) getBool(tags ...string) bool {
|
||||
tag := t.getTag(tags...)
|
||||
if tag == "" {
|
||||
return false
|
||||
}
|
||||
return 0
|
||||
i, _ := strconv.Atoi(strings.TrimSpace(tag))
|
||||
return i == 1
|
||||
}
|
||||
|
||||
func (t *Tags) getTuple(tags ...string) (int, int) {
|
||||
tag := t.getTag(tags...)
|
||||
if tag == "" {
|
||||
return 0, 0
|
||||
}
|
||||
tuple := strings.Split(tag, "/")
|
||||
t1, t2 := 0, 0
|
||||
t1, _ = strconv.Atoi(tuple[0])
|
||||
if len(tuple) > 1 {
|
||||
t2, _ = strconv.Atoi(tuple[1])
|
||||
} else {
|
||||
t2tag := t.getTag(tags[0] + "total")
|
||||
t2, _ = strconv.Atoi(t2tag)
|
||||
}
|
||||
return t1, t2
|
||||
}
|
||||
|
||||
func (t *Tags) getMbzID(tags ...string) string {
|
||||
tag := t.getTag(tags...)
|
||||
if _, err := uuid.Parse(tag); err != nil {
|
||||
return ""
|
||||
}
|
||||
return tag
|
||||
}
|
||||
|
||||
func (t *Tags) getInt(tags ...string) int {
|
||||
tag := t.getTag(tags...)
|
||||
i, _ := strconv.Atoi(tag)
|
||||
return i
|
||||
}
|
||||
|
||||
func (t *Tags) getFloat(tags ...string) float64 {
|
||||
var tag = t.getTag(tags...)
|
||||
var value, err = strconv.ParseFloat(tag, 64)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
@ -5,8 +5,8 @@ import (
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("ffmpegMetadata", func() {
|
||||
Describe("parseYear", func() {
|
||||
var _ = Describe("Tags", func() {
|
||||
Describe("getYear", func() {
|
||||
It("parses the year correctly", func() {
|
||||
var examples = map[string]int{
|
||||
"1985": 1985,
|
||||
@ -19,27 +19,27 @@ var _ = Describe("ffmpegMetadata", func() {
|
||||
"01/10/1990": 1990,
|
||||
}
|
||||
for tag, expected := range examples {
|
||||
md := &baseMetadata{}
|
||||
md.tags = map[string]string{"date": tag}
|
||||
md := &Tags{}
|
||||
md.tags = map[string][]string{"date": {tag}}
|
||||
Expect(md.Year()).To(Equal(expected))
|
||||
}
|
||||
})
|
||||
|
||||
It("returns 0 if year is invalid", func() {
|
||||
md := &baseMetadata{}
|
||||
md.tags = map[string]string{"date": "invalid"}
|
||||
md := &Tags{}
|
||||
md.tags = map[string][]string{"date": {"invalid"}}
|
||||
Expect(md.Year()).To(Equal(0))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("getMbzID", func() {
|
||||
It("return a valid MBID", func() {
|
||||
md := &baseMetadata{}
|
||||
md.tags = map[string]string{
|
||||
"musicbrainz_trackid": "8f84da07-09a0-477b-b216-cc982dabcde1",
|
||||
"musicbrainz_albumid": "f68c985d-f18b-4f4a-b7f0-87837cf3fbf9",
|
||||
"musicbrainz_artistid": "89ad4ac3-39f7-470e-963a-56509c546377",
|
||||
"musicbrainz_albumartistid": "ada7a83c-e3e1-40f1-93f9-3e73dbc9298a",
|
||||
md := &Tags{}
|
||||
md.tags = map[string][]string{
|
||||
"musicbrainz_trackid": {"8f84da07-09a0-477b-b216-cc982dabcde1"},
|
||||
"musicbrainz_albumid": {"f68c985d-f18b-4f4a-b7f0-87837cf3fbf9"},
|
||||
"musicbrainz_artistid": {"89ad4ac3-39f7-470e-963a-56509c546377"},
|
||||
"musicbrainz_albumartistid": {"ada7a83c-e3e1-40f1-93f9-3e73dbc9298a"},
|
||||
}
|
||||
Expect(md.MbzTrackID()).To(Equal("8f84da07-09a0-477b-b216-cc982dabcde1"))
|
||||
Expect(md.MbzAlbumID()).To(Equal("f68c985d-f18b-4f4a-b7f0-87837cf3fbf9"))
|
||||
@ -47,12 +47,12 @@ var _ = Describe("ffmpegMetadata", func() {
|
||||
Expect(md.MbzAlbumArtistID()).To(Equal("ada7a83c-e3e1-40f1-93f9-3e73dbc9298a"))
|
||||
})
|
||||
It("return empty string for invalid MBID", func() {
|
||||
md := &baseMetadata{}
|
||||
md.tags = map[string]string{
|
||||
"musicbrainz_trackid": "11406732-6",
|
||||
"musicbrainz_albumid": "11406732",
|
||||
"musicbrainz_artistid": "200455",
|
||||
"musicbrainz_albumartistid": "194",
|
||||
md := &Tags{}
|
||||
md.tags = map[string][]string{
|
||||
"musicbrainz_trackid": {"11406732-6"},
|
||||
"musicbrainz_albumid": {"11406732"},
|
||||
"musicbrainz_artistid": {"200455"},
|
||||
"musicbrainz_albumartistid": {"194"},
|
||||
}
|
||||
Expect(md.MbzTrackID()).To(Equal(""))
|
||||
Expect(md.MbzAlbumID()).To(Equal(""))
|
||||
|
@ -1,7 +1,6 @@
|
||||
package metadata
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
|
||||
"github.com/dhowden/tag"
|
||||
@ -9,51 +8,39 @@ import (
|
||||
"github.com/navidrome/navidrome/scanner/metadata/taglib"
|
||||
)
|
||||
|
||||
type taglibMetadata struct {
|
||||
baseMetadata
|
||||
hasPicture bool
|
||||
}
|
||||
|
||||
func (m *taglibMetadata) Title() string { return m.getTag("title", "titlesort", "_track") }
|
||||
func (m *taglibMetadata) Album() string { return m.getTag("album", "albumsort", "_album") }
|
||||
func (m *taglibMetadata) Artist() string { return m.getTag("artist", "artistsort", "_artist") }
|
||||
func (m *taglibMetadata) Genre() string { return m.getTag("genre", "_genre") }
|
||||
func (m *taglibMetadata) Year() int { return m.parseYear("date", "_year") }
|
||||
func (m *taglibMetadata) TrackNumber() (int, int) {
|
||||
return m.parseTuple("track", "tracknumber", "_track")
|
||||
}
|
||||
func (m *taglibMetadata) Duration() float32 { return m.parseFloat("length") }
|
||||
func (m *taglibMetadata) BitRate() int { return m.parseInt("bitrate") }
|
||||
func (m *taglibMetadata) HasPicture() bool { return m.hasPicture }
|
||||
|
||||
type taglibExtractor struct{}
|
||||
|
||||
func (e *taglibExtractor) Extract(paths ...string) (map[string]Metadata, error) {
|
||||
mds := map[string]Metadata{}
|
||||
func (e *taglibExtractor) Extract(paths ...string) (map[string]*Tags, error) {
|
||||
fileTags := map[string]*Tags{}
|
||||
for _, path := range paths {
|
||||
md, err := e.extractMetadata(path)
|
||||
tags, err := e.extractMetadata(path)
|
||||
if err == nil {
|
||||
mds[path] = md
|
||||
fileTags[path] = tags
|
||||
}
|
||||
}
|
||||
return mds, nil
|
||||
return fileTags, nil
|
||||
}
|
||||
|
||||
func (e *taglibExtractor) extractMetadata(filePath string) (*taglibMetadata, error) {
|
||||
var err error
|
||||
md := &taglibMetadata{}
|
||||
md.filePath = filePath
|
||||
md.fileInfo, err = os.Stat(filePath)
|
||||
if err != nil {
|
||||
log.Warn("Error stating file. Skipping", "filePath", filePath, err)
|
||||
return nil, errors.New("error stating file")
|
||||
}
|
||||
md.tags, err = taglib.Read(filePath)
|
||||
func (e *taglibExtractor) extractMetadata(filePath string) (*Tags, error) {
|
||||
parsedTags, err := taglib.Read(filePath)
|
||||
if err != nil {
|
||||
log.Warn("Error reading metadata from file. Skipping", "filePath", filePath, err)
|
||||
}
|
||||
md.hasPicture = hasEmbeddedImage(filePath)
|
||||
return md, nil
|
||||
if hasEmbeddedImage(filePath) {
|
||||
parsedTags["has_picture"] = []string{"true"}
|
||||
}
|
||||
|
||||
tags := NewTag(filePath, parsedTags, map[string][]string{
|
||||
"title": {"_track", "titlesort"},
|
||||
"album": {"_album", "albumsort"},
|
||||
"artist": {"_artist", "artistsort"},
|
||||
"genre": {"_genre"},
|
||||
"date": {"_year"},
|
||||
"track": {"_track"},
|
||||
"duration": {"length"},
|
||||
})
|
||||
|
||||
return tags, nil
|
||||
}
|
||||
|
||||
func hasEmbeddedImage(path string) bool {
|
||||
@ -77,6 +64,3 @@ func hasEmbeddedImage(path string) bool {
|
||||
|
||||
return m.Picture() != nil
|
||||
}
|
||||
|
||||
var _ Metadata = (*taglibMetadata)(nil)
|
||||
var _ Extractor = (*taglibExtractor)(nil)
|
||||
|
@ -20,7 +20,7 @@ import (
|
||||
"github.com/navidrome/navidrome/log"
|
||||
)
|
||||
|
||||
func Read(filename string) (map[string]string, error) {
|
||||
func Read(filename string) (map[string][]string, error) {
|
||||
fp := C.CString(filename)
|
||||
defer C.free(unsafe.Pointer(fp))
|
||||
id, m := newMap()
|
||||
@ -44,15 +44,15 @@ func Read(filename string) (map[string]string, error) {
|
||||
}
|
||||
|
||||
var lock sync.RWMutex
|
||||
var maps = make(map[uint32]map[string]string)
|
||||
var maps = make(map[uint32]map[string][]string)
|
||||
var mapsNextID uint32
|
||||
|
||||
func newMap() (id uint32, m map[string]string) {
|
||||
func newMap() (id uint32, m map[string][]string) {
|
||||
lock.Lock()
|
||||
defer lock.Unlock()
|
||||
id = mapsNextID
|
||||
mapsNextID++
|
||||
m = make(map[string]string)
|
||||
m = make(map[string][]string)
|
||||
maps[id] = m
|
||||
return
|
||||
}
|
||||
@ -69,10 +69,8 @@ func go_map_put_str(id C.ulong, key *C.char, val *C.char) {
|
||||
defer lock.RUnlock()
|
||||
m := maps[uint32(id)]
|
||||
k := strings.ToLower(C.GoString(key))
|
||||
if _, ok := m[k]; !ok {
|
||||
v := strings.TrimSpace(C.GoString(val))
|
||||
m[k] = v
|
||||
}
|
||||
v := strings.TrimSpace(C.GoString(val))
|
||||
m[k] = append(m[k], v)
|
||||
}
|
||||
|
||||
//export go_map_put_int
|
||||
|
@ -18,7 +18,6 @@ var _ = Describe("taglibExtractor", func() {
|
||||
Expect(m.Album()).To(Equal("Album"))
|
||||
Expect(m.Artist()).To(Equal("Artist"))
|
||||
Expect(m.AlbumArtist()).To(Equal("Album Artist"))
|
||||
Expect(m.Composer()).To(Equal("Composer"))
|
||||
Expect(m.Compilation()).To(BeTrue())
|
||||
Expect(m.Genre()).To(Equal("Rock"))
|
||||
Expect(m.Year()).To(Equal(2014))
|
||||
|
@ -51,7 +51,7 @@ const (
|
||||
filesBatchSize = 100
|
||||
)
|
||||
|
||||
// TagScanner algorithm overview:
|
||||
// Scan algorithm overview:
|
||||
// Load all directories from the DB
|
||||
// Traverse the music folder, collecting each subfolder's ModTime (self or any non-dir children, whichever is newer)
|
||||
// For each changed folder: get all files from DB whose path starts with the changed folder (non-recursively), check each file:
|
||||
|
Loading…
x
Reference in New Issue
Block a user