navidrome/model/metadata/map_mediafile.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

196 lines
5.8 KiB
Go

package metadata
import (
"cmp"
"context"
"encoding/json"
"maps"
"math"
"strconv"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/utils/str"
)
func (md Metadata) ToMediaFile(libID int, folderID string) model.MediaFile {
mf := model.MediaFile{
LibraryID: libID,
FolderID: folderID,
Tags: maps.Clone(md.tags),
}
// Title and Album
mf.Title = md.mapTrackTitle()
mf.Album = md.mapAlbumName()
mf.SortTitle = md.String(model.TagTitleSort)
mf.SortAlbumName = md.String(model.TagAlbumSort)
mf.OrderTitle = str.SanitizeFieldForSorting(mf.Title)
mf.OrderAlbumName = str.SanitizeFieldForSortingNoArticle(mf.Album)
mf.Compilation = md.Bool(model.TagCompilation)
// Disc and Track info
mf.TrackNumber, _ = md.NumAndTotal(model.TagTrackNumber)
mf.DiscNumber, _ = md.NumAndTotal(model.TagDiscNumber)
mf.DiscSubtitle = md.String(model.TagDiscSubtitle)
mf.CatalogNum = md.String(model.TagCatalogNumber)
mf.Comment = md.String(model.TagComment)
if f := md.NullableFloat(model.TagBPM); f != nil {
if r := math.Round(*f); r > 0 && r <= math.MaxInt32 {
mf.BPM = new(int(r))
}
}
mf.Lyrics = md.mapLyrics()
mf.ExplicitStatus = md.mapExplicitStatusTag()
// Dates
date, origDate, relDate := md.mapDates()
mf.OriginalYear, mf.OriginalDate = origDate.Year(), string(origDate)
mf.ReleaseYear, mf.ReleaseDate = relDate.Year(), string(relDate)
mf.Year, mf.Date = date.Year(), string(date)
// MBIDs
mf.MbzRecordingID = md.String(model.TagMusicBrainzRecordingID)
mf.MbzReleaseTrackID = md.String(model.TagMusicBrainzTrackID)
mf.MbzAlbumID = md.String(model.TagMusicBrainzAlbumID)
mf.MbzReleaseGroupID = md.String(model.TagMusicBrainzReleaseGroupID)
mf.MbzAlbumType = md.String(model.TagReleaseType)
// ReplayGain
mf.RGAlbumPeak = md.NullableFloat(model.TagReplayGainAlbumPeak)
mf.RGAlbumGain = md.mapGain(model.TagReplayGainAlbumGain, model.TagR128AlbumGain)
mf.RGTrackPeak = md.NullableFloat(model.TagReplayGainTrackPeak)
mf.RGTrackGain = md.mapGain(model.TagReplayGainTrackGain, model.TagR128TrackGain)
// General properties
mf.HasCoverArt = md.HasPicture()
mf.Duration = md.Length()
mf.BitRate = md.AudioProperties().BitRate
mf.SampleRate = md.AudioProperties().SampleRate
if bd := md.AudioProperties().BitDepth; bd > 0 {
mf.BitDepth = new(bd)
}
mf.Channels = md.AudioProperties().Channels
mf.Codec = md.AudioProperties().Codec
mf.Path = md.FilePath()
mf.Suffix = md.Suffix()
mf.Size = md.Size()
mf.BirthTime = md.BirthTime()
mf.UpdatedAt = md.ModTime()
mf.Participants = md.mapParticipants()
mf.Artist = md.mapDisplayArtist()
mf.AlbumArtist = md.mapDisplayAlbumArtist(mf)
// Persistent IDs
mf.PID = md.trackPID(mf)
mf.AlbumID = md.albumID(mf, conf.Server.PID.Album)
// BFR These IDs will go away once the UI handle multiple participants.
// BFR For Legacy Subsonic compatibility, we will set them in the API handlers
mf.ArtistID = mf.Participants.First(model.RoleArtist).ID
mf.AlbumArtistID = mf.Participants.First(model.RoleAlbumArtist).ID
// BFR What to do with sort/order artist names?
mf.OrderArtistName = mf.Participants.First(model.RoleArtist).OrderArtistName
mf.OrderAlbumArtistName = mf.Participants.First(model.RoleAlbumArtist).OrderArtistName
mf.SortArtistName = mf.Participants.First(model.RoleArtist).SortArtistName
mf.SortAlbumArtistName = mf.Participants.First(model.RoleAlbumArtist).SortArtistName
// Don't store tags that are first-class fields (and are not album-level tags) in the
// MediaFile struct. This is to avoid redundancy in the DB
//
// Remove all tags from the main section that are not flagged as album tags
for tag, conf := range model.TagMainMappings() {
if !conf.Album {
delete(mf.Tags, tag)
}
}
return mf
}
func (md Metadata) AlbumID(mf model.MediaFile, pidConf string) string {
return md.albumID(mf, pidConf)
}
func (md Metadata) mapGain(rg, r128 model.TagName) *float64 {
v := md.Gain(rg)
if v != nil {
return v
}
r128value := md.String(r128)
if r128value != "" {
var v, err = strconv.Atoi(r128value)
if err != nil {
return nil
}
// Convert Q7.8 to float
value := float64(v) / 256.0
// Adding 5 dB to normalize with ReplayGain level
value += 5
return &value
}
return nil
}
func (md Metadata) mapLyrics() string {
rawLyrics := md.Pairs(model.TagLyrics)
lyricList := make(model.LyricList, 0, len(rawLyrics))
ctx := log.NewContext(context.Background(), "file", md.filePath)
for _, raw := range rawLyrics {
lang := raw.Key()
text := raw.Value()
lyrics, err := model.ParseLyrics(ctx, "", lang, []byte(text))
if err != nil {
log.Warn(ctx, "Unexpected failure occurred when parsing lyrics", err)
continue
}
for _, lyric := range lyrics {
if !lyric.IsEmpty() {
lyricList = append(lyricList, lyric)
}
}
}
res, err := json.Marshal(lyricList)
if err != nil {
log.Warn("Unexpected error occurred when serializing lyrics", "file", md.filePath, err)
return ""
}
return string(res)
}
func (md Metadata) mapExplicitStatusTag() string {
switch md.first(model.TagExplicitStatus) {
case "1", "4":
return "e"
case "2":
return "c"
default:
return ""
}
}
func (md Metadata) mapDates() (date Date, originalDate Date, releaseDate Date) {
// Start with defaults
date = md.Date(model.TagRecordingDate)
originalDate = md.Date(model.TagOriginalDate)
releaseDate = md.Date(model.TagReleaseDate)
// For some historic reason, taggers have been writing the Release Date of an album to the Date tag,
// and leave the Release Date tag empty.
legacyMappings := (originalDate != "") &&
(releaseDate == "") &&
(date >= originalDate)
if legacyMappings {
return originalDate, originalDate, date
}
// when there's no Date, first fall back to Original Date, then to Release Date.
date = cmp.Or(date, originalDate, releaseDate)
return date, originalDate, releaseDate
}