mirror of
https://github.com/navidrome/navidrome.git
synced 2026-08-01 07:21:17 +00:00
* refactor(scanner): make tag value splitting position-based Replaces the ZWSP substitution trick with index-based cutting, in preparation for artist split exceptions, which need match positions. * feat(scanner): protect whitelisted names in tag value splitting Separator matches inside word-bounded exception matches no longer split. Matching is case-insensitive and longest-first; boundaries are rune-aware. * feat(scanner): add Scanner.ArtistSplitExceptions config option * feat(scanner): honor artist split exceptions for participant tags Applies Scanner.ArtistSplitExceptions to artist, albumartist and role tag splitting. Generic tags (genre, mood, ...) are unaffected. * fix(scanner): apply split exceptions when per-tag Split overrides participant tags Per-tag Tags.<name>.Split makes the generic ingestion path split the tag before participant mapping runs, bypassing the whitelist. Attach the exceptions to participant tag mappings (including sort variants) in clean(). * feat(scanner): split performer names and honor split exceptions Performer pair values were never split; multiple names in one PERFORMER value stayed a single artist. Split them with the roles separators, using the same whitelist protection as other participant tags. * test(scanner): lock MBID ordering for split performer values * refactor(scanner): consolidate split-exception wiring and drop hot-path lock ArtistSplitExceptionsRx is called per tag mapping per scanned file across concurrent goroutines; replace the mutex+joined-key cache with an atomic pointer compared via slices.Equal. Route all participant call sites through WithParticipantExceptions and a shared splitParticipantValues helper. * refactor(scanner): unexport artistSplitExceptionsRx All external callers go through WithParticipantExceptions, so the accessor does not need to be part of the model package API.
398 lines
10 KiB
Go
398 lines
10 KiB
Go
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
|
|
Codec string
|
|
}
|
|
|
|
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 {
|
|
mapping = mapping.WithParticipantExceptions(name)
|
|
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 {
|
|
// For lyrics, don't use parseVorbisPairs as parentheses in lyrics content
|
|
// should not be interpreted as language keys (e.g. "(intro)" is not a language)
|
|
if name == model.TagLyrics {
|
|
for _, v := range aliasValues {
|
|
id3Base = append(id3Base, NewPair("xxx", v))
|
|
}
|
|
} else {
|
|
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 after, ok := strings.CutPrefix(keyStr, prefix); ok {
|
|
keyPart := after
|
|
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
|
|
}
|