navidrome/model/metadata/persistent_ids.go
Deluan Quintão ab2f1b45de
perf: reduce hot-path heap escapes from value-param pointer aliasing (#5342)
* perf(subsonic): keep album/mediafile params on stack in response helpers

Two helpers were forcing their entire value parameter onto the heap via
pointer-to-field aliasing, adding one full-struct heap allocation per
response item on hot Subsonic endpoints (search3, getAlbumList2, etc.).

- childFromMediaFile assigned &mf.BirthTime to the returned Child,
  pulling the whole ~1KB model.MediaFile to the heap on every call.
- buildDiscSubtitles passed &a.UpdatedAt to NewArtworkID inside a loop,
  pulling the whole model.Album to the heap on every album with discs.

Both now copy the time.Time to a stack-local and use gg.P / &local so
only the small time.Time escapes. Verified via go build -gcflags=-m=2:
moved to heap: mf and moved to heap: a are gone at these sites.

* perf(metadata): avoid per-track closure allocations in PID computation

createGetPID was a factory that returned nested closures capturing
mf model.MediaFile (~992 bytes) by reference. Since it is called three
times per track during scans (trackPID, albumID, artistID), every track
triggered the allocation of three closures plus a heap copy of the full
MediaFile.

Refactor the body into package-level functions (computePID, getPIDAttr)
that take hash as an explicit parameter and the inner slice.Map callback
to an indexed for loop, removing the closure-capture of mf entirely.
trackPID/albumID/artistID now call computePID directly.

The tiny createGetPID wrapper was kept only for tests; move the
closure-building into the test file so production has no dead API.

Verified via go build -gcflags=-m=2 on model/metadata: no
"moved to heap: mf" anywhere in persistent_ids.go, and the callers in
map_mediafile.go / map_participants.go no longer heap-promote their
MediaFile argument.
2026-04-10 21:59:49 -04:00

109 lines
3.1 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)
}
// BFR Must be configurable?
func (md Metadata) artistID(name string) string {
mf := model.MediaFile{AlbumArtist: name}
return computePID(mf, md, "albumartistid", false, id.NewHash)
}
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,
)
}