4987 Commits

Author SHA1 Message Date
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
Deluan
caa0043030 fix(jellyfin): honor AlbumIds filter on /Items
Feishin fetches an album's tracks with AlbumIds=<id>&IncludeItemTypes=Audio&Recursive=true, but the /Items handler never read the AlbumIds parameter, so the request degenerated into the entire library sorted by album: every track (with MediaSources, when requested) was counted and streamed for what should be one album's worth of songs — very slow on large libraries, and wrong results. Parse AlbumIds like GenreIds (comma-separated and repeated spellings, hex-decoded) and filter songs through a new filter.ByAlbumID helper, keeping the album_id column knowledge in the filter package.
2026-07-15 23:29:59 -04:00
Deluan
1d5efdd5a0 fix(jellyfin): don't truncate InstantMix to the Similar ceiling
Finamp's Radio Mix requests /Items/{id}/InstantMix?limit=250, but getInstantMix
clamped the limit to maxSimilarLimit (100), so the queue was cut to 100 tracks.

A mix is a playback queue, not a "related items" list, so it gets its own
ceiling instead of sharing the Similar one. The Similar handlers keep 100.

Verified against a live library: the sonic provider supplies all 250 tracks for
a seed that previously returned 100.
2026-07-15 21:21:26 -04:00
Deluan Quintão
09022b4bd2
feat(jellyfin): AudioMuse-AI compatible sonic endpoints (#5782)
* refactor(jellyfin): inject core/sonic into the Jellyfin Router

* feat(jellyfin): add AudioMuse /info endpoint

* feat(jellyfin): add AudioMuse /similar_tracks endpoint

* feat(jellyfin): gate AudioMuse endpoints on sonic provider

* feat(jellyfin): add AudioMuse /find_path endpoint

* fix(jellyfin): fix case-insensitive route collision across positions

canonicalRouteSegments keyed canonical case by lower-cased segment name
alone, globally. Two unrelated routes sharing a segment name with
different casing at different tree depths (e.g. "Info" in
/System/Info/Public vs "info" in /AudioMuseAI/info) silently overwrote
each other, 404-ing the loser even for exact-case requests. Replace the
flat map with a position-aware trie mirroring the routing tree.

* test(jellyfin): e2e tests for AudioMuse endpoints

* docs(jellyfin): document AudioMuse compatibility endpoints

* test(jellyfin): harden AudioMuse tests and doc note (final-review follow-ups)

- Comment-lock the []string{} (not nil) contract for /AudioMuseAI/info's
  AvailableEndpoints so it keeps serializing as [] rather than null, and
  add a raw-body assertion to the existing empty-list test to catch a
  regression a struct-only unmarshal can't detect.
- Cover the previously-untested engine-error branch in similar_tracks and
  find_path, both of which degrade to an empty result.
- Document that find_path's path/total_distance only reflect hops through
  libraries the caller can access in multi-library setups.

* refactor(jellyfin): dedup AudioMuse test request helper, presize dedup map

* refactor(sonic): expose sonic.Engine interface; drop typed-nil guard in jellyfin.New

The Jellyfin Router's sonic field was an interface but New() took the concrete
*sonic.Sonic, so a nil arg became a non-nil typed-nil and needed a guard — the
only injected dependency that did. Move the interface (sonic.Engine) beside its
implementation, take it in New() like every other service, and bind it in wire.

* refactor(jellyfin): case-insensitive routing via lowercased paths

Replace the position-aware route trie with a trivial middleware that lowercases
the request path, and register every route in lowercase. Simpler, and no segment
name can collide across positions. caseInsensitivePaths moves into middlewares.go
alongside normalizeQueryKeys. Relies on the invariant that no Jellyfin path segment
carries case-sensitive data (all ids are lowercase hex via dto.EncodeID).

* feat(jellyfin): add AudioMuse /health endpoint

A liveness probe matching the reference plugin: 200 with an empty body when a
SonicSimilarity provider is loaded, 404 otherwise. /AudioMuseAI/info now
advertises it (list alphabetized like the plugin's OrderBy).

Also trims the AudioMuse and case-insensitive-routing comments to their essential
rationale.

* fix(jellyfin): hex-encode user IDs so lowercased paths stay valid

Address PR review: user IDs were the one id the Jellyfin API emitted raw (base62,
uppercase-capable), so lowercasing request paths could alter a userId segment. Encode
them via dto.EncodeID like every other id, making the 'all boundary ids are lowercase
hex' invariant true — no routing special-casing needed. Also caps user-controlled n /
max_steps, fixes the songAgent test comment, and adds leading slashes to the README
endpoint list.
2026-07-15 20:44:56 -04:00
Deluan Quintão
adeaa93e7e
fix(jellyfin): honor Recursive=false for library parents (#5788)
* fix(jellyfin): honor Recursive=false for library parents

/Items ignored the Recursive parameter entirely — it appeared only in tests,
never in production code. Finamp's download sync sends exactly one
Recursive=false request per library (ParentId=<library>&IncludeItemTypes=Audio)
to pick up tracks outside any album, supplementing its recursive per-album
fetch. We answered with every song in the library: 208MB and 21s per sync
measured on a real library, against 20KB for the album queries. Finamp then
required every track twice, once via its album and once under the library node.

In Jellyfin 10.10 Recursive never reaches SQL. It selects between an in-memory
walk of a folder's direct Children (false) and a DB ancestor query (true), with
IncludeItemTypes applied as a post-filter over that direct-child list
(ItemsController.cs:308, Folder.cs:949-994). The default is false, and neither
IncludeItemTypes nor SearchTerm forces recursion, so Recursive=false with
IncludeItemTypes=Audio on a music library returns an empty list — which is what
Finamp expects and codes for.

Filter the requested types to those nested directly under a library when the
parent is a library and Recursive is not true; the hierarchy this API exposes is
library -> album -> track, so no track is ever a library's direct child. Album
and playlist parents are untouched, as their tracks are real direct children in
Jellyfin (MusicAlbum.cs:86-89, Playlist.cs:140-167) and Jellify opens playlists
with Recursive=false. A library parent is the only case handled: /Items with no
ParentId still returns every song where Jellyfin returns the root's children,
but no observed client sends that, and honoring it would surprise any client
that simply omits Recursive.

* refactor(jellyfin): narrow the Recursive=false filter to Audio

Replace the libraryChildTypes allowlist with a direct Audio check. The two are
behaviorally identical — parseTypes only ever yields Audio, MusicArtist,
MusicAlbum, MusicGenre or Playlist, and the allowlist held the latter four, so
it excluded exactly Audio and nothing else.

The allowlist claimed those four are a library's direct children. That isn't
true of MusicGenre or Playlist: listGenres is deliberately unscoped because
genres are global tags, and listPlaylists ignores scopeIDs entirely, so neither
is nested under a library at all. They were on the list only to leave their
behavior untouched. The one verified invariant is that no track is a library's
direct child, so state just that — it is also more conservative, as a type added
later keeps its current behavior instead of being filtered by a stale list.

Add a test pinning the omitted-Recursive default: ItemsController binds
`bool? recursive` and reads it as `recursive ?? false`, so omitting the param is
a non-recursive request and filters Audio for a library parent.
2026-07-15 19:02:12 -04:00
Deluan Quintão
3d438b08ef
fix(jellyfin): close the unbounded playlist and search paths left by #5783 (#5784)
* fix(jellyfin): stream playlist tracks instead of loading every one

PR #5783 left the playlist paths materializing: all three loaded every track
of a playlist, whatever the client asked for. A playlist can be the whole
library — a smart playlist matching everything — so this is the same OOM class
that PR fixed for the other collections. Measured on a 96k-track smart playlist,
/Items?ParentId=<playlist>&Limit=10 peaked at 1.3GB to return ten tracks.

Playlist tracks now stream from a cursor, like every other collection:

- PlaylistTrackRepository gains GetCursor and CountAll, sharing the select
  builder with loadTracks so cursor rows hydrate identically, plus
  GetMediaFileIDs for callers that need every id but no track data.
- playlists.Tracks(ctx, id) exposes that repo to the HTTP layer with visibility
  enforced, returning ErrNotFound rather than the repo's nil-and-log-a-warning
  (which /Items would hit on every album browse, since ParentId is usually not
  a playlist).
- /Playlists/{id}/Items now honors StartIndex/Limit, which it silently ignored
  before — it always returned the whole playlist. Real Jellyfin pages it.
- getPlaylist runs an id-only query: PlaylistInfo carries every track id so it
  can't be paged, but it no longer hydrates rows it discards.
- The route joins the throttled group, as it's now cursor-backed.

Measured against a copy of a 96k-track production DB, peak RSS over idle, with
byte-for-byte identical responses on every endpoint:

  /Items?ParentId=<pl>&Limit=10   1275MB -> 1MB    8.8s -> 3.6s (0.27s warm)
  /Items?ParentId=<pl> unbounded  1353MB -> 8MB    8.7s -> 3.4s
  /Playlists/<pl>/Items           1217MB -> 7MB    8.8s -> 3.4s
  /Playlists/<pl>                 1178MB -> 33MB   9.1s -> 3.8s

TotalRecordCount now costs a count query where the old path got it from
len(tracks): 6ms on the largest real playlist in that library (2638 tracks),
288ms on the synthetic all-96k one. The old path paid 1.3GB and 4s+ instead.

* fix(jellyfin): bound unbounded /Items searches

The other collections stream, so an unbounded one costs about one item of
memory. Search can't: the repositories' Search returns a slice, so it
materializes every match. PR #5783 left two ways to reach that.

A whitespace-only SearchTerm was the first. " " != "", so it took the search
path, where doSearch trims it back to empty and hits its "empty query, return
everything in natural order" branch — with no LIMIT, since executeTwoPhase only
applies one when Max > 0. The whole library, materialized. Trimming at the two
parse sites makes the `search != ""` checks mean what they look like they mean:
a blank term is not a search, so it takes the unfiltered streaming path, which
still returns everything, exactly as real Jellyfin does for an empty term.

A real search with no Limit was the second, and needs an actual bound. Search
gets a default of 100 when the client sends no Limit — matching the
DefaultSearchLimit in Jellyfin's unreleased SqlSearchProvider — plus a ceiling,
without which Limit=999999 would still materialize the library. The default
alone wouldn't have closed the hole. An explicit Limit under the ceiling is
honored unclamped, as upstream does; truncating a search is safe in a way
truncating /Items is not, since nothing syncs a library through searchTerm.

Note this is upstream's own bug: v10.11's /Search/Hints returns the entire
library for a whitespace-only term, which master fixed by switching to
ThrowIfNullOrWhiteSpace.

* fix(jellyfin): cap the search Limit the client asked for, not the merge window

searchPage sees two different things in opts.Max: the client's Limit for a
single-type query, and mergeTypes' internal offset+limit window for a multi-type
one. Clamping there hit both, so a multi-type search paging past the ceiling
fetched only `ceiling` rows of the first type and the merged page skipped into
the next one — IncludeItemTypes=Audio,MusicAlbum&SearchTerm=song&StartIndex=2000
&Limit=1 returned an album instead of the 2001st song.

The ceiling now applies where the client's Limit is read: queryItems (after the
playlist branch, so a playlist parent's page isn't capped by a stray SearchTerm)
and getArtists, which reads its own. A limit of 0 stays 0, keeping searchPage's
default. What a deep page materializes is then bounded by the client's
StartIndex, as it already was for any multi-type query, search or not.

Also from review: the playlist-track mock reused the previously stored Options
when called without any, so a later no-args call inherited stale paging.

* fix(jellyfin): apply the search default to the client's Limit, not per type

The default lived in searchPage, which runs per type and after mergeTypes has
already picked its branch. So an unbounded multi-type search left q.limit at 0,
mergeTypes took its chained branch, and StartIndex was applied to a list each
type had already truncated to the default — dropping matches rather than paging
them. With 200 matching songs, IncludeItemTypes=Audio,MusicAlbum&SearchTerm=song
&StartIndex=150 returned nothing at all, and without StartIndex it returned the
default per type instead of in total.

clampSearchLimit now applies the default and the ceiling together, to the
client's Limit, at the two places it's read (queryItems and getArtists). A search
therefore always gives mergeTypes a real window, so it pages the merged result
and cuts it once, at the end.

* fix(jellyfin): bound the multi-type search window against StartIndex

mergeTypes asks each type for offset+limit rows before paginating the merged
list, so a search still materialized whatever StartIndex asked for:
IncludeItemTypes=Audio,MusicAlbum&SearchTerm=x&StartIndex=500000&Limit=1 pulled
~500001 matches per type. Bounding the window alone isn't enough — below it the
merged rows are the client's page, but at it a truncated type is followed by the
next one's rows, which is what made a clamped window serve an album where the
2001st song belonged.

So the window is capped at maxSearchLimit and the page is clipped to it: pages
below the ceiling are served in full and unchanged, a page straddling it is cut
at it, and past it the result is empty rather than another type's rows. The
total reports what can actually be paged to, so a client stops instead of asking
for pages that no longer exist.

This is the merge's own limit, not the client's: a single-type search still pages
as deep as it likes, since its offset goes to SQL. Non-search multi-type queries
keep the unbounded offset+limit window, which predates this and wants the same
treatment via CountAll (exact per-type totals let whole types be skipped) rather
than a cap.

* fix(jellyfin): advertise the pageable search total, not the page window

Clipping the multi-type search total to `window` clipped it to StartIndex+Limit
on an ordinary page, so a first page of Limit=10 reported TotalRecordCount 10
however many matches there were, and a client paging on the total stopped after
one page. The cap belongs at the ceiling — what can be paged to overall — not at
the current page.

The tests missed it because the only one asserting a total used
StartIndex=maxSearchLimit, where the window happens to equal the ceiling.

* refactor(jellyfin): fold the search clamp into clampLimit and simplify mergeTypes

Cleanup pass over the branch, no behaviour change:

- clampSearchLimit was clampLimit (similar.go) with different constants, so the
  latter takes the default and ceiling as arguments and both call it. Similar's
  default moves out of three IntOr calls into defaultSimilarLimit.
- mergeTypes derived a second `limit` and needed an early return for the empty
  page, only because paginate reads 0 as "unbounded". Clipping the merged slice
  to the window instead lets q.limit be passed straight through: window is
  min(offset+limit, ceiling), so clipping there is the same cut.
- playlistTracks returned (result, handled, error) where handled=false always
  meant error=nil. Splitting the lookup out gives playlistTracksRepo returning
  (repo, ok), and the nilerr suppression goes with it.
- The comment on playlists.Tracks blamed the log warning for its extra Get; the
  reason is that PlaylistRepository.Tracks discards the error behind a nil. Also
  drops a stale reference to a renamed variable.
2026-07-15 18:09:59 -04:00
Deluan Quintão
53d54baef0
fix(jellyfin): stream collection responses to prevent OOM on large libraries (#5783)
* fix(jellyfin): stream /Items responses to prevent OOM on large libraries

Finamp's library sync issues an unbounded GET /Items?IncludeItemTypes=Audio
with Fields=MediaSources and no Limit. On a large library this built the whole
result set — every MediaFile and every BaseItemDto, each fat with MediaSources —
in memory, and json.Encoder then buffered the entire ~200MB response before
writing a byte. Measured on a 96k-track library, one such request peaked near
1.6GB RSS; in a memory-limited container the resulting slowness made clients
retry, stacking concurrent full-library builds until the process was OOM-killed.

Stream the song listing straight from a DB cursor (MediaFileRepository.GetCursor),
mapping and encoding one row at a time, so peak memory is bounded to about one
item regardless of library size. The cursor is opened lazily, when streaming
begins, so it doesn't hold a DB connection across the CountAll and ServerId
lookups that run first (ServerId can write on first use and would deadlock
against an open reader). Pagination still works — the cursor query carries
LIMIT/OFFSET/ORDER BY from StartIndex/Limit/SortBy — and TotalRecordCount stays
the full count. Other materialized responses stream their JSON too, avoiding the
encoder's buffer-the-whole-output cost.

Also bound artwork image concurrency with the same server.ThrottleBacklog that
Subsonic's getCoverArt uses, so a burst of image requests during sync can't
exhaust memory through concurrent decode/resize.

Verified against a copy of a 96k-track production database: byte-for-byte
identical response, peak RSS 1593MB -> 53MB, time-to-first-byte 8.1s -> 0.2s.

* fix(jellyfin): fail loudly on /Items streaming errors instead of a truncated 200

Addresses code review: a streamed /Items response commits HTTP 200 before an
error can occur, so failures were surfacing as misleadingly successful bodies.

- A cursor-open failure (e.g. a busy DB under connection pressure) is now
  surfaced before the first byte is written: the cursor open is deferred into
  the item source and run in writeItems after the ServerId lookup but before the
  envelope, returning a clean 500 the client can retry — not a 200 with an empty
  item list.
- A mid-stream (row-scan) error now aborts without closing the JSON envelope,
  leaving the body malformed. A truncated-but-valid response would let a sync
  client (Finamp) treat the short list as the whole library and prune local
  tracks; malformed JSON forces the client's parser to fail and retry.

* refactor(jellyfin): stream every collection endpoint from a DB cursor

Streaming was applied only to the song listing, which left two write paths, two
ServerId stamping mechanisms and a listXxx return-type split ("songs is
special") that made the code hard to follow.

Add GetCursor to the album, artist, genre and playlist repositories, mirroring
MediaFileRepository.GetCursor: same select builder as GetAll, so each cursor
yields identical, fully-hydrated rows (hydration happens per-row in PostScan,
and toModels is only a deref loop). The playlist cursor keeps GetAll's
owner/public visibility filter. Each repository test asserts the cursor yields
exactly what GetAll returns, including Max/Offset.

With cursors everywhere, every listXxx returns itemsResult and every collection
streams through one writer:

- listAlbums, listArtists and listPlaylists now stream from their cursors;
  search paths stay materialized (Search returns a slice).
- listGenres stays materialized: its total is the length of the full list and it
  paginates in memory, so there is nothing for a cursor to page over.
- api.ok's QueryResult case delegates to writeItems, so the ~9 inline callers
  funnel into the same writer; streamQueryResult and wrapResult are gone.
- /Items/Latest streams as a bare JSON array (its Jellyfin wire shape) via
  writeItemsArray, which shares the item loop and stamping. That also closes the
  same unbounded hole /Items had: limit=0 disabled its LIMIT.
- ServerId is now stamped in exactly one place, so api.ok only handles single,
  non-collection payloads.

Verified against a copy of a 96k-track production database: every endpoint
byte-for-byte identical to master. Unbounded /Items peak RSS 1691MB -> 54MB;
time-to-first-byte 6.3s -> 0.19s, /Artists 1.14s -> 0.13s.

* refactor(jellyfin): route every response through api.ok

Handlers were split between api.ok and api.writeItems with no clear rule for
which to call. api.ok now accepts itemsResult too, so it is the single entry
point: callers hand it whatever they have and it routes collections (cursor
-backed or materialized) to the streaming writer. writeItems is reached only
through api.ok now. The one exception is /Items/Latest, which returns a bare
JSON array rather than a QueryResult envelope and so writes directly — noted in
both doc comments.

Also drop GenreRepository.GetCursor: listGenres derives its total from the
length of the full list and paginates in memory, so there is nothing for a
cursor to page over, leaving the method unused.

* fix(jellyfin): stream the unbounded multi-type /Items merge

The multi-type merge capped each per-type query at offset+limit only when a
Limit was given. Without one (Finamp's favorites screen sends multi-type), every
type ran an unbounded query and collect() drained each cursor into a slice — so
/Items?IncludeItemTypes=MusicAlbum,Audio with no Limit materialized every album
and every song, the same OOM class this branch set out to fix. The comment
claiming the set was "capped at offset+limit per type" was wrong for limit=0.

Without a limit the merged page is just each type's rows in order minus the
first offset, which is exactly what chaining the per-type cursors yields, so
stream that instead of merging in memory. The bounded path is unchanged: with a
Limit each type holds at most offset+limit rows, so merging and paginating
across the combined list is safe. Cursors are opened one at a time (each pins a
DB connection until drained), with the first opened eagerly so the usual failure
is still a clean error before any byte is written.

Measured on a 96k-track library, /Items?IncludeItemTypes=MusicAlbum,Audio with
no Limit (103,393 items): peak RSS 612MB -> 53MB, time-to-first-byte 4.9s ->
0.6s, response byte-for-byte identical.

* refactor(jellyfin): parse /Items params into a struct

The /Items dispatcher was a single 90-line block that parsed a dozen params and
then threaded them positionally through three layers — queryItemsOfType took 11
arguments, listSongs and listAlbums 9 each — so reading any one of them meant
decoding a long argument list.

Parse once into an itemsQuery and pass that instead. queryItems now reads as the
four things it actually does: parse, the id/playlists-folder/playlist-parent
special cases, single-type dispatch, multi-type merge — with the playlist-parent
and merge bodies moved to playlistTracks and mergeTypes. Every listXxx takes
(ctx, opts, q).

No behavior change: parsing, ordering and the entityParent rule are unchanged,
and /Artists still passes favOnly=false explicitly by building the subset of the
query it uses.

* docs(jellyfin): trim comments on the streaming path

Cut the comments back to the non-obvious why: the deferred cursor open (the
ServerId write would deadlock against an open reader), the deliberate malformed
JSON on a mid-stream error, why chained opens one cursor at a time, why
listGenres stays materialized, and why the cursor helpers take the underlying
func type. Dropped the rest — restatements of the code, doc comments that
repeated a test's own name, and the GetCursor interface comments that the
existing MediaFileRepository.GetCursor does without.

* refactor(persistence): fold the cursor wrappers into a generic wrapCursor

wrapAlbumCursor, wrapArtistCursor, wrapMediaFileCursor and wrapFolderCursor were
the same twelve lines four times over, differing only in the type name, and the
playlist cursor had its own inline copy of the loop.

Add one generic wrapCursor. It takes an extractor func rather than a method on
an interface: a type parameter can't reach an embedded field, and methods that
only satisfy a generic constraint are reported by the unused linter. The
extractor also lets both type parameters be inferred, so call sites need no
explicit instantiation. Each entity keeps its named wrapper as a one-liner,
since the model cursor types are defined types and need the conversion — and the
existing wrapper tests call them directly.

The nil-row error now names the model type via %T ("unexpected nil model.Album")
instead of a hand-written per-entity string; the three tests asserting that
message are updated.

* fix(jellyfin): bound concurrent collection streams

A streamed collection holds a DB cursor — and its pooled connection — for the whole client-paced
response, where the old materialize-then-write path released it as soon as the query finished. The
pool is shared with the scanner, Subsonic, the native API and the UI, so enough slow clients take
every connection and everything else blocks waiting for one. Measured against a copy of a 96k-track
production DB, 20 concurrent unbounded streams against a pool of 16 stalled a write for 16.2s (the
other 14 writes in the run took ~2ms — the signature of connection starvation, not lock contention).

Cap concurrent streams at half the pool. Excess requests queue rather than fail, so no client is
rejected: the same 20 streams now all complete and the worst write is 2.5ms.

- conf.MaxOpenConns() now owns the pool sizing (db calls it). It belongs in conf: the pool is a
  tunable, db already imports conf, and putting it in db would force server/jellyfin to import db
  just to size the cap against it. Expressing the cap as MaxOpenConns()/2 also keeps the two from
  drifting apart.
- Uses chi's ThrottleBacklog, not server.ThrottleBacklog: the latter buffers the whole response to
  release its token early, which is right for artwork but would undo the streaming. chi's panics on a
  non-positive limit, so throttleStreams guards it — setting MaxConcurrentStreams=0 disables the cap
  instead of crashing the server at startup.

* refactor(jellyfin): move throttleStreams to middlewares.go

It's a middleware, so it belongs beside normalizeQueryKeys, authenticate and
withPlayer rather than in api.go. Its tests move to middlewares_test.go with it,
keeping one test file per production file.

* fix(jellyfin): abandon the scan when the client stops reading

encodeItems discarded its write errors and relied on the final Flush to report
them, so once bufio's buffer filled and the flush failed, the loop still pulled
every remaining row through the cursor and serialized it for a client that was
gone. A test with a failing writer confirms it: all 20k items were drained.

That wastes CPU, and holds the cursor's pooled DB connection and a stream slot
(now a capped resource) for the length of a full scan nobody is reading. Check
the per-item writes so the first failure ends the scan; the fixed envelope
writes stay unchecked, since bufio latches for them anyway.
2026-07-15 14:07:00 -04:00
Deluan Quintão
fe6ac2e577
feat(jellyfin): experimental Jellyfin Music API support (#5730)
* feat(sharing): enable sharing by default

Flip the EnableSharing default from false to true so new installations have the sharing feature available out of the box. Users can still disable it via the EnableSharing config option.

The native API only registers the /share route when sharing is enabled, so the nativeapi tests that build the router without wiring a share service now explicitly disable sharing in their setup to avoid registering a route backed by a nil service.

* feat(jellyfin): add config flag and URL path constant

Adds the disabled-by-default Server.Jellyfin config option (Enabled,
ServerName) and consts.URLPathJellyfinAPI, following the existing
LastFM/ListenBrainz patterns. Later tasks will use these to mount the
Jellyfin-compatible API router.

* fix(jellyfin): default Jellyfin ServerName to "Navidrome"

* feat(jellyfin): package skeleton with System handshake endpoints

Adds server/jellyfin: the Router (mirrors server/subsonic and
server/public), its Wire-friendly New(...) constructor, a chi routes()
table, and the ok() JSON response helper. Implements the unauthenticated
handshake surface Jellyfin clients probe first: GET /System/Info/Public,
GET+POST /System/Ping, and GET /QuickConnect/Enabled (quick connect is
unsupported, so it always reports disabled).

The public info payload's Id must be stable across restarts (Jellyfin
clients cache ServerId), so it reuses the get-or-create Property pattern
already used for InsightsID in core/metrics/insights.go: fetch
consts.JellyfinServerIDKey from the Property repository, generating and
persisting a new UUID on first read. A dedicated key (rather than the
existing InsightsID) keeps the anonymous telemetry identifier from being
exposed on this unauthenticated endpoint.

* test(jellyfin): cover ping and quickConnectEnabled handlers

Both were added alongside the System/Info/Public handshake endpoint but
had no direct test coverage.

* fix(jellyfin): memoize stable server Id and cover get-or-create path

* feat(jellyfin): wire and mount the router behind Jellyfin.Enabled

Add jellyfin.New to the shared Wire provider set and a
CreateJellyfinAPIRouter injector, then mount the router at /jellyfin
in startServer(), gated by conf.Server.Jellyfin.Enabled, mirroring the
existing LastFM/ListenBrainz router mounts.

* feat(jellyfin): add Jellyfin DTOs and model mappers

Adds BaseItemDto, QueryResult, UserItemDataDto, UserDto,
AuthenticationResult, MediaSourceInfo, PlaybackInfoResponse,
NameGuidPair and SessionInfo DTOs to server/jellyfin/dto, plus
model-to-DTO mappers for songs, albums, artists and genres that
later browse/stream/write endpoints will consume.

* test(jellyfin): cover GenreToBaseItem mapper

* feat(jellyfin): advertise Jellyfin-compatible version, brand ServerName with Navidrome version

* fix(jellyfin): map LastPlayedDate and omit zero track/disc numbers

* feat(jellyfin): authentication middleware and AuthenticateByName login

* fix(jellyfin): reject empty login password, wire ServerName, add negative auth tests

* refactor(jellyfin): use new(x) builtin instead of intPtr helper

* feat(jellyfin): user views and current-user endpoints

* feat(jellyfin): Items query engine, item detail, and latest

Adds the /Items universal query endpoint, dispatching by IncludeItemTypes
over albums/artists/songs/genres with ParentId, SearchTerm, Filters=IsFavorite,
SortBy/SortOrder and StartIndex/Limit support, plus GET /Items/{itemId} and
/Users/{userId}/Items/Latest. Reuses server/subsonic/filter builders (by
artist/album/starred) instead of hand-rolled squirrel filters, and resolves
sort keys per item type since each repository maps sort names to different
real/aliased columns.

Also adds the missing CountAll to tests.MockArtistRepo, exposed by this task
(the mock embedded a nil ArtistRepository for it and would panic on use).

* refactor(server): extract shared query filter builders to server/filter

Moves server/subsonic/filter to server/filter so server/jellyfin can use
the shared query-option builders without importing server/subsonic,
enforcing the rule that no API package imports another API's package.

* feat(jellyfin): full multi-library support with per-user access scoping

Replace the single hardcoded "music" UserView with one CollectionFolder
view per library the user can access, and scope every /Items browse
query (albums, songs, artists, /Latest) to those libraries via
server/filter's ApplyLibraryFilter/ApplyArtistLibraryFilter.

ParentId is now disambiguated: a numeric value the user has access to is
treated as a library scope (browsing a UserView), otherwise it falls
through as an entity id (artist/album), which safely matches nothing
rather than leaking another library's content. getItem now 404s when
fetching an album or song outside the user's accessible libraries;
artists are skipped (they can span multiple libraries) with a TODO.

Genres remain unscoped since they're global tags, not per-library
entities.

* test(jellyfin): cover admin library-access path and drop nil test contexts

* feat(jellyfin): Artists and Genres endpoints

Add /Artists, /Artists/AlbumArtists, /Genres and /MusicGenres. Artists
listing is library-scoped (defaulting to the user's accessible
libraries, narrowed by an accessible ParentId), delegating access
control to listArtists/ApplyArtistLibraryFilter. Genres are global and
unscoped, matching the ML decision already made for listGenres.

* refactor(jellyfin): share resolveLibraryScope between items and artists

* feat(jellyfin): item image endpoint via artwork service

* fix(jellyfin): let net/http sniff image Content-Type instead of forcing jpeg

* feat(jellyfin): audio streaming and PlaybackInfo with library access control

* feat(jellyfin): favorites and rating write-back with library access control

Adds POST/DELETE handlers for /Users/{userId}/FavoriteItems/{itemId} and
/Users/{userId}/Items/{itemId}/Rating. A shared resolveAnnotated helper
probes album/artist/media file (mirroring getItem's order) and 404s before
writing if the user lacks access to the album/song's library; artists are
exempt since they span multiple libraries. Ratings are halved coming in
(Jellyfin 0-10 -> Navidrome 0-5) to match the doubling in dto.UserData.

Also teaches MockMediaFileRepo and MockArtistRepo's SetStar/SetRating (and
MockAlbumRepo's, previously a no-op) to actually mutate the backing data so
write-back can be asserted in tests.

* feat(jellyfin): playback reporting and scrobbling

Add /Sessions/Playing[/Progress|/Stopped] and /Sessions/Capabilities[/Full]
handlers, backed by core/scrobbler.PlayTracker. A new withPlayer middleware
resolves/registers a model.Player from the Emby DeviceId header (used
directly as the stable player id, unlike Subsonic's cookie fallback) and
injects it into the request context for scrobbling.

The Stopped report calls ReportPlayback with IgnoreScrobble to end the
now-playing session without double-counting, then calls Submit as the
single source of the play-count increment and external scrobble.

* feat(jellyfin): playlist read and write-back

Adds POST /Playlists, GET/POST/DELETE /Playlists/{id}/Items. Tags each
playlist item with PlaylistItemId (the entry's position within the
playlist) so DELETE .../Items?EntryIds=... can remove a specific
occurrence by the id core/playlists.RemoveTracks actually expects,
rather than the song id used everywhere else.

* test(jellyfin): cover empty Ids/EntryIds in playlist add/remove

* feat(jellyfin): unknown-route logging, generic 500s, rating clamp, docs

Hardening pass ahead of real client testing: unmatched routes and
unsupported methods now return a logged, JSON 404 instead of chi's
default plain-text response, so a missing endpoint a client needs is
easy to spot in the logs. Internal errors (ffmpeg output, file paths,
etc.) no longer leak into 500 response bodies -- a shared
internalError helper logs the real error server-side and always
returns a generic message. Inbound Jellyfin ratings are clamped to
0-10 before being halved into Navidrome's 0-5 scale, and /System/Ping
now replies with a bare plain-text body as real Jellyfin servers do.
Adds a README with an enable/curl walkthrough and known limitations.

* docs(jellyfin): clarify public image endpoint and accessibleLibraryIDs comments

* fix(jellyfin): case-insensitive path routing for Jellyfin client compatibility

* refactor(server): extract case-insensitive path routing to a shared helper

* fix(jellyfin): return User Policy and Configuration so Finamp completes login

* fix(jellyfin): support Playlist type, multi-type Items queries, and PlayCount/DatePlayed sort

Real Finamp requests break against three /Items query engine bugs: Playlist
requests fell through to albums, multi-type IncludeItemTypes (e.g. Finamp's
favorites screen) only returned the first requested type, and comma-separated
SortBy lists (e.g. "DateCreated,SortName") were matched as one opaque string
so they always fell through to the repo default.

Also extends tests/mock_playlist_repo.go with SetData/GetAll so playlist
listing is testable like the other mock repos.

* feat(jellyfin): implement /socket WebSocket for real-time client sessions

* fix(jellyfin): resolve library-view ids in getItem so clients can load the library

* fix(jellyfin): serve direct file at /Items/{id}/File and accept ApiKey query param

* fix(jellyfin): resolve playlist ids in getItem

* fix(jellyfin): read lowercase ids/entryIds playlist params and add playlist-users endpoints

* fix(jellyfin): include MediaSources with Size/Bitrate on tracks so clients show download size

* fix(jellyfin): populate all required MediaSourceInfo bool/array fields to match Jellyfin

* fix(jellyfin): hex-encode item ids at the API boundary for Jellyfin client compatibility

Finamp (and presumably other clients) parses ids as radix-16, but Navidrome's
base62 nanoids aren't valid hex and crash its queue packing. Hex-encode every
id emitted by the Jellyfin API and decode every id received, keeping the
transform reversible and stateless at the boundary rather than touching any
model id.

* fix(jellyfin): populate MediaStreams with the audio stream so clients can size/transcode

* fix(jellyfin): support Ids batch-fetch in /Items so clients can fetch items by id

* fix(jellyfin): do not disable Jellyfin server when disabling external services

* fix(jellyfin): emit per-image ImageBlurHashes so clients de-dupe images and stop warning

* fix(jellyfin): support playlist cover upload/delete and display via item image endpoints

* feat(jellyfin): implement GET /Playlists/{id} for playlist visibility

Finamp's playlist edit screen calls GET /Playlists/{id} to read the
OpenAccess (public visibility) flag; we only had the sub-routes, so it
404'd and the edit screen failed to load. Return the Jellyfin PlaylistDto
shape (OpenAccess from Public, empty Shares, media item ids).

* fix(jellyfin): display playlist cover art

Uploaded playlist covers never showed in clients for two reasons: the
playlist BaseItemDto advertised no Primary ImageTag (so clients didn't
know to fetch a cover), and the public image endpoint resolved artwork
under the request's anonymous context, so a private playlist failed its
visibility filter and fell back to the placeholder. Advertise the Primary
image tag/blurhash on playlists, and resolve artwork under an elevated
context (as core/artwork's cache warmer does).

* fix(jellyfin): expand album/artist/playlist ids when building playlists

Jellyfin clients (Finamp) send container ids — an album, artist or
playlist — in a playlist's Ids list and expect the server to expand each
into its child tracks. core/playlists only understands media file ids, so
creating or adding with an album id silently produced an empty playlist.
Expand container ids to their tracks (in order) before create/add; bare
song ids still pass through. Adds filter.SongsByArtistID.

* fix(jellyfin): support playlist deletion via DELETE /Items/{id}

Finamp deletes a playlist with DELETE /Items/{id}, which we didn't route,
so deletion silently failed. Implement it via core/playlists.Delete (which
enforces ownership and removes the cover file). Only playlists are
deletable through this API; non-playlist ids return 404, non-owners 403.

* test(jellyfin): add e2e suite harness + smoke tests

* test(jellyfin): e2e for system, auth, routing, browsing, annotations

* test(jellyfin): e2e for playlists (CRUD, expansion, cover) and item images

* test(jellyfin): e2e for streaming, sessions, and multi-user access control

* fix(jellyfin): artist search 500 (library filter leaked into FTS query)

getArtists/listArtists reused the browse-path filters (notMissing +
ApplyArtistLibraryFilter) for the search path, but artist Search expects a
sole Eq{library_id} filter it can consume as a scope — artists have no
library_id column, so the compound/join filter leaked into the FTS query
and 500'd. Every Finamp artist search failed (the filter is applied even
unscoped, since admins resolve to all library ids). Build search filters
separately. Adds e2e search coverage.

* feat(jellyfin): implement POST /Playlists/{id} to update name, visibility, tracks

Finamp edits a playlist (make public, rename, reorder) via POST
/Playlists/{id}, which we didn't route, so every edit 404'd. Implement it:
Ids present -> replace track list (Create with existing id, preserving
name); otherwise update Name/IsPublic via core/playlists.Update. Adds a
shared playlistError helper (403/404/500) reused by deleteItem, plus e2e
coverage.

* docs(jellyfin): update README for playlists, images, id encoding, and e2e

* fix(jellyfin): default album track listing to track order

Browsing an album's tracks (Items?ParentId=<albumId>&IncludeItemTypes=Audio)
took only SongsByAlbum's filters and dropped its Sort, so tracks came back
in arbitrary order. Default opts.Sort to the album (disc+track) order when
browsing an album without an explicit SortBy — matching Subsonic's GetAlbum
and real Jellyfin. An explicit SortBy still wins.

* fix(jellyfin): filter items by AlbumArtistIds/ArtistIds

An artist's page in Finamp sends ParentId=<libraryId> (scoping) plus
AlbumArtistIds/ArtistIds/contributingArtistIds for the artist itself, but
queryItems only honored ParentId, so an artist's albums and tracks came
back unfiltered (every artist's content). Parse the artist-id params and
apply AlbumsByArtistID (albums) / SongsByArtistID (tracks). Adds e2e
coverage.

* fix(jellyfin): only count a play past the scrobble threshold

reportPlaybackStopped set IgnoreScrobble and force-submitted a play on
every Stopped report, so a briefly-played track (e.g. an immediate skip)
was marked played. Finamp sends Stopped on every track switch, so the
threshold must be applied server-side. Let ReportPlayback's StateStopped
logic decide (play + scrobble only past 50% of the track / 4-minute cap)
instead. Subsonic differs because there the client gates submission.

* fix(jellyfin): sort album tracks by track number when client sends IndexNumber SortBy

Finamp's album view requests SortBy=ParentIndexNumber,IndexNumber,SortName
(disc, track, name), but applySort didn't recognize ParentIndexNumber or
IndexNumber and fell through to SortName, sorting tracks alphabetically by
title. Map both keys to the album (disc+track) sort. The reversed-title
fixture lets the e2e tell track order from title order.

* fix(jellyfin): honor the isFavorite query param for favorites filtering

Finamp's artist 'Favourite tracks' widget requests favorites via the
standalone isFavorite=true query param, not Filters=IsFavorite, so the
filter was ignored and non-favorited tracks were returned. Detect both
forms. (The widget's reshuffling is Finamp's explicit SortBy=Random.)

* feat(jellyfin): emit DateCreated (Date Added) on items

BaseItemDto had no DateCreated, so clients showed 'No Date Added' and had
nothing to sort 'Recently Added' by. Emit it as ISO 8601 from each entity's
CreatedAt (the same field the recently_added sort uses) for songs, albums
and artists.

* fix(jellyfin): set ArtistItems/AlbumArtists on songs (Now Playing artist)

SongToBaseItem only set Artists (names) and AlbumArtist, not the structured
ArtistItems/AlbumArtists. Finamp's Now Playing screen reads ArtistItems and
shows 'Unknown Artist' when it's absent. Populate both (track artist and
album artist) as name+id pairs, mirroring AlbumToBaseItem.

* docs(jellyfin): note synthetic blurhash as a follow-up

Document that ImageBlurHashes are derived from the item id (a solid-color
placeholder), not computed from the cover art like real Jellyfin, and
outline what a proper implementation would take.

* docs(jellyfin): note WebSocket events and favourited playlists as follow-ups

* fix(jellyfin): filter the Artists page by role (album artist vs performer)

/Artists and /Artists/AlbumArtists both called the same role-agnostic
handler, so Navidrome's per-role artist entries (composers, arrangers,
performers) all showed as Album Artists, and the two tabs were identical.
Filter /Artists/AlbumArtists to RoleAlbumArtist and /Artists to RoleArtist
(and the MusicArtist browse to album artists), via filter.ArtistsByRole.
Verified live: Beatles Singles' album-artists dropped 138->5, composers
excluded, performers (Billy Preston) show only under /Artists.

* fix(jellyfin): sort playlists by name (missing Playlist sort mapping)

applySort had no sortColumnsByType entry for the Playlist type, so
SortBy=SortName was ignored and the Playlists screen showed them in the
repo's default order. Map SortName/Name -> name (as Subsonic does) and
DateCreated -> created_at. Verified live: case-insensitive alphabetical.

* fix(jellyfin): read query params case-insensitively (Jellify support)

Jellify (and the official Jellyfin TypeScript SDK) send query params in
camelCase (parentId, albumArtistIds, artistIds, includeItemTypes), where
Finamp sends PascalCase. Our handlers read fixed-case keys, so every
Jellify filter/sort/paging param was silently dropped:

- an artist's page listed albums and tracks from all artists
- opening an album listed every album instead of its tracks

Real Jellyfin binds query params case-insensitively (ASP.NET model
binding), so add a normalizeQueryKeys middleware that folds every query
key to lowercase once, and read params by their lowercase name. This
also removes the scattered dual-case hacks (Ids/ids, IsFavorite,
ContributingArtistIds, api_key/ApiKey, queryParam) that let this bug
class through.

Also infer the child type from an album parent: Jellify browses an album
with only parentId (no IncludeItemTypes), and Jellyfin infers Audio from
the parent; without it we fell back to listing all albums.

Verified live against Finamp+Jellify and with 4 new e2e specs replaying
Jellify's exact request shapes.

* fix(jellyfin): advertise LocalAddress in the public system info handshake

Jellify (and other @jellyfin/sdk clients) that connect by raw address fall
back to HTTP when TLS isn't available, then adopt the handshake's
LocalAddress as their server base URL. We never populated it, so Jellify's
SDK `api` object was undefined and sign-in crashed with "Cannot read
property 'configuration' of undefined" (getUserApi(api!) with an undefined
api). Over an HTTPS connection Jellify uses connectionType=hostname and
never reads LocalAddress, which is why it worked while an HTTPS proxy was
in front.

Populate LocalAddress from the request — scheme + host (honoring
X-Forwarded-*), plus the /jellyfin mount path — matching real Jellyfin,
which always sends it. Export server.ServerAddress for the resolution.

* fix(jellyfin): separate "Featured On" from an artist's own discography

Jellify's artist page fetches the discography via albumArtistIds and the
"Featured On" section via contributingArtistIds, relying on the server to
return disjoint sets. We collapsed albumArtistIds/artistIds/
contributingArtistIds into one AlbumsByArtistID filter, so an artist's own
albums appeared in both sections.

Add AlbumsByContributingArtistID — albums where the artist is a track
artist but NOT the album artist — matching Jellyfin's ContributingArtistIds
(in Artists, not in AlbumArtists), and route contributingArtistIds to it.

* fix(jellyfin): accept a bare Authorization token for audio streaming

Jellify's native player (react-native-nitro-player) authenticates the audio
stream by setting a bare Authorization header carrying the raw access token
({ AUTHORIZATION: api.accessToken }), not the "MediaBrowser ... Token=" scheme
parseEmbyAuth understands. tokenFromRequest didn't recognize it, so every
/Audio/{id}/stream request from the native player 401'd and no audio played
(the JS client still optimistically posted playback progress, masking it).

Accept a bare (or "Bearer <token>") Authorization header, as real Jellyfin
does. Verified live: the native player's exact request now streams 200.

* fix(jellyfin): embed a self-authenticating stream URL in PlaybackInfo

Jellify's native audio player (react-native-nitro-player / ExoPlayer) fetches
the stream without forwarding any auth — captured request headers were only
Connection/Icy-Metadata/Accept-Encoding/User-Agent, no Authorization and no
api_key — so every /Audio/{id}/stream request 401'd and nothing played (the
JS client still optimistically posted progress, masking it).

Real Jellyfin returns stream URLs with the token embedded; we returned none,
so Jellify fell back to a token-less DirectPlay URL. Populate
MediaSources[0].TranscodingUrl with /Audio/{id}/universal?api_key=<caller
token>, which Jellify's player uses verbatim. Direct-play clients (Finamp
builds its own /Items/{id}/File?ApiKey URL) ignore the field, so they're
unaffected.

* feat(jellyfin): serve per-item UserData (GET /UserItems/{id}/UserData)

Jellify fetches this per item to render played/favourite indicators; we 404'd
it. Resolve the item via the existing getItem resolver (which loads the
caller's annotations and enforces the same library-access gate) and return its
UserData, falling back to an empty-but-valid object for items without
annotations (e.g. playlists). Also registers the legacy
/Users/{userId}/Items/{itemId}/UserData spelling.

* refactor(jellyfin): drop unused bare-Authorization token support

Added mid-troubleshooting on the theory that Jellify's native player sends a
bare Authorization header, but the debug capture showed it sends no auth header
at all — the real fix was embedding api_key in PlaybackInfo's TranscodingUrl.
No client reaches this path (Jellify JS uses the MediaBrowser scheme, Finamp
uses X-Emby-Token, the native player uses the api_key URL), so remove it and
its tests per YAGNI. Effectively reverts 4f8ac1e0.

* feat(jellyfin): implement Similar endpoints via the external provider

Add GET /Artists/{id}/Similar (related artists) and GET /Items/{id}/Similar
(similar songs for a track, similar albums for an album, related artists for an
artist), sourced from the same external.Provider (Last.fm etc.) that powers
Subsonic's getArtistInfo2/getSimilarSongs. Only library-present artists are
returned so each is navigable; provider errors and unknown ids degrade to an
empty 200 result, so clients (Jellify) stop hammering these with 404 retries.

Injects external.Provider into the Router (regenerated via make wire).

* perf(jellyfin): make Similar endpoints non-blocking (bounded quick wait)

Each /Similar request fetched from Last.fm synchronously (500ms-1.4s), and
Jellify requests Similar for many items at once on the home/artist screens, so
the whole screen stalled — a home pull-to-refresh dragged for seconds.

Run the external lookup on a background context and return within a 500ms quick
wait: a cached artist resolves instantly, a cold one returns empty now while the
lookup finishes caching in the background, so a later load is fast and
populated. Verified live: cold calls bounded at ~500ms (was up to 1.4s), warm
calls 3-8ms.

* fix(jellyfin): answer ManualPlaylistsFolder so the home stops stalling

Jellify resolves its "playlists library" via IncludeItemTypes=
ManualPlaylistsFolder, then lists playlists with ParentId set to that folder's
id. We didn't recognize the type, so parseTypes fell back to MusicAlbum and
returned the album list. Jellify's query then found no item with
CollectionType=playlists and resolved undefined — which React Query rejects,
retrying it in a backoff loop that stalled the home pull-to-refresh for ~5s
(every response was fast server-side; the delay was the client's retries).

Return a synthetic "playlists" folder (CollectionType=playlists) for the
ManualPlaylistsFolder query, resolve ParentId=<that folder> to the user's
playlists, and give playlists a Path under "data" (Jellify drops playlists
whose Path lacks it). Adds a Path field to BaseItemDto.

* docs(jellyfin): note lyrics and InstantMix/sonic-similarity as follow-ups

* fix(jellyfin): genre paging params and same-key casing collisions

getGenres read StartIndex/Limit in PascalCase, which normalizeQueryKeys had
already folded to lowercase, so genre paging was silently ignored; totals now
come from the full (small) genre list instead of the page length.
normalizeQueryKeys now merges values when two casings of a key collide,
instead of nondeterministically keeping one.

* fix(jellyfin): round ratings to the nearest star instead of truncating

Rating is a nullable double 0-10 in Jellyfin's contract. Truncating integer
division stored 9 as 4 stars, and both Rating=1 and fractional values (which
failed integer parsing) became 0 — silently deleting the rating. Parse as
float, round, and floor nonzero input at one star.

* fix(jellyfin): apply Name/IsPublic sent together with a track replacement

Jellyfin's UpdatePlaylist applies every provided field, but the Ids branch
returned early, silently discarding a rename or visibility change sent in the
same body (core/playlists.Create with an existing id ignores the name).

* fix(jellyfin): don't rotate the stored server id on transient DB errors

serverID treated any Property.Get error as "no id yet" and persisted a fresh
UUID over JellyfinServerID, with sync.Once pinning it for the process lifetime
— a busy DB or canceled request context on the first request would break every
client's cached ServerId. Only ErrNotFound mints a new id now, and failures
yield an uncached temporary value so the next request retries.

* fix(jellyfin): report real search totals instead of page length or unfiltered counts

Artist search returned TotalRecordCount = len(page), so clients stopped after
the first page; album/song search counted via CountAll, which can't see the
search term, so clients paged through phantom results. The repos' Search API
has no match count, so fetch one row beyond the page: offset+len is exact on
the last page and a strictly growing lower bound before it — paging clients
terminate exactly at the last match.

* fix(jellyfin): browse playlists via the generic /Items path and resolve the playlists folder by id

A typeless /Items?ParentId=<playlistId> (legal in real Jellyfin, used by
generic clients) fell through to the MusicAlbum default and returned an empty
list; it now returns the playlist's tracks, paginated, with visibility
enforced by GetWithTracks. resolveItemByID also answers the synthetic
playlists-folder id the server itself advertises instead of 404ing it.

* perf(jellyfin): cap per-type queries in multi-type /Items requests

The multi-type merge path queried each type with a zero-value QueryOptions —
no LIMIT in SQL — materializing every matching row (each with embedded
MediaSources) just to slice out one page in memory. Each type now fetches at
most StartIndex+Limit rows, the worst case one type can contribute to the
merged window; totals still come from CountAll.

* fix(jellyfin): dedupe and bound the background Similar fetches

awaitSimilar spawned a detached, deadline-free goroutine per request; clients
re-polling after the empty quick-wait response piled up duplicate provider
chains (no singleflight anywhere below) racing writes on the same artist row.
Identical in-flight requests now share one fetch — keyed per user, since the
mapped items embed the user's annotations — and the background context gets a
one-minute deadline so a hung provider can't hold goroutines forever.

* fix(jellyfin): gate private playlist covers on the public image route

The unauthenticated image endpoint elevated every request to an admin context,
so anyone who knew or guessed a playlist id could fetch another user's private
uploaded cover. Library artwork still resolves elevated (Jellyfin clients fetch
images without auth headers), but playlist covers are now served only when the
playlist is public or the request's optional token identifies its owner or an
admin; everyone else gets the placeholder.

* fix(log): redact full api_key values, including JWTs

The api_key pattern matched only word characters, stopping at a JWT's first
'.' — the Jellyfin API embeds the session JWT as api_key in TranscodingUrl, so
request logs kept its payload and signature, enough to reconstruct a replayable
token by prepending the constant header. Match to the next query separator
instead, like the sibling s=/p=/jwt= patterns.

* fix(jellyfin): record LastLoginAt on Jellyfin logins

authenticateByName re-implements credential validation and skipped the
UpdateLastLoginAt call the web UI's validateLogin makes, so users who only log
in via Jellyfin clients showed a never/stale Last Login in the admin UI.

* perf(jellyfin): batch song resolution in /Items?ids= and playlist expansion

Both paths probed up to four repositories per client-supplied id. Songs — the
common case — now resolve via chunked media_file.id IN queries (same pattern
as playqueue's loadTracks); only the residue pays the container probes.

* fix(jellyfin): wait for the real Similar result instead of answering a cacheable empty list

The 500ms quick wait returned an empty 200 for any cold lookup —
indistinguishable from "no similar items exist", so clients cached the wrong
answer until an app restart. With fetches deduplicated, wait up to the agents'
HTTP timeout for the actual result; only a hung provider now yields the empty
fallback, and its fetch still warms the cache in the background. Also makes
the dedup test deterministic (the old one raced its release channel).

* refactor(tests): extract the shared e2e harness into tests/harness

The Subsonic and Jellyfin e2e suites each carried their own copy of the
golden-DB lifecycle (boot, seed users/library, scan, WAL snapshot), the
ATTACH-DATABASE restore, fixture-FS registration, and the SpyStreamer /
NoopFFmpeg doubles. Those now live in one importable package (same pattern as
core/storage/storagetest); fixture libraries and request helpers stay
per-suite since they encode each API's test expectations.

* docs(jellyfin): make comments concise

Compress the narrative comments accumulated during live client testing into
short why-only notes; keep the client quirks, security rationales and gotchas,
drop the exposition. Comments-only change (net -141 lines).

* fix(tests): silence gosec taint false-positive in harness snapshot write

The write moved from a _test.go file (which gosec skips) into the importable
harness package; the path derives from GinkgoT().TempDir().

* fix(jellyfin): route the current /UserFavoriteItems favorite endpoint

@jellyfin/sdk 0.13.0 (used by Jellify) posts favorites to
POST/DELETE /UserFavoriteItems/{itemId}, while we only routed the legacy
/Users/{userId}/FavoriteItems/{itemId} (Finamp). Jellify favorites 404'd
("Failed to add favourite"). Route both spellings to the same handlers.

* feat(jellyfin): honor Fields on /Items and add missing conformance fields

Match real Jellyfin's response shape: gate MediaSources (and MediaStreams)
behind Fields=MediaSources instead of always embedding them — clients that
need Size request it, as Finamp does — which cuts a 46-track artist response
from ~87KB to a fraction. Also emit the always-present fields Jellyfin sets:
ServerId (stamped centrally in ok), LocationType, HasLyrics, and SortName
(when Fields=SortName). PlaybackInfo still carries MediaSources (its purpose).

* fix(jellyfin): address code review security and correctness findings

Applies the reviewed findings from PR #5730:

- Gate similar songs/albums on the caller's library access, so the
  external provider can't surface metadata from libraries the user
  cannot see. Also clamp the client-supplied limit before it sizes any
  allocation or provider fetch (CodeQL user-controlled allocation).
- Prepend the /jellyfin mount prefix to the PlaybackInfo TranscodingUrl,
  so a client resolving it as an absolute host path still reaches the
  mounted router.
- Recognize WebP and GIF magic numbers on raw cover uploads, matching
  the formats Navidrome already supports.
- Bound the playlist cover upload: honor EnableArtworkUpload for
  non-admins and cap the body with MaxBytesReader/MaxImageUploadSize,
  mirroring the native image endpoint.
- Propagate non-not-found repository errors from resolveAnnotated as 500
  instead of silently returning 404.
- Normalize the literal prefix of mixed literal.param path segments
  (e.g. STREAM.mp3) so case-insensitive routing reaches stream.{container}.
- Rate-limit POST /Users/AuthenticateByName with the same per-IP limiter
  as /auth/login when AuthRequestLimit is set.
- Guard against a nil user in authenticateByName.

* fix(jellyfin): correct playlist track browsing and multi-id edits

Fixes three playlist issues, two found testing against Jellify and one
from the PR review:

- Resolve a playlist ParentId to its tracks even when the client sends
  IncludeItemTypes=Audio. Jellify opens a playlist with
  ParentId=<playlist>&IncludeItemTypes=Audio; the id was routed through
  listSongs as an album id, returning an empty list.
- Read repeated id query params (ids=X&ids=Y), not just the first value.
  Jellify's @jellyfin/sdk serializes id arrays as repeated params, so
  adding an album (which it expands client-side into many ids) only
  added the first track. Applies to both add and remove; the
  comma-separated form other clients use still works.
- Let an explicit empty Ids array clear a playlist. Ids is now a pointer
  so an omitted field still means 'leave unchanged', while an empty list
  clears the tracks (via RemoveTracks, since the repository skips track
  writes for an empty list).

* fix(jellyfin): register clients as players on any authenticated request

Jellyfin clients did not appear in the players list. Unlike Subsonic,
whose getPlayer middleware runs on every authenticated endpoint, the
Jellyfin router only registered a player on the /Sessions/Playing
reports, so browsing or streaming never created one.

Apply withPlayer to the whole authenticated group, mirroring Subsonic,
so the calling device registers (and scrobbling has a player) as soon as
it makes any authenticated request. Two follow-ups found while testing:

- Skip registration when the request carries no client/device info (no
  X-Emby-Authorization, e.g. the /socket handshake that auths via
  ?api_key= only), which otherwise created a junk player named ' []'.
- URL-decode the X-Emby-Authorization field values. Jellify's
  @jellyfin/sdk percent-encodes them (Device='Pixel%208%20Pro') while
  Finamp sends them raw, so the player name showed as
  'Jellify [Pixel%208%20Pro]'.

* refactor(jellyfin): move case-insensitive routing into the jellyfin package

The case-insensitive path normalization lived in the server package but
was only ever used by the Jellyfin router (its whole purpose is that
Jellyfin clients route case-insensitively while chi does not). Move it
into server/jellyfin and unexport it, so it sits with its only caller
and no longer needs to be exported across a package boundary.

* docs(jellyfin): document player registration, playlist and image behavior

Updates the package README for the behavior added/fixed this round:

- New 'Players and sessions' section: any authenticated request now
  registers the device as a player (like Subsonic), with the
  Client [Device] naming, URL-decoding of the Emby auth fields, and the
  /socket skip that avoids a nameless player.
- Authentication: note AuthenticateByName is rate-limited per IP.
- Playlists: repeated vs comma-separated id params, and that an explicit
  empty Ids clears the playlist while an omitted Ids leaves it untouched.
- Cover art: WebP/GIF magic-number detection plus the MaxImageUploadSize
  and EnableArtworkUpload gates.
- Endpoints table: add the /Similar and /UserFavoriteItems /
  /UserItems/.../UserData routes that were already served but unlisted.

* feat(jellyfin): expose configured users on the login user-picker

Adds Jellyfin.ExposedPublicUsers, a comma-separated allowlist of
usernames that GET /Users/Public advertises so Jellyfin clients (Finamp,
Jellify) can show a login user-picker instead of a blank username field.

The endpoint is unauthenticated, so it defaults to exposing no users and
never lists the full user table: only the admin-configured names are
returned, resolved live per request (a name that doesn't exist is skipped
and logged). Each entry is a minimal DTO (Name, Id) with no
Policy/Configuration, so admin status isn't leaked pre-login, and no
avatar since Navidrome has no per-user profile images.

* style(jellyfin): trim redundant comments

Remove comments that restated the code or duplicated an explanation
already given nearby, keeping the ones that capture non-obvious rationale
(client-specific quirks, gotchas). Comment-only change; no behavior
difference.

* perf(db): keep query planner statistics trustworthy with full ANALYZE

PRAGMA optimize's internal ANALYZE runs with a limited analysis budget
(~2000 rows) that writes wrong sqlite_stat1 entries for low-cardinality
indexes: on a 96K-track library it claimed (missing, library_id) narrows
to ~2000 rows when it matches the whole table. The planner then prefers
that index over the sort index and falls back to a full-table temp
B-tree sort per request, turning paginated song listings into
multi-second queries (reproduced at 5.5s on real hardware; ~90x slower
than with correct stats). Every index-creating migration re-triggered
the poisoning via the post-migration optimize, and the daily optimizer
could re-trigger it on large library changes. Setting analysis_limit on
the connection does not help: optimize ignores it.

Run a plain full ANALYZE instead: after migrations with schema changes,
and in db.Optimize (daily schedule and scan-end). Stats are stored in
the database file, so one connection suffices and the per-connection
pool loop is gone. The Optimize call at shutdown is removed: stats are
maintained at migration/scan/daily points, and an ANALYZE during
shutdown only delays it and races container stop timeouts.

* perf(db): add covering index for title-sorted song listings

Deep pagination over songs sorted by title (WHERE missing/library_id,
ORDER BY order_title LIMIT/OFFSET - the shape Jellyfin clients use to
enumerate the library, and non-admin native/Subsonic song lists share)
walked media_file_order_title and fetched the table row for every
skipped entry just to evaluate the filter and the annotation/bookmark
join keys: offset+limit random reads, seconds per page on cold spinning
disks.

The new (missing, library_id, order_title, id) index makes the offset
skip fully index-resident: filter columns and the join key come from the
index, and only the emitted page touches table rows. Measured on a
96K-track library: offset 50000 drops from ~6.5s (poisoned stats) /
~100ms (good stats, warm) to 24ms, and cold deep pages on NAS hardware
from ~7s to ~0.5s.

* feat(jellyfin): support transcoding via HLS playlist and server-forced player format

- withPlayer now propagates the player's configured transcoding into the
  request context (like Subsonic's getPlayer), so a format forced in
  Settings > Players applies to the Jellyfin stream endpoints
- new GET /Audio/{itemId}/main.m3u8, the endpoint Finamp plays through
  when its transcoding setting is enabled: a single-segment HLS VOD
  playlist pointing at the existing progressive transcode endpoint
- streamAudio: treat audioBitRate as bits/sec (Jellyfin convention) and
  fall back to audioCodec as target format when no container is given

* fix(jellyfin): honor GenreIds when browsing genre albums and tracks

Finamp's genre screen sends ParentId=<libraryId> plus GenreIds=<genreId>,
but /Items ignored the param, so every genre returned the whole library.

- filter.ByGenreID delegates to persistence.TagIDFilter (exported, was
  tagIDFilter), the same mechanism behind the native API's genre_id filter
- id lists are read via queryIDs, covering both spellings clients use
  (comma-separated and repeated params); /Items?ids= gains the repeated
  form too

* feat(jellyfin): filter album artists by GenreIds

Finamp's artist tab sends GenreIds to /Artists/AlbumArtists when a genre
filter is active; the param was ignored, returning all artists.

filter.ArtistsByGenreID matches artists credited as album artist on an
album with the genre, via a non-correlated semi-join over album
participants (86ms on a 29K-artist library; the correlated EXISTS form
takes 11 minutes). Applied on the browse path of /Artists* and
/Items?IncludeItemTypes=MusicArtist.

The performers variant (/Artists?GenreIds=) still ignores the filter: it
needs the same semi-join against media_file.participants, unmeasured on
large libraries.

* fix(jellyfin): version playlist image tag so clients refresh uploaded covers

Uploads were stored and served correctly, but Finamp kept showing the old
cover: it caches covers keyed by blurHash (its imageId is the item id,
which never changes), and both our image tag and synthetic blurhash were
derived from the playlist id alone.

The tag is now <id>-<UpdatedAt millis hex> (SetImage/RemoveImage go
through a full Put, which bumps UpdatedAt), and the blurhash derives from
the tag, so every cover change rotates both. UpdatedAt over-invalidates
(any playlist edit busts the cover cache), which only costs a refetch;
hashing the actual image remains the proper long-term tag.

An e2e test guards the whole chain, since a partial Put(pls, cols...)
would silently stop bumping UpdatedAt.

* fix(jellyfin): align cover upload limit and validation with the native endpoint

Cover uploads of large photos failed with a silent 400: the
MaxImageUploadSize cap was applied to the wire body, which Jellyfin
clients base64-encode (4/3 inflation), so the effective raw-image limit
was only ~7.5MB — and Finamp uploads picked photos uncompressed.

Align with the native endpoint:
- the limit caps the decoded image; the read cap allows for base64
  inflation
- validate by decoding (image.DecodeConfig) and take the storage
  extension from the real format instead of the Content-Type header,
  which clients get wrong (Finamp falls back to image/jpeg); a HEIC or
  corrupt file is now rejected instead of stored as a broken cover
- rejected uploads log the reason (size/limit/decode error); diagnosing
  this from production logs previously required guesswork

* fix(jellyfin): optimize SongsByArtistID filter to improve performance at library scale

Signed-off-by: Deluan <deluan@navidrome.org>

* fix(jellyfin): sort tracks by release year for SortBy=PremiereDate

Finamp's "Latest Releases" artist section sends
SortBy=PremiereDate,Album,... descending; PremiereDate wasn't in the
Audio sort map, so applySort fell through to Album and the view came
back in reverse album-name order (a 2002 remix album first, the 2013
release last).

Map premieredate/productionyear to the year sort key, matching the
ProductionYear the DTO exposes for songs and the existing MusicAlbum
mapping (premieredate -> max_year).

* feat(jellyfin): expose PremiereDate on tracks and albums

Finamp's "Latest Releases" artist/genre sections re-sort the merged
server responses client-side by PremiereDate and keep the top 5. Without
the field every comparison returns equal and Dart's unstable sort leaves
the picks in arbitrary order — a 2007 remix could lead the list even
with the server sorting by year correctly.

Serialize PremiereDate as ISO 8601 from the date tag (padding partial
"2007"/"2007-02" values so DateTime.tryParse accepts them), falling
back to the year; omitted when neither exists.

* feat(jellyfin): resolve Finamp-truncated item ids (saved queue restore)

Finamp persists its play queue by packing every item id into exactly 16
bytes (packIds assumes Jellyfin's 32-hex GUID ids), so our longer ids
come back truncated after an app restart and "Failed to restore queue"
loops forever: the /Items?ids= batch resolves nothing.

Navidrome ids can't be made GUID-shaped (nanoid ids can exceed 128 bits),
so compensate server-side: a 16-char id — a length no Navidrome id family
uses — is resolved by unique-prefix range scan, with ambiguity failing
safe. The ids= batch echoes the id as requested (Finamp matches restored
items by its stored ids), and stream, image, item, user-data, favorite,
rating and playback-report endpoints accept truncated ids transparently.

The proper fix belongs upstream in Finamp's packIds; documented in the
README so this layer can be removed once that ships.

* feat(jellyfin): implement Items/{id}/InstantMix

Finamp requests an instant mix on every track tap when its "start
instant mix for individual tracks" setting is on (plus the long-press
menus); the 404 made those taps fail with an error and play nothing.

A track seed returns itself first — Finamp plays exactly what comes
back — followed by the external provider's similar songs, capped at the
requested limit and filtered to the caller's libraries. Container seeds
(artist/album) return the provider's similar-songs blend. Provider
errors and unknown seeds degrade to seed-only/empty results instead of
404s, reusing the Similar endpoints' bounded-wait singleflight (with a
distinct cache key, since mixes and similar lists answer different
shapes). Sonic-similarity backing stays a follow-up (see README).

* style(jellyfin): trim wordy comments

* refactor(jellyfin): apply cleanup review findings

- batch truncated-id resolution in /Items?ids=: one chunked range query
  for all media-file prefixes instead of a query per id (a restored
  queue sends hundreds of truncated ids)
- resolve truncated ids on the /Similar endpoints too, matching the
  neighboring InstantMix; document which entry points don't resolve
- hoist maxImageUploadSize to core, deleting the byte-identical copies
  in nativeapi and jellyfin (tests moved to core)
- drop the unused type parameter on premiereDate

* fix(jellyfin): never drop the instant mix seed on a slow provider

The seed track was built inside the awaited provider fetch, so when the
external agent was slow or unreachable the request hit the 10s wait and
answered a fully empty mix — Finamp then played nothing on tap, even
though the seed needs no provider at all (seen live: Last.fm unreachable
from the server, responseSize=49).

Build the seed outside the await: only the similar-songs tail is fetched
and bounded, and a timeout now degrades to a seed-only mix. Also folds
instantMixForSong into getInstantMix, since the tail is exactly
similarSongs.

* fix(jellyfin): prefer the recommended Authorization scheme when picking a token

Jellyfin's authorization guidance deprecates X-Emby-Token,
X-MediaBrowser-Token, X-Emby-Authorization and api_key; the Authorization
MediaBrowser scheme is the recommended form. All spellings stay accepted,
but when a client sends several, the recommended one now wins. Adds
coverage for the canonical Authorization header, which no test exercised
directly.

* refactor(jellyfin): rename parseEmbyAuth to parseMediaBrowserAuth

The scheme is named MediaBrowser; the old name evoked the deprecated
X-Emby-* spellings even though the function also parses the recommended
Authorization header.

* fix(jellyfin): prefer the Authorization header over X-Emby-Authorization

The recommended header now wins when both carry MediaBrowser data — but
only when it actually parses as MediaBrowser: a reverse proxy may inject
Basic/Digest credentials into Authorization while the client sends the
deprecated header, and those must not swallow the client's auth.

* fix(jellyfin): require the MediaBrowser scheme when parsing auth headers

The parser extracted key="value" pairs from any Authorization value; a
foreign scheme whose parameters happened to use our field names would
have been misread as client auth. Validate the scheme word instead
(case-insensitively, per HTTP), accepting the legacy "Emby" spelling
like real Jellyfin. Replaces the any-recognized-field heuristic for
detecting proxy-injected Basic/Digest credentials.

* Revert "perf(db): keep query planner statistics trustworthy with full ANALYZE"

This reverts commit 118563e1053196f0e2e42dd4bee54e39a04ab145.
The planner-statistics work now lives in its own PR (#5740); this branch
keeps only the covering-index migration.

* fix(db): renumber jellyfin covering-index migration after master's latest

* fix(db): renumber Jellyfin covering-index migration

* docs(jellyfin): document playlist annotations

* refactor(log): clarify comments on external services query params

* refactor(e2e): move Subsonic e2e suite to server/subsonic/e2e

All files in server/e2e were Subsonic tests, so relocate the package
under server/subsonic/e2e to sit alongside the API it exercises. The
package name stays 'e2e'; only doc/comment references are updated.

* refactor(filter): consolidate genre filters and unexport tagIDFilter

Route filterByGenre, ByGenreID, and ArtistsByGenreID through a single
genreTagFilter helper so the EXISTS json_tree(tags,"$.genre") predicate
lives in one place. With server/filter no longer using it, persistence's
TagIDFilter is only referenced within the package, so unexport it.

---------

Signed-off-by: Deluan <deluan@navidrome.org>
2026-07-14 11:46:37 -04:00