Commit graph

5,042 commits

Author SHA1 Message Date
Deluan
d2aa486d0c fix(cli): estimate artwork reprocess external lookups per agent, not per item
The reprocess prompt billed one external lookup per externally-capable item.
fetchArtistImage/fetchAlbumImage try every enabled image agent and stop early
only on a hit, and resolvePlaylist can fetch the m3u image and then resolve up
to four sampled albums for the grid, each walking the album agents again. The
number the operator confirmed could understate real provider traffic several
fold, in the prompt whose whole job is to stop a provider flood.

ExternalLookupsPerItem now multiplies by the visible image-agent count and adds
the playlist grid factor. It stays a floor: the CLI never calls Manager.Start(),
so the plugin registry is empty and plugin-provided agents are dropped by
getEnabledAgentNames. On an install with 5 agents of which 3 are plugins the
count is well under the truth, so the wording is now "at least N" rather than
"up to N" — a zero visible count still bills one lookup for the same reason.

Fixing the plugin visibility is out of scope: Manager.Start() needs a Subsonic
router and writes to the DB via syncPlugins, breaking this command group's
read-only guarantee.
2026-08-14 16:46:02 -04:00
Deluan
08160f3cc0 fix(cli): count playlists in the artwork reprocess external estimate
The estimate used WalksPriorityChain, which is true only for artist and album,
so a playlist-only reprocess reported "External lookups: none" and the
confirmation prompt dropped the external-cost warning. Playlists do reach the
network: through the m3u ExternalImageURL fetch when EnableM3UExternalAlbumArt
is on, and — verified by test — through the generated grid, whose tiles resolve
album art via the full album priority chain.

Adds artwork.MayFetchExternal, a config-aware predicate for "can this kind's
resolver reach the network", and uses it for the estimate. WalksPriorityChain
keeps its separate job of deciding whether explain prints a chain block.
2026-08-14 16:31:37 -04:00
Deluan
7b83d570b1 refactor(artwork): rename Resolver to TracingResolver for clarity 2026-08-14 15:39:13 -04:00
Deluan
3a76a24dab test(persistence): drop two artwork-queue specs that cannot fail
One seeded hash and source together and then asserted the two counts agree,
so its setup guaranteed the result; the other repeated the count-does-not-
enqueue property already covered by the CountBySource spec.
2026-08-14 15:23:05 -04:00
Deluan
e025b11a6c refactor(cli): drop the artwork command's duplicated state and formatting
Reuse artwork.RecheckKinds and artwork.WalksPriorityChain, extract
newTabWriter and externalEstimate, fold reprocessSelectsAll into
selectedKinds, and derive the queue total and the walks-chain flag instead of
carrying them in the report structs.
2026-08-14 15:22:59 -04:00
Deluan
b04d0c42cc refactor(artwork): own the kind list and the chain predicate in the package
Export RecheckKinds and add WalksPriorityChain so the CLI stops keeping its
own copies of both, and unexport externalCandidate, which nothing outside the
package consumes.
2026-08-14 15:22:37 -04:00
Deluan
7a09e3e08d fix(cli): print the absent artwork source under the name --source accepts
`artwork explain` rendered the stored empty source as "(absent)", while
`artwork reprocess --source` only accepts "absent", so pasting what explain
printed straight back into reprocess was rejected as an unknown source.
2026-08-14 15:22:30 -04:00
Deluan
46a332757f refactor(cli): simplify parseArtworkKind with slices.Contains
Replaces a nested loop and a manual append with slices.Contains and the
repo's slice.Map helper. Same behaviour, same error message.
2026-08-14 15:13:55 -04:00
Deluan
8e1ed55ca6 refactor(artwork): export the trace types directly
ChainTrace and TraceStep were unexported types re-exported through aliases,
which existed only so the CLI had a name to refer to them by. The types are
public API — Resolver.Steps returns []TraceStep and the CLI constructs a
ChainTrace — so name them that way and drop the indirection.

Encapsulation is unchanged: add, mu and steps stay unexported, so only this
package can write a step.
2026-08-14 15:13:55 -04:00
Deluan
ceb0be482e refactor(artwork): make the trace nil-safe in one place only
add already handles a nil trace, so record's own guard was dead; Steps was the
odd one out and would panic where every other method tolerates nil.
2026-08-14 15:13:55 -04:00
Deluan
29156d3053 refactor(artwork): delete the never-emitted not-reached outcome
Candidates after the winner are lower priority and say nothing about why a source
won; the ones that matter sit above it and are already recorded.
2026-08-14 15:13:55 -04:00
Deluan
cf2b4df36b refactor(artwork): drop the unreachable breaker branch from the tracing gate
--live wires the tracing gate straight to passthroughGate, so errBreakerOpen can
never reach it; the test only passed by injecting a fake gate.
2026-08-14 15:13:55 -04:00
Deluan
b276e8aceb fix(cli): keep the re-enqueue warning when a backfill is already running
A stale stored fingerprint with items already queued is the worst state the
system can be in: a second full re-enqueue is pending on top of the one running.
The line carried the weakest wording of the three, and was untested.
2026-08-14 15:13:55 -04:00
Deluan
a1b9976521 refactor(artwork): export the trace outcome vocabulary
The CLI hardcoded the outcome literals and the "external:" prefix, so renaming a
constant's value in core/artwork left cmd compiling and the suite green while
`artwork explain` silently degraded its verdict.

Renaming a value now fails the golden vocabulary test in core/artwork and the
explainResult tests in cmd.
2026-08-14 15:13:55 -04:00
Deluan
3ca194c887 fix(cli): lead the artwork status backfill line with the queued backlog
By the time anyone runs a diagnostic, backfill has usually already stored the new
fingerprint, so 'up to date' was printed while thousands of items churned through external
providers. The backlog is the finding; the fingerprint is context.

Also echoes the config inputs the fingerprint covers, so a change can be traced to the
setting that caused it, and pins the rendered rows: the Absent values, the queue TOTAL and
a queue-scoped kind/priority pair were all unasserted, so kindName and priorityName were
effectively untested. FingerprintInputs is now the single listing ConfigFingerprint hashes;
a pinned hash proves the value did not change.
2026-08-14 15:13:55 -04:00
Deluan
0950f939fb feat(cli): add artwork status
Reports the queue, where artwork currently resolves from, absent counts against the 24h
recheck window, and the stored config fingerprint versus the current one — the line that
turns 'why is my server re-resolving everything?' into one command.

fingerprint() and staleAbsentAge are exported so the CLI reports the values backfill
itself compares, instead of a second copy of the formula that can silently drift.
2026-08-14 15:13:55 -04:00
Deluan
7f97107d45 feat(artwork): add read-only queue and absent counters
Both are needed by the artwork status CLI: a queue breakdown by kind and priority, and
the absent totals split against the recheck cutoff.
2026-08-14 15:13:55 -04:00
Deluan
482ed50d7d fix(cli): cover the reprocess --yes guard and preview the external cost
Mutating the --yes check to `if true` left the suite green, so the one bypass
of the confirmation was unverified. The choice is now reprocessConfirm(yes, in),
covered in both directions.

