mirror of
https://github.com/navidrome/navidrome.git
synced 2026-10-08 18:37:09 +02:00
* 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.
414 lines
11 KiB
Go
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
|
|
}
|