4994 Commits

Author SHA1 Message Date
Deluan
cddc30b586 fix(persistence): gate the parent fold by the reader's album-root rule
The parent folder's images_updated_at now counts only when the reader could
actually serve the album-root cover: multiple album folders (disc layout), or a
single folder with no images of its own. This mirrors albumRootParent's first
qualification gate, so an unrelated artist-level image change no longer advances
(and temporarily suppresses) the version of every sibling album with its own
cover. The remaining gates (other-audio, library root) stay unmirrored: with
omission plus the write-side clamp, residual over-approximation only causes a
short suppression that closes on the next serve. Plan verified on a 96K-track
production copy: unchanged, all PK point lookups.
2026-07-17 23:41:08 -04:00
Deluan
9e3fc91cec test(artwork): separate the cover swap in time for Windows clock granularity
The quick-scan e2e swapped the cover milliseconds after the first serve; Windows'
~15ms timer granularity could collapse the stored version and the new folder
timestamp into equal values, failing the staleness assertion. A 50ms gap makes the
ordering deterministic on all platforms.
2026-07-17 23:36:52 -04:00
Deluan
a98bebc314 fix(artwork): clamp the persisted blurhash version to the entity's
The read-side artwork version may over-approximate the served sources (folder
parents for disc layouts), which with omission semantics suppresses a perfectly
valid stored hash. At persist time the hash is fresh for the served bytes by
construction, so the write now loads the entity and clamps blur_hash_updated_at
up to its current ArtworkUpdatedAt: after any serve the DTO accepts the stored
hash, and every omission window closes regardless of read-side precision. The
in-memory state shrinks to a pure decode cache (checksum -> hash); staleness
decisions moved to the row read, which also lets a drifted stored value be
restored from the served bytes.
2026-07-17 23:28:02 -04:00
Deluan
817baa5c1a fix(jellyfin): omit the blurhash when no current hash exists, never fabricate
Upstream Jellyfin omits ImageBlurHashes entries when no hash was computed, and
clients are built around that: redesign Finamp uses the blurhash as immutable
cover identity (year-long cache pins, download dedup) and falls back to id-keyed
caching with a short TTL when it is absent. Emitting a rotating fake seeded by
id+version fed a fabricated identity into those caches and churned them on every
version bump, and a fake accepted under an imprecise artwork version could pin a
wrong value for a year. Absence is strictly safer: no LQIP during the first-serve
gap, self-healing within the fallback TTL.

The staleness gate is kept, its job now being to suppress a stale stored hash
rather than to choose between real and fake. Songs no longer carry a fabricated
per-album value either; art resolution uses AlbumPrimaryImageTag alone.
2026-07-17 23:28:01 -04:00
Deluan
f0fe070ba1 fix(scanner): cap folder images_updated_at at scan time
A future-stamped image file (clock skew on NAS mounts) previously flowed verbatim
into folder.images_updated_at and from there into the album artwork version, while
the stored blur_hash_updated_at is capped at now on write. The DTO staleness gate
then kept emitting the fake blurhash until wall time caught up with the file mtime.
Capping at the source keeps future values out of the DB entirely; rotation is
preserved because any later change is capped to a later scan time. Capping in the
DTO instead would be worse: the version would become a moving target and the fake
seed would rotate on every request.
2026-07-17 22:47:43 -04:00
Deluan
08d83a7785 fix(persistence): include parent folders in the album artwork version
Multi-disc albums keep their cover in the album-root folder, which the artwork
reader reaches via albumRootParent but which is not in album.folder_ids (only the
disc folders hold media files). A root cover swap therefore advanced the served
cache key without moving the selected artwork version, recreating the stale-hash
deadlock for hash-keyed clients. The version subquery now also considers the
folders' parents. This slightly over-covers (an artist-folder image change can
advance a single-folder album's version), which errs on the side of one spurious
refetch instead of permanent staleness. Plan verified on a 96K-track production
copy: still PK point lookups, outer scan unchanged.
2026-07-17 22:47:42 -04:00
Deluan
9a36047096 fix(artwork): version the resized cache key to backfill blurhashes on upgrade
Resized entries cached by a pre-blurhash version serve straight from the cache:
the resized reader never runs, the original is never read, and the tee never gets
a chance to compute a hash — clients that only request sized images (Jellyfin
maxwidth) would keep receiving the fake blurhash indefinitely for those items.