The external estimate only reached the operator through the prompt, which
--dry-run skips — hiding the number in the one mode that exists to show it
before committing. The preview now carries it, and the prompt drops the clause
when no lookup will be made.

An empty selection says so again under --dry-run.
2026-08-14 15:13:55 -04:00
Deluan
bd7b46c91c fix(cli): cover the reprocess selection rule and validate sources table-wide
The reconciliation that makes --source alone target every kind was only
exercised through runReprocess, which no test calls: mutating it to
`all := reprocessAll` left the suite green. It is now reprocessSelectsAll,
covered for all three selectors.

Scoping source validation to the selected kinds made the same well-formed
filter valid or invalid depending on which other kinds were selected, and its
error read the same for a typo as for a source that simply does not apply to
the chosen kind. Validation is now table-wide: a typo still aborts, while a
valid-but-inapplicable source falls through to "Nothing matches".

Also: the prompt now counts only the kinds that reach an external agent as
external cost, and --dry-run on an empty selection reports a dry run.
2026-08-14 15:13:55 -04:00
Deluan
fd5aca7682 feat(cli): add artwork reprocess
Bulk re-enqueues artwork by kind and/or by the source an item currently
resolves from, previewing the matched count and confirming before queueing.

The preview counts with CountBySource (rows matched) and reports separately
what EnqueueBySource inserted: its DO NOTHING conflict policy leaves an
already-queued row untouched, so the two numbers differ and the output must
not claim the skipped rows were re-queued.

