825 Commits

Author SHA1 Message Date
Deluan Quintão
0dfe460be6
Merge branch 'master' into artwork-blurhash 2026-07-28 23:18:48 -04:00
Deluan Quintão
23548f40a0
fix(share): give visual feedback when downloading from a share (#5865)
* fix(share): give visual feedback when downloading a share

The share page handed the download URL to navidrome-music-player, which fell
through to downloadjs and buffered the whole ZIP into memory via XHR before
saving it. Nothing was handed to the browser until the last byte arrived, so a
large share produced a long silent window with no player feedback and no
browser download UI, inviting repeat clicks that each spawn another server-side
zip+transcode.

Use the player's customDownloader prop to trigger a synthetic anchor instead,
so the browser performs the download and reports its own progress. An anchor
rather than assigning window.location.href: the share page's service worker
registers a NavigationRoute over all navigations, which intercepts the streamed
archive and fails it into the offline fallback (observed as HTTP 503 in Chrome).

handleDownloads now loads the share before streaming so it can set
Content-Disposition and Content-Type. This also fixes error reporting: ZipShare
previously wrote to the ResponseWriter before checkShareError ran, locking the
status at 200, so expired, missing and non-downloadable shares all returned 200.
They now correctly return 410, 404 and 403.

* feat(share): acknowledge the download click in the player

The browser's download UI is the real progress indicator, but nothing in the
page itself reacted to the click, so the moment before the browser catches up
still read as unresponsive. Dim the download button and make it unclickable for
two seconds after a download starts, reusing the JSS function-value pattern the
existing single-track styling already uses.

A repeat download restarts the window instead of extending the original, and
the timer is cleared on unmount. This also blunts repeat clicking, where every
extra click costs another server-side zip and transcode.

Add SharePlayer tests covering the download mechanism and this state machine.
The dimming itself is verified in a browser rather than jsdom: JSS function
values are not evaluated there, so the rule is never emitted and a CSS
assertion would pass or fail for the wrong reason.

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

* test(share): assert render counts in SharePlayer feedback tests

The two acknowledgement tests compared the props object across renders, which
React may reuse, so they passed without proving anything and then failed once
the surrounding assertions changed. Count renders instead, and let the pending
timer run out rather than advancing exactly to its deadline, which does not
cross it.

The repeat-download test now also asserts that no render happens at the
original deadline, proving the timer was replaced rather than merely that one
eventually fired.

* fix(share): count one visit per share download

The preflight share load added in this branch made every download record two
visits: handleDownloads called Share.Load, and ZipShare then loaded the share
again internally. Share.Load increments and persists VisitCount, so the counter
advanced twice per download and the repository work was duplicated.

Pass the already-loaded share into ZipShare instead of its id. handleDownloads
is its only production caller, and it now has the share in hand for the
Content-Disposition header anyway.

The archiver test asserts Load is not called, so the double-load cannot come
back unnoticed. Verified against a running server: the counter now advances by
one per download.

* test(share): derive feedback-window timings from the constant

The acknowledgement tests hardcoded clock advances tuned to a 2000ms window.
Raising DOWNLOAD_FEEDBACK_MS to 5000 left them advancing 1500ms and 1001ms,
which no longer reach the deadline they are meant to cross, so the repeat-
download test passed without proving the timer had been replaced.

Export the constant and derive the advances from it, and let the pending timer
run out in the unmount test rather than advancing a fixed amount. Changing the
duration can no longer silently strand a test short of its deadline.

---------

Signed-off-by: Deluan <deluan@navidrome.org>
2026-07-28 16:13:44 -04:00
Deluan
8c65931ba7 refactor: use stdlib slices/maps and utils helpers in artwork code
Mechanical cleanups, no behavior change:

- 15 copies of the same id-extraction loop collapse to slice.Map (5 repo
  mocks, the scanner's track sweep, 4 wantIDs assertions) and slice.ToMap
  (6 index-by-id loops in the hydration specs).
- disc.go built a map[string]bool purely to dedup folder ids and then
  walked it back into a slice; slice.Unique says that directly.
- folders_artist.go's image filter is slice.Filter over model.IsImageFile.
- mock_artwork_repo deleted from a map while ranging it; maps.DeleteFunc
  states the intent.
- sort.Slice -> slices.SortFunc + cmp.Or; math.Min/Max -> builtin min/max;
  make+copy -> bytes.Clone; strings.Split -> SplitSeq on a per-request
  path; three-clause pixel loops -> for range.
- Reuse utils.BaseName where a stem was recomputed by hand. Not at
  playlist_cover.go:27: that path is a full OS path and utils.BaseName
  uses path.Base, which does not split backslashes.
- Drop a dead nil-guard in agents.go: getAgent returns a bare nil
  interface, and a type assertion on nil already yields ok == false.

cmp.Or was rejected for the gate fallback (func types are not comparable,
does not compile) and for ItemArtwork.AttemptedAt (cmp.Or compares
time.Time with ==, which includes loc; IsZero does not).
2026-07-27 21:56:44 -04:00
Deluan
02fcf8bea7 style(artwork): trim verbose comments to the 1-2 line budget
Comments only; no executable code changed. Verified by comparing the Go
token stream of every touched file before and after: identical.

Removes 375 of the 1104 comment lines this branch added, targeting content
that belongs in a commit message or PR body rather than in the code:
rejected alternatives ("DeleteIfUnchanged, not Delete", "Waking all beats
routing by kind"), refactor history ("as the legacy reader did"), issue
references (#5798, #5597, #5376), benchmark numbers (~400ms, ~16k allocs),
and four persistence doc comments that duplicated the interface godoc in
model/artwork.go verbatim.

Comments predating this branch are left untouched.

The ASCII fixture trees in the e2e suites are deliberately kept above the
line budget: they diagram the fixture layout with its expected outcomes,
and every pre-existing block in those files carries one.
2026-07-27 18:57:31 -04:00
Deluan
4cac0b1401 fix(artwork): stop serving artwork for entities that no longer exist
Artwork state and its bytes outlive a deleted entity until the next
prune (@daily), and the serving path consulted only item_artwork, so a
Subsonic id or a signed public token kept serving a removed entity's
image in the meantime. Master's readers loaded the entity first, so this
was a regression.

The check goes in serveHash, the one path that can hand back a found
row's bytes: absent rows are already unavailable, and the provisional
and disc paths load their entity to resolve at all. Doing it there
instead of per-handler also settles who owns the invariant. Subsonic had
worked around it with artworkAccessible, whose comment described the
service "bypassing the library and private-playlist filters"; that
workaround is now deleted, since the service resolves through the
request-scoped repositories and enforces the filters itself.

Because those repositories are ctx-scoped, each caller says what it
wants by what it passes: Subsonic hands over the request context and so
gets visibility as well as existence, while the public image route
elevates like the Jellyfin one already did -- a token is the
authorization there, and a visibility check would hide a shared private
playlist, the very case shares exist for.

Two supporting fixes. albumRepository.Exists and mediaFileRepository
.Exists used the plain exists() helper, which applies no library filter,
so they reported rows in libraries the caller cannot see; they now count
through applyLibraryFilter as CountAll and artistRepository.Exists
already do. Neither had a production caller. RadioRepository gained the
Exists it lacked.

Tests follow the layers: the service refuses a found row whose entity is
gone, the repositories hide rows the caller may not see, and the
handlers only assert the context they hand over. Jellyfin needed no
change -- resolveArtworkID probes the entity tables, so a deleted item
yields an empty artwork id -- but that protection was incidental and
untested, so it is pinned now.
2026-07-27 17:07:52 -04:00
Deluan
0669f23471 fix(subsonic): stop serving artwork for a deleted radio
artworkAccessible checked every kind except radio, which fell through to
the default "no per-user access control" branch and returned true without
a lookup. Radio deletion removes only the entity row -- item_artwork and
the uploaded file survive until the next prune -- so the old ra- id kept
serving the removed radio's image for up to a day.

This is a regression against master, where newRadioArtworkReader loaded
the radio first and returned its error, so deletion took effect at once.
Radios stay globally visible; only existence is checked.

CreateMockedRadioRepo left Data nil, so its own Put panicked on first
use; initialized it.

Reported by Codex on #5847.
2026-07-27 15:05:55 -04:00
Deluan
0ba89dae49 refactor(artwork): name the service for its domain, not its role
Every other core interface is named for what it is -- Playlists,
Library, Scrobbler, MediaStreamer -- while this one was artwork.Service,
the only X.Service in the tree. It becomes artwork.Artwork/NewArtwork,
and serving.go follows the type to artwork.go.

ProvideImageStore was likewise the only "func Provide" in the repo; it
is GetImageStore now, matching GetImageCache beside it. cmd/wire_gen.go
regenerated via make wire.
2026-07-26 22:10:31 -04:00
Deluan
ff010f8db1 refactor(model): give ItemImage an AspectRatio method
The zero/absent guards around width/height are ItemImage's own invariant, not
the Jellyfin adapter's. Moving them onto the model keeps one definition for
every consumer, so a second one cannot quietly disagree about what an unknown
ratio means.
2026-07-26 11:26:57 -04:00
Deluan
09b647c9d0 feat(jellyfin): expose PrimaryImageAspectRatio
Real Jellyfin carries width/height of the Primary image on BaseItemDto
(MediaBrowser.Model/Dto/BaseItemDto.cs), attaching it only when the request's
Fields asks for it (DtoService.cs ContainsField). The dimensions are now on
model.ItemImage for the web UI's blurhash placeholder, so the adapter can
report the same thing for free.

Gated behind Fields to match, and omitted rather than defaulted when the item
has no image or unknown dimensions: real Jellyfin falls back to a per-type
default of 1 for music, but a wrong ratio mis-shapes a client's placeholder,
and we only lack dimensions when the artwork is genuinely unresolved.

primaryImageTag becomes primaryImage, returning the tag, blurhashes and ratio
together, so the choice of which image is Primary is made once per mapper
rather than the same ItemImage being threaded through two calls.

ArtistToBaseItem and PlaylistToBaseItem take Fields now, like the album and
song mappers already did.
2026-07-26 11:23:12 -04:00
Deluan
6bb3e98e4c fix(artwork): give full-size disc art a real ETag
Regression from 04d5a556. Keying the resize cache on identity meant the
disc response no longer carried a content hash, and the full-size branch
set no ETag either — so WriteImageHeaders fell back to the empty hash
and emitted `ETag: ""` for every full-size disc image. Since ifNoneMatch
compares the unquoted value, a client echoing that back matched, and got
a 304 even after the image was replaced.

The full-size branch now carries the same identity validator the sized
branch uses, so it moves when the folder's images change.

WriteImageHeaders is hardened against the class as well: an empty
validator is no validator, so it is neither emitted nor matched.

Reported by Codex on #5847.
2026-07-25 15:06:51 -04:00
Deluan
6f93fa3141 fix(jellyfin): make a track's own cover reachable for Jellyfin clients
Media files are never enqueued — the scanner drops their state without
queueing them and the recheck kinds exclude them — so an unresolved
track keeps an empty ImageHash and SongToBaseItem always fell through to
AlbumPrimaryImageTag. Finamp then asks for the album image, nothing ever
requests mf-, and the read-through that resolves the track never fires:
the own-cover branch was unreachable for Jellyfin-only users.

An eligible, unresolved, not-known-absent track now advertises its id as
the Primary tag, which is what makes the client ask. The request serves
the embedded art and queues the track so the worker persists a real
hash. No blurhash is sent, since none exists yet and a fake would be
cached against that tag forever.

Serving gains the album fallback that made this safe to advertise: an
eligible track whose frame will not extract now falls back the way
CoverArtID does instead of answering with a placeholder.

Reported by Codex on #5847.
2026-07-25 14:44:08 -04:00
Deluan
d48c7b04da fix(artwork): stop advertising a hash for bytes that won't be served
Hydration stamped the album's hash and blurhash onto an embedded-eligible
track that had no state row yet. Serving takes provisionalEmbedded for
exactly that case and returns the track's own embedded image, so the id
carried a content-version belonging to a different picture: every such
request fell back to no-cache instead of immutable, and a client keying
its cover cache on the blurhash paired the album's with the track's art.

AlbumCoverArtID had the mirror-image problem, building the album id from
the track's own ItemImage. It happened to work only because hydration
overwrote ImageHash in precisely the fallback cases; a track with its own
resolved art would have stamped that hash onto the album's id.
2026-07-25 10:25:46 -04:00
Deluan
6553a75b0e fix(artwork): keep not-found distinct from artwork-absent
GetOrPlaceholder folded model.ErrNotFound in with ErrUnavailable, so an
id matching no entity returned 200 and the placeholder PNG. That made
the ErrorDataNotFound branch in getCoverArt unreachable: Subsonic went
from error 70 to a successful placeholder, and Jellyfin's Primary image
endpoint from 404 to 200. Neither is ours to change.

An entity with no art and an id with no entity are different answers;
only the first is a placeholder.
2026-07-25 10:14:22 -04:00
Deluan
61cbd2f5f9 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.
2026-07-24 16:58:23 -04:00
Deluan
53babc7a2a 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.
2026-07-24 16:24:19 -04:00
Deluan
a1eb5e8343 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.
2026-07-24 10:46:59 -04:00
Deluan
77e6f7fdc3 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.
2026-07-23 23:53:09 -04:00
Deluan
8545cc4762 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.
2026-07-23 23:46:44 -04:00
Deluan
dafcdf438f feat(jellyfin): emit real blurhashes and drop the synthesized fallback 2026-07-23 20:28:56 -04:00
Deluan
c1a22889c7 fix(jellyfin): trim primaryImageTag comment to why-only, within budget 2026-07-23 20:19:43 -04:00
Deluan
6f8e33d28a feat(jellyfin): version album and artist image tags by content hash 2026-07-23 20:14:40 -04:00
Deluan
a998a329e6 test(artwork): use renamed ArtworkWorkerConcurrency in e2e tests 2026-07-23 14:09:39 -04:00
Deluan
42fd263cdf 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).
2026-07-23 14:08:08 -04:00
Deluan
3eaa21229d 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.
2026-07-23 14:08:08 -04:00
Deluan
9dd306eb10 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.
2026-07-23 14:08:08 -04:00
Deluan
50ada9ad29 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).
2026-07-23 14:08:08 -04:00
Deluan
66d3d23149 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.
2026-07-23 14:08:08 -04:00
Deluan
c2d7ae773c chore(artwork): generic 500 bodies on refresh endpoint, trim stale test comments 2026-07-23 14:08:08 -04:00
Deluan
dfd4bec270 test(artwork): end-to-end coverage for the serving cutover 2026-07-23 14:08:08 -04:00
Deluan
0d1df1648e feat(artwork): precache on acquisition, bump on upload/radio changes, manual re-resolve API 2026-07-23 14:08:08 -04:00
Deluan
937e58e5fb refactor(artwork): delete the legacy reader chain, cache warmer, and provider image methods 2026-07-23 14:08:08 -04:00
Deluan
8fea7efa50 feat(subsonic): content-hash coverArt ids, omit artwork on known-absent 2026-07-23 14:08:08 -04:00
Deluan
25b32f9706 feat(server): serve artwork from persisted state with content-hash caching 2026-07-23 14:08:08 -04:00
Deluan
3b7cbf41dd feat(model): content-hash artwork id suffix and hydratable per-entity image state 2026-07-23 14:07:52 -04:00
Deluan
c01e9b3184 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.
2026-07-22 15:53:32 -04:00
Deluan Quintão
fed9665060
fix(streaming): surface why a transcode decision failed (#5820)
* fix(subsonic): surface the reason a transcode decision failed

getTranscodeDecision returned a bare "failed to make transcode decision"
with no clue why, and the probe command ran ffprobe with -v quiet, so even
the server log bottomed out at "exit status 1". A user whose files had been
moved by an external tool only saw the opaque error.

ProbeAudioStream now returns a typed ProbeError that separates the file path
from the reason: ffprobe runs with -v error so its stderr diagnostic is
captured, and a missing or unreadable file is reported as "file not found"
rather than ffprobe's misleading "Invalid data found". The handler logs the
full detail (including the path) and returns the reason to the client with
the server path stripped out.

Reported-by: Tolriq (Symfonium)

* refactor(ffmpeg): use errors.AsType for ExitError match

probeErrorReason used the older var+errors.As form while the rest of the
codebase (and its sibling transcodeFailureReason) uses the generic
errors.AsType. Switch to it for consistency; behavior is unchanged.

* fix(subsonic): return error 70 when the source file is missing

A getTranscodeDecision probe failure was always reported as generic error 0.
When the source file is gone (moved or deleted out from under the DB), that is
a not-found condition, so return the standard Subsonic error 70 ("data not
found") instead — matching what the endpoint already returns for an unknown
mediaId. Files that exist but are corrupt or unreadable stay error 0.

ProbeError now wraps the underlying cause and implements Unwrap, so the handler
detects the case with errors.Is(err, fs.ErrNotExist).

* fix(ffmpeg): keep probe error paths out of client-facing reasons

Addresses review feedback on the ProbeError type: the Reason field doubled as
both the log detail and the client message, so an ffprobe launch failure (a
*os.PathError from fork/exec) could leak the ffprobe binary path to clients,
and an unexpected stat error was reduced to "file not accessible" in the log.

Split the two concerns: Reason now holds only a path-free, client-safe string
(built at construction), while Error() logs the full underlying cause. Launch
failures return a generic "could not read file" instead of the raw exec error.
SafeReason no longer does substring path-stripping (removing the empty-Path
edge case); the stripping happens once, against ffprobe's stderr.

* fix(subsonic): don't report a broken ffprobe as a missing media file

Two issues from review of the previous commit:

Code 70 was selected with errors.Is(err, fs.ErrNotExist), but a launch failure
of a deleted ffprobe binary is an *os.PathError that also wraps fs.ErrNotExist.
A server-side ffprobe problem was therefore reported to clients as a missing
media file. ProbeError now carries an explicit NotFound flag, set only on the
file-access branch, and the handler keys the code off that instead of the chain.

ffprobe can also exit 0 while yielding no audio stream (an audio-suffixed
container holding only video). That parse failure was returned unwrapped, so
clients got "internal error"; it is now wrapped in a ProbeError too.
2026-07-19 18:51:32 -04:00
Deluan Quintão
5927e693d1
feat(jellyfin): filter items by year and record label (#5817)
* feat(persistence): add AlbumRepository.GetYears for distinct album years

* test(persistence): verify GetYears de-duplicates repeated years

Regression test that adds two albums with the same non-zero max_year
(2005) and verifies that GetYears() returns that year exactly once,
ensuring the SQL DISTINCT clause is applied correctly. Catches any
future removal of DISTINCT from the GetYears query.

* feat(jellyfin): add legacy /Items/Filters endpoint (genres + years)

* feat(jellyfin): add /Studios endpoint from record label tags

* refactor(persistence): drop duplicate columns in tagRepository.GetAll

* fix(jellyfin): exclude missing albums from filter years

GetYears only filtered max_year > 0, so albums whose files were all removed
(missing=true, kept when Scanner.PurgeMissing=never) contributed stale years
to /Items/Filters. Filter them out like the normal album listings do. Also
return an empty slice from the MockAlbumRepo to match the real repository.

* feat(jellyfin): filter /Items by Years=

* feat(jellyfin): filter /Items by StudioIds= (record labels)

* feat(jellyfin): scope filter and studio lists to ParentId library

* refactor(jellyfin): extract parentIDScope and libraryScopeFilter helpers

Collapse the three inline resolveLibraryScope(dto.DecodeID(parentid)) call
sites and the duplicated empty-scope guard into two small helpers, so the
empty-scope=unrestricted contract lives in one place. Reuse the existing
names() helper in the Years= e2e test.

* feat(jellyfin): expose record labels as album Studios

Add a Studios field to the album BaseItemDto, populated from the record-label
tags and gated behind Fields=Studios (matching Jellyfin's ItemFields
convention). Studio ids reuse the record-label tag identity, so they round-trip
with the /Studios list and the StudioIds= filter. Real Jellyfin leaves Studios
empty for music; Feishin reads it as the album's record label.
2026-07-19 12:39:31 -04:00
Deluan Quintão
4efd92cf83
feat(server): expose album-level ReplayGain in albums (#5816)
* feat(model): aggregate album ReplayGain in ToAlbum

* feat(db): add nullable album ReplayGain columns with backfill

* feat(persistence): persist album ReplayGain fields

* feat(jellyfin): expose album NormalizationGain from ReplayGain

* refactor(model): lazy-init mostFrequentPtr map; clean up RG test rows

* fix(db): backfill album ReplayGain with most-frequent value, not max

A plain max() picked a minority outlier that diverged from MediaFiles.ToAlbum
(which uses the most-frequent value), and GetTouchedAlbums never re-derives an
unchanged album, so the wrong value would persist. Reproduce the modal
aggregation via grouped CTEs, which also groups media_file once instead of a
per-album correlated scan.

* refactor(model): return an owned pointer from mostFrequentPtr

Avoid aliasing a MediaFile field so the resulting Album is independent of the
source slice.
2026-07-18 22:19:29 -04:00
Deluan Quintão
7234ea23b7
feat(jellyfin): expose NormalizationGain from ReplayGain tags (#5815)
* feat(jellyfin): expose NormalizationGain from ReplayGain tags

Adds NormalizationGain and AlbumNormalizationGain to Audio BaseItemDtos,
sourced from the scanner's ReplayGain values (REPLAYGAIN_* tags, with R128_*
already converted to the same -18 LUFS reference). Same wire contract as real
Jellyfin: PascalCase keys, omitted when absent, no Fields gating. Album items
intentionally omit the field: model.Album has no gain column and Feishin reads
gain from song DTOs.

* test(jellyfin): e2e coverage for NormalizationGain fields

* test(jellyfin): drop redundant NormalizationGain passthrough spec

The JSON-casing spec already proves the mapped values (the substring
"NormalizationGain":-3.5 can only appear if the passthrough worked), so the
direct-struct spec added no coverage.

* style(jellyfin): use ASCII punctuation in gain comment
2026-07-18 20:26:14 -04:00
Deluan Quintão
59f1b4206c
fix(jellyfin): serve playlist covers regardless of visibility (#5813)
The image endpoint only served a private playlist's cover when the request
carried a token identifying its owner or an admin. But clients fetch cover
URLs without credentials — real Jellyfin's image routes are anonymous — so
every private playlist rendered the generic placeholder in Jellyfin clients
(observed in production), while the same covers displayed fine through the
always-authenticated Subsonic/native APIs.

Drop the gate and serve playlist covers like album/artist/track artwork:
playlist ids are unguessable without credentials, so anonymous access does
not meaningfully expose private playlist contents.
2026-07-18 19:36:12 -04:00
Deluan Quintão
3158451b8d
refactor(server): drop redundant error return from req.Strings parsing (#5812)
* refactor(req): drop redundant error return from Strings

The error from Strings carried no information beyond emptiness — it fired
exactly when the param was absent — and nearly every caller discarded it with
a blank identifier. Strings now just returns the values (empty when absent),
making the common optional-list reads one clean expression.

The few required-param callers (scrobble, createShare) check for emptiness and
return the same Subsonic error code 10 as before; their e2e tests now pin that
code. Ints and Times keep their contracts by synthesizing ErrMissingParam
themselves, so selectedMusicFolderIds is untouched. The jellyfin parseFields
helper is inlined away, since ParseFields(p.Strings("fields")...) now
compiles directly.

* docs(req): clarify Strings returns nil when param is absent
2026-07-18 19:30:04 -04:00
Deluan Quintão
1e82f515c4
fix(jellyfin): honor repeated Fields query params (#5811)
The Fields param was read with StringOr, which keeps only one value, so a
request sending it as repeated params (Fields=Genres&Fields=MediaSources) — as
Finamp and Feishin do — lost all but the first. Field-gated data like
MediaSources was then omitted for Audio items even though the client asked for
it; the comma-separated form happened to work because ParseFields splits on
commas. Real Jellyfin accepts both forms.

ParseFields is now variadic and a parseFields helper reads every repeated value
via req.Values.Strings, applied to the item list, single-item, and playlist
endpoints.
2026-07-18 18:59:08 -04:00
Deluan Quintão
09ac342f5a
fix(jellyfin): split Artists/ArtistItems per track artist (#5810)
* fix(jellyfin): split Artists/ArtistItems per track artist

The Jellyfin API returned a single ArtistItems entry built from the flattened
display artist, so a multi-artist track (e.g. "De La Soul feat. Redman") lost
its individual artists and dropped every artist id but the first — while the
same track's OpenSubsonic response correctly split them. Real Jellyfin splits
Artists/ArtistItems one entry per track artist.

Build both from Participants[RoleArtist], which is already loaded on the search
and browse paths, falling back to the flattened display fields when absent.
AlbumArtists stays a single credit, matching real Jellyfin.

* fix(jellyfin): omit empty Artists in the fallback path

When a track has no participants and no display artist, the fallback set
Artists to a single-element slice holding an empty string. Only populate it
when the display artist is non-empty, so untagged tracks omit the field
instead of sending [""].
2026-07-18 18:16:52 -04:00
Kendall Garner
9c7cf7d734
feat(jellyfin): expose genreitems for song/album (#5809)
* feat(jellyfin): expose genreitems for song/album

* use encode id instead
2026-07-18 17:46:11 -04:00
Deluan
e4a423db11 feat(jellyfin): emit refreshResource events on favorite/rating changes
Like Subsonic's setStar/setRating, the Jellyfin favorite and rating
endpoints now broadcast a refreshResource event, so the web UI updates
immediately when a Jellyfin client changes an annotation.

Also fixes model.GetEntityByID to propagate unexpected repository errors
instead of reporting them as not-found, preserving the 500-vs-404
distinction for all its callers.
2026-07-18 16:50:23 -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
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
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