* feat(artwork): add artwork, item_artwork and artwork_queue tables * feat(artwork): add artwork models, repository interfaces and mocks * feat(artwork): implement artwork repository * feat(artwork): implement item_artwork repository with batched hydration * feat(artwork): implement artwork_queue repository * feat(artwork): add content-addressed originals store * feat(artwork): add artwork prune (orphan cleanup) * fix(artwork): never sweep files on transient DB errors during prune * refactor(artwork): fold originals package into core/artwork as ImageStore * refactor(artwork): merge item artwork state into ArtworkRepository * fix(artwork): chunk unbounded IN clauses and restore interface docs * refactor(artwork): apply simplify-pass cleanups Internal item_artwork sqlRepository helper, toSQLArgs upserts, batched queue enqueue, EnqueueStaleAbsent moved to queue repo, snapshot-based prune sweep, mock/real semantics aligned. * fix(artwork): address review findings on prune/sweep races and mock fidelity Sweep now honors an mtime grace window (in-flight acquisitions and temp files), reacquired orphans reset the prune grace window, and the queue mock implements real stale-absent semantics. * fix(artwork): atomic orphan deletion and timestamp semantics from review DeleteOrphans re-checks age+references at delete time, PutItemArtwork defaults attempted_at, queue mock timestamps mirror SQL. * fix(artwork): guard orphan file removal with the prune grace window Duplicate ImageStore writes refresh the file mtime and Remove skips files newer than the cutoff, so overlapping acquisitions cannot lose their store files to a concurrent prune. * fix(artwork): rewrite vanished duplicates and sweep stale mime variants Write falls through to a real write when the liveness touch fails, and sweep retention now matches the recorded mime's extension so obsolete variants are reclaimed. * fix(artwork): index artwork_queue in dequeue order The previous leading retry_at range column forced a temp B-tree sort of the whole eligible set on every DequeueBatch; ordering the index by (priority DESC, enqueued_at) lets scans stop after the batch size. * fix(artwork): honor the orphan cutoff in the repository mock The mock's DeleteOrphans now applies createdBefore like the SQL implementation, and a new spec covers a freshly reacquired row surviving prune. * fix(artwork): reject malformed hashes in ImageStore operations Known-absent states carry an empty hash and malformed persisted hashes could panic path sharding or inject separators; Write/Open/Remove now return an error for anything but 16 lowercase hex chars. * fix(artwork): mock PutImage refreshes created_at like the SQL repository Prune specs now age fixtures directly instead of seeding stale timestamps through the upsert. * fix(artwork): store backing-file provenance per item, not per hash * feat(artwork): import blurhash encoder from #5797 * feat(artwork): add worker-side artwork resolvers * fix(artwork): propagate playlist tile failures and dedupe external step * feat(artwork): add acquisition processor Resolves one queue item end to end: hash/dedup, decode + 128px thumbnail blurhash, place bytes (store vs source file), and persist found/absent/ failed state for the worker (Task 4) to act on. * style(artwork): tighten processor comments to budget * feat(artwork): add acquisition worker service * feat(artwork): enqueue artwork resolution from scan and CRUD paths * feat(artwork): artwork backfill, fingerprint re-resolution and scheduled jobs * test(artwork): leak/soak coverage and deferred assertions * fix(artwork): propagate transient artist image errors to the worker callGetImage swallowed all agent errors, so an agent outage surfaced as ErrNotFound and the worker settled artist artwork as a definitive absent (and reset the breaker). Add an additive ArtistImageResult path that returns the underlying agent error on transient failure while keeping ArtistImage byte-identical for existing callers; the worker's artist external step uses it via fromArtistExternalResult. * fix(artwork): resolve full playlist source chain resolvePlaylist only built the generated grid, dropping the uploaded-image, sidecar and ExternalImageURL sources the old reader_playlist.go chain serves. Port the full chain before the grid fallback: uploaded (upload), sidecar (folder), and ExternalImageURL routed through extGate with the same extError semantics as the other external steps. Also rewires the artist external step onto ArtistImageResult. * fix(artwork): purge dangling queue rows and guard concurrent re-enqueues Queue rows for deleted entities failed forever (Get -> ErrNotFound -> failed -> capped retries, unbounded). Add ArtworkQueueRepository.PurgeDangling, called from Prune next to the item_artwork purge. Separately, the found/absent path unconditionally deleted the dequeued row, erasing a concurrent scan re-enqueue; switch to DeleteIfUnchanged, which deletes only while retry_at still matches the dequeued value (verified retry_at is the column an Enqueue upsert resets). * style(artwork): fix comment accuracy and budget; fingerprint ArtistImageFolder Correct the inverted workerDeps.extGate comment, trim over-budget doc comments, and add conf.Server.ArtistImageFolder to the resolution fingerprint so an image-folder change re-resolves artist artwork. * fix(artwork): treat missing local playlist cover as definitive, not transient A playlist ExternalImageURL pointing at a local file that fails to open was routed through extError, causing failed/48h-retry loops that burn a rate limiter token forever instead of falling through to the generated grid. * refactor(artwork): deduplicate purge loop, backfill table, and extGate alias * fix(artwork): cap resolved image reads A user-editable ExternalImageURL can point at an arbitrarily large endpoint; a fast server could make the worker buffer hundreds of MB inside the 5s HTTP timeout. Bound the read to a fixed 20MB cap (no config knob) via io.LimitReader and fail the item if it is exceeded. * fix(artwork): retry higher-priority external art after fallback hit With CoverArtPriority="external,cover.jpg", a transient external failure followed by a folder hit dropped the external error: the worker recorded found and deleted the queue row, so the configured higher-priority external art was never retried. Carry extError onto the fallback resolution and add an outcomeFoundStale that persists+serves the art but reschedules via MarkFailed, giving the external source another chance. When external later answers definitively-not-found, the hit is not stale and the row is deleted. * fix(artwork): treat playlist cover URL 404 as definitive miss The playlist ExternalImageURL step used sources.go's fromURL, which maps any non-200 to a generic error, so a stale URL returning 404/410 was classified transient: infinite backoff plus it counted toward the circuit breaker, blocking valid external work. Add a local fetch in resolve.go that maps 404/410 to model.ErrNotFound (definitive) while keeping other non-200s transient. sources.go is left untouched. * test(artwork): move soak test into the Ginkgo suite * test(artwork): make leak and permission tests pass on linux goleak now ignores notify's nonrecursive-tree goroutines (linux uses inotify, which spawns dispatch+internal instead of darwin's recursive dispatch), and the read-only-dir prune spec skips under root, where permission bits cannot make Remove fail. * fix(artwork): reject decompression-bomb dimensions before decoding * fix(artwork): keep fresh re-enqueues ahead of stale failure backoff * fix(artwork): include M3U external art flag in the config fingerprint * fix(artwork): resolve private playlists with an admin context * test(artwork): convert non-synctest timing tests to Ginkgo specs TestArtworkBackoffSchedule and TestArtworkWorkerRunNoLeak needed no real *testing.T (no synctest), so move them into worker_test.go as Ginkgo specs. TestArtworkBreakerHalfOpen stays plain since testing/synctest requires a real *testing.T, matching core/scrobbler's precedent. * fix(artwork): store backing-file provenance per item, not per hash * fix(artwork): apply image limits to playlist tile decoding decodeTile ran image.Decode on every sampled album's resolved bytes before processItem's maxImageBytes/maxImagePixels guards applied, letting an oversized or decompression-bomb tile fully decode unbounded. Enforce both caps inside decodeTile itself. * refactor(artwork): reuse auth.WithAdminUser and dedupe image cap guards * perf(artwork): fetch only IDs for backfill enumeration Backfill enumerated every album, artist, playlist and radio via GetAll and mapped out just the ID. GetAll materializes full entities (library joins, participant/stats/tags JSON, annotation, artwork hydration), so on a large library it loaded tens of thousands of heavy structs only to read one field each — spiking transient RSS to ~1GB during the one-time upgrade backfill, a memory risk on small NAS/Pi hardware. Add GetAllIDs to the album, artist, playlist and radio repositories: it reuses each repo's base row-set filter (library visibility, artist content join, playlist userFilter) but projects only id, skipping the heavy columns and post-processing. A per-repo parity test asserts GetAllIDs returns exactly the same id set as GetAll. Verified on a 727MB / 29k-artist production DB copy: peak RSS during backfill dropped from ~1012MB to ~89MB, file descriptors flat, same 36,138 items enqueued. * feat(artwork): promote worker concurrency and external rate to real configs The artwork worker's drain speed was governed by two hidden Dev flags, DevArtworkWorkerConcurrency and DevArtworkExternalRPS, both defaulting to 2. On a large library's one-time backfill the external rate limiter is the real ceiling: every art-less item waits on it before the (rate-limited) external lookup, so the drain crawls at ~RPS items/sec while local-art items are unaffected. Promote both to documented, supported options: ArtworkWorkerConcurrency (default 4) sets local-resolution parallelism, ArtworkExternalMaxRPS (default 2, 0 = unlimited) caps external-agent lookups to stay polite to Last.fm/Deezer/etc. Operators can now trade first-backfill speed against external-API rate limits. The old Dev names still map for backward compat. * fix(deezer): never return empty-image-id placeholder pictures * feat(agents): enumerate enabled image-retriever agents per capability * feat(artwork): worker fetches agent images directly with per-agent rate limits and breakers * fix(artwork): treat agent not-found as breaker success * feat(model): content-hash artwork id suffix and hydratable per-entity image state * feat(persistence): hydrate artwork hash and absence onto entity pages * feat(artwork): resolve media_file embedded art in the worker, invalidate on rescan * feat(artwork): broadcast refresh events when artwork lands * fix(artwork): broadcast refresh for stale-found artwork too * feat(artwork): state-backed serving path with provisional read-through * feat(server): serve artwork from persisted state with content-hash caching * feat(subsonic): content-hash coverArt ids, omit artwork on known-absent * refactor(artwork): delete the legacy reader chain, cache warmer, and provider image methods * feat(artwork): precache on acquisition, bump on upload/radio changes, manual re-resolve API * test(artwork): end-to-end coverage for the serving cutover * chore(artwork): generic 500 bodies on refresh endpoint, trim stale test comments * fix(artwork): request read-through must not reset the failure backoff The provisional read-through and dangling re-enqueue used Enqueue, whose upsert resets retry_at, so any browse of an unresolved entity that was backing off after an external failure made it immediately eligible again — defeating the exponential backoff during a provider outage. Add EnqueueBump, which raises priority but leaves an existing row's retry_at intact, and route the serving path through it. Scan and manual re-resolve keep Enqueue's reset (a detected change wants immediate retry). * fix(artwork): keep an eligible track's cover requestable when its album is absent An embedded-eligible track with no resolved item_artwork row inherited the album's ImageAbsent, so when the album resolved absent (e.g. CoverArtPriority without 'embedded') the track's coverArt was omitted permanently — the client never requested it, so the lazy mediafile path never resolved it — even though the serving path would extract and serve the track's own embedded art. Hydration now never copies the album's absence onto an eligible-but-unresolved track. * fix(artwork): validate each agent image URL before picking the largest bestImageURL selected the largest by size and only then parsed it, so a malformed largest URL (e.g. a bad percent-escape) returned nil and shadowed a valid smaller candidate, contradicting the documented skip-unparseable behavior. Parse per candidate and compare sizes only among URLs that parse. * fix(artwork): fall back to disc art, not the album, for multi-disc tracks serveMediaFile delegated an absent/ineligible track straight to AlbumCoverArtID, skipping the disc-specific lookup that MediaFile.CoverArtID (and the deleted legacy reader) use. On multi-disc albums with per-disc images that served the album cover instead of the configured disc artwork. Delegate through DiscCoverArtID. * fix(artwork): enqueue uploaded artwork only after the filename is persisted SetImage cleared state and enqueued the bump before the caller stored the new filename, so a worker drain in that window could resolve against the old (already deleted) file and settle absent, leaving the upload unused until a later scan. Move the invalidate+enqueue into EnqueueArtwork, which each caller now invokes after the entity Put. * fix(artwork): keep multi-disc tracks requestable when the album is absent Round-1's hydration fix still copied the album's known-absent onto a non-eligible (or own-absent) track, but MediaFile.CoverArtID routes a multi-disc track to disc art, which resolves provisionally and is never known-absent. Marking it absent made Subsonic omit coverArt so clients never requested a valid disc image. Only mark a single-disc track absent, and only when its own art won't resolve. * fix(artwork): serve a local playlist ExternalImageURL as a file-backed reference A local ExternalImageURL was resolved through the external step and labelled external, so placeBytes copied it into the content-addressed store and dropped its path/mtime — replacing the file never tripped the staleness check. Classify local references as file-backed (resolved in place, even on the request path) and keep store-backed behaviour only for http(s) URLs. * fix(artwork): requeue playlist cover when its track set changes A generated-grid cover went stale after track mutations: nothing re-resolved the playlist's artwork, and the request path deliberately never rebuilds the grid, so serveEntity kept returning the old grid hash indefinitely. Enqueue pl artwork from refreshCounters (the choke point for every track-set change); no clear, so the old cover keeps serving until the worker rebuilds. * fix(artwork): open library-backed artwork through its on-disk root A library configured with a file:// path stored absRoot as the raw URI, so Abs produced strings like file:/music/cover.jpg that os.Open/os.Stat reject — folder, upload and embedded art were treated as dangling on every request, looping forever. Normalize a file:// path to its parsed OS path (the same root os.DirFS uses); non-local schemes are left unchanged (out of scope, per the artwork-musicfs TODO). * fix(artwork): only use disc resolution for multi-disc albums DiscCoverArtID returns a dc- id for any track with DiscNumber>0, so serveDisc ran the full DiscArtPriority chain even for single-disc albums, where a stray disc*/ embedded image could shadow higher-priority album art. Gate disc resolution on the album having more than one disc, matching the legacy reader; single-disc tracks serve album art directly. * fix(artwork): invalidate artwork when an uploaded image is deleted Deleting an artist/radio/playlist upload cleared the filename but left the found item_artwork row and its hash, so lists kept advertising the deleted cover's hash-suffixed immutable URL and clients could display it indefinitely. Call EnqueueArtwork after the delete-side Put, symmetric with upload, so the state is cleared and re-resolved to the next source (or absent). * fix(artwork): restore synthetic-artist guard and unicode normalization in agent lookups Moving agent calls into the worker bypassed two behaviors of the aggregate provider: Agents.GetArtistImages' guard for Unknown/Various Artists (a direct retriever call could assign an unrelated image to a synthetic artist), and auxAlbum/auxArtist.Name's DevPreserveUnicodeInExternalCalls normalization (records with typographic quotes/dashes missed exact-name searches). Re-apply both before enumerating retrievers. * fix(artwork): enforce entity visibility on the Subsonic getCoverArt path serveEntity reads persisted item_artwork by id, bypassing the library and private- playlist filters that the legacy entity-load applied. On the authenticated Subsonic path a user could fetch artwork for an inaccessible album or someone else's private playlist by guessing an id. getCoverArt now resolves the underlying entity through the request-scoped (filtered) repositories and serves the placeholder when it is not visible, so existence isn't leaked and the always-an-image invariant holds. The public share (JWT-authorized) and Jellyfin (admin) paths are intentionally untouched. * fix(artwork): version the artwork ETag with the served representation The ETag was the pixel hash of the original image, so a CoverArtQuality or EnableWebPEncoding change altered the resized bytes without changing the ETag — revalidating clients got a spurious 304 and kept the old encoding. Resized responses now carry a representation ETag (hash + size + square + encode settings) used for the ETag header and If-None-Match, while the immutable decision stays on the pixel hash (URLs remain pixel-identity per the spec, so hash-suffixed clients keep zero-request caching). Full-size originals fall back to the pixel hash as before. * fix(artwork): don't stamp the album hash onto multi-disc tracks The hydration fallback assigned a found album hash to every fallback track, but a multi-disc track's CoverArtID emits a dc- id served from disc-specific art whose hash is unknown at hydration time. Advertising dc-..._<albumHash> gave clients a content- version that never changes when the disc image does, breaking id-based refresh. Only stamp the album hash for single-disc tracks (DiscNumber == 0); multi-disc tracks stay unhashed and rely on the correct ETag returned by the served response. * fix(artwork): enqueue new empty playlists by id, and refresh on absent outcomes Two worker/enqueue fixes from review: - playlistRepository.Put assigned the generated id to the caller's Playlist but passed the stale copy (empty id) to refreshCounters, enqueueing a pl|"" row the worker failed until the daily dangling purge while the real playlist went unresolved. Set the id on the copy before enqueueing. - The drain refresh batch only included found/foundStale, so a cover removed by a scan (found -> absent) never notified clients, leaving the old immutable image displayed. Broadcast absent outcomes too; precache still only warms found/foundStale. * fix(artwork): honor disabled per-track art at serve time; use nanosecond mtime provenance Two serving-correctness fixes from review: - serveMediaFile served a persisted mf embedded image even after EnableMediaFileCoverArt was turned off (the setting isn't in the config fingerprint, so found rows aren't reprocessed). Direct mf- URLs now honor the setting at serve time and fall back to disc/album art. - The file-backed staleness check compared whole-second mtimes, so a same-second content replacement (two writes in one second, or timestamp-preserving tools) could serve different bytes under the old hash + immutable policy. RefMtime is now unix-nanoseconds (no schema change; int64 column), detecting sub-second changes where the filesystem records them. * fix(artwork): preserve the drive when normalizing Windows file:// library paths url.Parse puts the volume of file://C:/Music in Host, not Path, so localOSRoot dropped it and returned /Music — os.Open/os.Stat then failed and folder/embedded art on Windows looped as dangling. Rejoin the host volume, matching core/storage/local's newLocalStorage. * fix(artwork): clamp negative sizes to full-size; convert imghttp test to Ginkgo - A negative size (Subsonic size / Jellyfin maxwidth accept signed ints) reached resizeStaticImage, where the square path builds image.NewNRGBA(Rect(0,0,size,size)) — a giant rectangle that panics/OOMs. Clamp size<0 to 0 (full-size) at the Service entry. Positive sizes were already clamped to the original. - imghttp used a plain func Test with a table; convert to a Ginkgo DescribeTable with the suite entry point in imghttp_suite_test.go (AGENTS.md test-framework requirement). * test(artwork): use renamed ArtworkWorkerConcurrency in e2e tests * feat(artwork): carry blurhash through item image hydration * feat(nativeapi): expose artwork hash, absence and blurhash * feat(artwork): hydrate the parent album's artwork state onto tracks * test(artwork): add hydrateArtwork regression guard for AlbumImage wiring Drives hydrateArtwork itself (not applyItemImage directly) over tracks that take each of the loop's continue branches, so a future edit moving the AlbumImage fill below a continue would fail loudly instead of passing silently. * feat(jellyfin): version album and artist image tags by content hash * fix(jellyfin): trim primaryImageTag comment to why-only, within budget * feat(jellyfin): emit real blurhashes and drop the synthesized fallback * feat(ui): version cover art urls by content hash and skip absent art * feat(ui): add BlurHashCanvas placeholder component * fix(ui): clear stale blurhash pixels and assert the draw path in tests Clear the canvas before each decode attempt so a hash change that fails to decode doesn't leave the previous frame's pixels on screen once this wires into a list that recycles items. Also strengthen the specs to assert createImageData/putImageData were actually invoked (and with what), instead of only checking that a <canvas> element exists. * feat(ui): show the blurhash while an album cover loads * fix(artwork): hydrate cursor streams via an id pre-pass The album, artist and playlist GetCursor built their own select and never called hydrateArtwork, so every Jellyfin list endpoint (all six stream via GetCursor) emitted entity-id image tags and no blurhash. Only GetAll hydrated, which is why Subsonic and the native API were unaffected. Each cursor now resolves its ordered/filtered/paginated id set with the cheap id-only GetAllIDs query, then streams those ids in chunks through the repo's existing GetAll, which already hydrates and applies the full select. Max/Offset are consumed by the pre-pass alone; the chunk query carries only the caller's filters, Sort and Order. This also removes a pre-existing deep-pagination cost: keeping OFFSET out of the joined query makes the pre-pass a covering index scan instead of paying the library and annotation joins for every skipped row. Benchmarked on a synthetic 100k-album DB with the real schema, page=500 at offset 90,000: 3.9ms via the id pre-pass, 52.5ms for the current shape, 192.7ms for a naive join. An unpaginated full stream costs ~24% more, which is the trade. GetAllIDs gains the annotation join whenever the caller's filters or sort reference an annotation column (same gate CountAll uses), otherwise Filters=IsFavorite and SortBy=PlayCount would fail in the pre-pass. The playlist pre-pass repeats GetAll's columns so ORDER BY keeps resolving to playlist.name rather than the joined user.name. * fix(jellyfin): hydrate artwork on the song cursor Jellyfin's listSongs streamed media files via GetCursor, which never hydrates artwork, so songs emitted entity-id image tags and no blurhash. media_file now uses the same id pre-pass as the other three cursors (album/artist/playlist), for consistency, but on a separate method, GetCursorWithArtwork: GetCursor itself must stay untouched, since it's also the scanner's hot path and the scanner never reads artwork. Measured on 1,000,000 tracks, the pre-pass over all ids costs +41.8 MB heap and +298 ms versus GetCursor's bounded +0.0 MB. The Jellyfin path is paginated, though, so in practice it only ever pre-passes a page's worth of ids, not the full library, and doesn't pay that cost. * feat(jellyfin): emit a song's own cover art when it differs from the album's Real Jellyfin fills ImageTags from each item's own images before falling back to the parent album, and Finamp checks imageTags.Primary before AlbumId. Our mapper read only the album's image, so a track with distinct embedded art silently showed the album cover. Emit exactly one entry under ImageBlurHashes.Primary: Go marshals map[string]string in sorted key order rather than insertion order, so a second entry could pair the wrong blurhash with the image imageId resolves to, and Finamp pins that pairing in its cache for 365 days. * test(persistence): scope the GetCursorWithArtwork full-stream spec to tie-free ids The fixture has title ties (e.g. three "Antenna" tracks), so the unscoped positional comparison against GetAll only passed because SQLite's tie order happened to coincide between the full scan and the pre-pass's id IN (...) fetch. Scope it to onlySongs like the sibling ordering specs already do. * refactor(artwork): route song own-art through primaryImageTag; align chunk size Cleanups surfaced by /simplify: the song mapper's own-art branch reimplemented primaryImageTag's tag+blurhash-map construction (and its one-entry invariant) — route it through the helper so that invariant lives in one place. Tie artworkChunkSize to a whole multiple of artworkBatchSize so a cursor page re-chunks into even hydration batches. Hoist a duplicated imageLoading && blurHash boolean in the album grid. * fix(ui): serve the placeholder for known-absent art instead of a broken icon getCoverArtUrl returned '' for an imageAbsent record, so <img src={undefined}> rendered as the browser's broken-image icon on every absent cover. The server already serves a proper placeholder for absent art, so build the url and let it render. * feat(ui): show the blurhash as the loading placeholder across cover surfaces Add a shared CoverImage component (useImageUrl blob cache + blurhash + fade) and render the blurhash while a cover loads on the list thumbnails (CoverArtAvatar, radio) and the artist/album/playlist detail pages. The detail pages now go through CoverImage instead of a plain CardMedia, so their images come from the in-memory blob cache and survive React remounts without re-fetching. BlurHashCanvas gains an optional style prop. * refactor(ui): unify list cover surfaces onto the shared CoverImage component Route the album grid, CoverArtAvatar (artist/playlist lists) and the radio list's cover field through CoverImage instead of each carrying its own useImageUrl + blurhash-overlay wiring. CoverImage gains a default object-fit: cover. Radio keeps its uploaded-image gate and the generic radio placeholder for stations with no art. * fix(ui): address CoverImage review findings Restructure CoverImage so the size/shape lives on the root and the blurhash + image are absolute fills: the <img> mounts only once its blob is ready, so an unresolved cover never flashes a broken <img>. Add a fit prop (default cover) so album/playlist detail keep their letterbox instead of being cropped by a hardcoded object-fit. Remove the orphaned coverLoading styles and an unused subsonic import; add a CoverImage unit test. * perf(ui): only refetch already-loaded records on SSE refresh The artwork worker broadcasts a RefreshResource event per resolved chunk, carrying every id in the chunk. useResourceRefresh was doing a getMany for all of them, so any open list/detail page fetched hundreds of artists it was not displaying. Filter the event ids to records already in the store; the rest load fresh (with their new artwork) when navigated to. * refactor(artwork): scale worker concurrency with CPU count ArtworkWorkerConcurrency now defaults to max(2, NumCPU()/2) instead of a fixed 4, mirroring MaxOpenConns: local resolution scales with the host but stays at half the SQLite pool so it never starves the scanner/UI. External RPS stays a fixed 2 — it gates third-party API calls and is bounded by their tolerance, not the host, so it must not scale with CPUs. Also drop the DevArtworkWorkerConcurrency/DevArtworkExternalRPS deprecated aliases: those names were never released, so there is nothing to migrate. * feat(artwork): re-queue an absent cover when its page is viewed serveEntity now schedules a Bump recheck for an entity whose art was recorded absent, so viewing a missing cover re-triggers resolution (e.g. after an external source that was down during the scan comes back), matching the request-time bump that already covers never-resolved entities. Throttled by attempted_at against requestRecheckAge (1h) so repeatedly opening a genuinely-absent page can't hammer external services. EnqueueBump preserves an existing failed-state backoff via MAX(priority,...) and inserts a fresh, immediately-eligible recheck for a settled-absent row (whose queue row was already deleted). * refactor(artwork): move ImageUploadService to artwork.Uploader Relocate the image-upload service from core to core/artwork as artwork.Uploader, co-locating it with the resolver/worker/serving that own the artwork state it invalidates. MaxImageUploadSize moves too — its only callers are the two image-upload handlers — which lets core/image_upload.go be deleted entirely. Extract the shared "clear resolved state + re-queue at Bump" invalidation into artwork.Refresh and fold nativeapi's refreshArtwork handler onto it, removing the duplicated DeleteForItem+Enqueue block that had drifted into three places. The wire provider moves from core's set to artwork's; the playlists.ImageUploadService binding moves to the top-level injector so core/artwork stays unaware of playlists. Behavior is unchanged. * refactor(artwork): thread model.Kind through the artwork API Entity-level artwork queries now take a typed model.Kind instead of a bare prefix string. GetItemArtwork, DeleteForItem(s), GetInfoForItems, EnqueueStaleAbsent, hydrateItemImages, enqueueBackfillKind and artwork.Refresh convert to the prefix string only at the two real boundaries: the SQL item_kind column (kind.Prefix() inside each repo) and external string inputs (a new model.ParseKind for the nativeapi URL param, which also validates it). The Backfill/stale-absent kind slices, the resolve.go dispatch switch, and the kind→resource / kind→table lookup maps now use the Kind vars directly. The queue lifecycle methods (MarkFailed/Delete*) keep string kinds — they operate on a dequeued item's raw ItemKind column, which stays a string field, always populated via kind.Prefix(). Removes every bare "al"/"ar"/… prefix literal from non-test code (27 -> 0); behavior is unchanged. * tune(artwork): drop backoff base from 5m to 15s The exponential retry (base × 4^attempts, cap 48h) started at 5 minutes, so a single transient failure — a timeout under load, an external blip — parked a cover for 5 minutes even though a retry seconds later would have resolved it. Start at 15s instead: transient failures recover almost immediately (15s → 1m → 4m → 16m …), while persistent failures still escalate to the 48h cap (now at the 8th attempt instead of the 5th). * tune(artwork): 5s backoff base + 12h give-up, drop the cap Retry backoff now starts at 5s (was 15s) so a transient failure recovers on essentially the next drain, and jitter widens to ±40% so a wave of correlated failures doesn't re-clump into one poll. Add a 12h give-up budget measured from enqueued_at: once the next backoff would land past it, the worker stops retrying instead of grinding at a cap forever. A bare failure settles absent (handed to the 24h stale-absent sweep, and still recoverable on a page view); a found-stale keeps its already-served art. The budget bounds the tail, so the separate 48h backoffCap is removed. * fix(lastfm): match album.getInfo on name+artist only, not MBID Last.fm's album.getInfo by MBID is unreliable: a correct MBID can return a different album, or none. Observed with black midi's "7-eleven" (whose correct MBID returned a FLEETWOOD release) and both missing The Chats albums (one MBID 404s, the other resolves to a different self-titled release). The worker then recorded covers absent — or would fetch the wrong art — even though the correct cover is on Last.fm by name+artist. Stop passing the MBID to album.getInfo; query by name+artist only, which also drops the now-dead error-6 MBID-retry fallback. The low-level client keeps its MBID support for other callers; only the album lookup changes. * fix(lastfm): return agents.ErrNotFound on error 6 (not found) Last.fm returns error 6 for a missing artist/album — a definitive negative — but the agent returned the raw *lastFMError, so the artwork worker treated every not-found as a real fault: it counted toward the per-source circuit breaker (5 in a row opens it, fast-failing all Last.fm calls including valid ones) and was retried as a transient error instead of settling absent. On a first scan of a library with many artists Last.fm lacks, this stalled valid cover lookups and left entities churning in backoff. Translate error 6 to the shared agents.ErrNotFound at the agent boundary (callAlbumGetInfo / callArtistGetInfo), matching how the Deezer agent maps its client's not-found, and log it at Debug instead of Error — which also removes the not-found log spam. * feat(artwork): log external image-lookup failures at debug The worker's res.reader==nil && extError branch returned outcomeFailed with no log, so a failing external cover lookup (agent error, dead image URL, download timeout) was undiagnosable. Log the agent, entity, and underlying error at the fetch site where it's in hand — this surfaced a Last.fm album.getInfo returning an image URL that itself 404s. * fix(artwork): treat a 404/410 image URL as not-found, not a transient fault An agent (notably Last.fm's album.getInfo) can advertise a cover URL that is itself dead — a 404. sources.go's fromURL returned a generic error for any non-200, so a dead URL was treated as a transient failure: it churned in backoff and counted toward the circuit breaker, stalling valid lookups. Map 404/410 to model.ErrNotFound in fromURL so a dead URL settles absent, and collapse the near-identical fetchPlaylistImageURL (which already did this for M3U covers) into it. * feat(artwork): make artwork re-resolution targeted, not blunt Two gaps in when the pipeline re-resolves artwork: The recheck job only requeued absent-state rows (hash=''), so an entity that was never processed — added between scans, or on a server with the scanner disabled — had no periodic safety net and stayed without artwork indefinitely. Add EnqueueMissing(kind): a SQL set-difference enqueueing entities with no item_artwork row at Recheck priority (ON CONFLICT DO NOTHING, so it never disturbs a queued row). Run it once at startup and hourly alongside the stale-absent recheck. Rename staleAbsentKinds -> recheckKinds accordingly. Conversely, the config fingerprint included consts.Version, which embeds the git SHA and so changed on every build, re-enqueueing every entity in the library (~34k here) and re-querying external agents at the configured RPS for anything without local art. Replace it with an explicit artworkEpoch constant, bumped deliberately when resolution semantics change. The cases that motivated the version input — absent art becoming available — are already covered by the stale-absent and missing-row rechecks; only a corrected wrong-pick needs the epoch. A test guards against reintroducing the version. * test(artwork): restore resolution edge-case e2e coverage The serving cutover removed the album/disc/artist/mediafile/playlist/radio e2e specs that documented the folder-selection rules and guarded the #5376/#5456/ #5451/#5457 regressions; nothing replaced them, so compareImageFiles and the parent-fallback logic were left untested. Restore them driving the real pipeline: a real scanner populates the folder graph from an in-memory library, the real Worker drains the queue, and the real Service serves. Folder-backed art is file-backed (served via os.Open, which the in-memory FS can't satisfy) so its selection is asserted on the persisted state row; store-backed and real-disk sources are asserted byte-for-byte. Single-disc disc resolution now serves album art directly, so only multi-disc disc scenarios are ported. * fix(artwork): run disc resolution for single-disc albums too |
||
|---|---|---|
| .. | ||
| dto | ||
| e2e | ||
| annotations.go | ||
| annotations_test.go | ||
| api.go | ||
| api_test.go | ||
| audiomuse.go | ||
| audiomuse_test.go | ||
| auth.go | ||
| auth_test.go | ||
| browsing.go | ||
| browsing_test.go | ||
| images.go | ||
| images_test.go | ||
| items.go | ||
| items_test.go | ||
| jellyfin_suite_test.go | ||
| library.go | ||
| lyrics.go | ||
| lyrics_test.go | ||
| middlewares.go | ||
| middlewares_test.go | ||
| playlists.go | ||
| playlists_test.go | ||
| README.md | ||
| response.go | ||
| response_test.go | ||
| routing_test.go | ||
| sessions.go | ||
| sessions_test.go | ||
| similar.go | ||
| similar_test.go | ||
| socket.go | ||
| socket_test.go | ||
| stream.go | ||
| stream_test.go | ||
| system.go | ||
| system_test.go | ||
| truncated_ids.go | ||
| users.go | ||
| users_test.go | ||
Jellyfin API
This package implements a subset of the Jellyfin REST API on top of Navidrome's existing library, users, playlists and scrobbling infrastructure. It lets Jellyfin-compatible clients (e.g. Finamp, jftui) browse and stream a Navidrome library without requiring a real Jellyfin server.
It is not a full Jellyfin server implementation: only the endpoints needed to browse a music library, stream audio, manage favorites/ratings for songs, albums, artists, and playlists, report playback, and manage playlists are implemented. Video, live TV, plugins, and Jellyfin's admin/dashboard APIs are out of scope.
Enabling
The Jellyfin API is disabled by default. Enable it via navidrome.toml:
[Jellyfin]
Enabled = true
# Optional: override the server name reported to clients (defaults to "Navidrome <version>")
ServerName = "My Music Server"
# Optional: usernames to show in the client login user-picker (default: none). See "Public user list".
ExposedPublicUsers = "alice, bob"
# Optional: max collection responses streaming at once (default: half the DB connection pool,
# min 2). Each streaming response holds a DB connection for its whole duration; excess requests
# queue rather than fail.
MaxConcurrentStreams = 4
or via environment variables:
ND_JELLYFIN_ENABLED=true
ND_JELLYFIN_SERVERNAME="My Music Server"
ND_JELLYFIN_EXPOSEDPUBLICUSERS="alice,bob"
Once enabled, the API is mounted at:
http://<host>:<port>/jellyfin
All the paths below are relative to that base URL (e.g. System/Info/Public means
http://localhost:4533/jellyfin/System/Info/Public). Routes are matched case-insensitively,
since real Jellyfin clients (and jellyfin-apiclient-python) send mixed-case paths.
Authentication
Jellyfin clients authenticate with POST /Users/AuthenticateByName using the user's Navidrome
username/password, and get back an AccessToken (a Navidrome JWT). That token is then sent on
every subsequent request as the X-Emby-Token header (or embedded in the
X-Emby-Authorization/Authorization header's Token="..." field, or as an api_key/ApiKey
query param — all forms are accepted, matching what different clients do).
POST /Users/AuthenticateByName is rate-limited per IP with the same limiter as the native
/auth/login (AuthRequestLimit/AuthWindowLength), since it's an unauthenticated brute-force
surface.
Public user list (login picker)
GET /Users/Public lets a client render a login user-picker (tap a user, then just type the
password) instead of a blank username field. It's unauthenticated, so by default it exposes
no users. Set Jellyfin.ExposedPublicUsers to a comma-separated list of usernames to advertise:
[Jellyfin]
ExposedPublicUsers = "alice, bob"
Only the named users are listed (never the full user table), resolved live per request; a configured
name that doesn't exist is skipped and logged at Warn. Each entry is a minimal DTO (Name, Id)
with no Policy/Configuration, so admin status isn't leaked to unauthenticated callers, and no
avatar (PrimaryImageTag omitted — Navidrome has no per-user profile images).
Players and sessions
Every authenticated request registers (or refreshes) the calling device as a Navidrome player,
mirroring Subsonic's getPlayer — so a Jellyfin client shows up in the players list (and scrobbling
has a player) as soon as it makes any authenticated call, not only when it reports playback. The
player id is the device id from X-Emby-Authorization (DeviceId="..."); the player name is
Client [Device]. Those field values are URL-decoded, since some clients percent-encode them
(Jellify sends Device="Pixel%208%20Pro", Finamp sends it raw). A request that carries no
client/device info (e.g. the GET socket handshake, which authenticates via ?api_key= only) is
skipped, so it doesn't create a nameless player.
ID encoding
Navidrome item ids are hex-encoded at the API boundary (dto.EncodeID/DecodeID): every id
is hex-encoded on the way out and hex-decoded on the way in. This is required because some clients
parse ids as radix-16 — Finamp's queue packIds, for instance, does int.parse(chunk, radix:16),
which chokes on Navidrome's base62 ids (e.g. 5QFKvMsJrd57QE2Le2dKKo). Because a base62 id can
itself be valid hex, correctness depends on every emit path encoding and every receive path
decoding — see dto/ids.go.
Multi-library behavior
Jellyfin has no native concept of multiple music libraries the way Navidrome does, so each
Navidrome library the current user can access is exposed as its own top-level Jellyfin
"CollectionFolder" view (GET /UserViews), instead of merging every library into a single view.
Browsing (/Items), artists, and the "Latest" list are all scoped to the libraries the
authenticated user has access to; a library (or item within it) the user cannot access returns
404, never 403, so ids can't be used as an existence oracle.
Browsing filters
GET /Items accepts the filter params clients use to build screens: ParentId (a library view id
for scoping, an artist id when browsing into an artist's albums, or an album id when browsing into
an album's tracks); AlbumArtistIds/ArtistIds/contributingArtistIds (an artist's albums or
tracks — Finamp's artist screen sends these alongside ParentId=<libraryId>); AlbumIds (an
album's tracks — Feishin fetches them this way instead of ParentId); GenreIds (a
genre's albums or tracks — Finamp's genre screen sends it the same way; /Artists/AlbumArtists
and MusicArtist queries accept it too, matching artists credited on an album of that genre);
SearchTerm;
favorites-only (Filters=IsFavorite or the standalone isFavorite=true); SortBy/SortOrder;
StartIndex/Limit; and Ids (batch fetch by id). Recursive=false with a library ParentId
returns direct children only (no tracks — no track is a library's direct child).
Implemented endpoints
| Area | Endpoints |
|---|---|
| Handshake / system | GET System/Info/Public, GET System/Info (authenticated), GET/POST System/Ping, GET QuickConnect/Enabled |
| Auth | POST Users/AuthenticateByName, GET Users/Public |
| Users | GET UserViews, GET Users/{userId}/Views, GET Users/Me, GET Users/{userId} |
| Browsing | GET Items, GET Users/{userId}/Items, GET Items/{itemId}, GET Users/{userId}/Items/{itemId}, GET Users/{userId}/Items/Latest, DELETE Items/{itemId} (playlists only) |
| Artists / genres | GET Artists, GET Artists/AlbumArtists, GET Genres, GET MusicGenres |
| Similar / mixes | GET Artists/{itemId}/Similar, GET Items/{itemId}/Similar, GET Items/{itemId}/InstantMix |
| Images | GET Items/{itemId}/Images/{type}[/{index}] (public), POST/DELETE Items/{itemId}/Images/{type} (playlist cover, authenticated) |
| Favorites / ratings for songs, albums, artists, and playlists | POST/DELETE UserFavoriteItems/{itemId}, POST/DELETE Users/{userId}/FavoriteItems/{itemId}, POST/DELETE Users/{userId}/Items/{itemId}/Rating, GET UserItems/{itemId}/UserData, GET Users/{userId}/Items/{itemId}/UserData |
| Streaming | GET Audio/{itemId}/stream[.{container}], GET Audio/{itemId}/universal, GET Audio/{itemId}/main.m3u8, GET Items/{itemId}/File, GET Items/{itemId}/Download, GET/POST Items/{itemId}/PlaybackInfo |
| Lyrics | GET Audio/{itemId}/Lyrics |
| Playback reporting | POST Sessions/Playing, POST Sessions/Playing/Progress, POST Sessions/Playing/Stopped, POST Sessions/Capabilities[/Full] |
| Playlists | POST Playlists, GET Playlists/{playlistId}, POST Playlists/{playlistId} (rename / visibility / replace tracks), GET Playlists/{playlistId}/Items, POST/DELETE Playlists/{playlistId}/Items, GET Playlists/{playlistId}/Users[/{userId}] |
| Real-time | GET socket (WebSocket; keeps clients like Finamp from 404-loop-reconnecting) |
| AudioMuse-AI (see below) | GET AudioMuseAI/info, GET AudioMuseAI/health, GET AudioMuseAI/similar_tracks, GET AudioMuseAI/find_path |
Any other path returns a 404 with a {} JSON body, and is logged server-side at Debug level
as Jellyfin API: unhandled route (method + path). If a client you're testing needs an endpoint
that isn't in the table above, check the server logs for these lines to see exactly what it's
requesting.
Playlist management
Playlists are the main writable surface of this API:
- Container expansion. When creating (
POST Playlists), adding to (POST Playlists/{id}/Items) or replacing (POST Playlists/{id}) a playlist, theIdsmay contain containers — album, artist or playlist ids — not just song ids. Each is expanded into its tracks (in order) before the write, matching how Jellyfin clients populate these lists. A bare song id passes through. - Id list encoding.
POST/DELETE Playlists/{id}/Itemsaccept the id list both ways clients spell it: repeated params (ids=X&ids=Y, how Jellify's@jellyfin/sdkserializes arrays) and a single comma-separated value (ids=X,Y, Finamp). Reading only the first value would add just one track of an expanded album. - Update (
POST Playlists/{id}): withIdspresent, the track list is replaced (Finamp uses this for reordering) — an explicit emptyIds([]) clears the playlist, while an omittedIdsleaves the tracks untouched and only updatesName/IsPublic.IsPublicmaps to Navidrome'sPublicflag, surfaced to clients asOpenAccessonGET Playlists/{id}. - Cover art:
POST Items/{id}/Images/Primaryuploads a playlist cover (raw or base64 body, JPEG/PNG/WebP/GIF detected by magic number, extension fromContent-Type);DELETEremoves it. Only playlists are writable through this API — album/artist covers come from tag/sidecar scanning, so a non-playlist id returns501. Uploads honor the same gates as the native endpoint: they're bounded byMaxImageUploadSizeand requireEnableArtworkUploadfor non-admins. PlaylistItemId:GET Playlists/{id}/Itemstags each entry withPlaylistItemId(the playlist-track row id, distinct from the song id) so a client can echo it back viaDELETE Playlists/{id}/Items?EntryIds=...to remove one occurrence of a song that appears more than once in the same playlist.
Ownership is enforced by core/playlists: a non-owner editing/deleting a playlist gets 403 if
it is visible to them (public) or 404 if it is not (private) — the API never reveals that
someone else's private playlist exists.
Images
The GET Items/{itemId}/Images/{type} route is intentionally public (artwork isn't sensitive,
matching Jellyfin's lenient image handling), so it carries no authenticated user. Artwork is
therefore resolved under an elevated admin context — the same approach core/artwork's cache
warmer uses — so user-scoped items like private playlists still resolve their cover instead of
falling back to the placeholder. Album, artist, media-file and playlist ids are all resolved to
their Navidrome ArtworkID.
Finamp saved-queue id truncation
Real Jellyfin item ids are GUIDs — 128-bit values, always 32 hex characters. Finamp relies on that
when persisting its play queue across restarts: packIds() bit-packs every id into exactly 16
bytes. Navidrome ids are 22-character base62 strings, not 32-hex GUIDs, which means Finamp silently
stores only the first 16 characters of each id and asks for those truncated ids back when
restoring the queue — item lookups, then streaming, images, favorites and playback reports for the
restored tracks.
This API compensates server-side (truncated_ids.go): a 16-character id — a length no Navidrome
id uses — is resolved to the full id by unique-prefix lookup (an indexed range scan;
ambiguity is detected and fails safe). The /Items?ids= batch response echoes the id as
requested, because Finamp matches restored items back to its stored ids, and the other item
endpoints accept truncated ids transparently.
Proper fix (upstream): Finamp's packIds()/_unpackIds() (lib/models/finamp_models.dart)
should handle ids that aren't 32-hex GUIDs — e.g. store variable-length ids when any id in the
queue doesn't match the GUID shape. Jellyfin-compatible servers aren't guaranteed to use GUID ids,
so this is worth a Finamp issue/PR; once a fixed release is widespread, this compatibility layer
can be removed.
Streaming and transcoding
The stream endpoints reuse the same transcode-decision pipeline as the Subsonic /stream endpoint:
GET Audio/{id}/stream[.{container}]/universal— the target format comes from the.{container}path suffix, thecontainerparam, or (when neither is present)audioCodec.audioBitRate/maxStreamingBitrateare bits/sec, per Jellyfin convention.static=trueforces direct play (raw), never a transcode.GET Items/{id}/File/Download— always the original file bytes, matching real Jellyfin. Finamp plays throughFilewhen its transcoding setting is off, so an undecodable format (e.g. DSF) can't be rescued server-side on this path.GET Audio/{id}/main.m3u8— the endpoint Finamp plays through when its transcoding setting is on. Implemented as a single-segment HLS VOD playlist whose one segment is the progressive transcode endpoint above, so the whole pipeline (decision, cache, forced transcoding) is reused. Segment codec honorsaudioCodecbut is limited to what HLS packed-audio can carry (aac,mp3); anything else falls back toaac. Seeking re-reads from the start, like Subsonic transcoded streams.- Server-forced transcoding. A format/bitrate configured on the registered player (Settings →
Players) is applied to
stream,universalandmain.m3u8— same override semantics as Subsonic.File/Downloadstay raw. For HLS clients, forceaacormp3; other formats are advertised and served but packed-audio players won't decode them.
AudioMuse-AI compatible endpoints
Compatibility shim for Jellyfin front-ends that integrate AudioMuse-AI
— e.g. Symfonium can use these endpoints for sonic mixes when
connected as a Jellyfin client.
Backed natively by Navidrome's core/sonic engine (the SonicSimilarity plugin capability) — no
external AudioMuse-AI backend or proxy is involved. The endpoints are gated on a SonicSimilarity
plugin being loaded, like the Subsonic sonicSimilarity OpenSubsonic extension.
GET /AudioMuseAI/info— returns{"Version": <navidrome version>, "AvailableEndpoints": [...]}(200).AvailableEndpointslists the endpoints below only when a provider is loaded; otherwise it is empty.GET /AudioMuseAI/health— liveness probe: 200 with an empty body when a provider is loaded, else 404.GET /AudioMuseAI/similar_tracks?item_id=<id>&n=10&eliminate_duplicates=true— 404 when no provider is loaded; otherwise a JSON array of{author, distance, item_id, title}(200;[]when there is no match or noitem_id).eliminate_duplicates(default true) limits results to one track per artist.GET /AudioMuseAI/find_path?start_song_id=<id>&end_song_id=<id>&max_steps=25— 404 when no provider is loaded; otherwise{"path": [{author, item_id, title, tempo?}], "total_distance": <float>}(200), or 400 withstart_song_id and end_song_id are required.when either id is missing.
item_id/start_song_id/end_song_id are the hex-encoded ids Navidrome hands Jellyfin clients.
tempo comes from the track's BPM when known; the richer AudioMuse per-track features
(energy, key, mood_vector, scale, other_features) are not provided. In multi-library
setups, find_path's path and total_distance only reflect hops through tracks in libraries
the caller can access, since hops through inaccessible libraries are filtered out of the result.
curl walkthrough
This mirrors the sequence a real client (e.g. Finamp) follows: handshake, login, browse the library hierarchy, fetch playback info, stream, favorite, report playback, and manage a playlist.
BASE=http://localhost:4533/jellyfin
# 1. Handshake (no auth required)
curl -s "$BASE/System/Info/Public" | jq .
# 2. Login - capture the AccessToken
TOKEN=$(curl -s -X POST "$BASE/Users/AuthenticateByName" \
-H 'Content-Type: application/json' \
-d '{"Username":"admin","Pw":"password"}' | jq -r .AccessToken)
AUTH=(-H "X-Emby-Token: $TOKEN")
# 3. List the user's views (one per accessible library)
curl -s "${AUTH[@]}" "$BASE/UserViews" | jq .
# 4. Browse artists
curl -s "${AUTH[@]}" "$BASE/Items?IncludeItemTypes=MusicArtist" | jq .
ARTIST_ID=$(curl -s "${AUTH[@]}" "$BASE/Items?IncludeItemTypes=MusicArtist&Limit=1" | jq -r '.Items[0].Id')
# 5. Drill into that artist's albums (ParentId with no IncludeItemTypes defaults to MusicAlbum)
ALBUM_ID=$(curl -s "${AUTH[@]}" "$BASE/Items?ParentId=$ARTIST_ID" | jq -r '.Items[0].Id')
# 6. List the album's songs
USER_ID=$(curl -s "${AUTH[@]}" "$BASE/Users/Me" | jq -r .Id)
SONG_ID=$(curl -s "${AUTH[@]}" "$BASE/Users/$USER_ID/Items?ParentId=$ALBUM_ID&IncludeItemTypes=Audio" \
| jq -r '.Items[0].Id')
# 7. Ask for playback info, then stream the song
curl -s -X POST "${AUTH[@]}" "$BASE/Items/$SONG_ID/PlaybackInfo" | jq .
curl -s "${AUTH[@]}" "$BASE/Audio/$SONG_ID/stream" -o /tmp/song.audio
# 8. Favorite the song
curl -s -X POST "${AUTH[@]}" "$BASE/Users/$USER_ID/FavoriteItems/$SONG_ID" | jq .
# 9. Report playback start/stop (also drives scrobbling)
curl -s -X POST "${AUTH[@]}" -H 'Content-Type: application/json' \
-d "{\"ItemId\":\"$SONG_ID\",\"PositionTicks\":0}" "$BASE/Sessions/Playing"
curl -s -X POST "${AUTH[@]}" -H 'Content-Type: application/json' \
-d "{\"ItemId\":\"$SONG_ID\",\"PositionTicks\":1200000000}" "$BASE/Sessions/Playing/Stopped"
# 10. Create a playlist from a whole album (the album id is expanded to its tracks)
PLAYLIST_ID=$(curl -s -X POST "${AUTH[@]}" -H 'Content-Type: application/json' \
-d "{\"Name\":\"My Playlist\",\"Ids\":[\"$ALBUM_ID\"]}" "$BASE/Playlists" | jq -r .Id)
# 11. Make it public, then remove one entry
curl -s -X POST "${AUTH[@]}" -H 'Content-Type: application/json' \
-d '{"IsPublic":true}' "$BASE/Playlists/$PLAYLIST_ID"
ENTRY_ID=$(curl -s "${AUTH[@]}" "$BASE/Playlists/$PLAYLIST_ID/Items" | jq -r '.Items[0].PlaylistItemId')
curl -s -X DELETE "${AUTH[@]}" "$BASE/Playlists/$PLAYLIST_ID/Items?EntryIds=$ENTRY_ID"
# 12. Delete the playlist
curl -s -X DELETE "${AUTH[@]}" "$BASE/Items/$PLAYLIST_ID"
Testing
Handler-level unit tests live alongside each file (*_test.go). A full end-to-end suite in
e2e/ exercises every endpoint through the real router against a real SQLite database and
real repositories (only artwork/streaming/ffmpeg are stubbed), with per-Describe snapshot
isolation — mirroring the Subsonic server/subsonic/e2e suite. Run it with:
make test PKG=./server/jellyfin/...
Known limitations
- Genres are global.
GET Genres/MusicGenresis not scoped to the current user's libraries (genre tags aren't per-library entities in Navidrome's model). - Artist item-access relies on list-time scoping. Unlike albums and songs (which each
belong to exactly one library and are checked against
user.HasLibraryAccesson every fetch), an artist can have content across multiple libraries vialibrary_artist, so there's no single library id to gate a directGET Items/{artistId}or favorite/rating call against. Access control for artists is enforced by scoping theArtists/Items?IncludeItemTypes=MusicArtistlist to the user's libraries, plus the persistence layer's own defense-in-depth; a client that already has an artist id from elsewhere is not re-checked against library membership. - Blurhashes are synthetic, not computed from the artwork (follow-up).
ImageBlurHashesis populated bydto/blurhash.go, which derives a well-formed 1-component (solid color) blurhash by hashing the item id — it never looks at the actual image. Real Jellyfin computes a multi-component blurhash from the cover's pixels (downscaled to 128×128) once at scan time and stores it per image, so its placeholder approximates the art. Ours satisfies the protocol (Finamp gets a valid value to use as a de-dup key and a placeholder, no missing-blurhash warning) but renders as a flat color while art loads. A proper implementation would compute the real blurhash in thecore/artworkpipeline (where the image is already decoded), cache it keyed like the artwork, and have the mappers read it — keeping the synthetic value as a fallback for art that hasn't been rendered yet. - The WebSocket only keep-alives; it pushes no events (follow-up).
GET socketsends aForceKeepAliveand answersKeepAlivepings so real-time clients (Finamp) settle into a working session instead of 404-loop-reconnecting, but it never pushes anything. A follow-up would broadcast real session/playstate and library-change events over it (viaserver/events), mirroring Jellyfin's session messages. - Lyrics.
GET Audio/{id}/Lyricsserves the main lyric track as aLyricDto(Startin 100ns ticks, word-levelCueswhen present), resolved through the fullcore/lyricspipeline (embedded,.lrcsidecars, plugins perLyricsPriority) behind a 5-minute TTL cache that also caches misses — Jellify fetches for every played track, Feishin per song change, so lyric-less tracks are the hot path. No lyrics → 404 (never an empty 200), which all three clients degrade gracefully. Finamp gates its lyrics view on aLyricMediaStream(notHasLyrics, which is just a list badge): browse lists advertise it from embedded lyrics only (the"[]"sentinel check — the column is never""post-scan), whilePlaybackInforuns the full pipeline per track so sidecar/plugin lyrics also light up. Feishin additionally requires server version ≥ 10.9 — the reasonjellyfinVersionis 10.9.11. Concurrent misses on the same track share one pipeline invocation (SimpleCache.GetWithLoaderis singleflighted), and the load runs detached from the request context with a one-minute bound, so a cancelled request or hung plugin can't fail or pin the load for other waiters. Follow-up: tracks whose only lyrics are sidecar/plugin-sourced show noHasLyricsbadge in lists (request-time sources can't be known at list time without per-row I/O).