mirror of
https://github.com/navidrome/navidrome.git
synced 2026-06-19 07:37:15 +00:00
da56df3160
* feat(smartplaylist): support isMissing/isPresent on mbz_* and lyrics fields Mark the six mbz_* MusicBrainz ID columns and the lyrics column as Nullable in the criteria field map, then extend missingExpr to handle string columns where absence is encoded as NULL or empty string (plus '[]' for lyrics). The Numeric/Boolean path (ReplayGain) is preserved via an explicit type check. * refactor(model): make MediaFile BPM and BitDepth nullable pointers Convert BPM and BitDepth fields in model.MediaFile from int to *int so that 'tag absent' is distinguishable from zero. The metadata mapper now uses NullableFloat for BPM (nil when absent or zero/unparseable) and only sets BitDepth when the audio property is non-zero (lossy codecs report 0). All read sites use gg.V() for zero-fallback deref so Subsonic API output and transcoding behaviour are byte-identical to before. The persistence layer bridges the existing NOT NULL DB columns by coercing nil to 0 on write and 0 back to nil on read in PostMapArgs/PostScan; a later migration task will drop those constraints. Hash upgrade safety is verified by a new MediaFile.Hash describe block: nil *int hashes identically to the old int(0) default via ZeroNil+IgnoreZeroValue, so no files will be spuriously re-imported after this change. Extra files touched beyond the plan's list: core/stream/legacy_client_test.go (BitDepth in model.MediaFile literals), persistence/mediafile_repository.go (NOT NULL bridge). * test(model): pin pre-conversion golden hashes for BPM/BitDepth * feat(smartplaylist): support isMissing/isPresent on bpm and bitDepth * feat(db): make bpm and bit_depth columns nullable, backfill 0 to NULL Drop the NOT NULL constraint on media_file.bpm and bit_depth via a lossless migration that converts legacy 0-means-absent values to real NULL. Remove the temporary shim in PostScan/PostMapArgs that was bridging the old NOT NULL columns to the *int model fields. Add round-trip persistence tests asserting NULL storage for nil pointers and correct value round-trip for non-nil pointers. * test(e2e): verify isMissing/isPresent partition for nullable fields Add DescribeTable covering bpm, bitdepth, lyrics, and mbz_recording_id: for each field, isMissing + isPresent song counts must equal the total library count, proving the nullable-column SQL is exhaustive and correct. * test(e2e): seed bpm tag so isMissing/isPresent partition is non-trivial * fix(model): omit bitDepth from JSON when absent instead of emitting null * feat(smartplaylist): support isMissing/isPresent on more string fields Enable isMissing/isPresent operators for album, comment, catalognumber, discsubtitle, albumcomment, sorttitle, sortalbum, sortartist, sortalbumartist, and explicitstatus by marking them Nullable in fieldMap. * refactor(smartplaylist): unify missingExpr column logic into one flow Collapse the numeric/string fork in missingExpr into a single empties-driven loop (numeric/boolean fields simply have no empties), and replace the duplicated IsTag/IsRole guard with a three-way switch that expresses the dispatch model once. No SQL semantics change for string fields; numeric/boolean fields now emit a single-element Or/And which squirrel parenthesizes (e.g. `(col IS NULL)` instead of bare `col IS NULL`) — update the affected test expectations accordingly.
193 lines
5.7 KiB
Go
193 lines
5.7 KiB
Go
package metadata
|
|
|
|
import (
|
|
"cmp"
|
|
"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 v := int(math.Round(*f)); v != 0 {
|
|
mf.BPM = new(v)
|
|
}
|
|
}
|
|
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))
|
|
|
|
for _, raw := range rawLyrics {
|
|
lang := raw.Key()
|
|
text := raw.Value()
|
|
|
|
lyrics, err := model.ToLyrics(lang, text)
|
|
if err != nil {
|
|
log.Warn("Unexpected failure occurred when parsing lyrics", "file", md.filePath, err)
|
|
continue
|
|
}
|
|
if !lyrics.IsEmpty() {
|
|
lyricList = append(lyricList, *lyrics)
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|