Versioning the resized cache key makes every post-upgrade sized request miss and
refill. The refill pulls the original through Get, which usually hits the cached
original (original keys are unchanged), so the backfill costs one decode+re-encode
per resized variant with no source or external-provider I/O. Orphaned entries are
evicted by the cache's LRU as usual.
2026-07-17 22:30:38 -04:00
Deluan
07af29fbb4 fix(jellyfin): fold folder image mtimes into the album blurhash version
An in-place cover-file swap followed by a quick scan updates only the folder's
images_updated_at: no tracks are imported, so the album row never moves and
ArtworkUpdatedAt() stayed at the old value. The Jellyfin DTO then kept emitting
the previous stored blurhash, and clients that key their cover caches on it never
refetched the image, so the served-bytes recompute could never run.

The album select now surfaces the newest folder images_updated_at (bare-column
correlated subquery over json_each(folder_ids), so the datetime decltype survives
and scans as time.Time) and ArtworkUpdatedAt() folds it in. Benchmarked on a 96K
track production copy: ~90us/page added, no query-plan change; the subquery is a
PK point lookup per folder with at most two rows to order. Artist images and
playlist sidecars have the same theoretical gap but their timestamps cannot be
derived in SQL; they remain a documented follow-up.
2026-07-17 22:14:13 -04:00
Deluan
2499de2bac perf(artwork): key blurhash dedup by identity, plus review cleanups
The seen map was keyed by the full ArtworkID, which embeds the client token's
LastUpdate: every scan bump rotated the key, so the same-bytes dedup (and its
cheap version re-persist path) never fired in production and stale entries
accumulated forever. The key now zeroes LastUpdate, making the map bounded by
entity count and restoring the tested dedup behavior.

Cleanups from the same review: the base83 encoder is now exported from the
blurhash package and the DTO's duplicate copy is deleted; the always-set
blurHashes field lost its test-shaped nil guards; clearIfStored dropped its
inert version parameter (cleared rows never have their timestamp read); the
teeReader done flag is replaced by nilling the callback; worker-era comments
(async, cache-miss recompute) were updated to the inline design; and the e2e
specs assert directly instead of polling, since the hash is persisted before
the read helpers return.
2026-07-17 21:46:12 -04:00
Deluan
83e9bb8180 refactor(artwork): compute the blurhash inline, drop the background worker
Decode+encode is a few ms (the encoder downscales before its pixel loops), and the
tee fires on Close after the response is fully written, so the hash can be computed
in the serving goroutine: the queue, wake/stop lifecycle, lazy-start admin context,
and the e2e worker-teardown ordering all become unnecessary and are removed. This
also closes two correctness holes the queue had: a deletion signal could be dropped
behind a pending serve job, and buffered image bytes had no global cap.