An unknown --source is rejected against the sources present in item_artwork,
rather than silently matching nothing and printing a reassuring 0.
2026-08-14 15:13:55 -04:00
Deluan
4e07b292a3 feat(cli): add artwork refresh 2026-08-14 15:13:55 -04:00
Deluan
ab3e6fe949 fix(cli): never state an artwork outcome the walk did not observe
A transient external failure traced as 'error' fell through to 'not resolved', which
is the most common state behind a missing-artwork report. It is now indeterminate, and
an offline win that a skipped higher-priority external candidate could have taken says
so instead of naming a winner the live chain might not pick.
2026-08-14 15:13:55 -04:00
Deluan
18476ff811 fix(artwork): trace an external tier that never reaches an agent
A configured 'external' token vanished from the chain when no enabled agent provided
images for that entity type, and for synthetic artists, leaving the trace unable to
say whether the tier was even considered.
2026-08-14 15:13:55 -04:00
Deluan
8458522dc5 feat(cli): add artwork explain
Prints why an item has the artwork it has: the stored state, its queue row, the
governing config, the resolver's priority-chain walk and the verdict. Offline by
default so a diagnostic run cannot add load to an external provider; --live asks
the agents for real. Playlists and radios do not walk a priority chain, so they
report that instead of an empty chain table.
2026-08-14 15:13:55 -04:00
Deluan
5ba8a9fcb5 feat(artwork): read a single queue row by item
The explain CLI must report whether an item is queued, at what priority and when it
retries; the queue repository could only be drained in eligibility batches, which
cannot see a row that is still backing off.
2026-08-14 15:13:55 -04:00
Deluan
e3c78a9cb1 feat(artwork): expose a tracing resolver for the CLI 2026-08-14 15:13:55 -04:00
Deluan
3eeb453aba feat(artwork): add repository queries to enqueue by current source 2026-08-14 15:13:55 -04:00
Deluan
90a7cb4706 feat(artwork): trace external agents at the gate seam 2026-08-14 15:13:55 -04:00
Deluan
fe67ec68d0 refactor(artwork): report never-evaluated candidates as skipped 2026-08-14 15:13:55 -04:00
Deluan
62eb781e8d fix(artwork): record priority candidates the chain never evaluated 2026-08-14 15:13:55 -04:00
Deluan
4bad716e0b feat(artwork): trace the local priority chain 2026-08-14 15:13:54 -04:00
Deluan
bda549f8a8 feat(artwork): add a resolution chain trace collector 2026-08-14 15:13:54 -04:00
Deluan Quintão
b617a878b9
feat(insights): report the app store or hosting platform via ND_PLATFORM (#5956)
* feat(insights): report the app store or hosting platform via ND_PLATFORM

Insights had no way to tell where an instance is deployed. The existing `os.package` field is written only by our own packagers and holds just `deb`, `rpm` or `msi`, so it answers "which installer", not "which platform". Overloading it would mix two unrelated dimensions in the same field.

This adds a separate top-level `platform` field, self-declared by the deployer through the `ND_PLATFORM` environment variable. App stores and hosting providers (ZimaOS, PikaPods, TrueNAS, Unraid, and others) generally deploy our container image unmodified and can only inject environment variables, so an env var is the one marker they can all set. It is deliberately not a config option: it is a packager marker, not something users should tune, and it stays out of the config surface.

Both values are now whitespace-trimmed. The msi packager writes the file with `echo`, so `os.package` has been arriving as `"msi\n"` and sorting separately from `"msi"` in any aggregation.

* test(insights): isolate hostingPlatform specs from an inherited ND_PLATFORM

The spec asserting an empty result read the real environment, so it failed on any machine that already had ND_PLATFORM set. Unset it per-spec, using Setenv first so Ginkgo restores the original value on cleanup.
2026-08-14 13:46:52 -04:00
Deluan Quintão
95615bcb18
fix(artwork): serve images whose format has no registered decoder (#5952)
* fix(artwork): serve images whose format has no registered decoder

The new pipeline derives dimensions, mime and the placeholder hashes at
resolution time, so a decode became a precondition for recording artwork at
all. An image in a format Go has no decoder for therefore failed acquisition,
retried until the 12h budget ran out, and then settled as absent, serving a
placeholder from that point on. The old pipeline decoded only to resize and
fell back to the original bytes when that failed, so these covers used to work.

A local file is picked by matching an image extension, so bytes it cannot
decode are most likely a codec we lack: image.ErrFormat on a folder, upload or
embedded source now yields an Artwork row carrying just the hash and mime, and
the bytes stay servable. An external response carries no such guarantee, so it
still fails and retries rather than pinning a non-image body as a cover. A
corrupt image of a known format and an over-cap declared size still fail, so
the decompression bomb guard is unchanged. Absent rows recorded by earlier
builds are re-resolved by the existing stale-absent recheck within a day, so
no epoch bump is needed to repair them.

Reusing a stored image now re-decodes when it carries no dimensions, so a
row recorded while a decoder was missing can still be upgraded later.

Registers jxl, heic and heif in mime_types.yaml: image detection resolves the
extension through the host MIME table, and the Alpine release image ships no
/etc/mime.types, so those covers were never recorded in folder.ImageFiles
there and never reached the pipeline at all.

* fix(artwork): never record empty bytes as artwork

image.DecodeConfig returns image.ErrFormat for an empty payload just as it
does for a codec with no registered decoder, so a zero-byte cover file was
recorded as found artwork and served as an empty response instead of falling
back to the placeholder. A truncated image of a known format already fails
with unexpected EOF rather than ErrFormat, so only the empty case needed the
guard.
2026-08-14 09:43:13 -04:00
Deluan Quintão
aa0824e03b
feat(jellyfin): add System/Endpoint so Finamp's connection test passes (#5955)
Finamp's connection test GETs /System/Endpoint and treats anything other
than a 200 carrying an IsInNetwork key as "not a Jellyfin server", so the
test failed against Navidrome and dual-connection setups could never
switch to the local address.

IsInNetwork mirrors Jellyfin's default LAN set (NetworkManager with no
LocalNetworkSubnets configured): loopback, the RFC 1918 ranges, fc00::/7
and fe80::/10. Notably that set omits 169.254.0.0/16, so Go's
IsLinkLocalUnicast is deliberately restricted to its IPv6 half.

IsLocal mirrors HttpContext.IsLocal(): the caller shares the connection's
local address, not merely "is loopback". It falls back to the loopback
check when the local address is unavailable.
2026-08-14 09:17:00 -04:00
Deluan Quintão
59a4ed8e79
fix(plugins): stop reporting plugin call failures as not-found (#5953)
* fix(plugins): stop reporting plugin call failures as not-found

MetadataAgent joined agents.ErrNotFound onto every failed plugin call, so a
transport fault was indistinguishable from a definitive miss. The artwork
circuit breaker treats a not-found as a successful, definitive answer and
resets its failure counter, so it never opened for a failing plugin and kept
calling it on every request. Observed with the apple-music plugin against
prod: ~900 iTunes 429s in 27 minutes with the breaker never tripping.

Return the underlying error instead. The genuine empty-result branches still
return agents.ErrNotFound, and agent fallback is unaffected because
callAgentMethod/callAgentSliceMethod continue on any error, not only on
ErrNotFound.

* test(plugins): fold duplicate metadata agent error specs into one table

The error-handling container drove all 11 MetadataAgent methods twice: once
to assert the message, once to assert the failure is not an ErrNotFound. The
argument lists were identical, so each method cost two WASM instantiations for
one method's worth of coverage, and a new capability had to be registered in
two places to stay guarded.

Fold both assertions into a single DescribeTable, document the ErrNotFound
contract at the sentinel where agent implementers will read it, and collapse
breaker.record's hand-inlined predicate onto isTransientExternal, which it
already duplicated by hand with a keep-in-sync comment.

* fix(plugins): keep an unimplemented plugin method a definitive miss

Returning the raw plugin error made errNotImplemented and errFunctionNotFound
look like provider faults. Every MetadataAgent satisfies ArtistImageRetriever
and AlbumImageRetriever regardless of what the plugin actually exports, so
artwork resolution calls those stubs on a partially-implemented plugin: each
call counted toward the artwork circuit breaker and kept the item in the retry
queue instead of settling it absent.

Map both sentinels back onto agents.ErrNotFound, joined so the underlying
reason survives for diagnostics, and leave real call failures untouched. This
matches what ScrobblerPlugin already does for the same two sentinels.

The partial-implementation specs asserted only MatchError(errNotImplemented),
which the previous errors.Join satisfied incidentally, so nothing caught the
lost not-found semantics. They now assert both and are folded into one table.

* test(plugins): cover the missing-export arm of agentErr

The partial-metadata-agent fixture registers through the Go PDK, which exports
every method and answers with the not-implemented code, so no fixture reaches
the errFunctionNotFound branch. Building one would mean hand-writing Extism
exports to deliberately omit a function, which tests the manager's function
lookup rather than the mapping this PR added.

Cover agentErr directly instead: both sentinels classify as a definitive miss,
a call failure and a non-zero exit stay faults, and the underlying reason
survives in every case.

* fix(artwork): stop counting a cancelled run against the circuit breaker

callPluginFunction returns ctx.Err() when a plugin call is cancelled, and that
reached breaker.record as an ordinary error, so cancellations counted toward
the five consecutive failures that open a gate. A cancellation says nothing
about the provider, so it now neither counts nor clears the failure run.

Deliberately scoped to breaker.record rather than isTransientExternal: the
latter also drives whether the queue item is rescheduled, and a cancelled item
must still be retried rather than settling absent. context.DeadlineExceeded is
left counting as a fault, since a provider that blows the budget is one worth
backing off from.

Reachable today only at shutdown, where the in-memory breaker state is
discarded anyway. It becomes live the moment Worker.gate is used on a
request-scoped context, which is why it is worth closing now.
2026-08-13 22:56:09 -04:00
Deluan Quintão
757ca783d3
fix(instant-mix): top short mixes up instead of returning what the first source found (#5951)
* fix(instant-mix): top short mixes up instead of returning what the first source found

SimilarSongs returned the first non-empty source's tracks, however few. For a
thinly-represented artist that meant a 3-track mix no matter the requested count:
the artist agent found nothing, the similar-artists fallback matched 3 library
tracks, and `len(res) > 0` kept seed-track sampling from ever running.

Clients treat that as a failed mix and retry with a bigger limit forever. Finamp
cycles limit 34 through 472 and starts over, ~1 request every 2s indefinitely,
each one re-hitting Last.fm, Deezer and AudioMuse.

Sources are now chained rather than raced: each one tops the mix up until it
holds count tracks, so the agent's picks, the similar-artists fallback and
seed-track sampling all contribute instead of the first one winning outright.

* refactor(external): move similar-songs code to its own file

provider.go held two distinct concerns: artist/album external metadata and the
similar-songs mix pipeline. The mix code was already one contiguous block, and
maxSeeds, maxSimilarSongs and dedupByID were used by nothing else.

Moved SimilarSongs and its helpers to provider_similarsongs.go, matching the
existing provider_similarsongs_test.go. Pure code motion: the moved block is
byte-for-byte unchanged and provider.go has no additions, only deletions.

* fix(instant-mix): dedup before deciding a mix is full

topUp measured res before deduplicating it. Matcher.MatchSongs deliberately
re-emits a library track when the same input song repeats, and the similar-artists
fallback can reach one track through several artists, so len(res) could equal count
while holding fewer unique tracks. That returned a mix with duplicates in it and
stopped the top-up early; the caller then deduplicated and handed back a short mix,
which is the client retry loop this branch set out to fix.

Deduplicate first, so the length check counts what the client will actually receive.

* perf(instant-mix): skip a fallback once the mix is already full

The artist path nested one topUp inside another, so the inner one measured only
similarSongsFallback's own result against the full count. With 49 agent matches and
one fallback match for count=50 the mix was already full, yet seed-track sampling
still ran and fired up to five GetSimilarSongsByTrack calls whose results the outer
topUp then truncated away.

topUp now takes the sources as a variadic list and re-checks the accumulated mix
before each one, so a later, costlier source only runs while the mix is still short.
That also flattens the artist case: the agent, the similar-artists fallback and
seed-track sampling are now three peers in one chain instead of two nested calls.

* fix(instant-mix): count distinct tracks when picking the fallback mix

similarSongsFallback stopped after count picks from the weighted chooser, but a
track can sit in that chooser once per artist listing it in their top songs, and
Pick removes the entry it returns. Repeats therefore consumed pick slots and left
unique candidates stranded, so the batch could come back short of count. On the
track path this is the only source, so that short mix reached the client and kept
the retry loop alive.

Track the ids already picked and keep drawing until count distinct tracks are held
or the chooser is empty.

* fix(instant-mix): match the whole agent response before trimming

MatchSongs was capped at count, and it re-emits a track when the same song repeats,
so [A, A, B] with count=2 returned [A, A] and never reached B. topUp then shrank
that to [A] and, with an empty or overlapping fallback, the mix stayed short even
though B had been available all along. seedMix already matched its full merged set
for this reason; mixFromAgent now does the same and leaves the trim to topUp.

Also drop the capacity hint on the picked-ids map. It was sized from the caller's
count, which CodeQL flags as an allocation sized by user input (go/uncontrolled-
allocation-size). SimilarSongs clamps count to maxSimilarSongs long before this
point, so the hint bought nothing worth the alert.

* refactor(instant-mix): tidy the mix chain and its specs

Quality pass over the new code, no behaviour change:

- topUp: drop the first-vs-last error bookkeeping (the value is only read when the
  mix is empty, so the distinction is unobservable) and the redundant nil guard
  (dedupByID returns nil for an empty result, so both branches already agreed).
- mixFromAgent: assign through the if-scoped err instead of a second error name.
- Hoist the similar-artists fallback closure written verbatim in two switch arms.
- Use map[string]struct{} in the pick loop, matching dedupByID in the same file.
- Trim three comments back within budget; two restated the line below them and one
  carried commit-message rationale.
- Tests: add an ids() helper for the ID assertion repeated seven times, and fold
  the track-entity stub block copied into three specs into stubTrackEntity. The
  block hard-coded .Twice() on GetEntityByID, which pinned an implementation
  detail no spec asserts.

* revert(instant-mix): inline the similar-artists fallback closure again

Hoisting it to a shared artistFallback var moved the call away from the arm that
uses it and saved nothing: each arm reads better spelling out its own sources.
2026-08-13 18:50:34 -04:00
Deluan Quintão
6c3e7e268b
feat(instant-mix): support album, playlist and genre sources (#5948)
* feat(agents): local agent genre-hint similar songs fallback

* feat(external): playlist instant mix via seed-track sampling

* test(external): cover playlist mix never-empty fallback and maxSeeds cap

Adds coverage for the empty-match seed fallback and the maxSeeds
call cap on GetSimilarSongsByTrack, per code review finding.

* feat(external): genre instant mix via seed-track sampling

* feat(external): album instant mix falls back to AudioMuse track similarity

* feat(external): artist instant mix falls back to seed-track sampling

* fix(jellyfin): route genre seeds through instant mix instead of empty

* feat(jellyfin): add /Albums/{id}/Similar route for albumMix radio

* perf(external): bound playlist seed sampling to a random N

samplePlaylistTracks loaded an entire playlist's joined rows just to keep
5 random seeds; push the bound and randomization into the query instead,
matching the other samplers (GetRandom/GetAllByTags with Max).

Fixing this surfaced a real bug: resetSeededRandom's SEEDEDRAND rewrite
assumed every table's id is TEXT, but playlist_tracks.id is an INTEGER
position, so the random sort silently dropped every row. Cast the id to
TEXT before hashing (no-op for the other, TEXT-id tables).

Also trims a changelog-flavored comment and a duplicated rationale in
server/jellyfin/similar_test.go.

* refactor(external): parallelize seed mix and dedup mix helpers

Run the up-to-5 per-seed GetSimilarSongsByTrack calls concurrently (errgroup),
route the four container cases through a shared seedMix helper, flatten the
genre lookup, and sample playlist seeds without forcing a smart-playlist
rebuild. Share the media-file->Song mapping in the local agent.

* perf(agents): use the indexed genre filter for local similarity

Replace GetAllByTags (a json_tree scan of every media_file row) with the
media_file_tags semi-join from #5940, deriving the seed's genre tag ids
locally since they hash from (name, value).

Also carry the library id and the recording MBID on the returned songs:
the matcher resolves by id first and looks up mbz_recording_id, so the
release-track id it got before matched nothing and the local fallback
silently returned no songs.

* refactor: drop redundant MBID and fold mixFromSeeds into seedMix

The local agent returns library tracks, so the id alone resolves them in the
matcher's first phase; the MBID was never consulted. mixFromSeeds had no
caller other than seedMix.

* docs: trim redundant comments

* fix(jellyfin): adopt the GUID id codec in the merged similar routes

getSimilarAlbums still used resolveItemID/DecodeID, which #5942 replaced with
itemIDParam; its tests passed raw ids that the strict codec now rejects.

* fix(external): guard non-positive counts and blend every seed

A negative Subsonic count reached matched[:count] and panicked. The matcher
also keeps input order and stops at count, so seed-grouped results let the
first seed fill the whole mix; interleaving gives every seed a share.

Drops the duplicate playlist-track mock in favour of tests.MockPlaylistTrackRepo,
which pages like the real repository and records the query options.

* fix(external): refresh smart playlists before sampling seeds

A smart playlist materializes no playlist_tracks until it is evaluated, so
sampling without the refresh mixed an empty seed set. The refresh is a no-op
for regular playlists, inside the refresh delay, and for non-owners.

* fix(external): skip missing tracks and a nil playlist-track repo when sampling

Tracks() logs and returns a nil repository when its own lookup fails, so the
chained GetAll panicked. Seeds can also reach the mix verbatim when the agents
find nothing, so a missing file would surface as an unplayable entry.

* fix(jellyfin): never report the seed album as its own similar album

The sampled-seed fallback returns the album's own tracks, which similarAlbums
mapped straight back to the requested album, often as the only result.

* test(agents): assert the genre predicate instead of relying on the mock

MockMediaFileRepo ignores QueryOptions.Filters, so the spec passed even with
no genre filter at all. It now checks the generated predicate carries the
seed's own tag id, the indexed join and the missing exclusion.

* fix(external): clamp the requested count before it becomes a query limit

Subsonic passes the client's count through unbounded. At MaxInt64 the local
agent's count+1 overflows negative, and GetRandom omits the SQL limit unless
Max is positive, so one request would hydrate every matching track. 500 is
what the widest caller (similarAlbums, limit*5) legitimately asks for.

* fix(external): deduplicate playlist seeds by media file

A playlist can hold the same file at several positions, so sampling its rows
could seed the mix twice: a wasted agent call, and a duplicate track whenever
the seed fallback kicks in.

* fix(external): drop tracks two seeds both recommend

The matcher re-emits a track when two inputs are identical, so overlapping
recommendations took several slots in the mix. Match the whole merged set and
dedup before trimming. Playlist sampling now over-fetches before its own
dedup, so repeated positions cannot collapse the seed count.

* test(external): make the seed-blend assertion independent of the shuffle

It matched four tracks and kept two at random, so both could come from the
first seed once in six runs. Keeping three of the four makes a seed-two track
unavoidable.

* fix(external): seed artist mixes from every credited role

media_file.artist_id is the deprecated primary artist, so an artist credited
only on the album, as on compilations, sampled no seeds at all. Use the same
participant filter the artist listings use.

* refactor(external): drop the now-vestigial seed interleaving

Matching the whole merged set removed the early truncation the interleave
guarded against, and the shuffle before the trim makes input order irrelevant.
Its comment described the old behaviour.

* test(agents): give the id-mapping fixture a matching genre

The related track carried no genre, so the real query would never return it;
the spec only passed because the mock ignores QueryOptions.Filters.

* test(agents): drop the MBID from the id-mapping fixture

Local agent candidates are non-missing library rows, so the matcher always
resolves them in its id phase and never reads the MBID. The field guarded a
regression that could not change behaviour.

* test(agents): remove unnecessary comment about MBID in GetArtistTopSongs test

* fix(jellyfin): only let a not-found entity fall through in getInstantMix

Discarding the error conflated a genre id, which never resolves, with a real
lookup failure, which then made a provider call that fails the same way.

* test: pin the invariants the specs only appeared to cover

The missing filter was asserted by substring, so flipping it to true passed
everywhere, including the spec named for it. Matching the whole merged set,
the local agent's over-fetch, and its no-genres early return had no coverage
at all; each is now pinned by a spec that fails when the code is broken.

* test: make the remaining specs say what they actually guard

The playlist-track spec named a sort whitelist it does not exercise; it guards
the integer-id CAST, so it now asserts no rows are dropped. The maxSeeds cap
passed with either bound removed, and the over-fetch was pinned by its literal
value rather than the duplicate positions it exists for. Also drops setup the
count guard returns before reaching.

* fix(external): fall back when the agent's picks are not in this library

A non-empty answer whose songs are all absent locally matched nothing and was
returned as-is, so the mix came back empty with sampleable source tracks
sitting right there.

* refactor(external): name the agent-then-fallback flow once

Each entity case repeated the same error and emptiness plumbing around the
matcher. mixFromAgent states it once and each case supplies only what differs:
how to ask, and what to do when the answer is unusable.
2026-08-12 23:02:13 -04:00
Deluan Quintão
c66ef04dd3
refactor(jellyfin): emit real 128-bit GUIDs as item ids (#5942)
* test(jellyfin): use canonical ids in dto fixtures

Fixtures used short placeholder strings, which are not valid Navidrome ids. Deriving them from
id.NewHash keeps the labels readable while exercising the real id shape.

* test(jellyfin): use canonical ids in handler fixtures

Fixtures used short placeholder strings, which are not valid Navidrome ids. Deriving them from
id.NewHash keeps the labels readable while exercising the real id shape. Playlist entry positions
stay decimal, matching the integer playlist_tracks.id column.

* test(jellyfin): use canonical ids in e2e fixtures

Fixtures used short placeholder strings, which are not valid Navidrome ids. Deriving them from
id.NewHash keeps the labels readable while exercising the real id shape.

* test(jellyfin): use canonical ids in audiomuse fixtures

audiomuse_test.go passes ids as bare function args (mf(id, ...), call(query, user)) rather than
via ID: struct-literal fields, so the original grep-built file list missed it. Same conversion as
the rest of the fixtures: fake labels through id.NewHash via testID.

* test(jellyfin): convert remaining nonexistent-id sentinels in e2e tests

Reviewer swept for enc("literal") sites the brief's dto.EncodeID grep missed. These "does not
exist" fixtures must stay well-formed GUIDs under the strict codec, or the test degrades from
"resolves to nothing" to "empty path segment".

* refactor(jellyfin): emit real 128-bit GUIDs as item ids

Navidrome ids are now a canonical 22-char base62 encoding of exactly 128 bits, so they map
losslessly onto Jellyfin GUIDs. Previously the API hex-encoded the id string itself, producing
44 hex chars where Jellyfin uses 32.

Integer library ids, the synthetic playlists folder, and playlist entry positions (a
playlist_tracks.id, an integer column) aren't 128-bit values, so they get a reserved GUID space
tagged by kind. DecodeID is now strict: malformed input returns an empty string instead of
passing through unchanged.

BREAKING: Jellyfin clients see entirely new item ids.

* fix(jellyfin): 404 malformed playlist ids instead of silently creating

updatePlaylist decoded a malformed playlistId to "", the same sentinel core/playlists.Create
uses to mean "make a new playlist" — the overload createPlaylist deliberately relies on. A
malformed id now 404s before reaching Create.

Also tightens id-codec test fixtures: several tests set chi params to a raw canonical id, which
now decodes to "" and only passed because the fakes ignore the id argument; and a batch of
not-found sentinels now use well-formed-but-nonexistent GUIDs so they exercise the intended path
instead of the malformed-id path. READMEs "lossless" claim softened to note the reserved space.

* refactor(jellyfin): drop the id truncation workaround

Finamp's saved-queue packing keeps the first 16 bytes of each item id. That was lossy only
because our ids were 44 hex chars; now they are 32, so the packing round-trips exactly and the
server-side prefix recovery is dead code.

Removes an indexed range scan per restored queue and the ambiguous-prefix path that could
resolve to the wrong item.

* fix(jellyfin): emit ServerId and PlaySessionId in Jellyfin's id format

Jellyfin serializes GUIDs without dashes; ServerId was emitting the dashed UUID form. A
ServerId persisted before this change is normalized on read rather than rewritten.

PlaySessionId was emitting a raw internal id instead of the encoded form.

BREAKING: the ServerId change makes clients treat the server as new, so users re-login once.

* fix(jellyfin): 404 on undecodable id filters instead of widening the query

DecodeID collapsed an absent param and an undecodable one into the empty string, and downstream
an empty id means no filter. A client sending a stale pre-upgrade id therefore had its filter
silently dropped: ParentId, ArtistIds and AlbumArtistIds each returned the whole library instead
of a scoped result. Every existing client hits this on first launch after the id format changes.

Scalar id params now distinguish the two cases and report not-found. List-valued params already
failed closed. EncodeID logs a diagnostic when a non-empty id is not canonical, which should not
happen post-migration and would otherwise ship an unaddressable item silently.

* test(jellyfin): drop comments that restate the spec names

* refactor(jellyfin): decode reserved GUIDs from bytes, not hex strings

DecodeID already had the 16 decoded bytes, then re-derived the kind tag and payload by slicing the
hex string and parsing it a second time. Reading them off the byte slice matches how the format is
specified and removes the duplicate parse.

Bounding the payload inside encodeReserved gives both encoders the 32-char guarantee, which only
EncodePlaylistEntryID enforced before.

Playlist entries now decode through DecodePlaylistEntryID, which rejects other kinds. The tag was
being encoded and then discarded, so a song id passed as an EntryId reached RemoveTracks as a
playlist_tracks position.

Drops the per-field log.Warn from EncodeID: it sat in a leaf codec without a ctx and would emit
once per item per request on exactly the bad-data population it was meant to surface.

* refactor(jellyfin): make DecodeID report whether the id was decodable

DecodeID returned the empty string for both an absent param and an undecodable one, and
downstream an empty id means no filter. That conflation is what let a stale id widen /Items to
the whole library; it had been patched at two call sites, leaving three different policies for an
undecodable id in one package and ~16 handlers correct only because a repo Get("") happens to fail.

Returning (string, bool) makes the ambiguity unrepresentable, and the compiler forces each of the
~22 sites to decide. URL params share one itemIDParam helper that 404s; id lists go through
DecodeIDs, which is all-or-nothing because dropping bad entries would empty a list and make its
len() > 0 filter gate vanish — the original bug by another route.

A well-formed but unknown id is still 200 with zero results; only malformed ids 404. Malformed
ids now also 404 on the image and similar/instant-mix routes, which previously answered with a
placeholder or an empty list.
2026-08-12 19:02:34 -04:00
Deluan Quintão
5a4a3099f1
fix(cli): accept absolute paths in selective scan --target (#5947)
* fix(scanner): accept absolute paths in selective scan --target

The scanner's fs.FS only accepts paths relative to the library root, so an
absolute --target path (e.g. 2:/jukebox/collection) failed with an opaque
"invalid argument" error. Rebase absolute targets onto the library root
before scanning; relative paths are unchanged.

Fixes #5943

* refactor(scanner): simplify libraryRelativePath with IsLocal and slice.ToMap

* fix(scanner): make libraryRelativePath cross-platform

Windows CI failed: the tests hardcoded Unix-style absolute paths, which are
not absolute on Windows, and filepath.Rel yields backslash-separated paths
that the io/fs-based scanner FS rejects. Build the test paths with
filepath.Abs so they are absolute on every OS, and normalize the rebased
result with filepath.ToSlash.

* fix(scanner): resolve relative library root before rebasing target

filepath.Rel cannot rebase an absolute target onto a relative library root
(e.g. the default MusicFolder=./music), so an absolute --target was left
unchanged and rejected by the rooted io/fs. Make the library root absolute
first; it resolves against the same cwd as the scanner's fs.
2026-08-12 14:55:04 -04:00
Deluan Quintão
752b38609c
feat(artist): add Share and Download actions to the Artist detail page (#5944)
* feat(ui): add Share button to artist detail page

* feat(ui): add Download button to artist detail page

* fix(ui): scope artist share/download to album-artist content

Gate the artist Share/Download actions on album-artist stats and show the
album-artist size, since ZipArtist and the share query only cover
album_artist_id songs. Previously the total (role-inclusive) size was shown
and guest-only artists could produce an empty archive. Applies to the artist
toolbar, the shared context menu, and the download dialog title.

* fix: match artist download/share to album-artist participation

ZipArtist and the artist share query filtered the deprecated album_artist_id
column, which only stores the first album artist of a track. Secondary
album-artists (co-credited but not first) got an empty download/share even
though the UI offered it. Filter by the album-artist role participation
instead, matching the artist's album-artist stats used to gate the actions.
Also cover the artist-specific size branch of the download dialog.

* fix(share): scope artist shares to the owner's libraries

The artist share query broadened to album-artist participation, which could
pull a secondary album artist's tracks from libraries the (non-admin) share
owner cannot access into the public share. Load the artist share as the owner
so their library access is applied, mirroring how playlist shares already work.
Adds a repository test covering co-album-artist inclusion and library scoping.

* test(share): assert album participation branch of artist shares

Link the co-album-artist fixtures to albums and assert share.Albums (used by
Subsonic getShares) includes the accessible album and excludes the one in a
library the owner cannot access, so the album participation + scoping branch
is covered too.

* fix: exclude missing files from artist download/share actions

An artist's stats still count files that went missing, so the toolbar/context
menu could offer Download/Share for an artist whose files are all gone, while
the share query (missing=false) returns nothing and downloads open dead paths.
Hide the actions when the artist is missing and exclude missing files from
ZipArtist, matching the share semantics.

* refactor: dedupe artist download-size and share-owner lookups

Extract the 'album-artist download size (or none when missing)' rule into a
single artistDownloadSize() helper shared by the toolbar, context menu, and
download dialog, and factor the duplicated share-owner context lookup into a
shareRepository.ownerContext() method used by both the artist and playlist
share cases.

* refactor(ui): move artistDownloadSize helper to common

utils is for domain-agnostic, potentially portable code; this helper is
Navidrome-specific (artist stats shape), so it belongs in common. Consumers
import it directly from common/artist to avoid pulling in the common barrel.
2026-08-12 11:59:56 -04:00
Deluan
8978c7b9fa Revert "feat(jellyfin): send a synthetic placeholder blurhash for unresolved artwork (#5941)"
This reverts commit 036c9cab96.
2026-08-12 11:46:23 -04:00
Deluan Quintão
036c9cab96
feat(jellyfin): send a synthetic placeholder blurhash for unresolved artwork (#5941)
* refactor(artwork): let callers pin the blurhash component counts

* feat(artwork): synthesize a unique placeholder blurhash from a seed

* fix(artwork): base the synthetic-hash no-collision guarantee on the prefix, not length

The prior comment and test claimed a synthetic value could never collide with a
real one because it's always shorter. That's false: components() targets ~16
tiles by scaling one axis down as the other hits the 9 cap, so an extreme
aspect ratio (e.g. 10x200) collapses to 1x9 = 9 components, which encodes to
the same 22 characters as a 3x3 synthetic hash. The old test only exercised a
square 64x64 gradient, so it never caught this.

The real guarantee is structural, not length-based: the first character encodes
shape as (xComp-1)+(yComp-1)*9, and components() derives xf*yf = 16 exactly
before flooring/capping (xf = sqrt(16w/h), yf = xf*h/w = sqrt(16h/w), so
xf*yf = sqrt(256) = 16). Two factors both in [2,3) can't multiply to 16, so
Encode can never derive 3x3 - the synthetic prefix 'K' is structurally
exclusive to Synthetic. Replaced the length-based test with one that sweeps
extreme aspect ratios and asserts no real encode ever produces prefix 'K',
alongside the assertion that Synthetic always does.

* feat(artwork): tint a synthetic blurhash from a base colour

Parses baseColor into HSL and clamps saturation/lightness so the tint
stays muted; per-cell hashing (unchanged) is what keeps a shared tint
across an album's tracks from colliding, as the new 1M-seed spec
proves under a single fixed colour.

* fix(artwork): trim over-long comment in synthetic blurhash test

Review flagged the collision spec's comment for exceeding the 2-line
budget; the Finamp rationale it restated already lives in the design
doc and commit history.

* feat(jellyfin): send a synthetic blurhash for unresolved artwork

Clients render nothing where a placeholder belongs when ImageBlurHashes
is omitted for pending artwork. primaryImage now synthesizes a value
(seeded on the tag, so a cover swap re-keys it) whenever a tag is
present but no blurhash has been computed yet. Known-absent artwork
(ImageAbsent) is unaffected: it still emits neither tag nor blurhash,
since GetOrPlaceholder would otherwise pin a shared placeholder under a
distinct cache key for a year.

* fix(jellyfin): avoid computing a synthetic blurhash when a real one exists

cmp.Or evaluates both arguments before choosing between them, so
blurhash.Synthetic ran (and was discarded) on every call even when
img.BlurHash was already set. That's the common case in production:
artwork_repository.go populates BlurHash from persisted values once a
scan resolves it, so a healthy library paid the synthesis cost (xxh3
hashing, HSL conversion, image alloc, DCT encode) on every mapped item
for a value it never used. Branch on emptiness first instead.

* feat(jellyfin): tint a pending track's placeholder with its album's colour

primaryImage never reaches the embeddedArtPending branch of
SongToBaseItem, since there is no resolved image yet to feed it. Seed
the synthetic blurhash on mf.ID (so the value stays unique and the
client still issues the read-through request) but tint it with the
album's DominantColor, which is already hydrated on MediaFile at no
extra cost.

* style(artwork): trim synthetic blurhash comments to the budget

* docs(jellyfin): fix stale blurhash README bullet + two review nits

The "Blurhashes are synthetic" bullet under Known limitations described
dto/blurhash.go, which was deleted when the real core/artwork-computed
blurhash + synthetic-fallback pipeline landed; every claim in it was
false. Replaced it with an accurate paragraph in the Images section,
since the described behaviour is now the finished design, not a gap.

Also: fix a doc/body comment mismatch in blurhash.go (component counts
are 2..9, not 1..9), and deduplicate the inline DC-extraction logic in
synthetic_test.go by reusing the existing dcOf helper.

* refactor(artwork): slice the synthetic cell jitter on byte boundaries

The three perturbations came off one hash with mismatched masks and shifts
(0xFF at 0, 0x3F at 8, 0x3F at 14), so a reader had to do the arithmetic to
confirm the fields did not overlap. Only 20 of the 64 bits were in use either
way, so the narrower fields bought nothing.

Uniform byte slices at 0/8/16 are non-overlapping by inspection and give each
field the full 8 bits. Both 1,000,000-seed collision specs still measure
1,000,000 distinct values. Also drops the local `n` alias, which was a second
name for synthComponents inside a 15-line function.

* docs(jellyfin): fix the blurhash paragraph's opening sentence

It opened with "follow the same principle", pointing back at the preceding
paragraph on admin-context artwork resolution — an unrelated subject, so the
reader looks for a connection that is not there.

* fix(artwork): render the synthetic grid larger than its component count

The 3x3 cell grid was handed straight to the encoder as a 3x3 image, so the
source had exactly as many samples as basis functions. Blurhash normalises its
coefficients by 1/(w*h) and 2/(w*h), which assumes many samples per component,
so the AC terms came out far too large. At the bottom-right corner the x and y
bases are both [1, -0.5, -0.5], everything lines up negative, and the result
clamped to black — a dark blob on every synthetic placeholder.

Rendering the same nine colours bilinearly at 8x8 first removes it: measured
over three seeds, the darkest corner goes from 13 to 74 and the darkest pixel
from 1 to 63. 8px is the smallest size that clears the artefact; 12 and 16 are
visually indistinguishable and cost 1.7x and 2.7x more.

The interpolation is hand-rolled rather than x/image's scaler, which allocated
528 times per call against 14 for this. Both 1,000,000-seed collision specs
still measure 1,000,000 distinct values.

* refactor(artwork): tidy the synthetic upscale helpers

cellWeight nudged its upper bound with a 1e-9 epsilon so int() could never
land on the last cell. Clamping the coordinate and then the index says the
same thing without a magic constant, and makes the clamp-don't-extrapolate
intent explicit — edge pixels map outside the cell centres, so the fraction
would otherwise run past 1.

The grid type is spelled once as colorGrid rather than repeated in the local
and the upscale signature, and encodeAt's doc now states the source-size
contract that Synthetic depends on, so the next caller sees it at the
function rather than only in synthetic.go.

Output is unchanged: all seven sample hashes match byte for byte.

* perf(artwork): make the synthetic upscale separable

Both axes are square and constant-sized, so the per-pixel cell index and
weight were the same 8 values recomputed 64 times per call. They are now
built once by sync.OnceValue, matching the srgbToLinearTable pattern.

The interpolation is also separable: stretching each of the 3 grid rows
horizontally once and then blending rows vertically does 264 lerps where
the per-pixel form did 576.

upscale drops from 347ns to 260ns, Synthetic from 1780ns to 1669ns. Output
is byte-identical across all seven sample hashes — same operations in the
same order, only hoisted.

* refactor(artwork): let a caller supply the cosine basis

encodeAt built its cosine tables inline, so Synthetic rebuilt bit-identical
ones on every call — its shape is always 3 components over 8 pixels. Splitting
the table construction into cosBasis and the encoder proper into encodePixels
lets Synthetic build the basis once via sync.OnceValue, and leaves encodeAt's
signature and behaviour untouched.

Synthetic drops from 14 allocations to 6 (1669ns to 1508ns). The time saving
is small and this path only runs while artwork is still unresolved; the
allocation cut is the point, alongside a shorter encodeAt.

Every hash is unchanged: nine real Encode outputs spanning square, 10x200,
200x10, 1x50 and a real JPEG, plus all seven synthetic samples, all byte for
byte identical before and after.
2026-08-11 18:34:27 -04:00
Deluan Quintão
9e95b19a4f
feat(artwork): make the artwork image size cap configurable (#5931)
* feat(artwork): make the artwork image size cap configurable

Replace the hardcoded 20MB cap on resolved image reads with a new
MaxImageSize config option. Load floors it at MaxImageUploadSize so an
accepted upload can never be too large for the resolver to read back.

* fix(conf): reject zero-valued byte-size options at startup

ParseBytes accepts "0", but parseSize silently substitutes the default
for it, so the accepted config would differ from the effective limit.

* fix(conf): reject byte-size options that overflow int64

A raw value above math.MaxInt64 parses as a valid uint64 but wraps to a
negative int64 in parseSize, giving readCapped a non-positive LimitReader
bound so every artwork read comes back empty.
2026-08-11 10:42:39 -04:00
Deluan
c4126fa674 fix(jellyfin): emit SortName for artists and albums, honoring PreferSortTags
Finamp's A-Z fast scroll re-derives each item's sort key client-side from
SortName, paging until it reaches the tapped letter. We only emitted SortName
for songs, so Finamp fell back to reconstructing a key from the display name,
which diverges from the order_* key the list is actually sorted by (curly
quotes, non-English articles). The scan then believed it had passed the
letter and scrolled back to the top instead of loading further pages.

Emit SortName for artists and albums using the same key the persistence
layer sorts by, via a sortName helper that mirrors Subsonic's PreferSortTags
handling: order_* names by default, sort tags when the config is enabled.
The song mapper now honors the config too, instead of always preferring the
sort tag. Also parse Fields in the /Artists* handlers, which ignored the
parameter entirely, so no field-gated data could ever be returned there.
2026-08-11 09:19:16 -04:00
Deluan Quintão
95b8d9dd04
perf(genre): index genre filtering via join tables across all APIs (#5940)
Filtering by genre scanned every media_file/album row and JSON-parsed its
`tags` column (a per-row json_tree(tags) EXISTS) with no usable index, so
Finamp's genre screen took 1.9-6.5s per tap against a ~97k-track library.
Album and album-artist genre queries had the same unindexed shape.

Add normalized media_file_tags and album_tags join tables (genre only for
now, via an indexedTagNames allowlist), populated by a new updateTags in
Put (mirroring updateParticipants) and backfilled in the migration. Genre
filtering across the Jellyfin, Subsonic and native APIs now runs as an
index-backed semi-join through shared TagIDSemiJoin/TagNameSemiJoin helpers
instead of a full scan. On a copy of the production DB the per-request cost
for a typical genre drops from ~330ms to sub-millisecond, adding ~10MB.
2026-08-11 08:00:50 -04:00
Deluan
8e0ff1a235 fix(jellyfin): resolve genre id as a MusicGenre item
The Jellyfin /Items/{id} endpoint resolved albums, artists, songs and
playlists by id but not genres, so a genre id returned 404. Finamp's
genre "See all" fetches the genre as the track list's parent item, and
that 404 crashed its screen to a blank page after the tracks flashed in.

Add a Genre lookup to resolveItemByID (backed by a new GenreRepository.Get)
so a genre id returns its MusicGenre BaseItemDto, matching real Jellyfin.
2026-08-10 21:37:57 -04:00
Deluan Quintão
d080707060
fix(jellyfin): return a mixed, globally-limited list for multi-type /Items requests (#5935)
* feat(jellyfin): allow random sort for artist/genre/playlist item types

* feat(jellyfin): add round-robin interleave helper for merged item types

* fix(jellyfin): mix and globally limit multi-type Items requests

Run per-type queries in parallel and round-robin interleave the results so a
request for multiple IncludeItemTypes (e.g. Finamp's random favorite) returns a
mixed, globally-limited page instead of one type's rows followed by the next.

* fix(jellyfin): dedupe repeated IncludeItemTypes to avoid duplicate items and redundant queries

* refactor(jellyfin): dedupe via slice.Unique and extract queryTypeWindow helper

* perf(jellyfin): serve random multi-type pages from offset 0

A random merge reshuffles every request, so paginating it is meaningless — page N
is just another fresh draw (as in real Jellyfin). Serving from offset 0 caps the
per-type fetch at limit instead of offset+limit, avoiding deep-offset blow-up for
the random case (Finamp's random-favorite quick action).

* refactor(jellyfin): resolve random-merge via applySort; simplify merge signatures

Detect the random-page shortcut by resolving each type's sort through applySort
(matching how the sort is actually chosen) instead of string-matching SortBy, and
only when every type is random. Drop the always-zero window param from
mergeTypesStreaming and derive the window inside mergeTypesPaged.
2026-08-10 21:21:44 -04:00
Deluan Quintão
7736bbb545
fix(cache): write the completion marker before closing the cache writer (#5927)
* fix(cache): write the completion marker before closing the cache writer

Readers of an in-progress cache write see EOF the moment the writer closes,
but the .complete marker was created after the close, on the background
goroutine — so a fully-read stream did not mean the cache was done touching
disk. The new artwork precache spec ends right at EOF, and its
GinkgoT().TempDir() cleanup raced the marker creation, failing the Windows CI
job with 'unlinkat ...: The directory is not empty' (the race also reproduces
on macOS, 2 of 3 runs, with the tightened test).

Writing the marker after a clean copy but before Close makes reader-EOF imply
every on-disk write for the entry is finished. A failed writer Close still
invalidates the entry, which removes both the marker and the data file. The
existing marker test now asserts the marker exists immediately at EOF instead
of Eventually.

* fix(artwork): never dispatch queue items after the drain context is cancelled

The 10x Windows stress run for the previous commit surfaced a second flake in
the same package: 'leaves undispatched items queued when cancelled mid-batch'
lost row alc7 in 4 of 10 runs. In drain, when a semaphore slot is free and the
context is already cancelled, both cases of the blocking select are ready and
Go picks one at random — so a cancelled drain could still dispatch items. A
non-blocking Done check before the select gives cancellation priority.

The race was invisible on Linux/macOS only by accident: the spec seeded the
album repo with a single album (each SetData overwrote the last), so only the
final row (alc7) resolved to absent and got deleted when dispatched; the
others fell on the retry path and survived. Nanosecond enqueue timestamps
made alc0 always first out of the mock dequeue, masking the race, while
Windows' coarse clock ties the timestamps and randomizes the order. The spec
now seeds all eight albums, which made the race reproduce locally on the
first try (row alc0) and now guards the fix on every platform.

* test: give cache-init waits a 10s timeout for loaded CI runners

A 10x parallel Windows stress run timed out one artwork spec in BeforeEach:
the FileCache init goroutine (mkdir + reload walk) took over Gomega's default
1s Eventually timeout under shared-runner disk contention. Bump the three
identical init waits (two artwork suites and the utils/cache helper) to 10s.

* test(scanner): widen watcher debounce margins for loaded CI runners

The watcher debouncing spec asserts 'no scan yet' inside 20ms Consistently
windows while the debounce wait was only 50ms — a 2.5x margin that a loaded
Windows runner blows through by delaying the timer-reset notification, firing
the scan early (failed all three FlakeAttempts in a 10x stress run). Raise the
test debounce wait to 200ms (10x the observation windows) and the scan-fired
Eventually timeouts to 2s to match.

* refactor(artwork): collapse drain cancellation into a single exit path

Replace the non-blocking ctx pre-check plus duplicated select exit with one
select and a ctx.Err() check after it. Besides removing the duplication, this
closes the residual race: a cancellation landing between the two selects could
still let the blocking select randomly pick the free semaphore slot and
dispatch the item. Now a dispatch is only possible when the context was live
after slot acquisition.

* style: trim flaky-test fix comments to single lines

Compress each two-line comment added by this PR to the one line that carries
the invariant; drop the narration around it.
2026-08-10 14:10:05 -04:00
Deluan Quintão
7993fb9158
perf(persistence): use *_artists join tables for artist participant filters (#5930)
* perf(persistence): use *_artists join tables for artist participant filters

The artist_id/artists_id, role_<role>_id and role_total_id filters, plus the
AlbumsByArtistID/AlbumsByContributingArtistID/SongsByArtistID helpers, scanned
every album or media_file row through json_tree(participants, ...), which no
index can serve — the cause of multi-second artist pages on large libraries
(discussion #5929). Rewrite them to semi-join the album_artists and
media_file_artists tables via a shared ParticipantIDFilter helper. The join
tables are written in the same transaction as the participants JSON, so
results are unchanged.

album_artists' unique constraint led with album_id, so artist-driven lookups
had no usable index. Rebuild the table with the constraint reordered to
(artist_id, album_id, role, sub_role), mirroring media_file_artists, instead
of adding a fourth index: measured within 4% of a dedicated covering index
(geomean -92.5% vs json_tree on a 96k-track production copy) while saving
~7MiB and per-scan write amplification. Album-side consumers (participant
rewrites, FK cascades, markMissing) keep using album_artists_album_id, and
updateParticipants' ON CONFLICT target already names artist_id first. The
rebuild is linear work: 1.1s on a 113k-row production copy.

* chore(gitignore): add temp benchmark files to ignore list

* fix(persistence): clear album_artists when an album is saved without participants

albumRepository.Put skipped updateParticipants when the Participants map was empty, so a hypothetical save with no participants would write {} to the JSON column but leave stale album_artists rows behind, now visible through the semi-join filters. No current caller can hit this (albums built by MediaFiles.ToAlbum always have participants), but make Put unconditional anyway, matching mediaFileRepository.Put, so the join table always moves with the JSON. Raised by Codex review on #5930.
2026-08-10 11:42:27 -04:00