mirror of
https://github.com/navidrome/navidrome.git
synced 2026-10-28 14:17:10 +01:00
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
220 lines
6.7 KiB
Go
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
|
|
}
|