navidrome/model/album.go
Deluan 07af29fbb4 fix(jellyfin): fold folder image mtimes into the album blurhash version
An in-place cover-file swap followed by a quick scan updates only the folder's
images_updated_at: no tracks are imported, so the album row never moves and
ArtworkUpdatedAt() stayed at the old value. The Jellyfin DTO then kept emitting
the previous stored blurhash, and clients that key their cover caches on it never
refetched the image, so the served-bytes recompute could never run.

The album select now surfaces the newest folder images_updated_at (bare-column
correlated subquery over json_each(folder_ids), so the datetime decltype survives
and scans as time.Time) and ArtworkUpdatedAt() folds it in. Benchmarked on a 96K
track production copy: ~90us/page added, no query-plan change; the subquery is a
PK point lookup per folder with at most two rows to order. Artist images and
playlist sidecars have the same theoretical gap but their timestamps cannot be
derived in SQL; they remain a documented follow-up.
2026-07-17 22:14:13 -04:00

180 lines
7.8 KiB
Go

package model
import (
"fmt"
"iter"
"math"
"sync"
"time"
"github.com/navidrome/navidrome/conf"
"github.com/gohugoio/hashstructure"
)
type Album struct {
Annotations `structs:"-" hash:"ignore"`
ID string `structs:"id" json:"id"`
LibraryID int `structs:"library_id" json:"libraryId"`
LibraryPath string `structs:"-" json:"libraryPath" hash:"ignore"`
LibraryName string `structs:"-" json:"libraryName" hash:"ignore"`
Name string `structs:"name" json:"name"`
EmbedArtPath string `structs:"embed_art_path" json:"-"`
AlbumArtistID string `structs:"album_artist_id" json:"albumArtistId"` // Deprecated, use Participants
// AlbumArtist is the display name used for the album artist.
AlbumArtist string `structs:"album_artist" json:"albumArtist"`
MaxYear int `structs:"max_year" json:"maxYear"`
MinYear int `structs:"min_year" json:"minYear"`
Date string `structs:"date" json:"date,omitempty"`
MaxOriginalYear int `structs:"max_original_year" json:"maxOriginalYear"`
MinOriginalYear int `structs:"min_original_year" json:"minOriginalYear"`
OriginalDate string `structs:"original_date" json:"originalDate,omitempty"`
ReleaseDate string `structs:"release_date" json:"releaseDate,omitempty"`
Compilation bool `structs:"compilation" json:"compilation"`
Comment string `structs:"comment" json:"comment,omitempty"`
SongCount int `structs:"song_count" json:"songCount"`
Duration float32 `structs:"duration" json:"duration"`
Size int64 `structs:"size" json:"size"`
Discs Discs `structs:"discs" json:"discs,omitempty"`
SortAlbumName string `structs:"sort_album_name" json:"sortAlbumName,omitempty"`
SortAlbumArtistName string `structs:"sort_album_artist_name" json:"sortAlbumArtistName,omitempty"`
OrderAlbumName string `structs:"order_album_name" json:"orderAlbumName"`
OrderAlbumArtistName string `structs:"order_album_artist_name" json:"orderAlbumArtistName"`
CatalogNum string `structs:"catalog_num" json:"catalogNum,omitempty"`
MbzAlbumID string `structs:"mbz_album_id" json:"mbzAlbumId,omitempty"`
MbzAlbumArtistID string `structs:"mbz_album_artist_id" json:"mbzAlbumArtistId,omitempty"`
MbzAlbumType string `structs:"mbz_album_type" json:"mbzAlbumType,omitempty"`
MbzAlbumComment string `structs:"mbz_album_comment" json:"mbzAlbumComment,omitempty"`
MbzReleaseGroupID string `structs:"mbz_release_group_id" json:"mbzReleaseGroupId,omitempty"`
FolderIDs []string `structs:"folder_ids" json:"-" hash:"set"` // All folders that contain media_files for this album
ExplicitStatus string `structs:"explicit_status" json:"explicitStatus"`
// External metadata fields
Description string `structs:"description" json:"description,omitempty" hash:"ignore"`
SmallImageUrl string `structs:"small_image_url" json:"smallImageUrl,omitempty" hash:"ignore"`
MediumImageUrl string `structs:"medium_image_url" json:"mediumImageUrl,omitempty" hash:"ignore"`
LargeImageUrl string `structs:"large_image_url" json:"largeImageUrl,omitempty" hash:"ignore"`
ExternalUrl string `structs:"external_url" json:"externalUrl,omitempty" hash:"ignore"`
ExternalInfoUpdatedAt *time.Time `structs:"external_info_updated_at" json:"externalInfoUpdatedAt" hash:"ignore"`
Genre string `structs:"genre" json:"genre" hash:"ignore"` // Easy access to the most common genre
Genres Genres `structs:"-" json:"genres" hash:"ignore"` // Easy access to all genres for this album
Tags Tags `structs:"tags" json:"tags,omitempty" hash:"ignore"` // All imported tags for this album
Participants Participants `structs:"participants" json:"participants" hash:"ignore"` // All artists that participated in this album
Missing bool `structs:"missing" json:"missing"` // If all file of the album ar missing
ImportedAt time.Time `structs:"imported_at" json:"importedAt" hash:"ignore"` // When this album was imported/updated
CreatedAt time.Time `structs:"created_at" json:"createdAt"` // Oldest CreatedAt for all songs in this album
UpdatedAt time.Time `structs:"updated_at" json:"updatedAt"` // Newest UpdatedAt for all songs in this album
// BlurHash of the album cover, computed from the served artwork bytes. Excluded from
// full-row writes (structs:"-"): only UpdateBlurHash writes it, so scans can't erase it.
BlurHash string `structs:"-" json:"blurHash,omitempty" hash:"ignore"`
BlurHashUpdatedAt *time.Time `structs:"-" json:"-" hash:"ignore"`
// FolderImagesUpdatedAt is the newest images_updated_at among the album's folders (selected, not
// persisted): an in-place cover-file swap moves it even though the album row stays untouched.
FolderImagesUpdatedAt *time.Time `structs:"-" json:"-" hash:"ignore"`
}
func (a Album) CoverArtID() ArtworkID {
return artworkIDFromAlbum(a)
}
// ArtworkUpdatedAt is the album's artwork version. ExternalInfoUpdatedAt is deliberately excluded:
// it bumps on every agent TTL refresh even when the image is unchanged, and actual image changes
// are caught by hashing the served bytes instead.
func (a Album) ArtworkUpdatedAt() time.Time {
t := a.UpdatedAt
if a.ImportedAt.After(t) {
t = a.ImportedAt
}
if a.FolderImagesUpdatedAt != nil && a.FolderImagesUpdatedAt.After(t) {
t = *a.FolderImagesUpdatedAt
}
return t
}
func (a Album) FullName() string {
if conf.Server.Subsonic.AppendAlbumVersion && len(a.Tags[TagAlbumVersion]) > 0 {
return fmt.Sprintf("%s (%s)", a.Name, a.Tags[TagAlbumVersion][0])
}
return a.Name
}
// Equals compares two Album structs, ignoring calculated fields
func (a Album) Equals(other Album) bool {
// Normalize float32 values to avoid false negatives
a.Duration = float32(math.Floor(float64(a.Duration)))
other.Duration = float32(math.Floor(float64(other.Duration)))
opts := &hashstructure.HashOptions{
IgnoreZeroValue: true,
ZeroNil: true,
}
hash1, _ := hashstructure.Hash(a, opts)
hash2, _ := hashstructure.Hash(other, opts)
return hash1 == hash2
}
// AlbumLevelTags contains all Tags marked as `album: true` in the mappings.yml file. They are not
// "first-class citizens" in the Album struct, but are still stored in the album table, in the `tags` column.
var AlbumLevelTags = sync.OnceValue(func() map[TagName]struct{} {
tags := make(map[TagName]struct{})
m := TagMappings()
for t, conf := range m {
if conf.Album {
tags[t] = struct{}{}
}
}
return tags
})
func (a *Album) SetTags(tags TagList) {
a.Tags = tags.GroupByFrequency()
for k := range a.Tags {
if _, ok := AlbumLevelTags()[k]; !ok {
delete(a.Tags, k)
}
}
}
type Discs map[int]string
func (d Discs) Add(discNumber int, discSubtitle string) {
d[discNumber] = discSubtitle
}
type DiscID struct {
AlbumID string `json:"albumId"`
ReleaseDate string `json:"releaseDate"`
DiscNumber int `json:"discNumber"`
}
type Albums []Album
type AlbumCursor iter.Seq2[Album, error]
type AlbumRepository interface {
CountAll(...QueryOptions) (int64, error)
Exists(id string) (bool, error)
Put(*Album) error
UpdateExternalInfo(*Album) error
UpdateBlurHash(id string, blurHash string, artworkUpdatedAt time.Time) error
Get(id string) (*Album, error)
GetAll(...QueryOptions) (Albums, error)
GetCursor(...QueryOptions) (AlbumCursor, error)
// The following methods are used exclusively by the scanner:
Touch(ids ...string) error
TouchByMissingFolder() (int64, error)
GetTouchedAlbums(libID int) (AlbumCursor, error)
RefreshPlayCounts() (int64, error)
CopyAttributes(fromID, toID string, columns ...string) error
AnnotatedRepository
SearchableRepository[Albums]
}