update
Some checks failed
Pipeline: Test, Lint, Build / Get version info (push) Has been cancelled
Pipeline: Test, Lint, Build / Lint Go code (push) Has been cancelled
Pipeline: Test, Lint, Build / Test Go code (push) Has been cancelled
Pipeline: Test, Lint, Build / Test JS code (push) Has been cancelled
Pipeline: Test, Lint, Build / Lint i18n files (push) Has been cancelled
Pipeline: Test, Lint, Build / Check Docker configuration (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (darwin/amd64) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (darwin/arm64) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (linux/386) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (linux/amd64) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (linux/arm/v5) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (linux/arm/v6) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (linux/arm/v7) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (linux/arm64) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (windows/386) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (windows/amd64) (push) Has been cancelled
Pipeline: Test, Lint, Build / Push to GHCR (push) Has been cancelled
Pipeline: Test, Lint, Build / Push to Docker Hub (push) Has been cancelled
Pipeline: Test, Lint, Build / Cleanup digest artifacts (push) Has been cancelled
Pipeline: Test, Lint, Build / Build Windows installers (push) Has been cancelled
Pipeline: Test, Lint, Build / Package/Release (push) Has been cancelled
Pipeline: Test, Lint, Build / Upload Linux PKG (push) Has been cancelled
Close stale issues and PRs / stale (push) Has been cancelled
POEditor import / update-translations (push) Has been cancelled
Some checks failed
Pipeline: Test, Lint, Build / Get version info (push) Has been cancelled
Pipeline: Test, Lint, Build / Lint Go code (push) Has been cancelled
Pipeline: Test, Lint, Build / Test Go code (push) Has been cancelled
Pipeline: Test, Lint, Build / Test JS code (push) Has been cancelled
Pipeline: Test, Lint, Build / Lint i18n files (push) Has been cancelled
Pipeline: Test, Lint, Build / Check Docker configuration (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (darwin/amd64) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (darwin/arm64) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (linux/386) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (linux/amd64) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (linux/arm/v5) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (linux/arm/v6) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (linux/arm/v7) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (linux/arm64) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (windows/386) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (windows/amd64) (push) Has been cancelled
Pipeline: Test, Lint, Build / Push to GHCR (push) Has been cancelled
Pipeline: Test, Lint, Build / Push to Docker Hub (push) Has been cancelled
Pipeline: Test, Lint, Build / Cleanup digest artifacts (push) Has been cancelled
Pipeline: Test, Lint, Build / Build Windows installers (push) Has been cancelled
Pipeline: Test, Lint, Build / Package/Release (push) Has been cancelled
Pipeline: Test, Lint, Build / Upload Linux PKG (push) Has been cancelled
Close stale issues and PRs / stale (push) Has been cancelled
POEditor import / update-translations (push) Has been cancelled
This commit is contained in:
387
model/metadata/metadata.go
Normal file
387
model/metadata/metadata.go
Normal file
@@ -0,0 +1,387 @@
|
||||
package metadata
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"io/fs"
|
||||
"math"
|
||||
"path"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/utils/slice"
|
||||
)
|
||||
|
||||
type Info struct {
|
||||
FileInfo FileInfo
|
||||
Tags model.RawTags
|
||||
AudioProperties AudioProperties
|
||||
HasPicture bool
|
||||
}
|
||||
|
||||
type FileInfo interface {
|
||||
fs.FileInfo
|
||||
BirthTime() time.Time
|
||||
}
|
||||
|
||||
type AudioProperties struct {
|
||||
Duration time.Duration
|
||||
BitRate int
|
||||
BitDepth int
|
||||
SampleRate int
|
||||
Channels int
|
||||
}
|
||||
|
||||
type Date string
|
||||
|
||||
func (d Date) Year() int {
|
||||
if d == "" {
|
||||
return 0
|
||||
}
|
||||
y, _ := strconv.Atoi(string(d[:4]))
|
||||
return y
|
||||
}
|
||||
|
||||
type Pair string
|
||||
|
||||
func (p Pair) Key() string { return p.parse(0) }
|
||||
func (p Pair) Value() string { return p.parse(1) }
|
||||
func (p Pair) parse(i int) string {
|
||||
parts := strings.SplitN(string(p), consts.Zwsp, 2)
|
||||
if len(parts) > i {
|
||||
return parts[i]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
func (p Pair) String() string {
|
||||
return string(p)
|
||||
}
|
||||
func NewPair(key, value string) string {
|
||||
return key + consts.Zwsp + value
|
||||
}
|
||||
|
||||
func New(filePath string, info Info) Metadata {
|
||||
return Metadata{
|
||||
filePath: filePath,
|
||||
fileInfo: info.FileInfo,
|
||||
tags: clean(filePath, info.Tags),
|
||||
audioProps: info.AudioProperties,
|
||||
hasPicture: info.HasPicture,
|
||||
}
|
||||
}
|
||||
|
||||
type Metadata struct {
|
||||
filePath string
|
||||
fileInfo FileInfo
|
||||
tags model.Tags
|
||||
audioProps AudioProperties
|
||||
hasPicture bool
|
||||
}
|
||||
|
||||
func (md Metadata) FilePath() string { return md.filePath }
|
||||
func (md Metadata) ModTime() time.Time { return md.fileInfo.ModTime() }
|
||||
func (md Metadata) BirthTime() time.Time { return md.fileInfo.BirthTime() }
|
||||
func (md Metadata) Size() int64 { return md.fileInfo.Size() }
|
||||
func (md Metadata) Suffix() string {
|
||||
return strings.ToLower(strings.TrimPrefix(path.Ext(md.filePath), "."))
|
||||
}
|
||||
func (md Metadata) AudioProperties() AudioProperties { return md.audioProps }
|
||||
func (md Metadata) Length() float32 { return float32(md.audioProps.Duration.Milliseconds()) / 1000 }
|
||||
func (md Metadata) HasPicture() bool { return md.hasPicture }
|
||||
func (md Metadata) All() model.Tags { return md.tags }
|
||||
func (md Metadata) Strings(key model.TagName) []string { return md.tags[key] }
|
||||
func (md Metadata) String(key model.TagName) string { return md.first(key) }
|
||||
func (md Metadata) Int(key model.TagName) int64 { v, _ := strconv.Atoi(md.first(key)); return int64(v) }
|
||||
func (md Metadata) Bool(key model.TagName) bool { v, _ := strconv.ParseBool(md.first(key)); return v }
|
||||
func (md Metadata) Date(key model.TagName) Date { return md.date(key) }
|
||||
func (md Metadata) NumAndTotal(key model.TagName) (int, int) { return md.tuple(key) }
|
||||
func (md Metadata) Float(key model.TagName, def ...float64) float64 {
|
||||
return float(md.first(key), def...)
|
||||
}
|
||||
func (md Metadata) NullableFloat(key model.TagName) *float64 { return nullableFloat(md.first(key)) }
|
||||
|
||||
func (md Metadata) Gain(key model.TagName) *float64 {
|
||||
v := strings.TrimSpace(strings.Replace(md.first(key), "dB", "", 1))
|
||||
return nullableFloat(v)
|
||||
}
|
||||
func (md Metadata) Pairs(key model.TagName) []Pair {
|
||||
values := md.tags[key]
|
||||
return slice.Map(values, func(v string) Pair { return Pair(v) })
|
||||
}
|
||||
func (md Metadata) first(key model.TagName) string {
|
||||
if v, ok := md.tags[key]; ok && len(v) > 0 {
|
||||
return v[0]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func float(value string, def ...float64) float64 {
|
||||
v := nullableFloat(value)
|
||||
if v != nil {
|
||||
return *v
|
||||
}
|
||||
if len(def) > 0 {
|
||||
return def[0]
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func nullableFloat(value string) *float64 {
|
||||
v, err := strconv.ParseFloat(value, 64)
|
||||
if err != nil || v == math.Inf(-1) || math.IsInf(v, 1) || math.IsNaN(v) {
|
||||
return nil
|
||||
}
|
||||
return &v
|
||||
}
|
||||
|
||||
// Used for tracks and discs
|
||||
func (md Metadata) tuple(key model.TagName) (int, int) {
|
||||
tag := md.first(key)
|
||||
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 := md.first(key + "total")
|
||||
t2, _ = strconv.Atoi(t2tag)
|
||||
}
|
||||
return t1, t2
|
||||
}
|
||||
|
||||
var dateRegex = regexp.MustCompile(`([12]\d\d\d)`)
|
||||
|
||||
func (md Metadata) date(tagName model.TagName) Date {
|
||||
return Date(md.first(tagName))
|
||||
}
|
||||
|
||||
// date tries to parse a date from a tag, it tries to get at least the year. See the tests for examples.
|
||||
func parseDate(filePath string, tagName model.TagName, tagValue string) string {
|
||||
if len(tagValue) < 4 {
|
||||
return ""
|
||||
}
|
||||
|
||||
// first get just the year
|
||||
match := dateRegex.FindStringSubmatch(tagValue)
|
||||
if len(match) == 0 {
|
||||
log.Debug("Error parsing date", "file", filePath, "tag", tagName, "date", tagValue)
|
||||
return ""
|
||||
}
|
||||
|
||||
// if the tag is just the year, return it
|
||||
if len(tagValue) < 5 {
|
||||
return match[1]
|
||||
}
|
||||
|
||||
// if the tag is too long, truncate it
|
||||
tagValue = tagValue[:min(10, len(tagValue))]
|
||||
|
||||
// then try to parse the full date
|
||||
for _, mask := range []string{"2006-01-02", "2006-01"} {
|
||||
_, err := time.Parse(mask, tagValue)
|
||||
if err == nil {
|
||||
return tagValue
|
||||
}
|
||||
}
|
||||
log.Debug("Error parsing month and day from date", "file", filePath, "tag", tagName, "date", tagValue)
|
||||
return match[1]
|
||||
}
|
||||
|
||||
// clean filters out tags that are not in the mappings or are empty,
|
||||
// combine equivalent tags and remove duplicated values.
|
||||
// It keeps the order of the tags names as they are defined in the mappings.
|
||||
func clean(filePath string, tags model.RawTags) model.Tags {
|
||||
lowered := lowerTags(tags)
|
||||
mappings := model.TagMappings()
|
||||
cleaned := make(model.Tags, len(mappings))
|
||||
|
||||
for name, mapping := range mappings {
|
||||
var values []string
|
||||
switch mapping.Type {
|
||||
case model.TagTypePair:
|
||||
values = processPairMapping(name, mapping, lowered)
|
||||
default:
|
||||
values = processRegularMapping(mapping, lowered)
|
||||
}
|
||||
cleaned[name] = values
|
||||
}
|
||||
|
||||
cleaned = filterEmptyTags(cleaned)
|
||||
return sanitizeAll(filePath, cleaned)
|
||||
}
|
||||
|
||||
func processRegularMapping(mapping model.TagConf, lowered model.Tags) []string {
|
||||
var values []string
|
||||
for _, alias := range mapping.Aliases {
|
||||
if vs, ok := lowered[model.TagName(alias)]; ok {
|
||||
splitValues := mapping.SplitTagValue(vs)
|
||||
values = append(values, splitValues...)
|
||||
}
|
||||
}
|
||||
return values
|
||||
}
|
||||
|
||||
func lowerTags(tags model.RawTags) model.Tags {
|
||||
lowered := make(model.Tags, len(tags))
|
||||
for k, v := range tags {
|
||||
lowered[model.TagName(strings.ToLower(k))] = v
|
||||
}
|
||||
return lowered
|
||||
}
|
||||
|
||||
func processPairMapping(name model.TagName, mapping model.TagConf, lowered model.Tags) []string {
|
||||
var aliasValues []string
|
||||
for _, alias := range mapping.Aliases {
|
||||
if vs, ok := lowered[model.TagName(alias)]; ok {
|
||||
aliasValues = append(aliasValues, vs...)
|
||||
}
|
||||
}
|
||||
|
||||
// always parse id3 pairs. For lyrics, Taglib appears to always provide lyrics:xxx
|
||||
// Prefer that over format-specific tags
|
||||
id3Base := parseID3Pairs(name, lowered)
|
||||
|
||||
if len(aliasValues) > 0 {
|
||||
id3Base = append(id3Base, parseVorbisPairs(aliasValues)...)
|
||||
}
|
||||
return id3Base
|
||||
}
|
||||
|
||||
func parseID3Pairs(name model.TagName, lowered model.Tags) []string {
|
||||
var pairs []string
|
||||
prefix := string(name) + ":"
|
||||
for tagKey, tagValues := range lowered {
|
||||
keyStr := string(tagKey)
|
||||
if strings.HasPrefix(keyStr, prefix) {
|
||||
keyPart := strings.TrimPrefix(keyStr, prefix)
|
||||
if keyPart == string(name) {
|
||||
keyPart = ""
|
||||
}
|
||||
for _, v := range tagValues {
|
||||
pairs = append(pairs, NewPair(keyPart, v))
|
||||
}
|
||||
}
|
||||
}
|
||||
return pairs
|
||||
}
|
||||
|
||||
var vorbisPairRegex = regexp.MustCompile(`\(([^()]+(?:\([^()]*\)[^()]*)*)\)`)
|
||||
|
||||
// parseVorbisPairs, from
|
||||
//
|
||||
// "Salaam Remi (drums (drum set) and organ)",
|
||||
//
|
||||
// to
|
||||
//
|
||||
// "drums (drum set) and organ" -> "Salaam Remi",
|
||||
func parseVorbisPairs(values []string) []string {
|
||||
pairs := make([]string, 0, len(values))
|
||||
for _, value := range values {
|
||||
matches := vorbisPairRegex.FindAllStringSubmatch(value, -1)
|
||||
if len(matches) == 0 {
|
||||
pairs = append(pairs, NewPair("", value))
|
||||
continue
|
||||
}
|
||||
key := strings.TrimSpace(matches[0][1])
|
||||
key = strings.ToLower(key)
|
||||
valueWithoutKey := strings.TrimSpace(strings.Replace(value, "("+matches[0][1]+")", "", 1))
|
||||
pairs = append(pairs, NewPair(key, valueWithoutKey))
|
||||
}
|
||||
return pairs
|
||||
}
|
||||
|
||||
func filterEmptyTags(tags model.Tags) model.Tags {
|
||||
for k, v := range tags {
|
||||
clean := filterDuplicatedOrEmptyValues(v)
|
||||
if len(clean) == 0 {
|
||||
delete(tags, k)
|
||||
} else {
|
||||
tags[k] = clean
|
||||
}
|
||||
}
|
||||
return tags
|
||||
}
|
||||
|
||||
func filterDuplicatedOrEmptyValues(values []string) []string {
|
||||
seen := make(map[string]struct{}, len(values))
|
||||
var result []string
|
||||
for _, v := range values {
|
||||
if v == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[v]; ok {
|
||||
continue
|
||||
}
|
||||
seen[v] = struct{}{}
|
||||
result = append(result, v)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func sanitizeAll(filePath string, tags model.Tags) model.Tags {
|
||||
cleaned := model.Tags{}
|
||||
for k, v := range tags {
|
||||
tag, found := model.TagMappings()[k]
|
||||
if !found {
|
||||
continue
|
||||
}
|
||||
|
||||
var values []string
|
||||
for _, value := range v {
|
||||
cleanedValue := sanitize(filePath, k, tag, value)
|
||||
if cleanedValue != "" {
|
||||
values = append(values, cleanedValue)
|
||||
}
|
||||
}
|
||||
if len(values) > 0 {
|
||||
cleaned[k] = values
|
||||
}
|
||||
}
|
||||
return cleaned
|
||||
}
|
||||
|
||||
const defaultMaxTagLength = 1024
|
||||
|
||||
func sanitize(filePath string, tagName model.TagName, tag model.TagConf, value string) string {
|
||||
// First truncate the value to the maximum length
|
||||
maxLength := cmp.Or(tag.MaxLength, defaultMaxTagLength)
|
||||
if len(value) > maxLength {
|
||||
log.Trace("Truncated tag value", "tag", tagName, "value", value, "length", len(value), "maxLength", maxLength)
|
||||
value = value[:maxLength]
|
||||
}
|
||||
|
||||
switch tag.Type {
|
||||
case model.TagTypeDate:
|
||||
value = parseDate(filePath, tagName, value)
|
||||
if value == "" {
|
||||
log.Trace("Invalid date tag value", "tag", tagName, "value", value)
|
||||
}
|
||||
case model.TagTypeInteger:
|
||||
_, err := strconv.Atoi(value)
|
||||
if err != nil {
|
||||
log.Trace("Invalid integer tag value", "tag", tagName, "value", value)
|
||||
return ""
|
||||
}
|
||||
case model.TagTypeFloat:
|
||||
_, err := strconv.ParseFloat(value, 64)
|
||||
if err != nil {
|
||||
log.Trace("Invalid float tag value", "tag", tagName, "value", value)
|
||||
return ""
|
||||
}
|
||||
case model.TagTypeUUID:
|
||||
_, err := uuid.Parse(value)
|
||||
if err != nil {
|
||||
log.Trace("Invalid UUID tag value", "tag", tagName, "value", value)
|
||||
return ""
|
||||
}
|
||||
}
|
||||
return value
|
||||
}
|
||||
Reference in New Issue
Block a user