diff --git a/adapters/lastfm/agent_test.go b/adapters/lastfm/agent_test.go
index 6e94a075d..94024b9ab 100644
--- a/adapters/lastfm/agent_test.go
+++ b/adapters/lastfm/agent_test.go
@@ -576,10 +576,8 @@ var _ = Describe("lastfmAgent", func() {
httpClient.Res = http.Response{Body: io.NopCloser(bytes.NewBufferString(lastfmError6)), StatusCode: 200}
_, err := agent.GetAlbumInfo(ctx, "123", "U2", "mbid-1234")
Expect(err).To(HaveOccurred())
- // error 6 is a definitive not-found, so it must satisfy the shared sentinel (else it
- // would trip the artwork worker's circuit breaker and be retried as a transient fault).
+ // A definitive not-found must satisfy the sentinel, or the artwork worker retries it.
Expect(errors.Is(err, agents.ErrNotFound)).To(BeTrue())
- // No MBID retry: album.getInfo is queried by name+artist only, in a single call.
Expect(httpClient.RequestCount).To(Equal(1))
Expect(httpClient.SavedRequest.URL.Query().Get("mbid")).To(BeEmpty())
})
@@ -615,8 +613,7 @@ var _ = Describe("lastfmAgent", func() {
It("maps a Last.fm error 6 (artist not found) to the shared not-found sentinel", func() {
apiClient.Res = http.Response{Body: io.NopCloser(bytes.NewBufferString(lastfmError6)), StatusCode: 200}
_, err := agent.GetArtistImages(ctx, "123", "Nonexistent Artist", "")
- // Definitive not-found, not a fault — must satisfy the sentinel through the %w wrap so
- // runs of missing artists never trip the artwork worker's circuit breaker.
+ // Not a fault: runs of missing artists must not trip the worker's circuit breaker.
Expect(errors.Is(err, agents.ErrNotFound)).To(BeTrue())
})
diff --git a/cmd/root.go b/cmd/root.go
index c4f6e9473..1fa173619 100644
--- a/cmd/root.go
+++ b/cmd/root.go
@@ -358,7 +358,7 @@ func startArtworkWorker(ctx context.Context, worker *artwork.Worker) func() erro
}
// scheduleArtworkHousekeeping runs the startup fingerprint backfill and registers the
-// recurring stale-absent recheck and prune jobs. Scan-triggered prune lands in a later phase.
+// recurring stale-absent recheck and prune jobs.
func scheduleArtworkHousekeeping(ctx context.Context, worker *artwork.Worker) func() error {
return func() error {
schedulerInstance := scheduler.GetInstance()
diff --git a/core/artwork/agent_images.go b/core/artwork/agent_images.go
index 681490936..bc4d0735a 100644
--- a/core/artwork/agent_images.go
+++ b/core/artwork/agent_images.go
@@ -13,9 +13,7 @@ import (
"github.com/navidrome/navidrome/utils/str"
)
-// externalName applies the DevPreserveUnicodeInExternalCalls normalization the aggregate
-// provider used, so agent searches match the same way (typographic quotes/dashes cleared
-// unless preserved).
+// externalName mirrors the normalization the aggregate provider applies, so agent searches match.
func externalName(name string) string {
if conf.Server.DevPreserveUnicodeInExternalCalls {
return name
@@ -23,9 +21,8 @@ func externalName(name string) string {
return str.Clear(name)
}
-// bestImageURL returns the largest-Size image URL, skipping empty or unparseable
-// URLs; nil when none qualifies. Parsing happens per candidate so a malformed largest
-// URL never shadows a valid smaller one.
+// bestImageURL returns the largest parseable image URL, so a malformed candidate never
+// shadows a smaller valid one.
func bestImageURL(imgs []agents.ExternalImage) *url.URL {
var best *url.URL
var bestSize int
@@ -44,12 +41,10 @@ func bestImageURL(imgs []agents.ExternalImage) *url.URL {
return best
}
-// fetchArtistImage tries each enabled artist-image agent in order, each under its own gate.
-// Returns the winning reader + agent name; extErr is true only when NO agent succeeded and
-// at least one failed transiently (a later success beats an earlier agent error).
+// fetchArtistImage tries each enabled artist-image agent in order. extErr is true only when no
+// agent succeeded and at least one failed transiently.
func fetchArtistImage(ctx context.Context, ag *agents.Agents, gate gateFunc, ar model.Artist) (r io.ReadCloser, agentName string, extErr bool) {
- // Synthetic artists have no real external image; mirror Agents.GetArtistImages' guard so a
- // direct retriever call can't assign an unrelated result to Unknown/Various Artists.
+ // Synthetic artists would otherwise get an unrelated agent result assigned to them.
switch ar.ID {
case consts.UnknownArtistID, consts.VariousArtistsID:
return nil, "", false
@@ -71,7 +66,7 @@ func fetchArtistImage(ctx context.Context, ag *agents.Agents, gate gateFunc, ar
return reader, a.Name, false
}
if isTransientExternal(err) {
- extErr = true // includes errBreakerOpen and download failures: retry via the next agent
+ extErr = true
log.Debug(ctx, "Artwork: External artist-image lookup failed", "agent", a.Name, "artist", ar.Name, err)
}
}
diff --git a/core/artwork/agent_images_test.go b/core/artwork/agent_images_test.go
index 8525ab377..f57b3c19a 100644
--- a/core/artwork/agent_images_test.go
+++ b/core/artwork/agent_images_test.go
@@ -54,9 +54,8 @@ func (f *fakeImageAgent) GetAlbumImages(_ context.Context, name, _, _ string) ([
return f.imgs, f.err
}
-// imageAgents registers the fakes as built-in agents (ignoring the DataStore) and
-// enables them in order, returning the process-wide Agents. Because the fakes ignore
-// ds, reusing the GetAgents singleton across tests is safe.
+// imageAgents registers the fakes as built-in agents and enables them in order. The fakes
+// ignore the DataStore, so reusing the process-wide GetAgents singleton across tests is safe.
func imageAgents(fakes ...*fakeImageAgent) *agents.Agents {
names := make([]string, 0, len(fakes))
for _, f := range fakes {
diff --git a/core/artwork/artwork.go b/core/artwork/artwork.go
index 1f75b8550..6cf466c64 100644
--- a/core/artwork/artwork.go
+++ b/core/artwork/artwork.go
@@ -20,32 +20,30 @@ import (
var ErrUnavailable = errors.New("artwork unavailable")
-// errStaleSource signals that a backing file's mtime no longer matches the state
-// row's RefMtime: the stored hash may be stale, so the load is aborted (dangling).
+// errStaleSource means the backing file's mtime no longer matches RefMtime, so the stored hash may be stale.
var errStaleSource = errors.New("artwork: source file changed since resolution")
// Image is one servable artwork response.
type Image struct {
io.ReadCloser
- Hash string // pixel-identity hash (immutable URL match); "" for placeholders
- ETag string // served-representation validator; "" falls back to Hash (full-size original)
- LastUpdated time.Time // zero for placeholders
+ Hash string // pixel identity; "" for placeholders
+ ETag string // representation validator; "" means Hash applies (full-size original)
+ LastUpdated time.Time
Placeholder bool
}
-// representationTag identifies a served resized representation for HTTP validation: it changes with
-// the dimensions and the encode settings (CoverArtQuality/EnableWebPEncoding), so a config change
-// invalidates a revalidating client's cache even though the pixel hash is unchanged.
+// representationTag varies with dimensions and encode settings, so a config change invalidates
+// a revalidating client's cache even though the pixel hash is unchanged.
func representationTag(hash string, size int, square bool) string {
return fmt.Sprintf("%s.%d.%v.%s", hash, size, square, formatQualityTag())
}
type Artwork interface {
- // Get serves resolved/provisional artwork; ErrUnavailable or model.ErrNotFound when
- // there is nothing to serve (absent, pending, dangling) — caller picks placeholder vs 404.
+ // Get returns ErrUnavailable when there is nothing to serve and model.ErrNotFound when
+ // the id resolves to nothing, so the caller can pick placeholder vs 404.
Get(ctx context.Context, artID model.ArtworkID, size int, square bool) (*Image, error)
- // GetOrPlaceholder parses a raw id token (raw entity ids accepted, as today) and falls
- // back to the kind's placeholder image (never resized, Placeholder=true).
+ // GetOrPlaceholder accepts an artwork token or a raw entity id, falling back to the
+ // kind's placeholder image (never resized, Placeholder=true).
GetOrPlaceholder(ctx context.Context, id string, size int, square bool) (*Image, error)
}
@@ -55,7 +53,6 @@ func NewArtwork(ds model.DataStore, cache cache.FileCache, store *ImageStore, ff
// EntityExists reports whether the entity an artwork id points at is still there: state rows
// outlive a deleted entity until the next prune, so a servable row is not evidence of its owner.
-// The repositories are ctx-scoped, so a request context makes this a visibility check too.
func EntityExists(ctx context.Context, ds model.DataStore, artID model.ArtworkID) bool {
var found bool
var err error
@@ -95,8 +92,8 @@ func (s *service) GetOrPlaceholder(ctx context.Context, id string, size int, squ
if err == nil {
img, err = s.Get(ctx, artID, size, square)
}
- // Only a resolvable entity with no art gets the placeholder. An id that matches no entity
- // stays ErrNotFound, so getCoverArt keeps answering error 70 and Jellyfin keeps 404ing.
+ // Only a resolvable entity with no art gets a placeholder; an unknown id must stay
+ // ErrNotFound so callers can still answer 404 / Subsonic error 70.
if errors.Is(err, ErrUnavailable) {
return placeholderImage(artID.Kind), nil
}
@@ -108,7 +105,7 @@ func (s *service) Get(ctx context.Context, artID model.ArtworkID, size int, squa
return nil, ErrUnavailable
}
if size < 0 {
- size = 0 // a negative size is a full-size request, not a giant (OOM) resize rectangle
+ size = 0 // a negative size means full-size, not a giant (OOM) resize rectangle
}
switch artID.Kind {
case model.KindDiscArtwork:
@@ -120,13 +117,10 @@ func (s *service) Get(ctx context.Context, artID model.ArtworkID, size int, squa
}
}
-// requestRecheckAge throttles view-triggered rechecks of an absent entity so repeatedly opening a
-// genuinely-absent page can't hammer external services; below staleAbsentAge to catch younger absences.
+// requestRecheckAge throttles view-triggered rechecks so reopening a genuinely-absent page can't
+// hammer external services; below staleAbsentAge to catch younger absences.
const requestRecheckAge = time.Hour
-// serveEntity serves an entity whose state the worker owns (album/artist/playlist/radio):
-// found row serves its hash, absent row is unavailable (promoting a stale recheck on view),
-// missing row reads through provisionally.
func (s *service) serveEntity(ctx context.Context, artID model.ArtworkID, size int, square bool) (*Image, error) {
ia, err := s.ds.Artwork(ctx).GetItemArtwork(artID.Kind, artID.ID, model.ImageTypePrimary)
switch {
@@ -135,8 +129,8 @@ func (s *service) serveEntity(ctx context.Context, artID model.ArtworkID, size i
case err != nil:
return nil, err
case ia.Hash == "":
- // EnqueueBump preserves an existing backoff row's retry_at; for a settled absent row
- // (no queue row) it inserts a fresh, immediately-eligible recheck.
+ // EnqueueBump preserves an existing backoff row's retry_at, and inserts an
+ // immediately-eligible recheck for a settled absent row.
if time.Since(ia.AttemptedAt) > requestRecheckAge {
s.enqueue(ctx, artID, model.ArtworkPriorityBump)
}
@@ -146,10 +140,8 @@ func (s *service) serveEntity(ctx context.Context, artID model.ArtworkID, size i
}
}
-// serveSource is the one place bytes become an Image: a full-size request streams open()
-// directly, anything else goes through the resize cache under key. hash is the pixel identity
-// where one exists ("" for disc art, which has no state row) and doubles as the full-size
-// validator, so an ETag is only needed when the bytes are resized or the hash is missing.
+// serveSource is the one place bytes become an Image. hash is the pixel identity ("" for disc art)
+// and doubles as the full-size validator, so an ETag is only needed when resized or hash is "".
func (s *service) serveSource(ctx context.Context, key, hash string, lastUpdate time.Time,
size int, square bool, open func() (io.ReadCloser, error),
) (*Image, error) {
@@ -176,11 +168,10 @@ func (s *service) serveSource(ctx context.Context, key, hash string, lastUpdate
return &Image{ReadCloser: stream, Hash: hash, ETag: representationTag(key, size, square), LastUpdated: lastUpdate}, nil
}
-// serveHash serves the bytes of a found state row. A mismatch/open error is dangling (a warm
-// cache still serves), but a cancelled request is not: it must not enqueue a re-resolution.
+// serveHash serves the bytes of a found state row. A mismatch/open error is dangling, but a
+// cancelled request is not: it must not enqueue a re-resolution.
func (s *service) serveHash(ctx context.Context, artID model.ArtworkID, ia *model.ItemArtwork, size int, square bool) (*Image, error) {
- // Only this path can hand back a deleted entity's bytes: an absent row is already
- // unavailable, and the provisional and disc paths load their entity to resolve at all.
+ // Only this path can hand back a deleted entity's bytes; the others load their entity anyway.
if !EntityExists(ctx, s.ds, artID) {
return nil, ErrUnavailable
}
@@ -203,8 +194,7 @@ func (s *service) serveHash(ctx context.Context, artID model.ArtworkID, ia *mode
return img, nil
}
-// openOriginal opens the full-resolution bytes for a found state row, enforcing the
-// mtime invariant: bytes are never served under a hash they no longer match.
+// openOriginal enforces the mtime invariant: bytes are never served under a hash they no longer match.
func openOriginal(ia *model.ItemArtwork, mime string, store *ImageStore) (io.ReadCloser, error) {
if isFileBacked(ia.Source) {
f, err := os.Open(ia.SourcePath)
@@ -224,8 +214,7 @@ func openOriginal(ia *model.ItemArtwork, mime string, store *ImageStore) (io.Rea
}
return f, nil
}
- // Store-backed (embedded/external/generated): the bytes live in the content-addressed
- // store, but an embedded source still carries the audio file's mtime to detect edits.
+ // Store-backed bytes still carry the source's mtime, to detect edits to embedded art.
if ia.SourcePath != "" && ia.RefMtime != 0 {
info, err := os.Stat(ia.SourcePath)
if err != nil {
@@ -240,8 +229,8 @@ func openOriginal(ia *model.ItemArtwork, mime string, store *ImageStore) (io.Rea
return store.Open(ia.Hash, mime)
}
-// provisional does a local-only read-through for an entity with no state row: it enqueues
-// the worker (Bump) and serves any local bytes immediately, never writing a state row.
+// provisional serves local bytes for an entity with no state row, enqueuing the worker but
+// never writing a state row itself.
func (s *service) provisional(ctx context.Context, artID model.ArtworkID, size int, square bool) (*Image, error) {
item := model.ArtworkQueueItem{ItemKind: artID.Kind.Prefix(), ItemID: artID.ID, ImageType: model.ImageTypePrimary}
res, err := newLocalResolver(s.ds, s.ffmpeg).resolve(ctx, item)
@@ -254,8 +243,7 @@ func (s *service) provisional(ctx context.Context, artID model.ArtworkID, size i
return s.serveResolution(ctx, res, size, square)
}
-// serveResolution turns a local resolution's bytes into a servable Image (byte-hash
-// only, no decode). A resolution with no reader is unavailable.
+// serveResolution turns a local resolution's bytes into a servable Image (byte-hash only, no decode).
func (s *service) serveResolution(ctx context.Context, res resolution, size int, square bool) (*Image, error) {
if res.reader == nil {
return nil, ErrUnavailable
@@ -274,12 +262,9 @@ func (s *service) serveResolution(ctx context.Context, res resolution, size int,
func() (io.ReadCloser, error) { return io.NopCloser(bytes.NewReader(data)), nil })
}
-// serveMediaFile serves a track: own found art wins; an absent row delegates to the album;
-// a missing row extracts embedded art (if eligible, enqueuing) else delegates without enqueue.
func (s *service) serveMediaFile(ctx context.Context, artID model.ArtworkID, size int, square bool) (*Image, error) {
- // Per-track art can be disabled after mf rows were resolved (the setting is not in the
- // config fingerprint). Honor it at serve time so a direct mf- URL falls back to disc/album
- // instead of serving stale persisted embedded art.
+ // The setting is not in the config fingerprint, so honor it at serve time: a direct mf- URL
+ // must fall back to disc/album instead of serving stale persisted embedded art.
if !conf.Server.EnableMediaFileCoverArt {
mf, err := s.ds.MediaFile(ctx).Get(artID.ID)
if err != nil {
@@ -292,9 +277,9 @@ func (s *service) serveMediaFile(ctx context.Context, artID model.ArtworkID, siz
case err == nil && ia.Hash != "":
return s.serveHash(ctx, artID, ia, size, square)
case err == nil:
- // absent row → fall through to album delegation
+ // absent row: fall through
case errors.Is(err, model.ErrNotFound):
- // no row → fall through to embedded eligibility / album delegation
+ // no row: fall through
default:
return nil, err
}
@@ -307,13 +292,11 @@ func (s *service) serveMediaFile(ctx context.Context, artID model.ArtworkID, siz
if noRow && conf.Server.EnableMediaFileCoverArt && mf.HasCoverArt {
return s.provisionalEmbedded(ctx, artID, *mf, size, square)
}
- // Mirror MediaFile.CoverArtID's fallback: a multi-disc track defers to its disc art
- // (which itself falls back to the album), not straight to the album.
+ // Mirror MediaFile.CoverArtID: a track defers to its disc art, which falls back to the album.
return s.Get(ctx, mf.DiscCoverArtID(), size, square)
}
-// provisionalEmbedded extracts a track's embedded art for an immediate serve and always
-// enqueues the track (Bump) so the worker persists state; it never writes a state row.
+// provisionalEmbedded serves a track's embedded art immediately, leaving the state row to the worker.
func (s *service) provisionalEmbedded(ctx context.Context, artID model.ArtworkID, mf model.MediaFile, size int, square bool) (*Image, error) {
lib, err := loadLibraryView(ctx, s.ds, mf.LibraryID)
if err != nil {
@@ -322,31 +305,26 @@ func (s *service) provisionalEmbedded(ctx context.Context, artID model.ArtworkID
res, ok := resolveEmbedded(ctx, lib, s.ffmpeg, mf.Path)
s.enqueue(ctx, artID, model.ArtworkPriorityBump)
if !ok {
- // Eligible but unextractable (truncated frame, no ffmpeg): fall back the way
- // CoverArtID does rather than answer with a placeholder.
+ // Eligible but unextractable: fall back the way CoverArtID does, not to a placeholder.
return s.Get(ctx, mf.DiscCoverArtID(), size, square)
}
return s.serveResolution(ctx, res, size, square)
}
-// serveDisc serves disc-level artwork as a pure provisional read-through: no state rows,
-// no enqueue. It tries the disc-folder selection chain and falls back to the album cover.
+// serveDisc reads disc art through with no state row and no enqueue, falling back to the album cover.
func (s *service) serveDisc(ctx context.Context, artID model.ArtworkID, size int, square bool) (*Image, error) {
dr, err := newDiscArtworkReader(ctx, s.ds, artID)
if err != nil {
return nil, err
}
- // Single-disc albums run the chain too: a disc can carry its own art, distinct from the
- // album cover, and DiscArtPriority is what expresses that preference.
+ // Single-disc albums run the chain too: a disc can carry art distinct from the album cover.
selectImage := func() (io.ReadCloser, string, error) {
funcs := dr.fromDiscArtPriority(ctx, s.ffmpeg, conf.Server.DiscArtPriority)
return selectImageReader(ctx, artID, funcs...)
}
albumArtID := model.ArtworkID{Kind: model.KindAlbumArtwork, ID: dr.album.ID}
- // Disc art has no state row, so there is no stored content hash — to key the resize cache
- // on, or to fall back to as a validator. Keying on the id and the album's mtime, as the
- // legacy reader did, lets a warm cache answer without touching the filesystem; the chain
- // runs only on a miss. The key carries DiscArtPriority so changing it invalidates.
+ // Disc art has no state row, hence no content hash: keying on id, album mtime and
+ // DiscArtPriority lets a warm cache answer without running the chain or touching the disk.
key := fmt.Sprintf("%s|%d|%s", artID.ID, dr.cacheTime().UnixNano(), conf.Server.DiscArtPriority)
img, err := s.serveSource(ctx, key, "", dr.cacheTime(), size, square,
func() (io.ReadCloser, error) { rc, _, err := selectImage(); return rc, err })
@@ -359,16 +337,14 @@ func (s *service) serveDisc(ctx context.Context, artID model.ArtworkID, size int
return img, nil
}
-// dangling enqueues a re-resolution at Scan priority and reports the artwork as
-// unavailable, leaving the state row untouched.
+// dangling enqueues a re-resolution and reports unavailable, leaving the state row untouched.
func (s *service) dangling(ctx context.Context, artID model.ArtworkID) (*Image, error) {
log.Debug(ctx, "Artwork: State row points at bytes we cannot serve, re-resolving", "artID", artID)
s.enqueue(ctx, artID, model.ArtworkPriorityScan)
return nil, ErrUnavailable
}
-// enqueue schedules a request-triggered re-resolution. It uses EnqueueBump so an incidental
-// read-through never resets a failed resolution's backoff (unlike scan/manual re-resolve).
+// enqueue uses EnqueueBump so an incidental read-through never resets a failed resolution's backoff.
func (s *service) enqueue(ctx context.Context, artID model.ArtworkID, priority int) {
err := s.ds.ArtworkQueue(ctx).EnqueueBump(model.ArtworkQueueItem{
ItemKind: artID.Kind.Prefix(),
@@ -390,8 +366,8 @@ func placeholderImage(kind model.Kind) *Image {
return &Image{ReadCloser: r, Placeholder: true}
}
-// PlaceholderFor returns the kind-appropriate placeholder for an artwork id, for callers that must
-// serve a placeholder without consulting persisted state (e.g. an access-control denial).
+// PlaceholderFor returns the kind-appropriate placeholder for an artwork id, for callers that
+// must not consult persisted state (e.g. an access-control denial).
func PlaceholderFor(id string) *Image {
artID, _ := model.ParseArtworkID(id)
return placeholderImage(artID.Kind)
@@ -401,8 +377,7 @@ type coverArtIDGetter interface {
CoverArtID() model.ArtworkID
}
-// parseArtworkID ports the legacy getArtworkId: parse the token, and if it is a raw
-// entity id, resolve the entity and take its CoverArtID.
+// parseArtworkID accepts an artwork token or a raw entity id, resolving the latter to its CoverArtID.
func (s *service) parseArtworkID(ctx context.Context, id string) (model.ArtworkID, error) {
if id == "" {
return model.ArtworkID{}, ErrUnavailable
diff --git a/core/artwork/artwork_suite_test.go b/core/artwork/artwork_suite_test.go
index b3a76a768..0f93f49a1 100644
--- a/core/artwork/artwork_suite_test.go
+++ b/core/artwork/artwork_suite_test.go
@@ -38,7 +38,6 @@ func TestArtwork(t *testing.T) {
}
// osDirFS wraps os.DirFS as a storage.MusicFS for integration tests.
-// ReadTags is not exercised by these tests, so it is left as a stub.
type osDirFS struct{ fs.FS }
func (o osDirFS) ReadTags(...string) (map[string]metadata.Info, error) { return nil, nil }
diff --git a/core/artwork/artwork_test.go b/core/artwork/artwork_test.go
index 4ee99edec..59429d8b4 100644
--- a/core/artwork/artwork_test.go
+++ b/core/artwork/artwork_test.go
@@ -41,8 +41,6 @@ var _ = Describe("Artwork", func() {
primaryKey := func(kind, id string) string { return kind + "|" + id + "|" + model.ImageTypePrimary }
- // seedFoundStore installs a store-backed found state (bytes in the content-addressed
- // store, no backing file) and returns the hash.
seedFoundStore := func(kind, id string, imgBytes []byte) string {
hash, err := hashImage(bytes.NewReader(imgBytes))
Expect(err).ToNot(HaveOccurred())
@@ -53,8 +51,7 @@ var _ = Describe("Artwork", func() {
return hash
}
- // seedEntity registers the owning entity, without which the state row describes something
- // that no longer exists and the service correctly refuses to serve it.
+ // Without its owning entity, a state row is not served at all.
seedEntity = func(kind, id string) {
GinkgoHelper()
switch kind {
@@ -119,8 +116,7 @@ var _ = Describe("Artwork", func() {
img, err := svc.Get(ctx, model.MustParseArtworkID("al-al1"), 100, false)
Expect(err).ToNot(HaveOccurred())
- // A resized response versions its validator with the encode settings, distinct from
- // the pixel hash, so a CoverArtQuality/WebP change invalidates client caches.
+ // A resized response versions its ETag with the encode settings, not the pixel hash.
Expect(img.ETag).To(Equal(representationTag(img.Hash, 100, false)))
Expect(img.ETag).ToNot(Equal(img.Hash))
resized := readAll(img)
@@ -128,8 +124,7 @@ var _ = Describe("Artwork", func() {
Expect(err).ToNot(HaveOccurred())
Expect(cfg.Width).To(Equal(100))
- // Delete the store file: a warm resize-cache entry must keep serving without
- // ever touching the original (the stale-serve self-heal).
+ // Deleting the store file proves the warm entry serves without touching the original.
hash, _ := hashImage(bytes.NewReader(coverBytes))
Expect(store.Remove(hash, "image/jpeg", time.Now().Add(time.Hour))).To(Succeed())
Eventually(func(g Gomega) {
@@ -199,8 +194,7 @@ var _ = Describe("Artwork", func() {
Expect(queueRepo.Data[primaryKey("al", "al3b")].Priority).To(Equal(model.ArtworkPriorityScan))
})
- // State rows and their bytes outlive a deleted entity until the next prune, so serving
- // straight from the row would keep handing out a removed entity's image.
+ // State rows outlive a deleted entity until the next prune.
It("refuses to serve a found row whose entity is gone", func() {
hash := seedFoundStore("al", "alzz", coverBytes)
Expect(hash).ToNot(BeEmpty())
@@ -228,7 +222,6 @@ var _ = Describe("Artwork", func() {
_, err := svc.Get(ctx, model.MustParseArtworkID("al-al4b"), 0, false)
Expect(err).To(MatchError(ErrUnavailable))
Expect(queueRepo.Data[primaryKey("al", "al4b")].Priority).To(Equal(model.ArtworkPriorityBump))
- // The absent state row is left intact; only a recheck is scheduled.
ia, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "al4b", model.ImageTypePrimary)
Expect(err).ToNot(HaveOccurred())
Expect(ia.Hash).To(BeEmpty())
@@ -272,7 +265,6 @@ var _ = Describe("Artwork", func() {
It("ignores a resolved mf row and delegates to the album when per-track art is disabled", func() {
conf.Server.EnableMediaFileCoverArt = false
- // A resolved mf row exists (from when the setting was on) but must not be served.
seedFoundStore("mf", "mf7", []byte("stale embedded track art"))
seedFoundStore("al", "albz", coverBytes)
mfRepo.SetData(model.MediaFiles{{ID: "mf7", AlbumID: "albz"}})
@@ -322,7 +314,6 @@ var _ = Describe("Artwork", func() {
})
It("delegates a multi-disc track to its disc art, not straight to the album", func() {
- // Not embedded-eligible: the fallback must mirror CoverArtID (disc first, then album).
folderRepo.result = []model.Folder{{Path: "tests/fixtures/artist/an-album", ImageFiles: []string{"cover.jpg"}}}
albumRepo.SetData(model.Albums{{ID: "aldd", Name: "Album", FolderIDs: []string{"f1"}, Discs: model.Discs{1: "One", 2: "Two"}}})
seedFoundStore("al", "aldd", []byte("album-art-distinct")) // album's own found art differs
@@ -330,15 +321,13 @@ var _ = Describe("Artwork", func() {
img, err := svc.Get(ctx, model.MustParseArtworkID("mf-mf5"), 0, false)
Expect(err).ToNot(HaveOccurred())
- // The disc-folder image wins over the album's found art, proving it routed via serveDisc.
+ // The disc-folder image, not the album's own art: proof it routed through serveDisc.
Expect(readAll(img)).To(Equal(coverBytes))
})
- // Jellyfin clients are now told to request the track image before it resolves, so an
- // unextractable frame must not answer with a placeholder where the album has art.
It("falls back to the album when an eligible track's embedded art will not extract", func() {
conf.Server.EnableMediaFileCoverArt = true
- // HasCoverArt is set, but the file yields no extractable image (it is not audio).
+ // HasCoverArt is set, but the file is not audio, so nothing extracts.
mfRepo.SetData(model.MediaFiles{{
ID: "mfbad", AlbumID: "albad", LibraryID: 0, HasCoverArt: true,
Path: "tests/fixtures/artist/an-album/front.png",
@@ -352,8 +341,7 @@ var _ = Describe("Artwork", func() {
})
It("routes a single-disc track through disc resolution too", func() {
- // A single disc can carry its own art: DiscArtPriority still applies, so the disc
- // image is served even when the album has different found art.
+ // DiscArtPriority applies to single-disc albums too, over the album's own found art.
folderRepo.result = []model.Folder{{Path: "tests/fixtures/artist/an-album", ImageFiles: []string{"cover.jpg"}}}
albumRepo.SetData(model.Albums{{ID: "alsd", Name: "Album", FolderIDs: []string{"f1"}, Discs: model.Discs{1: ""}}})
seedFoundStore("al", "alsd", []byte("album-art-distinct"))
@@ -375,9 +363,8 @@ var _ = Describe("Artwork", func() {
Expect(readAll(img)).To(Equal(coverBytes))
})
- // The resize cache is keyed on the id and the album's mtime, not on the image bytes, so a
- // warm hit needs no filesystem access. Dropping the source between the two requests is
- // how that shows: re-reading would fall back to the album instead.
+ // The resize cache keys on id + album mtime, not on the bytes, so dropping the source
+ // between the two requests is what shows a warm hit never touches the filesystem.
It("serves a sized disc image from cache without re-reading the source", func() {
folderRepo.result = []model.Folder{{Path: "tests/fixtures/artist/an-album", ImageFiles: []string{"cover.jpg"}}}
albumRepo.SetData(model.Albums{{ID: "aldc3", Name: "Album", FolderIDs: []string{"f1"}}})
@@ -388,8 +375,6 @@ var _ = Describe("Artwork", func() {
warmed := readAll(first)
Expect(warmed).ToNot(BeEmpty())
- // With the source gone and the album holding no art of its own, a re-read would
- // fall through to the album and fail.
folderRepo.result = nil
second, err := svc.Get(ctx, discID, 64, false)
@@ -397,8 +382,7 @@ var _ = Describe("Artwork", func() {
Expect(readAll(second)).To(Equal(warmed), "a warm sized request must not touch the source")
})
- // A disc image can be replaced without the album row changing, so the key folds in the
- // folder's ImagesUpdatedAt; keying on album.UpdatedAt alone would serve the old image.
+ // A disc image can change without the album row changing, so the key folds in ImagesUpdatedAt.
It("invalidates the cached image when the folder's images change", func() {
folderRepo.result = []model.Folder{{
Path: "tests/fixtures/artist/an-album", ImageFiles: []string{"cover.jpg"},
@@ -412,7 +396,6 @@ var _ = Describe("Artwork", func() {
firstKey := first.ETag
readAll(first)
- // The image was replaced: same album row, newer folder images timestamp.
folderRepo.result[0].ImagesUpdatedAt = time.Now()
second, err := svc.Get(ctx, discID, 64, false)
Expect(err).ToNot(HaveOccurred())
@@ -420,8 +403,7 @@ var _ = Describe("Artwork", func() {
Expect(second.ETag).ToNot(Equal(firstKey), "a replaced image must not keep the old cache entry")
})
- // Disc art has no content hash to fall back to, so without an explicit validator the
- // full-size response would carry an empty ETag — identical for every disc image.
+ // Disc art has no content hash, so without an explicit validator every ETag would be empty.
It("gives a full-size disc image a validator that tracks the source", func() {
folderRepo.result = []model.Folder{{
Path: "tests/fixtures/artist/an-album", ImageFiles: []string{"cover.jpg"},
@@ -490,8 +472,7 @@ var _ = Describe("Artwork", func() {
Expect(readAll(img)).To(Equal(phBytes))
})
- // An entity that has no art and an id that names no entity are different answers:
- // Subsonic reports error 70 for the latter, and Jellyfin 404s.
+ // "No art" and "no such entity" are different answers: clients 404 only on the latter.
It("reports not-found rather than a placeholder for an id with no entity", func() {
_, err := svc.GetOrPlaceholder(ctx, "al-nosuchalbum", 0, false)
Expect(err).To(MatchError(model.ErrNotFound))
diff --git a/core/artwork/blurhash/blurhash.go b/core/artwork/blurhash/blurhash.go
index 302ec83c0..3fe8efbec 100644
--- a/core/artwork/blurhash/blurhash.go
+++ b/core/artwork/blurhash/blurhash.go
@@ -1,5 +1,5 @@
-// Package blurhash implements the blurhash encoding algorithm (https://github.com/woltapp/blurhash),
-// matching Jellyfin's parameters so clients tuned against Jellyfin see equivalent hashes.
+// Package blurhash implements the blurhash encoding (https://github.com/woltapp/blurhash),
+// parameterized to match Jellyfin so clients see equivalent hashes.
package blurhash
import (
@@ -15,10 +15,10 @@ import (
const alphabet = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz#$%*+,-.:;=?@[]^_{|}~"
-// maxInputSize matches Jellyfin: larger inputs are slower with no visually discernible difference.
+// maxInputSize: larger inputs are slower with no visible difference in the result.
const maxInputSize = 128
-// Components picks x/y component counts for an image, targeting ~16 near-square tiles (Jellyfin's formula).
+// Components picks x/y component counts targeting ~16 near-square tiles.
func Components(width, height int) (int, int) {
if width <= 0 || height <= 0 {
return 0, 0
@@ -109,8 +109,7 @@ func Encode(img image.Image, xComp, yComp int) (string, error) {
return sb.String(), nil
}
-// toRGBA gives the pixel loop direct Pix access, avoiding a per-pixel allocation through the
-// image.At interface (~16k allocs per encode).
+// toRGBA gives the pixel loop direct Pix access, avoiding a per-pixel allocation via image.At.
func toRGBA(img image.Image) *image.RGBA {
if rgba, ok := img.(*image.RGBA); ok {
return rgba
@@ -165,8 +164,7 @@ func linearToSRGB(v float64) int {
return int((1.055*math.Pow(v, 1/2.4)-0.055)*255 + 0.5)
}
-// Encode83 encodes value as a fixed-width, big-endian base83 string of the given length, using the
-// blurhash spec's alphabet.
+// Encode83 encodes value as a fixed-width, big-endian base83 string of the given length.
func Encode83(value, length int) string {
b := make([]byte, length)
for i := length - 1; i >= 0; i-- {
diff --git a/core/artwork/blurhash/blurhash_test.go b/core/artwork/blurhash/blurhash_test.go
index 18dd86b99..b457eac64 100644
--- a/core/artwork/blurhash/blurhash_test.go
+++ b/core/artwork/blurhash/blurhash_test.go
@@ -103,7 +103,6 @@ var _ = Describe("Encode", func() {
})
It("downscales large images internally without changing the result materially", func() {
- // A 1000px solid image must encode fine and carry the same DC as its small version.
big, err := blurhash.Encode(solidImage(1000, 1000, color.NRGBA{R: 60, G: 120, B: 180, A: 255}), 5, 5)
Expect(err).ToNot(HaveOccurred())
small, err := blurhash.Encode(solidImage(16, 16, color.NRGBA{R: 60, G: 120, B: 180, A: 255}), 5, 5)
diff --git a/core/artwork/disc.go b/core/artwork/disc.go
index 84d4543b3..049d2bf3f 100644
--- a/core/artwork/disc.go
+++ b/core/artwork/disc.go
@@ -27,14 +27,13 @@ type discArtworkReader struct {
isMultiFolder bool
firstTrackRel string // library-relative; for fromTag / ffmpeg via lib.Abs
lib libraryView
- // imagesUpdatedAt is the newest ImagesUpdatedAt across the album's and this disc's folders.
- // An image can be replaced without the album row changing, so this is what makes a cache
- // key notice it.
+ // Newest ImagesUpdatedAt across the album's and this disc's folders: an image can be
+ // replaced without the album row changing, so this is what makes a cache key notice it.
imagesUpdatedAt time.Time
}
// cacheTime is the disc image's validity stamp: any of these moving means the selection may
-// have changed. Mirrors what the legacy reader folded into its cache key.
+// have changed.
func (d *discArtworkReader) cacheTime() time.Time {
return utils.TimeNewest(d.album.UpdatedAt, d.album.ImportedAt, d.imagesUpdatedAt)
}
@@ -161,19 +160,12 @@ func (d *discArtworkReader) fromDiscSubtitle(ctx context.Context, subtitle strin
}
}
-// globMetaChars holds the substitution metacharacters understood by
-// filepath.Match. The '\' escape character is intentionally excluded:
-// disc art patterns come from user config and never include escaped
-// metachars in practice, and treating '\' as a metachar would misalign
-// the literal-prefix extraction in extractDiscNumber.
+// filepath.Match's '\' escape is excluded on purpose: treating it as a metachar
+// would misalign the literal-prefix extraction in extractDiscNumber.
const globMetaChars = "*?["
-// extractDiscNumber parses the disc number from a filename matched by a
-// filepath.Match-style glob pattern.
-//
-// Both pattern and filename must already be lowercased by the caller, which
-// is also expected to have verified that filepath.Match(pattern, filename)
-// is true before calling this function.
+// extractDiscNumber parses the disc number from a filename matched by a filepath.Match-style
+// glob. Caller must lowercase both args and have already verified the match.
func extractDiscNumber(pattern, filename string) (int, bool) {
metaIdx := strings.IndexAny(pattern, globMetaChars)
if metaIdx < 0 {
@@ -199,9 +191,8 @@ func extractDiscNumber(pattern, filename string) (int, bool) {
return num, true
}
-// fromExternalFile returns a sourceFunc that matches image files against a glob
-// pattern. A numbered filename whose number equals the target disc wins over
-// any unnumbered candidate; callers must pass a lowercase pattern.
+// fromExternalFile matches image files against a (lowercase) glob pattern. A numbered
+// filename whose number equals the target disc wins over any unnumbered candidate.
func (d *discArtworkReader) fromExternalFile(ctx context.Context, pattern string) sourceFunc {
isLiteral := !strings.ContainsAny(pattern, globMetaChars)
return func() (io.ReadCloser, string, error) {
diff --git a/core/artwork/e2e/acquire_serve_test.go b/core/artwork/e2e/acquire_serve_test.go
index 61f37e0bd..4e2540ead 100644
--- a/core/artwork/e2e/acquire_serve_test.go
+++ b/core/artwork/e2e/acquire_serve_test.go
@@ -20,9 +20,7 @@ import (
. "github.com/onsi/gomega"
)
-// These specs wire the real Worker and Service over the same ImageStore and mock repositories,
-// then drive the full enqueue → drain → serve loop. They assert the integration of the chain, not
-// the per-source resolution rules (which the unit suites in package artwork already cover).
+// Covers the enqueue → drain → serve chain; per-source resolution rules live in the unit suites.
var _ = Describe("Acquisition → serve loop", func() {
var (
ctx context.Context
@@ -42,7 +40,6 @@ var _ = Describe("Acquisition → serve loop", func() {
coverBytes []byte
)
- // itemFound reports whether the worker has persisted a resolved (hash-bearing) state row.
itemFound := func(kind model.Kind, id string) func() bool {
return func() bool {
ia, err := artRepo.GetItemArtwork(kind, id, model.ImageTypePrimary)
@@ -66,7 +63,7 @@ var _ = Describe("Acquisition → serve loop", func() {
conf.Server.CacheFolder = conf.NewDir(GinkgoT().TempDir())
conf.Server.DataFolder = conf.NewDir(GinkgoT().TempDir())
conf.Server.CoverArtPriority = "cover.jpg"
- conf.Server.ArtistArtPriority = "artist.png" // upload wins first; kept offline as a safety net
+ conf.Server.ArtistArtPriority = "artist.png" // keeps artist resolution offline
conf.Server.EnableMediaFileCoverArt = true
conf.Server.ArtworkWorkerConcurrency = 1
@@ -94,8 +91,7 @@ var _ = Describe("Acquisition → serve loop", func() {
}
ffm := tests.NewMockFFmpeg("")
store = artwork.NewImageStore(GinkgoT().TempDir())
- // size=0 requests stream originals and never touch the resize cache, so this reader is a
- // compile-time stand-in only; the resize path is covered by the package's serving_test.
+ // size=0 requests stream originals, so this reader is never called (serving_test covers resizing).
imgCache := cache.NewFileCache("ArtworkPipelineE2E", "100MB", "images", 0,
func(context.Context, cache.Item) (io.Reader, error) {
return nil, errors.New("resize not exercised in e2e")
@@ -106,8 +102,6 @@ var _ = Describe("Acquisition → serve loop", func() {
worker = artwork.NewWorker(ds, store, agents.GetAgents(ds, nil), ffm, events.NoopBroker(), imgCache)
})
- // seedFolderAlbum wires an album backed by the real fixture folder cover, shared by the album
- // and playlist-grid scenarios.
seedFolderAlbum := func(albumID string) {
folderRepo.result = []model.Folder{{Path: albumFolderPath, ImageFiles: []string{"cover.jpg"}}}
albumRepo.SetData(model.Albums{{ID: albumID, Name: "Album", FolderIDs: []string{"f1"}, LibraryID: 0}})
@@ -159,7 +153,6 @@ var _ = Describe("Acquisition → serve loop", func() {
img, err := svc.Get(ctx, model.MustParseArtworkID("pl-pl1"), 0, false)
Expect(err).ToNot(HaveOccurred())
Expect(img.Hash).To(Equal(ia.Hash))
- // The generated grid is a fresh PNG placed in the content-addressed store.
art, err := artRepo.GetImage(ia.Hash)
Expect(err).ToNot(HaveOccurred())
Expect(art.Mime).To(Equal("image/png"))
@@ -187,7 +180,6 @@ var _ = Describe("Acquisition → serve loop", func() {
ID: "mf1", AlbumID: "al1", HasCoverArt: true, LibraryID: 0, Path: mp3Fixture,
}})
- // First read: no state row yet → extract embedded art provisionally and enqueue the track.
provisional, err := svc.Get(ctx, model.MustParseArtworkID("mf-mf1"), 0, false)
Expect(err).ToNot(HaveOccurred())
Expect(provisional.Placeholder).To(BeFalse())
@@ -198,14 +190,13 @@ var _ = Describe("Acquisition → serve loop", func() {
_, err = artRepo.GetItemArtwork(model.KindMediaFileArtwork, "mf1", model.ImageTypePrimary)
Expect(err).To(MatchError(model.ErrNotFound), "provisional serving must not write a state row")
- // The provisional read enqueued a Bump; drain it and confirm the persisted hash matches.
+ // The provisional read enqueued a Bump; drain it.
runWorkerUntil(ctx, worker, itemFound(model.KindMediaFileArtwork, "mf1"))
ia, err := artRepo.GetItemArtwork(model.KindMediaFileArtwork, "mf1", model.ImageTypePrimary)
Expect(err).ToNot(HaveOccurred())
Expect(ia.Source).To(Equal("embedded"))
Expect(ia.Hash).To(Equal(provisional.Hash))
- // Second read: now served from the persisted state row / store, same bytes.
resolved, err := svc.Get(ctx, model.MustParseArtworkID("mf-mf1"), 0, false)
Expect(err).ToNot(HaveOccurred())
Expect(resolved.Hash).To(Equal(ia.Hash))
@@ -225,7 +216,6 @@ var _ = Describe("Acquisition → serve loop", func() {
Expect(art.Width).To(BeNumerically(">", 0))
Expect(art.Height).To(BeNumerically(">", 0))
Expect(art.SizeBytes).To(BeNumerically("==", len(coverBytes)))
- // Never a synthesized value: the blurhash is encoded from the real pixels.
Expect(art.BlurHash).ToNot(BeEmpty())
})
@@ -273,17 +263,16 @@ var _ = Describe("Acquisition → serve loop", func() {
Expect(err).ToNot(HaveOccurred())
staleHash := ia.Hash
- // Replace the backing file with different bytes and a newer mtime.
path := model.UploadedImagePath(consts.EntityRadio, name)
Expect(os.WriteFile(path, readFixture(artistPngFixture), 0o600)).To(Succeed())
newer := time.Now().Add(2 * time.Second)
Expect(os.Chtimes(path, newer, newer)).To(Succeed())
- // The mtime no longer matches the state row, so the old hash's bytes are never served.
+ // The mtime no longer matches the state row, so the stale bytes are not served.
_, err = svc.Get(ctx, model.MustParseArtworkID("ra-ra1"), 0, false)
Expect(err).To(MatchError(artwork.ErrUnavailable))
- // That read enqueued a re-resolution; draining it republishes the new bytes.
+ // That failed read enqueued a re-resolution.
runWorkerUntil(ctx, worker, func() bool {
cur, gerr := artRepo.GetItemArtwork(model.KindRadioArtwork, "ra1", model.ImageTypePrimary)
return gerr == nil && cur.Hash != "" && cur.Hash != staleHash
@@ -307,15 +296,14 @@ var _ = Describe("Acquisition → serve loop", func() {
})
})
-// mustGet unwraps a Service.Get result for inline byte assertions.
func mustGet(img *artwork.Image, err error) *artwork.Image {
GinkgoHelper()
Expect(err).ToNot(HaveOccurred())
return img
}
-// gifFixture is a 4x4 GIF held as raw bytes on purpose: encoding one would import image/gif into
-// this test binary and register the decoder, masking the production import the spec above guards.
+// Raw bytes on purpose: encoding a GIF here would register image/gif in the test binary, masking
+// the production import the spec above guards.
var gifFixture = []byte{
0x47, 0x49, 0x46, 0x38, 0x39, 0x61, 0x04, 0x00, 0x04, 0x00, 0x80, 0x00,
0x00, 0x2e, 0x86, 0xc1, 0xf4, 0xd0, 0x3f, 0x2c, 0x00, 0x00, 0x00, 0x00,
diff --git a/core/artwork/e2e/album_test.go b/core/artwork/e2e/album_test.go
index 92fabf9fc..c27f672e3 100644
--- a/core/artwork/e2e/album_test.go
+++ b/core/artwork/e2e/album_test.go
@@ -9,10 +9,8 @@ import (
. "github.com/onsi/gomega"
)
-// Folder-backed album art is served via os.Open(SourcePath), which the in-memory library FS
-// cannot satisfy; the worker's persisted state row (Source + SourcePath) is the resolver's
-// selection, so folder scenarios assert on it. Embedded art lands in the content-addressed store
-// and is asserted byte-for-byte; a no-art album settles absent.
+// The in-memory library FS cannot satisfy the os.Open(SourcePath) used to serve folder art, so
+// folder scenarios assert on the worker's state row (Source + SourcePath) instead of the bytes.
var _ = Describe("Album artwork resolution", func() {
BeforeEach(func() {
setupResolutionHarness()
@@ -354,8 +352,7 @@ var _ = Describe("Album artwork resolution", func() {
})
scan()
- // Album B first: a drain settles every ready item, and folder art is only
- // byte-servable while the album still has no state row.
+ // Album B first: the acquire in expectAbsent would settle Album B too.
expectFolderCover(albumByName("Album B"), "Artist/Album B/cover.jpg")
expectAbsent(albumByName("Album A"))
})
@@ -389,11 +386,10 @@ var _ = Describe("Album artwork resolution", func() {
})
})
- // albumRootParent refuses the library root as an album root (parent.ParentID == ""), so a
- // stray image at the top of the library never becomes some album's cover.
+ // albumRootParent refuses the library root as an album root (parent.ParentID == "").
When("a multi-disc album sits directly at the library root with a cover.jpg beside it", func() {
// (library root)
- // ├── cover.jpg ← must NOT be adopted: the root is never an album root
+ // ├── cover.jpg ← must NOT be adopted
// ├── CD1/
// │ └── 01 - Track.mp3
// └── CD2/
@@ -410,15 +406,14 @@ var _ = Describe("Album artwork resolution", func() {
})
})
- // compareImageFiles prefers shallower paths on a basename tie, so an artist-folder cover.jpg
- // would outrank the album's own if the parent folder were ever considered here. It is not:
- // albumRootParent skips the parent for a single-folder album that has images of its own.
+ // The shallower artist-folder cover.jpg would win the basename tie, but albumRootParent skips
+ // the parent folder for a single-folder album that has images of its own.
When("a single-folder album has its own cover.jpg and the artist folder has one too", func() {
// Artist/
// ├── cover.jpg ← shallower, but must NOT win
// └── Album/
// ├── 01 - Track.mp3
- // └── cover.jpg ← should win (the album has images of its own)
+ // └── cover.jpg ← should win
It("prefers the album's own cover over the shallower artist-folder cover", func() {
conf.Server.CoverArtPriority = defaultCoverPriority
setLayout(fstest.MapFS{
@@ -441,8 +436,7 @@ var _ = Describe("Album artwork resolution", func() {
// ├── Album A bonus/
// │ └── 02 - Track.mp3 (album: "Album A")
// └── Album B/
- // └── 01 - Track.mp3 other-album audio, so the artist folder is
- // correctly rejected as Album A's root
+ // └── 01 - Track.mp3 (other-album audio: rejects the artist folder as a root)
It("prefers the album's own art over the artist image", func() {
conf.Server.CoverArtPriority = defaultCoverPriority
setLayout(fstest.MapFS{
diff --git a/core/artwork/e2e/disc_test.go b/core/artwork/e2e/disc_test.go
index f869cadae..5fdd5f27b 100644
--- a/core/artwork/e2e/disc_test.go
+++ b/core/artwork/e2e/disc_test.go
@@ -11,8 +11,7 @@ import (
)
// Disc art is a serve-time read through the library FS (no worker state row), so per-disc images
-// are asserted byte-for-byte. Single-disc albums run the disc chain too — a disc can carry art
-// distinct from the album cover. Album-root covers are folder-backed and asserted on the state row.
+// are asserted byte-for-byte, while album-root covers are asserted on the state row.
var _ = Describe("Disc artwork resolution", func() {
BeforeEach(func() {
setupResolutionHarness()
diff --git a/core/artwork/e2e/e2e_suite_test.go b/core/artwork/e2e/e2e_suite_test.go
index bec7b12cf..8881da3a6 100644
--- a/core/artwork/e2e/e2e_suite_test.go
+++ b/core/artwork/e2e/e2e_suite_test.go
@@ -1,6 +1,5 @@
-// Package e2e exercises the artwork pipeline end to end: it enqueues real entities, drives the
-// real Worker to drain the queue, and serves the result through the real Service, over a real
-// ImageStore and real library files.
+// Package e2e exercises the artwork pipeline end to end: the real Worker drains the queue and the
+// real Service serves the result, over a real ImageStore and real library files.
package e2e
import (
@@ -36,7 +35,6 @@ const (
albumFolderPath = "tests/fixtures/artist/an-album"
)
-// readFixture returns the raw bytes of a project-relative fixture file.
func readFixture(rel string) []byte {
GinkgoHelper()
data, err := os.ReadFile(rel)
@@ -44,7 +42,6 @@ func readFixture(rel string) []byte {
return data
}
-// readAll drains an artwork image to bytes and closes it.
func readAll(img *artwork.Image) []byte {
GinkgoHelper()
Expect(img).ToNot(BeNil())
@@ -54,8 +51,6 @@ func readAll(img *artwork.Image) []byte {
return data
}
-// runWorkerUntil starts the real worker loop, waits for a condition, then cancels and joins it,
-// mirroring how cmd drives Worker.Run in production.
func runWorkerUntil(ctx context.Context, worker *artwork.Worker, until func() bool) {
GinkgoHelper()
runCtx, cancel := context.WithCancel(ctx)
@@ -66,8 +61,6 @@ func runWorkerUntil(ctx context.Context, worker *artwork.Worker, until func() bo
Eventually(done, 2*time.Second).Should(Receive(BeNil()))
}
-// fakeFolderRepo is the minimal FolderRepository the album/playlist resolution chains touch:
-// GetAll yields the seeded folders and the album-root parent lookup finds nothing.
type fakeFolderRepo struct {
model.FolderRepository
result []model.Folder
@@ -81,8 +74,6 @@ func (f *fakeFolderRepo) HasAudioOutsideFolders(model.Folder, []string) (bool, e
func (f *fakeFolderRepo) Get(string) (*model.Folder, error) { return nil, model.ErrNotFound }
-// writeUpload copies a fixture into the per-entity upload folder under the data dir and returns
-// the bare filename UploadedImagePath expects.
func writeUpload(entityType, name, srcFixture string) string {
GinkgoHelper()
dst := model.UploadedImagePath(entityType, name)
diff --git a/core/artwork/e2e/mediafile_test.go b/core/artwork/e2e/mediafile_test.go
index 6667b8dc2..d26756dcd 100644
--- a/core/artwork/e2e/mediafile_test.go
+++ b/core/artwork/e2e/mediafile_test.go
@@ -126,8 +126,7 @@ var _ = Describe("MediaFile artwork resolution", func() {
scan()
replaceWithRealMP3("Artist/Album/01 - Track.mp3")
- // The setting is not part of the artwork fingerprint, so a direct mf- request must
- // honor it at serve time rather than serving previously-eligible embedded art.
+ // The setting is not part of the artwork fingerprint, so it must be honored at serve time.
conf.Server.EnableMediaFileCoverArt = false
mf := mediafileOn("Artist/Album/01 - Track.mp3")
trackArtID := model.NewArtworkID(model.KindMediaFileArtwork, mf.ID, nil)
diff --git a/core/artwork/e2e/playlist_test.go b/core/artwork/e2e/playlist_test.go
index 2db8d4dda..2de601877 100644
--- a/core/artwork/e2e/playlist_test.go
+++ b/core/artwork/e2e/playlist_test.go
@@ -169,7 +169,6 @@ var _ = Describe("Playlist artwork resolution", func() {
// ├── AlbumB/{01 - Track.mp3, cover.png} ← tile 2
// ├── AlbumC/{01 - Track.mp3, cover.png} ← tile 3
// └── AlbumD/{01 - Track.mp3, cover.png} ← tile 4
- // Four distinct tiles fill the grid outright, with no mirroring.
It("fills all four grid quadrants with distinct album art", func() {
conf.Server.CoverArtPriority = "cover.*"
layout := fstest.MapFS{}
diff --git a/core/artwork/e2e/resolution_harness_test.go b/core/artwork/e2e/resolution_harness_test.go
index d79cf498c..ed27cd36b 100644
--- a/core/artwork/e2e/resolution_harness_test.go
+++ b/core/artwork/e2e/resolution_harness_test.go
@@ -38,12 +38,6 @@ import (
"go.senan.xyz/taglib"
)
-// This harness restores the pre-cutover artwork resolution edge-case coverage, but drives it
-// through the real pipeline: a real scanner populates the folder graph from an in-memory library,
-// the real Worker drains the queue to resolve/persist state, and the real Service serves it.
-// It documents the folder-selection rules (album/disc/artist priority, #5376/#5456/#5451/#5457)
-// that the lightweight acquire_serve_test.go intentionally leaves to this suite.
-
const fakeLibScheme = "artworkfake"
const fakeLibPath = fakeLibScheme + ":///music"
@@ -61,9 +55,8 @@ var (
fakeFS *storagetest.FakeFS
)
-// The DB file lives in a suite-level tempdir: the go-sqlite3 singleton keeps the file open for the
-// whole suite, and Ginkgo's per-spec TempDir cleanup can't unlink a file with a live handle on
-// Windows. A suite-level tempdir plus an AfterSuite close avoids the lock conflict.
+// The go-sqlite3 singleton holds the file open for the whole suite, and Windows cannot unlink a
+// file with a live handle, so the DB cannot live in Ginkgo's per-spec TempDir.
var suiteDBTempDir string
// Migrating the schema costs ~400ms, so it runs once per suite and specs reset by truncating.
@@ -117,8 +110,7 @@ func setupResolutionHarness() {
ffm := tests.NewMockFFmpeg("")
rstore = artwork.NewImageStore(filepath.Join(tempDir, "store"))
- // size=0 requests stream originals and never touch the resize cache, so this reader is a
- // compile-time stand-in only; the resize path is covered by the package's serving_test.
+ // size=0 requests stream originals, so this reader is never called (serving_test covers resizing).
imgCache := cache.NewFileCache("ArtworkResolutionE2E", "100MB", "images", 0,
func(context.Context, cache.Item) (io.Reader, error) {
return nil, fmt.Errorf("resize not exercised in e2e")
@@ -129,7 +121,7 @@ func setupResolutionHarness() {
rworker = artwork.NewWorker(rds, rstore, agents.GetAgents(rds, nil), ffm, events.NoopBroker(), imgCache)
}
-// setLayout populates the fake library. All paths must be forward-slash and relative.
+// setLayout paths must be relative and forward-slash.
func setLayout(files fstest.MapFS) {
GinkgoHelper()
fakeFS.SetFiles(files)
@@ -143,8 +135,6 @@ func scan() {
Expect(err).ToNot(HaveOccurred())
}
-// acquire drives the worker to resolve one entity and returns its persisted state row. It fails if
-// the worker never settles (found or absent) within the timeout.
func acquire(kind model.Kind, id string) model.ItemArtwork {
GinkgoHelper()
rworker.Bump(kind.Prefix(), id)
@@ -170,7 +160,6 @@ func runResolutionWorkerUntil(until func() bool) {
Eventually(done, 2*time.Second).Should(Receive(BeNil()))
}
-// serveBytes reads an artwork ID through the real Service at full size and returns its bytes.
func serveBytes(artID model.ArtworkID) []byte {
GinkgoHelper()
img, err := rsvc.Get(rctx, artID, 0, false)
@@ -189,7 +178,6 @@ func serveErr(artID model.ArtworkID) error {
return err
}
-// libFileBytes returns the contents of the one library file whose path ends with suffix.
func libFileBytes(suffix string) []byte {
GinkgoHelper()
var match string
@@ -203,10 +191,8 @@ func libFileBytes(suffix string) []byte {
return fakeFS.MapFS[match].Data
}
-// expectAlbumFolderCover asserts the album resolves to the library image at the given path suffix,
-// byte-for-byte. The serve happens before acquisition on purpose: with no state row the request
-// path resolves locally through the library FS, whereas a settled folder row is file-backed and
-// read with os.Open, which the in-memory FS cannot satisfy.
+// Serving before acquiring is deliberate: with no state row the request resolves through the
+// library FS, while a settled folder row is read with os.Open, which the in-memory FS cannot serve.
func expectAlbumFolderCover(al model.Album, suffix string) {
GinkgoHelper()
requireNoStateRow(model.KindAlbumArtwork, al.ID)
@@ -216,9 +202,7 @@ func expectAlbumFolderCover(al model.Album, suffix string) {
Expect(filepath.ToSlash(ia.SourcePath)).To(HaveSuffix(suffix))
}
-// requireNoStateRow guards the byte-level folder assertions: a drain resolves every ready queue
-// row, so acquiring one entity can settle others. Once settled, folder art is file-backed and the
-// in-memory FS cannot serve it — so these assertions must come before any acquire in a spec.
+// A drain settles every ready item, so byte-level folder assertions must precede any acquire.
func requireNoStateRow(kind model.Kind, id string) {
GinkgoHelper()
_, err := rds.Artwork(rctx).GetItemArtwork(kind, id, model.ImageTypePrimary)
@@ -226,7 +210,6 @@ func requireNoStateRow(kind model.Kind, id string) {
"assert %s %q before acquiring any other entity in this spec", kind, id)
}
-// expectAlbumAbsent asserts the album settled absent (no source resolved) and serves unavailable.
func expectAlbumAbsent(al model.Album) {
GinkgoHelper()
ia := acquire(model.KindAlbumArtwork, al.ID)
@@ -234,8 +217,6 @@ func expectAlbumAbsent(al model.Album) {
Expect(serveErr(al.CoverArtID())).To(MatchError(artwork.ErrUnavailable))
}
-// expectArtistFolder asserts the worker selected a library folder image (artist.*, album/artist.*)
-// as the artist image; like album folder art it is file-backed, so it is asserted on the state row.
func expectArtistFolder(ar model.Artist, suffix string) {
GinkgoHelper()
requireNoStateRow(model.KindArtistArtwork, ar.ID)
@@ -245,8 +226,6 @@ func expectArtistFolder(ar model.Artist, suffix string) {
Expect(filepath.ToSlash(ia.SourcePath)).To(HaveSuffix(suffix))
}
-// writeUploadedImage drops raw bytes into the per-entity upload folder under DataFolder, matching
-// the layout model.UploadedImagePath expects. Uploads are real files on disk, so they serve back.
func writeUploadedImage(entity, filename string, data []byte) {
GinkgoHelper()
dst := model.UploadedImagePath(entity, filename)
@@ -254,21 +233,17 @@ func writeUploadedImage(entity, filename string, data []byte) {
Expect(os.WriteFile(dst, data, 0o600)).To(Succeed())
}
-// discArtID is the artwork ID for one disc of an album.
func discArtID(al model.Album, disc int) model.ArtworkID {
return model.NewArtworkID(model.KindDiscArtwork, model.DiscArtworkID(al.ID, disc), &al.UpdatedAt)
}
-// expectDiscImage asserts a multi-disc album serves the given disc's art byte-for-byte. Disc art
-// is a pure serve-time read through the library FS (no worker/state row), so this serves it live.
+// Disc art is a pure serve-time read through the library FS: no worker, no state row.
func expectDiscImage(al model.Album, disc int, label string) {
GinkgoHelper()
Expect(serveBytes(discArtID(al, disc))).To(Equal(pngBytes(label)))
}
-// gridQuadrants decodes a generated 2x2 playlist cover and samples the center of each quadrant,
-// in rect() order: top-left, top-right, bottom-left, bottom-right. Each tile is a solid color, so
-// the samples identify which album art landed where (and whether tiles were mirrored).
+// Samples in rect() order: top-left, top-right, bottom-left, bottom-right.
func gridQuadrants(data []byte) [4]color.RGBA {
GinkgoHelper()
img, _, err := image.Decode(bytes.NewReader(data))
@@ -282,9 +257,7 @@ func gridQuadrants(data []byte) [4]color.RGBA {
return [4]color.RGBA{at(qw, qh), at(3*qw, qh), at(qw, 3*qh), at(3*qw, 3*qh)}
}
-// storedBytes returns the bytes the worker placed in the content-addressed store for a
-// store-backed resolution (embedded/generated). Folder/upload sources are file-backed and are
-// not in the store; assert those on ia.SourcePath instead.
+// Store-backed sources only (embedded/generated); file-backed ones assert on ia.SourcePath.
func storedBytes(ia model.ItemArtwork) []byte {
GinkgoHelper()
art, err := rds.Artwork(rctx).GetImage(ia.Hash)
@@ -297,9 +270,7 @@ func storedBytes(ia model.ItemArtwork) []byte {
return data
}
-// smallPNG builds a tiny valid PNG whose pixel color is derived from label, so the bytes are
-// distinct per label (a resolver picking a different file yields a different hash/path) while
-// still decoding cleanly for the worker's blurhash step.
+// The pixel color derives from label, so each label yields distinct, still-decodable bytes.
func smallPNG(label string) *fstest.MapFile {
h := fnv.New32a()
_, _ = h.Write([]byte(label))
@@ -316,13 +287,11 @@ func smallPNG(label string) *fstest.MapFile {
return &fstest.MapFile{Data: buf.Bytes()}
}
-// pngBytes returns the bytes smallPNG(label) writes, for byte-for-byte serve assertions.
func pngBytes(label string) []byte {
GinkgoHelper()
return smallPNG(label).Data
}
-// trackFile builds a fake MP3 entry with optional tag overrides (album, disc, discsubtitle, ...).
func trackFile(num int, title string, extra ...map[string]any) *fstest.MapFile {
tags := storagetest.Track(num, title)
for _, e := range extra {
@@ -333,10 +302,8 @@ func trackFile(num int, title string, extra ...map[string]any) *fstest.MapFile {
return storagetest.MP3(tags)
}
-// embeddedArtFixture is a real MP3 with an embedded picture; FakeFS's JSON-encoded tags aren't
-// taglib-readable, so embedded-art scenarios swap these bytes in after scanning. embeddedArtBytes
-// is the exact image taglib extracts from it. Both load lazily (after tests.Init chdirs to the
-// project root and registers Gomega), via loadEmbeddedFixture from setupResolutionHarness.
+// FakeFS's JSON-encoded tags aren't taglib-readable, so embedded-art specs swap in these real MP3
+// bytes after scanning. Loaded lazily: tests.Init must chdir to the project root first.
var (
embeddedFixtureOnce sync.Once
embeddedArtFixture []byte
@@ -367,8 +334,6 @@ func extractEmbeddedArt(mp3 []byte) []byte {
return data
}
-// replaceWithRealMP3 swaps the fake entry at relPath for the real embedded-art MP3, so the
-// library FS returns a taglib-parseable stream during resolution.
func replaceWithRealMP3(relPath string) {
GinkgoHelper()
fakeFS.MapFS[relPath] = &fstest.MapFile{Data: embeddedArtFixture}
diff --git a/core/artwork/folders_artist.go b/core/artwork/folders_artist.go
index e411cdf9b..9c27ac9ac 100644
--- a/core/artwork/folders_artist.go
+++ b/core/artwork/folders_artist.go
@@ -21,8 +21,6 @@ import (
)
const (
- // maxArtistFolderTraversalDepth defines how many directory levels to search
- // when looking for artist images (artist folder + parent directories)
maxArtistFolderTraversalDepth = 3
)
@@ -35,8 +33,7 @@ func fromArtistFolder(ctx context.Context, libFS fs.FS, libPath, artistFolder, p
if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
return nil, "", fmt.Errorf(`artist folder '%s' is outside library '%s'`, artistFolder, libPath)
}
- // fs.Glob / path.Join below expect forward-slash paths; filepath.Rel may
- // return backslash separators on Windows.
+ // fs.Glob needs forward slashes; filepath.Rel returns backslashes on Windows.
rel = filepath.ToSlash(rel)
current := artistFolder
var unreadable error
@@ -49,7 +46,7 @@ func fromArtistFolder(ctx context.Context, libFS fs.FS, libPath, artistFolder, p
unreadable = err
}
if rel == "." {
- break // reached library root; don't traverse above it
+ break // reached library root
}
rel = path.Dir(rel)
current = filepath.Dir(current)
@@ -61,10 +58,8 @@ func fromArtistFolder(ctx context.Context, libFS fs.FS, libPath, artistFolder, p
}
}
-// findImageInFolder globs libFS at relFolder for pattern and returns the first
-// matching image. absFolder is used only for the returned display path and log
-// messages so callers see absolute-looking paths consistent with the rest of
-// the artwork pipeline.
+// findImageInFolder returns the first image matching pattern; absFolder is only used for
+// the returned display path and log messages.
func findImageInFolder(ctx context.Context, libFS fs.FS, relFolder, absFolder, pattern string) (io.ReadCloser, string, error) {
log.Trace(ctx, "Artwork: Looking for artist image", "pattern", pattern, "folder", absFolder)
globPattern := pattern
@@ -77,7 +72,6 @@ func findImageInFolder(ctx context.Context, libFS fs.FS, relFolder, absFolder, p
return nil, "", err
}
- // Filter to valid image files
var imagePaths []string
for _, m := range matches {
if !model.IsImageFile(m) {
@@ -86,8 +80,7 @@ func findImageInFolder(ctx context.Context, libFS fs.FS, relFolder, absFolder, p
imagePaths = append(imagePaths, m)
}
- // Sort image files by prioritizing base filenames without numeric
- // suffixes (e.g., artist.jpg before artist.1.jpg)
+ // Prefer base filenames over numeric-suffixed ones (artist.jpg before artist.1.jpg)
slices.SortFunc(imagePaths, compareImageFiles)
var openErr error
@@ -125,7 +118,7 @@ func loadArtistFolder(ctx context.Context, ds model.DataStore, albums model.Albu
if len(albums) == 0 {
return "", time.Time{}, nil
}
- libID := albums[0].LibraryID // Just need one of the albums, as they should all be in the same Library - for now! TODO: Support multiple libraries
+ libID := albums[0].LibraryID // TODO: Support albums spanning multiple libraries
folderPath := str.LongestCommonPrefix(paths)
if !strings.HasSuffix(folderPath, string(filepath.Separator)) {
@@ -133,15 +126,13 @@ func loadArtistFolder(ctx context.Context, ds model.DataStore, albums model.Albu
}
folderPath = filepath.Dir(folderPath)
- // Manipulate the path to get the folder ID
- // TODO: This is a bit hacky, but it's the easiest way to get the folder ID, ATM
+ // TODO: Hacky, but the easiest way to get the folder ID ATM
libPath := core.AbsolutePath(ctx, ds, libID, "")
folderID := model.FolderID(model.Library{ID: libID, Path: libPath}, folderPath)
log.Trace(ctx, "Artwork: Calculating artist folder details", "folderPath", folderPath, "folderID", folderID,
"libPath", libPath, "libID", libID, "albumPaths", paths)
- // Get the last update time for the folder
folders, err := ds.Folder(ctx).GetAll(model.QueryOptions{Filters: squirrel.Eq{"folder.id": folderID, "missing": false}})
if err != nil || len(folders) == 0 {
log.Warn(ctx, "Artwork: Could not find folder for artist", "folderPath", folderPath, "id", folderID,
@@ -151,8 +142,7 @@ func loadArtistFolder(ctx context.Context, ds model.DataStore, albums model.Albu
return folderPath, folders[0].ImagesUpdatedAt, nil
}
-// findImageInArtistFolder scans a folder for an image file matching the artist's MBID or name
-// (case-insensitive). Returns the full path, or empty string if not found.
+// findImageInArtistFolder matches an image by MBID or artist name (case-insensitive), "" if none.
func findImageInArtistFolder(folder, mbzArtistID, artistName string) string {
entries, err := os.ReadDir(folder)
if err != nil {
diff --git a/core/artwork/folders_artist_test.go b/core/artwork/folders_artist_test.go
index 27b52c9e4..a35e40232 100644
--- a/core/artwork/folders_artist_test.go
+++ b/core/artwork/folders_artist_test.go
@@ -10,9 +10,8 @@ import (
. "github.com/onsi/gomega"
)
-// unreadableFS globs like its embedded MapFS but refuses to open anything, standing in for a
-// stale mount or a permissions failure. Injecting the error keeps this independent of the
-// filesystem: os.Chmod does not restrict read access on Windows.
+// unreadableFS globs like its embedded MapFS but refuses to open anything. Injecting the error
+// keeps this independent of the filesystem: os.Chmod does not restrict read access on Windows.
type unreadableFS struct{ fstest.MapFS }
func (u unreadableFS) Open(string) (fs.File, error) { return nil, fs.ErrPermission }
diff --git a/core/artwork/gate.go b/core/artwork/gate.go
index 68c281284..096b1120d 100644
--- a/core/artwork/gate.go
+++ b/core/artwork/gate.go
@@ -21,36 +21,33 @@ const (
var errBreakerOpen = errors.New("artwork: external circuit breaker open")
// gateFunc gates one named external fetch (rate limit + circuit breaker per name).
-// resolveItem defaults to passthroughGate; the worker injects the per-agent gate.
type gateFunc = func(name string, f func() (io.ReadCloser, string, error)) (io.ReadCloser, string, error)
func passthroughGate(_ string, f func() (io.ReadCloser, string, error)) (io.ReadCloser, string, error) {
return f()
}
-// isTransientExternal reports whether an external step failed in a way worth retrying;
-// a not-found (from either package) is a definitive answer, not a fault.
+// isTransientExternal reports whether an external failure is worth retrying; a not-found
+// (from either package) is a definitive answer, not a fault.
func isTransientExternal(err error) bool {
return err != nil && !errors.Is(err, agents.ErrNotFound) && !errors.Is(err, model.ErrNotFound)
}
-// extGate is one agent's rate limiter + circuit breaker; each external agent gets its
-// own so a provider whose API or CDN is down backs off in isolation from the others.
+// extGate is one agent's rate limiter and circuit breaker, so a failing provider backs off
+// in isolation from the others.
type extGate struct {
limiter *rate.Limiter
breaker *breaker
}
-// gate wraps a named external step with that agent's own rate limiter and circuit
-// breaker, matching gateFunc so it can be handed to the processor's resolver.
+// gate runs a named external step through that agent's rate limiter and circuit breaker.
func (w *Worker) gate(name string, f func() (io.ReadCloser, string, error)) (io.ReadCloser, string, error) {
g := w.gateFor(name)
if !g.breaker.allow() {
log.Debug(w.runCtx, "Artwork: Skipping agent, circuit breaker open", "agent", name)
return nil, "", errBreakerOpen
}
- // Waiting for the rate-limit permit is counted separately: a slow agent and a throttled one
- // look identical from the drain, but only one of them is the provider's fault.
+ // Timed separately so a throttled agent isn't mistaken for a slow provider.
waitStart := time.Now()
if err := g.limiter.Wait(w.runCtx); err != nil {
return nil, "", err
@@ -63,8 +60,7 @@ func (w *Worker) gate(name string, f func() (io.ReadCloser, string, error)) (io.
return r, path, err
}
-// gateFor lazily creates the per-name gate on first use, each with its own limiter at
-// ArtworkExternalMaxRPS and its own breaker.
+// gateFor lazily creates the per-name gate on first use.
func (w *Worker) gateFor(name string) *extGate {
w.gatesMu.Lock()
defer w.gatesMu.Unlock()
@@ -81,8 +77,8 @@ func (w *Worker) gateFor(name string) *extGate {
return g
}
-// breaker opens after breakerThreshold consecutive external errors and admits a
-// single probe once breakerProbeAfter has elapsed; a success re-closes it.
+// breaker opens after breakerThreshold consecutive errors and admits a single probe once
+// breakerProbeAfter has elapsed; a success re-closes it.
type breaker struct {
mu sync.Mutex
failures int
@@ -107,8 +103,7 @@ func (b *breaker) allow() bool {
func (b *breaker) record(name string, err error) {
b.mu.Lock()
defer b.mu.Unlock()
- // A not-found (from either package) is a definitive answer, not a fault; only real
- // errors trip the breaker. Must stay consistent with isTransientExternal.
+ // A not-found is a definitive answer, not a fault; keep in sync with isTransientExternal.
if err == nil || errors.Is(err, model.ErrNotFound) || errors.Is(err, agents.ErrNotFound) {
if b.failures >= breakerThreshold {
log.Info("Artwork: Circuit breaker closed for agent", "agent", name)
diff --git a/core/artwork/housekeeping.go b/core/artwork/housekeeping.go
index f5da3d0cc..398256d03 100644
--- a/core/artwork/housekeeping.go
+++ b/core/artwork/housekeeping.go
@@ -15,28 +15,23 @@ import (
"github.com/navidrome/navidrome/model"
)
-// staleAbsentAge is how old an absent resolution must be before the recheck job retries it.
const staleAbsentAge = 24 * time.Hour
-// recheckKinds are the item kinds eligible for the periodic recheck jobs (stale-absent and
-// missing-row). Media files are excluded: they resolve embedded-only, at scan or on view.
+// recheckKinds omits media files: they resolve embedded-only, at scan or on view.
var recheckKinds = []model.Kind{
model.KindArtistArtwork, model.KindAlbumArtwork, model.KindPlaylistArtwork, model.KindRadioArtwork,
}
-// hasRecheckPath reports whether a periodic job will revisit this kind, which is what makes
-// settling absent on an exhausted retry budget recoverable rather than permanent.
+// hasRecheckPath reports whether a periodic job will revisit this kind, making an absent settle recoverable.
func hasRecheckPath(prefix string) bool {
kind, ok := model.ParseKind(prefix)
return ok && slices.Contains(recheckKinds, kind)
}
-// artworkEpoch invalidates all resolution state when bumped; bump it in the same change that
-// alters resolution semantics. Deliberately not the server version, which changes every build.
+// artworkEpoch invalidates all resolution state when bumped; bump it whenever resolution semantics change.
const artworkEpoch = 1
-// fingerprint summarizes the inputs that affect artwork resolution outcomes; a
-// change means previously resolved (or absent) state may no longer be correct.
+// fingerprint covers the inputs that affect resolution outcomes; a change invalidates stored state.
func fingerprint() string {
raw := fmt.Sprintf("%s|%s|%s|%s|%t|%t|%d",
conf.Server.CoverArtPriority, conf.Server.ArtistArtPriority, conf.Server.ArtistImageFolder,
@@ -45,8 +40,7 @@ func fingerprint() string {
return hex.EncodeToString(sum[:])
}
-// backfill enqueues artwork resolution for every entity when the config fingerprint changed
-// (or was never stored), artists first so those pages resolve before the larger backlog.
+// backfill enqueues artwork resolution for every entity when the config fingerprint changed.
func backfill(ctx context.Context, ds model.DataStore) (bool, error) {
start := time.Now()
ctx = auth.WithAdminUser(ctx, ds)
@@ -60,7 +54,7 @@ func backfill(ctx context.Context, ds model.DataStore) (bool, error) {
return false, nil
}
- // Artists first: few entities, most external-dependent, so they get queue headstart.
+ // Artists first: few entities, most external-dependent, so they get a queue headstart.
kinds := []struct {
kind model.Kind
fetch func() ([]string, error)
@@ -100,8 +94,6 @@ func enqueueBackfillKind(ctx context.Context, ds model.DataStore, kind model.Kin
return ds.ArtworkQueue(ctx).Enqueue(items...)
}
-// enqueueStaleAbsentAll requeues absent-state entries older than staleAbsentAge, across
-// every artwork-bearing kind, for the periodic recheck job.
func enqueueStaleAbsentAll(ctx context.Context, ds model.DataStore) error {
cutoff := time.Now().Add(-staleAbsentAge)
queue := ds.ArtworkQueue(ctx)
@@ -113,8 +105,7 @@ func enqueueStaleAbsentAll(ctx context.Context, ds model.DataStore) error {
return nil
}
-// enqueueMissingAll requeues entities that have no item_artwork row yet, across every recheck
-// kind: the safety net for entities a scan never enqueued (added between scans, or scanner off).
+// enqueueMissingAll is the safety net for entities a scan never enqueued (added between scans, or scanner off).
func enqueueMissingAll(ctx context.Context, ds model.DataStore) error {
queue := ds.ArtworkQueue(ctx)
for _, kind := range recheckKinds {
@@ -125,8 +116,7 @@ func enqueueMissingAll(ctx context.Context, ds model.DataStore) error {
return nil
}
-// Refresh clears an item's resolved artwork state and re-queues it at Bump priority, so a
-// deliberate refresh (image upload or manual re-resolve) drops the current pick and re-resolves it.
+// Refresh drops an item's resolved artwork state and re-queues it at Bump priority.
func Refresh(ctx context.Context, ds model.DataStore, kind model.Kind, id string) error {
if err := ds.Artwork(ctx).DeleteForItem(kind, id); err != nil {
return fmt.Errorf("clearing artwork state: %w", err)
diff --git a/core/artwork/housekeeping_test.go b/core/artwork/housekeeping_test.go
index f556579b8..cadb6dbfd 100644
--- a/core/artwork/housekeeping_test.go
+++ b/core/artwork/housekeeping_test.go
@@ -213,9 +213,7 @@ var _ = Describe("Housekeeping", func() {
artRepo.ItemData["al-stale"] = model.ItemArtwork{ItemKind: "al", ItemID: "al1", ImageType: model.ImageTypePrimary, Hash: "", AttemptedAt: old}
artRepo.ItemData["pl-stale"] = model.ItemArtwork{ItemKind: "pl", ItemID: "pl1", ImageType: model.ImageTypePrimary, Hash: "", AttemptedAt: old}
artRepo.ItemData["ra-stale"] = model.ItemArtwork{ItemKind: "ra", ItemID: "ra1", ImageType: model.ImageTypePrimary, Hash: "", AttemptedAt: old}
- // Not stale: too recent.
artRepo.ItemData["ar-recent"] = model.ItemArtwork{ItemKind: "ar", ItemID: "ar2", ImageType: model.ImageTypePrimary, Hash: "", AttemptedAt: recent}
- // Not absent: has a resolved hash.
artRepo.ItemData["al-resolved"] = model.ItemArtwork{ItemKind: "al", ItemID: "al2", ImageType: model.ImageTypePrimary, Hash: "somehash", AttemptedAt: old}
err := enqueueStaleAbsentAll(ctx, ds)
@@ -250,7 +248,6 @@ var _ = Describe("Housekeeping", func() {
})
It("enqueues only entities that have no item_artwork row, across all kinds", func() {
- // al1 is already resolved and ar1 already absent: both must be skipped.
artRepo.ItemData["al-resolved"] = model.ItemArtwork{ItemKind: "al", ItemID: "al1", ImageType: model.ImageTypePrimary, Hash: "somehash", AttemptedAt: time.Now()}
artRepo.ItemData["ar-absent"] = model.ItemArtwork{ItemKind: "ar", ItemID: "ar1", ImageType: model.ImageTypePrimary, Hash: "", AttemptedAt: time.Now()}
diff --git a/core/artwork/image_store.go b/core/artwork/image_store.go
index bfbc5a9bc..bac20360f 100644
--- a/core/artwork/image_store.go
+++ b/core/artwork/image_store.go
@@ -16,8 +16,7 @@ import (
"github.com/zeebo/xxh3"
)
-// ImageStore is the content-addressed store for artwork images that have no
-// library file backing them (external downloads, embedded extractions, generated).
+// ImageStore is the content-addressed store for artwork images with no library file backing them.
type ImageStore struct {
root string
}
@@ -26,14 +25,12 @@ func NewImageStore(rootDir string) *ImageStore {
return &ImageStore{root: rootDir}
}
-// GetImageStore roots the store in its own subtree under the data folder, so
-// Prune's recursive sweep never reaches the per-entity upload folders next to it.
+// GetImageStore roots the store in its own subtree so Prune's sweep never reaches the upload folders beside it.
func GetImageStore() *ImageStore {
return NewImageStore(filepath.Join(conf.Server.DataFolder.String(), consts.ArtworkFolder, "store"))
}
-// extForMime is deliberately NOT mime.ExtensionsByType: extensions are baked into
-// content-addressed paths and re-derived on Open, so they must be stable across OSes.
+// extForMime must stay stable across OSes: extensions are baked into stored paths and re-derived on Open.
func extForMime(m string) string {
switch m {
case "image/jpeg":
@@ -56,8 +53,7 @@ func hashImage(r io.Reader) (string, error) {
return fmt.Sprintf("%016x", d.Sum64()), nil
}
-// validHash rejects anything but 16 lowercase hex chars: known-absent states carry "",
-// and malformed persisted hashes must never reach path sharding (slice panics, separators).
+// validHash guards path sharding: a malformed hash would slice-panic or inject path separators.
func validHash(hash string) bool {
if len(hash) != 16 {
return false
@@ -85,7 +81,7 @@ func (s *ImageStore) Write(hash, mimeType string, r io.Reader) error {
if err := os.Chtimes(dst, now, now); err == nil {
return nil
}
- // touch failed (file likely pruned concurrently) — fall through and write it
+ // touch failed (likely pruned concurrently) — fall through and rewrite it
}
if err := os.MkdirAll(filepath.Dir(dst), 0755); err != nil {
return err
@@ -112,8 +108,7 @@ func (s *ImageStore) Open(hash, mimeType string) (io.ReadCloser, error) {
return os.Open(s.path(hash, mimeType))
}
-// Remove deletes the store file unless it is newer than olderThan, in which case
-// an overlapping acquisition may have just touched it and be about to commit its row.
+// Remove skips files newer than olderThan: an overlapping acquisition may not have committed its row yet.
func (s *ImageStore) Remove(hash, mimeType string, olderThan time.Time) error {
if !validHash(hash) {
return fmt.Errorf("imagestore: invalid hash %q", hash)
@@ -136,9 +131,8 @@ func (s *ImageStore) Remove(hash, mimeType string, olderThan time.Time) error {
return err
}
-// Sweep removes store files not accepted by keep. Files modified after cutoff
-// (including temp files) are always kept: their acquisition row may not be committed yet.
-// The walk is cancellable: it runs under the worker's prune lock, which shutdown waits on.
+// Sweep removes store files not accepted by keep. Files modified after cutoff are always
+// kept: their acquisition row may not be committed yet.
func (s *ImageStore) Sweep(ctx context.Context, cutoff time.Time, keep func(hash, ext string) bool) (int, error) {
removed := 0
err := filepath.WalkDir(s.root, func(path string, d fs.DirEntry, err error) error {
diff --git a/core/artwork/playlist_cover.go b/core/artwork/playlist_cover.go
index 43047a6c6..68f4c4e10 100644
--- a/core/artwork/playlist_cover.go
+++ b/core/artwork/playlist_cover.go
@@ -18,9 +18,7 @@ import (
const tileSize = 600
-// findPlaylistSidecarPath scans the directory of the playlist file for a sidecar
-// image file with the same base name (case-insensitive). Returns empty string if
-// no matching image is found or if plsPath is empty.
+// findPlaylistSidecarPath finds an image beside plsPath with the same base name (case-insensitive).
func findPlaylistSidecarPath(ctx context.Context, plsPath string) string {
if plsPath == "" {
return ""
@@ -59,25 +57,21 @@ func rect(pos int) image.Rectangle {
return r
}
-// fillCenter crops the source image from the center and scales it to fill dstW x dstH exactly,
-// equivalent to imaging.Fill with Center anchor.
+// fillCenter center-crops src and scales it to fill dstW x dstH exactly.
func fillCenter(src image.Image, dstW, dstH int) image.Image {
srcBounds := src.Bounds()
srcW := srcBounds.Dx()
srcH := srcBounds.Dy()
- // Calculate crop rectangle (center crop to match destination aspect ratio)
srcAspect := float64(srcW) / float64(srcH)
dstAspect := float64(dstW) / float64(dstH)
var cropRect image.Rectangle
if srcAspect > dstAspect {
- // Source is wider — crop horizontally
cropW := int(float64(srcH) * dstAspect)
cropX := (srcW - cropW) / 2
cropRect = image.Rect(srcBounds.Min.X+cropX, srcBounds.Min.Y, srcBounds.Min.X+cropX+cropW, srcBounds.Max.Y)
} else {
- // Source is taller — crop vertically
cropH := int(float64(srcW) / dstAspect)
cropY := (srcH - cropH) / 2
cropRect = image.Rect(srcBounds.Min.X, srcBounds.Min.Y+cropY, srcBounds.Max.X, srcBounds.Min.Y+cropY+cropH)
@@ -88,10 +82,7 @@ func fillCenter(src image.Image, dstW, dstH int) image.Image {
return dst
}
-// decodeTile and assembleTiles mirror playlistArtworkReader's createTile/
-// createTiledImage, reusing the same rect/fillCenter cropping helpers.
-// decodeTile runs on every sampled album's resolved bytes before the processor's
-// own maxImageBytes/maxImagePixels guards apply, so it enforces them itself too.
+// decodeTile runs before the processor's size guards apply, so it enforces the caps itself.
func decodeTile(r io.ReadCloser) (image.Image, error) {
data, err := readCapped(r)
if err != nil {
diff --git a/core/artwork/processor.go b/core/artwork/processor.go
index af589991a..d6d078fa4 100644
--- a/core/artwork/processor.go
+++ b/core/artwork/processor.go
@@ -18,14 +18,13 @@ import (
xdraw "golang.org/x/image/draw"
)
-// outcome tells the worker what to do with the queue row: found/absent
-// delete it, failed reschedules it via MarkFailed.
+// outcome tells the worker whether to delete the queue row (found/absent) or reschedule it.
type outcome int
const (
outcomeFound outcome = iota
// outcomeFoundStale: state was written and is served, but a higher-priority external
- // step failed, so the row must retry (via MarkFailed) to give that source another chance.
+ // source failed, so the row must retry to give it another chance.
outcomeFoundStale
outcomeAbsent
outcomeFailed
@@ -47,12 +46,12 @@ func (o outcome) String() string {
// thumbnailSize is the max dimension fed to blurhash.
const thumbnailSize = 128
-// maxImageBytes caps a resolved image read: a user-editable ExternalImageURL could
-// point at an arbitrarily large endpoint, and 20MB is generous for any real cover.
+// maxImageBytes caps a resolved image read: a user-editable ExternalImageURL could point at
+// an arbitrarily large endpoint.
const maxImageBytes = 20 << 20
-// maxImagePixels caps declared dimensions: a tiny compressed file can declare a
-// huge canvas that image.Decode would expand into gigabytes (decompression bomb).
+// maxImagePixels guards against decompression bombs: a tiny file can declare a canvas that
+// image.Decode would expand into gigabytes.
const maxImagePixels = 64 << 20
// acquired is what a successful acquire persisted, handed back so the caller can warm the resize
@@ -63,8 +62,7 @@ type acquired struct {
data []byte
}
-// processor turns one queue item into stored artwork. It owns acquisition only — settling the
-// queue row afterwards is the Worker's job. pruneLock is nil in tests, where nothing prunes.
+// processor turns one queue item into stored artwork; settling the queue row is the Worker's job.
type processor struct {
ds model.DataStore
store *ImageStore
@@ -76,8 +74,6 @@ type processor struct {
// blurhash it, place its bytes, and persist the resulting state.
func (p *processor) acquire(ctx context.Context, item model.ArtworkQueueItem) (out outcome, got *acquired) {
repo := p.ds.Artwork(ctx)
- // Timed on every exit, failures included: an item that is slow is usually one that failed
- // slowly, and the outcome alone doesn't say whether the cost was the network or the decode.
start := time.Now()
defer func() {
log.Debug(ctx, "Artwork: Acquisition finished", "kind", item.ItemKind, "id", item.ItemID,
@@ -91,8 +87,7 @@ func (p *processor) acquire(ctx context.Context, item model.ArtworkQueueItem) (o
}
if res.reader == nil {
if res.extError || res.localError {
- // A source errored/timed out rather than answering "no image": never settle on
- // absent, keep serving old state.
+ // A fault is not a definitive "no image": never settle absent, keep serving old state.
log.Debug(ctx, "Artwork: No image, but a source faulted; keeping previous state",
"kind", item.ItemKind, "id", item.ItemID, "extError", res.extError, "localError", res.localError)
return outcomeFailed, nil
@@ -101,7 +96,6 @@ func (p *processor) acquire(ctx context.Context, item model.ArtworkQueueItem) (o
}
defer res.reader.Close()
- // Times the download too: res.reader is the provider's response body for external sources.
readStart := time.Now()
data, err := readCapped(res.reader)
if err != nil {
@@ -123,7 +117,6 @@ func (p *processor) acquire(ctx context.Context, item model.ArtworkQueueItem) (o
art, err := repo.GetImage(hash)
switch {
case err == nil:
- // Dedup hit: identical bytes already known, reuse dims/mime/blurhash.
log.Debug(ctx, "Artwork: Reusing a known image, skipping decode", "kind", item.ItemKind,
"id", item.ItemID, "hash", hash)
case errors.Is(err, model.ErrNotFound):
@@ -148,7 +141,6 @@ func (p *processor) acquire(ctx context.Context, item model.ArtworkQueueItem) (o
}
got = &acquired{ia: ia, mime: art.Mime, data: data}
if res.extError {
- // Served, but a higher-priority external source errored: the pick may improve on retry.
log.Debug(ctx, "Artwork: Serving a lower-priority source after an external failure",
"kind", item.ItemKind, "id", item.ItemID, "source", res.source)
return outcomeFoundStale, got
@@ -156,9 +148,8 @@ func (p *processor) acquire(ctx context.Context, item model.ArtworkQueueItem) (o
return outcomeFound, got
}
-// persist places the bytes and commits the rows referencing them. Only this window excludes
-// Prune, which reclaims store files no row points at; resolution stays outside so a slow fetch
-// cannot hold prune off.
+// persist places the bytes and commits the rows referencing them, excluding Prune for that
+// window only so a slow resolution can never hold it off.
func (p *processor) persist(repo model.ArtworkRepository, item model.ArtworkQueueItem,
art *model.Artwork, res resolution, data []byte,
) (*model.ItemArtwork, error) {
@@ -183,14 +174,14 @@ func (p *processor) persist(repo model.ArtworkRepository, item model.ArtworkQueu
RefMtime: refMtime,
AttemptedAt: time.Now(),
}
- // PutItemArtwork stamps UpdatedAt on ia, so what it holds now matches the persisted row.
+ // PutItemArtwork stamps UpdatedAt on ia, so the returned struct matches the persisted row.
if err := repo.PutItemArtwork(ia); err != nil {
return nil, fmt.Errorf("persisting item artwork state: %w", err)
}
return ia, nil
}
-// writeAbsent records a known-absent state: every local/external source answered definitively "no".
+// writeAbsent records a known-absent state: every source answered definitively "no".
func writeAbsent(ctx context.Context, repo model.ArtworkRepository, item model.ArtworkQueueItem) outcome {
err := repo.PutItemArtwork(&model.ItemArtwork{
ItemKind: item.ItemKind,
@@ -207,7 +198,6 @@ func writeAbsent(ctx context.Context, repo model.ArtworkRepository, item model.A
return outcomeAbsent
}
-// readCapped reads r, rejecting anything over maxImageBytes.
func readCapped(r io.Reader) ([]byte, error) {
data, err := io.ReadAll(io.LimitReader(r, maxImageBytes+1))
if err != nil {
@@ -219,15 +209,13 @@ func readCapped(r io.Reader) ([]byte, error) {
return data, nil
}
-// decodeCapped rejects declared dimensions over maxImagePixels BEFORE the
-// full-decode allocation, then decodes.
+// decodeCapped rejects declared dimensions over maxImagePixels before the full-decode allocation.
func decodeCapped(data []byte) (image.Image, string, error) {
cfg, format, err := image.DecodeConfig(bytes.NewReader(data))
if err != nil {
return nil, "", fmt.Errorf("decode image config: %w", err)
}
- // Compared by division so the cap holds for any dimensions a decoder might report, without
- // depending on a multiplication staying inside int64.
+ // Compared by division so the cap cannot be defeated by an int64 overflow.
if cfg.Width <= 0 || cfg.Height <= 0 || cfg.Width > maxImagePixels/cfg.Height {
return nil, "", fmt.Errorf("image dimensions %dx%d exceed pixel cap %d", cfg.Width, cfg.Height, maxImagePixels)
}
@@ -238,8 +226,7 @@ func decodeCapped(data []byte) (image.Image, string, error) {
return img, format, nil
}
-// decodeArtwork builds a new Artwork row from raw bytes: dimensions, mime and a
-// blurhash computed from a downscaled thumbnail.
+// decodeArtwork builds a new Artwork row from raw bytes: dimensions, mime and blurhash.
func decodeArtwork(ctx context.Context, hash string, data []byte) (*model.Artwork, error) {
img, format, err := decodeCapped(data)
if err != nil {
@@ -263,8 +250,7 @@ func decodeArtwork(ctx context.Context, hash string, data []byte) (*model.Artwor
}, nil
}
-// makeThumbnail downscales img to fit within maxSize on its longest side.
-// Images within bounds are returned as-is (no upscaling).
+// makeThumbnail downscales img to fit within maxSize on its longest side; it never upscales.
func makeThumbnail(img image.Image, maxSize int) image.Image {
b := img.Bounds()
w, h := b.Dx(), b.Dy()
@@ -277,14 +263,14 @@ func makeThumbnail(img image.Image, maxSize int) image.Image {
return dst
}
-// isFileBacked reports whether a resolution's bytes already live in a library/upload
-// file, so the acquisition must not duplicate them into the content-addressed store.
+// isFileBacked reports whether the bytes already live in a library/upload file, so the
+// content-addressed store must not duplicate them.
func isFileBacked(source string) bool {
return source == "folder" || source == "upload"
}
-// placeBytes reports the item's backing-file provenance (folder/upload: image, embedded: audio,
-// external/generated: none) and writes the bytes into the store for the non-file-backed sources.
+// placeBytes reports the item's backing-file provenance and writes the bytes into the store
+// for the sources that have none.
func placeBytes(store *ImageStore, art *model.Artwork, res resolution, data []byte) (sourcePath string, refMtime int64, err error) {
if isFileBacked(res.source) {
return res.sourcePath, res.refMtime, nil
@@ -295,8 +281,7 @@ func placeBytes(store *ImageStore, art *model.Artwork, res resolution, data []by
return sourcePath, refMtime, store.Write(art.Hash, art.Mime, bytes.NewReader(data))
}
-// mimeForFormat maps an image.Decode format name to its MIME type; extForMime
-// in image_store.go performs the inverse for content-addressed file paths.
+// mimeForFormat maps an image.Decode format name to its MIME type; extForMime is the inverse.
func mimeForFormat(format string) string {
switch format {
case "jpeg":
diff --git a/core/artwork/processor_test.go b/core/artwork/processor_test.go
index fcdd9de8a..14f516d58 100644
--- a/core/artwork/processor_test.go
+++ b/core/artwork/processor_test.go
@@ -22,8 +22,7 @@ import (
. "github.com/onsi/gomega"
)
-// pngHeaderWithDims builds just a PNG signature + IHDR chunk declaring w×h. DecodeConfig
-// reads the header without touching pixel data, so the body can be omitted entirely.
+// DecodeConfig reads only the header, so the pixel data can be omitted entirely.
func pngHeaderWithDims(w, h uint32) []byte {
ihdr := make([]byte, 13)
binary.BigEndian.PutUint32(ihdr[0:], w)
@@ -124,8 +123,8 @@ var _ = Describe("processor.acquire", func() {
Expect(lock.held()).To(BeFalse(), "the window must close before acquire returns")
})
- // Resolution can reach the network under its own timeout, so holding the lock across it
- // would let one slow provider block prune, and every drain queued behind prune's writer.
+ // Resolution can reach the network, so holding the lock across it would let one slow
+ // provider block prune, and every drain queued behind prune's writer.
It("never takes it while only resolving", func() {
folderRepo.result = nil
ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{
@@ -179,8 +178,7 @@ var _ = Describe("processor.acquire", func() {
It("failed-on-unreadable-local: a listed cover that will not open never records absent", func() {
conf.Server.CoverArtPriority = "cover.jpg"
- // A healthy library whose folder listing names a cover the FS will not hand over —
- // what a stale mount looks like from here.
+ // A folder listing that names a cover the FS will not hand over: a stale mount.
libRoot := GinkgoT().TempDir()
Expect(os.MkdirAll(filepath.Join(libRoot, "an-album"), 0o755)).To(Succeed())
libRepo.SetData(model.Libraries{{ID: 0, Path: testFileLibPath(libRoot)}})
@@ -196,12 +194,10 @@ var _ = Describe("processor.acquire", func() {
Expect(err).To(MatchError(model.ErrNotFound), "an I/O fault must not be recorded as absent")
})
- // An upload outranks every other source, so an unreadable one must neither settle absent
- // nor let a lower-priority image take its place.
+ // An upload outranks every source, so an unreadable one must not let a lower one take over.
It("failed-on-unreadable-upload: an upload that will not open never records absent", func() {
if runtime.GOOS == "windows" {
- // os.Chmod cannot revoke read access there, so the file would open and the spec
- // would pass on the decode error instead of the unreadable source.
+ // The file would still open, so the spec would pass on the decode error instead.
Skip("chmod does not restrict read access on Windows")
}
radioRepo := tests.CreateMockedRadioRepo()
@@ -277,7 +273,6 @@ var _ = Describe("processor.acquire", func() {
Expect(ia.Source).To(Equal("external:deezerFake"))
Expect(ia.Hash).ToNot(BeEmpty())
- // External art is content-addressed into the store, not file-backed.
art, err := artRepo.GetImage(ia.Hash)
Expect(err).ToNot(HaveOccurred())
rc, err := store.Open(ia.Hash, art.Mime)
@@ -300,8 +295,7 @@ var _ = Describe("processor.acquire", func() {
ia1, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "al5", model.ImageTypePrimary)
Expect(err).ToNot(HaveOccurred())
- // Poison the stored blurhash: if the second item re-decodes instead of
- // deduping on hash, this sentinel gets overwritten by a real computed value.
+ // A re-decode instead of a hash dedup would overwrite this sentinel.
poisoned := artRepo.Data[ia1.Hash]
poisoned.BlurHash = "SENTINEL"
artRepo.Data[ia1.Hash] = poisoned
@@ -318,8 +312,7 @@ var _ = Describe("processor.acquire", func() {
})
It("two items, two files, identical bytes: each item keeps its own provenance; the shared artwork row is written once", func() {
- // Two distinct library files with byte-identical content resolve to the same
- // hash. Provenance is per-item, so neither file's path may overwrite the other.
+ // Byte-identical files share one hash, but provenance is per item.
libRoot := GinkgoT().TempDir()
imgBytes, err := os.ReadFile(filepath.Join(repoRoot, "tests/fixtures/artist/an-album/cover.jpg"))
Expect(err).ToNot(HaveOccurred())
@@ -345,7 +338,7 @@ var _ = Describe("processor.acquire", func() {
Expect(filepath.ToSlash(iaA.SourcePath)).To(HaveSuffix("album-a/cover.jpg"))
Expect(iaA.RefMtime).To(Equal(time.Unix(1000, 0).UnixNano()))
- // Poison the shared row's blurhash: the second item must dedup on hash, not re-decode.
+ // A re-decode instead of a hash dedup would overwrite this sentinel.
poisoned := artRepo.Data[iaA.Hash]
poisoned.BlurHash = "SENTINEL"
artRepo.Data[iaA.Hash] = poisoned
@@ -359,13 +352,11 @@ var _ = Describe("processor.acquire", func() {
Expect(filepath.ToSlash(iaB.SourcePath)).To(HaveSuffix("album-b/cover.jpg"))
Expect(iaB.RefMtime).To(Equal(time.Unix(2000, 0).UnixNano()))
- // The first item's provenance survives the second item processing identical bytes.
iaAafter, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "alA", model.ImageTypePrimary)
Expect(err).ToNot(HaveOccurred())
Expect(filepath.ToSlash(iaAafter.SourcePath)).To(HaveSuffix("album-a/cover.jpg"))
Expect(iaAafter.RefMtime).To(Equal(time.Unix(1000, 0).UnixNano()))
- // One shared artwork row, and dedup preserved it untouched.
Expect(artRepo.Data).To(HaveLen(1))
reused, err := artRepo.GetImage(iaA.Hash)
Expect(err).ToNot(HaveOccurred())
@@ -411,9 +402,8 @@ var _ = Describe("processor.acquire", func() {
Expect(err).To(MatchError(model.ErrNotFound))
})
- // The cap is compared by division, so it cannot be slipped by dimensions whose product
- // would overflow. No supported format can declare such dimensions today — image/png caps
- // them at 2^30-1 and the rest are 16-bit — so this pins the arithmetic, not a live hole.
+ // The cap is compared by division so an overflowing product cannot slip past it; no supported
+ // format can declare these dimensions, so this pins the arithmetic, not a live hole.
DescribeTable("rejects out-of-range declared dimensions",
func(w, h uint32) {
_, err := decodeArtwork(ctx, "bomb", pngHeaderWithDims(w, h))
@@ -451,8 +441,6 @@ var _ = Describe("processor.acquire", func() {
})
})
-// countingLocker stands in for the worker's prune read-lock, recording how often and how long
-// acquire holds it.
type countingLocker struct {
locks int
unlocks int
diff --git a/core/artwork/prune.go b/core/artwork/prune.go
index 88ef0177a..2146e3778 100644
--- a/core/artwork/prune.go
+++ b/core/artwork/prune.go
@@ -33,8 +33,7 @@ func prune(ctx context.Context, ds model.DataStore, store *ImageStore) error {
log.Info(ctx, "Artwork: Purged dangling queue rows", "count", queuePurged)
}
- // One grace cutoff for both the DB orphan check and the file sweep: files younger
- // than the window may belong to acquisitions whose rows aren't committed yet.
+ // Files younger than the grace window may belong to acquisitions whose rows aren't committed yet.
cutoff := time.Now().Add(-pruneMinAge)
candidates, err := repo.GetOrphanHashes(cutoff)
if err != nil {
@@ -48,8 +47,7 @@ func prune(ctx context.Context, ds model.DataStore, store *ImageStore) error {
if err := repo.DeleteOrphans(cutoff, candidates); err != nil {
return err
}
- // DeleteOrphans may spare candidates reacquired since the snapshot; only remove files
- // for rows actually gone (absent from the post-delete re-read).
+ // DeleteOrphans may spare candidates reacquired since the snapshot; only drop files whose rows are gone.
survivors, err := repo.GetImages(candidates)
if err != nil {
return err
@@ -59,8 +57,6 @@ func prune(ctx context.Context, ds model.DataStore, store *ImageStore) error {
if _, ok := survivors[h]; ok {
continue
}
- // A spared fresh file is at worst a stray a later sweep reclaims;
- // Worker.RunPrune serializes prune against in-flight acquisitions.
if err := store.Remove(h, arts[h].Mime, cutoff); err != nil {
log.Warn(ctx, "Artwork: Could not remove orphan file", "hash", h, err)
}
diff --git a/core/artwork/prune_test.go b/core/artwork/prune_test.go
index 4afdc6ba9..348c7d15e 100644
--- a/core/artwork/prune_test.go
+++ b/core/artwork/prune_test.go
@@ -107,7 +107,6 @@ var _ = Describe("Prune", func() {
Expect(awRepo.PutImage(&model.Artwork{Hash: h, Mime: "image/jpeg"})).To(Succeed())
ageArtwork(h, time.Now().Add(-2*time.Hour))
awRepo.OrphanHashes = []string{h}
- // Reacquisition: an item now references the hash the snapshot flagged as orphan.
Expect(awRepo.PutItemArtwork(&model.ItemArtwork{ItemKind: "al", ItemID: "a1",
ImageType: model.ImageTypePrimary, Hash: h, Source: "folder"})).To(Succeed())
@@ -144,8 +143,7 @@ var _ = Describe("Prune", func() {
Expect(awRepo.PutImage(&model.Artwork{Hash: h, Mime: "image/jpeg"})).To(Succeed())
ageArtwork(h, time.Now().Add(-2*time.Hour))
awRepo.OrphanHashes = []string{h}
- // The row is legitimately orphaned, but a concurrent acquisition just touched the
- // file's mtime (duplicate Write) and is about to commit a row referencing it.
+ // The row is orphaned, but a concurrent acquisition just touched the file's mtime.
Expect(prune(context.Background(), ds, store)).To(Succeed())
@@ -213,24 +211,19 @@ var _ = Describe("Prune", func() {
Expect(os.Chmod(shardDir, 0500)).To(Succeed())
DeferCleanup(func() { _ = os.Chmod(shardDir, 0755) })
- // hb (blocked) is processed first: if store.Remove's failure aborted the loop
- // instead of warning and continuing, hg would never be reached.
+ // hb is processed first: aborting on its Remove failure would leave hg unreached.
awRepo.OrphanHashes = []string{hb, hg}
- // Prune still errors: Sweep independently revisits hb's leftover file and,
- // unlike the loop below, has no warn-and-continue fallback of its own.
+ // Prune still errors: Sweep revisits hb's leftover file with no warn-and-continue of its own.
err := prune(context.Background(), ds, store)
Expect(err).To(HaveOccurred())
- // hg: reached and fully pruned despite being queued after the failing hb -
- // proof the loop didn't return/break on the first Remove error.
_, err = awRepo.GetImage(hg)
Expect(err).To(MatchError(model.ErrNotFound))
_, err = store.Open(hg, "image/jpeg")
Expect(os.IsNotExist(err)).To(BeTrue())
- // hb: row still purged (DeleteOrphans doesn't depend on file removal), but the
- // file itself survives since store.Remove failed and only warned.
+ // The row purge does not depend on file removal, so only the file survives.
_, err = awRepo.GetImage(hb)
Expect(err).To(MatchError(model.ErrNotFound))
rc, err := store.Open(hb, "image/jpeg")
diff --git a/core/artwork/resize.go b/core/artwork/resize.go
index 58c8a2796..b64ad1adb 100644
--- a/core/artwork/resize.go
+++ b/core/artwork/resize.go
@@ -21,12 +21,8 @@ import (
func init() {
conf.AddHook(func() {
- // gen2brain/webp selects native (purego/libwebp) vs WASM in its own
- // package init() and exposes the result only via webp.Dynamic(); there is
- // no runtime way to switch back. On 32-bit ARM/x86 the purego callback path
- // crashes (issue #5597), so those builds must be compiled with the
- // "nodynamic" tag (see Dockerfile), which makes webp.Dynamic() report an
- // error here and forces the safe WASM path.
+ // gen2brain/webp picks native vs WASM in its own init(), with no way to switch at
+ // runtime: 32-bit builds need the "nodynamic" tag (see Dockerfile) to force WASM.
if err := webp.Dynamic(); err != nil {
log.Debug("Artwork: Using WASM WebP encoder/decoder", "reason", err)
} else {
@@ -49,7 +45,6 @@ func resizeImageData(ctx context.Context, ffm ffmpeg.FFmpeg, data []byte, size i
log.Trace(ctx, "Artwork: Resized image", "bytes", len(data), "size", size, "square", square,
"elapsed", time.Since(start))
}()
- // Preserve animation for animated images
if isAnimatedGIF(data) {
if ffm.IsAvailable() {
// Animated GIF: convert to animated WebP via ffmpeg (with optional resize)
@@ -67,11 +62,8 @@ func resizeImageData(ctx context.Context, ffm ffmpeg.FFmpeg, data []byte, size i
return resizeStaticImage(data, size, square)
}
-// toFastScaleType converts images whose concrete type has no optimized scaler
-// in x/image/draw (e.g. *image.NYCbCrA from WebP, *image.Paletted from indexed
-// PNGs) into *image.RGBA, which has a fast path. Without this, CatmullRom.Scale
-// falls back to a generic per-pixel At()/RGBA() loop that is several times
-// slower. Fast-path types are returned unchanged.
+// toFastScaleType converts types x/image/draw has no optimized scaler for (e.g. *image.NYCbCrA,
+// *image.Paletted) to *image.RGBA, avoiding CatmullRom.Scale's generic per-pixel fallback.
func toFastScaleType(img image.Image) image.Image {
switch img.(type) {
case *image.RGBA, *image.NRGBA, *image.Gray, *image.YCbCr:
diff --git a/core/artwork/resolve.go b/core/artwork/resolve.go
index 5826c913c..e50532d7b 100644
--- a/core/artwork/resolve.go
+++ b/core/artwork/resolve.go
@@ -24,21 +24,19 @@ type resolution struct {
source string // model.ItemArtwork.Source value: "folder", "embedded", "external", "upload", "generated"
sourcePath string // backing library/upload file (folder/upload: the image; embedded: the audio file); "" otherwise
refMtime int64 // sourcePath mtime (unix-nanoseconds) at resolution; 0 when no sourcePath
- // external source errored/timed out. With no reader: forces failed (never absent).
- // On a hit: a higher-priority external step failed—serve this, but retry later.
+ // external source errored/timed out. With no reader it forces failed (never absent);
+ // on a hit a higher-priority external step failed—serve this, but retry later.
extError bool
- // a local source that should have been readable wasn't (stale mount, permissions).
- // With no reader: forces failed, so a transient I/O fault never records absent.
+ // a local source that should have been readable wasn't. With no reader it forces failed,
+ // so a transient I/O fault never records absent.
localError bool
}
// chainState carries what a priority walk has seen so far. A hit takes extErr with it so a
-// transient external failure still schedules a retry, but not localErr: a local candidate a
-// later source recovered from is re-listed by the scanner when it actually changes.
+// transient external failure still retries; localErr is dropped, as the scanner re-lists changes.
type chainState struct{ extErr, localErr bool }
-// try reports a hit, stamping the accumulated external failure onto it, and otherwise records
-// the miss. Folding both into one call is what keeps the OR from being forgotten at a new site.
+// try stamps the accumulated external failure onto a hit, and records the miss otherwise.
func (c *chainState) try(res resolution, ok bool) (resolution, bool) {
if ok {
res.extError = c.extErr
@@ -53,15 +51,13 @@ func (c *chainState) exhausted() resolution {
return resolution{extError: c.extErr, localError: c.localErr}
}
-// externalSource is the ability to reach the network: which agents to ask, and the per-agent
-// rate limiter and circuit breaker to ask them through.
+// externalSource holds the agents to ask and the rate limiter/circuit breaker to ask them through.
type externalSource struct {
agents *agents.Agents
gate gateFunc
}
-// resolver walks a kind's priority chain and returns the first hit. A nil ext means local-only,
-// so "may this resolution go external" is one fact rather than two that could disagree.
+// resolver walks a kind's priority chain and returns the first hit; a nil ext means local-only.
type resolver struct {
ds model.DataStore
ffmpeg ffmpeg.FFmpeg
@@ -75,8 +71,8 @@ func newResolver(ds model.DataStore, ag *agents.Agents, ffm ffmpeg.FFmpeg, gate
return &resolver{ds: ds, ffmpeg: ffm, ext: &externalSource{agents: ag, gate: gate}}
}
-// newLocalResolver offers no parameter through which an external source could be supplied, so
-// a request can neither reach the network nor sample album art for the worker-built grid.
+// newLocalResolver builds a resolver that can neither reach the network nor sample album art
+// for the worker-built grid.
func newLocalResolver(ds model.DataStore, ffm ffmpeg.FFmpeg) *resolver {
return &resolver{ds: ds, ffmpeg: ffm}
}
@@ -115,8 +111,7 @@ func (r *resolver) fetchExternalArtist(ctx context.Context, ar model.Artist) (io
return fetchArtistImage(ctx, r.ext.agents, r.ext.gate, ar)
}
-// resolveAlbum ports the folder/embedded/external selection from
-// reader_album.go, walking conf.Server.CoverArtPriority.
+// resolveAlbum walks conf.Server.CoverArtPriority over the folder, embedded and external sources.
func (r *resolver) resolveAlbum(ctx context.Context, albumID string) (resolution, error) {
al, err := r.ds.Album(ctx).Get(albumID)
if err != nil {
@@ -154,8 +149,7 @@ func (r *resolver) resolveAlbum(ctx context.Context, albumID string) (resolution
return chain.exhausted(), nil
}
-// resolveArtist ports the upload/folder/external selection from
-// reader_artist.go: upload always wins, then conf.Server.ArtistArtPriority.
+// resolveArtist tries the uploaded image first, then walks conf.Server.ArtistArtPriority.
func (r *resolver) resolveArtist(ctx context.Context, artistID string) (resolution, error) {
ar, err := r.ds.Artist(ctx).Get(artistID)
if err != nil {
@@ -171,7 +165,7 @@ func (r *resolver) resolveArtist(ctx context.Context, artistID string) (resoluti
return upload, nil
}
- // Only consider albums where the artist is the sole album artist, same as reader_artist.go.
+ // Only consider albums where the artist is the sole album artist.
als, err := r.ds.Album(ctx).GetAll(model.QueryOptions{
Filters: squirrel.And{
squirrel.Eq{"album_artist_id": artistID},
@@ -230,8 +224,7 @@ func (r *resolver) resolveArtist(ctx context.Context, artistID string) (resoluti
return chain.exhausted(), nil
}
-// resolvePlaylist ports reader_playlist.go's chain: uploaded image, sidecar,
-// ExternalImageURL, then the generated 2x2 grid sourced through resolveAlbum.
+// resolvePlaylist tries the uploaded image, the sidecar and ExternalImageURL, then a generated grid.
func (r *resolver) resolvePlaylist(ctx context.Context, playlistID string) (resolution, error) {
pl, err := r.ds.Playlist(ctx).Get(playlistID)
if err != nil {
@@ -252,8 +245,7 @@ func (r *resolver) resolvePlaylist(ctx context.Context, playlistID string) (reso
return res, nil
}
}
- // A local ExternalImageURL is a file-backed reference: serve it in place (staleness-checked,
- // and available even on the request path). Only http(s) URLs need the gated remote fetch.
+ // A local ExternalImageURL is file-backed and served in place; only http(s) needs the gated fetch.
localImg, remoteImg := classifyPlaylistImage(pl.ExternalImageURL)
if localImg != "" {
res, ok := resolveLocalFile(localImg, "folder")
@@ -265,8 +257,7 @@ func (r *resolver) resolvePlaylist(ctx context.Context, playlistID string) (reso
}
}
if r.ext == nil {
- // The remote ExternalImageURL fetch and the 2x2 grid are worker-only; a request must
- // not fetch remotely nor sample album art synchronously.
+ // The remote fetch and the generated grid are worker-only; a request must do neither.
return resolution{}, nil
}
if remoteImg != nil && conf.Server.EnableM3UExternalAlbumArt {
@@ -309,14 +300,13 @@ func (r *resolver) resolvePlaylist(ctx context.Context, playlistID string) (reso
}
}
if len(tiles) == 0 {
- // A tile-level failure must never resolve as a clean absent: propagate
- // internal errors, and force extError for external ones.
+ // A tile-level failure must never resolve as a clean absent.
if tileErr != nil {
return resolution{}, fmt.Errorf("resolvePlaylist: sampled album art failed: %w", tileErr)
}
return resolution{extError: extErr}, nil
}
- // Grow to 4 tiles by repeating what we have, mirroring reader_playlist.go's loadTiles.
+ // Grow to 4 tiles by repeating what we have.
switch len(tiles) {
case 2:
tiles = append(tiles, tiles[1], tiles[0])
@@ -330,7 +320,7 @@ func (r *resolver) resolvePlaylist(ctx context.Context, playlistID string) (reso
return resolution{reader: grid, source: "generated", extError: extErr}, nil
}
-// resolveRadio ports reader_radio.go: only an uploaded image, no fallback.
+// resolveRadio serves only an uploaded image; there is no fallback.
func (r *resolver) resolveRadio(ctx context.Context, radioID string) (resolution, error) {
radio, err := r.ds.Radio(ctx).Get(radioID)
if err != nil {
@@ -340,8 +330,8 @@ func (r *resolver) resolveRadio(ctx context.Context, radioID string) (resolution
return res, nil
}
-// resolveMediaFile resolves a track's own embedded art only; there is no folder or
-// external fallback, so disabled/missing cover art is a definitive absent.
+// resolveMediaFile resolves a track's own embedded art only, so disabled or missing cover art
+// is a definitive absent.
func (r *resolver) resolveMediaFile(ctx context.Context, id string) (resolution, error) {
mf, err := r.ds.MediaFile(ctx).Get(id)
if err != nil {
@@ -358,9 +348,8 @@ func (r *resolver) resolveMediaFile(ctx context.Context, id string) (resolution,
return res, nil
}
-// resolveExternalStep runs a single external sourceFunc through the named gate; used by
-// the playlist ExternalImageURL step. ok reports a hit; extErr reports a non-not-found
-// error (a not-found is a definitive "no", not a failure).
+// resolveExternalStep runs a single external sourceFunc through the named gate. extErr excludes
+// a not-found, which is a definitive "no" rather than a failure.
func resolveExternalStep(gate gateFunc, name string, sf sourceFunc) (res resolution, ok bool, extErr bool) {
r, path, err := gate(name, sf)
if r != nil {
@@ -369,8 +358,8 @@ func resolveExternalStep(gate gateFunc, name string, sf sourceFunc) (res resolut
return resolution{}, false, err != nil && !errors.Is(err, model.ErrNotFound)
}
-// classifyPlaylistImage splits a playlist ExternalImageURL into a local filesystem path
-// (served file-backed) or a remote http(s) URL (fetched and stored); at most one is set.
+// classifyPlaylistImage splits a playlist ExternalImageURL into a local filesystem path or a
+// remote http(s) URL; at most one is set.
func classifyPlaylistImage(imageURL string) (localPath string, remote *url.URL) {
if imageURL == "" {
return "", nil
@@ -429,8 +418,8 @@ func resolveArtistFolderPattern(ctx context.Context, lib libraryView, artistFold
return resolution{reader: r, source: "folder", sourcePath: path, refMtime: mtimeOf(path)}, true
}
-// resolveLocalFile opens an absolute path directly (uploads, image-folder). A missing path is
-// "no source"; any other open failure says nothing about whether the image exists.
+// resolveLocalFile opens an absolute path directly. A missing path is "no source"; any other
+// open failure says nothing about whether the image exists.
func resolveLocalFile(path, source string) (resolution, bool) {
if path == "" {
return resolution{}, false
@@ -450,8 +439,7 @@ func mtimeOf(path string) int64 {
return info.ModTime().UnixNano()
}
-// mtimeViaFS stats through the library FS instead of a joined absolute path,
-// since library roots in tests may not be real OS paths (e.g. testfile://).
+// mtimeViaFS stats through the library FS, since library roots in tests may not be real OS paths.
func mtimeViaFS(fsys fs.FS, name string) int64 {
if fsys == nil || name == "" {
return 0
diff --git a/core/artwork/resolve_test.go b/core/artwork/resolve_test.go
index 1459d7da8..fc1a6a3ff 100644
--- a/core/artwork/resolve_test.go
+++ b/core/artwork/resolve_test.go
@@ -387,8 +387,7 @@ var _ = Describe("resolveItem", func() {
Expect(img.Bounds().Dx()).To(Equal(expectedSize))
Expect(img.Bounds().Dy()).To(Equal(expectedSize))
},
- // tileSize-1: the 4-tile canvas is built as [0, tileSize-1], matching
- // reader_playlist.go's createTiledImage exactly.
+ // tileSize-1: the 4-tile canvas is built as [0, tileSize-1].
Entry("1 album -> single tile", []string{"t1"}, tileSize/2),
Entry("2 albums -> duplicated to 4 tiles", []string{"t1", "t2"}, tileSize-1),
Entry("3 albums -> duplicated to 4 tiles", []string{"t1", "t2", "t3"}, tileSize-1),
@@ -508,8 +507,7 @@ var _ = Describe("resolveItem", func() {
Expect(res.extError).To(BeFalse())
})
- // A local resolver holds no agents, and the external branch dereferences them before the
- // gate is ever consulted, so reaching it at all would panic rather than degrade.
+ // A local resolver holds no agents: reaching the external branch would panic, not degrade.
It("skips the external step instead of dereferencing absent agents", func() {
conf.Server.CoverArtPriority = "external"
ds.MockedAlbum = tests.CreateMockAlbumRepo()
@@ -521,8 +519,7 @@ var _ = Describe("resolveItem", func() {
Expect(res.extError).To(BeFalse(), "a skipped step is not a failed one")
})
- // The request path must never reach the network nor sample album art synchronously. The
- // worker resolving the same playlist is asserted alongside, so this cannot pass vacuously.
+ // The worker resolving the same playlist is asserted alongside, so this cannot pass vacuously.
It("resolves a playlist locally without fetching remotely or building the grid", func() {
conf.Server.EnableM3UExternalAlbumArt = true
var hits atomic.Int32
@@ -587,7 +584,6 @@ var _ = Describe("resolveItem", func() {
})
It("skips a grid tile whose declared dimensions are a decompression bomb", func() {
- // End-to-end regression: a bomb-declaring tile must not break the grid.
libRoot := GinkgoT().TempDir()
Expect(os.MkdirAll(filepath.Join(libRoot, "bomb"), 0755)).To(Succeed())
Expect(os.WriteFile(filepath.Join(libRoot, "bomb", "cover.jpg"), pngHeaderWithDims(50000, 50000), 0600)).To(Succeed())
@@ -606,8 +602,7 @@ var _ = Describe("resolveItem", func() {
})
It("does not resolve as absent when every sampled album fails to resolve", func() {
- // "missing1"/"missing2" are not in MockAlbumRepo's data, so resolveAlbum
- // returns a genuine (non-external) error for every sampled tile.
+ // The album ids are absent from MockAlbumRepo, so every tile fails non-externally.
plRepo := tests.CreateMockPlaylistRepo()
plRepo.SetData(model.Playlists{{ID: "pl3", Name: "Playlist"}})
plRepo.TracksRepo = &tests.MockPlaylistTrackRepo{AlbumIDs: []string{"missing1", "missing2"}}
@@ -620,8 +615,7 @@ var _ = Describe("resolveItem", func() {
})
})
-// decodeTile runs on every sampled album's resolved bytes before the processor's
-// own guards apply, so it must enforce the same caps independently.
+// decodeTile runs before the processor's own guards, so it must enforce the caps itself.
var _ = Describe("decodeTile", func() {
It("rejects a decompression bomb before the full decode", func() {
data := pngHeaderWithDims(50000, 50000) // 2.5 gigapixels, far above the cap
diff --git a/core/artwork/sources.go b/core/artwork/sources.go
index b2cd5be44..885ca03cf 100644
--- a/core/artwork/sources.go
+++ b/core/artwork/sources.go
@@ -187,9 +187,8 @@ func fromURL(ctx context.Context, imageUrl *url.URL) (io.ReadCloser, string, err
if err != nil {
return nil, "", err
}
- // A dead image URL is a definitive miss, not a transient fault: agents (e.g. Last.fm) can
- // advertise an image URL that 404s. Map it to ErrNotFound so it settles absent instead of
- // retrying forever and tripping the artwork breaker.
+ // An agent-advertised URL that 404s is a definitive miss, not a fault: settle absent
+ // instead of retrying forever and tripping the artwork breaker.
if resp.StatusCode == http.StatusNotFound || resp.StatusCode == http.StatusGone {
resp.Body.Close()
return nil, "", model.ErrNotFound
diff --git a/core/artwork/uploader.go b/core/artwork/uploader.go
index 2c3c380dd..9c38ae2e5 100644
--- a/core/artwork/uploader.go
+++ b/core/artwork/uploader.go
@@ -15,8 +15,7 @@ import (
"github.com/navidrome/navidrome/utils"
)
-// MaxImageUploadSize returns the configured MaxImageUploadSize in bytes, or the built-in default
-// when it's unset/invalid. Shared by every API that accepts image uploads.
+// MaxImageUploadSize returns the configured max upload size in bytes, or the built-in default.
func MaxImageUploadSize() int64 {
if size, err := humanize.ParseBytes(conf.Server.MaxImageUploadSize); err == nil && size > 0 {
return int64(size)
@@ -25,18 +24,15 @@ func MaxImageUploadSize() int64 {
return int64(size)
}
-// Uploader stores a user-uploaded entity image and invalidates that entity's artwork state so the
-// upload becomes the served cover.
+// Uploader stores a user-uploaded entity image and invalidates that entity's artwork state.
type Uploader interface {
SetImage(ctx context.Context, entityType string, entityID string, name string, oldPath string, reader io.Reader, ext string) (filename string, err error)
RemoveImage(ctx context.Context, path string) error
- // EnqueueArtwork clears an item's resolved state and re-queues it at Bump priority. Callers
- // must invoke it AFTER persisting the new filename, so the worker never resolves the old one.
+ // EnqueueArtwork re-resolves the item's artwork. Call it AFTER persisting the new
+ // filename, or the worker resolves the old one.
EnqueueArtwork(ctx context.Context, entityType, entityID string)
}
-// uploadEntityKind maps an upload's entity type to its artwork kind prefix, so a
-// successful upload can clear and re-queue that item's artwork state.
var uploadEntityKind = map[string]model.Kind{
consts.EntityArtist: model.KindArtistArtwork,
consts.EntityPlaylist: model.KindPlaylistArtwork,
@@ -59,14 +55,12 @@ func (s *uploader) SetImage(ctx context.Context, entityType string, entityID str
return "", fmt.Errorf("creating image directory: %w", err)
}
- // Remove old image if it exists
if oldPath != "" {
if err := os.Remove(oldPath); err != nil && !os.IsNotExist(err) {
log.Warn(ctx, "Artwork: Failed to remove old image", "path", oldPath, err)
}
}
- // Save new image
f, err := os.Create(absPath)
if err != nil {
return "", fmt.Errorf("creating image file: %w", err)
@@ -79,8 +73,6 @@ func (s *uploader) SetImage(ctx context.Context, entityType string, entityID str
return filename, nil
}
-// EnqueueArtwork clears the item's resolved state and re-queues it at Bump priority: the
-// upload is now the top-priority source, so the worker re-resolves and the UI swaps.
func (s *uploader) EnqueueArtwork(ctx context.Context, entityType, id string) {
kind, ok := uploadEntityKind[entityType]
if !ok {
diff --git a/core/artwork/worker.go b/core/artwork/worker.go
index 1e0a33444..c994e3320 100644
--- a/core/artwork/worker.go
+++ b/core/artwork/worker.go
@@ -22,13 +22,13 @@ import (
const (
workerPollInterval = 5 * time.Second
backoffBase = 5 * time.Second
- // giveUpAfter bounds the retry budget from enqueue: past it the worker stops retrying and
- // hands the item to the periodic stale-absent recheck (settling absent on a bare failure).
+ // giveUpAfter bounds the retry budget from enqueue; past it the item falls to the
+ // periodic stale-absent recheck.
giveUpAfter = 12 * time.Hour
)
-// drainPool drains one class of work with its own slot budget, so a kind whose resolution
-// blocks cannot occupy slots another kind needs.
+// drainPool drains one class of work with its own slot budget, so a blocking kind cannot
+// occupy slots another kind needs.
type drainPool struct {
name string
kinds []string
@@ -36,9 +36,8 @@ type drainPool struct {
wake chan struct{}
}
-// Worker drains the artwork queue through the processor: each external agent is rate-limited
-// and circuit-broken independently, and prune is serialized against the store-write window
-// via pruneMu.
+// Worker drains the artwork queue: each external agent is rate-limited and circuit-broken
+// independently, and pruneMu serializes prune against the store-write window.
type Worker struct {
proc *processor
cache cache.FileCache
@@ -67,8 +66,8 @@ func NewWorker(ds model.DataStore, store *ImageStore, ag *agents.Agents, ffmpeg
return w
}
-// newDrainPools splits the drain by what bounds it: gate() waits for its rate-limit permit
-// while holding a slot, so a sleeping lookup would otherwise crowd out a cover sitting on disk.
+// newDrainPools splits the drain by what bounds it: gate() holds a slot while waiting for its
+// rate-limit permit, so a sleeping lookup would crowd out a cover sitting on disk.
func newDrainPools() []*drainPool {
budget := conf.MaxOpenConns() // floored at 4, so both remainders below stay positive
local := min(max(1, conf.Server.ArtworkWorkerConcurrency), budget-1)
@@ -80,8 +79,7 @@ func newDrainPools() []*drainPool {
}
}
-// Kind is a proxy for cost: an album whose chain reaches "external" still costs a local slot,
-// but only the few with no local art do.
+// Kind is a proxy for cost: an album that reaches an external agent still costs a local slot.
var (
externalDrainKinds = []string{model.KindArtistArtwork.Prefix()}
localDrainKinds = []string{
@@ -92,8 +90,7 @@ var (
}
)
-// Run blocks draining the queue until ctx is cancelled. It exits cleanly with no
-// leaked goroutines: each drain waits for its batch before the loop can return.
+// Run blocks draining the queue until ctx is cancelled.
func (w *Worker) Run(ctx context.Context) error {
w.runCtx = ctx
var wg sync.WaitGroup
@@ -104,7 +101,6 @@ func (w *Worker) Run(ctx context.Context) error {
return nil
}
-// runPool drains one pool's kinds until ctx is cancelled.
func (w *Worker) runPool(ctx context.Context, p *drainPool) {
ticker := time.NewTicker(workerPollInterval)
defer ticker.Stop()
@@ -117,7 +113,7 @@ func (w *Worker) runPool(ctx context.Context, p *drainPool) {
return
}
if n > 0 {
- continue // keep draining while this pool has ready work
+ continue
}
select {
case <-ctx.Done():
@@ -128,8 +124,7 @@ func (w *Worker) runPool(ctx context.Context, p *drainPool) {
}
}
-// Bump enqueues an item at the highest priority and wakes the drain loop. It is
-// non-blocking: a wake already pending is enough.
+// Bump enqueues an item at the highest priority and wakes the drain loops.
func (w *Worker) Bump(kind, id string) {
item := model.ArtworkQueueItem{
ItemKind: kind,
@@ -141,8 +136,7 @@ func (w *Worker) Bump(kind, id string) {
log.Warn("Artwork: Could not bump queue item", "kind", kind, "id", id, err)
return
}
- // Waking all beats routing by kind: a spurious wake costs one empty dequeue, while an
- // unrouted kind would never wake at all.
+ // Wake every pool: a spurious wake only costs an empty dequeue.
for _, p := range w.pools {
select {
case p.wake <- struct{}{}:
@@ -159,27 +153,25 @@ func (w *Worker) RunPrune(ctx context.Context) error {
return prune(ctx, w.proc.ds, w.proc.store)
}
-// Backfill enqueues every entity for re-resolution when the artwork config fingerprint has
-// changed since the last run, artists first. It reports whether anything was enqueued.
+// Backfill enqueues every entity for re-resolution when the artwork config fingerprint changed,
+// artists first. It reports whether anything was enqueued.
func (w *Worker) Backfill(ctx context.Context) (bool, error) {
return backfill(ctx, w.proc.ds)
}
-// EnqueueStaleAbsentAll requeues known-absent entries older than staleAbsentAge, so artwork
-// that appeared since the last attempt is eventually picked up.
+// EnqueueStaleAbsentAll requeues known-absent entries older than staleAbsentAge.
func (w *Worker) EnqueueStaleAbsentAll(ctx context.Context) error {
return enqueueStaleAbsentAll(ctx, w.proc.ds)
}
-// EnqueueMissingAll requeues entities that have no artwork state row at all: the safety net
-// for anything a scan never enqueued.
+// EnqueueMissingAll requeues entities with no artwork state row: the safety net for anything
+// a scan never enqueued.
func (w *Worker) EnqueueMissingAll(ctx context.Context) error {
return enqueueMissingAll(ctx, w.proc.ds)
}
func (w *Worker) drain(ctx context.Context, concurrency int, kinds ...string) (int, error) {
- // Dequeue well past the worker pool so a slow item (an external lookup burning its
- // timeout) never idles the other slots: the pool stays fed until the batch runs out.
+ // Dequeue well past the pool size so a slow external lookup never idles the other slots.
// DequeueBatch does not mark rows taken, so this is one query per pass, not per slot.
items, err := w.proc.ds.ArtworkQueue(ctx).DequeueBatch(max(16, 4*concurrency), kinds...)
if err != nil {
@@ -189,8 +181,8 @@ func (w *Worker) drain(ctx context.Context, concurrency int, kinds ...string) (i
return 0, nil
}
drainStart := time.Now()
- // Resolved only once there is work, and per drain rather than per item: the worker needs an
- // admin identity for private playlists, and can start before any admin exists.
+ // Private playlists need an admin identity; resolved per drain because the worker can
+ // start before any admin exists.
ctx = auth.WithAdminUser(ctx, w.proc.ds)
sem := make(chan struct{}, concurrency)
var wg sync.WaitGroup
@@ -206,16 +198,15 @@ func (w *Worker) drain(ctx context.Context, concurrency int, kinds ...string) (i
wg.Go(func() {
defer func() { <-sem }()
out, got := w.process(ctx, item)
- // Refresh clients on any visible state change: found/foundStale (new art) and absent
- // (removed art — clients must drop a previously-served immutable cover). foundStale
- // also wrote a served state row.
+ // Absent counts as a visible change too: clients must drop a previously-served
+ // immutable cover.
if out == outcomeFound || out == outcomeFoundStale || out == outcomeAbsent {
refreshMu.Lock()
refresh = append(refresh, item)
refreshMu.Unlock()
}
- // Precache only actual images. Post-outcome only: the queue row was already settled
- // by process, so warming the resize cache here can never block or alter queue ops.
+ // Post-outcome: the queue row is already settled, so warming the cache can't
+ // block or alter queue ops.
if got != nil {
w.precache(ctx, got)
}
@@ -228,8 +219,8 @@ func (w *Worker) drain(ctx context.Context, concurrency int, kinds ...string) (i
return len(items), nil
}
-// artworkKindToResource maps an artwork kind to the UI resource name carried in the refresh
-// event; note media_file maps to "song", so this can't derive from Kind.String().
+// artworkKindToResource maps a kind to its UI resource name; media_file maps to "song", so
+// this can't derive from Kind.String().
var artworkKindToResource = map[model.Kind]string{
model.KindAlbumArtwork: "album",
model.KindArtistArtwork: "artist",
@@ -238,8 +229,8 @@ var artworkKindToResource = map[model.Kind]string{
model.KindMediaFileArtwork: "song",
}
-// broadcastRefresh emits one coalesced RefreshResource for the batch's newly-acquired
-// artwork, so connected UIs re-fetch the affected records (and pick up the new coverArt id).
+// broadcastRefresh emits one coalesced RefreshResource for the batch, so UIs re-fetch the
+// affected records and pick up the new coverArt id.
func (w *Worker) broadcastRefresh(ctx context.Context, found []model.ArtworkQueueItem) {
if len(found) == 0 {
return
@@ -270,16 +261,16 @@ func (w *Worker) process(ctx context.Context, item model.ArtworkQueueItem) (outc
queue := w.proc.ds.ArtworkQueue(ctx)
switch out {
case outcomeFound, outcomeAbsent:
- // DeleteIfUnchanged, not Delete: a scan that re-enqueued this row mid-flight reset
- // its retry_at, so the row survives here and the next drain re-resolves it.
+ // A scan that re-enqueued this row mid-flight reset its retry_at, so the row survives
+ // here and the next drain re-resolves it.
if err := queue.DeleteIfUnchanged(item.ItemKind, item.ItemID, item.ImageType, item.RetryAt); err != nil {
log.Warn(ctx, "Artwork: Could not delete processed queue item", "kind", item.ItemKind, "id", item.ItemID, err)
}
case outcomeFoundStale, outcomeFailed:
retryAt := time.Now().Add(backoff(item.Attempts))
if retryAt.Before(item.EnqueuedAt.Add(giveUpAfter)) {
- // MarkFailedIfUnchanged, not MarkFailed: a scan that re-enqueued this row mid-flight reset
- // retry_at, so stale backoff must not stomp its fresh, immediate eligibility.
+ // A mid-flight re-enqueue reset retry_at; stale backoff must not stomp its
+ // fresh, immediate eligibility.
if err := queue.MarkFailedIfUnchanged(item.ItemKind, item.ItemID, item.ImageType, item.RetryAt, retryAt); err != nil {
log.Warn(ctx, "Artwork: Could not reschedule failed queue item", "kind", item.ItemKind, "id", item.ItemID, err)
}
@@ -288,10 +279,8 @@ func (w *Worker) process(ctx context.Context, item model.ArtworkQueueItem) (outc
"budgetLeft", time.Until(item.EnqueuedAt.Add(giveUpAfter)))
break
}
- // Retry budget exhausted: stop retrying. Absent is only recoverable where a periodic
- // recheck will revisit it, so kinds without one keep no row at all; and art already
- // being served is kept, since exhaustion means the source stayed unreachable rather
- // than that the entity lost its cover.
+ // Absent is only recoverable where a periodic recheck revisits it, so other kinds keep
+ // no row; art already being served is kept, as exhaustion means unreachable, not removed.
settled := "kept previous state"
if out == outcomeFailed && hasRecheckPath(item.ItemKind) && !w.hasResolvedArtwork(ctx, item) {
writeAbsent(ctx, w.proc.ds.Artwork(ctx), item)
@@ -306,7 +295,6 @@ func (w *Worker) process(ctx context.Context, item model.ArtworkQueueItem) (outc
return out, got
}
-// hasResolvedArtwork reports whether the item already has a hash-bearing state row.
func (w *Worker) hasResolvedArtwork(ctx context.Context, item model.ArtworkQueueItem) bool {
kind, ok := model.ParseKind(item.ItemKind)
if !ok {
@@ -317,15 +305,14 @@ func (w *Worker) hasResolvedArtwork(ctx context.Context, item model.ArtworkQueue
}
// precache warms the resize cache at the UI cover size from the bytes just acquired, so the
-// first UI request is a cache hit without re-reading the rows or the file. Skipped when
-// disabled; failures are debug-only.
+// first UI request hits without re-reading the rows or the file.
func (w *Worker) precache(ctx context.Context, got *acquired) {
if !conf.Server.EnableArtworkPrecache || w.cache == nil || w.cache.Disabled(ctx) {
return
}
precacheStart := time.Now()
- // Same key as the serving path (hash/size/square); only the source of the bytes differs.
- // square matches what the list surfaces request, otherwise this warms a key nothing reads.
+ // Same key as the serving path: square must match what the list surfaces request, or this
+ // warms a key nothing reads.
item := &resizedItem{
hash: got.ia.Hash,
size: conf.Server.UICoverArtSize,
diff --git a/core/artwork/worker_soak_test.go b/core/artwork/worker_soak_test.go
index e905664a4..eb7346102 100644
--- a/core/artwork/worker_soak_test.go
+++ b/core/artwork/worker_soak_test.go
@@ -17,13 +17,10 @@ import (
. "github.com/onsi/gomega"
)
-// soakCycles is deliberately >2000: this is a leak regression guard, not a
-// performance benchmark, so it favors a stable signal over raw speed.
+// Deliberately >2000: a leak guard favors a stable signal over speed.
const soakCycles = 2200
var _ = Describe("Worker soak", func() {
- // Runs acquisition over many cycles across a mix of sources, asserting
- // goroutines/heap plateau instead of growing unbounded (a leak guard). Skipped under -short.
It("does not leak goroutines, heap, or fds over many acquisition cycles", func() {
if testing.Short() {
Skip("skipping soak test in short mode")
@@ -57,8 +54,7 @@ var _ = Describe("Worker soak", func() {
proc := &processor{ds: ds, store: store, resolver: newResolver(ds, ag, ffm, nil)}
conf.Server.CoverArtPriority = "cover.jpg, embedded"
- // Dangling refs (al/ra ids the repos don't know about) mirror an entity
- // deleted after being enqueued; ds.Radio auto-provisions an empty mock repo.
+ // Dangling refs mirror an entity deleted after being enqueued.
items := []model.ArtworkQueueItem{
{ItemKind: "al", ItemID: "al-folder"},
{ItemKind: "al", ItemID: "al-embed"},
@@ -78,8 +74,7 @@ var _ = Describe("Worker soak", func() {
}
settleGoroutines := func() int {
- // Background goroutines (GC workers, etc.) can take a moment to wind down;
- // poll for two consecutive equal samples instead of trusting a single one.
+ // Background goroutines wind down late, so poll for two consecutive equal samples.
prev := -1
for range 100 {
runtime.GC()
@@ -102,8 +97,7 @@ var _ = Describe("Worker soak", func() {
it := items[i%len(items)]
out, _ := proc.acquire(context.Background(), it)
- // "Serve-adjacent" read-back: exercise the Phase 2 surfaces a caller would
- // use after acquisition, not the old serving pipeline.
+ // Read-back exercises the surfaces a caller would use after acquisition.
if out == outcomeFound {
kind, _ := model.ParseKind(it.ItemKind)
ia, err := artRepo.GetItemArtwork(kind, it.ItemID, model.ImageTypePrimary)
diff --git a/core/artwork/worker_test.go b/core/artwork/worker_test.go
index 903a1e86f..d2a2f0dac 100644
--- a/core/artwork/worker_test.go
+++ b/core/artwork/worker_test.go
@@ -22,8 +22,6 @@ import (
"go.uber.org/goleak"
)
-// recordingCache captures the keys passed to Get so precache warming can be asserted,
-// and can be forced Disabled to exercise the skip path.
type recordingCache struct {
cache.FileCache
mu sync.Mutex
@@ -48,8 +46,8 @@ func (c *recordingCache) getKeys() []string {
return append([]string(nil), c.keys...)
}
-// reenqueueOnDequeue simulates a concurrent scan Enqueue between DequeueBatch and the
-// worker's delete by bumping retry_at, so a DeleteIfUnchanged on the dequeued value no-ops.
+// Simulates a concurrent Enqueue between DequeueBatch and the worker's delete, so
+// DeleteIfUnchanged on the dequeued value no-ops.
type reenqueueOnDequeue struct {
*tests.MockArtworkQueueRepo
done bool
@@ -303,8 +301,7 @@ var _ = Describe("Worker", func() {
Expect(err).ToNot(HaveOccurred())
Expect(n).To(Equal(1))
- // The concurrent re-enqueue reset retry_at; the failure path must not stomp it
- // with stale backoff nor bump attempts, so the row stays immediately eligible.
+ // The re-enqueue reset retry_at; the failure path must not stomp it nor bump attempts.
it := findQueued(queueRepo, "al", "al8")
Expect(it).ToNot(BeNil())
Expect(it.Attempts).To(BeZero())
@@ -317,7 +314,7 @@ var _ = Describe("Worker", func() {
imageAgents(&fakeImageAgent{name: "failAgent", err: errors.New("agent timed out")})
w = NewWorker(ds, store, ag, ffm, broker, imgCache)
Expect(queueRepo.Enqueue(model.ArtworkQueueItem{ItemKind: "al", ItemID: "al9"})).To(Succeed())
- // Age the row past the budget so the next retry would land beyond enqueued_at+giveUpAfter.
+ // Age the row past the retry budget.
for k, v := range queueRepo.Data {
if v.ItemID == "al9" {
v.EnqueuedAt = time.Now().Add(-(giveUpAfter + time.Hour))
@@ -329,7 +326,6 @@ var _ = Describe("Worker", func() {
Expect(err).ToNot(HaveOccurred())
Expect(n).To(Equal(1))
- // Row removed (stops retrying) and the failure settles absent for the periodic sweep.
Expect(findQueued(queueRepo, "al", "al9")).To(BeNil())
ia, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "al9", model.ImageTypePrimary)
Expect(err).ToNot(HaveOccurred())
@@ -363,8 +359,8 @@ var _ = Describe("Worker", func() {
Expect(ia.Hash).To(Equal("cafebabe"), "a persistent outage must not discard served art")
})
- // Media files are excluded from recheckKinds, so an absent row written here would never
- // be revisited: a transient read error would look like "this track has no cover" forever.
+ // Media files are excluded from recheckKinds, so an absent row here would never be
+ // revisited: a transient read error would look permanent.
It("does not settle absent on exhaustion for a kind with no recheck path", func() {
conf.Server.EnableMediaFileCoverArt = true
ds.MockedMediaFile = tests.CreateMockMediaFileRepo()
@@ -403,7 +399,6 @@ var _ = Describe("Worker", func() {
Expect(err).ToNot(HaveOccurred())
Expect(n).To(Equal(1))
- // Resolved as absent (no art) and removed — not stuck failing on ErrNotFound forever.
Expect(findQueued(queueRepo, "pl", "plPriv")).To(BeNil())
ia, err := artRepo.GetItemArtwork(model.KindPlaylistArtwork, "plPriv", model.ImageTypePrimary)
Expect(err).ToNot(HaveOccurred())
@@ -526,9 +521,8 @@ var _ = Describe("Worker", func() {
})
It("does not open the breaker on a run of agent not-found misses", func() {
- // Regression: agents.ErrNotFound is a definitive miss, not a fault. A run of
- // artless items must never trip the breaker, or they'd loop in retry instead of
- // settling absent. Uses the real gate, not passthroughGate.
+ // agents.ErrNotFound is a definitive miss, not a fault: artless items must not
+ // trip the breaker, or they would loop in retry instead of settling absent.
notFound := func() (io.ReadCloser, string, error) { return nil, "", agents.ErrNotFound }
for range breakerThreshold + 3 {
_, _, err := w.gate("A", notFound)
@@ -601,8 +595,7 @@ var _ = Describe("Worker", func() {
Expect(imgCache.getKeys()).To(BeEmpty())
})
- // It warms from the bytes the acquisition already held, so no state row or store file
- // needs to exist for it to work.
+ // It warms from the bytes acquisition already held, so no state row or store file is needed.
It("warms from the acquired bytes without reading them back", func() {
conf.Server.EnableArtworkPrecache = true
ia := &model.ItemArtwork{
@@ -615,9 +608,8 @@ var _ = Describe("Worker", func() {
w.precache(ctx, &acquired{ia: ia, mime: "image/jpeg", data: data})
- // Nothing backs that hash on disk or in the store, so the entry can only have come
- // from the bytes handed in. Probing with a source that refuses to open proves it
- // is really cached rather than re-read on demand.
+ // Nothing backs that hash on disk, so a hit can only come from the bytes handed in;
+ // the probe refuses to open, proving nothing is re-read.
probe := &resizedItem{
hash: ia.Hash, size: 300, square: true, ffmpeg: ffm,
open: func() (io.ReadCloser, error) { return nil, errors.New("precache must not re-read the source") },
@@ -642,9 +634,8 @@ var _ = Describe("Worker", func() {
Expect(pooled).To(HaveLen(len(artworkKindToResource)), "a kind is claimed by more than one pool")
})
- // A first backfill enqueues artists before albums, and artists resolve through a
- // rate-limited agent that holds its slot while waiting. Sharing one pool let ~29k
- // sleeping lookups sit in front of every album for hours.
+ // Artists resolve through a rate-limited agent that holds its slot while waiting, so one
+ // shared pool would park every album behind them.
It("resolves albums while artists are stuck on a slow agent", func() {
conf.Server.CoverArtPriority = "cover.jpg"
conf.Server.ArtistArtPriority = "external"
@@ -677,15 +668,13 @@ var _ = Describe("Worker", func() {
runCtx, cancel := context.WithCancel(ctx)
done := make(chan struct{})
go func() { defer close(done); _ = w.Run(runCtx) }()
- // Wait for Run to return: a leaked pool goroutine outlives the spec and races the
- // config snapshot Ginkgo restores on cleanup.
+ // Join Run: a leaked pool goroutine would race the config snapshot Ginkgo restores.
DeferCleanup(func() {
cancel()
close(block) // unpark the blocked lookups so the pools can unwind
<-done
})
- // The album must land while every artist is still parked in the agent.
Eventually(func() bool {
ia, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "alx", model.ImageTypePrimary)
return err == nil && ia.Hash != ""
@@ -698,8 +687,6 @@ var _ = Describe("Worker", func() {
})
Describe("batching", func() {
- // A cancelled drain leaves its undispatched rows untouched in the queue, so a later
- // drain picks them up unchanged.
It("leaves undispatched items queued when cancelled mid-batch", func() {
for i := range 8 {
id := fmt.Sprintf("alc%d", i)
@@ -720,8 +707,6 @@ var _ = Describe("Worker", func() {
}
})
- // The pool is fed from one dequeue per pass: a batch sized to the pool would make a
- // single slow item idle the other slots for as long as it runs.
It("dequeues past the worker pool so one drain covers many items", func() {
for i := range 16 {
ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{{ID: fmt.Sprintf("alb%d", i), Name: "Album"}})
diff --git a/core/artwork/worker_timing_test.go b/core/artwork/worker_timing_test.go
index 9b64fc53a..f7315b20a 100644
--- a/core/artwork/worker_timing_test.go
+++ b/core/artwork/worker_timing_test.go
@@ -41,8 +41,6 @@ func TestArtworkBreakerHalfOpen(t *testing.T) {
})
}
-// Drives the worker's per-name gate map with the fake clock: one agent's open breaker
-// must neither block another agent nor short-circuit the other's probe recovery.
func TestArtworkGatePerAgentBreakerIsolation(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
g := NewWithT(t)
diff --git a/model/artwork.go b/model/artwork.go
index 204fe5ebb..0c8ce3a00 100644
--- a/model/artwork.go
+++ b/model/artwork.go
@@ -15,20 +15,17 @@ type Artwork struct {
const ImageTypePrimary = "primary"
-// ItemImage is per-entity artwork state hydrated at query time; never persisted
-// (structs:"-" keeps it out of upserts).
+// ItemImage is per-entity artwork state hydrated at query time; never persisted.
type ItemImage struct {
ImageHash string `structs:"-" json:"imageHash,omitempty"`
ImageAbsent bool `structs:"-" json:"imageAbsent,omitempty"`
BlurHash string `structs:"-" json:"blurHash,omitempty"`
- // Dimensions of the original image. A blurhash carries no aspect ratio, so a client
- // needs these to decode the placeholder into the shape the real image will occupy.
+ // A blurhash carries no aspect ratio, so clients need these to shape the placeholder.
ImageWidth int `structs:"-" json:"imageWidth,omitempty"`
ImageHeight int `structs:"-" json:"imageHeight,omitempty"`
}
-// AspectRatio is the image's width/height, or nil when there is no image or its dimensions are
-// unknown (an unresolved item). Never guesses: a wrong ratio mis-shapes a client's placeholder.
+// AspectRatio is the image's width/height, or nil when the image or its dimensions are unknown.
func (i ItemImage) AspectRatio() *float64 {
if i.ImageAbsent || i.ImageWidth <= 0 || i.ImageHeight <= 0 {
return nil
@@ -47,8 +44,7 @@ type ItemArtwork struct {
SourcePath string `structs:"source_path"`
// RefMtime is SourcePath's mtime (unix-nanoseconds) at resolution; 0 when there is no SourcePath.
RefMtime int64 `structs:"ref_mtime"`
- // attempted_at/updated_at are nullable in the schema but always set by PutItemArtwork;
- // raw inserts must set them too, since these non-pointer time.Time fields fail to scan NULL.
+ // Nullable in the schema, but every insert must set them: these non-pointer fields cannot scan NULL.
AttemptedAt time.Time `structs:"attempted_at"`
UpdatedAt time.Time `structs:"updated_at"`
}
@@ -65,8 +61,7 @@ type ItemArtworkInfo struct {
// Absent reports a known-absent artwork state (resolved, no image).
func (i ItemArtworkInfo) Absent() bool { return i.Hash == "" }
-// Image projects the hydration entry onto the entity-facing struct, so every hydration
-// site copies the same set of fields.
+// Image projects the hydration entry onto the entity-facing struct.
func (i ItemArtworkInfo) Image() ItemImage {
return ItemImage{
ImageHash: i.Hash,
@@ -96,50 +91,43 @@ const (
)
type ArtworkRepository interface {
- // Image identity (artwork table)
GetImage(hash string) (*Artwork, error)
PutImage(a *Artwork) error
GetImages(hashes []string) (map[string]Artwork, error)
// GetOrphanHashes returns hashes referenced by no item_artwork row and older than cutoff.
GetOrphanHashes(createdBefore time.Time) ([]string, error)
- // DeleteOrphans deletes the given hashes only if still unreferenced and older than cutoff (atomic re-check).
+ // DeleteOrphans deletes the given hashes only if still unreferenced and older than cutoff.
DeleteOrphans(createdBefore time.Time, hashes []string) error
- // Per-item state (item_artwork table)
GetItemArtwork(kind Kind, id, imageType string) (*ItemArtwork, error)
PutItemArtwork(ia *ItemArtwork) error
DeleteForItem(kind Kind, id string) error
- // DeleteForItems removes state rows for the given ids of one kind, in chunks.
DeleteForItems(kind Kind, ids []string) error
- // GetInfoForItems hydrates a page: one batched query, item_artwork joined to artwork.
+ // GetInfoForItems hydrates a page in one batched query.
GetInfoForItems(kind Kind, ids []string) (map[string]ItemArtworkInfo, error)
- // GetAllMimes returns hash -> current mime for every stored artwork, for sweep retention checks.
+ // GetAllMimes returns hash -> current mime for every stored artwork.
GetAllMimes() (map[string]string, error)
// PurgeDanglingItemArtwork removes state rows whose entity no longer exists.
PurgeDanglingItemArtwork() (int64, error)
}
type ArtworkQueueRepository interface {
- // Enqueue upserts; an existing row keeps the higher of the two priorities and has its
- // retry_at reset (a detected change wants immediate re-resolution).
+ // Enqueue upserts; an existing row keeps the higher priority and has its retry_at reset.
Enqueue(items ...ArtworkQueueItem) error
// EnqueueBump upserts like Enqueue but preserves an existing row's retry_at, so a
// request-triggered read-through never resets a failed resolution's backoff.
EnqueueBump(items ...ArtworkQueueItem) error
// DequeueBatch returns up to n items with retry_at <= now, priority desc, enqueued_at asc.
- // Restricted to the given item kinds when any are passed, so a drain pool sees only its own
- // work and cannot be held up behind another kind's backlog.
+ // Restricted to the given kinds when any are passed, so one kind cannot block another's drain.
DequeueBatch(n int, kinds ...string) ([]ArtworkQueueItem, error)
// MarkFailedIfUnchanged applies the failure backoff only while retry_at still matches
- // seenRetryAt; a concurrent re-enqueue (which resets retry_at) keeps its fresh eligibility.
+ // seenRetryAt, so a concurrent re-enqueue keeps its fresh eligibility.
MarkFailedIfUnchanged(kind, id, imageType string, seenRetryAt, retryAt time.Time) error
- // DeleteIfUnchanged deletes the row only if its retry_at still matches retryAt, so a
- // concurrent re-enqueue (which resets retry_at) survives instead of being erased.
+ // DeleteIfUnchanged deletes only while retry_at still matches, sparing a concurrent re-enqueue.
DeleteIfUnchanged(kind, id, imageType string, retryAt time.Time) error
Count() (int64, error)
// EnqueueStaleAbsent inserts queue rows (priority Recheck) for absent states older than cutoff.
EnqueueStaleAbsent(kind Kind, attemptedBefore time.Time) (int64, error)
- // EnqueueMissing inserts queue rows (priority Recheck) for entities of the kind that have no
- // item_artwork row at all, so a never-processed entity is eventually resolved even without a scan.
+ // EnqueueMissing inserts queue rows (priority Recheck) for entities with no item_artwork row.
EnqueueMissing(kind Kind) (int64, error)
// PurgeDangling removes queue rows whose entity no longer exists.
PurgeDangling() (int64, error)
diff --git a/model/artwork_test.go b/model/artwork_test.go
index ecb89625b..a6a3fc701 100644
--- a/model/artwork_test.go
+++ b/model/artwork_test.go
@@ -23,8 +23,7 @@ var _ = Describe("ItemImage JSON", func() {
Expect(out).To(HaveKeyWithValue("blurHash", "LEHV6nWB2yk8"))
})
- // Clients decode the blurhash into a bitmap of their choosing, so without the dimensions they
- // cannot know the placeholder's shape and default to a square.
+ // Without the dimensions, clients cannot know the placeholder's shape and default to a square.
It("exposes the image dimensions alongside the blurhash", func() {
al := model.Album{ID: "al-3", Name: "Album"}
al.BlurHash = "LEHV6nWB2yk8"
diff --git a/persistence/album_repository.go b/persistence/album_repository.go
index 75f98d5d9..66632bdac 100644
--- a/persistence/album_repository.go
+++ b/persistence/album_repository.go
@@ -202,8 +202,7 @@ func (r *albumRepository) CountAll(options ...model.QueryOptions) (int64, error)
}
func (r *albumRepository) Exists(id string) (bool, error) {
- // Filtered like CountAll: the plain exists() helper applies no library filter, so it
- // would report a row in a library the caller cannot see.
+ // The exists() helper applies no library filter, so it would report rows the caller cannot see.
c, err := r.count(r.applyLibraryFilter(r.newSelect().Where(Eq{"album.id": id})))
return c > 0, err
}
@@ -259,7 +258,6 @@ func (r *albumRepository) GetAll(options ...model.QueryOptions) (model.Albums, e
return albums, nil
}
-// hydrateArtwork fills each album's ImageHash/ImageAbsent from one batched item_artwork lookup.
func (r *albumRepository) hydrateArtwork(albums model.Albums) {
if len(albums) == 0 {
return
@@ -271,9 +269,7 @@ func (r *albumRepository) hydrateArtwork(albums model.Albums) {
}
}
-// GetAllIDs returns just the album IDs for the same row set as GetAll, skipping the
-// heavy column projection and JSON post-processing. Used by bulk enumeration (artwork backfill)
-// and as GetCursor's id pre-pass.
+// GetAllIDs returns the IDs of GetAll's row set, skipping its column projection and JSON decoding.
func (r *albumRepository) GetAllIDs(options ...model.QueryOptions) ([]string, error) {
sq := r.applyLibraryFilter(r.newSelect(options...).Columns("album.id"))
if filtersNeedAnnotation(sq) {
diff --git a/persistence/album_repository_test.go b/persistence/album_repository_test.go
index 66acef072..32ee40e64 100644
--- a/persistence/album_repository_test.go
+++ b/persistence/album_repository_test.go
@@ -993,8 +993,7 @@ var _ = Describe("AlbumRepository", func() {
})
})
- // Exists used the unfiltered helper, so it reported albums in libraries the caller
- // cannot see -- the same leak Get/GetAll/CountAll already guard against.
+ // Exists must apply the same library filter as Get/GetAll/CountAll.
Describe("Exists library visibility", func() {
It("hides an album the user has no library access to", func() {
Expect(albumRepo.Put(&model.Album{ID: "vis-album", Name: "Vis", LibraryID: 1})).To(Succeed())
diff --git a/persistence/artist_repository.go b/persistence/artist_repository.go
index 00aa75c81..c5c3a2444 100644
--- a/persistence/artist_repository.go
+++ b/persistence/artist_repository.go
@@ -266,8 +266,7 @@ func (r *artistRepository) GetAll(options ...model.QueryOptions) (model.Artists,
}
// GetAllIDs returns just the artist IDs for the same row set as GetAll, skipping the
-// heavy stats columns and JSON post-processing. Used by bulk enumeration (artwork backfill)
-// and as GetCursor's id pre-pass.
+// heavy stats columns and JSON post-processing.
func (r *artistRepository) GetAllIDs(options ...model.QueryOptions) ([]string, error) {
sq := r.applyLibraryFilterToArtistQuery(r.newSelect(options...).Columns("artist.id")).GroupBy("artist.id")
if filtersNeedAnnotation(sq) {
diff --git a/persistence/artwork_hydration.go b/persistence/artwork_hydration.go
index 8bc6d579b..0e8edce6e 100644
--- a/persistence/artwork_hydration.go
+++ b/persistence/artwork_hydration.go
@@ -12,12 +12,12 @@ import (
"github.com/pocketbase/dbx"
)
-// artworkChunkSize bounds each chunk fetch's id IN-list (under SQLite's bound-parameter limit); a
-// whole multiple of artworkBatchSize so a page re-chunks into even hydration batches.
+// Keeps the id IN-list under SQLite's bound-parameter limit; a whole multiple of artworkBatchSize
+// so a page re-chunks into even hydration batches.
const artworkChunkSize = artworkBatchSize * 3
-// streamByIDs yields the rows of ids in chunks, fetching each chunk through the caller's hydrating
-// fetch. Resolving ids first keeps OFFSET out of the joined query (spec §6).
+// streamByIDs yields rows in id chunks through the caller's hydrating fetch. Resolving ids first
+// keeps OFFSET out of the joined query.
func streamByIDs[S ~[]T, T any](ids []string, fetch func(chunk []string) (S, error)) iter.Seq2[T, error] {
return func(yield func(T, error) bool) {
for chunk := range slices.Chunk(ids, artworkChunkSize) {
@@ -36,8 +36,8 @@ func streamByIDs[S ~[]T, T any](ids []string, fetch func(chunk []string) (S, err
}
}
-// chunkOptions narrows the caller's options to a chunk of ids, dropping Max/Offset (the id pre-pass
-// already consumed them) and reusing its Sort, which may be a seeded random expression by now.
+// chunkOptions narrows options to a chunk of ids, dropping Max/Offset (the id pre-pass already
+// consumed them) and reusing Sort, which may be a seeded random expression by now.
func chunkOptions(options []model.QueryOptions, idField string) func([]string) model.QueryOptions {
var base model.QueryOptions
if len(options) > 0 {
@@ -53,8 +53,8 @@ func chunkOptions(options []model.QueryOptions, idField string) func([]string) m
}
}
-// hydrateItemImages returns per-item artwork info for a fetched page via one batched query per kind
-// (never a join, see spec §6). On error it logs and returns an empty map so the page still renders.
+// hydrateItemImages returns per-item artwork info in one batched query per kind. On error it logs
+// and returns an empty map, so the page still renders.
func hydrateItemImages(ctx context.Context, db dbx.Builder, kind model.Kind, ids []string) map[string]model.ItemArtworkInfo {
if len(ids) == 0 {
return map[string]model.ItemArtworkInfo{}
@@ -75,7 +75,7 @@ func applyItemImage(infos map[string]model.ItemArtworkInfo, id string, img *mode
}
// hydrateMediaFileArtwork mirrors MediaFile.CoverArtID: an embedded-eligible file with resolved own
-// art uses it, else it falls back to the album's. Two batched item_artwork lookups, never a join.
+// art uses it, else it falls back to the album's.
func hydrateMediaFileArtwork(ctx context.Context, db dbx.Builder, mfs model.MediaFiles) {
if len(mfs) == 0 {
return
@@ -96,26 +96,20 @@ func hydrateMediaFileArtwork(ctx context.Context, db dbx.Builder, mfs model.Medi
eligible := mf.HasCoverArt && conf.Server.EnableMediaFileCoverArt
ownInfo, ownResolved := mfInfos[mf.ID]
if eligible && ownResolved && !ownInfo.Absent() {
- mf.ItemImage = ownInfo.Image() // own resolved art wins
+ mf.ItemImage = ownInfo.Image()
continue
}
ownWontResolve := !eligible || (ownResolved && ownInfo.Absent())
- // Fallback (see MediaFile.CoverArtID): inherit a found album hash for optimistic caching,
- // but only when the album's bytes are what serving will actually return. A multi-disc track
- // emits a dc- id served from disc art, and an eligible-but-unresolved track still extracts
- // its own embedded image — stamping the album hash on either advertises a content-version
- // (and a blurhash) belonging to a different image.
+ // Inherit the album hash only when serving returns those exact bytes: a multi-disc track is
+ // served disc art, and an eligible-but-unresolved one still extracts its own embedded image.
if album, ok := albumInfos[mf.AlbumID]; ok && !album.Absent() {
if mf.DiscNumber == 0 && ownWontResolve {
mf.ItemImage = album.Image()
}
continue
}
- // Nothing found. Mark absent only when serving would definitively yield a placeholder:
- // a single-disc track whose album is known-absent and whose own art won't resolve. A
- // multi-disc track resolves disc art provisionally (never known-absent), and an
- // eligible-but-unresolved track can still extract its own embedded art — both stay
- // requestable.
+ // Mark absent only when serving would definitively yield a placeholder; disc art and
+ // still-extractable embedded art both keep a track requestable.
if mf.DiscNumber > 0 {
continue
}
@@ -125,8 +119,7 @@ func hydrateMediaFileArtwork(ctx context.Context, db dbx.Builder, mfs model.Medi
}
}
-// hydrateCursor buffers a streamed page into batches and hydrates each before yielding, so a
-// cursor carries the same artwork state as a fetched page without a per-row query.
+// hydrateCursor hydrates a streamed cursor in batches, avoiding a per-row query.
func hydrateCursor[T any](cursor iter.Seq2[T, error], hydrate func([]T)) iter.Seq2[T, error] {
return func(yield func(T, error) bool) {
buf := make([]T, 0, artworkBatchSize)
@@ -157,8 +150,7 @@ func hydrateCursor[T any](cursor iter.Seq2[T, error], hydrate func([]T)) iter.Se
}
}
-// hydratePlaylistTrackArtwork hydrates the MediaFile embedded in each playlist track, so a track
-// reached through a playlist carries the same artwork state as one reached through the songs list.
+// hydratePlaylistTrackArtwork hydrates the MediaFile embedded in each playlist track.
func hydratePlaylistTrackArtwork(ctx context.Context, db dbx.Builder, tracks model.PlaylistTracks) {
if len(tracks) == 0 {
return
diff --git a/persistence/artwork_hydration_test.go b/persistence/artwork_hydration_test.go
index 28678700b..adf0e7e53 100644
--- a/persistence/artwork_hydration_test.go
+++ b/persistence/artwork_hydration_test.go
@@ -18,8 +18,7 @@ import (
"github.com/pocketbase/dbx"
)
-// seedAnnotations gives each id a distinct rating, play count, play date and starred_at for the
-// current user, dropping the rows again when the spec ends. It writes them directly, so that
+// seedAnnotations gives each id distinct annotation values, writing them directly so that
// SetRating's average_rating update does not outlive the cleanup.
func seedAnnotations(itemType string, ids ...string) {
GinkgoHelper()
@@ -103,7 +102,6 @@ var _ = Describe("Artwork hydration", func() {
al.ImageAbsent = true
Expect(repo.(*albumRepository).Put(&al)).To(Succeed())
- // No item_artwork rows exist, so a fresh read must observe zero values.
got, err := repo.Get(al.ID)
Expect(err).ToNot(HaveOccurred())
Expect(got.ImageHash).To(BeEmpty())
@@ -179,8 +177,6 @@ var _ = Describe("Artwork hydration", func() {
Expect(got.ImageHash).To(Equal("plget8888888888"))
})
- // A track reached through a playlist must carry the same artwork state as one reached
- // through the songs list, or its cover id has no hash to serve immutably or blur.
It("hydrates the tracks reached through a playlist", func() {
Expect(aw.PutImage(&model.Artwork{Hash: "pltrackhash1234", Mime: "image/jpeg", BlurHash: "LPLBLURhash"})).To(Succeed())
putInfo("al", songDayInALife.AlbumID, "pltrackhash1234")
@@ -279,16 +275,13 @@ var _ = Describe("Artwork hydration", func() {
byID := getByID()
- // eligible + own hash -> own hash
Expect(byID["1001"].ImageHash).To(Equal("mfh1001xxxxxxxx"))
Expect(byID["1001"].ImageAbsent).To(BeFalse())
- // eligible + embedded absent -> falls through to album 102 info
Expect(byID["1002"].ImageHash).To(Equal("alh102xxxxxxxxxx"))
Expect(byID["1002"].ImageAbsent).To(BeFalse())
- // not eligible (no embedded cover) -> album 103 info (known-absent)
Expect(byID["1003"].ImageHash).To(BeEmpty())
Expect(byID["1003"].ImageAbsent).To(BeTrue())
- // not eligible, album has no row -> zero values (unresolved)
+ // 2002: not eligible, and its album has no row at all -> unresolved
Expect(byID["2002"].ImageHash).To(BeEmpty())
Expect(byID["2002"].ImageAbsent).To(BeFalse())
})
@@ -304,15 +297,12 @@ var _ = Describe("Artwork hydration", func() {
byID := getByID()
- // own-art-wins: ImageHash is the track's own, but AlbumImage still carries the album's.
Expect(byID["1001"].ImageHash).To(Equal("mfh1001albimgxxx"))
Expect(byID["1001"].AlbumImage.ImageHash).To(Equal("alh101albimgxxxx"))
- // single-disc album-inheritance: not eligible, so ImageHash mirrors the album's hash.
Expect(byID["1002"].ImageHash).To(Equal("alh102albimgxxxx"))
Expect(byID["1002"].AlbumImage.ImageHash).To(Equal("alh102albimgxxxx"))
- // multi-disc bailout: ImageHash stays bare, but AlbumImage still reflects the absence.
Expect(byID["2002"].ImageHash).To(BeEmpty())
Expect(byID["2002"].ImageAbsent).To(BeFalse())
Expect(byID["2002"].AlbumImage.ImageAbsent).To(BeTrue())
@@ -341,18 +331,16 @@ var _ = Describe("Artwork hydration", func() {
})
It("keeps an eligible file optimistic when its own art is unresolved, even if the album is absent", func() {
- // 1004 is eligible (has embedded cover) with no mf state row yet; its album (103) is absent.
setCover("1004", true)
DeferCleanup(func() { setCover("1004", false) })
putInfo("al", "103", "") // album known-absent
byID := getByID()
- // The track's own embedded art is still unresolved, so it must NOT inherit the album's
- // absence: coverArt stays requestable so serving can extract the embedded art.
+ // 1004's own embedded art is still unresolved, so coverArt must stay requestable.
Expect(byID["1004"].ImageAbsent).To(BeFalse())
Expect(byID["1004"].ImageHash).To(BeEmpty())
- // A non-eligible sibling on the same absent album still inherits the absence.
+ // 1003 is not eligible, so it still inherits the album's absence.
Expect(byID["1003"].ImageAbsent).To(BeTrue())
})
@@ -361,8 +349,6 @@ var _ = Describe("Artwork hydration", func() {
byID := getByID()
- // 2002 is multi-disc (DiscNumber>0); CoverArtID emits a dc- id served from disc art of
- // unknown identity, so it must not advertise the album's hash as its content-version.
Expect(byID["2002"].ImageHash).To(BeEmpty())
Expect(byID["2002"].ImageAbsent).To(BeFalse())
})
@@ -372,14 +358,12 @@ var _ = Describe("Artwork hydration", func() {
byID := getByID()
- // 2002 is a multi-disc track (DiscNumber>0); CoverArtID points at disc art, which
- // resolves provisionally, so it must never inherit the album's absence.
Expect(byID["2002"].ImageAbsent).To(BeFalse())
Expect(byID["2002"].ImageHash).To(BeEmpty())
})
- // serveMediaFile extracts this track's own embedded art (provisionalEmbedded), so the
- // album's hash would advertise a content-version for bytes nobody will serve.
+ // serveMediaFile serves this track's own embedded art, so the album's hash would
+ // advertise a content-version for bytes nobody will serve.
It("leaves the hash bare for an eligible file whose own art is unresolved", func() {
setCover("1004", true)
DeferCleanup(func() { setCover("1004", false) })
@@ -438,16 +422,15 @@ var _ = Describe("Artwork hydration", func() {
albumRadioactivity.ID, albumMultiDisc.ID, albumCJK.ID, albumPunctuation.ID}}
onlyArtists = squirrel.Eq{"artist.id": []string{artistKraftwerk.ID, artistBeatles.ID,
artistCJK.ID, artistPunctuation.ID}}
- // Both fixture playlists belong to the same owner, so the owner_name sort would have a
- // single value to order by. This one is also private and owned by a third user, which
- // is what the non-admin visibility spec needs to see hidden.
+ // Both fixture playlists share an owner, leaving the owner_name sort a single value to
+ // order by; this one is also private, which the non-admin visibility spec needs.
foreign := model.Playlist{Name: "Foreign", OwnerID: thirdUser.ID, OwnerName: thirdUser.UserName}
Expect(playlistRepo.Put(&foreign)).To(Succeed())
DeferCleanup(func() { Expect(playlistRepo.Delete(foreign.ID)).To(Succeed()) })
onlyPlaylists = squirrel.Eq{"playlist.id": []string{plsBest.ID, plsCool.ID, foreign.ID}}
- // The suite annotates a single album and artist, so the annotation-backed sorts would
- // have nothing to order. Seed distinct values, and drop them again after each spec.
+ // The suite annotates a single album and artist, leaving the annotation-backed sorts
+ // nothing to order.
seedAnnotations("album", albumSgtPeppers.ID, albumAbbeyRoad.ID)
seedAnnotations("artist", artistKraftwerk.ID, artistCJK.ID)
@@ -515,8 +498,7 @@ var _ = Describe("Artwork hydration", func() {
To(Equal(slice.Map(want, func(a model.Album) string { return a.ID })))
})
- // The sorts and filters the Jellyfin list endpoints issue: the id pre-pass must resolve each
- // of them exactly like the full query. Comparing sort keys, not ids, keeps ties out of it.
+ // The sorts the Jellyfin list endpoints issue; comparing sort keys, not ids, keeps ties out.
DescribeTable("orders albums like GetAll",
func(opts model.QueryOptions, key func(model.Album) string) {
opts = scoped(opts, onlyAlbums)
@@ -612,8 +594,7 @@ var _ = Describe("Artwork hydration", func() {
repo := NewPlaylistRepository(otherCtx, GetDBXBuilder())
opts := model.QueryOptions{Sort: "name", Filters: onlyPlaylists}
- // Both phases must filter on their own: the id pre-pass, and the chunk fetch that would
- // otherwise re-read an id whose visibility changed in between.
+ // Both phases must filter on their own: the id pre-pass and the chunk fetch.
Expect(repo.GetAllIDs(opts)).To(ConsistOf(plsBest.ID))
all, err := repo.GetAll(model.QueryOptions{Filters: onlyPlaylists})
Expect(err).ToNot(HaveOccurred())
@@ -633,15 +614,13 @@ var _ = Describe("Artwork hydration", func() {
BeforeEach(func() {
mfRepo = NewMediaFileRepository(ctx, GetDBXBuilder())
putInfo("al", albumSgtPeppers.ID, "curhash11111111")
- // Distinct titles keep the ordering/paging specs tie-free; other fixture songs share
- // titles (e.g. "Antenna") or albums, which would make row order ambiguous.
+ // Distinct titles only: other fixture songs share titles (e.g. "Antenna" x3), which
+ // would make the positional comparisons against GetAll pass by tie-order coincidence.
onlySongs = squirrel.Eq{"media_file.id": []string{songDayInALife.ID, songComeTogether.ID,
songRadioactivity.ID, songAntenna.ID, songDisc1Track01.ID, songCJK.ID, songPunctuation.ID}}
})
It("hydrates artwork onto every streamed track, unlike GetCursor", func() {
- // Scoped to onlySongs (distinct titles): the full fixture has title ties (e.g. "Antenna"
- // x3), so positional comparison against GetAll would only pass by tie-order coincidence.
opts := model.QueryOptions{Sort: "title", Filters: onlySongs}
want, err := mfRepo.GetAll(opts)
Expect(err).ToNot(HaveOccurred())
diff --git a/persistence/artwork_queue_repository.go b/persistence/artwork_queue_repository.go
index e3e1f735b..5da7f343c 100644
--- a/persistence/artwork_queue_repository.go
+++ b/persistence/artwork_queue_repository.go
@@ -11,7 +11,7 @@ import (
"github.com/pocketbase/dbx"
)
-// enqueueChunkSize keeps each multi-row insert under SQLite's bind-variable limit (7 cols -> 700 vars).
+// Keeps each multi-row insert under SQLite's bind-variable limit (7 cols -> 700 vars).
const enqueueChunkSize = 100
type artworkQueueRepository struct {
@@ -26,16 +26,13 @@ func NewArtworkQueueRepository(ctx context.Context, db dbx.Builder) model.Artwor
return r
}
-// Enqueue also restarts the retry budget the worker measures from enqueued_at, so a fresh
-// request never inherits an old row's spent window and give up on its first attempt.
+// Enqueue also resets enqueued_at, so a fresh request does not inherit an old row's spent retry budget.
func (r *artworkQueueRepository) Enqueue(items ...model.ArtworkQueueItem) error {
return r.enqueue(`ON CONFLICT (item_kind, item_id, image_type) DO UPDATE SET
priority = MAX(priority, excluded.priority), retry_at = excluded.retry_at,
attempts = 0, enqueued_at = excluded.enqueued_at`, items)
}
-// EnqueueBump raises priority like Enqueue but leaves an existing row's retry_at intact, so a
-// request-triggered read-through never resets a failed resolution's backoff. New rows insert eligible.
func (r *artworkQueueRepository) EnqueueBump(items ...model.ArtworkQueueItem) error {
return r.enqueue(`ON CONFLICT (item_kind, item_id, image_type) DO UPDATE SET
priority = MAX(priority, excluded.priority)`, items)
@@ -72,8 +69,6 @@ func (r *artworkQueueRepository) DequeueBatch(n int, kinds ...string) ([]model.A
return res, err
}
-// MarkFailedIfUnchanged applies the backoff only while retry_at still equals seenRetryAt;
-// a concurrent Enqueue resets retry_at, so its fresh eligibility survives untouched.
func (r *artworkQueueRepository) MarkFailedIfUnchanged(kind, id, imageType string, seenRetryAt, retryAt time.Time) error {
upd := Update(r.tableName).
Set("attempts", Expr("attempts + 1")).
@@ -83,13 +78,10 @@ func (r *artworkQueueRepository) MarkFailedIfUnchanged(kind, id, imageType strin
return err
}
-// DeleteIfUnchanged deletes the row only while its retry_at still equals the dequeued
-// value; a concurrent Enqueue resets retry_at, so the row survives to be re-resolved.
func (r *artworkQueueRepository) DeleteIfUnchanged(kind, id, imageType string, retryAt time.Time) error {
return r.delete(Eq{"item_kind": kind, "item_id": id, "image_type": imageType, "retry_at": retryAt})
}
-// PurgeDangling removes queue rows whose entity no longer exists, per kind.
func (r *artworkQueueRepository) PurgeDangling() (int64, error) {
return purgeDangling(r.executeSQL, r.tableName)
}
diff --git a/persistence/artwork_queue_repository_test.go b/persistence/artwork_queue_repository_test.go
index a2843bf14..5baa482b6 100644
--- a/persistence/artwork_queue_repository_test.go
+++ b/persistence/artwork_queue_repository_test.go
@@ -19,8 +19,8 @@ var _ = Describe("ArtworkQueueRepository", func() {
ImageType: model.ImageTypePrimary, Priority: prio}
}
- // backOff puts a row into the state a failed resolution leaves behind. Production only ever
- // reaches that state through MarkFailedIfUnchanged, which needs the retry_at it dequeued.
+ // Writes the post-failure state directly: the production path, MarkFailedIfUnchanged, needs
+ // a retry_at only a dequeue can hand it.
backOff := func(kind, id string, retryAt time.Time) {
GinkgoHelper()
r := repo.(*artworkQueueRepository)
@@ -63,11 +63,9 @@ var _ = Describe("ArtworkQueueRepository", func() {
It("EnqueueBump raises priority without resetting a backing-off row's retry_at", func() {
Expect(repo.Enqueue(item("al", "b1", model.ArtworkPriorityScan))).To(Succeed())
- // Push retry_at into the future so the row is backing off and hidden from dequeue.
backOff("al", "b1", time.Now().Add(time.Hour))
Expect(repo.DequeueBatch(10)).To(BeEmpty())
- // A request-triggered bump raises priority but must leave the backoff intact.
Expect(repo.EnqueueBump(item("al", "b1", model.ArtworkPriorityBump))).To(Succeed())
Expect(repo.DequeueBatch(10)).To(BeEmpty(), "bump must not reset retry_at")
@@ -101,7 +99,7 @@ var _ = Describe("ArtworkQueueRepository", func() {
It("MarkFailedIfUnchanged applies backoff only while retry_at is unchanged", func() {
Expect(repo.Enqueue(item("al", "m1", model.ArtworkPriorityScan))).To(Succeed())
- // Anchor retry_at in the past (attempts -> 1) so it can never collide with the re-enqueue's now.
+ // Anchor retry_at in the past so it can never collide with the re-enqueue's now.
backOff("al", "m1", time.Now().Add(-time.Hour))
got, err := repo.DequeueBatch(10)
Expect(err).ToNot(HaveOccurred())
@@ -111,7 +109,6 @@ var _ = Describe("ArtworkQueueRepository", func() {
// A concurrent scan re-enqueues, resetting retry_at to now.
Expect(repo.Enqueue(item("al", "m1", model.ArtworkPriorityScan))).To(Succeed())
- // Failing with the stale retry_at is a no-op: the re-enqueued row keeps its fresh state.
future := time.Now().Add(48 * time.Hour)
Expect(repo.MarkFailedIfUnchanged("al", "m1", model.ImageTypePrimary, original, future)).To(Succeed())
got, _ = repo.DequeueBatch(10)
@@ -119,7 +116,6 @@ var _ = Describe("ArtworkQueueRepository", func() {
Expect(got[0].Attempts).To(BeZero(), "re-enqueue clears attempts, and the stale failure must not bump them")
current := got[0].RetryAt
- // Failing with the current retry_at applies the backoff and bumps attempts.
Expect(repo.MarkFailedIfUnchanged("al", "m1", model.ImageTypePrimary, current, future)).To(Succeed())
got, _ = repo.DequeueBatch(10)
Expect(got).To(BeEmpty(), "backed-off row is hidden until the future retry_at")
@@ -171,7 +167,6 @@ var _ = Describe("ArtworkQueueRepository", func() {
n, _ := repo.Count()
Expect(n).To(Equal(int64(1)))
- // Deleting with the current retry_at removes it.
got, _ = repo.DequeueBatch(10)
Expect(got).To(HaveLen(1))
Expect(repo.DeleteIfUnchanged("al", "d1", model.ImageTypePrimary, got[0].RetryAt)).To(Succeed())
@@ -225,8 +220,6 @@ var _ = Describe("ArtworkQueueRepository", func() {
It("enqueues entities that have no item_artwork row at all", func() {
awRepo := NewArtworkRepository(context.Background(), GetDBXBuilder())
- // albumSgtPeppers has a resolved row and albumAbbeyRoad an absent row: both already
- // processed, so neither should be enqueued as "missing".
Expect(awRepo.PutItemArtwork(&model.ItemArtwork{ItemKind: "al", ItemID: albumSgtPeppers.ID, ImageType: model.ImageTypePrimary, Hash: "hX", AttemptedAt: time.Now()})).To(Succeed())
Expect(awRepo.PutItemArtwork(&model.ItemArtwork{ItemKind: "al", ItemID: albumAbbeyRoad.ID, ImageType: model.ImageTypePrimary, Hash: "", AttemptedAt: time.Now()})).To(Succeed())
diff --git a/persistence/artwork_repository_test.go b/persistence/artwork_repository_test.go
index 19b7219fe..3910a5575 100644
--- a/persistence/artwork_repository_test.go
+++ b/persistence/artwork_repository_test.go
@@ -11,7 +11,6 @@ import (
"github.com/pocketbase/dbx"
)
-// clearArtworkTables resets the shared test DB's artwork tables so specs don't leak state.
func clearArtworkTables() {
db := GetDBXBuilder()
for _, t := range []string{"artwork_queue", "item_artwork", "artwork"} {
diff --git a/persistence/mediafile_repository.go b/persistence/mediafile_repository.go
index 6b47b5228..df1472c2a 100644
--- a/persistence/mediafile_repository.go
+++ b/persistence/mediafile_repository.go
@@ -163,8 +163,7 @@ func (r *mediaFileRepository) CountBySuffix(options ...model.QueryOptions) (map[
}
func (r *mediaFileRepository) Exists(id string) (bool, error) {
- // Filtered like CountAll: the plain exists() helper applies no library filter, so it
- // would report a row in a library the caller cannot see.
+ // The exists() helper applies no library filter, so it would report rows the caller cannot see.
c, err := r.count(r.applyLibraryFilter(r.newSelect().Where(Eq{"media_file.id": id})))
return c > 0, err
}
@@ -226,7 +225,6 @@ func (r *mediaFileRepository) GetAll(options ...model.QueryOptions) (model.Media
return mfs, nil
}
-// hydrateArtwork hydrates a fetched page in place.
func (r *mediaFileRepository) hydrateArtwork(mfs model.MediaFiles) {
hydrateMediaFileArtwork(r.ctx, r.db, mfs)
}
@@ -301,8 +299,7 @@ func (r *mediaFileRepository) GetCursor(options ...model.QueryOptions) (model.Me
return wrapMediaFileCursor(cursor), nil
}
-// GetAllIDs returns just the media_file IDs for the same row set as GetAll, skipping the heavy
-// column projection. Used as GetCursorWithArtwork's id pre-pass.
+// GetAllIDs returns the IDs of GetAll's row set, skipping its wide column projection.
func (r *mediaFileRepository) GetAllIDs(options ...model.QueryOptions) ([]string, error) {
sq := r.applyLibraryFilter(r.newSelect(options...).Columns("media_file.id"))
if filtersNeedAnnotation(sq) {
@@ -313,8 +310,7 @@ func (r *mediaFileRepository) GetAllIDs(options ...model.QueryOptions) ([]string
return ids, err
}
-// GetCursorWithArtwork streams the same rows as GetCursor, hydrated, via the id pre-pass used by
-// the other cursors, rather than the scanner's bounded, unhydrated GetCursor itself.
+// GetCursorWithArtwork streams the same rows as GetCursor, hydrated, via an id pre-pass.
func (r *mediaFileRepository) GetCursorWithArtwork(options ...model.QueryOptions) (model.MediaFileCursor, error) {
ids, err := r.GetAllIDs(options...)
if err != nil {
diff --git a/persistence/mediafile_repository_test.go b/persistence/mediafile_repository_test.go
index d0e7071cf..b9cba03ff 100644
--- a/persistence/mediafile_repository_test.go
+++ b/persistence/mediafile_repository_test.go
@@ -1119,8 +1119,7 @@ var _ = Describe("MediaRepository", func() {
})
})
- // Exists used the unfiltered helper, so it reported tracks in libraries the caller
- // cannot see -- the same leak Get/GetAll/CountAll already guard against.
+ // Exists must apply the same library filter as Get/GetAll/CountAll.
Describe("Exists library visibility", func() {
It("hides a track the user has no library access to", func() {
restricted := model.User{ID: "restricted_mf_user", UserName: "rm", Name: "RM", Email: "rm@t.com"}
diff --git a/persistence/playlist_repository.go b/persistence/playlist_repository.go
index a40635e21..1ae1ce43e 100644
--- a/persistence/playlist_repository.go
+++ b/persistence/playlist_repository.go
@@ -134,8 +134,8 @@ func (r *playlistRepository) Put(p *model.Playlist, cols ...string) error {
}
pls.ID = id // r.put assigns the generated id to p, not to this copy
if isNew {
- // A brand-new playlist has art to find even with no tracks (an imported m3u can carry an
- // ExternalImageURL). An update landing here changed only metadata, so leave its cover be.
+ // Even a trackless new playlist has art to find (an imported m3u can carry an
+ // ExternalImageURL); an update landing here changed only metadata, so leave its cover be.
r.enqueueCoverRebuild(id)
}
return r.refreshCounters(&pls.Playlist)
@@ -184,7 +184,6 @@ func (r *playlistRepository) findBy(sql Sqlizer) (*model.Playlist, error) {
return &list[0], nil
}
-// hydrateArtwork fills each playlist's ImageHash/ImageAbsent from one batched item_artwork lookup.
func (r *playlistRepository) hydrateArtwork(playlists model.Playlists) {
if len(playlists) == 0 {
return
@@ -211,12 +210,10 @@ func (r *playlistRepository) GetAll(options ...model.QueryOptions) (model.Playli
return playlists, err
}
-// GetAllIDs returns just the playlist IDs for the same row set as GetAll (honoring userFilter),
-// skipping its per-row processing. Used by bulk enumeration (artwork backfill) and as GetCursor's
-// id pre-pass.
+// GetAllIDs returns the IDs of GetAll's row set, skipping its per-row processing.
func (r *playlistRepository) GetAllIDs(options ...model.QueryOptions) ([]string, error) {
- // Joins a projection of user, not the table: its name/created_at columns would otherwise make
- // an ORDER BY on the playlist's own ambiguous.
+ // Joins a projection of user, not the table: its name/created_at columns would make an ORDER BY
+ // on the playlist's own ambiguous.
sq := r.newSelect(options...).Columns("playlist.id", "user.user_name as owner_name").
Join("(select id, user_name from user) user on user.id = owner_id").Where(r.userFilter())
if filtersNeedAnnotation(sq) {
@@ -228,8 +225,7 @@ func (r *playlistRepository) GetAllIDs(options ...model.QueryOptions) ([]string,
}
func (r *playlistRepository) GetCursor(options ...model.QueryOptions) (model.PlaylistCursor, error) {
- // GetAllIDs and GetAll both apply userFilter: a cursor must not widen visibility beyond
- // public/owned playlists, even if visibility changes between the two queries.
+ // Both passes apply userFilter, so a visibility change between them cannot widen the cursor.
ids, err := r.GetAllIDs(options...)
if err != nil {
return nil, err
@@ -338,10 +334,8 @@ func (r *playlistRepository) refreshCounters(pls *model.Playlist) error {
return nil
}
-// enqueueCoverRebuild re-resolves the generated 2x2 grid, which depends on the track set. Called
-// only where that set actually changes: the grid samples albums at random, so rebuilding after a
-// mere rename would hand the playlist a different cover. No clear -- the old cover keeps serving
-// until the worker rebuilds, so there is no flicker.
+// enqueueCoverRebuild re-resolves the generated 2x2 grid. Call it only when the track set changes:
+// the grid samples albums at random, so rebuilding after a mere rename would change the cover.
func (r *playlistRepository) enqueueCoverRebuild(id string) {
item := model.ArtworkQueueItem{ItemKind: model.KindPlaylistArtwork.Prefix(), ItemID: id,
ImageType: model.ImageTypePrimary, Priority: model.ArtworkPriorityScan}
diff --git a/persistence/playlist_repository_test.go b/persistence/playlist_repository_test.go
index a60c8281a..f900e65ef 100644
--- a/persistence/playlist_repository_test.go
+++ b/persistence/playlist_repository_test.go
@@ -273,8 +273,7 @@ var _ = Describe("PlaylistRepository", func() {
Expect(queued).ToNot(ContainElement(HaveField("ItemID", "")), "must not enqueue an empty playlist id")
})
- // The grid samples albums at random, so re-resolving after a rename would silently hand the
- // playlist a different cover.
+ // The grid samples albums at random, so re-resolving after a rename would change the cover.
It("does not enqueue artwork when only metadata changes", func() {
ctx := request.WithUser(log.NewContext(GinkgoT().Context()), model.User{ID: "userid", UserName: "userid", IsAdmin: true})
newPls := model.Playlist{Name: "Rename Me", OwnerID: "userid"}
@@ -411,9 +410,8 @@ var _ = Describe("PlaylistRepository", func() {
})
})
- // Exists is ctx-sensitive through userFilter: a private playlist is invisible to anyone but
- // its owner or an admin. Callers that only want "does it still exist" -- the public image
- // route serving a share -- must elevate, or a shared private playlist looks deleted.
+ // Exists is ctx-sensitive through userFilter, so callers that only want "does it still exist"
+ // -- the public image route serving a share -- must elevate, or a private playlist looks gone.
Describe("Exists visibility", func() {
It("hides a private playlist from an unauthenticated context", func() {
// "userid" is the fixture user; playlist.owner_id has a FK to user(id).
diff --git a/server/imghttp/headers.go b/server/imghttp/headers.go
index 8f017d60a..0277132fb 100644
--- a/server/imghttp/headers.go
+++ b/server/imghttp/headers.go
@@ -1,6 +1,5 @@
-// Package imghttp holds the shared HTTP caching contract for artwork responses, so the
-// subsonic, public, and jellyfin image handlers apply identical headers without importing
-// each other.
+// Package imghttp holds the HTTP caching contract shared by the subsonic, public, and jellyfin
+// image handlers, so they apply identical headers without importing each other.
package imghttp
import (
@@ -11,33 +10,30 @@ import (
)
// WriteImageHeaders applies the artwork caching contract and reports whether a 304 was written
-// (in which case the caller must not write a body). requestedHash is the hash the client asserted
-// (id suffix / JWT payload / jellyfin tag param), or "" when the request carried no hash.
+// (the caller must then not write a body). requestedHash is the hash the client asserted, or "".
func WriteImageHeaders(w http.ResponseWriter, r *http.Request, img *artwork.Image, requestedHash string) (wrote304 bool) {
h := w.Header()
- // Placeholders are transient stand-ins for not-yet-resolved art: never cached, no validators.
+ // Placeholders are transient stand-ins for unresolved art: never cached, no validators.
if img.Placeholder {
h.Set("Cache-Control", "no-store")
return false
}
- // The validator identifies the served representation (resized/re-encoded bytes version it via
- // ETag), so a CoverArtQuality/EnableWebPEncoding change invalidates a revalidating client's
- // cache. Falls back to the pixel hash for full-size originals (bytes == the hash).
+ // The ETag versions the served representation, so a re-encoding config change invalidates a
+ // revalidating client's cache; full-size originals are their own pixel hash.
etag := img.ETag
if etag == "" {
etag = img.Hash
}
- // An empty validator is not one: emitting it would hand every such response the same ETag,
- // and matching it would 304 a client that echoed it back even after the bytes changed.
+ // An empty validator would be shared by every such response, 304ing clients that echo it back.
if etag != "" {
h.Set("ETag", `"`+etag+`"`)
}
if !img.LastUpdated.IsZero() {
h.Set("Last-Modified", img.LastUpdated.UTC().Format(http.TimeFormat))
}
- // Immutable only when the client asked for the exact current pixel hash; bare/legacy/mismatched
- // requests get cheap ETag revalidation instead, which fixes stale art after re-resolution.
+ // Immutable only when the client asked for the current pixel hash; anything else revalidates,
+ // so art that gets re-resolved is never pinned.
if requestedHash != "" && requestedHash == img.Hash {
h.Set("Cache-Control", "public, max-age=31536000, immutable")
} else {
@@ -51,8 +47,7 @@ func WriteImageHeaders(w http.ResponseWriter, r *http.Request, img *artwork.Imag
return false
}
-// ifNoneMatch reports whether the If-None-Match header asserts the given hash, using weak
-// comparison (RFC 9110): "*" matches any current representation and W/ prefixes are ignored.
+// ifNoneMatch reports whether If-None-Match asserts hash, using RFC 9110 weak comparison.
func ifNoneMatch(header, hash string) bool {
header = strings.TrimSpace(header)
if header == "" {
diff --git a/server/imghttp/headers_test.go b/server/imghttp/headers_test.go
index 80baae47d..2e8313faf 100644
--- a/server/imghttp/headers_test.go
+++ b/server/imghttp/headers_test.go
@@ -30,8 +30,8 @@ func placeholder() *artwork.Image {
return &artwork.Image{ReadCloser: io.NopCloser(strings.NewReader("PH")), Placeholder: true}
}
-// resized carries a representation ETag distinct from the pixel hash (as a resized/re-encoded
-// response does), so the validator versions with the encode settings.
+// A re-encoded response carries a representation ETag distinct from the pixel hash, so the
+// validator versions with the encode settings.
func resized() *artwork.Image {
return &artwork.Image{
ReadCloser: io.NopCloser(strings.NewReader("IMG")),
@@ -41,7 +41,6 @@ func resized() *artwork.Image {
}
}
-// unvalidated stands for a response with no content hash and no representation tag.
func unvalidated() *artwork.Image {
return &artwork.Image{ReadCloser: io.NopCloser(strings.NewReader("IMG")), LastUpdated: lastMod}
}
@@ -107,8 +106,8 @@ var _ = Describe("WriteImageHeaders", func() {
Entry("placeholder ignores If-None-Match and never 304s",
testCase{img: placeholder(), ifNoneMatch: "*", want304: false, wantCache: "no-store"}),
- // An image with neither ETag nor Hash has no validator. Emitting one would give every
- // such response the same empty tag, and matching it would 304 changed bytes.
+ // With no validator, an emitted ETag would be the same empty tag on every such response,
+ // and matching it would 304 changed bytes.
Entry("omits the ETag entirely when there is no validator",
testCase{img: unvalidated(), wantCache: "public, no-cache", wantLastMod: true}),
Entry("never 304s an empty validator echoed back by the client",
diff --git a/server/jellyfin/dto/mappers.go b/server/jellyfin/dto/mappers.go
index 974a50bd0..352fce11d 100644
--- a/server/jellyfin/dto/mappers.go
+++ b/server/jellyfin/dto/mappers.go
@@ -193,17 +193,15 @@ func SongToBaseItem(mf model.MediaFile, fields Fields) BaseItemDto {
} else if mf.Genre != "" {
item.Genres = []string{mf.Genre}
}
- // A track's own cover wins in Finamp's precedence (ImageTags.Primary before AlbumId).
+ // Clients prefer ImageTags.Primary over AlbumId, so a track's own cover must win here.
if mf.ImageHash != "" && mf.ImageHash != mf.AlbumImage.ImageHash {
tag, blurs, ratio := primaryImage(mf.ItemImage, mf.ID, fields)
item.ImageTags = map[string]string{"Primary": tag}
item.ImageBlurHashes = blurs
item.PrimaryImageAspectRatio = ratio
} else if embeddedArtPending(mf) {
- // Nothing enqueues media files, so an unresolved track only resolves when someone asks
- // for its image. Advertising the id here is what makes a Jellyfin client ask; the
- // request extracts the embedded art and queues the track for the worker. No blurhash:
- // there is no resolved image to have one yet, and a fake would be cached forever.
+ // Nothing enqueues media files: advertising the id is what makes a client ask, and that
+ // request is what extracts the embedded art and queues the track.
item.ImageTags = map[string]string{"Primary": mf.ID}
} else if mf.AlbumID != "" {
if tag, blurs, ratio := primaryImage(mf.AlbumImage, mf.AlbumID, fields); tag != "" {
@@ -215,15 +213,13 @@ func SongToBaseItem(mf model.MediaFile, fields Fields) BaseItemDto {
return item
}
-// embeddedArtPending reports a track whose own art is eligible but not resolved yet, and not
-// known to be absent.
func embeddedArtPending(mf model.MediaFile) bool {
return mf.HasCoverArt && conf.Server.EnableMediaFileCoverArt &&
mf.ImageHash == "" && !mf.ItemImage.ImageAbsent
}
-// primaryImage derives all a mapper advertises about one image, so Primary is chosen once. It never
-// fakes a blurhash: Finamp keys its cover cache on the value, pinning a stale cover forever (#5798).
+// primaryImage never fakes a blurhash: clients key their cover cache on the value, which would
+// pin a stale cover forever.
func primaryImage(img model.ItemImage, fallback string, fields Fields) (tag string, blurs map[string]map[string]string, ratio *float64) {
if img.ImageAbsent {
return "", nil, nil
diff --git a/server/jellyfin/dto/mappers_test.go b/server/jellyfin/dto/mappers_test.go
index e7234a203..a7b381c5c 100644
--- a/server/jellyfin/dto/mappers_test.go
+++ b/server/jellyfin/dto/mappers_test.go
@@ -290,8 +290,6 @@ var _ = Describe("mappers", func() {
Expect(string(b)).ToNot(ContainSubstring("NormalizationGain"))
})
- // Real Jellyfin only attaches it when the client asks (DtoService.ContainsField), and derives
- // it from the image's real dimensions.
Describe("PrimaryImageAspectRatio", func() {
nonSquare := func() model.Album {
al := model.Album{ID: "al1", Name: "Album"}
@@ -457,9 +455,8 @@ var _ = Describe("mappers", func() {
Expect(after.ImageTags["Primary"]).To(Equal(before.ImageTags["Primary"]))
})
- // Nothing enqueues media files, so a track's own art only resolves when something requests
- // it. A Jellyfin client that is only told about the album image never asks, so the track's
- // own cover would stay unreachable for Jellyfin-only users.
+ // Nothing enqueues media files, so a track's own art only resolves when a client requests it;
+ // advertising just the album image would leave that cover unreachable.
Describe("unresolved embedded art", func() {
BeforeEach(func() {
DeferCleanup(configtest.SetupConfig())
diff --git a/server/jellyfin/e2e/playlists_test.go b/server/jellyfin/e2e/playlists_test.go
index 2ba4b1608..08d6f4f6f 100644
--- a/server/jellyfin/e2e/playlists_test.go
+++ b/server/jellyfin/e2e/playlists_test.go
@@ -215,8 +215,8 @@ var _ = Describe("Playlists", func() {
To(Equal(http.StatusNotImplemented))
})
- // Guards the whole chain: an upload must clear any previously-resolved artwork state, or a
- // stale tag stays live under clients' blurhash-keyed cover cache until the next scan (#5798).
+ // An upload must clear the resolved artwork state, or clients keep serving the stale cover
+ // from their tag-keyed cache until the next scan.
It("clears the resolved image tag after a cover upload", func() {
plID := createPlaylist("Cover Tag", nil)
Expect(ds.Artwork(ctx).PutItemArtwork(&model.ItemArtwork{
@@ -233,8 +233,7 @@ var _ = Describe("Playlists", func() {
Expect(upload(adminUser, "/Items/"+enc(plID)+"/Images/Primary", "image/jpeg", jpeg).Code).
To(Equal(http.StatusNoContent))
- // The upload re-queues resolution instead of resolving inline, so the tag falls back to
- // unresolved rather than carrying over the pre-upload hash.
+ // The upload re-queues resolution instead of resolving inline, so the tag goes bare.
Expect(imageTag()).ToNot(Equal("1111111111111111"))
})
})
diff --git a/server/jellyfin/images_test.go b/server/jellyfin/images_test.go
index f8cdf0701..bdd47e956 100644
--- a/server/jellyfin/images_test.go
+++ b/server/jellyfin/images_test.go
@@ -75,8 +75,7 @@ var _ = Describe("Images", func() {
Expect(fa.recvId).To(ContainSubstring("a1"))
})
- // resolveArtworkID probes the entity tables, so a deleted item cannot produce an artwork id
- // at all -- which is what stops artwork state outliving its entity from being served here.
+ // resolveArtworkID probes the entity tables, so a deleted item yields no artwork id at all.
It("asks for no artwork once the item is deleted, rather than its lingering state", func() {
ds := &tests.MockDataStore{} // no albums/artists/tracks/playlists at all
fa := &fakeArtwork{}
diff --git a/server/nativeapi/artwork.go b/server/nativeapi/artwork.go
index 1fd8af479..ac7890661 100644
--- a/server/nativeapi/artwork.go
+++ b/server/nativeapi/artwork.go
@@ -9,7 +9,6 @@ import (
"github.com/navidrome/navidrome/model"
)
-// refreshableArtworkKinds are the entity kinds a manual re-resolve accepts.
var refreshableArtworkKinds = map[model.Kind]bool{
model.KindAlbumArtwork: true,
model.KindArtistArtwork: true,
@@ -22,7 +21,6 @@ func (api *Router) addArtworkRoute(r chi.Router) {
r.Post("/artwork/{kind}/{id}/refresh", api.refreshArtwork())
}
-// refreshArtwork clears an item's resolved artwork state and re-queues it at Bump priority.
// State is deliberately cleared so a wrong pick disappears immediately (placeholder until re-resolved).
func (api *Router) refreshArtwork() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
diff --git a/server/public/handle_images.go b/server/public/handle_images.go
index 1c45898c9..223dad5bb 100644
--- a/server/public/handle_images.go
+++ b/server/public/handle_images.go
@@ -39,9 +39,8 @@ func (pub *Router) handleImages(w http.ResponseWriter, r *http.Request) {
http.Error(w, "invalid request", http.StatusBadRequest)
return
}
- // Elevated like the Jellyfin image route: the token is the authorization, so the service's
- // entity check asks "is it still there", not "may this user see it" -- the latter would hide
- // a shared private playlist, which is the case shares exist to serve.
+ // The token is the authorization, so the entity check must ask "is it still there", not
+ // "may this user see it" -- the latter would hide a shared private playlist.
ctx = request.WithUser(ctx, model.User{IsAdmin: true})
size := p.IntOr("size", 0)
square := p.BoolOr("square", false)
diff --git a/server/public/handle_images_test.go b/server/public/handle_images_test.go
index cef4e0851..214108bef 100644
--- a/server/public/handle_images_test.go
+++ b/server/public/handle_images_test.go
@@ -53,8 +53,7 @@ var _ = Describe("handleImages", func() {
return httptest.NewRequest("GET", "/img?:id="+url.QueryEscape(token), nil)
}
- // The handler re-checks that the entity behind the token still exists, so every spec needs
- // a store where "1" is a live album.
+ // The handler re-checks that the entity behind the token still exists, so "1" must be live.
var ds *tests.MockDataStore
BeforeEach(func() {
diff --git a/server/subsonic/e2e/subsonic_artwork_test.go b/server/subsonic/e2e/subsonic_artwork_test.go
index 2679b4324..e757ebf14 100644
--- a/server/subsonic/e2e/subsonic_artwork_test.go
+++ b/server/subsonic/e2e/subsonic_artwork_test.go
@@ -43,9 +43,8 @@ import (
"go.senan.xyz/taglib"
)
-// The artwork serving path streams folder-backed originals via os.Open, which a fake FS cannot
-// back, so this suite scans a small REAL on-disk library and drives the real acquisition worker
-// and artwork.Artwork through the Subsonic and public image handlers.
+// The serving path streams folder-backed originals via os.Open, which a fake FS cannot back, so
+// this suite scans a small REAL on-disk library and drives the real worker and artwork.Artwork.
var _ = Describe("Artwork Serving", Ordered, func() {
var (
artRouter *subsonic.Router
@@ -105,9 +104,8 @@ var _ = Describe("Artwork Serving", Ordered, func() {
ds = &tests.MockDataStore{RealDS: persistence.New(db.Db())}
auth.Init(ds)
- // A re-scan of already-populated data deadlocks the parallel album-refresh phase at the
- // harness's MaxOpenConns=1, so wipe the golden content and import ONLY the real artwork
- // library fresh (the import path never enters that refresh phase).
+ // Re-scanning already-populated data deadlocks the parallel album-refresh phase at the
+ // harness's MaxOpenConns=1, so wipe the golden content and import this library fresh.
wipeScannedContent()
artLib := model.Library{Name: "Artwork Library", Path: musicDir}
Expect(ds.Library(ctx).Put(&artLib)).To(Succeed())
@@ -201,9 +199,8 @@ var _ = Describe("Artwork Serving", Ordered, func() {
Expect(w.Header().Get("Cache-Control")).To(Equal("no-store"))
})
- // The BeforeAll grants the artwork library to adminUser only, so regularUser cannot see
- // this album -- but its artwork state and bytes are perfectly servable by id. The service
- // resolves the entity through the caller's repositories, so the filter still applies.
+ // Only adminUser was granted this library, so regularUser must get the placeholder even
+ // though the artwork state and bytes are perfectly servable by id.
It("serves the placeholder for an album in a library the caller cannot see", func() {
w := httptest.NewRecorder()
artRouter.ServeHTTP(w, buildReq(regularUser, "getCoverArt", "id", "al-"+artfulID))
@@ -212,12 +209,10 @@ var _ = Describe("Artwork Serving", Ordered, func() {
Expect(w.Body.Bytes()).To(Equal(placeholder), "must not leak the real cover")
Expect(w.Header().Get("Cache-Control")).To(Equal("no-store"))
- // ...while the admin, who does have access, gets the real bytes for the same id.
Expect(getCover("id", "al-"+artfulID).Body.Bytes()).ToNot(Equal(placeholder))
})
- // An album with no art and an id naming no album are different answers; only the former
- // is a placeholder.
+ // An id naming no entity is a different answer from an album with no art: no placeholder.
It("answers error 70 for an id that matches no entity", func() {
w := getCover("id", "al-nosuchalbum")
Expect(w.Code).To(Equal(http.StatusOK))
@@ -261,8 +256,8 @@ func buildArtworkRouter(art artwork.Artwork) *subsonic.Router {
)
}
-// wipeScannedContent clears all scanned library content (including the golden fake library) so the
-// next scan is a clean import. FKs are disabled for the bulk delete, mirroring harness.Restore.
+// wipeScannedContent clears all scanned content so the next scan is a clean import. FKs are
+// disabled for the bulk delete, mirroring harness.Restore.
func wipeScannedContent() {
GinkgoHelper()
_, err := db.Db().Exec("PRAGMA foreign_keys = OFF")
@@ -283,8 +278,7 @@ func albumIDByName(name string) string {
return albums[0].ID
}
-// writeArtworkTrack lays down one tag-distinct track (and optionally a folder cover) so the scanner
-// creates a self-contained album; distinct ALBUM/ALBUMARTIST tags keep the two albums from merging.
+// Distinct ALBUM/ALBUMARTIST tags keep the generated albums from merging into one.
func writeArtworkTrack(root, artist, album, title string, withCover bool) {
GinkgoHelper()
dir := filepath.Join(root, artist, album)
@@ -306,9 +300,8 @@ func readArtworkFixture(name string) []byte {
return data
}
-// newDummyImageCache backs the artwork.Artwork's resize cache. size=0 requests stream originals
-// and never invoke the reader, so it only needs to satisfy the constructor; resize behavior is
-// covered by the artwork package's own suites.
+// size=0 requests stream originals and never invoke the reader, so this resize cache only has
+// to satisfy the constructor.
func newDummyImageCache(ctx context.Context) cache.FileCache {
GinkgoHelper()
c := cache.NewFileCache("SubsonicArtworkE2E", "100MB", "images", 0,
@@ -319,7 +312,6 @@ func newDummyImageCache(ctx context.Context) cache.FileCache {
return c
}
-// runWorkerUntil drives the real worker loop until a condition holds, then cancels and joins it.
func runWorkerUntil(ctx context.Context, worker *artwork.Worker, until func() bool) {
GinkgoHelper()
runCtx, cancel := context.WithCancel(ctx)
diff --git a/server/subsonic/helpers_test.go b/server/subsonic/helpers_test.go
index e437ada6c..51a92ac4c 100644
--- a/server/subsonic/helpers_test.go
+++ b/server/subsonic/helpers_test.go
@@ -349,8 +349,7 @@ var _ = Describe("helpers", func() {
})
Describe("childFromMediaFile", func() {
- // The album id carries the album's hash, which hydration puts in AlbumImage; the
- // track's own ItemImage describes its own art and must not be stamped onto an al- id.
+ // An al- id must carry the album's hash (AlbumImage), never the track's own.
It("suffixes coverArt with the album's content hash when resolved", func() {
mf := model.MediaFile{ID: "mf-1", AlbumID: "al-1", AlbumImage: model.ItemImage{ImageHash: hash}}
Expect(childFromMediaFile(ctx, mf).CoverArt).To(Equal("al-al-1_" + hash))
diff --git a/server/subsonic/media_retrieval_test.go b/server/subsonic/media_retrieval_test.go
index 63ceaa8fa..7610c866a 100644
--- a/server/subsonic/media_retrieval_test.go
+++ b/server/subsonic/media_retrieval_test.go
@@ -67,9 +67,8 @@ var _ = Describe("MediaRetrievalController", func() {
Expect(w.Body.String()).To(Equal(artwork.data))
})
- // Visibility now lives in the service, which resolves the entity through the
- // request-scoped repositories. The handler's whole contribution is handing over the
- // caller's context unchanged -- elevating here would bypass the library filter.
+ // The service applies the library filter from the caller's context, so elevating here
+ // would bypass it.
It("passes the caller's context to the service rather than elevating", func() {
r := newGetRequest("id=al-34")
usr := model.User{ID: "u1", UserName: "u1"}
diff --git a/tests/harness/harness.go b/tests/harness/harness.go
index b4da0377e..5949c4fae 100644
--- a/tests/harness/harness.go
+++ b/tests/harness/harness.go
@@ -86,9 +86,8 @@ func SetupDB(ctx context.Context, users ...*model.User) *DB {
return h
}
-// ResettableTables lists the tables a per-spec reset may write. FTS shadow tables are excluded;
-// they are kept in sync by their content tables' triggers, and writing them directly corrupts the
-// index.
+// ResettableTables lists the tables a per-spec reset may write. FTS shadow tables are excluded:
+// their content tables' triggers keep them in sync, and writing them directly corrupts the index.
func ResettableTables() []string {
rows, err := db.Db().Query("SELECT name FROM main.sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' AND name NOT LIKE '%_fts' AND name NOT LIKE '%_fts_%'")
Expect(err).ToNot(HaveOccurred())
@@ -103,9 +102,8 @@ func ResettableTables() []string {
return tables
}
-// TruncateDB empties every resettable table, leaving the migrated schema in place. Suites whose
-// specs each build their own library use this instead of a golden snapshot, since re-migrating the
-// schema per spec costs ~400ms.
+// TruncateDB empties every resettable table, leaving the migrated schema in place — for suites
+// whose specs each build their own library, so the schema is not re-migrated per spec.
func TruncateDB(tables []string) {
sqlDB := db.Db()
_, err := sqlDB.Exec("PRAGMA foreign_keys = OFF")
diff --git a/tests/mock_artwork_queue_repo.go b/tests/mock_artwork_queue_repo.go
index 0068d6087..82661d728 100644
--- a/tests/mock_artwork_queue_repo.go
+++ b/tests/mock_artwork_queue_repo.go
@@ -11,13 +11,13 @@ import (
type MockArtworkQueueRepo struct {
model.ArtworkQueueRepository
- // mu guards Data so the worker's concurrent drain can hit this mock race-free.
+ // mu guards Data: the worker drains this mock concurrently.
mu sync.Mutex
Data map[string]model.ArtworkQueueItem // keyed by iaKey(kind, id, imageType)
Err error
// ItemArtworkSource, when set, backs EnqueueStaleAbsent with real item_artwork state.
ItemArtworkSource *MockArtworkRepo
- // ExistingIDs, keyed by item_kind, backs PurgeDangling; a nil per-kind map keeps that kind.
+ // ExistingIDs is keyed by item_kind; a nil per-kind map means PurgeDangling keeps that kind.
ExistingIDs map[string]map[string]bool
}
@@ -191,8 +191,7 @@ func (m *MockArtworkQueueRepo) EnqueueStaleAbsent(kind model.Kind, attemptedBefo
return inserted, nil
}
-// EnqueueMissing enqueues entities in ExistingIDs[kind] that have no item_artwork row in
-// ItemArtworkSource and are not already queued, mirroring the SQL set-difference insert.
+// EnqueueMissing mirrors the SQL set-difference insert: ExistingIDs[kind] minus ItemArtworkSource.
func (m *MockArtworkQueueRepo) EnqueueMissing(kind model.Kind) (int64, error) {
m.mu.Lock()
defer m.mu.Unlock()
diff --git a/ui/src/album/AlbumGridView.jsx b/ui/src/album/AlbumGridView.jsx
index 983089416..e17fb617e 100644
--- a/ui/src/album/AlbumGridView.jsx
+++ b/ui/src/album/AlbumGridView.jsx
@@ -232,8 +232,7 @@ const LoadedAlbumGrid = ({ ids, data, basePath, width }) => {
}
const AlbumGridView = ({ albumListType, loaded, loading, seed, ...props }) => {
- // A re-roll replaces every album, so the previous roll must not linger while it loads. Blanking
- // on any load instead collapsed the grid to a spinner on each search keystroke.
+ // A re-roll replaces every album, so the previous roll must not linger while it loads.
const rerolling = useRollChanged(seed, loading) && albumListType === 'random'
const hide = rerolling || !props.data || !props.ids
return hide ? :
diff --git a/ui/src/album/useRollChanged.test.jsx b/ui/src/album/useRollChanged.test.jsx
index b7920a331..d0074a077 100644
--- a/ui/src/album/useRollChanged.test.jsx
+++ b/ui/src/album/useRollChanged.test.jsx
@@ -8,8 +8,8 @@ describe('useRollChanged', () => {
initialProps: props,
})
- // A re-roll redirects and remounts the grid, so "mounted with a load in flight" is exactly the
- // case where the store still holds the previous roll. Nothing is settled until that load lands.
+ // A re-roll remounts the grid, so on mount a load in flight means the store still holds the
+ // previous roll.
it('reports a change while a load is in flight on a fresh mount', () => {
const { result } = setup({ seed: 's1', loading: true })
expect(result.current).toBe(true)
@@ -20,15 +20,12 @@ describe('useRollChanged', () => {
expect(result.current).toBe(false)
})
- // Typing in the search box refetches with the same seed: the albums on screen still belong to
- // the roll being loaded, so they must stay put.
it('stays false while loading a filter change on the same roll', () => {
const { result, rerender } = setup({ seed: 's1', loading: false })
rerender({ seed: 's1', loading: true })
expect(result.current).toBe(false)
})
- // A new seed is a new roll, so what is on screen is about to be replaced wholesale.
it('goes true while loading after the seed changes', () => {
const { result, rerender } = setup({ seed: 's1', loading: false })
rerender({ seed: 's2', loading: true })
@@ -43,8 +40,7 @@ describe('useRollChanged', () => {
expect(result.current).toBe(false)
})
- // The seed can land a render before loading flips, which would otherwise record the new roll as
- // already shown and skip the blank entirely.
+ // The seed can land a render before loading flips; the new roll must not be recorded as shown.
it('still reports a change when the seed arrives before loading starts', () => {
const { result, rerender } = setup({ seed: 's1', loading: false })
rerender({ seed: 's2', loading: false })
diff --git a/ui/src/common/Artwork.jsx b/ui/src/common/Artwork.jsx
index 4c9398a4e..52d59f403 100644
--- a/ui/src/common/Artwork.jsx
+++ b/ui/src/common/Artwork.jsx
@@ -11,7 +11,7 @@ import { BlurHashCanvas } from './BlurHashCanvas'
const fadeMs = 500
const useStyles = makeStyles({
- // className supplies the size and shape; overflow:hidden clips the fills to a rounded shape.
+ // className supplies the size and shape; overflow:hidden clips the fills to it.
root: {
position: 'relative',
display: 'inline-flex',
@@ -34,9 +34,8 @@ const useStyles = makeStyles({
imgInstant: { opacity: 1, transition: 'none' },
})
-// Artwork renders an entity's cover through the shared useImageUrl blob cache, so it survives
-// React remounts without re-fetching. The blurhash is the loading placeholder; the image is only
-// mounted once its blob is ready, so an unresolved cover never renders as a broken
.
+// Renders a cover through the shared useImageUrl blob cache, so it survives remounts without
+// re-fetching. The image mounts only once its blob is ready, so it never renders broken.
export const Artwork = ({
record,
size = config.uiCoverArtSize,
@@ -50,8 +49,7 @@ export const Artwork = ({
const url = record ? subsonic.getCoverArtUrl(record, size, square) : ''
const { imgUrl } = useImageUrl(url)
- // A blob already cached when this instance mounted paints on the first frame, so it skips the
- // fade; anything fetched later cross-fades over the blurhash.
+ // A blob already cached at mount paints on the first frame, so it skips the fade.
const cachedOnMount = useRef(null)
if (cachedOnMount.current === null) {
cachedOnMount.current = !!imgUrl
@@ -63,8 +61,7 @@ export const Artwork = ({
setFaded(false)
}, [url])
- // Retire the blurhash on a timer rather than transitionend: under prefers-reduced-motion the
- // transition is none, so the event never fires and the placeholder would stay up forever.
+ // Timer, not transitionend: under prefers-reduced-motion there is no transition to end.
useEffect(() => {
if (!decoded || faded) return undefined
const timer = setTimeout(() => setFaded(true), fadeMs)
@@ -74,11 +71,9 @@ export const Artwork = ({
if (!record) return null
const instant = cachedOnMount.current
- // The blurhash stays mounted under the image until the fade ends. Swapping them the moment the
- // blob arrives would expose the empty container for the length of the fade.
+ // Kept mounted until the fade ends; swapping on blob arrival would flash an empty container.
const showBlurHash = !!record.blurHash && !instant && !faded
- // A square request is padded, not cropped, so its content is already aspect-fit inside the square
- // the server returns; `contain` is what keeps placeholder and image on the same pixels.
+ // A square request is padded, not cropped, so `contain` keeps placeholder and image aligned.
const effectiveFit = square ? 'contain' : fit
const ratio = record.imageWidth / record.imageHeight
const handleClick = imgUrl && onClick ? onClick : undefined
diff --git a/ui/src/common/Artwork.test.jsx b/ui/src/common/Artwork.test.jsx
index 7b9117860..22192bc6b 100644
--- a/ui/src/common/Artwork.test.jsx
+++ b/ui/src/common/Artwork.test.jsx
@@ -56,8 +56,7 @@ describe('Artwork', () => {
expect(canvas.style.objectFit).toBe('contain')
})
- // A square request is padded, not cropped, so the artwork still sits letterboxed inside the
- // square the server returns and the placeholder has to letterbox with it.
+ // A square request is padded, not cropped, so the placeholder has to letterbox with it.
it('letterboxes the blurhash when the server pads a non-square image to a square', () => {
useImageUrl.mockReturnValue({ imgUrl: null, loading: true })
const nonSquare = { ...withArt, imageWidth: 1200, imageHeight: 800 }
@@ -68,8 +67,7 @@ describe('Artwork', () => {
expect(canvas.style.objectFit).toBe('contain')
})
- // The padded square the server returns is aspect-fit, so cropping it would disagree with the
- // placeholder. Both renderers have to read `square` the same way.
+ // The padded square is aspect-fit, so `square` overrides fit="cover" as it does for the canvas.
it('fits the image itself with contain when the server padded to a square', () => {
useImageUrl.mockReturnValue({ imgUrl: 'blob:abc', loading: false })
const { container } = render(
@@ -109,15 +107,13 @@ describe('Artwork', () => {
useImageUrl.mockReturnValue({ imgUrl: null, loading: true })
const { container, rerender } = render()
- // Blob arrives: the image mounts transparent, with the blurhash still behind it.
useImageUrl.mockReturnValue({ imgUrl: 'blob:abc', loading: false })
rerender()
const img = container.querySelector('img')
expect(img).not.toBeNull()
expect(container.querySelector('canvas')).not.toBeNull()
- // Decoding starts the cross-fade; the blurhash only goes away once it finishes. The clock
- // drives it, not transitionend, which never fires under prefers-reduced-motion.
+ // The clock ends the fade, not transitionend, which never fires under reduced-motion.
act(() => {
fireEvent.load(img)
})
@@ -138,7 +134,6 @@ describe('Artwork', () => {
it('does not fade an image that was already cached on mount', () => {
useImageUrl.mockReturnValue({ imgUrl: 'blob:abc', loading: false })
const { container } = render()
- // No placeholder to cross-fade from, so it paints at once.
expect(container.querySelector('canvas')).toBeNull()
expect(container.querySelector('img').className).toContain('imgInstant')
})
diff --git a/ui/src/common/BlurHashCanvas.test.jsx b/ui/src/common/BlurHashCanvas.test.jsx
index c6f373c82..ebbe811b9 100644
--- a/ui/src/common/BlurHashCanvas.test.jsx
+++ b/ui/src/common/BlurHashCanvas.test.jsx
@@ -92,7 +92,6 @@ describe('BlurHashCanvas', () => {
rerender()
expect(ctxMock.clearRect).toHaveBeenCalledTimes(2)
- // No new pixels drawn after the clear, so the stale frame stays gone.
expect(ctxMock.putImageData).toHaveBeenCalledTimes(1)
})
})
diff --git a/ui/src/common/useScrollToTop.test.jsx b/ui/src/common/useScrollToTop.test.jsx
index 631d1362e..89bde3d37 100644
--- a/ui/src/common/useScrollToTop.test.jsx
+++ b/ui/src/common/useScrollToTop.test.jsx
@@ -12,8 +12,7 @@ describe('useScrollToTop', () => {
expect(window.scrollTo).toHaveBeenCalledWith({ top: 0 })
})
- // Navigating straight from one detail page to another (an album's artist link, say) reuses the
- // component, so only the key change tells us we are looking at something new.
+ // Detail-to-detail navigation reuses the component, so only the key change signals a new page.
it('scrolls again when the key changes', () => {
const { rerender } = renderHook(({ id }) => useScrollToTop(id), {
initialProps: { id: 'al-1' },
@@ -32,8 +31,7 @@ describe('useScrollToTop', () => {
expect(window.scrollTo).toHaveBeenCalledTimes(1)
})
- // The record arrives after the first render, so the key starts undefined; scrolling then would
- // fire before the page has its content and read as a no-op.
+ // The record arrives after the first render, so the key starts undefined.
it('waits for a key rather than scrolling on an empty record', () => {
const { rerender } = renderHook(({ id }) => useScrollToTop(id), {
initialProps: { id: undefined },
diff --git a/utils/cache/file_caches_test.go b/utils/cache/file_caches_test.go
index 4dd5bb626..7ce1b0393 100644
--- a/utils/cache/file_caches_test.go
+++ b/utils/cache/file_caches_test.go
@@ -342,8 +342,7 @@ var _ = Describe("File Caches", func() {
})
It("re-fetches when an adopted entry's data file vanished", func() {
- // Entries adopted on startup take a different code path than ones
- // created in-process, so cover both.
+ // Entries adopted on startup take a different code path than in-process ones.
var calls atomic.Int32
fc := callNewFileCache("test", "10MB", "test", 0, func(ctx context.Context, arg Item) (io.Reader, error) {
calls.Add(1)
diff --git a/utils/cache/spread_fs_test.go b/utils/cache/spread_fs_test.go
index 44b14db2b..d0b54567d 100644
--- a/utils/cache/spread_fs_test.go
+++ b/utils/cache/spread_fs_test.go
@@ -44,8 +44,7 @@ var _ = Describe("Spread FS", func() {
Describe("Create", func() {
It("leaves an already-open reader's bytes intact", func() {
- // A re-created entry must not shrink the file an older stream is still
- // serving: its reader would spin forever at the premature EOF.
+ // Shrinking a file an older stream still serves spins its reader at a premature EOF.
if runtime.GOOS == "windows" {
Skip("Windows cannot unlink a file with open handles, so Create reuses the inode")
}