mirror of
https://github.com/navidrome/navidrome.git
synced 2026-08-01 07:21:17 +00:00
When PID.Artist spec changes, existing artist IDs differ from newly-computed ones. Without remapping, all annotations (starred, ratings, play counts) become orphaned. This change builds an artistIDMap during phase 1 and reassigns annotations on persist, mirroring the existing albumIDMap flow. The migration logic is gated on the previous spec being non-empty and different from the current spec, so the default "name" spec (byte-identical to the historical hardcoded artistID) triggers no remapping on upgrade.
161 lines
4.9 KiB
Go
161 lines
4.9 KiB
Go
package metadata
|
|
|
|
import (
|
|
"cmp"
|
|
"fmt"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
"github.com/navidrome/navidrome/conf"
|
|
"github.com/navidrome/navidrome/consts"
|
|
"github.com/navidrome/navidrome/log"
|
|
"github.com/navidrome/navidrome/model"
|
|
"github.com/navidrome/navidrome/model/id"
|
|
"github.com/navidrome/navidrome/utils"
|
|
"github.com/navidrome/navidrome/utils/str"
|
|
)
|
|
|
|
type hashFunc = func(...string) string
|
|
|
|
// computePID calculates the persistent ID for a given spec. The spec is a
|
|
// pipe-separated list of fields, where each field is a comma-separated list of
|
|
// attributes. Attributes can be either tags or processed values like folder,
|
|
// albumid, albumartistid, etc. For each field, it gets all its attribute values
|
|
// and concatenates them, then hashes the result. If a field is empty, it is
|
|
// skipped and the function looks for the next field.
|
|
//
|
|
// Taking hash as a parameter (instead of closing over it in a factory) keeps
|
|
// mf on the stack: closing over mf would force the whole ~1KB MediaFile to the
|
|
// heap on every call.
|
|
func computePID(mf model.MediaFile, md Metadata, spec string, prependLibId bool, hash hashFunc) string {
|
|
switch spec {
|
|
case "track_legacy":
|
|
return legacyTrackID(mf, prependLibId)
|
|
case "album_legacy":
|
|
return legacyAlbumID(mf, md, prependLibId)
|
|
}
|
|
pid := ""
|
|
fields := strings.SplitSeq(spec, "|")
|
|
for field := range fields {
|
|
attributes := strings.Split(field, ",")
|
|
values := make([]string, len(attributes))
|
|
hasValue := false
|
|
for i, attr := range attributes {
|
|
v := getPIDAttr(mf, md, attr, prependLibId, spec, hash)
|
|
if v != "" {
|
|
hasValue = true
|
|
}
|
|
values[i] = v
|
|
}
|
|
if hasValue {
|
|
pid += strings.Join(values, "\\")
|
|
break
|
|
}
|
|
}
|
|
if prependLibId {
|
|
pid = fmt.Sprintf("%d\\%s", mf.LibraryID, pid)
|
|
}
|
|
return hash(pid)
|
|
}
|
|
|
|
func getPIDAttr(mf model.MediaFile, md Metadata, attr string, prependLibId bool, spec string, hash hashFunc) string {
|
|
attr = strings.TrimSpace(strings.ToLower(attr))
|
|
switch attr {
|
|
case "albumid":
|
|
if spec == conf.Server.PID.Album {
|
|
log.Error("Recursive PID definition detected, ignoring `albumid`", "spec", spec)
|
|
return ""
|
|
}
|
|
return computePID(mf, md, conf.Server.PID.Album, prependLibId, hash)
|
|
case "folder":
|
|
return filepath.Dir(mf.Path)
|
|
case "albumartistid":
|
|
return hash(str.Clear(strings.ToLower(mf.AlbumArtist)))
|
|
case "title":
|
|
return mf.Title
|
|
case "album":
|
|
return str.Clear(strings.ToLower(md.String(model.TagAlbum)))
|
|
}
|
|
return md.String(model.TagName(attr))
|
|
}
|
|
|
|
func (md Metadata) trackPID(mf model.MediaFile) string {
|
|
return computePID(mf, md, conf.Server.PID.Track, true, id.NewHash)
|
|
}
|
|
|
|
func (md Metadata) albumID(mf model.MediaFile, pidConf string) string {
|
|
return computePID(mf, md, pidConf, true, id.NewHash)
|
|
}
|
|
|
|
// computeArtistPID computes a persistent ID for a single participant using the
|
|
// given spec. The spec grammar is the same pipe/comma form used by Album/Track
|
|
// PIDs. Attributes recognised:
|
|
// - name → hash(clear(lower(participant.Name))) — the inner
|
|
// hash matches the historical 'albumartistid' path
|
|
// so the default spec "name" produces byte-identical
|
|
// IDs to the previous hardcoded artistID().
|
|
// - musicbrainz_artistid → participant.MbzArtistID (opaque identifier — no
|
|
// normalization).
|
|
// - sort_name → participant.SortArtistName (raw — sort forms are
|
|
// already canonicalized by taggers, and folding case
|
|
// could collapse legitimately distinct sort variants).
|
|
//
|
|
// Unlike album/track PIDs, no library prefix is applied: artists are shared
|
|
// across libraries by design.
|
|
func computeArtistPID(p model.Participant, spec string, hash hashFunc) string {
|
|
pid := ""
|
|
fields := strings.SplitSeq(spec, "|")
|
|
for field := range fields {
|
|
attributes := strings.Split(field, ",")
|
|
values := make([]string, len(attributes))
|
|
hasValue := false
|
|
for i, attr := range attributes {
|
|
v := getArtistPIDAttr(p, attr, hash)
|
|
if v != "" {
|
|
hasValue = true
|
|
}
|
|
values[i] = v
|
|
}
|
|
if hasValue {
|
|
pid += strings.Join(values, "\\")
|
|
break
|
|
}
|
|
}
|
|
return hash(pid)
|
|
}
|
|
|
|
// ComputeArtistPID is the exported entry point for callers outside this package
|
|
// (e.g. the scanner) that need to compute an artist PID under a specific spec.
|
|
func ComputeArtistPID(p model.Participant, spec string) string {
|
|
return computeArtistPID(p, spec, id.NewHash)
|
|
}
|
|
|
|
func getArtistPIDAttr(p model.Participant, attr string, hash hashFunc) string {
|
|
switch strings.TrimSpace(strings.ToLower(attr)) {
|
|
case "name":
|
|
if p.Name == "" {
|
|
return ""
|
|
}
|
|
return hash(str.Clear(strings.ToLower(p.Name)))
|
|
case "musicbrainz_artistid":
|
|
return p.MbzArtistID
|
|
case "sort_name":
|
|
return p.SortArtistName
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func (md Metadata) mapTrackTitle() string {
|
|
if title := md.String(model.TagTitle); title != "" {
|
|
return title
|
|
}
|
|
return utils.BaseName(md.FilePath())
|
|
}
|
|
|
|
func (md Metadata) mapAlbumName() string {
|
|
return cmp.Or(
|
|
md.String(model.TagAlbum),
|
|
consts.UnknownAlbum,
|
|
)
|
|
}
|