mirror of
https://github.com/navidrome/navidrome.git
synced 2026-10-11 03:47:18 +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.
196 lines
5.8 KiB
Go
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
|
|
}
|