Cleanup pass over the three preceding commits.
Tabulate the cosine terms in the UI blurhash decoder instead of calling Math.cos
per pixel per component: 248us -> 75us for a 32x32 decode, and an album grid
mounts one decoder per tile. Output is unchanged, which the pinned pixel specs
enforce. The Go encoder already tabulated the same terms.
Drop the dead paths that deriving components inside Encode left behind: the
zero-size guard in components, the post-downscale empty check, and the
no-AC-factor branch, which cannot be reached now that the counts are always at
least 1x9. The empty-image check moves ahead of the derivation, where it belongs.
In the queue mock, look up item_artwork by its existing iaKey rather than
scanning the map, and hold the lock across EnqueueIfMissing through a shared
unlocked helper instead of releasing it mid-operation. Extract the duplicated
drain-and-resolve block in the scanner specs into one helper.
The UI pulled in the `blurhash` dependency for one function, `decode`, called
from a single component. The decoder is ~80 lines of well-specified arithmetic,
so carrying a dependency for it costs more in supply chain and bundle than it
saves.
Equivalence was proven against the package before removing it: 84 hashes — three
real ones plus every component count from 1x1 to 9x9 — decoded at six sizes,
compared byte for byte, plus parity on which malformed inputs throw. Those pixel
values are now pinned in the spec, so drift from the reference algorithm fails.
The punch parameter is dropped rather than reproduced: no caller passes one, and
the package applies `punch | 1`, which silently turns a punch of 2 into 3.
Components had a single caller, which only ever fed it the bounds of the image
it then passed to Encode. Exporting it gave callers two ways to get it wrong —
components mismatched with the image, or out of the 1..9 range — in exchange for
a knob nobody turned.
Derive them from img.Bounds() at the top of Encode and unexport the helper. The
counts must come from the pre-downscale bounds: downscale's integer rounding can
shift the ratio across a component boundary, and the hash is a client-side cache
key. Verified byte-identical over 18 hashes spanning 9 aspect ratios.
The out-of-range validation goes with it, being unreachable once the counts are
always derived. The aspect-ratio table now asserts through Encode's size flag,
which encodes (x-1)+(y-1)*9.
A full scan re-imports every track, so the per-entity artwork enqueue in
persistChanges fired for every album and artist in the library — measured as
100% of both on a live instance, and ~8.5k artists / ~16.7k Deezer fetches on a
96k-file library. Re-importing a track is no evidence the art changed: artist
art has no track-content source at all, and the albums re-derived to identical
hashes across three consecutive scans.
Enqueue through EnqueueIfMissing on a full scan, which anti-joins item_artwork
so only entities that never resolved are queued. Incremental scans keep the
unconditional Enqueue, where a re-import does mean the file changed. Doing this
at the enqueue site rather than skipping it wholesale keeps a first scan filling
in covers as it walks, instead of stalling every cover until the scan ends.
EnqueueMissing grows a priority argument and runs once at end of scan, so an
entity phase 1 never saw still resolves, at Scan priority rather than dropping
to Recheck behind the backfill.
album/artist/playlist/radio each carried the same eight lines, differing
only in element type and model.Kind. hydrateItems takes a ref callback
yielding an item's id and the ItemImage to fill, which is all that varied.
The len()==0 guards drop out: hydrateItemImages already short-circuits an
empty id list, and both loops are no-ops on an empty slice. applyItemImage
stays as-is; hydrateMediaFileArtwork and its own specs still use it.
Mechanical cleanups, no behavior change:
- 15 copies of the same id-extraction loop collapse to slice.Map (5 repo
mocks, the scanner's track sweep, 4 wantIDs assertions) and slice.ToMap
(6 index-by-id loops in the hydration specs).
- disc.go built a map[string]bool purely to dedup folder ids and then
walked it back into a slice; slice.Unique says that directly.
- folders_artist.go's image filter is slice.Filter over model.IsImageFile.
- mock_artwork_repo deleted from a map while ranging it; maps.DeleteFunc
states the intent.
- sort.Slice -> slices.SortFunc + cmp.Or; math.Min/Max -> builtin min/max;
make+copy -> bytes.Clone; strings.Split -> SplitSeq on a per-request
path; three-clause pixel loops -> for range.
- Reuse utils.BaseName where a stem was recomputed by hand. Not at
playlist_cover.go:27: that path is a full OS path and utils.BaseName
uses path.Base, which does not split backslashes.
- Drop a dead nil-guard in agents.go: getAgent returns a bare nil
interface, and a type assertion on nil already yields ok == false.
cmp.Or was rejected for the gate fallback (func types are not comparable,
does not compile) and for ItemArtwork.AttemptedAt (cmp.Or compares
time.Time with ==, which includes loc; IsZero does not).
The follow-up loop could never fail: once the first "ar" index is asserted
to be 0, every non-"ar" element is necessarily at an index greater than 0.
A sequence like ["ar", "al", "ar"] passed both assertions, which is exactly
the interleaving the check exists to forbid.
Assert the partition directly instead: nothing after the first non-artist
call may be an artist. Verified by mutation — enqueueing artists a second
time after albums now fails the spec.
Comments only; no executable code changed. Verified by comparing the Go
token stream of every touched file before and after: identical.
Removes 375 of the 1104 comment lines this branch added, targeting content
that belongs in a commit message or PR body rather than in the code:
rejected alternatives ("DeleteIfUnchanged, not Delete", "Waking all beats
routing by kind"), refactor history ("as the legacy reader did"), issue
references (#5798, #5597, #5376), benchmark numbers (~400ms, ~16k allocs),
and four persistence doc comments that duplicated the interface godoc in
model/artwork.go verbatim.
Comments predating this branch are left untouched.
The ASCII fixture trees in the e2e suites are deliberately kept above the
line budget: they diagram the fixture layout with its expected outcomes,
and every pre-existing block in those files carries one.
The enqueue sat in refreshCounters, which Put also reaches for an
ordinary metadata update, so renaming a playlist or editing its comment
re-resolved the cover. The 2x2 grid samples albums with random(), so
that silently handed the playlist a different cover for an edit that
touched no tracks -- and refetched remote artwork to do it.
It now happens where the track set actually changes: addTracks (which
Put-with-tracks and updatePlaylist both funnel through) and renumber
(reached from removeOrphans). Creation still enqueues even with no
tracks, since an imported m3u can carry an ExternalImageURL.
Reported by Codex on #5847.
Artwork state and its bytes outlive a deleted entity until the next
prune (@daily), and the serving path consulted only item_artwork, so a
Subsonic id or a signed public token kept serving a removed entity's
image in the meantime. Master's readers loaded the entity first, so this
was a regression.
The check goes in serveHash, the one path that can hand back a found
row's bytes: absent rows are already unavailable, and the provisional
and disc paths load their entity to resolve at all. Doing it there
instead of per-handler also settles who owns the invariant. Subsonic had
worked around it with artworkAccessible, whose comment described the
service "bypassing the library and private-playlist filters"; that
workaround is now deleted, since the service resolves through the
request-scoped repositories and enforces the filters itself.
Because those repositories are ctx-scoped, each caller says what it
wants by what it passes: Subsonic hands over the request context and so
gets visibility as well as existence, while the public image route
elevates like the Jellyfin one already did -- a token is the
authorization there, and a visibility check would hide a shared private
playlist, the very case shares exist for.
Two supporting fixes. albumRepository.Exists and mediaFileRepository
.Exists used the plain exists() helper, which applies no library filter,
so they reported rows in libraries the caller cannot see; they now count
through applyLibraryFilter as CountAll and artistRepository.Exists
already do. Neither had a production caller. RadioRepository gained the
Exists it lacked.
Tests follow the layers: the service refuses a found row whose entity is
gone, the repositories hide rows the caller may not see, and the
handlers only assert the context they hand over. Jellyfin needed no
change -- resolveArtworkID probes the entity tables, so a deleted item
yields an empty artwork id -- but that protection was incidental and
untested, so it is pinned now.
artworkAccessible checked every kind except radio, which fell through to
the default "no per-user access control" branch and returned true without
a lookup. Radio deletion removes only the entity row -- item_artwork and
the uploaded file survive until the next prune -- so the old ra- id kept
serving the removed radio's image for up to a day.
This is a regression against master, where newRadioArtworkReader loaded
the radio first and returned its error, so deletion took effect at once.
Radios stay globally visible; only existence is checked.
CreateMockedRadioRepo left Data nil, so its own Put panicked on first
use; initialized it.
Reported by Codex on #5847.
fmt.Sprintf ran on every newly-decoded image even with Debug off, since
Go evaluates log arguments regardless of level. Separate width/height
fields also query better than a "300x300" string.
Correction to e0f1acd1a's message: it said "all 40" messages carry the
prefix. The package has 68 log call sites and 62 distinct messages; 40
was only what the test suite happened to exercise. The sweep itself was
complete -- zero unprefixed messages remain.
Prefix: 22 messages still logged unprefixed, so a line from this package
was indistinguishable from any other subsystem's. All 40 now carry
"Artwork: ", matching Scanner:/API:/Watcher: -- which earns its place
because DevLogSourceLine is off by default.
Timing on what can actually be slow: total per acquisition (on every
exit, failures included), the read that also covers the provider
download, hashing, decode+blurhash, resize, drain batch, precache,
prune, backfill, and the external agent call -- with the rate-limiter
wait counted separately, since a throttled agent and a slow one look
identical from the drain.
Debug coverage for states that were previously silent: dedup hit vs
decode, settling absent, serving a lower-priority source after an
external failure, retry scheduling with attempts and budget left, giving
up when the budget runs out, breaker open/close per agent, provisional
read-through, dangling state rows, and the mtime mismatch that makes art
appear to vanish. outcome gained a String() so it reads as a name.
TrimSuffix(s, "0s") was meant to turn "4h0m0s" into "4h", but it strips
any trailing "0s"/"0m" -- so 10s logged as "1", 20s as "2", 1m30s as
"1m3", 2h30m as "2h3", and a zero duration as the empty string. Every
elapsed/duration field in the app was affected.
The suffix now has to include the preceding unit, so only a whole
zero-valued component is dropped. The existing table only covered values
that dodge the bug (4m, 4h, 4m3s); added the ones that don't.
The three methods shared one comment attached to Backfill, so godoc
rendered the other two undocumented and the one it did show described
the group rather than the call. Each now opens with its own name and
says what that call does, including Backfill's bool return.
Every other core interface is named for what it is -- Playlists,
Library, Scrobbler, MediaStreamer -- while this one was artwork.Service,
the only X.Service in the tree. It becomes artwork.Artwork/NewArtwork,
and serving.go follows the type to artwork.go.
ProvideImageStore was likewise the only "func Provide" in the repo; it
is GetImageStore now, matching GetImageCache beside it. cmd/wire_gen.go
regenerated via make wire.
scheduleArtworkHousekeeping received a *artwork.Worker and then called
CreateDataStore() for a second handle onto the state that Worker already
owns, because Backfill, EnqueueStaleAbsentAll and EnqueueMissingAll were
free functions taking a DataStore.
They are now Worker methods over unexported implementations, the same
shape prune/RunPrune already uses: one public path, and the specs keep
calling the plain function with a mock store instead of standing up a
Worker. Fingerprint is unexported too -- nothing outside the package
used it.
Every source in the album and artist chains repeated the same five
lines: stamp the accumulated external failure onto a hit, or OR the
local fault into the running total on a miss. Each new source was a
chance to forget the OR, which is how the local-I/O-settles-absent bug
happened.
chainState.try does both, so each case drops to three lines and the
omission is no longer expressible. Semantics are unchanged: a hit still
carries extErr only. Also drops localErr from resolvePlaylist, which
declared it but never assigned it.
refresh.go was a 21-line file for one function that clears artwork state
and enqueues -- the same thing Backfill, EnqueueStaleAbsentAll and
EnqueueMissingAll already do next door.
Pure move.
resizedItem is the only implementation of artworkReader, which is
declared in image_cache.go, and it is used by the worker's precache as
well as the serving path -- so worker.go was reaching into serving.go
for a cache type. representationTag stays in serving.go, where the
HTTP validator belongs.
Pure move.
decodeTile and assembleTiles were in resolve.go while the geometry they
depend on -- rect, fillCenter, tileSize -- was in playlist_cover.go and
used nowhere else, so one file held the grid's helpers and another its
assembly.
Pure move.
gateFunc, passthroughGate and isTransientExternal sat in agent_images.go
while every implementation lived in worker.go: extGate, breaker,
Worker.gate, gateFor. isTransientExternal even carries a comment saying
it must stay consistent with breaker.record, which was in the other
file -- a rule spanning two files with only a comment holding it
together.
Pure move into gate.go: no symbol added or removed.
workerDeps was a parameter bag threaded into two free functions that
nothing outside the worker calls. It becomes the processor type, with
processItem and persist as acquire and persist methods on it, and Worker
holds one collaborator instead of reaching through a bag.
Kept as a separate type rather than folding onto Worker: acquisition
takes a queue item and returns bytes, while Worker.process settles the
queue row around it. That boundary is what keeps retry policy out of the
image pipeline, and what lets the acquisition specs build a three-field
value instead of a Worker with drain pools, gates, a broker and a
real on-disk cache.
The bag carried cache, which processItem never reads and only the
worker's precache uses, and carried agents/ffmpeg/gate solely to
reconstruct a resolver on every queue item. cache and ffmpeg move to
Worker, where precache actually uses them, and the resolver is built
once in NewWorker.
persist's hash parameter was redundant: decodeArtwork sets Hash and
GetImage selects it, so art.Hash already holds it on both paths.
The type itself now lives beside Worker, which owns it, rather than in
the file of the function it is passed to.
The package logged under three spellings of its own name -- "artwork: "
lowercase, "Prune: " and one "Artwork: " -- and the lowercase ones
carried lowercase message text, against 568 capitalized to 48 lowercase
elsewhere.
Prefixing itself is the convention here (Scanner:, API:, Watcher:) and
it earns its place: DevLogSourceLine is off by default, so without it a
line does not say which subsystem emitted it. So this normalizes the
spelling rather than dropping the prefix. Error strings stay lowercase
and unprefixed per Go convention.
serveHash, serveBytes and serveDisc each hand-copied the same five steps
-- test for full size, stream or build a resizedItem, call the cache,
wrap with a validator -- with a different error policy bolted on. The
ETag rule was restated at four sites and applied inconsistently.
serveSource now states it once: full size streams open() directly, and
an ETag is attached only when the bytes are resized or there is no hash
to validate against. Each caller keeps just its own error policy, and
serveBytes folds into its single caller.
Two behavior changes fall out, both narrowing an aborted request's blast
radius: serveDisc propagates context.Canceled instead of falling back to
a full album resolution, and serveHash's full-size path propagates it
instead of going dangling, which would have enqueued a re-resolution for
a request nobody is waiting on.
The resolve* chain walkers each took seven parameters -- ds, agents,
ffmpeg, gate, localOnly -- while the package already had workerDeps
bundling the same collaborators for processItem. They are now methods on
a resolver.
The external capability is one nilable field instead of three values
that had to agree. Previously a local-only resolution passed agents=nil,
gate=denyGate and localOnly=true, and only the localOnly check actually
protected anything: the external branch dereferences agents in the loop
header, before the gate closure runs, so denyGate could never fire. It
is deleted. A nil ext now both marks the resolution local-only and
removes the agents there were to dereference, and newLocalResolver takes
no parameter that could supply one.
The playlist tile loop hardcoded localOnly=false, safe only because an
early return 22 lines above it made that unreachable; it now inherits
the resolver's capability.
resolveItemLocal's guard against the remote ExternalImageURL fetch and
the 2x2 grid had no coverage: deleting it left the whole suite green
while putting synchronous network calls on the request path.
The worker resolving the same playlist is asserted alongside, so the
spec cannot pass by simply resolving nothing.
MarkFailed and Delete had no production caller: the worker only ever uses
MarkFailedIfUnchanged and DeleteIfUnchanged, which refuse to act on a row
a concurrent scan re-enqueued. Keeping the unconditional pair meant the
interface offered the racy variant under the more obvious name.
MarkFailedIfUnchanged does not build on MarkFailed, so nothing in the
implementation needed them either. The repository tests used them to put
a row into a backed-off state; they now do that directly.
artworkReader.LastUpdated had no callers: invalidation rides entirely on
the cache key, so the interface member, the resizedItem field and its
four assignments were vestigial. Reader's second return value was
likewise discarded at all three call sites.
resizedItem.Key duplicated representationTag's format string over
identical inputs, where drift would serve a wrong-keyed entry under a
right-looking validator; it now derives from it. newResizedItem had one
caller and a doc comment claiming a sharing with worker.precache that
never existed -- precache builds its own literal.
Also unexport Prune, which no caller outside the package used while
RunPrune documented itself as the only sanctioned path, drop a
single-call placeholder wrapper, and delete five fakeFolderRepo fields
no spec ever set.
The 12h retry budget hands a bare failure to the periodic stale-absent
sweep, which is what makes the resulting absent row recoverable. Media
files are deliberately excluded from that sweep -- they resolve embedded
only, at scan or on view -- so exhausting the budget on a transient read
error recorded a "this track has no cover" verdict that nothing would
ever revisit.
Settle absent only for kinds a recheck job covers. Without a row the
track stays unresolved, so the next view re-enqueues it.
The prune read-lock wrapped all of processItem, including external fetches under
their own timeout. Since a pending RWMutex writer blocks new readers, one prune
arriving behind a slow provider stalled every subsequent item in both drain
pools. Extract persist() so the lock covers only the window it protects: store
placement plus the two row writes.
The in-flight set guarded against a queue row appearing twice in one batch, but
artwork_queue's primary key makes that impossible, drains are serial per pool,
and the pools' kind lists are disjoint. Removing it also retires the cancellation
unwind loop that existed only to release those claims.
The grid was replaced by a spinner whenever the random list was loading, so
each search keystroke collapsed it to spinner height and back. That blanket
blanking is the flicker commit 9e559311a removed for every other list; random
kept the exception so a re-roll would not flash the roll it is replacing.
Blank on a seed change instead of on any load: a re-roll gets a new seed and
still blanks, while a search keeps the seed and leaves the grid in place. The
seed is tracked from empty rather than from the current value because a re-roll
redirects and remounts the grid, which would otherwise look already-settled
with the previous roll still on screen.
Same rule for the pagination, which was hidden on the same condition.
React Router keeps the previous page's scroll offset, so opening an album from
a scrolled list started the detail page mid-song-list. Artist pages had the
same bug; it just shows less because the artist list is rarely long enough to
scroll far.
Keyed on the record id rather than mount, so detail-to-detail navigation (an
album's artist link) resets too, and so the scroll waits for the record instead
of firing against an empty page.
The zero/absent guards around width/height are ItemImage's own invariant, not
the Jellyfin adapter's. Moving them onto the model keeps one definition for
every consumer, so a second one cannot quietly disagree about what an unknown
ratio means.
Real Jellyfin carries width/height of the Primary image on BaseItemDto
(MediaBrowser.Model/Dto/BaseItemDto.cs), attaching it only when the request's
Fields asks for it (DtoService.cs ContainsField). The dimensions are now on
model.ItemImage for the web UI's blurhash placeholder, so the adapter can
report the same thing for free.
Gated behind Fields to match, and omitted rather than defaulted when the item
has no image or unknown dimensions: real Jellyfin falls back to a per-type
default of 1 for music, but a wrong ratio mis-shapes a client's placeholder,
and we only lack dimensions when the artwork is genuinely unresolved.
primaryImageTag becomes primaryImage, returning the tag, blurhashes and ratio
together, so the choice of which image is Primary is made once per mapper
rather than the same ItemImage being threaded through two calls.
ArtistToBaseItem and PlaylistToBaseItem take Fields now, like the album and
song mappers already did.
The UI decoded every blurhash into a 32x32 bitmap and stretched it to fill its
container, so a non-square cover showed a full-box blur that collapsed into a
letterboxed image the moment it loaded. On the album detail page the
placeholder overhung the image by a third of the box height.
A blurhash string carries no aspect ratio of its own, so the dimensions have to
come from the server. artwork.width/height were already stored and read by
nobody; they now surface on ItemImage as imageWidth/imageHeight, hydrated
through the join that was already in place. Existing rows already carry them,
so no migration or rescan is needed.
A square request is padded rather than cropped, which aspect-fits the content
inside the square the server returns. That made the grid a second instance of
the same bug, so `square` now implies contain for the image as well as the
placeholder, instead of the two renderers reading it differently.
Unlinking before re-creating fixes the premature-EOF spin on unix, but Windows
refuses to remove a file another handle still has open and returns a sharing
violation. Because Create surfaces that error, every cache miss on a path with a
live reader would have failed outright - worse than the spin it was meant to fix.
Split the create behind a build tag: unix unlinks for a fresh inode, Windows keeps
truncating in place and stays exposed to the spin, which is the behaviour it
already had. The unix-only spec is skipped there.
Create re-opened the path with O_TRUNC, which shrinks the file out from under an
older stream that may still be serving readers. stream.Reader then hits EOF from
the OS at the new, shorter length while the broadcaster still reports the original
size, so Wait() reports 'more data exists' and the reader retries forever - a tight
pread loop that burns a core and never releases its handle, which in turn blocks
Stream.Remove() indefinitely.
Unlink first and create with O_EXCL so the new entry gets a fresh inode. Existing
readers keep their descriptor on the old inode, see its full contents, and reach a
clean EOF.
Note this does not address the deferred unlink deleting the re-created file, which
is handled separately by the re-fetch in fileCache.Get.
precache built its resizedItem without setting square, so it warmed
h-<hash>.<size>.false.<quality>. The list surfaces - album grid, artwork avatars,
playlist and radio details - all request square covers, so the warmed entry was
never read and every grid cover stayed a cold miss on first view.
Set square on the precache item so the key matches the request path. The existing
specs asserted the '.300.false.' key and were updated accordingly.
serveHash routed every non-cancel cache error into dangling(), which returns
ErrUnavailable and is then rendered as a placeholder at 200 OK. A cache-layer
fault was therefore indistinguishable from an album genuinely having no artwork,
and left no trace: a broken resize cache silently served placeholders for a
quarter of the library while the logs stayed clean.
Log the error before falling back, so the cause is recoverable from the logs.
fscache's Remove drops the in-memory entry, releases the lock, and only then
unlinks - blocking until every outstanding reader closes. A Get landing in that
window re-creates the file at the same path under a fresh entry, and the deferred
unlink deletes those new bytes. The entry survives pointing at nothing, and since
a present entry is treated as a hit, every later Get for that key returns ENOENT
for the rest of the process's life. Only a restart, which rebuilds the map from
disk, cleared it.
Get now drops such an entry and retries once, so a vanished data file costs one
re-fetch instead of poisoning the key permanently. This also covers a file
disappearing for reasons unrelated to that race, such as external deletion or a
restored backup.
Specs cover an in-process entry, one adopted at startup, and the deferred-removal
race itself.
Defence in depth rather than a live hole: the reported crafted PNG
(0xffffffff square) never reaches the multiplication, because
image/png rejects it at DecodeConfig, and the largest dimensions any
supported format can declare — 2^30-1 for PNG, 16-bit for JPEG and
GIF, 14-bit for WebP — cannot overflow the int64 product.
decodeCapped is format-agnostic though, so the guard should not depend
on a decoder's own limits staying where they are. Comparing by division
holds for any dimensions a decoder might report, and non-positive ones
are now rejected outright.
os.Chmod cannot revoke read access on Windows — it only toggles the
read-only attribute — so the findImageInFolder spec opened the file
happily and failed there, and the upload spec passed for the wrong
reason: outcomeFailed came from the 1-byte payload failing to decode,
not from the source being unreadable.
findImageInFolder takes an fs.FS, so the failure is now injected and
the spec is filesystem-independent. The upload path goes through
os.Open directly and has nothing to inject, so it skips on Windows
rather than pretend to cover it.