The in-memory dedup now remembers a checksum of the served bytes plus the persisted
version: identical serves skip the decode and the write, but the same bytes under a
newer entity version re-persist, so blur_hash_updated_at keeps pace with row updates
and the Jellyfin DTO's staleness gate keeps emitting the stored hash after scans.
Placeholder-triggered clears check the seen map, then the stored row, so coverless
entities cost one row read once per process instead of a probe and write per serve.
UpdateBlurHash is a plain UPDATE with no user filtering, so the request context is
used directly (a client abort is survived via context.WithoutCancel).
2026-07-17 21:20:46 -04:00
Deluan
7307fd716b fix(artwork): close the underlying stream from the tee; rename to tee_reader
The tee was built around io.NopCloser(r) and teeCachedStream.Close only closed the
tee, so the underlying CachedStream (an open cache-file fd, or the raw source stream
when the image cache is disabled) was never closed — one fd leaked per teed serve,
enough to exhaust the process limit during a client's initial cover sync. The tee now
wraps the stream directly and its Close propagates; teeCachedStream is gone (all
artwork handlers io.Copy, none Seek). The file is renamed to tee_reader.go since the
wrapper is generic, not blurhash-specific.
2026-07-17 21:13:03 -04:00
Deluan
b63c9b095b test(artwork): cover playlist placeholder-clear via the tee
A playlist that loses its sidecar cover serves the bundled placeholder through
Get; those bytes flow through the tee, the runner recognizes the placeholder, and
clears the stored hash. This exercises the free deletion path (no GetOrPlaceholder
needed, since the playlist reader chain ends in fromAlbumPlaceholder).
2026-07-17 20:36:38 -04:00
Deluan
e8fac4c335 feat(artwork): trigger blurhash from the served-bytes tee
Get wraps an eligible original-size serve (album/artist/playlist) in a teeCachedStream
so the bytes streamed to the client are also captured; on a fully-consumed Close the
runner hashes exactly what was served. GetOrPlaceholder routes a vanished album/artist
cover through EnqueueClearIfGone, since no bytes flow through the tee on ErrUnavailable.
capAtNow keeps a future mtime out of the stored version. The mtime-preserved cover-swap
characterization test (previously pending) now passes, and the disappearing-cover e2e
serves through GetOrPlaceholder to match the real Jellyfin/Subsonic path.
2026-07-17 20:34:47 -04:00
Deluan
f6dd722119 refactor(artwork): compute blurhash from served bytes, drop proxy signals
The worker no longer infers whether the served bytes changed through a stack of
proxy signals (snapshot timestamp vs stored blur_hash_updated_at, freshness guard,
gone bit, idempotent-write skip, computeFromArtwork re-read). It now takes the exact
bytes captured from a serve and is a pure function of them: placeholder clears,
undecodable is left alone, otherwise encode and write with an in-memory last-hash
dedup. Deletion is a checkGone job that re-reads once and clears only if the source
is still gone, so a transient fetch failure can't clobber a valid hash.
2026-07-17 20:34:33 -04:00
Deluan
2169938b30 feat(artwork): add teeReader to capture served artwork bytes
A wrapping io.ReadCloser that mirrors read bytes into a bounded buffer and, on a
fully-consumed Close, hands the captured bytes to a callback. Partial reads and
oversized streams are skipped so the callback only ever receives a complete,
bounded image — the exact bytes the client received. This is the capture side of
the served-bytes blurhash tee.
2026-07-17 19:36:31 -04:00
Deluan
eb5ecabc5a test(artwork): characterize mtime-preserved cover swap staleness (pending)
A cover replaced in place without a mtime change is not re-hashed today: the
freshness guard skips recompute when no timestamp moved, so the stored blurhash
(and the client's cover cache keyed by it) stays stale. Marked pending until the
served-bytes tee lands, which recomputes from the exact bytes served.
2026-07-17 19:35:13 -04:00
Deluan
bf7ae5e82e fix(artwork): cap the blurhash snapshot at now to survive future mtimes
A future-dated artwork file mtime (clock skew, or a file stamped ahead of the
server clock) flowed into the reader's LastUpdated snapshot and was persisted
verbatim as blur_hash_updated_at. Both the worker's freshness guard and the
Jellyfin DTO use a !Before comparison against that timestamp, so a later
legitimate cover change whose row/mtime clock was still behind the future value
would be skipped, pinning the stored hash (and the client's cached cover) until
wall time caught up.

Cap the snapshot at time.Now() in process() before every freshness comparison
and persist, so a real later change always advances past it. Normal past mtimes
(the legitimate 'snapshot exceeds row version' case) are unaffected.
2026-07-17 15:51:01 -04:00
Deluan
5cd75503cb fix(artwork): backfill warm caches and supersede pending gone on refill
Two issues in the fill-triggered blurhash recompute:

1. Enqueuing was gated on a cache miss (!r.Cached). On an upgraded instance the
   image cache is persisted and adopted across restarts, so already-cached
   artwork serves as a cache hit and never enqueued, leaving its migrated-empty
   blur_hash as a synthetic value indefinitely. Enqueue on every original-size
   serve instead: the worker's freshness guard turns already-hashed rows into a
   cheap read and only recomputes when the hash is stale or missing.

2. Enqueue merged the gone flag with a sticky OR, so a cover restored right
   after a missing-art serve kept gone=true and took the clear-and-return path,
   never recomputing. A successful serve proves the artwork exists, so it now
   clears any pending gone for that artwork; a gone that follows a fill still
   sticks.
2026-07-17 15:36:17 -04:00
Deluan
2cd967a456 fix(artwork): keep the stored blurhash on transient recompute errors
process() cleared the stored hash whenever computeFromArtwork returned an
error OR an empty hash. A transient failure (the 30s context timeout, a flaky
cache/DB/reader read) is not evidence the artwork changed, so clearing on it
made the Jellyfin DTO fall back to a fake blurhash and churn clients' cover
caches until a later successful fill restored it.

Split the two outcomes: a compute error now leaves the stored hash intact and
lets a later fill retry, while an empty hash (a placeholder, i.e. the cover is
confirmed gone) still clears it. Deletion witnessed by a failed serve is
already handled separately by the gone path.
2026-07-17 15:18:28 -04:00
Deluan
66d6fdc1a6 test(artwork): stop the blurhash worker before spec TempDir cleanup
The artwork e2e suite stopped the worker via a DeferCleanup registered in
setupHarness's BeforeEach, which Ginkgo runs LAST — after a spec body's own
GinkgoT().TempDir() cleanups. With the cache disabled in these tests, every
artwork serve now enqueues a blurhash recompute, so the worker can still be
reading a spec-local sidecar file when its TempDir is removed. On Windows that
unlink fails ("the process cannot access the file because it is being used by
another process"), which flaked the playlist case-insensitive sidecar spec.

Move the worker shutdown to a suite-level AfterEach, which runs before any
spec-body DeferCleanup, so no artwork file is held open when TempDir removal
runs. Close() is idempotent, so the change is safe across specs.
2026-07-17 15:03:19 -04:00
Deluan
3759488db5 refactor(artwork): trigger blurhash recompute from the cache fill
The blurhash worker previously recomputed on every artwork serve and
reconciled a row-level freshness oracle against serve-time hints (force,
sourceGone, imageUpdatedAt) to decide whether the served bytes had actually
changed. Every staleness bug found in review was one case where that weak
oracle disagreed with the true one, and each fix imported one more serve-time
fragment into it.

Move the trigger to the image-cache fill instead: an original-size cache miss
is exactly when the served bytes change, so the reader's LastUpdated snapshot
is the truth and no reconciliation is needed. Resized fills recurse through
Get(size=0) and hash the original once; a disabled cache reports every original
serve as a fill, keeping real hashes available (the worker's unchanged-hash
guard keeps that write-free).

This deletes the force/sourceGone/imageUpdatedAt flags, the double-freshness
comparison, and the entire negative cache. Only the two irreducible pieces
survive: the placeholder byte-compare and the ErrUnavailable clear-hook
(EnqueueGone), since deletion-without-rescan is invisible to every passive
signal. Adds an e2e test for in-place cover swaps, the scenario that drove the
deleted machinery, now covered structurally by the fill trigger.

Schema, DTO, repositories and the blurhash encoder package are unchanged.
2026-07-17 14:47:01 -04:00
Deluan
ae36e4dfc7 fix(artwork): recompute on original serves when the image cache is disabled
With ImageCacheSize=0 every serve reads live bytes, so an in-place cover
swap without rescan changed the served image with no signal the worker
could see. Original-size serves now force on disabled caches, and a new
unchanged-hash guard skips the DB write, so the forced path costs only the
background decode those installs already pay per request.
2026-07-17 14:13:09 -04:00
Deluan
d0ac427377 ci: disable green-tea GC on the Windows test job
Go 1.26's green-tea GC crashes intermittently on Windows runners: three
distinct runtime fatal-error signatures across 1.26.4/1.26.5, always in
the persistence suite, twice in a row on this PR.
2026-07-17 14:08:44 -04:00
Deluan
7a61776c1c fix(artwork): clear a fresh-looking hash when the serve finds no source
In the window before a rescan records a deleted cover (or after cache
eviction/restart), no row or mtime signal moves, so the freshness skip kept
the stale hash. ErrUnavailable serves now carry a sourceGone signal that
bypasses the skip only while a stored hash remains to clear — afterwards
the negative cache applies, so artwork-less entities still don't re-resolve
per serve. The e2e spec now covers the no-rescan window.
2026-07-17 13:53:08 -04:00
Deluan
a7eec3afc3 fix(artwork): clear the stored hash when the artwork source disappears
A removed cover made Get error with ErrUnavailable before the enqueue, so
the worker never saw the entity and the stale hash kept being emitted. The
unavailable path now enqueues too; the worker's reader signal carries the
folder change and the existing no-result clearing applies. Covered by an
e2e spec exercising the full disappear-and-clear flow.
2026-07-17 13:33:45 -04:00
Deluan
0e74cf0ab1 fix(artwork): only force blurhash recompute on original-size cache misses
Resized cache keys vary per requested size, so a first request for a new
thumbnail size (or an evicted resized entry) forced a recompute with an
unchanged source — wasted work, and a transient failure during it could
clear a valid stored hash. Resized readers re-fetch the original through
Get, so the original-size call still carries the real change signal.
2026-07-17 13:16:07 -04:00
Deluan
9ac2c6a5e3 fix(model): exclude blur hash fields from full-row writes
Scanner and maintenance paths build fresh entities with empty blur hash
fields, so every ordinary refresh erased the computed hash and caused a
double Finamp cover refetch (fake, then real again). The fields are now
read-only projections (structs:"-"): only UpdateBlurHash writes them.
2026-07-17 12:57:33 -04:00
Deluan
c4ca3dca5e fix(artwork): clear the stored hash when a recompute yields no result
A cover deleted or corrupted in place (mtime-only signal, row unchanged)
left the old blur_hash in the DB, and the DTO kept emitting a hash for
artwork no longer served. Since the worker only reaches compute when there
is change evidence, a no-result with a stored hash now clears it, making
the DTO fall back to the rotating fake.
2026-07-17 12:48:36 -04:00
Deluan
3a8505584b fix(artwork): hash the cached artwork bytes, not a fresh source read
Generated playlist mosaics pick albums with random(), so a direct source
read produced a different image than the cached one clients download, and
the persisted blurhash described a mosaic nobody sees. The worker now reads
through the image cache and detects placeholders by byte equality with the
embedded assets (cached reads carry no source path).
2026-07-17 12:27:13 -04:00
Deluan
f9f6d36ebb fix(artwork): TTL for no-result memoization, prompt Close, no warmup force spike
- memoize every no-result outcome (ErrUnavailable can wrap transient agent
  and storage failures, so it is not a reliable definitive/transient
  discriminator) but bound it with a 1h TTL, which also un-poisons entries
  recorded under future file mtimes
- cancel the worker context and check done in the drain loop, so Close
  returns promptly instead of draining the backlog at up to 30s per item
- only force recompute when the image cache is operational: during warmup
  every serve is a miss, which caused a recompute spike at startup
2026-07-17 12:11:30 -04:00
Deluan
43500c0ffe fix(artwork): persist the image freshness signal to stop per-serve recomputes
When a cover file's mtime is newer than the entity row (common), persisting
the row version made the freshness check fail on every serve, re-encoding
and re-writing the same hash each time. The snapshot now stores the newest
signal (row version or image mtime), and both freshness checks accept a
snapshot at-or-after the row version.
2026-07-17 11:59:24 -04:00
Deluan
b040345fb0 fix(artwork): stop the blurhash worker on Close to fix test data races
CI's race detector caught the process-lifetime worker goroutine outliving
Ginkgo specs and touching mocks/fake filesystems being torn down. The
worker now has a stop() that waits for in-flight work, exposed as Close()
on the artwork service; unit suites close it up front (they don't exercise
blurhash), the artwork e2e suite closes it on cleanup.
2026-07-17 11:51:24 -04:00
Deluan
5bdeaad95e perf(artwork): eliminate per-pixel allocations in blurhash encoding
Normalize the downscaled image to *image.RGBA once and read Pix directly
(the image.At interface boxed a color per pixel — ~16k allocs/encode), and
replace per-pixel math.Pow with a 256-entry sRGB-to-linear table. ~72%
faster (3.0ms -> 0.86ms for covers >=300px), 19 allocs/op. Benchmark
included.
2026-07-17 11:33:16 -04:00
Deluan
696399dab7 fix(artwork): don't memoize transient blurhash failures; track image freshness with cache disabled
Only ErrUnavailable (definitively no artwork) is negative-cached; timeouts
and storage/agent hiccups retry on a later serve. Enqueue now carries the
reader's LastUpdated so file swaps are detected even when the image cache
is disabled (where every serve reads the source and miss-forcing is off).
2026-07-17 11:20:41 -04:00
Deluan
9ea4e22ea0 fix(artwork): address PR review bot feedback
- don't record noResult for timed-out/cancelled computations (transient
  failures must not suppress retries for the same artwork version)
- bound the noResult negative cache at 25k entries
- use context.Background for the worker's root context
- add hash:"ignore" to Artist/Playlist blurhash fields for consistency
  with Album (inert today; neither struct is hashed)
2026-07-17 11:13:59 -04:00
Deluan
4d8b486dfd Merge remote-tracking branch 'origin/master' into feat/artwork-blurhash
# Conflicts:
#	model/playlist.go
#	model/playlist_test.go
2026-07-17 11:09:09 -04:00
Deluan Quintão
85132240e0
feat(smartplaylist): per-playlist refreshDelay for stable daily/weekly playlists (#5790)
* feat(utils): add ParseDuration/FormatDuration with day and week units

* feat(criteria): add per-playlist refreshDelay to smart playlist rules

* feat(smartplaylist): honor per-playlist refreshDelay in refresh gate

* feat(subsonic): compute smart playlist validUntil from effective refresh delay

* fix(playlists): reset smart playlist evaluation window when rules change via API

* refactor(utils): flatten FormatDuration recursion, single-pass duration regex

* fix(playlists): address PR review feedback

- Reset EvaluatedAt to nil instead of zero-time on rules change and NSP
  re-import, so getPlaylist(s) never reports year-1 Changed/validUntil in
  the window between an edit and the next owner read
- Parse negative d/w durations so they are rejected with the consistent
  "negative duration" error instead of "unknown unit"
- Quote input in the negative-duration error, matching the parse error
- Gate per-playlist RefreshDelay behind IsSmartPlaylist, matching its doc
2026-07-16 22:20:35 -04:00
Deluan
e100c48102 fix(artwork): address blurhash review findings
- force recompute on image-cache miss, so in-place cover/sidecar swaps and
  agent image updates refresh the stored hash even when no entity row moved
- exclude external_info_updated_at from the artwork version: agent TTL
  refreshes bump it with an unchanged image, churning Finamp's cover cache
- lazy-start the worker goroutine and bound each computation with a 30s
  timeout; remember no-result entities per version to avoid re-decoding
  placeholders on every serve
- error (instead of silently skipping) on unknown artwork kinds in persist
2026-07-16 21:52:28 -04:00
Deluan
0b365a0090 refactor(artwork): simplify blurhash cleanup review findings
- detect placeholder artwork via the reader's source path instead of
  comparing against precomputed placeholder hashes (fragile fingerprint,
  dead artist-placeholder branch)
- collapse the three identical UpdateBlurHash repo methods into a shared
  sqlRepository.updateBlurHash helper
2026-07-16 21:52:28 -04:00
Deluan
76e1fba067 test(artwork): e2e coverage for async blurhash persistence 2026-07-16 21:52:28 -04:00
Deluan
585ac0aab3 feat(jellyfin): emit stored blurhashes with version-seeded fake fallback 2026-07-16 21:52:28 -04:00
Deluan
9f9019df07 feat(artwork): compute and persist blurhashes asynchronously on artwork serve 2026-07-16 21:51:04 -04:00
Deluan
f763ebff5b feat(persistence): add UpdateBlurHash targeted update to album/artist/playlist repos 2026-07-16 21:51:04 -04:00
Deluan
a115726e71 feat(model): add blur_hash columns and ArtworkUpdatedAt version methods 2026-07-16 21:51:04 -04:00
Deluan
470b4bdfa0 feat(artwork): add blurhash encoder package 2026-07-16 21:51:04 -04:00
Deluan
ae7e81e33f docs(jellyfin): sync README with current implementation
Update the Jellyfin API README to cover changes that landed after it was
written: add the Lyrics and AudioMuse-AI endpoints to the implemented-endpoints
table, document the AlbumIds filter (Feishin), Recursive=false handling, and
the Jellyfin.MaxConcurrentStreams config option. Mention Symfonium as a client
of the AudioMuse-AI endpoints. Update the lyrics limitation for the
singleflighted cache loader shipped in #5792. Drop two stale known-limitation
entries: sonic similarity (plugin metadata agents already feed
InstantMix/Similar) and MD5-hash ids (the codec round-trip is symmetric, and
with the API unreleased no client can hold a raw un-encoded id).
2026-07-16 20:48:56 -04:00
Deluan Quintão
27f0210392
fix(scrobbler): tolerate out-of-order playback reports (#5793)
* fix(scrobbler): tolerate out-of-order playback reports

Clients may fire reportPlayback requests concurrently (Feishin sends
'starting' and 'playing' in parallel, and the previous track's 'stopped'
races the next track's start), so reports can be processed out of order.
Two cases corrupted the now-playing session: a late 'starting' for the
track already playing downgraded the session state, freezing position
estimation at 0:00 until the next report; and a late 'stopped' for the
previous track removed the new track's session and dispatched a playback
report mislabeled with the new track's metadata, causing presence-style
plugins (e.g. Discord Rich Presence) to clear or show stale state.

ReportPlayback now ignores a 'starting' report when the session already
has the same track in playing state, and ignores a 'stopped' report for
a track other than the current session's - skipping both the session
removal and the plugin dispatch, while still counting the play and
dispatching external scrobbles for the stopped track.

Reported in https://github.com/jeffvli/feishin/issues/2131

* fix(scrobbler): serialize session writes to close starting/playing race

The out-of-order 'starting' guard checked the session cache before the
media-file load, leaving a window where a concurrent 'playing' report on a
fresh session could write between the check and the write, and still be
overwritten back to 'starting'. Session check-then-write sections are now
serialized by a mutex, with the guard re-checked after the load. Also
tightens the guard comment to say 'playing session', matching the condition.

Found by Codex review on #5793.

* fix(scrobbler): fully exit ReportPlayback when ignoring out-of-order reports

The out-of-order guards used 'break', which only exits the switch, so the
post-switch NowPlaying block still ran for an ignored 'starting' report and
enqueued a NowPlaying dispatch with the stale report's position - potentially
overwriting a pending correct-position entry, since the queue is keyed by
client. Return nil instead, so ignored reports have no side effects.

Found by Gemini review on #5793.
2026-07-16 20:47:47 -04:00
Deluan Quintão
756df9decf
fix: dedupe and cap concurrent lyrics plugin fetches (#5792)
* fix: dedupe and cap concurrent lyrics plugin fetches

Clients like Finamp prefetch lyrics for several queue tracks at once. The
resulting burst of concurrent plugin calls can rate-limit the primary
lyrics provider into a timeout, making the plugin fall back to a lower
quality source and cache the bad result.

SimpleCache.GetWithLoader now deduplicates concurrent loads of the same
key via singleflight, with every waiter receiving the winner's result or
error. The Jellyfin lyrics loader is detached from the request context so
one cancelled request cannot fail the load for all waiters, and the
lyrics adapter caps in-flight plugin calls at 2 per plugin, queueing the
rest. As a side effect, the cached HTTP client used by the Last.fm,
Deezer and ListenBrainz agents also collapses identical concurrent
requests into a single upstream call.

* fix: harden lyrics concurrency fixes per review

Replace the stringified singleflight keys with a per-cache flight map
keyed by the cache key type itself, eliminating potential key collisions
for non-string keys, the nil-interface assertion panic, and the
stringification overhead. Release the lyrics semaphore slot via defer so
a panicking plugin call cannot leak it, and bound the detached lyrics
load with a one-minute timeout so a hung plugin cannot pin its
singleflight and semaphore slot indefinitely.
2026-07-16 20:10:35 -04:00
Deluan Quintão
29f481cd7b
feat(jellyfin): lyrics endpoint and Lyric stream advertising (#5791)
* feat(jellyfin): add LyricDto and lyrics mapper

* feat(jellyfin): advertise Lyric media stream for embedded lyrics

* feat(jellyfin): implement GET /Audio/{itemId}/Lyrics

* feat(jellyfin): advertise pipeline-resolved lyrics in PlaybackInfo

* feat(jellyfin): advertise server version 10.9.11 for client lyrics gates

* test(jellyfin): e2e coverage for lyrics endpoint and advertising

Seeds "Stairway To Heaven" with an embedded LRC lyric tag (lyrics:eng)
and covers PlaybackInfo's Lyric MediaStream, GET /Audio/{id}/Lyrics,
and the HasLyrics badge end to end.

Fixes a bug the new seed exposed: HasLyrics and the Lyric MediaStream
gate compared mf.Lyrics against "", but the persistence layer never
stores an empty string post-scan (it normalizes to the JSON sentinel
"[]"), so every track was reporting HasLyrics=true. Both call sites
now parse the column via StructuredLyrics()/LyricList.Main() instead.

* fix(jellyfin): cheap sentinel check for embedded lyrics advertising

* chore(jellyfin): trim over-budget comments in lyrics code

* test(jellyfin): cover lyrics pipeline error and nil-start cue skip

* docs(jellyfin): document lyrics support and follow-ups in README

* refactor(jellyfin): promote embedded-lyrics sentinel check to MediaFile

The "[]" no-lyrics sentinel is persistence-layer knowledge; expose it as
model.MediaFile.HasEmbeddedLyrics() instead of a dto-local helper. Also
dedupe the test lyrics-cache construction and pre-size the media stream
slice.

* refactor(jellyfin): consolidate tick conversions around one constant

ticksPerMillis is now the single source of the 100ns-tick unit; the
scrobble handlers' three inline /10_000 divisions become
dto.MillisFromTicks.

* fix(jellyfin): align lyric advertising with the serving predicate

PlaybackInfo advertised on any non-empty LyricList while the endpoint
404s when the main lyric has no lines; both now share servableLyric.
Handler tests also send hex-encoded ids to match real traffic.

* chore(jellyfin): drop unneeded lyrics package alias in e2e suite
2026-07-16 14:39:11 -04:00
Deluan
c582ed31fa feat(jellyfin): add authenticated System/Info endpoint
Wire up GET /System/Info returning the previously unused dto.SystemInfo,
available to any authenticated user, matching real Jellyfin's authorization
(FirstTimeSetupOrIgnoreParentalControl, not admin-only). Feishin calls this
endpoint on connect and reads Version to feature-gate; it previously got the
unhandled-route 404.

The advertised version stays 10.8.13: Feishin unlocks structured lyrics and
public-playlist share permissions at >=10.9.0, and this API serves neither
(no lyrics endpoint; playlist user permissions are stubs), so a higher
version would falsely advertise capabilities.
2026-07-16 08:53:44 -04:00