package gotaglib
import (
"errors"
"fmt"
"io"
"io/fs"
"runtime/debug"
"strings"
"time"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/core/storage/local"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model/metadata"
"go.senan.xyz/taglib"
)
type extractor struct {
fs fs.FS
}
func (e extractor) Parse(files ...string) (map[string]metadata.Info, error) {
results := make(map[string]metadata.Info)
for _, path := range files {
props, err := e.extractMetadata(path)
if err != nil {
continue
}
results[path] = *props
}
return results, nil
}
func (e extractor) Version() string {
bi, ok := debug.ReadBuildInfo()
if ok {
for _, dep := range bi.Deps {
if dep.Path == "go.senan.xyz/taglib" {
if dep.Replace != nil {
return dep.Replace.Version
}
return dep.Version
}
}
}
return "unknown"
}
func (e extractor) extractMetadata(filePath string) (info *metadata.Info, err error) {
debug.SetPanicOnFault(true)
defer func() {
if r := recover(); r != nil {
log.Error("gotaglib: WASM runtime panic reading file. Skipping", "filePath", filePath, "panic", r)
debug.PrintStack()
err = fmt.Errorf("WASM runtime panic: %v", r)
}
}()
f, close, err := e.openFile(filePath)
if err != nil {
log.Warn("gotaglib: Error reading metadata from file. Skipping", "filePath", filePath, err)
return nil, err
}
defer close()
allTags := f.AllTags()
props := f.Properties()
ap := metadata.AudioProperties{
Duration: props.Length.Round(time.Millisecond * 10),
BitRate: int(props.Bitrate),
Channels: int(props.Channels),
SampleRate: int(props.SampleRate),
BitDepth: int(props.BitsPerSample),
Codec: props.Codec,
}
normalizedTags := make(map[string][]string, len(allTags.Tags))
for key, values := range allTags.Tags {
lowerKey := strings.ToLower(key)
normalizedTags[lowerKey] = values
}
processRawTags(allTags, normalizedTags)
parseTuple(normalizedTags, "track")
parseTuple(normalizedTags, "disc")
parseLyrics(normalizedTags)
parseTIPL(normalizedTags)
delete(normalizedTags, "tmcl")
hasPicture := len(props.Images) > 0
return &metadata.Info{
Tags: normalizedTags,
AudioProperties: ap,
HasPicture: hasPicture,
}, nil
}
func (e extractor) openFile(filePath string) (f *taglib.File, closeFunc func(), err error) {
file, err := e.fs.Open(filePath)
if err != nil {
return nil, nil, err
}
rs, isSeekable := file.(io.ReadSeeker)
if !isSeekable {
file.Close()
return nil, nil, errors.New("file is not seekable")
}
f, err = taglib.OpenStream(rs,
taglib.WithReadStyle(taglib.ReadStyleFast),
taglib.WithFilename(filePath),
)
if err != nil {
file.Close()
return nil, nil, err
}
closeFunc = func() {
f.Close()
file.Close()
}
return f, closeFunc, nil
}
func parseTuple(tags map[string][]string, prop string) {
tagName := prop + "number"
tagTotal := prop + "total"
if value, ok := tags[tagName]; ok && len(value) > 0 {
parts := strings.Split(value[0], "/")
tags[tagName] = []string{parts[0]}
if len(parts) == 2 {
tags[tagTotal] = []string{parts[1]}
}
}
}
func parseLyrics(tags map[string][]string) {
lyrics := tags["lyrics"]
if len(lyrics) > 0 {
tags["lyrics:xxx"] = lyrics
delete(tags, "lyrics")
}
}
func processRawTags(allTags taglib.AllTags, normalizedTags map[string][]string) {
switch allTags.Format {
case taglib.FormatMPEG, taglib.FormatWAV, taglib.FormatAIFF:
parseID3v2Frames(allTags.Raw, normalizedTags)
case taglib.FormatMP4:
parseMP4Atoms(allTags.Raw, normalizedTags)
case taglib.FormatASF:
parseASFAttributes(allTags.Raw, normalizedTags)
}
}
func parseID3v2Frames(rawFrames map[string][]string, tags map[string][]string) {
for key, values := range rawFrames {
lowerKey := strings.ToLower(key)
if strings.HasPrefix(lowerKey, "uslt:") || strings.HasPrefix(lowerKey, "sylt:") {
parts := strings.SplitN(lowerKey, ":", 2)
if len(parts) == 2 && parts[1] != "" {
lang := parts[1]
lyricsKey := "lyrics:" + lang
tags[lyricsKey] = append(tags[lyricsKey], values...)
}
}
}
for key := range tags {
if strings.HasPrefix(key, "lyrics:") && key != "lyrics" {
delete(tags, "lyrics")
break
}
}
}
const iTunesKeyPrefix = "----:com.apple.iTunes:"
func parseMP4Atoms(rawAtoms map[string][]string, tags map[string][]string) {
for key, values := range rawAtoms {
normalizedKey := strings.TrimPrefix(key, iTunesKeyPrefix)
normalizedKey = strings.ToLower(normalizedKey)
if _, exists := tags[normalizedKey]; !exists {
tags[normalizedKey] = values
}
}
}
func parseASFAttributes(rawAttrs map[string][]string, tags map[string][]string) {
for key, values := range rawAttrs {
normalizedKey := strings.ToLower(key)
if _, exists := tags[normalizedKey]; !exists {
tags[normalizedKey] = values
}
}
}
var tiplMapping = map[string]string{
"arranger": "arranger",
"engineer": "engineer",
"producer": "producer",
"mix": "mixer",
"DJ-mix": "djmixer",
}
func parseTIPL(tags map[string][]string) {
tipl := tags["tipl"]
if len(tipl) == 0 {
return
}
addRole := func(currentRole string, currentValue []string) {
if currentRole != "" && len(currentValue) > 0 {
role := tiplMapping[currentRole]
tags[role] = append(tags[role], strings.Join(currentValue, " "))
}
}
var currentRole string
var currentValue []string
for part := range strings.SplitSeq(tipl[0], " ") {
if _, ok := tiplMapping[part]; ok {
addRole(currentRole, currentValue)
currentRole = part
currentValue = nil
continue
}
currentValue = append(currentValue, part)
}
addRole(currentRole, currentValue)
delete(tags, "tipl")
}
var _ local.Extractor = (*extractor)(nil)
func init() {
local.RegisterExtractor("taglib", func(fsys fs.FS, baseDir string) local.Extractor {
return &extractor{fsys}
})
conf.AddHook(func() {
log.Debug("go-taglib version", "version", extractor{}.Version())
})
}