navidrome/model/metadata/map_mediafile.go
Deluan Quintão 56cc32aede
fix(scanner): map movement number and count from ID3 MVIN and MP4 tags (#6357)
TagLib reports ID3 MVIN and MP4 ©mvi as movementnumber, and MP4 ©mvc as
movementcount. Neither name was aliased in mappings.yaml, so getSong
returned movements with the name only. Add both aliases.

ID3 MVIN stores the value as n/N (e.g. 1/4), which parsed as 0. Split it
at scan time, like track and disc numbers, so the stored movement tag holds
the number and movementtotal gets the count when the file has no explicit
total. Storing the clean value also lets smart playlist rules on the
movement tag match. Existing files need a full scan to pick this up.

Fixes #6356
2026-10-21 13:26:26 +01:00

220 lines
6.7 KiB
Go

package metadata
import (
"cmp"
"context"
"encoding/json"
"maps"
"math"
"slices"
"strconv"
"strings"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/utils/str"
)
func (md Metadata) ToMediaFile(lib model.Library, folderID string) model.MediaFile {
mf := model.MediaFile{
LibraryID: lib.ID,
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()
md.mapMovementNumbers(mf.Tags)
// 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
pid := lib.EffectivePID()
mf.PID = md.trackPID(mf, pid)
mf.AlbumID = md.albumID(mf, 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
}
// mapMovementNumbers splits ID3 MVIN "n/N" values, like tuple() does for track and disc numbers.
// Totals are only filled when every value has one, so the parallel movement tags stay aligned.
func (md Metadata) mapMovementNumbers(tags model.Tags) {
values := md.Strings(model.TagMovementNumber)
if !slices.ContainsFunc(values, func(v string) bool { return strings.Contains(v, "/") }) {
return
}
numbers := make([]string, len(values))
totals := make([]string, len(values))
allHaveTotals := true
for i, v := range values {
var found bool
numbers[i], totals[i], found = strings.Cut(v, "/")
allHaveTotals = allHaveTotals && found
}
tags[model.TagMovementNumber] = numbers
if allHaveTotals && len(md.Strings(model.TagMovementTotal)) == 0 {
tags[model.TagMovementTotal] = totals
}
}
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
}