navidrome/scanner/metadata.go

141 lines
3.7 KiB
Go

package scanner
import (
"encoding/json"
"errors"
"os"
"os/exec"
"path"
"strconv"
"strings"
"time"
"github.com/cloudsonic/sonic-server/conf"
"github.com/cloudsonic/sonic-server/log"
"github.com/dhowden/tag"
)
type Metadata struct {
filePath string
suffix string
fileInfo os.FileInfo
t tag.Metadata
duration int
bitRate int
compilation bool
}
func ExtractMetadata(filePath string) (*Metadata, error) {
m := &Metadata{filePath: filePath}
m.suffix = strings.ToLower(strings.TrimPrefix(path.Ext(filePath), "."))
fi, err := os.Stat(filePath)
if err != nil {
return nil, err
}
m.fileInfo = fi
f, err := os.Open(filePath)
if err != nil {
return nil, err
}
defer f.Close()
t, err := tag.ReadFrom(f)
if err != nil {
return nil, err
}
m.t = t
err = m.probe(filePath)
return m, err
}
func (m *Metadata) Title() string { return m.t.Title() }
func (m *Metadata) Album() string { return m.t.Album() }
func (m *Metadata) Artist() string { return m.t.Artist() }
func (m *Metadata) AlbumArtist() string { return m.t.AlbumArtist() }
func (m *Metadata) Composer() string { return m.t.Composer() }
func (m *Metadata) Genre() string { return m.t.Genre() }
func (m *Metadata) Year() int { return m.t.Year() }
func (m *Metadata) TrackNumber() (int, int) { return m.t.Track() }
func (m *Metadata) DiscNumber() (int, int) { return m.t.Disc() }
func (m *Metadata) HasPicture() bool { return m.t.Picture() != nil }
func (m *Metadata) Compilation() bool { return m.compilation }
func (m *Metadata) Duration() int { return m.duration }
func (m *Metadata) BitRate() int { return m.bitRate }
func (m *Metadata) ModificationTime() time.Time { return m.fileInfo.ModTime() }
func (m *Metadata) FilePath() string { return m.filePath }
func (m *Metadata) Suffix() string { return m.suffix }
func (m *Metadata) Size() int { return int(m.fileInfo.Size()) }
// probe analyzes the file and returns duration in seconds and bitRate in kb/s.
// It uses the ffprobe external tool, configured in conf.Sonic.ProbeCommand
func (m *Metadata) probe(filePath string) error {
cmdLine, args := createProbeCommand(filePath)
log.Trace("Executing command", "cmdLine", cmdLine, "args", args)
cmd := exec.Command(cmdLine, args...)
output, err := cmd.CombinedOutput()
if err != nil {
return err
}
return m.parseOutput(output)
}
func (m *Metadata) parseInt(objItf interface{}, field string) (int, error) {
obj := objItf.(map[string]interface{})
s, ok := obj[field].(string)
if !ok {
return -1, errors.New("invalid ffprobe output field obj." + field)
}
fDuration, err := strconv.ParseFloat(s, 64)
if err != nil {
return -1, err
}
return int(fDuration), nil
}
func (m *Metadata) parseOutput(output []byte) error {
var data map[string]map[string]interface{}
err := json.Unmarshal(output, &data)
if err != nil {
return err
}
format, ok := data["format"]
if !ok {
err = errors.New("invalid ffprobe output. no format found")
return err
}
if tags, ok := format["tags"]; ok {
c, _ := m.parseInt(tags, "compilation")
m.compilation = c == 1
}
m.duration, err = m.parseInt(format, "duration")
if err != nil {
return err
}
m.bitRate, err = m.parseInt(format, "bit_rate")
m.bitRate = m.bitRate / 1000
if err != nil {
return err
}
return nil
}
func createProbeCommand(filePath string) (string, []string) {
cmd := conf.Sonic.ProbeCommand
split := strings.Split(cmd, " ")
for i, s := range split {
s = strings.Replace(s, "%s", filePath, -1)
split[i] = s
}
return split[0], split[1:]
}