navidrome/model/metadata/metadata.go
Deluan Quintão 961ee8c413
fix(scanner): keep tag numbers within the int32 range (#6202)
* fix(scanner): keep tag numbers within the int32 range

A track number of 4294967295 (-1 stored as an unsigned 32-bit tag) was saved
as-is by 64-bit builds. 32-bit builds (armv5/6/7, 386) cannot read that value
back into an int, so every scan failed with "converting driver.Value type
int64 to a int: value out of range" when loading the folder's media files.

Track and disc numbers (and their totals) are now parsed as int32 and fall
back to 0 when out of range, matching how unparseable values are handled.
BPM values outside the int32 range are dropped. A migration resets existing
out-of-range track_number, disc_number and bpm values, and removes
out-of-range keys from album.discs, so databases written by 64-bit builds are
readable again by 32-bit ones. Persistent IDs are unaffected because they use
the raw tag text.

Fixes #6200

* fix(scanner): accept the int32 minimum as a BPM value

The BPM range check compared the absolute value against MaxInt32, which
rejected -2147483648 even though it fits in an int32. Compare against
MinInt32 and MaxInt32 separately, matching atoi32 and the migration.

* fix(scanner): treat negative track, disc and BPM values as missing

Track numbers, disc numbers and BPM can never be negative, so negative tag
values now map to 0 (track/disc, including totals) or nil (BPM), the same as
unparseable ones. The migration resets existing negative values as well as the
ones above the int32 range, and keeps only album disc keys from 0 to MaxInt32.
2026-09-22 19:51:32 -04:00

414 lines
11 KiB
Go

package metadata
import (
"cmp"
"io/fs"
"math"
"path"
"regexp"
"strconv"
"strings"
"time"
"unicode/utf8"
"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, "/")
total := md.first(key + "total")
if len(tuple) > 1 {
total = tuple[1]
}
return tagNumber(tuple[0]), tagNumber(total)
}
// tagNumber rejects negatives and values above int32, so the DB stays readable by 32-bit builds.
func tagNumber(s string) int {
v, err := strconv.ParseInt(s, 10, 32)
if err != nil || v < 0 {
return 0
}
return int(v)
}
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]
// Drop the partial rune the cut may have left: at most 3 trailing bytes,
// so a pre-existing invalid run elsewhere is never consumed.
for range 3 {
if r, size := utf8.DecodeLastRuneInString(value); r != utf8.RuneError || size != 1 {
break
}
value = value[:len(value)-1]
}
}
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:
u, err := uuid.Parse(value)
if err != nil {
log.Trace("Invalid UUID tag value", "tag", tagName, "value", value)
return ""
}
// Store the canonical form: uuid.Parse accepts braces, urn: prefixes and any
// two-byte wrapper, and a wrapped value would never match an exact-match query
value = u.String()
}
return value
}