navidrome/model/artwork_id.go
Deluan af984bc817 feat(artwork): advertise album art for tracks whose embedded cover is the album's
Most albums embed the same picture in every track, so clients requesting
per-track cover art (mf- ids) download and cache the same image once per
song. This change hashes the embedded picture at scan time (XXH3 of the
bytes, the same digest the artwork pipeline uses) and stores it on
media_file.embed_art_hash. ToAlbum stores the hash of the track it already
picks as the album's embedded cover (EmbedArtPath) on album.embed_art_hash.

A new MediaFile.HasOwnCoverArt replaces the scattered
"HasCoverArt && EnableMediaFileCoverArt" checks: a track whose hash equals
its album's now returns the disc/album artwork id from CoverArtID, so the
Subsonic, Jellyfin and native responses point every matching song at one
cached image. Serving a stale mf- URL follows the same rule, so what is
advertised and what is served agree. Tracks with a different embedded
picture (compilations, per-track art) keep their own ids.

Rows scanned before this change have an empty hash and keep the previous
behavior until a full scan; no config option is added. The album hash is
hydrated onto tracks with one batched PK lookup per page, only when the
page has hashed tracks. BestImageIndex is exported from core/artwork so the
scanner hashes the same picture the resolver serves.
2026-09-07 00:35:21 -04:00

173 lines
4.5 KiB
Go

package model
import (
"errors"
"fmt"
"strconv"
"strings"
"time"
"github.com/navidrome/navidrome/utils/slice"
)
type Kind struct {
prefix string
name string
}
func (k Kind) String() string {
return k.name
}
// Prefix is the short token used in artwork ids and the item_artwork.item_kind column.
func (k Kind) Prefix() string {
return k.prefix
}
var (
KindMediaFileArtwork = Kind{"mf", "media_file"}
KindArtistArtwork = Kind{"ar", "artist"}
KindAlbumArtwork = Kind{"al", "album"}
KindPlaylistArtwork = Kind{"pl", "playlist"}
KindDiscArtwork = Kind{"dc", "disc"}
KindRadioArtwork = Kind{"ra", "radio"}
)
var artworkKindMap = map[string]Kind{
KindMediaFileArtwork.prefix: KindMediaFileArtwork,
KindArtistArtwork.prefix: KindArtistArtwork,
KindAlbumArtwork.prefix: KindAlbumArtwork,
KindPlaylistArtwork.prefix: KindPlaylistArtwork,
KindDiscArtwork.prefix: KindDiscArtwork,
KindRadioArtwork.prefix: KindRadioArtwork,
}
// KindPrefixes leaves the typed Kind domain for the item_kind column, or for a help string.
func KindPrefixes(kinds []Kind) []string {
return slice.Map(kinds, func(k Kind) string { return k.prefix })
}
// ParseKind resolves an item_kind prefix (e.g. "al") to its Kind, reporting whether it was known.
// Use it at string boundaries — URL params, the item_kind column — to enter the typed Kind domain.
func ParseKind(prefix string) (Kind, bool) {
k, ok := artworkKindMap[prefix]
return k, ok
}
type ArtworkID struct {
Kind Kind
ID string
Hash string // content-hash suffix; "" = unknown/none
LastUpdate time.Time // legacy: populated only when parsing old _<hexTimestamp> tokens
}
func (id ArtworkID) String() string {
if id.ID == "" {
return ""
}
s := fmt.Sprintf("%s-%s", id.Kind.prefix, id.ID)
if id.Hash != "" {
return s + "_" + id.Hash
}
return s
}
func NewArtworkID(kind Kind, id string, lastUpdate *time.Time) ArtworkID {
artID := ArtworkID{Kind: kind, ID: id}
if lastUpdate != nil {
artID.LastUpdate = *lastUpdate
}
return artID
}
func ParseArtworkID(id string) (ArtworkID, error) {
parts := strings.SplitN(id, "-", 2)
if len(parts) != 2 {
return ArtworkID{}, errors.New("invalid artwork id")
}
kind, ok := artworkKindMap[parts[0]]
if !ok {
return ArtworkID{}, errors.New("invalid artwork kind")
}
parsedID := ArtworkID{
Kind: kind,
ID: parts[1],
}
parts = strings.SplitN(parts[1], "_", 2)
if len(parts) == 2 {
parsedID.ID = parts[0]
suffix := parts[1]
switch {
// Hash detection must come first: a 16-hex value with the high bit set overflows int64.
case isImageHash(suffix):
parsedID.Hash = suffix
case suffix != "0":
if lastUpdate, err := strconv.ParseInt(suffix, 16, 64); err == nil {
parsedID.LastUpdate = time.Unix(lastUpdate, 0)
}
}
}
return parsedID, nil
}
// FormatImageHash renders an XXH3-64 digest as the 16-hex image hash used in artwork ids and rows.
func FormatImageHash(sum uint64) string {
return fmt.Sprintf("%016x", sum)
}
// isImageHash reports whether s is a 16-char lowercase-hex XXH3-64 content hash.
func isImageHash(s string) bool {
if len(s) != 16 {
return false
}
for _, c := range s {
if !(c >= '0' && c <= '9' || c >= 'a' && c <= 'f') {
return false
}
}
return true
}
func MustParseArtworkID(id string) ArtworkID {
artID, err := ParseArtworkID(id)
if err != nil {
panic(artID)
}
return artID
}
func DiscArtworkID(albumID string, discNumber int) string {
return fmt.Sprintf("%s:%d", albumID, discNumber)
}
func ParseDiscArtworkID(id string) (albumID string, discNumber int, err error) {
parts := strings.SplitN(id, ":", 2)
if len(parts) != 2 || parts[1] == "" {
return "", 0, errors.New("invalid disc artwork id")
}
num, err := strconv.Atoi(parts[1])
if err != nil {
return "", 0, fmt.Errorf("invalid disc number in artwork id: %w", err)
}
return parts[0], num, nil
}
func artworkIDFromAlbum(al Album) ArtworkID {
return ArtworkID{Kind: KindAlbumArtwork, ID: al.ID, Hash: al.ImageHash}
}
func artworkIDFromMediaFile(mf MediaFile) ArtworkID {
return ArtworkID{Kind: KindMediaFileArtwork, ID: mf.ID, Hash: mf.ImageHash}
}
func artworkIDFromPlaylist(pls Playlist) ArtworkID {
return ArtworkID{Kind: KindPlaylistArtwork, ID: pls.ID, Hash: pls.ImageHash}
}
func artworkIDFromArtist(ar Artist) ArtworkID {
return ArtworkID{Kind: KindArtistArtwork, ID: ar.ID, Hash: ar.ImageHash}
}
func artworkIDFromRadio(r Radio) ArtworkID {
return ArtworkID{Kind: KindRadioArtwork, ID: r.ID, Hash: r.ImageHash}
}