Compare commits

..
Author SHA1 Message Date
bvandeusen 324059b2bd Merge pull request 'Discover request surface — taste-aware, rotating, snoozable, tag-targeted (milestone #268)' (#116) from dev into main
test-web / test (push) Successful in 1m5s
test-go / test (push) Successful in 1m30s
android / Build + lint + test (push) Successful in 5m1s
test-go / integration (push) Successful in 5m29s
release / Build signed APK (tag releases only) (push) Successful in 4m21s
release / Build + push container image (push) Successful in 17s
2026-08-03 08:38:24 -04:00
bvandeusenandClaude Opus 5 eec59193fa feat(discover): explain the taste match on both clients — #2377 (clients)
test-web / test (push) Successful in 33s
android / Build + lint + test (push) Successful in 3m57s
"Matches your taste in shoegaze and dream pop." replaces the seed
attribution when the candidate's own tags overlap the taste profile.

The preference order is the point of slice 6: the tag reason describes the
MUSIC ("sounds like what you like"), while seed attribution describes the
graph ("adjacent to something you played"). When we can say the former, it
is strictly the better explanation. When we can't — the common case, since
tag coverage for out-of-library artists is partial by nature (#2376) — the
card falls back to attribution rather than going blank.

Both clients share the wording, Oxford comma included, and both have tests
asserting the exact strings. That's deliberate: identical copy across two
codebases silently diverges unless something fails when it does.

Android caps at 3 tags client-side even though the server already does.
The server contract could widen; a run-on subtitle shouldn't be how we
find out.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 23:51:27 -04:00
bvandeusenandClaude Opus 5 ca4832e620 feat(discover): Discover tuning card on the admin lab — #2377 (web admin)
test-web / test (push) Successful in 35s
Rule #25/#27: the two knobs slice 6 added server-side are now touchable —
taste-tag weight and snooze length, with deviation dots, save, and reset,
matching the existing profile/taste cards.

Copy states what each knob does AND what it doesn't: the tag-weight hint
says 0 turns the term off and that an untagged candidate is never
penalised, and the snooze hint says it records no opinion about the artist
and never feeds the taste profile. Those are the two properties most likely
to be assumed backwards by whoever turns these next.

Also fixed a latent fragility the new card exposed rather than caused: all
three reset buttons had the accessible name "Reset to defaults", so the
existing test picked the LAST one and assumed that meant taste. Adding a
card below it would have silently retargeted that assertion at the wrong
scope. Each reset button now names its scope — better for screen readers
too, since three identical buttons on one page is a real a11y defect — and
the test selects by name instead of position.

The page's test fixture needed the new `discover` key in both `snapshot`
and `shipped`: the `as TuningSnapshot` cast means a missing field is not a
compile error, it's every test on the page throwing inside fillForm. Noted
that in the fixture so the next scope doesn't rediscover it.

Includes a test that a weight of 0 is actually SENT rather than dropped as
falsy — the off switch is the one value a truthiness bug would eat.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 23:49:17 -04:00
bvandeusenandClaude Opus 5 cf0d37bf8e fix(discover): compare against a baseline run, not a hardcoded score — #2377
test-go / test (push) Successful in 56s
test-go / integration (push) Successful in 4m52s
TestSuggestArtists_UntaggedCandidateSurvivesAlongsideTagged asserted the
untagged candidate's score was 0.9 — the raw similarity value I'd seeded.
It's actually 1.61, because the pool score is signal-weighted by the seed
query: ln(1+signal) x similarity, and a liked seed carries signal 5, so
ln(6) x 0.9.

The assertion was testing the seeding arithmetic, which is a different
layer and not what the test is about. Rewritten to run the same request
twice — once with the tag term disabled, once enabled — and assert the
untagged candidate's score is IDENTICAL across both. That states the real
property (the blend leaves untagged candidates alone) without depending on
how the pool score is derived, so it survives future changes to seeding.

Added a sanity assertion that the TAGGED candidate's score did move, so
the comparison can't pass by both runs being trivially identical — the
same "a test that cannot fail" trap recorded for this milestone.

Exact-preservation at the arithmetic level is already covered where it
belongs, by TestApplyTagOverlap_UntaggedCandidateScoreIsUnchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 23:45:48 -04:00
bvandeusenandClaude Opus 5 799dab029a feat(discover): rank suggestions by taste-tag overlap — #2377 (server)
test-go / test (push) Successful in 1m0s
test-go / integration (push) Failing after 4m55s
The payoff slice. Until now a candidate's only claim on a slot was "some
artist you play is adjacent to it in a similarity graph" — a fact that says
nothing about whether the music sounds like anything you like. Now the
candidate's own folksonomy tags (cached by slice 5) are compared against
the user's taste-profile tags, so the deck ranks on taste and can say WHY.

The blend is MULTIPLICATIVE — score × (1 + weight × overlap) — and that
choice carries the whole safety argument:

  - An untagged candidate has overlap 0, so its score is EXACTLY unchanged.
    Tag coverage is permanently partial (#2376); it must cost a candidate
    nothing, not sink it (rule #131).
  - Nothing can leapfrog on tags alone. An additive term with a large
    weight would let a near-zero-similarity artist outrank a strong match
    for sharing one popular tag, which reads as noise.
  - Weight 0 restores pure similarity order bit-for-bit, so the operator's
    knob has a real off position.

overlap = Σ(shared) candWeight × normalizedTasteWeight ÷ Σ(all) candWeight.
Normalizing the taste side by the user's strongest tag makes the score
comparable across users (taste weights accumulate with listening, so a
heavy listener's raw numbers dwarf a new user's while meaning the same
thing). Dividing by the candidate's own mass makes it comparable across
candidates, so a densely-tagged artist can't win on tag count alone.

Applied to the whole over-fetched pool BEFORE selectSuggestions, so the
rotation and diversity rules operate on blended scores — boosting only the
twelve already chosen by similarity would leave the re-ranking undone.

A query failure is returned, NOT degraded past. Graceful degradation is
for expected absence (no taste profile, no cached tags) and both are
handled explicitly as empty inputs; swallowing a real error would hide a
broken DB behind a subtly worse ranking that nothing reports.

Migration 0051 adds a FOURTH tuning scope rather than columns on
taste_tuning, because snooze_days lives here too and a snooze must never
be read as taste signal (#2374) — filing it under 'taste' would put it one
careless join from the leak that design forbids. Expanding
recommendation_tuning_audit's CHECK is in the same migration per rule #36,
and a test asserts the audit row lands, which is what would catch its
absence.

snooze_days moves out of a Go constant onto the tuning card (rule #25),
closing the deferral from #2374.

Tag-overlap tests use deliberately SKEWED fixtures: an evenly-matching pool
cannot exercise a re-ranking, since every candidate gets the same
multiplier and the order is unchanged whether the blend works or not.

Admin UI + client attribution follow in this batch — rule #27.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 20:31:12 -04:00
bvandeusenandClaude Opus 5 7315e37c15 fix(db): apply sqlc's actual output for candidate_artist_tags — #2376
test-go / test (push) Successful in 58s
test-go / integration (push) Successful in 4m53s
Three divergences in the hand-written generated file, all caught by
verify-generate on the first run. Two are sqlc rules I had wrong:

1. When a query's SELECT list exactly matches a table's columns in order,
   sqlc REUSES the model struct rather than emitting a bespoke Row type.
   So ListCandidateArtistTagsForMbids returns []CandidateArtistTag, and
   ListCandidateArtistTagsForMbidsRow should never have existed.

2. models.go is ordered by GO STRUCT NAME, not table name. Table order
   would put candidate_artist_tag_state before candidate_artist_tags;
   sqlc emits CandidateArtistTag before CandidateArtistTagState. The
   earlier slice-3 observation ("ordered by table name") was consistent
   with both orderings and so never discriminated — this case does.

3. sqlc smart-quotes a doubled '' inside a promoted comment into a
   typographic ”. Reworded the prose to say "the empty string" instead of
   encoding a mangling into the source.

Note the integration lane PASSED on the broken push while this failed.
That is #2380's lesson landing again, and the reason the check exists:
valid SQL executing against real Postgres proves nothing about whether
the committed Go matches its source.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 20:11:03 -04:00
bvandeusenandClaude Opus 5 4f9b083eec feat(discover): artist-tag cache for out-of-library candidates — #2376
test-go / test (push) Failing after 32s
test-go / integration (push) Successful in 4m50s
Migration 0050 adds candidate_artist_tags + candidate_artist_tag_state:
folksonomy tags for artists NOT in the library, which track_tags cannot
hold because it's FK'd to tracks(id) and a Discover candidate has no local
row. Slice 6 ranks against these; this slice only fills the cache.

The reuse the task claimed is real and verified: MusicBrainz's
fetchEntityTags(ctx, "artist", mbid, scale) already existed for the #1519
recording→artist fallback, so FetchArtistTags is a thin wrapper. Two
subtleties it does NOT inherit:

  - Weight scale is 1.0, not artistTagWeightFactor (0.6). That discount
    exists because FetchTrackTags uses artist tags as a *proxy* for a
    track's; here the artist IS the subject. Applying it would make these
    weights incomparable with track_tags — exactly the comparison slice 6
    depends on. Pinned by a test.
  - fetchEntityTags reports existing-but-untagged as (empty, nil) so the
    track path can fall through. There's no next level here, so empty
    becomes the terminal ErrNotFound; otherwise the enricher would settle
    a candidate as "enriched" with zero tags.

ArtistTagProvider is the split TrackTagProvider's own doc comment
anticipated ("e.g. artist-level tags"). Last.fm gains artist.getTopTags,
which returns the same toptags envelope, so the response type and
normalizer are reused unchanged.

Rather than write the merge-and-classify loop twice, extracted it from
EnrichTrack into runChain(). The ErrNotFound-vs-transient split is the
load-bearing part — those lead to opposite persistence decisions — so it
now has direct unit tests it never had while inlined.

Bookkeeping is a separate table, not columns, because the "providers had
nothing" outcome must be recordable for a candidate with zero tag rows,
and there is no per-candidate row to hang columns off (
artist_similarity_unmatched holds many rows per candidate). Absence of a
state row means "never processed", so a transient failure writes nothing
and stays eligible.

Two capacity realities are designed for, not papered over:
  - The pool is O(library artists x neighbours) and MusicBrainz allows
    ~1 req/s, so it can never drain in one pass. The eligibility query
    returns candidates in descending summed-similarity order, so the ones
    that can actually reach a deck are enriched first.
  - candidateBatch (50) is smaller than the track batch (200): tracks are
    finite and drain to completion, candidates are effectively unbounded
    and would otherwise starve the track arm forever.

GC sweeps both tables — the similarity feed churns, and a candidate that
joins the library has its tags in track_tags now. Tags swept before state
so a mid-sweep crash leaves a valid state, not a re-fetch loop.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 20:04:15 -04:00
bvandeusenandClaude Opus 5 f17356560d fix(discover): "in about a month" was unreachable in both clients — #2375
test-web / test (push) Successful in 48s
android / Build + lint + test (push) Successful in 7m42s
The days→months threshold (45) sat above the divisor (30), so a rounded
month count of 1 — which needs 15..44 days — could never be reached: every
one of those day counts hit the `in N days` branch first. The singular
branch was dead code on Android AND web.

Lowered the threshold to 30 in both clients, which makes 30..44 days read
"in about a month" instead of "in 44 days", and documented the invariant
(threshold must not exceed the divisor) next to each constant so the two
can't drift apart again.

Found by the unit test written for that branch, which is the whole reason
to assert on copy that looks obviously correct. Both suites now pin the
seam from both sides — 29 days and 30 days — so the branch can't go dead
again silently.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 19:19:05 -04:00
bvandeusenandClaude Opus 5 18a61f1065 fix(discover): complete the page-test mock + drop a return from returnsIn — #2375
test-web / test (push) Successful in 48s
android / Build + lint + test (push) Failing after 6m20s
Two CI failures from 6e39471a, both mechanical.

web: src/routes/discover/discover.test.ts mocks $lib/api/suggestions with
a factory, and SuggestionFeed now imports createSnoozesQuery from it. A
factory-shaped module mock must export everything the component tree
imports or rendering throws before any assertion runs — so all 12 of that
suite's tests failed on a surface they don't even exercise. Stubbed the
three new exports and defaulted the snooze query to empty, which keeps
the feed's empty-state copy on the "no signal yet" branch those tests
assert. (Same shape as Scribe #2109: when a shared component grows a
dependency, the break is in unrelated fixtures, not assertions.)

android: detekt ReturnCount — returnsIn had 3 returns against a limit of
2. Folded the two "nothing to state" guards into one by computing the
remaining duration as a nullable up front.

The Android compile and unit tests never ran on the last push: detekt
gates them, so Lucide.Clock is still unproven.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 19:09:24 -04:00
bvandeusenandClaude Opus 5 6e39471a70 feat(discover): snooze affordance on Android + web suggestion cards — #2375
test-web / test (push) Failing after 37s
android / Build + lint + test (push) Failing after 1m42s
Completes the snooze from slice 3 (#2374), so it's now touchable on both
clients (rule #27 — the server side alone was never shippable).

Copy is "Not right now" everywhere, never a dislike (rule #101). The
parked list even says so out loud: "Nothing here counts against your
taste profile."

Both clients flip the card in place to a "Not right now" state with an
Undo, rather than yanking it out of the grid under the cursor. The row
leaves on the next refetch; the persistent way back is a parked-list
section below the deck. That list isn't optional garnish — a snoozed
candidate is by definition absent from the deck, so without it the
DELETE endpoint is unreachable.

Android routes the write through the offline MutationQueue per rule #100,
as ONE toggle kind (SUGGESTION_SNOOZE_TOGGLE) carrying the desired state
rather than two action kinds. That reuses the LIKE_TOGGLE collapse: a
queued snooze the user has since undone is dropped unsent instead of
replaying after the undo and re-hiding an artist they asked to see. The
collapse helper is now a pure top-level function so that rule is unit
tested rather than inferred.

The repository does NOT enqueue on a 4xx — a permanent rejection would
replay to the same failure and would raise a misleading "will sync when
online" hint. The common case is a 404 from un-snoozing a row that
already lapsed, which is the user's intended end state anyway.

Also: an empty deck used to have one meaning (no listening signal yet).
It can now also mean "you parked them all", so the empty copy branches —
telling that user to go listen to something would be wrong advice.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 19:03:10 -04:00
bvandeusenandClaude Opus 5 86af79bd2f feat(discover): time-boxed suggestion snooze, server side — #2374
test-go / test (push) Successful in 1m20s
test-go / integration (push) Successful in 5m1s
Migration 0049 adds suggestion_snoozes(user_id, candidate_mbid,
candidate_name, snoozed_until), and SuggestArtistsForUser excludes rows
whose snooze hasn't expired.

This is NOT a dislike. Rule #101 forbids a "Not for me" / thumbs-down
UI; a snooze is the approved shape instead because it records no verdict
on the music, expires on its own (~90d), and never reaches the taste
profile. It's acquisition triage — "not right now" — so the filter sits
at the candidate stage rather than in the score, where it would become a
ranking signal by the back door.

Per-user throughout (rule #47): one household member parking a candidate
leaves everyone else's deck untouched.

candidate_name is denormalized because suggestions are out-of-library by
definition — there is no artists row to resolve a display name from, and
the un-snooze list has to show something. That list is why GET
/discover/snoozes exists at all: a parked candidate is by definition
absent from the deck, so without it the DELETE would be unreachable.

Also fixes a hole in the codegen check from #2380: `git diff` ignores
untracked paths, so a brand-new generated file would have passed it
silently. `git add -N` first. This commit is the first to add one.

Endpoints:
  POST   /api/discover/suggestions/{mbid}/snooze  (body: name, days)
  DELETE /api/discover/suggestions/{mbid}/snooze
  GET    /api/discover/snoozes

UI lands in slice 4 (#2375) before any of this merges — rule #27.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 22:51:51 -04:00
bvandeusenandClaude Opus 5 e006de5d4b fix(db): apply sqlc's actual output for SuggestArtistsForUser — #2380
test-go / test (push) Successful in 1m2s
test-go / integration (push) Successful in 4m55s
The new codegen check failed on its first run, against the slice-1 hand-edit,
which is precisely why it landed on its own commit.

What I got wrong: sqlc does not embed the leading `--` header block in the SQL
const. It strips those lines and promotes them to the generated method's Go doc
comment, gofmt-formatted — blank `//` separators around the indented list, tabs
for the indent. My hand-edit left the header inside the string AND left the
stale M5c doc comment sitting on the function, so the generated file described
behaviour the query no longer had.

Comments *inside* the statement body are kept as-is; only the header block moves.
Worth knowing before slices 5 and 6 add more queries.

Taken verbatim from the diff the check printed, which is the reason it prints
before asserting. Round-trip cost: one CI run, no guessing.

Note the integration lane passed on the previous push even with the wrong
generated file — the SQL text was valid and the signature was unchanged, so
executing it against real Postgres proved nothing about whether the committed
Go matched its source. That gap is exactly what #2380 closes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 22:23:35 -04:00
bvandeusenandClaude Opus 5 94e2cac03b ci(go): verify committed sqlc output matches its .sql sources — #2380
test-go / test (push) Failing after 43s
test-go / integration (push) Successful in 4m55s
internal/db/dbq is 39 files and ~12k lines of generated Go covering 307
queries, and nothing checked that it still matched internal/db/queries.
test-go.yml referenced sqlc.yaml only as a path trigger; sqlc never ran. So a
hand-edit, a half-applied regen, or a migration changed without a regen would
all pass CI while the typed layer quietly lied about the SQL underneath it —
which is the single thing adopting sqlc is supposed to buy.

This session's slice-1 change is an instance: its SQL const was verified
byte-identical against its own .sql source by script, but never against what
sqlc would actually emit. Nothing in the repo could have told the difference.

make verify-generate runs ahead of vet/lint/test, because if the typed layer
disagrees with its sources then everything downstream is testing a lie.

generate-go runs sqlc as a Go tool rather than a container: the ci-go image
already has Go, so this avoids docker-in-docker on the runner. It's pinned to
the same SQLC_VERSION as the existing containerised `generate`, so both routes
emit identical output and there is one version to bump — now annotated for
Renovate per rule #44.

The diff prints BEFORE the exit-code check on purpose. On failure the log then
holds sqlc's exact expected output, so correcting it is a copy rather than a
guess. That is also what makes new queries workable without installing
anything: this workstation has neither Go nor sqlc.

Makefile joins the workflow's paths:. Without it a Makefile-only change —
including this one — would not trigger the workflow that now depends on it.
Same class as #2204, where CI never ran on plugin/** changes.

Landing this on its own, ahead of slice 3, so that if it fails it is
unambiguous whether the drift came from slice 1 or from new code.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 22:16:45 -04:00
bvandeusenandClaude Opus 5 b27029f674 feat(discover): rotate the suggestion deck daily + cap one seed's share — #2373
test-go / test (push) Successful in 28s
test-go / integration (push) Successful in 4m54s
Second half of the reported symptom: suggestions "show the same artists until
you request one". The ranking was `ORDER BY total_score DESC` with no
randomization and no seen-state, so the only things that could ever change the
deck were a candidate entering the library or the user filing a request. The
tail of the ranking was unreachable — requesting was literally the only lever.

No SQL change was needed. The query already takes a limit, so it over-fetches a
pool (4x the slots, capped at 60) and the selection moves to Go, where it is a
pure function of (pool, limit, day) — no DB, no clock — and therefore unit
testable in the fast lane instead of behind the integration gate.

Three rules. The best few by score always lead, so the strongest matches never
rotate out of sight (For You's head/tail shape). The remaining slots are drawn
by md5(mbid + day), the same daily-stable idiom the Home rows already use:
stable within a day so pull-to-refresh doesn't reshuffle, different tomorrow,
and no stored state. And a per-seed cap keeps roughly a quarter of the deck
attributable to any one seed artist, so twelve neighbours of a single artist
can't be the whole surface.

The cap is a preference, not a quota. A user whose pool hangs off one or two
seeds would otherwise get a three-card surface — worse than the monoculture
being avoided, and exactly the vanish-or-nothing shape rule #131 exists to
prevent — so a short deck tops up in score order from what the cap set aside.
This is also what keeps the existing Top12Cap integration test honest: its
30 candidates share one seed, and without the top-up it would return 3.

Eight unit tests, including one that had to be rewritten mid-change: the first
version asserted the cap against an evenly-spread pool, where the top-N is
already diverse and the assertion could not fail. It now uses a skewed pool
where one seed owns the entire top of the ranking, which is the only shape that
actually exercises a cap.

Dropped two //nolint:gosec directives added in passing — gosec isn't in
.golangci.yml, so they suppressed nothing and only implied a check that runs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 12:56:42 -04:00
bvandeusenandClaude Opus 5 14aa22198f feat(discover): seed request suggestions from the taste profile — #2372
test-go / test (push) Successful in 29s
test-go / integration (push) Successful in 4m55s
The Discover request surface was the one recommendation surface still on its
M5c implementation from early May. #796's taste profile, #1488's taste_unheard
bucket and #1490's folksonomy enrichment all modernized in-library surfaces;
this one was never in scope for any of them, so it still projected raw likes +
plays through artist_similarity_unmatched.

Two defects fall out of that signal, `5*liked + Σexp(-age/halflife)` summed
over every play of the artist.

It is unbounded, and contribution is signal × similarity — so a handful of
heavily-played artists monopolize all twelve slots, and their share GROWS the
more the user listens. The surface entrenched harder the better it knew you,
which is exactly backwards and matches the reported "goes stale once it has a
strong signal of your taste".

It also counted every play_event with no was_skipped filter, so skipping an
artist repeatedly INCREASED its signal and pushed more of its neighbours at the
user. ListMostPlayedTracksForUser and the taste engine both filter skips; this
query was the odd one out.

Seeds now come from taste_profile_artists.weight, which the taste engine has
already engagement-graded, time-decayed and signed — an artist the user drifted
away from stops contributing instead of accumulating forever, and can even
contribute negatively. Tiered per rule #131 rather than hard-switched: tier 1 is
the profile, tier 2 is likes + completed plays for a user who has no profile
rows yet (new account, or before the first daily recompute), so the surface
never empties. The old unfiltered-play signal is gone, not kept behind a toggle.

The signal is also log-damped, so one artist cannot take every slot even when
its weight dwarfs the rest.

$2 stays wired to the tier-2 decay: it is genuinely still used there, and
dropping the parameter would have changed the generated signature.

sqlc's image is not on this workstation and the change preserves the query
signature exactly — same three params, same seven columns — so only the
embedded SQL const moves. Both copies are edited and verified byte-identical
rather than pulling a container onto the operator's machine; a malformed query
fails the integration lane loudly, which is the real check either way.

Four integration tests cover what changed: a taste weight alone seeds with no
like or play; tier 2 does not run alongside tier 1; a non-positive weight never
seeds (guarded by a second positive row, so an empty tier 1 can't make it pass
for the wrong reason); and skip-only history seeds nothing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 12:45:34 -04:00
bvandeusen 1138d75a45 Merge pull request 'Playlist-track atomic replace + ci-requirements true-up' (#115) from dev into main
release / Build signed APK (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 1m33s
android / Build + lint + test (push) Successful in 4m30s
2026-08-01 12:23:37 -04:00
bvandeusenandClaude Opus 5 cf7b489fec fix(playlists): make the playlist-track replace atomic
android / Build + lint + test (push) Successful in 4m4s
`refreshDetail` did an un-transacted `deleteByPlaylist` + `upsertAll` — the
same shape as the Home index write that #2327 just fixed. Room's
InvalidationTracker fires after the DELETE, so an observer of
`observeByPlaylist` would see `emptyList()` before the new rows land, which is
exactly what made every Home row visibly collapse to empty and refill.

Nothing consumes `observeByPlaylist` today, so this is not a live defect — it's
a landmine. Making playlist detail cache-first later would have silently
reintroduced the flicker, and the reason would have been three layers away from
the symptom. One `@Transaction` now costs nothing and removes that.

`deleteByPlaylist` is left in place as the building block but is no longer
called from outside the DAO.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 12:04:22 -04:00
bvandeusenandClaude Opus 5 8483948f23 docs(ci): true up ci-requirements.md — ci-android replaced ci-flutter
The sheet still described the pre-M8 world: "two CI images: ci-go +
ci-flutter", a ci-flutter dep list, and cross-workflow release polling
against flutter.yml. None of that is true now — flutter.yml is gone,
android.yml and release.yml both pull ci-android:36, and image-release
gates on `needs: [android-release]` instead of polling.

Family rule 39 makes this sheet CI-Runner's decision input for "add a dep
to an image vs. fork a variant", so a stale sheet quietly misinforms that
call: CI-Runner was still carrying ci-flutter for a consumer that no
longer exists, and had no record of ci-android's real consumer.

- Runtime images: ci-flutter:3.44 -> ci-android:36, with a note on why
  ci-flutter is now unconsumed and what would have to change to revive it.
- Image deps: replace the Flutter/Dart/NDK list with the actual
  ci-android surface (JDK 25 + Gradle 9.1 floor, SDK/build-tools 36, no
  NDK, ktlint + detekt).
- Label/image split: record that Android jobs still schedule on the
  flutter-ci label on purpose — it's a scheduling handle, not a toolchain
  assertion.
- Update channel: `needs:` gating, plus the non-tag rebundle path.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 10:38:50 -04:00
bvandeusen 0cea82984c Merge pull request 'Home updating-veil rework: change-triggered, settle-driven, with refresh feedback' (#114) from dev into main
android / Build + lint + test (push) Successful in 4m42s
release / Build signed APK (tag releases only) (push) Successful in 4m34s
release / Build + push container image (push) Successful in 1m37s
2026-07-31 23:32:05 -04:00
bvandeusenandClaude Opus 5 3acac985cd feat(home): veil only when content changed; tell the user when it didn't — #2327
android / Build + lint + test (push) Successful in 4m8s
The veil raised eagerly: any trigger over a warm cache put it up before
knowing whether the refresh would change anything. So every launch cost
~1-2s of opaque panel even when the pull returned exactly what was already
cached — which, now that the section swap is atomic and the index flow dedups
on ids, produces no visible churn to hide at all. The veil was covering
nothing and only delaying first paint.

The raise is now reactive: it fires when the content key actually differs from
what was already on screen, and never for a no-op refresh. The baseline is the
first state that HAS content, not the first state at all — over a warm cache
the cached rows paint a moment after the session starts, and counting that
first paint as "a change" would veil every launch, which is the thing being
fixed. Cost of reacting rather than anticipating: the veil arrives one emission
after the change, so a single atomic swap shows through. Everything messier
that follows it — tile hydration, then artwork — still lands behind it.

That leaves a hole this closes too: a manual pull where nothing changed would
now produce no veil, no movement, nothing whatsoever, which reads as broken. So
sessions report an outcome — CHANGED / UNCHANGED / FAILED — and Home surfaces
it as "Already up to date" or "Couldn't check for updates".

Only for refreshes a person actually asked for. "Already up to date" on every
launch, every 03:00 rebuild and every reconnect would be worse than silence, so
VeilSessionResult carries a userInitiated bit and background sessions stay
quiet. The bit is tracked separately from the request token because the request
channel is CONFLATED: coalescing drops the older token, and a user's pull must
not be swallowed by a background trigger arriving on its heels.

The surfaced failure is a deliberate narrowing of the earlier "silent on give
up" call, which is now read as being about background refreshes: for a pull the
user deliberately triggered, silence looks broken, and staying silent while the
success case speaks would be incoherent. Recovery is unaffected either way.

Pull-to-refresh now waits for whichever successor actually arrives — the veil,
or the snackbar — via finishedSessions, instead of only ever waiting on the
veil and timing out for 2s on an unchanged pull.

The Error-state Retry goes through the controller as well, so it gets the
retries and reports its outcome; over an empty cache there's no content to
protect, so no veil appears. HomeViewModel.refresh() is gone, replaced by
retry() and refreshFromPull() — the two things that actually exist.

Tests: two changed meaning and are rewritten rather than patched. A failed pull
writes nothing, so the veil no longer stands over the retries — it goes up when
a retry finally lands. And "waits for content to paint" became "cached content
painting is not mistaken for a change", which is the baseline subtlety above.
Added coverage for UNCHANGED, FAILED, the cold-load CHANGED case, and the
conflation of a user request with a background one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 23:16:49 -04:00
bvandeusenandClaude Opus 5 d3b40342b4 test(home): drive the veil tests' clock explicitly, not advanceUntilIdle
android / Build + lint + test (push) Successful in 3m54s
All seven new UpdateVeilController tests failed in CI run 3163, and the
one test that passed is the tell: it was the only one that never called
advanceUntilIdle().

advanceUntilIdle() advances only while *foreground* work remains. Every
coroutine this controller owns lives in backgroundScope — it has to, because
its consumer loop runs forever and would otherwise stop runTest from
completing — so advanceUntilIdle() returned having run nothing at all, and
the assertions landed on a session that never started. Hence "exhausts its
attempts. Expected <3>, actual <0>" and, where an earlier advanceTimeBy had
got a session partway, "retries until the pull succeeds. Expected <3>,
actual <2>".

Each wait is now an explicit advanceTimeBy sized for what that test still
has pending, and the class KDoc says why so nobody folds them back.

The drains stay deliberately under maxHoldMs. If a drain overshot the
ceiling, "the veil lowered" would stop distinguishing "it settled" from "it
gave up" — which is exactly what these tests exist to tell apart.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 20:47:20 -04:00
bvandeusenandClaude Opus 5 4f99b42844 ci(android): print full assertion messages for failing tests
android / Build + lint + test (push) Failing after 3m11s
CI run 3161 reported seven failures as bare "java.lang.AssertionError at
UpdateVeilControllerTest.kt:87" — and line 87 is the test's own `fun ... =
runTest {` line, not the assertion. Gradle picks the first stack frame
belonging to the test class, and assertions inside a `runTest { }` lambda
live in a generated suspend-lambda class that gets filtered out, so every
failure in a coroutine test collapses to the function declaration. With the
HTML report unreachable from CI, that leaves nothing to debug from.

testLogging with exceptionFormat = FULL prints the assertion message and the
whole stack trace for failures, which is what makes a coroutine-test failure
diagnosable at all here.

Also drop the NonCancellable floor-join from UpdateVeilController's finally.
Honouring the minimum hold while the scope is being torn down is pointless —
nothing is left to render the veil — and a finally that suspends is a finally
that can resist cancellation. The floor is now awaited in the try instead.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 20:39:54 -04:00
bvandeusenandClaude Opus 5 5044e7a055 fix(home): hold the updating veil until Home actually settles — #2327
android / Build + lint + test (push) Failing after 3m7s
The "Updating your mixes…" veil wiped on and straight back off before the
update finished, and a number of churn paths never raised it at all.

Three reasons it lowered early. refreshBehindVeil held it for
refresh().join() + a flat 500ms, but finishing the network pull is nowhere
near the end of the visible work: refreshIndex writes only the section id
lists, then each tile hydrates through MetadataProvider (null → skeleton →
album), and only then does the cover art load. Second, updatingInternal was
a plain Boolean cleared in a finally — reconnect and playlist.system_rebuilt
routinely arrive together, so whichever pull finished first wiped the veil
off while the other was still running. Third, refresh() swallowed every
failure in runCatching, so join() returned "fine" after a failed pull: veil
off, content unchanged, no retry.

So the veil's lifetime is now driven by watching the screen instead of by a
guess. UpdateVeilController raises, runs the work (retrying behind the veil),
then holds until the content signature has been unchanged for a quiet window
AND nothing is still loading — floored by a minimum hold so it cannot flash,
capped by a hard ceiling so it cannot strand, and with overlapping triggers
folded into one session rather than racing it. Giving up is silent and sets
no latch: the reconnect-driven recovery and the freshness sweeper keep
retrying afterwards exactly as before.

Cover art was the most visible pop-in and the refresh coroutine cannot see
it, so the composition reports it upward: ServerImage — the single choke
point behind CoverTile for every album/artist/playlist cover — counts its
in-flight loads into an ArtSettleTracker the veil waits on. Art also
crossfades now (set once on the ImageLoader, so it applies app-wide) with
the placeholder fading out over the same window, which softens the pop
everywhere the veil isn't involved.

Underneath all of it, the churn is largely no longer generated. replaceSection
was delete-then-insert per section, un-transacted, so observeBySection emitted
emptyList() — a visible collapse — before refilling, seven times in sequence.
It is now one @Transaction across all sections (Room notifies once, on commit,
so the empty gap is never observed), and the index flow dedups on the id list,
so a section whose contents did not move no longer tears down and rebuilds
every tile's hydration flow. fetchedAt is restamped on every write, which is
why the dedup compares ids rather than rows. Same fix CachedQuarantineDao
already carried for the same reason.

Trigger set widened per the operator's call: the initial load over a warm
cache (a full re-pull that churned every section completely unveiled), manual
pull-to-refresh, scan.run_finished (Home never reacted to it at all), and the
playlist.created/updated/deleted/tracks_changed kinds. The veil waits for
content to be on screen before raising, so a genuinely cold load still gets
its skeleton rather than an opaque panel over nothing.

refreshError is now cleared on success rather than at the start of each
attempt — with retries, clearing it up front made a failing cold start flash
the "Welcome to Minstrel" empty state between attempts.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 20:31:30 -04:00
bvandeusen 7d45a4e5c7 ci: artifacts that can actually be downloaded (issue 2270)
release / Build signed APK (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 1m10s
android / Build + lint + test (push) Successful in 4m29s
2026-07-30 15:51:15 -04:00
bvandeusenandClaude Opus 5 fa0827f668 ci: pin the download mirror to v6, not v5 — match on @actions/artifact
The previous pin matched the two actions by their own version numbers, which
is meaningless: upload-artifact and download-artifact release on unrelated
cadences. upload v5 bundles @actions/artifact ^4.0.0; download v5 bundles
^2.3.2. "v5 and v5" was in fact a mismatched pair.

download v6 is the tag that puts ^4.0.0 on both sides — and ^4.0.0 is the
library major just proven against this instance by the upload side
(thoughtsync run 3094: two artifacts listed, downloaded and extracted
intact). ^2.3.2 has never been exercised here.

Not v7: that major is a runner requirement rather than a feature change. It
moves to runs.using: node24 and upstream requires runner >= 2.327.1 for it,
which act_runner does not claim to satisfy. Everything pinned stays node20.

ci-requirements.md now carries the version/runtime table and the reasoning,
so the next person matches on the library instead of the tag number.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 15:37:43 -04:00
bvandeusenandClaude Opus 5 52d53e0044 ci: swap artifact upload+download to the mirrored actions (issue 2270)
android / Build + lint + test (push) Successful in 4m30s
android.yml and release.yml uploaded via actions/upload-artifact@v3, which
reports success while Gitea stores the result in a format its v4-only
artifact API will never serve back — 72 artifacts on this repo are on disk,
have valid DB rows, and are invisible to every retrieval path. Green jobs
producing nothing retrievable.

release.yml is a producer/consumer pair: android-release uploads
minstrel-apk and image-release downloads it to bundle into the container.
Swapping only the upload would have left download-artifact@v3 reading the
v1/v3 listing and finding nothing, so mirror the download side too —
bvandeusen/download-artifact, pull mirror of forgejo/download-artifact,
pinned at its v5 tag to match the upload pin's major.

Not actions/{upload,download}-artifact@v4: isGhes() throws on the hostname
before opening a connection, so no server-side change reaches it.

Upload steps also set if-no-files-found: error — image-release hard-depends
on minstrel-apk existing, so an empty upload must fail where it happens.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 15:28:10 -04:00
bvandeusen a26ef4e93c Merge pull request 'Queue fix + cross-client queue enhancements' (#112) from dev into main
test-web / test (push) Successful in 1m10s
android / Build + lint + test (push) Successful in 4m59s
release / Build signed APK (tag releases only) (push) Successful in 4m3s
release / Build + push container image (push) Successful in 1m42s
2026-07-23 08:31:34 -04:00
bvandeusenandClaude Opus 4.8 0774f5f55f fix(player): suppress TooManyFunctions on PlayerViewModel facade — #1944
android / Build + lint + test (push) Successful in 3m43s
Adding the queue move/remove/clear pass-throughs pushed the VM to 12 functions
(detekt cap 11). It's a thin transport facade forwarding to PlayerController, so
suppress with a rationale rather than splitting the delegating surface.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 23:16:17 -04:00
bvandeusenandClaude Opus 4.8 509cbe79b2 feat(player): Android queue — reorder, remove, art, auto-follow, clear — #1944
android / Build + lint + test (push) Failing after 1m26s
Queue screen gains: album-art thumbnails (ServerImage), drag-to-reorder via a
grip handle (offset->delta on release, mirroring the web), a remove button per
row, auto-follow of the now-playing track with a 'Jump to current' pill when
scrolled away, a clear-queue action, and a header count + total-time summary.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 23:12:23 -04:00
bvandeusenandClaude Opus 4.8 dc7b9b78fa feat(player): queue move/remove/clear on PlayerController + VM — #1944
Adds moveInQueue/removeFromQueue/clearQueue, each keeping the domain queueRefs
snapshot in lock-step with the Media3 timeline (mirrors playNext/enqueue). Media3
onEvents rebuilds uiState so the queue view reflects reorder/removal/clear.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 23:12:23 -04:00
bvandeusenandClaude Opus 4.8 cde74b5965 feat(player): web queue auto-follow + jump-to-current pill + clear-queue — #1944
test-web / test (push) Successful in 40s
QueueList now follows the now-playing row as the track auto-advances (only
while it's in view), centers it on open, and surfaces a 'Jump to current' pill
once the user scrolls it off-screen. Header gains a clear-queue action backed
by a new store clearQueue() that empties the queue and stops playback.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 22:57:23 -04:00
bvandeusenandClaude Opus 4.8 0efbf5fcaa feat(player): album-art thumbnails in web queue rows — #1944
Adds a 40px cover thumbnail (coverUrl(album_id), FALLBACK_COVER on error) to
each queue row, matching the artwork every comparable player shows in its
up-next list.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 22:57:23 -04:00
bvandeusenandClaude Opus 4.8 2038028d42 test(web): no-op scrollIntoView in vitest setup (jsdom lacks it) — #1931
test-web / test (push) Successful in 33s
The queue auto-scroll $effect calls scrollIntoView on render, and jsdom
doesn't implement it, so QueueDrawer.test.ts threw an unhandled TypeError that
failed the run even though every assertion passed. Polyfill it as a no-op in
the shared setup; tests never assert on scroll position.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 21:27:35 -04:00
bvandeusenandClaude Opus 4.8 723293110d feat(player): scroll web queue to now-playing track on open (Android parity) — #1931
test-web / test (push) Failing after 38s
QueueList gains an `active` prop; when it flips true (drawer opens) or on mount
(now-playing panel) it centers the current row in view. Index/length are read
untracked so it positions once per open rather than following auto-advance,
matching the Android queue. QueueDrawer passes active={queueDrawerOpen} since
its aside is always mounted.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 21:24:29 -04:00
bvandeusenandClaude Opus 4.8 41ebf1405b fix(player): open Android queue scrolled to now-playing track — #1929
android / Build + lint + test (push) Successful in 4m8s
QueueList used a plain LazyColumn with no hoisted state, so the queue always
opened at the top and the current track could be off-screen. Seed a
rememberLazyListState with the current index (coerced into bounds) so the list
renders already positioned on the now-playing row — no post-layout scroll flash.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 21:11:32 -04:00
bvandeusenandClaude Opus 4.8 f2dcf2596d fix(player): render QueueDrawer inside QueryClientProvider so queue LikeButtons resolve — #1928
test-web / test (push) Successful in 40s
The queue drawer's <aside> is always mounted, so QueueTrackRow's LikeButton
(added in #1596) instantiates the moment the queue populates on first play.
LikeButton calls useQueryClient() at init; with the drawer outside the
provider it threw 'No QueryClient was found in Svelte context', aborting the
reactive flush that starts playback — so play appeared to do nothing.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 20:47:21 -04:00
bvandeusen 47de7be472 fix(player): route notification next/prev to Sonos while casting — #171 (#111)
android / Build + lint + test (push) Successful in 5m51s
release / Build signed APK (tag releases only) (push) Successful in 5m27s
release / Build + push container image (push) Successful in 1m47s
2026-07-15 19:17:11 -04:00
bvandeusenandClaude Opus 4.8 659554df0e fix(player): route notification next/prev to Sonos while casting — #171
android / Build + lint + test (push) Successful in 4m9s
Device verification of v2026.07.15 found the notification/lock-screen
next+prev buttons dead during a UPnP cast (play/pause worked). Root cause:
the system media controls issue COMMAND_SEEK_TO_NEXT / COMMAND_SEEK_TO_PREVIOUS
-> Player.seekToNext()/seekToPrevious(), which are DISTINCT from the
seekToNextMediaItem()/seekToPreviousMediaItem() the in-app buttons call and
which MinstrelForwardingPlayer already routes to Sonos. seekToNext/Previous
were un-overridden, so ForwardingPlayer forwarded them to the paused local
delegate — nudging its cursor, which the identity poll then re-synced back to
Sonos, so the buttons read as dead.

Override seekToNext()/seekToPrevious() to delegate to the media-item variants
(the full Sonos path: optimistic local advance + AVTransport Next/Previous +
pending-transport gate) when a UPnP route is engaged; plain local playback
keeps the default behaviour. Fixes notification/lock-screen/Auto/Wear skip
during a cast. Completes milestone #171 Step 3 (#1606 / #606).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 19:11:40 -04:00
bvandeusen 2ecdd46a2b Player: unify local+UPnP behind one cursor (#171) + queue heart button (#1596) (#110)
release / Build + push container image (push) Successful in 17s
test-web / test (push) Successful in 51s
android / Build + lint + test (push) Successful in 5m36s
release / Build signed APK (tag releases only) (push) Successful in 5m17s
2026-07-15 16:30:52 -04:00
bvandeusenandClaude Opus 4.8 41fe76b90c fix(player): identity-locked UPnP cursor + single index writer — #171
android / Build + lint + test (push) Successful in 4m23s
The local ExoPlayer cursor and the Sonos renderer were two competing
sources of truth for "what's playing" during a cast. The delegate cursor
lagged (forward-only, index-based, size-capped sync, skipped during every
load/re-cast window), and TWO writers of PlayerUiState.queueIndex fought:
PlayerController.onEvents (reading the lagging cursor) stomped the
Sonos-derived index the position tick published, so the in-app player
flickered to the pre-cast track and the notification metadata went stale.

Step 1 — MinstrelForwardingPlayer.syncLocalCursorToRemote (replaces
maybeSyncLocalCursor): align the paused delegate cursor to the track the
renderer is actually playing, matched by track-id parsed from the Sonos
TrackURI (/api/tracks/{id}/stream) against delegate MediaItem.mediaId
(== TrackRef.id). Both directions; survives queue-reload index wobble;
nearest-occurrence tiebreak for duplicate tracks; falls back to the Sonos
Track index; suppressed during load and while a user transport is pending
Sonos's ack. The cursor is now the single authoritative "current track"
that both the in-app UI (onEvents) and the notification (getCurrentMediaItem)
read.

Step 2 — PlayerController: the position tick now patches only
position/duration/play-pause/buffer; onEvents is the sole writer of
queueIndex/currentTrack. Removed desiredQueueIndex, the forward-only
trackChanged path, and publishTickIfChanged. One writer, no stomp.

Part of milestone #171 (unify local + UPnP behind one cursor). Fixes the
flicker + stale-notification symptoms; supersedes #1211/#608/#612.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 14:59:03 -04:00
bvandeusenandClaude Opus 4.8 6912dadf2b fix(test): order likes mock before component import in queue tests — #1596
test-web / test (push) Successful in 36s
The prior test fix registered the emptyLikesMock stub but imported it
(and the component under test) in the wrong order: importing QueueTrackRow
/ QueueDrawer transitively loads LikeButton → the mocked $lib/api/likes,
whose hoisted factory runs before the emptyLikesMock import initialized —
"Cannot access '__vi_import_N__' before initialization".

Move the emptyLikesMock import above, and the component import below, the
vi.mock call — matching the ArtistMenu/PlayerBar test layout so the
factory's binding is ready when the component graph loads.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 13:12:37 -04:00
bvandeusenandClaude Opus 4.8 304e06acc8 test(player): stub likes API in queue component tests — #1596
test-web / test (push) Failing after 33s
QueueTrackRow now renders a LikeButton, which reads createLikedIdsQuery
and needs a QueryClient in Svelte context. The QueueTrackRow / QueueDrawer
unit tests render the rows without one, so they failed with "No
QueryClient was found in Svelte context". Mock $lib/api/likes with the
shared emptyLikesMock() helper — the same pattern PlayerBar/TrackMenu and
17 other component tests already use.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 13:08:06 -04:00
bvandeusenandClaude Opus 4.8 235839b696 feat(player): heart/like button in queue view (web + android) — #1596
test-web / test (push) Failing after 33s
android / Build + lint + test (push) Successful in 4m25s
The full-screen player's queue ("up next") track rows were the one
track-list surface missing the like heart that TrackRow/PlaylistTrackRow
(web) and playlist/album/artist detail (Android) already carried.

Web: render the shared <LikeButton> in QueueTrackRow between the row body
and the remove button (serves both the /now-playing aside and the mobile
QueueDrawer, same component). LikeButton already stops click propagation
so it won't trigger play-on-click.

Android: PlayerViewModel now exposes likedTrackIds (set-based, the same
idiom as the detail VMs) + toggleLikeTrack; QueueScreen threads
liked/onToggleLike through QueueList → QueueRow, which renders the shared
LikeButton after the duration. Liked state stays sourced from
LikesRepository by track.id — no TrackRef data-model change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 13:02:32 -04:00
89 changed files with 7098 additions and 449 deletions
+10 -4
View File
@@ -80,10 +80,16 @@ jobs:
- name: Upload debug APK
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
# Gitea Actions runs in GHES-emulation mode; @actions/artifact v2+
# (i.e. upload-artifact@v4+) errors with "GHESNotSupportedError".
# Pin to @v3 until act_runner or the artifact backend catches up.
uses: actions/upload-artifact@v3
# Mirrored action, never actions/upload-artifact. @v4+ throws
# GHESNotSupportedError client-side on the hostname (no server setting
# reaches that check), and @v3 is worse — it reports success while Gitea
# serves artifacts back only through the v4 API, so the upload is stored
# and invisible to every retrieval path. @v3 is what left 72 unreachable
# artifacts on this repo. Pinned by SHA because the mirror auto-syncs;
# full URL because DEFAULT_ACTIONS_URL sends bare owner/repo to github.com.
# See Scribe issues 2255 / 2270.
uses: https://git.fabledsword.com/bvandeusen/upload-artifact@cb8afe72b42edc798abfb8fcb556cf660d894245
with:
name: minstrel-android-debug-${{ github.sha }}
path: android/app/build/outputs/apk/debug/app-debug.apk
if-no-files-found: error
+24 -4
View File
@@ -131,12 +131,19 @@ jobs:
-PMINSTREL_VERSION_CODE=${{ steps.ver.outputs.code }}
- name: Upload APK as workflow artifact
# @v3 because Gitea Actions emulates GHES and the v2 artifact
# backend used by upload-artifact@v4 errors with GHESNotSupportedError.
uses: actions/upload-artifact@v3
# Mirrored action, never actions/upload-artifact — @v4+ refuses on the
# hostname, @v3 uploads something Gitea will never serve back. This is
# the producing half of a pair: image-release downloads `minstrel-apk`
# below with the matching download-artifact mirror. Both must stay on
# the v4 protocol — mixing a v3 upload with a v4 download (or the
# reverse) yields an empty listing, not an error. See Scribe 2255 / 2270.
uses: https://git.fabledsword.com/bvandeusen/upload-artifact@cb8afe72b42edc798abfb8fcb556cf660d894245
with:
name: minstrel-apk
path: android/app/build/outputs/apk/release/app-release.apk
# error, not the default warn: image-release hard-depends on this
# artifact existing, so an empty upload must fail here, not there.
if-no-files-found: error
- name: Attach APK to gitea Release
shell: bash
@@ -238,7 +245,20 @@ jobs:
# Tag pushes only — android-release just produced this. Non-tag
# builds take the "Bundle latest release APK" path below instead.
if: steps.guard.outputs.ready == 'true' && startsWith(github.ref, 'refs/tags/v')
uses: actions/download-artifact@v3
# Consuming half of the pair — never actions/download-artifact. Same fork,
# same reason: upstream's client-side GHES check rejects this hostname
# before it connects. bvandeusen/download-artifact mirrors
# code.forgejo.org/forgejo/download-artifact.
#
# SHA below is that fork's `v6` tag. Match on @actions/artifact, NOT on
# the action's own version number — the two actions release on unrelated
# cadences, and download v5 would pair a ^2.3.2 client with this file's
# ^4.0.0 uploader. v6 is the tag whose bundled library major (^4.0.0) is
# the same one proven against this instance by the upload side.
# Deliberately NOT v7: it moves to node24 and upstream requires runner
# >= 2.327.1 for it, which act_runner does not claim to satisfy.
# Pinned, not tagged — the mirror auto-syncs every 8h.
uses: https://git.fabledsword.com/bvandeusen/download-artifact@8d4e9521a5f7e5f8b6351f341f719f9f45a92a3a
with:
name: minstrel-apk
path: client/
+4
View File
@@ -27,6 +27,7 @@ on:
- 'go.mod'
- 'go.sum'
- 'sqlc.yaml'
- 'Makefile'
- 'internal/**'
- 'cmd/**'
- '.golangci.yml'
@@ -53,6 +54,9 @@ jobs:
go version
golangci-lint --version
- name: Generated code matches queries (sqlc)
run: make verify-generate
- name: go vet
run: go vet ./...
+25 -1
View File
@@ -1,10 +1,34 @@
.PHONY: generate test test-short test-integration lint build
.PHONY: generate generate-go verify-generate test test-short test-integration lint build
# renovate: datasource=docker depName=sqlc/sqlc
SQLC_VERSION := 1.31.1
# Local codegen. Containerised so a dev needs no sqlc install.
generate:
docker run --rm -v "$(CURDIR):/src" -w /src sqlc/sqlc:$(SQLC_VERSION) generate
# Same codegen, run as a Go tool instead of a container. This is the CI path:
# the ci-go image already has Go, so it avoids docker-in-docker. Pinned to the
# SAME version as `generate` above so both routes emit identical output.
generate-go:
go run github.com/sqlc-dev/sqlc/cmd/sqlc@v$(SQLC_VERSION) generate
# Fail if the committed generated code no longer matches the .sql sources.
#
# Nothing verified this before, so internal/db/dbq could silently drift from
# internal/db/queries — a hand-edit, a half-applied regen, or a schema change
# without a regen would all pass CI while the typed layer lied about the SQL.
#
# The diff is printed BEFORE the exit-code check on purpose: when this fails,
# the log then contains sqlc's exact expected output, which is what you commit.
verify-generate: generate-go
# -N (intent-to-add) so a BRAND-NEW generated file is visible to `git
# diff`, which otherwise ignores untracked paths entirely — a whole
# missing *.sql.go would sail through the check below.
git add -N -- internal/db/dbq
git --no-pager diff -- internal/db/dbq
git diff --quiet -- internal/db/dbq
test:
go test -race ./...
+14 -1
View File
@@ -210,4 +210,17 @@ dependencies {
debugImplementation(libs.compose.ui.test.manifest)
}
tasks.withType<Test> { useJUnitPlatform() }
tasks.withType<Test> {
useJUnitPlatform()
// Print the assertion message + full stack trace for failures. The
// default console output gives only "AssertionError at Foo.kt:12", and
// for a failure inside a `runTest { }` lambda even that line collapses
// to the test function's own line (the assertion frames live in the
// suspend-lambda class, which Gradle filters out) — leaving nothing to
// debug from when the HTML report isn't reachable, as in CI.
testLogging {
events("failed")
exceptionFormat = org.gradle.api.tasks.testing.logging.TestExceptionFormat.FULL
showStackTraces = true
}
}
@@ -6,6 +6,7 @@ import androidx.work.Configuration
import coil3.ImageLoader
import coil3.SingletonImageLoader
import coil3.network.okhttp.OkHttpNetworkFetcherFactory
import coil3.request.crossfade
import com.fabledsword.minstrel.cache.CacheIndexer
import com.fabledsword.minstrel.cache.mutations.MutationReplayer
import com.fabledsword.minstrel.cache.sync.SyncController
@@ -29,6 +30,10 @@ import okhttp3.OkHttpClient
import timber.log.Timber
import javax.inject.Inject
// Cover-art fade-in. Coil skips the transition for memory-cache hits, so
// already-loaded art still appears instantly — only a genuine fetch fades.
private const val ART_CROSSFADE_MS = 220
@HiltAndroidApp
class MinstrelApplication :
Application(),
@@ -213,11 +218,18 @@ class MinstrelApplication :
* OkHttp client as the network fetcher. The `callFactory` lambda
* is invoked lazily so Hilt has time to inject `okHttpClient`
* before Coil makes its first request.
*
* Crossfade is set here rather than per-call so every cover surface
* in the app fades its artwork in instead of snapping it. Art
* landing a beat after its tile was the most visible pop-in on Home
* (issue #2327); `ServerImage` fades its placeholder out over the
* same window so the two read as one cross-dissolve.
*/
override fun newImageLoader(context: android.content.Context): ImageLoader =
ImageLoader.Builder(context)
.components {
add(OkHttpNetworkFetcherFactory(callFactory = { okHttpClient }))
}
.crossfade(ART_CROSSFADE_MS)
.build()
}
@@ -3,9 +3,13 @@ package com.fabledsword.minstrel.api.endpoints
import com.fabledsword.minstrel.models.wire.ArtistSuggestionWire
import com.fabledsword.minstrel.models.wire.CreateRequestBody
import com.fabledsword.minstrel.models.wire.LidarrSearchResultWire
import com.fabledsword.minstrel.models.wire.SnoozeSuggestionBody
import com.fabledsword.minstrel.models.wire.SuggestionSnoozeWire
import retrofit2.http.Body
import retrofit2.http.DELETE
import retrofit2.http.GET
import retrofit2.http.POST
import retrofit2.http.Path
import retrofit2.http.Query
/**
@@ -30,4 +34,30 @@ interface DiscoverApi {
@POST("api/requests")
suspend fun createRequest(@Body body: CreateRequestBody)
/**
* Parks a suggestion — "not right now", NOT a dislike. Time-boxed
* server-side (90 days) and never fed into the taste profile.
*
* [body] must carry the artist's name: candidates are out-of-library, so
* the server has no local row to resolve a display name from and returns
* 400 without it.
*/
@POST("api/discover/suggestions/{mbid}/snooze")
suspend fun snoozeSuggestion(
@Path("mbid") mbid: String,
@Body body: SnoozeSuggestionBody,
)
/** Brings a parked suggestion back. 404 when it wasn't snoozed. */
@DELETE("api/discover/suggestions/{mbid}/snooze")
suspend fun unsnoozeSuggestion(@Path("mbid") mbid: String)
/**
* Currently-parked suggestions. Server filters expired rows, so every
* row returned is still snoozed. This is the only route back to an
* un-snooze once the card has left the deck.
*/
@GET("api/discover/snoozes")
suspend fun listSnoozes(): List<SuggestionSnoozeWire>
}
@@ -4,6 +4,7 @@ import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import androidx.room.Transaction
import com.fabledsword.minstrel.cache.db.entities.CachedHomeIndexEntity
import kotlinx.coroutines.flow.Flow
@@ -21,12 +22,32 @@ interface CachedHomeIndexDao {
)
suspend fun getBySection(section: String): List<CachedHomeIndexEntity>
/** True when Home has any cached section rows to render. */
@Query("SELECT EXISTS(SELECT 1 FROM cached_home_index)")
suspend fun hasAny(): Boolean
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsertAll(rows: List<CachedHomeIndexEntity>)
/** Replace-all pattern; sync wipes a section then re-inserts. */
@Query("DELETE FROM cached_home_index WHERE section = :section")
suspend fun deleteBySection(section: String)
@Query("DELETE FROM cached_home_index WHERE section IN (:sections)")
suspend fun deleteSections(sections: List<String>)
/**
* Swaps every listed section's rows in ONE transaction.
*
* Atomicity is the point, not just tidiness: Room's
* InvalidationTracker only notifies observers after the transaction
* commits, so [observeBySection] never sees the empty gap between the
* delete and the re-insert. Replacing sections one at a time (and
* un-transacted) made each Home row emit `emptyList()` — visibly
* collapsing — before refilling, and made the seven sections do it in
* sequence rather than as a single content swap.
*/
@Transaction
suspend fun replaceSections(sections: List<String>, rows: List<CachedHomeIndexEntity>) {
deleteSections(sections)
if (rows.isNotEmpty()) upsertAll(rows)
}
@Query("DELETE FROM cached_home_index")
suspend fun clear()
@@ -4,6 +4,7 @@ import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import androidx.room.Transaction
import com.fabledsword.minstrel.cache.db.entities.CachedPlaylistTrackEntity
import kotlinx.coroutines.flow.Flow
@@ -35,10 +36,33 @@ interface CachedPlaylistTrackDao {
@Query("SELECT MAX(position) FROM cached_playlist_tracks WHERE playlistId = :playlistId")
suspend fun maxPosition(playlistId: String): Int?
/** Replace-all pattern for a playlist; called after a sync delta lands. */
@Query("DELETE FROM cached_playlist_tracks WHERE playlistId = :playlistId")
suspend fun deleteByPlaylist(playlistId: String)
/**
* Replaces a playlist's whole membership in ONE transaction; called
* after a refresh or a sync delta lands.
*
* Atomic on purpose. Room's InvalidationTracker only notifies observers
* after the transaction commits, so [observeByPlaylist] never sees the
* empty gap between the delete and the re-insert. Un-transacted, that
* gap is a real observed state — it's what made every Home row visibly
* collapse to empty and refill before issue #2327 fixed the equivalent
* write in `CachedHomeIndexDao`.
*
* Nothing observes [observeByPlaylist] live today, so this is
* pre-emptive: it means making playlist detail cache-first later can't
* silently reintroduce that flicker.
*/
@Transaction
suspend fun replacePlaylistTracks(
playlistId: String,
rows: List<CachedPlaylistTrackEntity>,
) {
deleteByPlaylist(playlistId)
if (rows.isNotEmpty()) upsertAll(rows)
}
@Query(
"DELETE FROM cached_playlist_tracks " +
"WHERE playlistId = :playlistId AND trackId IN (:trackIds)",
@@ -35,6 +35,12 @@ object MutationKind {
// background avoids the duplicate + orphan row the old offline-on-stop
// path produced (see 2026-06-11 contract audit).
const val PLAY_ENDED: String = "play_ended"
// #2374 suggestion snooze. ONE toggle kind rather than separate
// snooze/unsnooze kinds, mirroring LIKE_TOGGLE, so a snooze followed by
// an undo collapses to the latest intent instead of replaying as two
// opposed calls whose order decides the outcome.
const val SUGGESTION_SNOOZE_TOGGLE: String = "suggestion_snooze_toggle"
}
/**
@@ -152,6 +158,25 @@ class MutationQueue @Inject constructor(
),
)
/**
* Queues a suggestion snooze (or its undo) for replay. [desiredSnoozed]
* is the TARGET state, so repeated taps collapse to one replay.
*
* [name] is carried even for an un-snooze, where the server ignores it,
* so a single payload shape serves both directions.
*/
suspend fun enqueueSuggestionSnoozeToggle(
mbid: String,
name: String,
desiredSnoozed: Boolean,
): Long = insertUserDriven(
MutationKind.SUGGESTION_SNOOZE_TOGGLE,
json.encodeToString(
SuggestionSnoozeTogglePayload.serializer(),
SuggestionSnoozeTogglePayload(mbid, name, desiredSnoozed),
),
)
suspend fun enqueueRequestCancel(requestId: String): Long = insertUserDriven(
MutationKind.REQUEST_CANCEL,
json.encodeToString(
@@ -192,6 +217,21 @@ class MutationQueue @Inject constructor(
}
}
/**
* Persisted payload for `MutationKind.SUGGESTION_SNOOZE_TOGGLE` (#2374).
* `desiredSnoozed` is the *target* state, matching [LikeTogglePayload], so
* the replayer can collapse repeated toggles for one candidate down to the
* last intent. Both directions are idempotent server-side: re-snoozing
* extends the window, and un-snoozing something already back is a 404 the
* replayer treats as permanent (nothing left to do).
*/
@Serializable
data class SuggestionSnoozeTogglePayload(
val mbid: String,
val name: String,
val desiredSnoozed: Boolean,
)
/**
* Persisted payload for `MutationKind.QUARANTINE_UNFLAG` — the
* `DELETE /api/quarantine/{trackId}` call lost during a connectivity
@@ -16,6 +16,7 @@ import com.fabledsword.minstrel.connectivity.NetworkStatusController
import com.fabledsword.minstrel.connectivity.ServerHealth
import com.fabledsword.minstrel.models.wire.PlayEndedRequest
import com.fabledsword.minstrel.models.wire.PlayOfflineRequest
import com.fabledsword.minstrel.models.wire.SnoozeSuggestionBody
import com.fabledsword.minstrel.auth.AuthStore
import com.fabledsword.minstrel.cache.db.dao.CachedMutationDao
import com.fabledsword.minstrel.cache.db.entities.CachedMutationEntity
@@ -114,11 +115,12 @@ class MutationReplayer @Inject constructor(
private suspend fun drain() {
val rows = dao.getAll()
// Collapse superseded like-toggles: only the latest desired state per
// (entity) is replayed; older toggles for the same entity are dropped
// unsent. Without this, partial-failure + differential retry could
// replay an older toggle last and invert the final like state.
val superseded = supersededLikeToggleIds(rows)
// Collapse superseded toggles (likes, suggestion snoozes): only the
// latest desired state per entity is replayed; older toggles for the
// same entity are dropped unsent. Without this, partial-failure +
// differential retry could replay an older toggle last and invert the
// final state — a snooze the user already undid would come back.
val superseded = supersededToggleIds(rows, json)
for (row in rows) {
if (row.id in superseded) {
dao.delete(row.id)
@@ -131,25 +133,6 @@ class MutationReplayer @Inject constructor(
}
}
/** Row ids of like-toggles superseded by a later toggle for the same entity. */
private fun supersededLikeToggleIds(rows: List<CachedMutationEntity>): Set<Long> {
val latestByEntity = HashMap<String, Long>()
val superseded = HashSet<Long>()
rows.asSequence()
.filter { it.kind == MutationKind.LIKE_TOGGLE }
.forEach { row ->
val decoded = runCatching {
json.decodeFromString(LikeTogglePayload.serializer(), row.payload)
}.getOrNull()
if (decoded != null) {
val key = "${decoded.entityType}:${decoded.entityId}"
// `rows` is ascending by id, so a prior entry is always older.
latestByEntity.put(key, row.id)?.let(superseded::add)
}
}
return superseded
}
private suspend fun outcomeFor(row: CachedMutationEntity): Outcome = try {
dispatch(row)
} catch (e: HttpException) {
@@ -182,6 +165,7 @@ class MutationReplayer @Inject constructor(
MutationKind.PLAY_ENDED -> dispatchPlayEnded(row.payload)
MutationKind.REQUEST_CANCEL -> dispatchRequestCancel(row.payload)
MutationKind.PLAYBACK_ERROR_REPORT -> dispatchPlaybackErrorReport(row.payload)
MutationKind.SUGGESTION_SNOOZE_TOGGLE -> dispatchSuggestionSnoozeToggle(row.payload)
// Unknown kind — drop so a stale schema entry can't wedge the queue.
else -> Outcome.DROP
}
@@ -277,6 +261,24 @@ class MutationReplayer @Inject constructor(
return Outcome.SENT
}
/**
* Replays a suggestion snooze in whichever direction the payload asks for.
*
* The un-snooze branch can legitimately 404 (the row already lapsed, or a
* previous attempt landed and the response was lost). [outcomeFor] classes
* 404 as permanent → DROP, which is right: the user's intended end state
* already holds, so there is nothing left to send.
*/
private suspend fun dispatchSuggestionSnoozeToggle(payload: String): Outcome {
val decoded = json.decodeFromString(SuggestionSnoozeTogglePayload.serializer(), payload)
if (decoded.desiredSnoozed) {
discoverApi.snoozeSuggestion(decoded.mbid, SnoozeSuggestionBody(name = decoded.name))
} else {
discoverApi.unsnoozeSuggestion(decoded.mbid)
}
return Outcome.SENT
}
private suspend fun dispatchPlaybackErrorReport(payload: String): Outcome {
val decoded = json.decodeFromString(PlaybackErrorReportPayload.serializer(), payload)
playbackErrorsApi.report(
@@ -297,3 +299,46 @@ class MutationReplayer @Inject constructor(
const val HTTP_TOO_MANY = 429
}
}
/**
* Row ids of desired-state toggles superseded by a later toggle for the same
* entity. Applies to every kind whose payload encodes a TARGET state rather
* than an action — like-toggles and suggestion snoozes (#2374) — because
* replaying a stale one last would invert the final state.
*
* Top-level and pure so it can be unit-tested without standing up a Retrofit
* instance. [rows] must be ascending by id (FIFO), which is what
* `CachedMutationDao.getAll()` returns.
*/
internal fun supersededToggleIds(rows: List<CachedMutationEntity>, json: Json): Set<Long> {
val latestByEntity = HashMap<String, Long>()
val superseded = HashSet<Long>()
rows.asSequence()
.mapNotNull { row -> toggleKeyOf(row, json)?.let { key -> key to row.id } }
.forEach { (key, id) ->
// Ascending ids mean a prior entry for this key is always older.
latestByEntity.put(key, id)?.let(superseded::add)
}
return superseded
}
/**
* Collapse key for a toggle row, or null when the row isn't a toggle — or its
* payload won't decode. Undecodable rows are deliberately left alone rather
* than grouped under a shared "corrupt" key, so one bad row can't suppress a
* good one behind it; the dispatcher DROPs it on its own.
*
* The kind is part of the key so two toggle kinds can never collide on the
* same entity id.
*/
private fun toggleKeyOf(row: CachedMutationEntity, json: Json): String? = when (row.kind) {
MutationKind.LIKE_TOGGLE -> runCatching {
json.decodeFromString(LikeTogglePayload.serializer(), row.payload)
}.getOrNull()?.let { "${row.kind}:${it.entityType}:${it.entityId}" }
MutationKind.SUGGESTION_SNOOZE_TOGGLE -> runCatching {
json.decodeFromString(SuggestionSnoozeTogglePayload.serializer(), row.payload)
}.getOrNull()?.let { "${row.kind}:${it.mbid}" }
else -> null
}
@@ -7,10 +7,14 @@ import com.fabledsword.minstrel.models.ArtistSuggestionRef
import com.fabledsword.minstrel.models.LidarrRequestKind
import com.fabledsword.minstrel.models.LidarrSearchResultRef
import com.fabledsword.minstrel.models.SeedContributionRef
import com.fabledsword.minstrel.models.SuggestionSnoozeRef
import com.fabledsword.minstrel.models.wire.ArtistSuggestionWire
import com.fabledsword.minstrel.models.wire.CreateRequestBody
import com.fabledsword.minstrel.models.wire.LidarrSearchResultWire
import com.fabledsword.minstrel.models.wire.SeedContributionWire
import com.fabledsword.minstrel.models.wire.SnoozeSuggestionBody
import com.fabledsword.minstrel.models.wire.SuggestionSnoozeWire
import retrofit2.HttpException
import retrofit2.Retrofit
import retrofit2.create
import javax.inject.Inject
@@ -46,6 +50,69 @@ class DiscoverRepository @Inject constructor(
suspend fun listSuggestions(): List<ArtistSuggestionRef> =
api.listSuggestions().map { it.toDomain() }
suspend fun listSnoozes(): List<SuggestionSnoozeRef> =
api.listSnoozes().map { it.toDomain() }
/**
* Parks a suggestion ("not right now"). Offline-first per rule #100: on
* transport failure the target state is queued for the replayer rather
* than dropped.
*
* Always reports success to the caller. Unlike a request, a snooze has no
* meaningful failed state to show — the user asked for a card to go away,
* and it will, either now or when the queue drains.
*/
suspend fun snoozeSuggestion(mbid: String, name: String): Unit = toggleSnooze(
mbid = mbid,
name = name,
desiredSnoozed = true,
) { api.snoozeSuggestion(mbid, SnoozeSuggestionBody(name = name)) }
/** Brings a parked suggestion back. Same offline-first contract. */
suspend fun unsnoozeSuggestion(mbid: String, name: String): Unit = toggleSnooze(
mbid = mbid,
name = name,
desiredSnoozed = false,
) { api.unsnoozeSuggestion(mbid) }
private suspend fun toggleSnooze(
mbid: String,
name: String,
desiredSnoozed: Boolean,
call: suspend () -> Unit,
) {
try {
call()
} catch (e: HttpException) {
// A 4xx is the server's considered answer, not a lost call, so
// queueing it would be wrong twice over: the replay is guaranteed
// to fail again, and the enqueue would raise a "will sync when
// online" snackbar for something already settled. The common case
// is a 404 from un-snoozing a row that already lapsed — which is
// the end state the user wanted anyway.
if (!isPermanent(e.code())) {
mutationQueue.enqueueSuggestionSnoozeToggle(mbid, name, desiredSnoozed)
}
} catch (
@Suppress("TooGenericExceptionCaught", "SwallowedException") e: Throwable,
) {
// Transport failure — intentional swallow, same offline-first
// rationale as createRequest above. The queue carries the desired
// STATE, so a later undo supersedes this rather than fighting it
// on replay.
mutationQueue.enqueueSuggestionSnoozeToggle(mbid, name, desiredSnoozed)
}
}
/**
* Mirrors MutationReplayer's classification so the enqueue decision here
* and the drop decision there can't disagree: 4xx is permanent except the
* two "retry me" statuses.
*/
private fun isPermanent(code: Int): Boolean =
code in HTTP_CLIENT_ERR_MIN..HTTP_CLIENT_ERR_MAX &&
code != HTTP_TIMEOUT && code != HTTP_TOO_MANY
suspend fun search(query: String, kind: LidarrRequestKind): List<LidarrSearchResultRef> =
api.search(query = query, kind = kind.wire).map { it.toDomain() }
@@ -85,6 +152,13 @@ class DiscoverRepository @Inject constructor(
RequestOutcome.QUEUED
}
}
private companion object {
const val HTTP_CLIENT_ERR_MIN = 400
const val HTTP_CLIENT_ERR_MAX = 499
const val HTTP_TIMEOUT = 408
const val HTTP_TOO_MANY = 429
}
}
// ── Mappers (internal — wire types stay out of UI) ──
@@ -105,6 +179,7 @@ private fun ArtistSuggestionWire.toDomain(): ArtistSuggestionRef = ArtistSuggest
name = name,
imageUrl = imageUrl,
attribution = attribution.map { it.toDomain() },
matchedTags = matchedTags,
)
private fun SeedContributionWire.toDomain(): SeedContributionRef = SeedContributionRef(
@@ -112,6 +187,12 @@ private fun SeedContributionWire.toDomain(): SeedContributionRef = SeedContribut
isLiked = isLiked,
)
private fun SuggestionSnoozeWire.toDomain(): SuggestionSnoozeRef = SuggestionSnoozeRef(
mbid = mbid,
name = name,
snoozedUntil = snoozedUntil,
)
private fun RequestCreatePayload.toBody(): CreateRequestBody = CreateRequestBody(
kind = kind,
artistMbid = artistMbid,
@@ -40,6 +40,7 @@ import com.fabledsword.minstrel.discover.data.RequestOutcome
import com.fabledsword.minstrel.models.ArtistSuggestionRef
import com.fabledsword.minstrel.models.LidarrRequestKind
import com.fabledsword.minstrel.models.LidarrSearchResultRef
import com.fabledsword.minstrel.models.SuggestionSnoozeRef
import com.fabledsword.minstrel.nav.Discover
import com.fabledsword.minstrel.shared.widgets.ErrorRetry
import com.fabledsword.minstrel.shared.widgets.LoadingCentered
@@ -104,6 +105,18 @@ private fun DiscoverBody(
ResultsState.Idle -> SuggestionsPane(
state = state.suggestions,
locallyRequestedMbids = state.locallyRequestedMbids,
snoozeUi = SnoozeUi(
locallySnoozedMbids = state.locallySnoozedMbids,
snoozes = state.snoozes,
// No snackbar on snooze: the row itself flips to "Not
// right now" with an Undo, so a snackbar would only
// repeat what the user can already see — and cover the
// next row while doing it.
onSnooze = { s -> scope.launch { viewModel.snoozeSuggestion(s) } },
onUnsnooze = { mbid, name ->
scope.launch { viewModel.unsnoozeSuggestion(mbid, name) }
},
),
onRequest = { s ->
scope.launch {
val outcome = viewModel.requestSuggestion(s)
@@ -178,10 +191,23 @@ private fun KindChips(kind: LidarrRequestKind, onChange: (LidarrRequestKind) ->
}
}
/**
* The snooze surface's data and callbacks, bundled rather than threaded
* through as four more parameters — the pane grew from one action to three
* with slice 4 and the signatures stopped being readable.
*/
private data class SnoozeUi(
val locallySnoozedMbids: Set<String>,
val snoozes: List<SuggestionSnoozeRef>,
val onSnooze: (ArtistSuggestionRef) -> Unit,
val onUnsnooze: (String, String) -> Unit,
)
@Composable
private fun SuggestionsPane(
state: SuggestionState,
locallyRequestedMbids: Set<String>,
snoozeUi: SnoozeUi,
onRequest: (ArtistSuggestionRef) -> Unit,
onRetry: () -> Unit,
) {
@@ -194,6 +220,7 @@ private fun SuggestionsPane(
)
is SuggestionState.Loaded -> SuggestionsList(
items = state.items.filter { it.mbid !in locallyRequestedMbids },
snoozeUi = snoozeUi,
onRequest = onRequest,
)
}
@@ -202,6 +229,7 @@ private fun SuggestionsPane(
@Composable
private fun SuggestionsList(
items: List<ArtistSuggestionRef>,
snoozeUi: SnoozeUi,
onRequest: (ArtistSuggestionRef) -> Unit,
) {
LazyColumn(
@@ -210,13 +238,61 @@ private fun SuggestionsList(
) {
item { SuggestionsHeader() }
if (items.isEmpty()) {
item { CenteredMessage("Listen to or like an artist to fill this in.") }
// An empty deck used to mean one thing — no listening signal yet.
// With snoozing it can also mean "you parked them all", and telling
// that user to go listen to something would be wrong advice.
item {
CenteredMessage(
if (snoozeUi.snoozes.isEmpty()) {
"Listen to or like an artist to fill this in."
} else {
"Nothing new right now — the artists you've parked are below."
},
)
}
} else {
items(items = items, key = { it.mbid }) { s ->
SuggestionTile(s = s, onRequest = { onRequest(s) })
SuggestionTile(
s = s,
snoozed = s.mbid in snoozeUi.locallySnoozedMbids,
onRequest = { onRequest(s) },
onSnooze = { snoozeUi.onSnooze(s) },
onUnsnooze = { snoozeUi.onUnsnooze(s.mbid, s.name) },
)
HorizontalDivider()
}
}
// Parked candidates live at the bottom of the same scroll, not behind a
// separate screen: it's a short list the user rarely needs, but it must
// be reachable — a snoozed candidate is gone from the deck above, so
// this is the only way back to it.
if (snoozeUi.snoozes.isNotEmpty()) {
item { SnoozedHeader() }
items(items = snoozeUi.snoozes, key = { "snoozed-${it.mbid}" }) { row ->
SnoozedTile(
row = row,
onUnsnooze = { snoozeUi.onUnsnooze(row.mbid, row.name) },
)
HorizontalDivider()
}
}
}
}
@Composable
private fun SnoozedHeader() {
Column(modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp)) {
HorizontalDivider(modifier = Modifier.padding(bottom = 12.dp))
Text(
text = "Not right now",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onBackground,
)
Text(
text = "These come back on their own. Nothing here counts against your taste profile.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
@@ -14,8 +14,10 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.AssistChip
import androidx.compose.material3.Button
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -24,14 +26,22 @@ import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage
import com.composables.icons.lucide.Clock
import com.composables.icons.lucide.Disc3
import com.composables.icons.lucide.Lucide
import com.composables.icons.lucide.User
import com.fabledsword.minstrel.models.ArtistSuggestionRef
import com.fabledsword.minstrel.models.LidarrSearchResultRef
import com.fabledsword.minstrel.models.SuggestionSnoozeRef
@Composable
internal fun SuggestionTile(s: ArtistSuggestionRef, onRequest: () -> Unit) {
internal fun SuggestionTile(
s: ArtistSuggestionRef,
snoozed: Boolean,
onRequest: () -> Unit,
onSnooze: () -> Unit,
onUnsnooze: () -> Unit,
) {
Row(
modifier = Modifier
.fillMaxWidth()
@@ -48,9 +58,13 @@ internal fun SuggestionTile(s: ArtistSuggestionRef, onRequest: () -> Unit) {
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
if (s.attributionText.isNotEmpty()) {
// Once parked, the "because you liked X" line is no longer the
// useful thing to say — confirming what just happened is.
val secondary =
if (snoozed) "Not right now — hidden for a while" else s.reasonText
if (secondary.isNotEmpty()) {
Text(
text = s.attributionText,
text = secondary,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 2,
@@ -58,7 +72,52 @@ internal fun SuggestionTile(s: ArtistSuggestionRef, onRequest: () -> Unit) {
)
}
}
Button(onClick = onRequest) { Text("Request") }
if (snoozed) {
TextButton(onClick = onUnsnooze) { Text("Undo") }
} else {
Button(onClick = onRequest) { Text("Request") }
IconButton(onClick = onSnooze) {
Icon(
imageVector = Lucide.Clock,
// Rule #101: the label states what happens, and passes no
// judgement on the music. Never "not for me".
contentDescription = "Not right now — hide ${s.name} for a while",
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
/**
* One row of the parked list. This exists because a snoozed candidate is by
* definition absent from the deck above, so without it there is no route back
* to an un-snooze once the card has gone.
*/
@Composable
internal fun SnoozedTile(row: SuggestionSnoozeRef, onUnsnooze: () -> Unit) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = row.name,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
text = "Back ${row.returnsIn()}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
TextButton(onClick = onUnsnooze) { Text("Bring back") }
}
}
@@ -10,6 +10,7 @@ import com.fabledsword.minstrel.discover.data.RequestOutcome
import com.fabledsword.minstrel.models.ArtistSuggestionRef
import com.fabledsword.minstrel.models.LidarrRequestKind
import com.fabledsword.minstrel.models.LidarrSearchResultRef
import com.fabledsword.minstrel.models.SuggestionSnoozeRef
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
@@ -27,6 +28,17 @@ data class DiscoverState(
val suggestions: SuggestionState = SuggestionState.Loading,
val results: ResultsState = ResultsState.Idle,
val locallyRequestedMbids: Set<String> = emptySet(),
/**
* Parked candidates, for the manage list under the feed. Empty is the
* normal case and hides the section entirely.
*/
val snoozes: List<SuggestionSnoozeRef> = emptyList(),
/**
* Just-snoozed MBIDs. These keep their row visible showing an Undo rather
* than yanking it out from under the user's finger; the row is gone on the
* next load, and [snoozes] is the way back after that.
*/
val locallySnoozedMbids: Set<String> = emptySet(),
)
sealed interface SuggestionState {
@@ -96,6 +108,47 @@ class DiscoverViewModel @Inject constructor(
)
}
}
// Refresh the parked list alongside the deck: a snooze made on another
// client should show up here, and one whose window lapsed should drop
// off. Sequenced after the deck load rather than raced with it so the
// two panes can't disagree about a candidate mid-refresh.
loadSnoozes()
}
/**
* Loads the parked list. Failure is deliberately silent: this is a
* secondary pane, and an error banner for it would sit above the suggestion
* feed the user actually came for. The list stays as-is and the next
* refresh retries.
*/
private suspend fun loadSnoozes() {
try {
val rows = repository.listSnoozes()
internal.update { it.copy(snoozes = rows) }
} catch (
@Suppress("TooGenericExceptionCaught", "SwallowedException") e: Throwable,
) {
// Keep whatever we last showed rather than blanking the section.
}
}
/**
* Parks a suggestion. Flips the row locally first so the tap registers
* immediately; the repository handles the offline case, so there is no
* failure branch to revert here — unlike the web client, where the fetch
* either lands or doesn't.
*/
suspend fun snoozeSuggestion(s: ArtistSuggestionRef) {
internal.update { it.copy(locallySnoozedMbids = it.locallySnoozedMbids + s.mbid) }
repository.snoozeSuggestion(s.mbid, s.name)
loadSnoozes()
}
/** Brings a parked suggestion back, from either the card or the list. */
suspend fun unsnoozeSuggestion(mbid: String, name: String) {
internal.update { it.copy(locallySnoozedMbids = it.locallySnoozedMbids - mbid) }
repository.unsnoozeSuggestion(mbid, name)
loadSnoozes()
}
fun runSearch() {
@@ -14,8 +14,10 @@ import com.fabledsword.minstrel.models.TrackRef
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
import retrofit2.Retrofit
import retrofit2.create
import javax.inject.Inject
@@ -34,9 +36,10 @@ import javax.inject.Singleton
* reveals when the fetch lands and Room re-emits. Mirrors Flutter's
* per-item tile providers.
*
* `refreshIndex()` pulls `GET /api/home/index`, replaces each section
* in-place (delete-then-insert, so the section Flows re-fire), and
* pre-warms the top artists via [HomeArtistPrewarmer].
* `refreshIndex()` pulls `GET /api/home/index`, swaps all sections in
* one transaction (so the rows update together in a single emission
* rather than collapsing and refilling), and pre-warms the top artists
* via [HomeArtistPrewarmer].
*/
@Singleton
// Per-section observe accessors (one per Home row) inflate the function
@@ -85,72 +88,98 @@ class HomeRepository @Inject constructor(
fun observeYouMightLikeArtists(): Flow<List<HomeTile<ArtistRef>>> =
observeArtistSection(SECTION_YOU_MIGHT_LIKE_ARTISTS)
/** True when the index cache already has content on screen to protect. */
suspend fun hasCachedIndex(): Boolean = homeIndexDao.hasAny()
/**
* Pulls /api/home/index, replaces each cached_home_index section,
* and pre-warms the top artists. The section Flows re-fire on the
* index change; missing entity rows hydrate via the on-miss path.
* Pulls /api/home/index and swaps every cached_home_index section in
* a single transaction, then pre-warms the top artists. Missing
* entity rows hydrate via the on-miss path.
*
* One transaction for all seven sections is deliberate: Room notifies
* observers once, on commit, so Home swaps from the old content to
* the new in a single emission. Per-section, un-transacted writes
* made every row visibly collapse to empty and refill, one after
* another (issue #2327).
*/
suspend fun refreshIndex() {
val wire = api.getHomeIndex()
replaceSection(SECTION_RECENTLY_ADDED_ALBUMS, "album", wire.recentlyAddedAlbums)
replaceSection(SECTION_REDISCOVER_ALBUMS, "album", wire.rediscoverAlbums)
replaceSection(SECTION_REDISCOVER_ARTISTS, "artist", wire.rediscoverArtists)
replaceSection(SECTION_MOST_PLAYED_TRACKS, "track", wire.mostPlayedTracks)
replaceSection(SECTION_LAST_PLAYED_ARTISTS, "artist", wire.lastPlayedArtists)
replaceSection(SECTION_YOU_MIGHT_LIKE_ALBUMS, "album", wire.youMightLikeAlbums)
replaceSection(SECTION_YOU_MIGHT_LIKE_ARTISTS, "artist", wire.youMightLikeArtists)
homeIndexDao.replaceSections(
sections = ALL_SECTIONS,
rows = rowsFor(SECTION_RECENTLY_ADDED_ALBUMS, "album", wire.recentlyAddedAlbums) +
rowsFor(SECTION_REDISCOVER_ALBUMS, "album", wire.rediscoverAlbums) +
rowsFor(SECTION_REDISCOVER_ARTISTS, "artist", wire.rediscoverArtists) +
rowsFor(SECTION_MOST_PLAYED_TRACKS, "track", wire.mostPlayedTracks) +
rowsFor(SECTION_LAST_PLAYED_ARTISTS, "artist", wire.lastPlayedArtists) +
rowsFor(SECTION_YOU_MIGHT_LIKE_ALBUMS, "album", wire.youMightLikeAlbums) +
rowsFor(SECTION_YOU_MIGHT_LIKE_ARTISTS, "artist", wire.youMightLikeArtists),
)
prewarmer.warm(
wire.rediscoverArtists + wire.lastPlayedArtists + wire.youMightLikeArtists,
)
}
private suspend fun replaceSection(section: String, entityType: String, ids: List<String>) {
homeIndexDao.deleteBySection(section)
if (ids.isEmpty()) return
homeIndexDao.upsertAll(
ids.mapIndexed { index, id ->
CachedHomeIndexEntity(
section = section,
position = index,
entityType = entityType,
entityId = id,
)
},
private fun rowsFor(
section: String,
entityType: String,
ids: List<String>,
): List<CachedHomeIndexEntity> = ids.mapIndexed { index, id ->
CachedHomeIndexEntity(
section = section,
position = index,
entityType = entityType,
entityId = id,
)
}
/**
* The section's ordered entity ids, deduplicated.
*
* Room re-runs the query on every write to `cached_home_index` — and
* `CachedHomeIndexEntity.fetchedAt` is stamped fresh each time — so
* comparing whole rows would call every rewrite a change. Comparing
* the id list instead means a section whose contents didn't actually
* move never restarts the `flatMapLatest` below, which would
* otherwise tear down and rebuild all of its tiles' hydration flows
* and flicker unchanged tiles (issue #2327).
*/
private fun observeSectionIds(section: String): Flow<List<String>> =
homeIndexDao.observeBySection(section)
.map { rows -> rows.map { it.entityId } }
.distinctUntilChanged()
@OptIn(ExperimentalCoroutinesApi::class)
private fun observeAlbumSection(section: String): Flow<List<HomeTile<AlbumRef>>> =
homeIndexDao.observeBySection(section).flatMapLatest { rows ->
if (rows.isEmpty()) {
observeSectionIds(section).flatMapLatest { ids ->
if (ids.isEmpty()) {
flowOf(emptyList())
} else {
combine(rows.map { metadataProvider.observeAlbum(it.entityId) }) { refs ->
rows.mapIndexed { i, r -> HomeTile(r.entityId, refs[i]) }
combine(ids.map { metadataProvider.observeAlbum(it) }) { refs ->
ids.mapIndexed { i, id -> HomeTile(id, refs[i]) }
}
}
}
@OptIn(ExperimentalCoroutinesApi::class)
private fun observeArtistSection(section: String): Flow<List<HomeTile<ArtistRef>>> =
homeIndexDao.observeBySection(section).flatMapLatest { rows ->
if (rows.isEmpty()) {
observeSectionIds(section).flatMapLatest { ids ->
if (ids.isEmpty()) {
flowOf(emptyList())
} else {
combine(rows.map { metadataProvider.observeArtist(it.entityId) }) { refs ->
rows.mapIndexed { i, r -> HomeTile(r.entityId, refs[i]) }
combine(ids.map { metadataProvider.observeArtist(it) }) { refs ->
ids.mapIndexed { i, id -> HomeTile(id, refs[i]) }
}
}
}
@OptIn(ExperimentalCoroutinesApi::class)
private fun observeTrackSection(section: String): Flow<List<HomeTile<TrackRef>>> =
homeIndexDao.observeBySection(section).flatMapLatest { rows ->
if (rows.isEmpty()) {
observeSectionIds(section).flatMapLatest { ids ->
if (ids.isEmpty()) {
flowOf(emptyList())
} else {
combine(rows.map { metadataProvider.observeTrack(it.entityId) }) { refs ->
rows.mapIndexed { i, r -> HomeTile(r.entityId, refs[i]) }
combine(ids.map { metadataProvider.observeTrack(it) }) { refs ->
ids.mapIndexed { i, id -> HomeTile(id, refs[i]) }
}
}
}
@@ -163,5 +192,16 @@ class HomeRepository @Inject constructor(
const val SECTION_LAST_PLAYED_ARTISTS = "last_played_artists"
const val SECTION_YOU_MIGHT_LIKE_ALBUMS = "you_might_like_albums"
const val SECTION_YOU_MIGHT_LIKE_ARTISTS = "you_might_like_artists"
/** Every section [refreshIndex] owns — the unit of one atomic swap. */
val ALL_SECTIONS = listOf(
SECTION_RECENTLY_ADDED_ALBUMS,
SECTION_REDISCOVER_ALBUMS,
SECTION_REDISCOVER_ARTISTS,
SECTION_MOST_PLAYED_TRACKS,
SECTION_LAST_PLAYED_ARTISTS,
SECTION_YOU_MIGHT_LIKE_ALBUMS,
SECTION_YOU_MIGHT_LIKE_ARTISTS,
)
}
}
@@ -43,6 +43,7 @@ import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
@@ -85,30 +86,38 @@ import com.fabledsword.minstrel.playlists.widgets.OfflinePoolCard
import com.fabledsword.minstrel.playlists.widgets.PlaylistCard
import com.fabledsword.minstrel.playlists.widgets.PlaylistPlaceholderCard
import com.fabledsword.minstrel.shared.UiState
import com.fabledsword.minstrel.shared.UpdateVeilController
import com.fabledsword.minstrel.shared.VeilOutcome
import com.fabledsword.minstrel.shared.VeilSessionResult
import com.fabledsword.minstrel.shared.VeilSettleState
import com.fabledsword.minstrel.shared.asCacheFirstStateFlow
import com.fabledsword.minstrel.shared.widgets.ArtSettleTracker
import com.fabledsword.minstrel.shared.widgets.EmptyState
import com.fabledsword.minstrel.shared.widgets.ErrorRetry
import com.fabledsword.minstrel.shared.widgets.HorizontalScrollRow
import com.fabledsword.minstrel.shared.widgets.LocalArtSettleTracker
import com.fabledsword.minstrel.shared.widgets.MinstrelTopAppBar
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
import com.fabledsword.minstrel.shared.widgets.SkeletonAlbumTile
import com.fabledsword.minstrel.shared.widgets.SkeletonArtistTile
import com.fabledsword.minstrel.shared.widgets.SkeletonSectionHeader
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Job
import kotlinx.coroutines.async
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.delay
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeoutOrNull
import javax.inject.Inject
private const val SHARE_STOP_TIMEOUT_MS = 5_000L
@@ -120,16 +129,22 @@ private const val BOTTOM_PADDING_FOR_MINIPLAYER_DP = 140
private const val RECENTLY_ADDED_GRID_ROWS = 2
private const val RECENTLY_ADDED_GRID_HEIGHT_DP = 440
// "Updating your mixes…" veil (automatic refresh). Held through the pull
// plus VEIL_SETTLE_MS so per-tile hydration lands behind it before it wipes
// off; near-opaque (VEIL_ALPHA) so the section churn never bleeds through.
private const val VEIL_SETTLE_MS = 500L
// "Updating your mixes…" veil. UpdateVeilController decides both whether it
// appears at all — only when a refresh actually changes something — and how
// long it stays, by watching the screen settle rather than by a fixed delay,
// which lowered it while tiles and artwork were still landing (#2327).
// Near-opaque (VEIL_ALPHA) so the section churn never bleeds through.
private const val VEIL_WIPE_MS = 280
private const val VEIL_ALPHA = 0.96f
private const val VEIL_SPINNER_DP = 22
private const val VEIL_SPINNER_STROKE_DP = 2
private const val VEIL_LABEL_GAP_DP = 12
// Backstop on how long a manual pull keeps its own indicator while waiting
// for its successor — the veil, or the "already up to date" snackbar — so the
// two never both vanish for a frame mid-handoff.
private const val PULL_HANDOFF_TIMEOUT_MS = 2_000L
// ─── State ───────────────────────────────────────────────────────────
data class HomeSections(
@@ -184,10 +199,13 @@ class HomeViewModel @Inject constructor(
initialValue = false,
)
private val poolMessages = Channel<String>(Channel.BUFFERED)
private val snackbarMessages = Channel<String>(Channel.BUFFERED)
/** Transient snackbar messages from offline-pool taps. */
val transientMessages: Flow<String> = poolMessages.receiveAsFlow()
/**
* Transient snackbar messages: offline-pool taps, playback failures, and
* the outcome of a refresh the user explicitly asked for.
*/
val transientMessages: Flow<String> = snackbarMessages.receiveAsFlow()
/**
* Copy for the most recent /home/index refresh failure; null once a
@@ -197,38 +215,13 @@ class HomeViewModel @Inject constructor(
*/
private val refreshError = MutableStateFlow<String?>(null)
private val updatingInternal = MutableStateFlow(false)
/**
* True while an automatic background refresh (the 03:00 daily rebuild
* or a reconnect re-pull) is repopulating Home. Drives the "Updating
* your mixes…" veil so the section churn — delete-then-insert in
* [HomeRepository.refreshIndex] plus per-tile hydration — happens
* hidden behind the veil instead of on screen. Manual pull-to-refresh
* and cold start are NOT veiled (they own the pull spinner / skeleton).
* Cover-art loads in flight on Home, reported by every [ServerImage]
* under [LocalArtSettleTracker]. The veil waits on this so artwork
* arriving a beat after its tile lands behind the veil rather than
* popping in on screen.
*/
val isUpdating: StateFlow<Boolean> = updatingInternal.asStateFlow()
init {
refresh()
// Screen-level auto-recovery (issue #1245): a Home that failed to
// load while the server was unreachable re-pulls itself the moment
// health returns — same idiom as SyncController, one layer up.
// Veiled: content is already on screen and would otherwise churn.
viewModelScope.launch {
networkStatus.recoveries().collect { refreshBehindVeil() }
}
// #968: the daily 03:00 rebuild (and manual refresh) emit
// playlist.system_rebuilt; re-pull Home so the system-playlist tiles
// and You-might-like rows reflect the new snapshot without a manual
// reload. Mirrors the web SSE consumer. Veiled so the multi-section
// rebuild churn hides behind "Updating your mixes…".
viewModelScope.launch {
eventsStream.events
.filter { it.kind == "playlist.system_rebuilt" }
.collect { refreshBehindVeil() }
}
}
val artTracker = ArtSettleTracker()
/**
* Tap an offline pool: shuffle + play its cached tracks. Empty
@@ -241,7 +234,7 @@ class HomeViewModel @Inject constructor(
OfflinePoolKind.LIKED -> shuffleSource.liked()
}.shuffled()
if (tracks.isEmpty()) {
poolMessages.trySend("No cached ${kind.label} tracks yet")
snackbarMessages.trySend("No cached ${kind.label} tracks yet")
} else {
player.setQueue(tracks, initialIndex = 0, source = "offline:${kind.name}")
}
@@ -278,14 +271,14 @@ class HomeViewModel @Inject constructor(
try {
val detail = libraryRepository.refreshAlbumDetail(albumId)
if (detail.tracks.isEmpty()) {
poolMessages.trySend("This album has no tracks to play.")
snackbarMessages.trySend("This album has no tracks to play.")
} else {
player.setQueue(detail.tracks, initialIndex = 0, source = "album:$albumId")
}
} catch (
@Suppress("TooGenericExceptionCaught") e: Throwable,
) {
poolMessages.trySend(
snackbarMessages.trySend(
"Couldn't start playback: ${ErrorCopy.fromThrowable(e)}",
)
}
@@ -303,14 +296,14 @@ class HomeViewModel @Inject constructor(
try {
val tracks = libraryRepository.fetchArtistTracks(artistId).shuffled()
if (tracks.isEmpty()) {
poolMessages.trySend("This artist has no tracks to play.")
snackbarMessages.trySend("This artist has no tracks to play.")
} else {
player.setQueue(tracks, initialIndex = 0, source = "artist:$artistId")
}
} catch (
@Suppress("TooGenericExceptionCaught") e: Throwable,
) {
poolMessages.trySend(
snackbarMessages.trySend(
"Couldn't start playback: ${ErrorCopy.fromThrowable(e)}",
)
}
@@ -328,52 +321,66 @@ class HomeViewModel @Inject constructor(
suspend fun playPlaylist(playlist: PlaylistRef) {
viewModelScope.launch {
playPlaylistShuffled(playlist, playlistsRepository, player) {
poolMessages.trySend(it)
snackbarMessages.trySend(it)
}
}.join()
}
/**
* Pulls both /home/index and the playlists list. Returns the Job
* for the combined refresh so a pull-to-refresh wrapper can await
* actual completion before hiding the indicator.
* Pulls /home/index, the playlists list and the system-playlist
* status. Returns true when the load-bearing /home/index pull
* succeeded — the veil controller retries on false and reports the
* outcome, so this must report failure rather than swallow it.
*/
fun refresh(): Job = viewModelScope.launch {
refreshError.value = null
val home = launch {
// /home/index is the load-bearing pull: its failure drives the
// empty-cache Error state. A failure over a populated cache
// stays silent — cached sections beat a full-screen error.
private suspend fun runRefresh(): Boolean = coroutineScope {
// /home/index is the load-bearing pull: its failure drives the
// empty-cache Error state. A failure over a populated cache
// stays silent — cached sections beat a full-screen error.
//
// Cleared on success, NOT at the start of each attempt: with the
// veil's retries, clearing up front made a failing cold start
// flash the "Welcome to Minstrel" empty state (empty cache + no
// error reads as Empty) between one attempt and the next.
val home = async {
runCatching { homeRepository.refreshIndex() }
.onSuccess { refreshError.value = null }
.onFailure { refreshError.value = ErrorCopy.fromThrowable(it) }
.isSuccess
}
val lists = launch { runCatching { playlistsRepository.refreshList() } }
val status = launch {
runCatching { homeRepository.getSystemPlaylistsStatus() }
.onSuccess { systemStatusInternal.value = it }
}
home.join()
lists.join()
status.join()
home.await()
}
/**
* Automatic background refresh with the "Updating your mixes…" veil
* raised (see [isUpdating]). Used by the daily-rebuild + reconnect
* paths where Home is already on screen. Holds the veil through the
* pull plus a short settle so per-tile hydration lands behind it, then
* lets it wipe off. Overlapping automatic refreshes are rare enough
* (once-daily rebuild, reconnect) that a plain flag beats a counter.
* The Error state's explicit Retry button. User-initiated, so it gets
* the controller's retries and reports its outcome; over an empty cache
* there's no content to protect, so no veil goes up.
*/
private fun refreshBehindVeil() {
viewModelScope.launch {
updatingInternal.value = true
try {
refresh().join()
delay(VEIL_SETTLE_MS)
} finally {
updatingInternal.value = false
}
fun retry() = veil.request(userInitiated = true)
/**
* Manual pull-to-refresh. Goes behind the veil like every other refresh
* (operator call, 2026-07-31: the churn a pull causes is identical to
* the automatic paths, and a small spinner didn't hide it).
*
* Suspends until the veil has taken over OR the session has finished,
* so the pull indicator hands off to exactly one successor: the veil if
* content changed, the "Already up to date" snackbar if it didn't. The
* timeout is only a backstop against a session that outlives it.
*/
suspend fun refreshFromPull() {
val before = veil.finishedSessions.value
veil.request(userInitiated = true)
withTimeoutOrNull(PULL_HANDOFF_TIMEOUT_MS) {
combine(veil.visible, veil.finishedSessions) { veiled, finished ->
veiled || finished != before
}.first { it }
}
}
@@ -425,6 +432,124 @@ class HomeViewModel @Inject constructor(
else -> UiState.Empty
}
}
// ─── Updating veil ───────────────────────────────────────────────
// Declared after uiState: these initialisers read it, and Kotlin runs
// property initialisers and init blocks in declaration order.
/**
* What the veil watches to decide Home has stopped moving: the whole
* rendered state, plus how many covers are still loading.
*
* [UiState.Success] wraps a [HomeSections] data class, so any visible
* change — a section swapping ids, one tile hydrating from skeleton to
* album — changes this value and re-arms the veil's quiet window.
*
* Unhydrated tiles deliberately do NOT gate `quiescent`. A tile whose
* on-miss fetch soft-fails keeps a null value indefinitely
* ([MetadataProvider] swallows those errors), so treating "no
* skeletons left" as the settle condition would pin the veil to its
* hard ceiling on every refresh. They're covered by the content key
* instead: each tile that lands re-arms the window, and once they stop
* landing the screen is genuinely still.
*/
private val settleSignal: Flow<VeilSettleState> =
combine(uiState, artTracker.inFlight) { state, artInFlight ->
VeilSettleState(
contentKey = state,
hasContent = state is UiState.Success,
quiescent = artInFlight == 0,
)
}
private val veil = UpdateVeilController(
scope = viewModelScope,
settleSignal = settleSignal,
shouldVeil = {
// Only worth hiding churn when there's already content to
// hide. A cold load over an empty cache keeps its skeleton —
// veiling that would replace a useful affordance with an
// opaque panel. `hasCachedIndex` is the honest check: uiState
// still reads Loading until the screen subscribes, so on a
// process restore over a warm cache it would say "no content"
// right before the cache emits.
uiState.value is UiState.Success || homeRepository.hasCachedIndex()
},
onSessionEnd = ::reportRefreshOutcome,
work = ::runRefresh,
)
/**
* Tells the user how a refresh *they asked for* went, in the one case
* the veil can't: when nothing changed there's no veil to see, and a
* pull that produces no visible response at all reads as broken.
*
* Only user-initiated sessions say anything. The same outcome from a
* background check — the initial load, the 03:00 rebuild, a reconnect —
* is noise, and "Already up to date" on every launch would be worse
* than silence (operator's call, 2026-07-31).
*/
private fun reportRefreshOutcome(result: VeilSessionResult) {
if (!result.userInitiated) return
when (result.outcome) {
// The veil was the feedback.
VeilOutcome.CHANGED -> return
VeilOutcome.UNCHANGED -> snackbarMessages.trySend("Already up to date")
VeilOutcome.FAILED -> snackbarMessages.trySend("Couldn't check for updates")
}
}
/**
* True while the "Updating your mixes…" veil should be raised.
*
* Raised only when a refresh actually changes what's on screen, and then
* held until Home settles — tiles hydrated, artwork loaded — instead of
* for a fixed delay after the network pull returns (issue #2327). A
* refresh that returns what's already cached shows no veil at all;
* [reportRefreshOutcome] tells the user instead, if they asked.
*/
val isUpdating: StateFlow<Boolean> = veil.visible
init {
// Every refresh path goes through the controller, which decides
// per session whether to raise the veil. That includes the initial
// load: over a warm cache it's a full re-pull that churns every
// section, and it used to run completely unveiled.
veil.request()
// Screen-level auto-recovery (issue #1245): a Home that failed to
// load while the server was unreachable re-pulls itself the moment
// health returns — same idiom as SyncController, one layer up.
// This is also the recovery that keeps trying after the veil has
// given up and lowered; the controller sets no latch against it.
viewModelScope.launch {
networkStatus.recoveries().collect { veil.request() }
}
// Server-side changes that rewrite what Home renders (#968 and
// the 2026-07-31 widening) re-pull behind the veil. Mirrors the
// web SSE consumer.
viewModelScope.launch {
eventsStream.events
.filter { it.kind in VEILED_EVENT_KINDS }
.collect { veil.request() }
}
}
private companion object {
/**
* Events that change what Home shows. `playlist.system_rebuilt`
* is the 03:00 daily rebuild; the other `playlist.*` kinds move
* the Playlists and Songs-like rows; `scan.run_finished` changes
* Recently added (and Home never reacted to it at all before).
*/
private val VEILED_EVENT_KINDS = setOf(
"playlist.system_rebuilt",
"playlist.created",
"playlist.updated",
"playlist.deleted",
"playlist.tracks_changed",
"scan.run_finished",
)
}
}
// ─── Screen ──────────────────────────────────────────────────────────
@@ -454,14 +579,20 @@ fun HomeScreen(
val offline by viewModel.offline.collectAsStateWithLifecycle()
val updating by viewModel.isUpdating.collectAsStateWithLifecycle()
PullToRefreshScaffold(
onRefresh = { viewModel.refresh().join() },
onRefresh = { viewModel.refreshFromPull() },
modifier = Modifier.fillMaxSize().padding(inner),
) {
Box(Modifier.fillMaxSize()) {
HomeStateCrossfade(state, systemStatus, offline, navController, viewModel)
// Automatic-refresh veil: the daily rebuild / reconnect
// churn hides behind an "Updating your mixes…" wipe. Manual
// pull owns the PullToRefreshBox spinner instead.
// Every cover below reports its load state to the tracker,
// so the veil can wait for artwork instead of guessing.
CompositionLocalProvider(
LocalArtSettleTracker provides viewModel.artTracker,
) {
HomeStateCrossfade(state, systemStatus, offline, navController, viewModel)
}
// Refresh veil: rebuild / reconnect / pull / event churn all
// hide behind an "Updating your mixes…" wipe that stays up
// until the screen has actually stopped moving.
UpdatingVeil(visible = updating)
}
}
@@ -499,7 +630,7 @@ private fun HomeStateCrossfade(
is UiState.Error -> ErrorRetry(
title = "Couldn't load home",
message = s.message,
onRetry = { viewModel.refresh() },
onRetry = { viewModel.retry() },
)
is UiState.Success -> HomeSuccessContent(
sections = s.data,
@@ -1,5 +1,8 @@
package com.fabledsword.minstrel.models
import kotlinx.datetime.Instant
import kotlin.math.roundToInt
/**
* Kind of Lidarr request being created. Wire form is the lowercase
* enum name; the helper [wire] keeps that mapping in one place.
@@ -49,6 +52,8 @@ data class ArtistSuggestionRef(
val name: String,
val imageUrl: String = "",
val attribution: List<SeedContributionRef> = emptyList(),
/** Taste-profile tags this candidate matches, strongest first (#2377). */
val matchedTags: List<String> = emptyList(),
) {
val attributionText: String
get() {
@@ -63,7 +68,92 @@ data class ArtistSuggestionRef(
}
}
/**
* The subtitle line for the card.
*
* Prefers the taste-tag reason over seed attribution when we have one,
* because it describes the MUSIC ("sounds like what you like") rather than
* the graph ("adjacent to something you played") — the whole point of
* milestone #268 slice 6. Falls back to attribution, which is the common
* case: tag coverage for out-of-library artists is partial by nature
* (#2376), so most candidates have no matched tags.
*
* Kept in lockstep with the web client's reasonText() in
* SuggestionFeed.svelte — same wording, same Oxford comma.
*/
val reasonText: String
get() {
val tags = matchedTags.take(MAX_ATTRIBUTION_PHRASES)
return when (tags.size) {
0 -> attributionText
1 -> "Matches your taste in ${tags[0]}."
2 -> "Matches your taste in ${tags[0]} and ${tags[1]}."
else -> "Matches your taste in ${tags[0]}, ${tags[1]}, and ${tags[2]}."
}
}
companion object {
private const val MAX_ATTRIBUTION_PHRASES = 3
}
}
/**
* A suggestion the user parked with "not right now" (#2374).
*
* Deliberately NOT a dislike: it carries no verdict on the artist, expires on
* its own, and never reaches the taste profile. Anything that treats this as
* negative preference signal is a bug.
*
* [snoozedUntil] is the raw RFC3339 string from the wire. Only the server
* decides whether a snooze is still in effect — every row the client receives
* already is — so this is read purely to phrase "back in about 3 months".
*/
data class SuggestionSnoozeRef(
val mbid: String,
val name: String,
val snoozedUntil: String,
) {
/**
* Relative return phrase for the manage list. Relative rather than a
* calendar date because the exact day a 90-day snooze lapses is noise the
* user never asked for.
*
* [nowMs] is injectable so this is testable without freezing the clock.
* Returns "shortly" for an unparseable or already-past timestamp: the row
* is on screen, so the server still considers it snoozed, and guessing is
* better than rendering an empty line.
*/
fun returnsIn(nowMs: Long = System.currentTimeMillis()): String {
val remainingMs = runCatching { Instant.parse(snoozedUntil).toEpochMilliseconds() }
.getOrNull()?.minus(nowMs)
// Two ways to have nothing to state: an unparseable timestamp, or one
// already lapsed by our clock though the server still returned the row
// (the two disagree). Neither is "today", which would read as a real
// prediction.
if (remainingMs == null || remainingMs <= 0) return "shortly"
val days = (remainingMs.toDouble() / MILLIS_PER_DAY).roundToInt()
return when {
days < 1 -> "today"
days == 1 -> "tomorrow"
days < DAYS_BEFORE_MONTHS -> "in $days days"
else -> {
val months = (days.toDouble() / DAYS_PER_MONTH).roundToInt()
if (months == 1) "in about a month" else "in about $months months"
}
}
}
private companion object {
const val MILLIS_PER_DAY = 86_400_000.0
// Below this, days read more naturally than a rounded month count.
//
// Must be <= DAYS_PER_MONTH, or the singular "in about a month" is
// unreachable: a rounded month count of 1 needs 15..44 days, and any
// threshold above 30 sends all of those down the days branch instead.
// This was 45 and the singular branch was dead code — the unit test
// for it is what surfaced that.
const val DAYS_BEFORE_MONTHS = 30
const val DAYS_PER_MONTH = 30.0
}
}
@@ -35,6 +35,13 @@ data class ArtistSuggestionWire(
val name: String = "",
@SerialName("image_url") val imageUrl: String = "",
val attribution: List<SeedContributionWire> = emptyList(),
/**
* Tags this candidate shares with the user's taste profile, strongest
* first (max 3, #2377). Absent for most candidates — tag coverage for
* out-of-library artists is partial by nature (#2376) — so the default
* empty list is the common case, not an error.
*/
@SerialName("matched_tags") val matchedTags: List<String> = emptyList(),
)
/**
@@ -48,6 +55,36 @@ data class SeedContributionWire(
@SerialName("is_liked") val isLiked: Boolean = false,
)
/**
* One row of `GET /api/discover/snoozes` — a suggestion the user parked
* with "not right now". The server only returns rows that are still in
* effect, so the client never compares [snoozedUntil] against the clock to
* decide whether to show it; it reads it only to say when the artist comes
* back.
*/
@Serializable
data class SuggestionSnoozeWire(
val mbid: String = "",
val name: String = "",
@SerialName("snoozed_until") val snoozedUntil: String = "",
@SerialName("created_at") val createdAt: String = "",
)
/**
* Body for `POST /api/discover/suggestions/{mbid}/snooze`.
*
* [name] is required by the server, not decorative: suggestions are
* out-of-library, so there is no artists row to resolve a display name from
* and the snooze list would have nothing to render. Omitting it is a 400.
*
* No `days` field. The duration is the server's to own (90 days); pinning it
* client-side would freeze the default at whatever this build shipped.
*/
@Serializable
data class SnoozeSuggestionBody(
val name: String,
)
/**
* Body posted to `POST /api/requests`. Mirrors the Flutter `createRequest`
* payload shape. Optional fields are emitted only when non-null
@@ -17,6 +17,7 @@ import com.fabledsword.minstrel.player.output.ActiveUpnpHolder
import com.fabledsword.minstrel.player.output.upnp.SoapFaultException
import com.fabledsword.minstrel.player.output.upnp.TransportState
import java.io.IOException
import kotlin.math.abs
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
@@ -58,8 +59,10 @@ import timber.log.Timber
* native queue via ClearQueue + AddURIToQueue, then points the
* transport at x-rincon-queue:<udn>#0. Skip/prev/seekTo delegate to
* AVTransport Next/Previous/SeekToTrack so Sonos manages gap-free
* advance natively. PollLoop syncs the local cursor by comparing the
* 1-based Track index from GetPositionInfo.
* advance natively. PollLoop keeps the local cursor aligned to the
* track the renderer is actually playing, matched by track identity
* (the id in GetPositionInfo's TrackURI) so it survives queue-reload
* index drift -- see [syncLocalCursorToRemote].
*/
class MinstrelForwardingPlayer(
private val delegate: Player,
@@ -339,6 +342,23 @@ class MinstrelForwardingPlayer(
}
}
// The system media notification / lock-screen / Android Auto / Wear 'next'
// and 'previous' buttons issue COMMAND_SEEK_TO_NEXT / COMMAND_SEEK_TO_PREVIOUS
// -> Player.seekToNext() / seekToPrevious(), which are DISTINCT from the
// *MediaItem variants the in-app transport buttons call. Un-overridden, the
// base ForwardingPlayer forwards these to the paused local delegate -- so
// the notification next/prev only nudged the local cursor (which the poll
// then re-synced back to Sonos), reading as dead buttons while casting.
// Route them through the same Sonos path as the media-item variants when a
// UPnP route is engaged; plain local playback keeps the default behaviour.
override fun seekToNext() {
if (isRemote() || isLoadingUpnp()) seekToNextMediaItem() else super.seekToNext()
}
override fun seekToPrevious() {
if (isRemote() || isLoadingUpnp()) seekToPreviousMediaItem() else super.seekToPrevious()
}
override fun getCurrentPosition(): Long =
if (isRemote()) remoteState.positionMs else super.getCurrentPosition()
@@ -526,18 +546,8 @@ class MinstrelForwardingPlayer(
/**
* One poll tick: read position + transport state from Sonos, apply to
* [remoteState], and forward-sync the local cursor to Sonos's Track
* index when not in queue load.
*
* Cursor sync is gated on `holder.target == null` (= not loading)
* because during load Sonos reports Track=1 while we're still
* appending, and syncing would race the SetAV+Seek that lands
* after. Outside load, forward sync catches Sonos auto-advances
* (queue end-of-track), Sonos-app driven Next presses, and any
* drift after a brief poll-failure burst that didn't trip the
* drop threshold. Forward-only because a Next override we just
* issued can race with a poll still reporting the prior Track --
* the next poll catches up safely.
* [remoteState], and sync the local cursor to the track the renderer is
* actually playing (see [syncLocalCursorToRemote]).
*/
private suspend fun pollOnce(active: ActiveUpnp) {
val info = active.avTransport.getPositionInfo()
@@ -553,7 +563,7 @@ class MinstrelForwardingPlayer(
trackUri = info.trackUri,
trackNumber = info.track,
)
maybeSyncLocalCursor(info.track)
syncLocalCursorToRemote(sonosTrack = info.track, trackUri = info.trackUri)
val transport = active.avTransport.getTransportInfo()
when (transport.state) {
TransportState.PLAYING -> {
@@ -577,23 +587,95 @@ class MinstrelForwardingPlayer(
notifyRemoteStateChanged()
}
private fun maybeSyncLocalCursor(sonosTrack: Int) {
if (holder.target.value != null) return
if (sonosTrack <= 0) return
val sonosIdx = sonosTrack - 1
/**
* Align the paused local delegate cursor to the track the renderer is
* actually playing, so the un-overridden current-item getters
* (getCurrentMediaItem/Index -- what both the in-app player UI via
* PlayerController.onEvents AND the MediaSession notification read) always
* resolve to that track. This is THE single source of truth for "what's
* playing" during a cast.
*
* Identity-first: match the track-id embedded in the renderer's current URI
* against the delegate's MediaItem.mediaId (PlayerController sets mediaId =
* TrackRef.id), so the index-offset wobble across queue reloads / re-casts
* can never park the cursor on a stale (pre-cast) track. Falls back to the
* 1-based Sonos Track index only when the URI can't be resolved to a queue
* item. Unlike the old forward-only sync this moves the cursor in BOTH
* directions -- a renderer Previous / re-cast to a lower track pulls it back
* too. Runs on the application looper (delegate access contract).
*
* Suppressed while:
* - loading ([isLoadingUpnp]) -- the native Sonos queue is still being
* (re)uploaded, so its Track index is meaningless; and
* - a user transport (next/prev/seekTo idx) is pending Sonos's ack --
* the override already advanced the delegate optimistically, and Sonos
* still reports the OLD track for a beat, so syncing now would undo the
* user's press. The pending deadline is honoured directly so a missed
* clear can't wedge the sync.
*/
private fun syncLocalCursorToRemote(sonosTrack: Int, trackUri: String) {
if (isLoadingUpnp()) return
val pendingDeadline = remoteState.pendingTransportDeadlineMs
if (pendingDeadline > 0L && SystemClock.elapsedRealtime() < pendingDeadline) return
handler.post {
val localIdx = delegate.currentMediaItemIndex
if (sonosIdx > localIdx && sonosIdx < delegate.mediaItemCount) {
val target = resolveRemoteCursorIndex(sonosTrack, trackUri) ?: return@post
if (target != delegate.currentMediaItemIndex) {
Timber.w(
"UPnP cursor catch-up: local=%d -> sonos=%d",
localIdx, sonosIdx,
"UPnP cursor sync: local=%d -> %d",
delegate.currentMediaItemIndex, target,
)
delegate.seekTo(sonosIdx, 0L)
delegate.seekTo(target, 0L)
}
}
}
/**
* Most-authoritative -> most-degraded (project rule #48): the track-id
* parsed from the renderer's current URI wins; else the 1-based Sonos Track
* index; else null (leave the cursor put rather than surface a wrong track).
* Must run on the application looper -- reads the delegate timeline.
*/
private fun resolveRemoteCursorIndex(sonosTrack: Int, trackUri: String): Int? {
val byId = trackIdFromStreamUri(trackUri)?.let { id ->
nearestIndexWithMediaId(id, preferNear = sonosTrack - 1)
}
return byId ?: (sonosTrack - 1).takeIf { it in 0 until delegate.mediaItemCount }
}
/**
* Index of the delegate MediaItem whose mediaId == [mediaId], preferring the
* occurrence nearest [preferNear] so a track that appears twice in the queue
* doesn't snap the cursor across the queue. null if the id isn't present.
*/
private fun nearestIndexWithMediaId(mediaId: String, preferNear: Int): Int? {
var best: Int? = null
var bestDist = Int.MAX_VALUE
for (i in 0 until delegate.mediaItemCount) {
if (delegate.getMediaItemAt(i).mediaId == mediaId) {
val dist = abs(i - preferNear)
if (dist < bestDist) {
best = i
bestDist = dist
}
}
}
return best
}
/** Extract `{id}` from a `.../api/tracks/{id}/stream...` stream URI. */
private fun trackIdFromStreamUri(uri: String): String? {
val start = uri.indexOf(TRACKS_PATH_MARKER)
if (start < 0) return null
val idStart = start + TRACKS_PATH_MARKER.length
val idEnd = uri.indexOf('/', idStart)
return if (idEnd > idStart) uri.substring(idStart, idEnd) else null
}
private companion object {
// Path segment that precedes the track-id in a stream URI
// (…/api/tracks/{id}/stream…) — used to map the renderer's current URI
// back to a delegate MediaItem by identity.
const val TRACKS_PATH_MARKER = "/api/tracks/"
const val POLL_INTERVAL_MS = 1_000L
const val NON_PLAYING_CONFIRM = 2
const val SEEK_ACK_WINDOW_MS = 2_000L
@@ -288,6 +288,37 @@ class PlayerController @Inject constructor(
controller.addMediaItem(track.toMediaItem(source = null))
}
/**
* Reorder the queue: move the item at [from] to [to], keeping the domain
* snapshot in lock-step with the player's MediaItem timeline. Media3 emits
* onEvents → uiState reflects the new order (and the still-playing item's
* index). No-op on bad indices or a no-move.
*/
fun moveInQueue(from: Int, to: Int) {
val controller = mediaController ?: return
if (from !in queueRefs.indices || to !in queueRefs.indices || from == to) return
queueRefs = queueRefs.toMutableList().apply { add(to, removeAt(from)) }
controller.moveMediaItem(from, to)
}
/**
* Remove the queue item at [index]. When it's the currently-playing item
* Media3 advances to the next automatically. No-op on a bad index.
*/
fun removeFromQueue(index: Int) {
val controller = mediaController ?: return
if (index !in queueRefs.indices) return
queueRefs = queueRefs.toMutableList().apply { removeAt(index) }
controller.removeMediaItem(index)
}
/** Empty the queue and stop playback. */
fun clearQueue() {
val controller = mediaController ?: return
queueRefs = emptyList()
controller.clearMediaItems()
}
/**
* Seed a fresh radio queue from [trackId]. The `source` tag is
* "radio:<id>" so the server-side rotation reporter can
@@ -598,18 +629,19 @@ class PlayerController @Inject constructor(
}
/**
* One position-polling tick. Owns track-change detection too: when UPnP
* is active and the wrapped ExoPlayer is paused, `delegate.seekTo` from
* `maybeSyncLocalCursor` may not fire `onMediaItemTransition`, leaving
* uiState.queueIndex stuck on the old track even after Sonos has
* advanced. So the tick reads Sonos's reported Track as the source of
* truth, rebuilds the index/title fields itself, and force-syncs the
* wrapped player as defense in depth.
* One position-polling tick. Patches ONLY the smoothly-changing transport
* fields (position / duration / play-pause / buffer) via `.copy()`. The
* queue index + current track are owned solely by [onEvents], which reads
* the authoritative delegate cursor: during a cast,
* [MinstrelForwardingPlayer.syncLocalCursorToRemote] keeps that cursor on
* the track the renderer is actually playing (matched by identity), so there
* is exactly one writer of "what's playing" and a stale tick can never
* revert it to the old track. (Previously the tick was a second index writer
* that fought onEvents — the flicker-to-stale-track bug.)
*/
private fun tickPositionPoll(controller: MediaController) {
val upnpActive = activeUpnpHolder.active.value != null
resolvePendingTransport(controller, upnpActive)
val pendingTransport = upnpActive && remoteState.pendingTransportDeadlineMs > 0L
val effectiveIsPlaying =
if (upnpActive) remoteState.isPlaying else controller.isPlaying
val effectivePosition = if (upnpActive) {
@@ -617,27 +649,29 @@ class PlayerController @Inject constructor(
} else {
controller.currentPosition
}
val desiredIdx = desiredQueueIndex(controller, upnpActive)
val idx = controller.currentMediaItemIndex
val current = uiStateInternal.value
val newPos = effectivePosition.coerceAtLeast(0)
val newDur = effectiveDuration(
upnpActive,
remoteState.durationMs,
controller.duration,
desiredIdx = desiredIdx,
controllerIdx = controller.currentMediaItemIndex,
desiredIdx = idx,
controllerIdx = idx,
)
val newBuf = controller.bufferedPosition.coerceAtLeast(0)
// Track adjustments are forward-only AND suppressed while a user
// transport press is pending Sonos confirmation. Together those keep
// either direction of user input from being undone by a stale poll.
val trackChanged = !pendingTransport &&
desiredIdx > current.queueIndex &&
desiredIdx in queueRefs.indices
publishTickIfChanged(
current, trackChanged, desiredIdx,
effectiveIsPlaying, newPos, newDur, newBuf,
)
val somethingChanged = current.isPlaying != effectiveIsPlaying ||
current.positionMs != newPos ||
current.durationMs != newDur ||
current.bufferedPositionMs != newBuf
if (somethingChanged) {
uiStateInternal.value = current.copy(
isPlaying = effectiveIsPlaying,
positionMs = newPos,
durationMs = newDur,
bufferedPositionMs = newBuf,
)
}
}
/**
@@ -654,48 +688,6 @@ class PlayerController @Inject constructor(
}
}
@Suppress("LongParameterList") // assembled at one tick call site; refactor would cost clarity
private fun publishTickIfChanged(
current: PlayerUiState,
trackChanged: Boolean,
desiredIdx: Int,
effectiveIsPlaying: Boolean,
newPos: Long,
newDur: Long,
newBuf: Long,
) {
val somethingChanged = trackChanged ||
current.isPlaying != effectiveIsPlaying ||
current.positionMs != newPos ||
current.durationMs != newDur
if (!somethingChanged) return
val newTrack = if (trackChanged) queueRefs[desiredIdx] else current.currentTrack
val newIdx = if (trackChanged) desiredIdx else current.queueIndex
uiStateInternal.value = current.copy(
currentTrack = newTrack,
queueIndex = newIdx,
isPlaying = effectiveIsPlaying,
positionMs = newPos,
durationMs = newDur,
bufferedPositionMs = newBuf,
)
// Intentionally do NOT call controller.seekTo here. That would route
// through MinstrelForwardingPlayer's seekTo override and re-issue
// AVTransport.SeekToTrack to Sonos -- which seeks Sonos back to the
// start of the same track it's already playing, restarting the song.
// The wrapped player's index is kept in sync by maybeSyncLocalCursor's
// delegate.seekTo (which bypasses the override). If it lags briefly,
// the next pollOnce catches up; the uiState above already reflects
// Sonos's truth for the user.
}
private fun desiredQueueIndex(controller: MediaController, upnpActive: Boolean): Int =
if (upnpActive) {
(remoteState.trackNumber - 1).coerceAtLeast(0)
} else {
controller.currentMediaItemIndex
}
// ── Remote position interpolation state ──────────────────────────────
// remoteState.positionMs is only refreshed by ForwardingPlayer's 1Hz
// SOAP poll (and only when the round-trip completes -- screen-off WiFi
@@ -1,11 +1,16 @@
package com.fabledsword.minstrel.player.ui
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.fabledsword.minstrel.likes.data.LikesRepository
import com.fabledsword.minstrel.player.PlayerController
import com.fabledsword.minstrel.player.PlayerUiState
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import javax.inject.Inject
/**
@@ -20,13 +25,29 @@ import javax.inject.Inject
* stub-test for ViewModel-level logic when it grows).
*/
@HiltViewModel
@Suppress("TooManyFunctions") // Thin transport facade — each fun forwards to PlayerController.
class PlayerViewModel @Inject constructor(
private val controller: PlayerController,
private val likes: LikesRepository,
) : ViewModel() {
val uiState: StateFlow<PlayerUiState> = controller.uiState
val dropEvents: SharedFlow<String> = controller.dropEvents
/**
* Reactive set of liked track ids — the set-based pattern the queue
* (and playlist/album/artist detail) uses to color each row's
* [com.fabledsword.minstrel.shared.widgets.LikeButton] without a
* per-row Flow subscription.
*/
val likedTrackIds: StateFlow<Set<String>> =
likes.observeLikedTrackIds()
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(SHARE_STOP_TIMEOUT_MS),
initialValue = emptySet(),
)
fun play() = controller.play()
fun pause() = controller.pause()
fun seekTo(positionMs: Long) = controller.seekTo(positionMs)
@@ -35,4 +56,16 @@ class PlayerViewModel @Inject constructor(
fun seekToIndex(index: Int) = controller.seekToIndex(index)
fun toggleShuffle() = controller.toggleShuffle()
fun cycleRepeat() = controller.cycleRepeat()
fun moveInQueue(from: Int, to: Int) = controller.moveInQueue(from, to)
fun removeFromQueue(index: Int) = controller.removeFromQueue(index)
fun clearQueue() = controller.clearQueue()
fun toggleLikeTrack(trackId: String) {
val desired = trackId !in likedTrackIds.value
viewModelScope.launch {
likes.toggleLike(LikesRepository.ENTITY_TRACK, trackId, desired)
}
}
}
private const val SHARE_STOP_TIMEOUT_MS = 5_000L
@@ -1,17 +1,25 @@
package com.fabledsword.minstrel.player.ui
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectDragGestures
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
@@ -20,22 +28,43 @@ import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.zIndex
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavHostController
import com.composables.icons.lucide.ArrowDown
import com.composables.icons.lucide.ArrowLeft
import com.composables.icons.lucide.GripVertical
import com.composables.icons.lucide.Lucide
import com.composables.icons.lucide.Music
import com.composables.icons.lucide.Trash2
import com.composables.icons.lucide.Volume2
import com.composables.icons.lucide.X
import com.fabledsword.minstrel.models.TrackRef
import com.fabledsword.minstrel.shared.formatDuration
import com.fabledsword.minstrel.shared.widgets.EmptyState
import com.fabledsword.minstrel.shared.widgets.LikeButton
import com.fabledsword.minstrel.shared.widgets.ServerImage
import kotlin.math.roundToInt
import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -44,16 +73,35 @@ fun QueueScreen(
viewModel: PlayerViewModel = hiltViewModel(),
) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
val likedTrackIds by viewModel.likedTrackIds.collectAsStateWithLifecycle()
Scaffold(
modifier = Modifier.fillMaxSize(),
topBar = {
TopAppBar(
title = { Text("Queue") },
title = {
Column {
Text("Queue")
if (state.queue.isNotEmpty()) {
Text(
text = queueSummary(state.queue),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
},
navigationIcon = {
IconButton(onClick = { navController.popBackStack() }) {
Icon(Lucide.ArrowLeft, contentDescription = "Back")
}
},
actions = {
if (state.queue.isNotEmpty()) {
IconButton(onClick = viewModel::clearQueue) {
Icon(Lucide.Trash2, contentDescription = "Clear queue")
}
}
},
)
},
) { inner ->
@@ -67,33 +115,109 @@ fun QueueScreen(
QueueList(
tracks = state.queue,
currentIndex = state.queueIndex,
likedTrackIds = likedTrackIds,
onJumpTo = viewModel::seekToIndex,
onToggleLike = viewModel::toggleLikeTrack,
onMove = viewModel::moveInQueue,
onRemove = viewModel::removeFromQueue,
)
}
}
}
}
@Suppress("LongParameterList") // Compose list wiring — layout + queue callbacks, not logic.
@Composable
private fun QueueList(
tracks: List<TrackRef>,
currentIndex: Int,
likedTrackIds: Set<String>,
onJumpTo: (Int) -> Unit,
onToggleLike: (String) -> Unit,
onMove: (Int, Int) -> Unit,
onRemove: (Int) -> Unit,
) {
LazyColumn(modifier = Modifier.fillMaxSize()) {
itemsIndexed(items = tracks, key = { _, track -> track.id }) { index, track ->
QueueRow(
track = track,
isCurrent = index == currentIndex,
onClick = { onJumpTo(index) },
)
HorizontalDivider()
val listState = rememberLazyListState(
initialFirstVisibleItemIndex = currentIndex.coerceIn(0, tracks.lastIndex),
)
val scope = rememberCoroutineScope()
// Follow the now-playing row as the track auto-advances, but only while it's
// near the visible window — if the user has scrolled away to browse, leave
// them there (the pill offers the way back). Parity with the web queue.
LaunchedEffect(currentIndex) {
if (currentIndex < 0) return@LaunchedEffect
val visible = listState.layoutInfo.visibleItemsInfo
val first = visible.firstOrNull()?.index ?: 0
val last = visible.lastOrNull()?.index ?: 0
if (currentIndex in (first - 1)..(last + 1)) {
listState.animateScrollToItem(currentIndex)
}
}
val currentVisible by remember {
derivedStateOf {
listState.layoutInfo.visibleItemsInfo.any { it.index == currentIndex }
}
}
Box(modifier = Modifier.fillMaxSize()) {
LazyColumn(state = listState, modifier = Modifier.fillMaxSize()) {
itemsIndexed(items = tracks, key = { _, track -> track.id }) { index, track ->
QueueRow(
track = track,
index = index,
queueSize = tracks.size,
isCurrent = index == currentIndex,
liked = track.id in likedTrackIds,
onClick = { onJumpTo(index) },
onToggleLike = { onToggleLike(track.id) },
onRemove = { onRemove(index) },
onMove = onMove,
)
HorizontalDivider()
}
}
JumpToCurrentPill(
visible = currentIndex >= 0 && !currentVisible,
onClick = {
scope.launch { listState.animateScrollToItem(currentIndex.coerceAtLeast(0)) }
},
modifier = Modifier.align(Alignment.BottomCenter).padding(bottom = 16.dp),
)
}
}
@Composable
private fun QueueRow(track: TrackRef, isCurrent: Boolean, onClick: () -> Unit) {
private fun JumpToCurrentPill(
visible: Boolean,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
AnimatedVisibility(visible = visible, modifier = modifier) {
FilledTonalButton(onClick = onClick) {
Icon(Lucide.ArrowDown, contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(modifier = Modifier.width(6.dp))
Text("Jump to current")
}
}
}
@Suppress("LongParameterList") // Compose row wiring — layout + queue callbacks, not logic.
@Composable
private fun QueueRow(
track: TrackRef,
index: Int,
queueSize: Int,
isCurrent: Boolean,
liked: Boolean,
onClick: () -> Unit,
onToggleLike: () -> Unit,
onRemove: () -> Unit,
onMove: (Int, Int) -> Unit,
) {
var dragOffsetY by remember { mutableFloatStateOf(0f) }
var rowHeightPx by remember { mutableIntStateOf(0) }
val highlight = if (isCurrent) {
MaterialTheme.colorScheme.primary.copy(alpha = HIGHLIGHT_ALPHA)
} else {
@@ -102,12 +226,23 @@ private fun QueueRow(track: TrackRef, isCurrent: Boolean, onClick: () -> Unit) {
Row(
modifier = Modifier
.fillMaxWidth()
.onSizeChanged { rowHeightPx = it.height }
.zIndex(if (dragOffsetY != 0f) 1f else 0f)
.graphicsLayer { translationY = dragOffsetY }
.background(highlight)
.clickable(onClick = onClick)
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
DragHandle(
index = index,
queueSize = queueSize,
rowHeightPx = rowHeightPx,
onOffsetChange = { dragOffsetY = it },
onMove = onMove,
)
QueueRowThumbnail(track = track)
if (isCurrent) {
Icon(
Lucide.Volume2,
@@ -115,26 +250,7 @@ private fun QueueRow(track: TrackRef, isCurrent: Boolean, onClick: () -> Unit) {
tint = MaterialTheme.colorScheme.primary,
)
}
Column(modifier = Modifier.weight(1f)) {
Text(
text = track.title,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurface,
fontWeight = if (isCurrent) FontWeight.Medium else FontWeight.Normal,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
val subtitle = queueSubtitle(track)
if (subtitle.isNotEmpty()) {
Text(
text = subtitle,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
QueueRowText(track = track, isCurrent = isCurrent, modifier = Modifier.weight(1f))
if (track.durationSec > 0) {
Text(
text = formatDuration(track.durationSec),
@@ -142,6 +258,95 @@ private fun QueueRow(track: TrackRef, isCurrent: Boolean, onClick: () -> Unit) {
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
LikeButton(liked = liked, onToggle = onToggleLike)
IconButton(onClick = onRemove) {
Icon(Lucide.X, contentDescription = "Remove from queue")
}
}
}
@Composable
private fun DragHandle(
index: Int,
queueSize: Int,
rowHeightPx: Int,
onOffsetChange: (Float) -> Unit,
onMove: (Int, Int) -> Unit,
) {
// Mirrors the web queue: the row follows the finger during a drag, then on
// release we translate the accumulated offset into a row delta and reorder.
var offset by remember { mutableFloatStateOf(0f) }
Icon(
Lucide.GripVertical,
contentDescription = "Reorder track",
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.pointerInput(index, queueSize, rowHeightPx) {
detectDragGestures(
onDrag = { change, dragAmount ->
change.consume()
offset += dragAmount.y
onOffsetChange(offset)
},
onDragEnd = {
val delta = if (rowHeightPx > 0) (offset / rowHeightPx).roundToInt() else 0
val target = (index + delta).coerceIn(0, queueSize - 1)
if (target != index) onMove(index, target)
offset = 0f
onOffsetChange(0f)
},
onDragCancel = {
offset = 0f
onOffsetChange(0f)
},
)
},
)
}
@Composable
private fun QueueRowThumbnail(track: TrackRef) {
Box(
modifier = Modifier
.size(48.dp)
.clip(RoundedCornerShape(4.dp))
.background(MaterialTheme.colorScheme.surfaceVariant),
contentAlignment = Alignment.Center,
) {
ServerImage(
url = track.coverUrl,
contentDescription = null,
modifier = Modifier.size(48.dp),
) {
Icon(
Lucide.Music,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
@Composable
private fun QueueRowText(track: TrackRef, isCurrent: Boolean, modifier: Modifier = Modifier) {
Column(modifier = modifier) {
Text(
text = track.title,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurface,
fontWeight = if (isCurrent) FontWeight.Medium else FontWeight.Normal,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
val subtitle = queueSubtitle(track)
if (subtitle.isNotEmpty()) {
Text(
text = subtitle,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
}
@@ -150,4 +355,18 @@ private fun queueSubtitle(track: TrackRef): String = listOf(track.artistName, tr
.filter { it.isNotEmpty() }
.joinToString(" · ")
/** "N tracks · 12 min" header summary. */
private fun queueSummary(tracks: List<TrackRef>): String {
val minutes = tracks.sumOf { it.durationSec } / SECONDS_PER_MINUTE
val length = if (minutes >= MINUTES_PER_HOUR) {
"${minutes / MINUTES_PER_HOUR}h ${minutes % MINUTES_PER_HOUR}m"
} else {
"$minutes min"
}
val noun = if (tracks.size == 1) "track" else "tracks"
return "${tracks.size} $noun · $length"
}
private const val HIGHLIGHT_ALPHA = 0.12f
private const val SECONDS_PER_MINUTE = 60
private const val MINUTES_PER_HOUR = 60
@@ -119,9 +119,9 @@ class PlaylistsRepository @Inject constructor(
throw e
}
playlistDao.upsertAll(listOf(wire.toPlaylistEntity()))
playlistTrackDao.deleteByPlaylist(id)
playlistTrackDao.upsertAll(
wire.tracks.mapNotNull { row ->
playlistTrackDao.replacePlaylistTracks(
playlistId = id,
rows = wire.tracks.mapNotNull { row ->
row.trackId?.let { trackId ->
CachedPlaylistTrackEntity(
playlistId = id,
@@ -0,0 +1,315 @@
package com.fabledsword.minstrel.shared
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeoutOrNull
import java.util.concurrent.atomic.AtomicBoolean
// Once raised, the veil stays up at least this long. Without a floor a
// no-op refresh wipes on and straight back off, which reads as a glitch.
private const val DEFAULT_MIN_HOLD_MS = 900L
// The screen must stop changing for this long before the veil lowers.
// Every content change re-arms it, so a refresh that lands in stages
// (index → tile hydration → artwork) holds the veil across all of them.
private const val DEFAULT_QUIET_MS = 700L
// Hard ceiling on visibility. A refresh that never settles must not
// strand the user behind an opaque veil; the work itself is NOT capped.
private const val DEFAULT_MAX_HOLD_MS = 12_000L
// Attempts per session. Retrying behind the veil is the point: a pull
// that fails on the first try gets another go before the user sees
// anything, instead of the veil wiping off over unchanged content.
private const val DEFAULT_ATTEMPTS = 3
private const val DEFAULT_RETRY_BACKOFF_MS = 600L
/** Tunables for [UpdateVeilController]; defaults are the Home values. */
data class VeilTimings(
val minHoldMs: Long = DEFAULT_MIN_HOLD_MS,
val quietMs: Long = DEFAULT_QUIET_MS,
val maxHoldMs: Long = DEFAULT_MAX_HOLD_MS,
val attempts: Int = DEFAULT_ATTEMPTS,
val retryBackoffMs: Long = DEFAULT_RETRY_BACKOFF_MS,
)
/**
* A snapshot of everything that visibly moves on the veiled screen.
*
* @param contentKey any value whose equality tracks what's rendered — a
* change means the screen moved, and re-arms the quiet timer.
* @param hasContent true when real content (not a skeleton or an empty
* state) is on screen. The veil waits for this before raising: there's
* nothing to hide until there's something to hide.
* @param quiescent false while something is still landing (artwork
* loading, tiles hydrating). The veil will not lower until this is
* true, up to [VeilTimings.maxHoldMs].
*/
data class VeilSettleState(
val contentKey: Any?,
val hasContent: Boolean,
val quiescent: Boolean,
)
/** What a finished session did, so callers can report it if they want. */
enum class VeilOutcome {
/** Content changed, and the veil covered the churn. */
CHANGED,
/** The refresh worked, but nothing on screen moved — already current. */
UNCHANGED,
/** Every attempt failed. */
FAILED,
}
/**
* One session's result, plus whether a user explicitly asked for it.
*
* [userInitiated] is what lets a caller tell feedback from noise: a user
* who pulled to refresh is owed an answer even when the answer is "nothing
* changed", while the same outcome from a background check is noise.
*/
data class VeilSessionResult(
val outcome: VeilOutcome,
val userInitiated: Boolean,
)
/**
* Drives an "updating" overlay from *observed content change and settling*
* rather than from a fixed delay.
*
* The problem this replaces: a veil held for `refresh().join() + 500ms`
* lowers while the screen is still moving, because finishing the network
* pull is nowhere near the end of the visible work — the pull writes id
* lists, then tiles hydrate one by one, then artwork loads. And a plain
* `isUpdating` Boolean set in a `finally` gets cleared by whichever of
* two overlapping refreshes finishes first, wiping the veil off mid-update
* (issue #2327).
*
* So instead: run [work], raise only if the content actually changes, then
* hold until [settleSignal] reports the screen has stopped changing for
* [VeilTimings.quietMs] AND is quiescent — bounded below by
* [VeilTimings.minHoldMs] so it can never flash, and above by
* [VeilTimings.maxHoldMs] so it can never strand.
*
* The raise is deliberately *reactive*: a refresh that returns what's
* already on screen — the common case on a launch over a warm cache —
* raises nothing at all, because a veil over an unchanged screen hides
* nothing and only delays first paint. The cost is that the veil arrives
* one emission after the change, so a single atomic content swap shows
* through; everything messier that follows it (tile hydration, then
* artwork) still lands behind the veil.
*
* Overlapping triggers extend the running session instead of racing it,
* so the veil stays up continuously rather than lowering and re-raising.
*
* Failure is quiet at this layer: [work] gets [VeilTimings.attempts] tries
* behind the veil, and if they all fail the veil simply wipes off over the
* cached content. Giving up ends only *this* session — it sets no latch and
* blocks nothing, so the caller's own recovery paths (reconnect re-pull,
* freshness sweeps, the next event, a manual pull) keep retrying afterwards
* exactly as before. Callers that want to surface a failure can do it from
* [onSessionEnd] instead.
*
* @param work one refresh attempt; returns true when it succeeded.
* @param shouldVeil sampled at session start — "is there cached content
* this refresh is about to overwrite?". False means a cold load, where
* a skeleton is the right affordance, and the work runs unveiled.
* @param onSessionEnd called once per finished session, on the controller's
* coroutine. Use it for user-facing feedback the veil itself can't give.
*/
class UpdateVeilController(
private val scope: CoroutineScope,
private val settleSignal: Flow<VeilSettleState>,
private val shouldVeil: suspend () -> Boolean,
private val timings: VeilTimings = VeilTimings(),
private val onSessionEnd: (VeilSessionResult) -> Unit = {},
private val work: suspend () -> Boolean,
) {
private val visibleInternal = MutableStateFlow(false)
/** True while the veil should be drawn over the screen. */
val visible: StateFlow<Boolean> = visibleInternal.asStateFlow()
private val finishedInternal = MutableStateFlow(0)
/**
* Increments as each session ends. Lets a caller wait for "this
* refresh is done" without knowing whether a veil ever went up —
* a pull-to-refresh indicator needs exactly that, since an unchanged
* refresh never raises one.
*/
val finishedSessions: StateFlow<Int> = finishedInternal.asStateFlow()
// Conflated: a burst of triggers (reconnect + rebuild event arriving
// together) collapses into one follow-up pass, not a queue of them.
private val requests = Channel<Unit>(Channel.CONFLATED)
// Sticky across a conflated burst: conflation drops the older token, so
// the "a user asked for this" bit can't ride on it. If ANY coalesced
// trigger was the user's, the session still owes them an answer.
private val userAsked = AtomicBoolean(false)
init {
// One consumer, so sessions are serialised by construction: two
// triggers can never each own a piece of the veil's state.
scope.launch {
while (true) {
requests.receive()
runSession()
}
}
}
/**
* Ask for a refresh. Safe to call from any trigger at any rate —
* calls arriving during a session extend it rather than starting a
* competing one.
*
* @param userInitiated true when a person explicitly asked (pull to
* refresh, a Retry button), which is what [VeilSessionResult] carries
* through to [onSessionEnd].
*/
fun request(userInitiated: Boolean = false) {
if (userInitiated) userAsked.set(true)
requests.trySend(Unit)
}
private suspend fun runSession() {
// Sampled at both ends of the work: a trigger folded in mid-session
// (see [drainWork]) may have been the user's, and they're still owed
// an answer for it.
val askedAtStart = userAsked.getAndSet(false)
if (!shouldVeil()) {
// Cold load: the skeleton is the right affordance, so no veil.
// Succeeding here did change the screen — from nothing to
// something — so it reports CHANGED, never "already up to date".
val ok = drainWork()
finish(succeeded = ok, changed = ok, userInitiated = askedAtStart)
return
}
val raised = CompletableDeferred<Unit>()
val raiser = scope.launch { raiseWhenContentChanges(raised) }
// Floor and ceiling are measured from the raise, not the request,
// so a late raise still gets its full no-flash minimum.
val floor = scope.launch {
raised.await()
delay(timings.minHoldMs)
}
val ceiling = scope.launch {
raised.await()
delay(timings.maxHoldMs)
visibleInternal.value = false
}
var succeeded = false
try {
succeeded = drainWork()
// Always wait for the settle, never conditionally on `visible`:
// work that finishes without suspending would otherwise reach
// here before the raiser has been dispatched, tear the session
// down, and leave the churn uncovered. This wait is also what
// makes `raised.isCompleted` below a trustworthy "did anything
// change?" — a change landing just after the pull returns still
// gets seen.
withTimeoutOrNull(timings.maxHoldMs) { awaitSettled() }
// Honour the no-flash minimum before lowering. Deliberately in
// the try and not the finally: on cancellation the scope is
// going away and nothing will render the veil, so the floor is
// pointless there — and a finally that suspends is a finally
// that can resist teardown.
if (raised.isCompleted) floor.join()
} finally {
raiser.cancel()
ceiling.cancel()
floor.cancel()
visibleInternal.value = false
finish(
succeeded = succeeded,
changed = raised.isCompleted,
userInitiated = askedAtStart,
)
}
}
/**
* Raises the veil the moment the screen's content differs from what was
* already on it — and never, if this refresh turns out to be a no-op.
*
* The baseline is the first state that HAS content, not simply the first
* state: over a warm cache the cached rows paint a moment after the
* session starts, and treating that first paint as "a change" would veil
* every launch, which is the whole thing this avoids.
*/
private suspend fun raiseWhenContentChanges(raised: CompletableDeferred<Unit>) {
val baseline = settleSignal.first { it.hasContent }
settleSignal.first { it.hasContent && it.contentKey != baseline.contentKey }
visibleInternal.value = true
raised.complete(Unit)
}
private fun finish(succeeded: Boolean, changed: Boolean, userInitiated: Boolean) {
val outcome = when {
!succeeded -> VeilOutcome.FAILED
changed -> VeilOutcome.CHANGED
else -> VeilOutcome.UNCHANGED
}
onSessionEnd(
VeilSessionResult(
outcome = outcome,
// Fold in a mid-session request from the user.
userInitiated = userInitiated || userAsked.getAndSet(false),
),
)
finishedInternal.update { it + 1 }
}
/** True when the refresh eventually succeeded. */
private suspend fun drainWork(): Boolean {
var succeeded: Boolean
do {
succeeded = runWorkWithRetries()
// A trigger that arrived mid-session gets folded into this one.
} while (requests.tryReceive().isSuccess)
return succeeded
}
private suspend fun runWorkWithRetries(): Boolean {
repeat(timings.attempts) { attempt ->
if (work()) return true
if (attempt < timings.attempts - 1) {
delay(timings.retryBackoffMs * (attempt + 1))
}
}
return false
}
/**
* Suspends until the screen has been unchanged for
* [VeilTimings.quietMs] and reports itself quiescent.
*
* `debounce` is what makes this hold across a staged update: every
* change restarts the window, so the veil lowers only once emissions
* actually stop. `first { quiescent }` then rejects a quiet-but-
* still-loading moment and waits for the next lull.
*/
@OptIn(FlowPreview::class)
private suspend fun awaitSettled() {
settleSignal
.distinctUntilChanged()
.debounce(timings.quietMs)
.first { it.quiescent }
}
}
@@ -0,0 +1,60 @@
package com.fabledsword.minstrel.shared.widgets
import androidx.compose.runtime.Stable
import androidx.compose.runtime.staticCompositionLocalOf
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
/**
* Counts the cover-art loads that are currently in flight, so a
* screen-level overlay can wait for the artwork to actually land instead
* of guessing with a fixed delay.
*
* Artwork is the most visible pop-in on Home: a tile can be fully
* hydrated (title, artist, counts all present) and still snap its cover
* in a second later, which is exactly the churn the "Updating your
* mixes…" veil exists to hide. The refresh coroutine can't see that —
* it finishes long before Coil does — so the composition reports it
* upward here instead.
*
* [ServerImage] reports into whatever tracker it finds in
* [LocalArtSettleTracker], which means every art surface in the app
* participates for free. Only *composed* images are counted, so a
* LazyRow's off-screen tiles are correctly ignored — the count tracks
* the pop-in a user can actually see.
*
* Provide one per screen that needs it (typically owned by the
* screen's ViewModel so its refresh logic can read [inFlight]):
*
* CompositionLocalProvider(LocalArtSettleTracker provides vm.artTracker) { ... }
*/
@Stable
class ArtSettleTracker {
private val inFlightInternal = MutableStateFlow(0)
/**
* How many on-screen images are still loading. Zero means the
* artwork has settled — every composed cover has either drawn or
* failed to a fallback.
*/
val inFlight: StateFlow<Int> = inFlightInternal.asStateFlow()
fun begin() {
inFlightInternal.update { it + 1 }
}
fun end() {
// Floor at zero: a decrement can outlive its increment when a
// tile is disposed mid-load and the count must not go negative
// and wedge "settled" off forever.
inFlightInternal.update { (it - 1).coerceAtLeast(0) }
}
}
/**
* The tracker [ServerImage] reports load state to, or null on screens
* that don't care (the default) — reporting is then a no-op.
*/
val LocalArtSettleTracker = staticCompositionLocalOf<ArtSettleTracker?> { null }
@@ -1,25 +1,40 @@
package com.fabledsword.minstrel.shared.widgets
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.layout.ContentScale
import coil3.compose.AsyncImage
import coil3.compose.AsyncImagePainter
import com.fabledsword.minstrel.shared.resolveServerUrl
// The fallback fades out as the artwork crossfades in (Coil's crossfade is
// configured globally on the ImageLoader in MinstrelApplication). Matching
// durations makes the swap read as one cross-dissolve; without the fade the
// placeholder icon vanished a frame before the cover appeared, which is the
// "art popping in" the Home veil exists to hide (issue #2327).
private const val FALLBACK_FADE_MS = 220
/**
* Renders a server-hosted image, resolving relative URLs centrally so
* every cover surface loads consistently. Shows [fallback] when the URL
* is blank/unresolvable, while the image is still loading, and when the
* load fails — so a tile is never left blank (e.g. art not yet backfilled,
* which the "You might like" row hits often).
*
* In-flight loads are reported to [LocalArtSettleTracker] when a screen
* provides one, so a screen-level overlay can wait for artwork to land
* instead of guessing with a fixed delay.
*/
@Composable
fun ServerImage(
@@ -40,6 +55,23 @@ fun ServerImage(
var state by remember(resolved) {
mutableStateOf<AsyncImagePainter.State>(AsyncImagePainter.State.Empty)
}
// Empty counts as loading: it's the pre-request state, so treating it
// as settled would let a screen overlay lower before Coil even starts.
val loading = state is AsyncImagePainter.State.Empty ||
state is AsyncImagePainter.State.Loading
val tracker = LocalArtSettleTracker.current
DisposableEffect(tracker, loading) {
if (loading) tracker?.begin()
// Balanced by construction: the effect re-runs when `loading` flips
// (decrement, then no re-increment) and disposes when a tile leaves
// the composition mid-load (scrolled away).
onDispose { if (loading) tracker?.end() }
}
val fallbackAlpha by animateFloatAsState(
targetValue = if (loading || state is AsyncImagePainter.State.Error) 1f else 0f,
animationSpec = tween(FALLBACK_FADE_MS),
label = "art-fallback",
)
Box(modifier = modifier, contentAlignment = Alignment.Center) {
AsyncImage(
model = resolved,
@@ -48,10 +80,10 @@ fun ServerImage(
contentScale = contentScale,
onState = { state = it },
)
if (state is AsyncImagePainter.State.Loading ||
state is AsyncImagePainter.State.Error
) {
fallback()
if (fallbackAlpha > 0f) {
Box(Modifier.alpha(fallbackAlpha), contentAlignment = Alignment.Center) {
fallback()
}
}
}
}
@@ -0,0 +1,132 @@
package com.fabledsword.minstrel.cache.mutations
import com.fabledsword.minstrel.cache.db.entities.CachedMutationEntity
import kotlinx.serialization.json.Json
import org.junit.jupiter.api.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
/**
* Collapse rules for desired-state toggles in the offline queue.
*
* The hazard this guards against is real and silent: without collapsing, a
* queued snooze that replays AFTER the user's undo re-hides an artist they
* asked to see again, and nothing surfaces the contradiction.
*/
class SupersededToggleIdsTest {
private val json = Json { ignoreUnknownKeys = true }
private fun snoozeRow(id: Long, mbid: String, desiredSnoozed: Boolean) = CachedMutationEntity(
id = id,
kind = MutationKind.SUGGESTION_SNOOZE_TOGGLE,
payload = json.encodeToString(
SuggestionSnoozeTogglePayload.serializer(),
SuggestionSnoozeTogglePayload(mbid, "Name", desiredSnoozed),
),
)
private fun likeRow(id: Long, entityId: String, desired: Boolean) = CachedMutationEntity(
id = id,
kind = MutationKind.LIKE_TOGGLE,
payload = json.encodeToString(
LikeTogglePayload.serializer(),
LikeTogglePayload("artist", entityId, desired),
),
)
@Test
fun `a snooze followed by its undo drops the snooze`() {
val rows = listOf(
snoozeRow(1, "mb-a", desiredSnoozed = true),
snoozeRow(2, "mb-a", desiredSnoozed = false),
)
// Only the later intent (the undo) survives to be replayed.
assertEquals(setOf(1L), supersededToggleIds(rows, json))
}
@Test
fun `toggles for different candidates never collapse into each other`() {
val rows = listOf(
snoozeRow(1, "mb-a", desiredSnoozed = true),
snoozeRow(2, "mb-b", desiredSnoozed = true),
)
assertTrue(supersededToggleIds(rows, json).isEmpty())
}
@Test
fun `only the newest of several toggles for one candidate survives`() {
val rows = listOf(
snoozeRow(1, "mb-a", desiredSnoozed = true),
snoozeRow(2, "mb-a", desiredSnoozed = false),
snoozeRow(3, "mb-a", desiredSnoozed = true),
)
assertEquals(setOf(1L, 2L), supersededToggleIds(rows, json))
}
// The kind is part of the collapse key, so a snooze and a like that happen
// to share an id string must not shadow one another.
@Test
fun `a like and a snooze on the same id string do not collide`() {
val rows = listOf(
likeRow(1, "same-id", desired = true),
snoozeRow(2, "same-id", desiredSnoozed = true),
)
assertTrue(supersededToggleIds(rows, json).isEmpty())
}
@Test
fun `like toggles still collapse — the pre-existing behaviour is intact`() {
val rows = listOf(
likeRow(1, "artist-1", desired = true),
likeRow(2, "artist-1", desired = false),
)
assertEquals(setOf(1L), supersededToggleIds(rows, json))
}
@Test
fun `non-toggle kinds are never collapsed, even repeated for one entity`() {
// Two appends to the same playlist are two real actions, not one
// desired state — collapsing them would lose a write.
val rows = listOf(
CachedMutationEntity(
id = 1,
kind = MutationKind.PLAYLIST_APPEND,
payload = json.encodeToString(
PlaylistAppendPayload.serializer(),
PlaylistAppendPayload("pl-1", listOf("t1")),
),
),
CachedMutationEntity(
id = 2,
kind = MutationKind.PLAYLIST_APPEND,
payload = json.encodeToString(
PlaylistAppendPayload.serializer(),
PlaylistAppendPayload("pl-1", listOf("t2")),
),
),
)
assertTrue(supersededToggleIds(rows, json).isEmpty())
}
// A row whose payload won't decode gets no key at all, rather than sharing
// a "corrupt" bucket — otherwise one bad row could suppress a good one
// behind it. The dispatcher DROPs the bad row on its own.
@Test
fun `an undecodable payload does not suppress a valid later row`() {
val rows = listOf(
CachedMutationEntity(
id = 1,
kind = MutationKind.SUGGESTION_SNOOZE_TOGGLE,
payload = "{ not json",
),
snoozeRow(2, "mb-a", desiredSnoozed = true),
)
assertTrue(supersededToggleIds(rows, json).isEmpty())
}
@Test
fun `an empty queue collapses nothing`() {
assertTrue(supersededToggleIds(emptyList(), json).isEmpty())
}
}
@@ -0,0 +1,85 @@
package com.fabledsword.minstrel.models
import org.junit.jupiter.api.Test
import kotlin.test.assertEquals
/**
* The card's subtitle line (#2377). Wording is kept in lockstep with the web
* client's reasonText() in SuggestionFeed.svelte — these assertions are the
* record of what that wording IS, so a change on one client without the other
* shows up as a failure rather than as silent divergence between the two
* surfaces.
*/
class ArtistSuggestionReasonTest {
private val seeds = listOf(
SeedContributionRef(name = "Seed", isLiked = true),
)
private fun suggestion(
matched: List<String> = emptyList(),
attribution: List<SeedContributionRef> = seeds,
) = ArtistSuggestionRef(
mbid = "mb",
name = "Candidate",
attribution = attribution,
matchedTags = matched,
)
@Test
fun `one matched tag reads in the singular`() {
assertEquals(
"Matches your taste in shoegaze.",
suggestion(matched = listOf("shoegaze")).reasonText,
)
}
@Test
fun `two matched tags join with and`() {
assertEquals(
"Matches your taste in shoegaze and dream pop.",
suggestion(matched = listOf("shoegaze", "dream pop")).reasonText,
)
}
@Test
fun `three matched tags use an Oxford comma`() {
assertEquals(
"Matches your taste in a, b, and c.",
suggestion(matched = listOf("a", "b", "c")).reasonText,
)
}
// The server caps at 3, but the client must not render a run-on line if a
// future server sends more.
@Test
fun `more than three matched tags are capped at three`() {
assertEquals(
"Matches your taste in a, b, and c.",
suggestion(matched = listOf("a", "b", "c", "d", "e")).reasonText,
)
}
// The COMMON case: most candidates have no cached tags (#2376), so the card
// must fall back to seed attribution rather than going blank.
@Test
fun `no matched tags falls back to seed attribution`() {
assertEquals("Because you liked Seed.", suggestion().reasonText)
}
// Nothing to say at all — a candidate with neither tags nor attribution
// yields an empty line, which the tile suppresses rather than rendering as
// a blank row.
@Test
fun `neither tags nor attribution yields an empty line`() {
assertEquals("", suggestion(attribution = emptyList()).reasonText)
}
// The taste reason WINS over attribution when both exist: describing the
// music beats describing the similarity graph.
@Test
fun `a taste match supersedes seed attribution`() {
val got = suggestion(matched = listOf("shoegaze")).reasonText
assertEquals("Matches your taste in shoegaze.", got)
}
}
@@ -0,0 +1,83 @@
package com.fabledsword.minstrel.models
import kotlinx.datetime.Instant
import org.junit.jupiter.api.Test
import kotlin.test.assertEquals
/**
* `returnsIn` phrasing for the parked-suggestions list (#2375). The clock is
* injected rather than frozen, so these assertions are stable.
*/
class SuggestionSnoozeRefTest {
private val now = 1_800_000_000_000L // fixed epoch ms; any value works
private fun snoozeIn(days: Double) = SuggestionSnoozeRef(
mbid = "mb",
name = "Parked",
snoozedUntil = Instant
.fromEpochMilliseconds(now + (days * 86_400_000L).toLong())
.toString(),
)
@Test
fun `the default 90-day snooze reads as about 3 months`() {
assertEquals("in about 3 months", snoozeIn(90.0).returnsIn(now))
}
@Test
fun `a month reads in the singular`() {
assertEquals("in about a month", snoozeIn(30.0).returnsIn(now))
}
@Test
fun `under the month threshold it counts days`() {
assertEquals("in 14 days", snoozeIn(14.0).returnsIn(now))
}
// Pins the days→months boundary. The singular branch was originally dead
// code because the threshold (45) sat above the divisor (30), so no day
// count could ever round to one month without being caught by the days
// branch first. Asserting both sides of the seam keeps that from
// regressing silently.
@Test
fun `the days-to-months boundary is exactly at 30 days`() {
assertEquals("in 29 days", snoozeIn(29.0).returnsIn(now))
assertEquals("in about a month", snoozeIn(30.0).returnsIn(now))
}
@Test
fun `well past a month still reads in the singular rather than jumping to two`() {
assertEquals("in about a month", snoozeIn(40.0).returnsIn(now))
}
@Test
fun `tomorrow is named, not rendered as 1 days`() {
assertEquals("tomorrow", snoozeIn(1.0).returnsIn(now))
}
@Test
fun `later today rounds down to today rather than going negative`() {
assertEquals("today", snoozeIn(0.1).returnsIn(now))
}
// The server only ever returns unexpired rows, so a past timestamp means
// our clock and the server's disagree. The row is on screen either way, so
// say something plausible rather than leaving the line blank.
@Test
fun `an already-past expiry degrades to shortly`() {
assertEquals("shortly", snoozeIn(-5.0).returnsIn(now))
}
@Test
fun `an unparseable timestamp degrades to shortly`() {
val row = SuggestionSnoozeRef(mbid = "mb", name = "Parked", snoozedUntil = "not-a-date")
assertEquals("shortly", row.returnsIn(now))
}
@Test
fun `an empty timestamp degrades to shortly`() {
val row = SuggestionSnoozeRef(mbid = "mb", name = "Parked", snoozedUntil = "")
assertEquals("shortly", row.returnsIn(now))
}
}
@@ -0,0 +1,314 @@
package com.fabledsword.minstrel.shared
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.advanceTimeBy
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
private const val WORK_MS = 1_000L
private const val CHURN_ROUNDS = 5
private const val ART_IN_FLIGHT = 3
private const val SUCCEED_ON_ATTEMPT = 3
private const val QUIET_WINDOWS_TO_OUTLAST = 3
// Time to let a session finish once the screen has stopped changing: the
// retry backoffs, the quiet window and the minimum hold all fit inside it,
// while staying well under maxHoldMs. That gap matters — if a drain ran
// past the ceiling, "the veil lowered" would no longer distinguish
// "it settled" from "it gave up", which is the whole point of these tests.
private const val DRAIN_MS = 3_000L
/**
* The veil's job is to go up only when content actually changes, and then to
* stay up until the screen has stopped moving. Each test pins one of the ways
* the original fixed-delay implementation got that wrong (issue #2327).
*
* The controller is built on `backgroundScope` throughout: its consumer
* loop runs forever, so hanging it off the test's own scope would stop
* `runTest` from ever completing.
*
* Consequence, and the reason every wait below is an explicit
* `advanceTimeBy`: **`advanceUntilIdle()` is useless here.** It advances
* only while *foreground* work remains, and everything this controller
* does lives in `backgroundScope` — so it returns having run nothing, and
* assertions land on a session that never started (CI run 3163 failed all
* seven of these with "expected 3, actual 0" and friends). Drive the clock
* deliberately instead; don't "simplify" these back to advanceUntilIdle.
*/
@OptIn(ExperimentalCoroutinesApi::class)
class UpdateVeilControllerTest {
private val timings = VeilTimings(
minHoldMs = 900,
quietMs = 700,
maxHoldMs = 12_000,
attempts = 3,
retryBackoffMs = 600,
)
/** Drives the settle signal by hand: content key, presence, art count. */
private class FakeScreen(hasContent: Boolean = true) {
val state = MutableStateFlow(Triple(0, hasContent, 0))
val signal = state.map { (key, hasContent, art) ->
VeilSettleState(contentKey = key, hasContent = hasContent, quiescent = art == 0)
}
/** Content visibly changed — what the veil exists to cover. */
fun churn() {
state.value = state.value.copy(first = state.value.first + 1)
}
fun artLoading(count: Int) {
state.value = state.value.copy(third = count)
}
fun contentAppears() {
state.value = state.value.copy(second = true)
}
}
private fun TestScope.controllerOn(
screen: FakeScreen,
shouldVeil: suspend () -> Boolean = { true },
onSessionEnd: (VeilSessionResult) -> Unit = {},
work: suspend () -> Boolean,
) = UpdateVeilController(
scope = backgroundScope,
settleSignal = screen.signal,
shouldVeil = shouldVeil,
timings = timings,
onSessionEnd = onSessionEnd,
work = work,
)
/** Records every visibility transition, so an extra raise can't hide. */
private fun TestScope.recordVisibility(controller: UpdateVeilController): List<Boolean> {
val seen = mutableListOf<Boolean>()
backgroundScope.launch { controller.visible.collect { seen.add(it) } }
return seen
}
@Test
fun `veil outlasts content that keeps churning after the pull returns`() = runTest {
val screen = FakeScreen()
val controller = controllerOn(screen) { true }
controller.request()
runCurrent()
// The pull's write lands: content changed, so the veil goes up.
screen.churn()
runCurrent()
assertTrue(controller.visible.value, "veil is up while the screen is still moving")
// Tiles hydrating one after another, each inside the quiet window.
// The old implementation had already wiped off after a flat 500ms.
repeat(CHURN_ROUNDS) {
advanceTimeBy(timings.quietMs / 2)
screen.churn()
runCurrent()
assertTrue(controller.visible.value, "veil must hold across staged churn")
}
advanceTimeBy(DRAIN_MS)
assertFalse(controller.visible.value, "veil lowers once the screen goes quiet")
}
@Test
fun `veil waits for artwork to finish loading`() = runTest {
val screen = FakeScreen()
val controller = controllerOn(screen) { true }
screen.artLoading(ART_IN_FLIGHT)
controller.request()
runCurrent()
screen.churn()
runCurrent()
// Well past the quiet window and the floor — but art is still in
// flight, so lowering now would show the covers popping in.
advanceTimeBy(timings.minHoldMs + timings.quietMs * QUIET_WINDOWS_TO_OUTLAST)
assertTrue(controller.visible.value, "veil must wait on in-flight art")
screen.artLoading(0)
advanceTimeBy(DRAIN_MS)
assertFalse(controller.visible.value, "veil lowers once art has landed")
}
@Test
fun `retries quietly, then veils the churn the successful attempt produces`() = runTest {
val screen = FakeScreen()
var attempts = 0
val controller = controllerOn(screen) {
attempts++
val succeeded = attempts >= SUCCEED_ON_ATTEMPT // fail twice
// Only a pull that worked writes anything.
if (succeeded) screen.churn()
succeeded
}
val seen = recordVisibility(controller)
controller.request()
runCurrent()
// A failed pull changes nothing, so there is nothing to hide yet —
// the retries happen with no veil at all.
assertFalse(controller.visible.value, "no veil over a pull that changed nothing")
advanceTimeBy(DRAIN_MS)
assertEquals(SUCCEED_ON_ATTEMPT, attempts, "retries until the pull succeeds")
assertEquals(
listOf(false, true, false),
seen,
"the veil went up once, over the churn the retry finally produced",
)
}
@Test
fun `giving up is silent, reports FAILED, and does not block later requests`() = runTest {
val screen = FakeScreen()
val results = mutableListOf<VeilSessionResult>()
var attempts = 0
var succeed = false
val controller = controllerOn(screen, onSessionEnd = { results += it }) {
attempts++
succeed
}
controller.request(userInitiated = true)
advanceTimeBy(DRAIN_MS)
assertEquals(timings.attempts, attempts, "exhausts its attempts")
assertFalse(controller.visible.value, "no veil — a failed pull changed nothing")
assertEquals(VeilOutcome.FAILED, results.single().outcome)
assertTrue(results.single().userInitiated, "the user asked, so they're owed an answer")
// Giving up must not latch anything off — the reconnect-driven
// recovery still gets to try again later.
succeed = true
controller.request()
advanceTimeBy(DRAIN_MS)
assertEquals(timings.attempts + 1, attempts, "a later request still runs")
}
@Test
fun `overlapping requests extend one veil instead of racing it`() = runTest {
val screen = FakeScreen()
var started = 0
val controller = controllerOn(screen) {
started++
delay(WORK_MS)
screen.churn()
true
}
val seen = recordVisibility(controller)
// Reconnect and the rebuild event arriving together is what made the
// old Boolean flag clear mid-update: whichever pull finished first
// wiped the veil off while the other was still running.
controller.request()
runCurrent()
controller.request()
advanceTimeBy(WORK_MS + 1)
assertTrue(controller.visible.value, "second trigger extends the same veil")
advanceTimeBy(DRAIN_MS)
assertEquals(2, started, "the mid-session trigger still did its pull")
assertEquals(listOf(false, true, false), seen, "one veil session, not two")
}
@Test
fun `a never-settling screen still releases the veil at the ceiling`() = runTest {
val screen = FakeScreen()
val controller = controllerOn(screen) {
screen.churn()
true
}
screen.artLoading(1) // an image that never completes
controller.request()
advanceTimeBy(timings.maxHoldMs + 1)
assertFalse(controller.visible.value, "the hard ceiling must never strand the user")
}
@Test
fun `an unchanged refresh never raises the veil and reports UNCHANGED`() = runTest {
val screen = FakeScreen()
val results = mutableListOf<VeilSessionResult>()
// Succeeds without writing anything — the common case on a launch
// over a warm cache, where the server returns what's already cached.
val controller = controllerOn(screen, onSessionEnd = { results += it }) { true }
val seen = recordVisibility(controller)
controller.request(userInitiated = true)
advanceTimeBy(DRAIN_MS)
assertEquals(listOf(false), seen, "a veil over an unchanged screen would hide nothing")
assertEquals(VeilOutcome.UNCHANGED, results.single().outcome)
assertTrue(results.single().userInitiated, "so the caller can say 'already up to date'")
}
@Test
fun `cached content painting is not mistaken for a change`() = runTest {
// Warm cache that hasn't painted yet: the rows arrive a moment after
// the session starts. Treating that first paint as churn would veil
// every single launch.
val screen = FakeScreen(hasContent = false)
val controller = controllerOn(screen) { true }
controller.request()
runCurrent()
screen.contentAppears()
advanceTimeBy(DRAIN_MS)
assertFalse(controller.visible.value, "first paint is not churn")
}
@Test
fun `a background trigger coalescing with the user's does not swallow their answer`() =
runTest {
val screen = FakeScreen()
val results = mutableListOf<VeilSessionResult>()
val controller = controllerOn(screen, onSessionEnd = { results += it }) { true }
// Conflation drops the older token, so the "a user asked" bit
// cannot ride on it — it's tracked separately for exactly this.
controller.request(userInitiated = true)
controller.request()
advanceTimeBy(DRAIN_MS)
assertTrue(
results.first().userInitiated,
"the user's request must not be conflated away",
)
}
@Test
fun `a cold load runs unveiled and is never reported as already up to date`() = runTest {
val screen = FakeScreen()
val results = mutableListOf<VeilSessionResult>()
var ran = false
val controller = controllerOn(
screen,
shouldVeil = { false }, // empty cache: the skeleton owns this
onSessionEnd = { results += it },
) {
ran = true
true
}
controller.request(userInitiated = true)
advanceTimeBy(DRAIN_MS)
assertTrue(ran, "the refresh still happens")
assertFalse(controller.visible.value, "but no veil over a skeleton")
// It went from nothing to something — that IS a change.
assertEquals(VeilOutcome.CHANGED, results.single().outcome)
}
}
+84 -11
View File
@@ -9,11 +9,17 @@ Minstrel's four workflows consume two CI images:
```
git.fabledsword.com/bvandeusen/ci-go:1.26
git.fabledsword.com/bvandeusen/ci-flutter:3.44
git.fabledsword.com/bvandeusen/ci-android:36
```
- `ci-go:1.26` — Go server tests (`.gitea/workflows/test-go.yml`), web SPA tests (`.gitea/workflows/test-web.yml`), and the release container build (`.gitea/workflows/release.yml`).
- `ci-flutter:3.44` — Flutter client tests + debug/release APK builds (`.gitea/workflows/flutter.yml`).
- `ci-go:1.26` — Go server tests (`.gitea/workflows/test-go.yml`), web SPA tests (`.gitea/workflows/test-web.yml`), and the release container build (`release.yml`'s `image-release` job).
- `ci-android:36` — native Kotlin/Compose client: ktlint + detekt + unit tests + debug APK (`.gitea/workflows/android.yml`), and the signed release APK (`release.yml`'s `android-release` job).
**`ci-flutter` is no longer consumed.** The M8 rewrite replaced the Flutter
client with the native Android app and `flutter.yml` was removed; `ci-android`
took its place. `flutter_client/` is still in the tree but nothing builds it.
CI-Runner still publishes `ci-flutter` and will retire it once that directory
goes — so if the Flutter client is ever revived, say so there first.
## Image deps used
@@ -25,13 +31,20 @@ git.fabledsword.com/bvandeusen/ci-flutter:3.44
- **docker buildx** — release container build + push in `release.yml`.
- **curl** — release-asset polling / upload in `release.yml`.
### From `ci-flutter:3.44`
- **Flutter** (3.44 stable channel) — `flutter pub get`, `flutter analyze --fatal-infos`, `flutter test`, `flutter build apk` (debug + signed release).
- **Dart** — `dart run tool/gen_tokens.dart`, `dart run build_runner build` (drift codegen).
- **Android SDK + NDK + cmdline-tools + build-tools** — APK assembly + signing.
- **Java 25** — Gradle / Android build.
### From `ci-android:36`
- **JDK 25** — Gradle launcher + Android build. Requires Gradle 9.1.0+ in
`android/gradle/wrapper`; older Gradle rejects JDK 25 with an opaque `"25.0.3"`
error. The workflows also set `JAVA_TOOL_OPTIONS=--enable-native-access=ALL-UNNAMED`
to silence Gradle's launcher-JVM restricted-method warning.
- **Android SDK + cmdline-tools + build-tools 36.0.0** — APK assembly + signing.
No NDK: the native client has no C/C++ sources (this is why it isn't on
`ci-flutter`).
- **ktlint + detekt** — `./gradlew ktlintCheck` and `./gradlew detekt` in
`android.yml`. Image pins track `android/gradle/libs.versions.toml` so local
and CI checks agree.
- **git** — `actions/checkout@v4` baseline (and any shell git operations).
- **base64 + curl** — keystore decode + release-asset upload in the tag-build path.
- **base64 + curl** — keystore decode + release-asset upload in `release.yml`'s
`android-release` job.
## Per-job tool installs
@@ -39,9 +52,69 @@ None.
## Notes
- **Label/image split.** Workflows keep `runs-on: go-ci` / `runs-on: flutter-ci` as the scheduling label per the [`ci-runners.md`](https://…/FabledRulebook/ci-runners.md) "label = scheduling handle, image = `container.image`" pattern. The labels are intentional handles, not toolchain assertions.
- **Label/image split.** Workflows keep `runs-on: go-ci` / `runs-on: flutter-ci` as the scheduling label per the [`ci-runners.md`](https://…/FabledRulebook/ci-runners.md) "label = scheduling handle, image = `container.image`" pattern. The labels are intentional handles, not toolchain assertions — which is why the Android jobs still schedule on `flutter-ci` while pulling `ci-android:36`. Switch them to `android-ci` if that runner label is ever registered; nothing breaks either way.
- **Integration-job docker-socket dependency.** `test-go.yml`'s integration job uses the runner's shared docker socket (`/var/run/docker.sock`) to bridge-IP-discover the per-job Postgres service container by name + network intersection — the dev compose's `minstrel-postgres-*` containers are explicitly skipped as belt-and-suspenders. Depends on `act_runner.valid_volumes` whitelisting the socket; if that ever stops auto-mounting, integration tests fail at the `docker inspect` step.
- **Go toolchain pin.** `go.mod` is on `go 1.25.0` because `golang.org/x/crypto v0.51.0` declares 1.25 as its minimum. `ci-go:1.26` satisfies this with headroom. Future `x/crypto` bumps that move the Go floor should be paired with an image-tag bump in this file + the workflows.
- **In-app update channel polling.** `release.yml` polls Gitea's release-asset API for up to 15 min on tag pushes to fetch the APK that `flutter.yml` is concurrently attaching to the same release. The asset eventually appears because `flutter.yml` and `release.yml` run in parallel on the same tag; if the polling times out, the server image ships without the bundled update channel (graceful degradation, not a build failure).
- **In-app update channel `needs:`, not polling.** `release.yml`'s `image-release` job declares `needs: [android-release]`, so on tag pushes the signed APK is guaranteed present before the image build starts — no polling window, no race. (The old cross-workflow polling against `flutter.yml` is gone with that workflow.) On non-tag `main` pushes `android-release` is skipped and `image-release` instead pulls the most recent release's APK and reconstructs its exact `versionName`, so `:latest` never ships without an update channel. It degrades to an empty `client/` — never a wrong version — if no release, asset, or tag commit-count can be resolved.
- **Cache server reachability.** `test-web.yml` does NOT use `cache: 'npm'` on `actions/setup-node` — the Gitea Actions cache server isn't reachable from this runner's container network and `setup-node` was burning ~4m41s on ETIMEDOUT before failing open. With the migration to `ci-go:1.26`, `setup-node` is removed entirely (Node is in the image). The cache concern reappears if a future change re-introduces a network-dependent action.
- **Artifacts — use the mirrored actions, never `actions/{upload,download}-artifact`.**
```yaml
uses: https://git.fabledsword.com/bvandeusen/upload-artifact@cb8afe72b42edc798abfb8fcb556cf660d894245
uses: https://git.fabledsword.com/bvandeusen/download-artifact@8d4e9521a5f7e5f8b6351f341f719f9f45a92a3a
```
Upstream's `@v4+` cannot work against this instance and no server-side change
will help: `isGhes()` rejects any hostname that isn't `github.com` /
`*.ghe.com` / `*.localhost` and throws before it opens a connection, so the
server is never asked what it supports. `@v3` is worse — it reports success,
and Gitea then serves artifacts back only through the v4 API
(`content_encoding = application/zip`), so a v3 upload is stored but invisible
to every retrieval path. A green job producing nothing retrievable; that is how
72 unreachable artifacts accumulated on this repo. Scribe issues 2255 / 2270.
Both are pull mirrors of the Forgejo project's forks
(`code.forgejo.org/forgejo/{upload,download}-artifact`, one commit on upstream
disabling that check), mirrored so CI depends on commits we hold and pinned by
SHA because the mirrors auto-sync every 8h — a moved upstream tag would
otherwise silently change what runs.
**Match the pins on `@actions/artifact`, not on the actions' own version
numbers.** The two actions release on unrelated cadences, so equal version
numbers do NOT mean a compatible pair — upload `v5` bundles `@actions/artifact`
^4.0.0 while download `v5` bundles ^2.3.2. The pins above are upload **v5** and
download **v6**, which is the pairing that puts ^4.0.0 on both sides. This
matters because `release.yml` is a producer/consumer pair — `android-release`
uploads `minstrel-apk`, `image-release` downloads it — and a protocol mismatch
across it yields an empty listing rather than an error, exactly the silent
failure this entry exists to prevent.
| tag | `@actions/artifact` | runtime |
|---|---|---|
| upload v4 | ^2.1.1 | node20 |
| **upload v5** ← pinned | **^4.0.0** | node20 |
| download v4 | ^2.1.1 | node20 |
| download v5 | ^2.3.2 | node20 |
| **download v6** ← pinned | **^4.0.0** | node20 |
| download v7 | ^5.0.0 | **node24** |
The only true protocol break in this history was **v3 → v4** (upstream:
"Downloading artifacts that were created from `actions/upload-artifact@v3` and
below are not supported"); v4-and-up are one family. Later majors are mostly
ergonomics and runtime — upload v4 forbids re-uploading a name and caps a job
at 500 artifacts; download v5 made by-ID extraction match by-name.
**Do not jump the download pin to v7.** That major is a runner requirement, not
a feature change: it moves to `runs.using: node24` and upstream states it
"requires a minimum Actions Runner version of 2.327.1 … if you are using
self-hosted runners, ensure they are updated before upgrading." act_runner is
not GitHub's runner and makes no such version claim, so node24 is unverified
here. Everything currently pinned is node20.
Upload steps set `if-no-files-found: error` rather than the default `warn`, so
an upload that matches nothing fails its own job instead of failing the
consumer later.
Retrieval: `GET /api/v1/repos/{owner}/{repo}/actions/runs/{run_id}/artifacts`
for the id (global run id, not the repo-scoped run number), then
`…/actions/artifacts/{id}/zip`. The workstation has no `unzip` — use
`python3 -m zipfile -e`.
- **Friction asks.** None pending. The two images cover everything Minstrel needs.
+22 -2
View File
@@ -67,15 +67,30 @@ func tasteRespFrom(t recsettings.TasteTuning) tasteTuningResp {
}
}
// discoverTuningResp is the Discover scope on the wire (#2377).
type discoverTuningResp struct {
TagOverlapWeight float64 `json:"tag_overlap_weight"`
SnoozeDays float64 `json:"snooze_days"`
}
func discoverRespFrom(d recsettings.DiscoverTuning) discoverTuningResp {
return discoverTuningResp{
TagOverlapWeight: d.TagOverlapWeight,
SnoozeDays: d.SnoozeDays,
}
}
// tuningSnapshot is both the GET response and the post-mutation echo:
// current values alongside shipped defaults so the card can mark
// which knobs deviate.
type tuningSnapshot struct {
Profiles map[string]weightsResp `json:"profiles"`
Taste tasteTuningResp `json:"taste"`
Discover discoverTuningResp `json:"discover"`
Shipped struct {
Profiles map[string]weightsResp `json:"profiles"`
Taste tasteTuningResp `json:"taste"`
Discover discoverTuningResp `json:"discover"`
} `json:"shipped"`
}
@@ -86,11 +101,13 @@ func (h *handlers) tuningSnapshot() tuningSnapshot {
recsettings.ScopeDailyMix: weightsRespFrom(h.recSettings.Weights(recsettings.ScopeDailyMix)),
}
out.Taste = tasteRespFrom(h.recSettings.Taste())
out.Discover = discoverRespFrom(h.recSettings.Discover())
out.Shipped.Profiles = map[string]weightsResp{
recsettings.ScopeRadio: weightsRespFrom(recsettings.ShippedRadioWeights()),
recsettings.ScopeDailyMix: weightsRespFrom(recsettings.ShippedDailyMixWeights()),
}
out.Shipped.Taste = tasteRespFrom(recsettings.ShippedTasteTuning())
out.Shipped.Discover = discoverRespFrom(recsettings.ShippedDiscoverTuning())
return out
}
@@ -120,9 +137,12 @@ func (h *handlers) handlePatchRecommendationTuning(w http.ResponseWriter, r *htt
}
var err error
if scope == recsettings.ScopeTaste {
switch scope {
case recsettings.ScopeTaste:
err = h.recSettings.UpdateTaste(r.Context(), body.Values)
} else {
case recsettings.ScopeDiscover:
err = h.recSettings.UpdateDiscover(r.Context(), body.Values)
default:
err = h.recSettings.UpdateProfile(r.Context(), scope, body.Values)
}
if err != nil {
+5
View File
@@ -104,6 +104,11 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev
authed.Get("/search", h.handleSearch)
authed.Get("/radio", h.handleRadio)
authed.Get("/discover/suggestions", h.handleListSuggestions)
// Snooze = "not right now", time-boxed and self-expiring
// (#2374). Not a dislike — see the migration for why.
authed.Post("/discover/suggestions/{mbid}/snooze", h.handleSnoozeSuggestion)
authed.Delete("/discover/suggestions/{mbid}/snooze", h.handleUnsnoozeSuggestion)
authed.Get("/discover/snoozes", h.handleListSuggestionSnoozes)
authed.Get("/home", h.handleGetHome)
authed.Get("/home/index", h.handleGetHomeIndex)
authed.Post("/events", h.handleEvents)
+159 -1
View File
@@ -2,13 +2,19 @@ package api
import (
"context"
"encoding/json"
"errors"
"io"
"net/http"
"strconv"
"strings"
"sync"
"github.com/go-chi/chi/v5"
"github.com/jackc/pgx/v5/pgtype"
"git.fabledsword.com/bvandeusen/minstrel/internal/apierror"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarr"
"git.fabledsword.com/bvandeusen/minstrel/internal/recommendation"
)
@@ -19,6 +25,12 @@ type suggestionView struct {
Name string `json:"name"`
Score float64 `json:"score"`
Attribution []seedContributionView `json:"attribution"`
// MatchedTags are the candidate's tags that overlap the user's taste
// profile, strongest first (#2377) — the "matches: shoegaze, melancholic"
// line. Omitted when empty, which is common: tag coverage for
// out-of-library artists is permanently partial (#2376), and the card
// falls back to the seed attribution it has always shown.
MatchedTags []string `json:"matched_tags,omitempty"`
// ImageURL is resolved on-demand from Lidarr (out-of-library
// artists have no local art row). Omitted when Lidarr is disabled
// or has no match — the client falls back to a placeholder. Not
@@ -65,7 +77,11 @@ func (h *handlers) handleListSuggestions(w http.ResponseWriter, r *http.Request)
halfLife = f
}
suggestions, err := recommendation.SuggestArtists(r.Context(), h.pool, user.ID, halfLife, limit)
// Read the tuned weight per request so an admin change takes effect on the
// next refresh, no restart (rule #25).
tagWeight := h.recSettings.Discover().TagOverlapWeight
suggestions, err := recommendation.SuggestArtists(
r.Context(), h.pool, user.ID, halfLife, limit, tagWeight)
if err != nil {
h.logger.Error("api: list suggestions", "err", err)
writeErr(w, apierror.InternalMsg("failed to load suggestions", err))
@@ -86,12 +102,154 @@ func (h *handlers) handleListSuggestions(w http.ResponseWriter, r *http.Request)
}
out = append(out, suggestionView{
MBID: s.MBID, Name: s.Name, Score: s.Score, Attribution: attr,
MatchedTags: s.MatchedTags,
})
}
h.resolveSuggestionArt(r.Context(), out)
writeJSON(w, http.StatusOK, out)
}
// maxSnoozeDays caps a client-supplied duration. The DEFAULT is not here: it's
// a DB-backed knob on the admin tuning card (rule #25), read per request via
// recSettings.Discover().SnoozeDays. See #2377.
const maxSnoozeDays = 365.0
// snoozeRequest is the POST body. Both fields are optional in the JSON sense
// (an absent body snoozes for the default), but Name is required in practice:
// candidates are out-of-library, so the server has no artists row to resolve a
// display name from and the un-snooze list would have nothing to show. The
// client always has it — it just rendered the card.
type snoozeRequest struct {
Name string `json:"name"`
Days float64 `json:"days"`
}
// snoozeView is one row of GET /api/discover/snoozes.
type snoozeView struct {
MBID string `json:"mbid"`
Name string `json:"name"`
SnoozedUntil pgtype.Timestamptz `json:"snoozed_until"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
}
// handleSnoozeSuggestion implements
// POST /api/discover/suggestions/{mbid}/snooze.
//
// Parks a candidate for `days` (default 90, capped at 365). Idempotent:
// snoozing an already-snoozed candidate extends it rather than conflicting.
//
// This is NOT negative feedback. It records no verdict on the artist and is
// never read by internal/taste — see 0049_suggestion_snoozes.up.sql for the
// rule #101 reasoning. Returns 204.
func (h *handlers) handleSnoozeSuggestion(w http.ResponseWriter, r *http.Request) {
user, ok := requireUser(w, r)
if !ok {
return
}
mbid := strings.TrimSpace(chi.URLParam(r, "mbid"))
if mbid == "" {
writeErr(w, apierror.BadRequest("invalid_id", "missing mbid"))
return
}
// An empty body is a valid "snooze this for the default period", so EOF
// is not an error here — decodeBody would reject it as a malformed body.
var body snoozeRequest
if err := json.NewDecoder(r.Body).Decode(&body); err != nil && !errors.Is(err, io.EOF) {
writeErr(w, apierror.BadRequest("invalid_body", ""))
return
}
name := strings.TrimSpace(body.Name)
if name == "" {
writeErr(w, apierror.BadRequest("invalid_body", "name is required"))
return
}
days := body.Days
if days <= 0 {
days = h.recSettings.Discover().SnoozeDays
}
if days > maxSnoozeDays {
// Clamp rather than reject: a client asking for longer than we allow
// still means "park this", and failing the write would leave the card
// sitting there as if the tap did nothing.
days = maxSnoozeDays
}
q := dbq.New(h.pool)
if err := q.SnoozeSuggestion(r.Context(), dbq.SnoozeSuggestionParams{
UserID: user.ID,
CandidateMbid: mbid,
CandidateName: name,
Column4: days,
}); err != nil {
h.logger.Error("api: snooze suggestion", "err", err)
writeErr(w, apierror.InternalMsg("failed to snooze suggestion", err))
return
}
w.WriteHeader(http.StatusNoContent)
}
// handleUnsnoozeSuggestion implements
// DELETE /api/discover/suggestions/{mbid}/snooze.
//
// Brings a parked candidate back immediately. 404s an MBID this user never
// snoozed, so the client can tell "undone" from "there was nothing there".
func (h *handlers) handleUnsnoozeSuggestion(w http.ResponseWriter, r *http.Request) {
user, ok := requireUser(w, r)
if !ok {
return
}
mbid := strings.TrimSpace(chi.URLParam(r, "mbid"))
if mbid == "" {
writeErr(w, apierror.BadRequest("invalid_id", "missing mbid"))
return
}
q := dbq.New(h.pool)
rows, err := q.UnsnoozeSuggestion(r.Context(), dbq.UnsnoozeSuggestionParams{
UserID: user.ID,
CandidateMbid: mbid,
})
if err != nil {
h.logger.Error("api: unsnooze suggestion", "err", err)
writeErr(w, apierror.InternalMsg("failed to unsnooze suggestion", err))
return
}
if rows == 0 {
writeErr(w, apierror.NotFound("snooze"))
return
}
w.WriteHeader(http.StatusNoContent)
}
// handleListSuggestionSnoozes implements GET /api/discover/snoozes.
//
// The un-snooze surface needs this: a parked candidate is by definition
// absent from the suggestion deck, so without a list there is no way to
// reach the DELETE above. Scoped to the caller (rule #47). Expired rows are
// already filtered by the query — the hourly gc sweep only reclaims space.
func (h *handlers) handleListSuggestionSnoozes(w http.ResponseWriter, r *http.Request) {
user, ok := requireUser(w, r)
if !ok {
return
}
rows, err := dbq.New(h.pool).ListActiveSuggestionSnoozes(r.Context(), user.ID)
if err != nil {
h.logger.Error("api: list suggestion snoozes", "err", err)
writeErr(w, apierror.InternalMsg("failed to load snoozes", err))
return
}
out := make([]snoozeView, 0, len(rows))
for _, row := range rows {
out = append(out, snoozeView{
MBID: row.CandidateMbid,
Name: row.CandidateName,
SnoozedUntil: row.SnoozedUntil,
CreatedAt: row.CreatedAt,
})
}
writeJSON(w, http.StatusOK, out)
}
// resolveSuggestionArt fills ImageURL on-demand from Lidarr's artist
// lookup, matched by MBID (foreignArtistId). Best-effort and cache-free:
// Lidarr is the only source — when it's disabled, unreachable, or has
@@ -0,0 +1,228 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
// source: candidate_artist_tags.sql
package dbq
import (
"context"
)
const countCandidateArtistTagCoverage = `-- name: CountCandidateArtistTagCoverage :one
SELECT count(*)::bigint AS processed,
count(*) FILTER (WHERE tag_source <> 'none')::bigint AS with_tags
FROM candidate_artist_tag_state
`
type CountCandidateArtistTagCoverageRow struct {
Processed int64
WithTags int64
}
// Operator-facing coverage: how many distinct candidates have been processed,
// and how many of those actually yielded tags. The gap is the honest ceiling
// from the task — obscure artists with no MBID presence or no upstream tags
// stay thin no matter how long the worker runs, and that is worth being able
// to see rather than inferring from a silent surface.
func (q *Queries) CountCandidateArtistTagCoverage(ctx context.Context) (CountCandidateArtistTagCoverageRow, error) {
row := q.db.QueryRow(ctx, countCandidateArtistTagCoverage)
var i CountCandidateArtistTagCoverageRow
err := row.Scan(&i.Processed, &i.WithTags)
return i, err
}
const deleteCandidateArtistTags = `-- name: DeleteCandidateArtistTags :exec
DELETE FROM candidate_artist_tags WHERE candidate_mbid = $1
`
// Clear a candidate's cached tags before rewriting (atomic replace by the
// caller, same shape as DeleteTrackTags).
func (q *Queries) DeleteCandidateArtistTags(ctx context.Context, candidateMbid string) error {
_, err := q.db.Exec(ctx, deleteCandidateArtistTags, candidateMbid)
return err
}
const gcDeleteOrphanedCandidateArtistTagState = `-- name: GcDeleteOrphanedCandidateArtistTagState :execrows
DELETE FROM candidate_artist_tag_state s
WHERE NOT EXISTS (
SELECT 1 FROM artist_similarity_unmatched u
WHERE u.candidate_mbid = s.candidate_mbid
)
OR EXISTS (SELECT 1 FROM artists a WHERE a.mbid = s.candidate_mbid)
`
// Same sweep for the bookkeeping rows. Kept as a separate statement rather
// than a cascade: the two tables are independent by design (a 'none' outcome
// has state but no tags), so neither can be the parent of the other.
func (q *Queries) GcDeleteOrphanedCandidateArtistTagState(ctx context.Context) (int64, error) {
result, err := q.db.Exec(ctx, gcDeleteOrphanedCandidateArtistTagState)
if err != nil {
return 0, err
}
return result.RowsAffected(), nil
}
const gcDeleteOrphanedCandidateArtistTags = `-- name: GcDeleteOrphanedCandidateArtistTags :execrows
DELETE FROM candidate_artist_tags t
WHERE NOT EXISTS (
SELECT 1 FROM artist_similarity_unmatched u
WHERE u.candidate_mbid = t.candidate_mbid
)
OR EXISTS (SELECT 1 FROM artists a WHERE a.mbid = t.candidate_mbid)
`
// Drops cached tags for candidates that no longer appear in the similarity
// feed, or that have since been added to the library (their tags now live in
// track_tags). The feed is refetched periodically and churns, so without this
// the cache only ever grows.
func (q *Queries) GcDeleteOrphanedCandidateArtistTags(ctx context.Context) (int64, error) {
result, err := q.db.Exec(ctx, gcDeleteOrphanedCandidateArtistTags)
if err != nil {
return 0, err
}
return result.RowsAffected(), nil
}
const insertCandidateArtistTag = `-- name: InsertCandidateArtistTag :exec
INSERT INTO candidate_artist_tags (candidate_mbid, tag, weight)
VALUES ($1, $2, $3)
ON CONFLICT (candidate_mbid, tag)
DO UPDATE SET weight = GREATEST(candidate_artist_tags.weight, EXCLUDED.weight)
`
type InsertCandidateArtistTagParams struct {
CandidateMbid string
Tag string
Weight float64
}
// Upsert one (candidate, tag); keep the stronger weight when two providers
// agree on a tag with different folksonomy strengths.
func (q *Queries) InsertCandidateArtistTag(ctx context.Context, arg InsertCandidateArtistTagParams) error {
_, err := q.db.Exec(ctx, insertCandidateArtistTag, arg.CandidateMbid, arg.Tag, arg.Weight)
return err
}
const listCandidateArtistTagsForMbids = `-- name: ListCandidateArtistTagsForMbids :many
SELECT candidate_mbid, tag, weight
FROM candidate_artist_tags
WHERE candidate_mbid = ANY($1::text[])
`
// Cached tags for a set of candidates, for slice 6's taste-overlap ranking.
// One row per (candidate, tag).
func (q *Queries) ListCandidateArtistTagsForMbids(ctx context.Context, dollar_1 []string) ([]CandidateArtistTag, error) {
rows, err := q.db.Query(ctx, listCandidateArtistTagsForMbids, dollar_1)
if err != nil {
return nil, err
}
defer rows.Close()
var items []CandidateArtistTag
for rows.Next() {
var i CandidateArtistTag
if err := rows.Scan(&i.CandidateMbid, &i.Tag, &i.Weight); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listCandidateArtistsMissingTags = `-- name: ListCandidateArtistsMissingTags :many
SELECT u.candidate_mbid,
coalesce(max(u.candidate_name), '')::text AS candidate_name,
sum(u.score)::float8 AS total_score
FROM artist_similarity_unmatched u
LEFT JOIN candidate_artist_tag_state s ON s.candidate_mbid = u.candidate_mbid
WHERE NOT EXISTS (SELECT 1 FROM artists a WHERE a.mbid = u.candidate_mbid)
AND (
s.candidate_mbid IS NULL
OR (s.tag_source = 'none' AND s.tag_sources_version < $1)
)
GROUP BY u.candidate_mbid
ORDER BY total_score DESC, u.candidate_mbid
LIMIT $2
`
type ListCandidateArtistsMissingTagsParams struct {
TagSourcesVersion int32
Limit int32
}
type ListCandidateArtistsMissingTagsRow struct {
CandidateMbid string
CandidateName string
TotalScore float64
}
// Folksonomy tags for out-of-library Discover candidates (#2376). Parallel to
// track_tags.sql, but keyed by MBID because the artist has no local row. See
// 0050_candidate_artist_tags.up.sql for why the bookkeeping is its own table.
// Candidates eligible for tag enrichment: never processed (no state row) or
// settled 'none' under an older provider version.
//
// artist_similarity_unmatched holds one row per (seed, candidate, source), so
// this GROUPs to one row per candidate — enriching the same MBID once per seed
// that pointed at it would multiply the API calls for no gain.
//
// ORDER BY summed similarity DESC is the load-bearing part. The candidate pool
// is O(library artists x neighbours per artist) — thousands — and MusicBrainz
// allows ~1 req/s, so it can NEVER be fully enriched in one pass. Draining in
// strength order means the candidates most likely to actually reach a user's
// deck get tags first, and the long tail fills in over subsequent ticks
// instead of starving behind it.
//
// Already-in-library candidates are skipped: they have an artists row, so
// their tags belong in track_tags, and the suggestion query filters them out
// anyway. $1 = current tag_sources_version, $2 = limit.
// candidate_name is coalesced to the empty string so it lands non-nullable in
// Go: the name is only a Last.fm lookup key, and empty simply means "MBID-keyed
// providers only", which the provider chain already handles. max() is an
// arbitrary-but-deterministic pick when several seeds spell one MBID
// differently.
func (q *Queries) ListCandidateArtistsMissingTags(ctx context.Context, arg ListCandidateArtistsMissingTagsParams) ([]ListCandidateArtistsMissingTagsRow, error) {
rows, err := q.db.Query(ctx, listCandidateArtistsMissingTags, arg.TagSourcesVersion, arg.Limit)
if err != nil {
return nil, err
}
defer rows.Close()
var items []ListCandidateArtistsMissingTagsRow
for rows.Next() {
var i ListCandidateArtistsMissingTagsRow
if err := rows.Scan(&i.CandidateMbid, &i.CandidateName, &i.TotalScore); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const setCandidateArtistTagState = `-- name: SetCandidateArtistTagState :exec
INSERT INTO candidate_artist_tag_state (candidate_mbid, tag_source, tag_sources_version)
VALUES ($1, $2, $3)
ON CONFLICT (candidate_mbid) DO UPDATE
SET tag_source = EXCLUDED.tag_source,
tag_sources_version = EXCLUDED.tag_sources_version,
updated_at = now()
`
type SetCandidateArtistTagStateParams struct {
CandidateMbid string
TagSource string
TagSourcesVersion int32
}
// Stamp the enrichment outcome so the drainer skips settled candidates.
// $2 = 'musicbrainz' | 'lastfm' | 'mixed' | 'none', $3 = current version.
func (q *Queries) SetCandidateArtistTagState(ctx context.Context, arg SetCandidateArtistTagStateParams) error {
_, err := q.db.Exec(ctx, setCandidateArtistTagState, arg.CandidateMbid, arg.TagSource, arg.TagSourcesVersion)
return err
}
+28
View File
@@ -240,6 +240,19 @@ type AuditLog struct {
CreatedAt pgtype.Timestamptz
}
type CandidateArtistTag struct {
CandidateMbid string
Tag string
Weight float64
}
type CandidateArtistTagState struct {
CandidateMbid string
TagSource string
TagSourcesVersion int32
UpdatedAt pgtype.Timestamptz
}
type ContextualLike struct {
ID pgtype.UUID
UserID pgtype.UUID
@@ -277,6 +290,13 @@ type DiagnosticEvent struct {
ReceivedAt pgtype.Timestamptz
}
type DiscoverTuning struct {
Singleton bool
TagOverlapWeight float64
SnoozeDays float64
UpdatedAt pgtype.Timestamptz
}
type GeneralLike struct {
UserID pgtype.UUID
TrackID pgtype.UUID
@@ -518,6 +538,14 @@ type SmtpConfig struct {
UpdatedAt pgtype.Timestamptz
}
type SuggestionSnooze struct {
UserID pgtype.UUID
CandidateMbid string
CandidateName string
SnoozedUntil pgtype.Timestamptz
CreatedAt pgtype.Timestamptz
}
type SystemPlaylistRotationState struct {
UserID pgtype.UUID
PlaylistKind string
+66 -13
View File
@@ -1022,20 +1022,50 @@ func (q *Queries) LoadRadioCandidatesV2(ctx context.Context, arg LoadRadioCandid
}
const suggestArtistsForUser = `-- name: SuggestArtistsForUser :many
WITH seeds AS (
WITH artist_plays AS (
-- Completed plays only. The previous seed query counted every play_event,
-- so skipping an artist repeatedly INCREASED its signal and pushed more of
-- its neighbours at the user (issue #2367 mechanism 3).
SELECT t.artist_id, count(*)::bigint AS play_count
FROM play_events pe
JOIN tracks t ON t.id = pe.track_id
WHERE pe.user_id = $1 AND pe.was_skipped = false
GROUP BY t.artist_id
),
profile_seeds AS (
SELECT tpa.artist_id, tpa.weight AS raw_signal
FROM taste_profile_artists tpa
WHERE tpa.user_id = $1 AND tpa.weight > 0
),
fallback_seeds AS (
SELECT a.id AS artist_id,
5.0 * (CASE WHEN gla.artist_id IS NOT NULL THEN 1 ELSE 0 END)
+ COALESCE(SUM(EXP(- EXTRACT(epoch FROM now() - pe.started_at) / ($2::float8 * 86400.0))), 0)
AS signal,
(gla.artist_id IS NOT NULL) AS is_liked,
COUNT(pe.id)::bigint AS play_count
AS raw_signal
FROM artists a
LEFT JOIN general_likes_artists gla ON gla.artist_id = a.id AND gla.user_id = $1
LEFT JOIN tracks t ON t.artist_id = a.id
LEFT JOIN play_events pe ON pe.track_id = t.id AND pe.user_id = $1
WHERE gla.artist_id IS NOT NULL OR pe.id IS NOT NULL
LEFT JOIN play_events pe
ON pe.track_id = t.id AND pe.user_id = $1 AND pe.was_skipped = false
WHERE (gla.artist_id IS NOT NULL OR pe.id IS NOT NULL)
AND NOT EXISTS (SELECT 1 FROM profile_seeds)
GROUP BY a.id, gla.artist_id
),
seeds AS (
SELECT s.artist_id,
ln(1.0 + s.raw_signal) AS signal,
(gla.artist_id IS NOT NULL) AS is_liked,
COALESCE(ap.play_count, 0)::bigint AS play_count
FROM (
SELECT artist_id, raw_signal FROM profile_seeds
UNION ALL
SELECT artist_id, raw_signal FROM fallback_seeds
) s
LEFT JOIN general_likes_artists gla
ON gla.artist_id = s.artist_id AND gla.user_id = $1
LEFT JOIN artist_plays ap ON ap.artist_id = s.artist_id
WHERE s.raw_signal > 0
),
contributions AS (
SELECT u.candidate_mbid,
u.candidate_name,
@@ -1052,6 +1082,18 @@ contributions AS (
AND r.lidarr_artist_mbid = u.candidate_mbid
AND r.status NOT IN ('rejected', 'failed')
)
-- Snoozed by this user and not yet expired (#2374). Time-boxed and
-- per-user: the candidate returns on its own once snoozed_until
-- passes, and stays visible to everyone else meanwhile. Deliberately
-- filtered at the candidate stage, NOT folded into the score — a
-- snooze carries no opinion about the music, so it must not become a
-- ranking signal.
AND NOT EXISTS (
SELECT 1 FROM suggestion_snoozes s
WHERE s.user_id = $1
AND s.candidate_mbid = u.candidate_mbid
AND s.snoozed_until > now()
)
)
SELECT candidate_mbid,
candidate_name,
@@ -1082,13 +1124,24 @@ type SuggestArtistsForUserRow struct {
TopPlayCounts []int64
}
// M5c: per-user artist suggestions ranked by signal x similarity. The
// seeds CTE collects the user's likes (x5) plus recency-decayed plays
// (exp(-age_days / $2)). The contributions CTE joins those seeds against
// artist_similarity_unmatched and filters out candidates already in
// library or already in a non-terminal lidarr_request. The outer SELECT
// aggregates per candidate, returning the top-3 contributing seeds for
// attribution. $1=user_id, $2=half_life_days, $3=limit.
// Per-user artist suggestions ranked by taste signal x similarity, projected
// through artist_similarity_unmatched (out-of-library candidates only).
//
// Seeds are TIERED (rule #131) so the surface never empties:
//
// tier 1 - taste_profile_artists.weight: engagement-graded, time-decayed and
// SIGNED by internal/taste, so an artist the user has drifted away
// from stops contributing instead of accumulating forever.
// tier 2 - likes + completed plays, used ONLY when the profile has no rows
// (new account, or before the first daily recompute).
//
// The signal is log-damped: contribution is signal x similarity, and the old
// undamped sum let one heavily-played artist's neighbours take every slot --
// entrenching harder the MORE the user listened (issue #2367 mechanism 2).
//
// Candidates already in the library, already requested and not terminal, or
// snoozed by this user, are excluded. $1=user_id, $2=half_life_days (tier 2
// decay), $3=limit.
func (q *Queries) SuggestArtistsForUser(ctx context.Context, arg SuggestArtistsForUserParams) ([]SuggestArtistsForUserRow, error) {
rows, err := q.db.Query(ctx, suggestArtistsForUser, arg.UserID, arg.Column2, arg.Limit)
if err != nil {
@@ -9,6 +9,22 @@ import (
"context"
)
const getDiscoverTuning = `-- name: GetDiscoverTuning :one
SELECT singleton, tag_overlap_weight, snooze_days, updated_at FROM discover_tuning WHERE singleton = true
`
func (q *Queries) GetDiscoverTuning(ctx context.Context) (DiscoverTuning, error) {
row := q.db.QueryRow(ctx, getDiscoverTuning)
var i DiscoverTuning
err := row.Scan(
&i.Singleton,
&i.TagOverlapWeight,
&i.SnoozeDays,
&i.UpdatedAt,
)
return i, err
}
const getTasteTuning = `-- name: GetTasteTuning :one
SELECT singleton, half_life_days, engagement_hard_skip, engagement_neutral, engagement_full, updated_at, enriched_tag_scale, era_scale, mood_scale FROM taste_tuning WHERE singleton = true
`
@@ -118,6 +134,32 @@ func (q *Queries) ListWeightProfiles(ctx context.Context) ([]RecommendationWeigh
return items, nil
}
const updateDiscoverTuning = `-- name: UpdateDiscoverTuning :one
UPDATE discover_tuning
SET tag_overlap_weight = $1,
snooze_days = $2,
updated_at = now()
WHERE singleton = true
RETURNING singleton, tag_overlap_weight, snooze_days, updated_at
`
type UpdateDiscoverTuningParams struct {
TagOverlapWeight float64
SnoozeDays float64
}
func (q *Queries) UpdateDiscoverTuning(ctx context.Context, arg UpdateDiscoverTuningParams) (DiscoverTuning, error) {
row := q.db.QueryRow(ctx, updateDiscoverTuning, arg.TagOverlapWeight, arg.SnoozeDays)
var i DiscoverTuning
err := row.Scan(
&i.Singleton,
&i.TagOverlapWeight,
&i.SnoozeDays,
&i.UpdatedAt,
)
return i, err
}
const updateTasteTuning = `-- name: UpdateTasteTuning :one
UPDATE taste_tuning
SET half_life_days = $1,
@@ -226,6 +268,24 @@ func (q *Queries) UpdateWeightProfile(ctx context.Context, arg UpdateWeightProfi
return i, err
}
const upsertDiscoverTuningDefaults = `-- name: UpsertDiscoverTuningDefaults :exec
INSERT INTO discover_tuning (singleton, tag_overlap_weight, snooze_days)
VALUES (true, $1, $2)
ON CONFLICT (singleton) DO NOTHING
`
type UpsertDiscoverTuningDefaultsParams struct {
TagOverlapWeight float64
SnoozeDays float64
}
// Boot reconcile for the Discover scope (#2377). Never overwrites
// operator-tuned values, same contract as the other two.
func (q *Queries) UpsertDiscoverTuningDefaults(ctx context.Context, arg UpsertDiscoverTuningDefaultsParams) error {
_, err := q.db.Exec(ctx, upsertDiscoverTuningDefaults, arg.TagOverlapWeight, arg.SnoozeDays)
return err
}
const upsertTasteTuningDefaults = `-- name: UpsertTasteTuningDefaults :exec
INSERT INTO taste_tuning (
singleton, half_life_days, engagement_hard_skip,
+126
View File
@@ -0,0 +1,126 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
// source: suggestion_snoozes.sql
package dbq
import (
"context"
"github.com/jackc/pgx/v5/pgtype"
)
const gcDeleteExpiredSuggestionSnoozes = `-- name: GcDeleteExpiredSuggestionSnoozes :execrows
DELETE FROM suggestion_snoozes WHERE snoozed_until < now()
`
// Keeps the table from growing without bound. Every read already filters on
// snoozed_until > now(), so deleting an expired row changes no behaviour —
// this is purely reclamation.
func (q *Queries) GcDeleteExpiredSuggestionSnoozes(ctx context.Context) (int64, error) {
result, err := q.db.Exec(ctx, gcDeleteExpiredSuggestionSnoozes)
if err != nil {
return 0, err
}
return result.RowsAffected(), nil
}
const listActiveSuggestionSnoozes = `-- name: ListActiveSuggestionSnoozes :many
SELECT candidate_mbid, candidate_name, snoozed_until, created_at
FROM suggestion_snoozes
WHERE user_id = $1 AND snoozed_until > now()
ORDER BY snoozed_until, candidate_mbid
`
type ListActiveSuggestionSnoozesRow struct {
CandidateMbid string
CandidateName string
SnoozedUntil pgtype.Timestamptz
CreatedAt pgtype.Timestamptz
}
// Backs the manage / un-snooze surface. Expired rows are filtered HERE
// rather than left to the sweeper: gc runs on an hourly tick, so a row can
// outlive its expiry by up to a tick and must not read as still-snoozed in
// the meantime.
func (q *Queries) ListActiveSuggestionSnoozes(ctx context.Context, userID pgtype.UUID) ([]ListActiveSuggestionSnoozesRow, error) {
rows, err := q.db.Query(ctx, listActiveSuggestionSnoozes, userID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []ListActiveSuggestionSnoozesRow
for rows.Next() {
var i ListActiveSuggestionSnoozesRow
if err := rows.Scan(
&i.CandidateMbid,
&i.CandidateName,
&i.SnoozedUntil,
&i.CreatedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const snoozeSuggestion = `-- name: SnoozeSuggestion :exec
INSERT INTO suggestion_snoozes (user_id, candidate_mbid, candidate_name, snoozed_until)
VALUES ($1, $2, $3, now() + ($4::float8 * INTERVAL '1 day'))
ON CONFLICT (user_id, candidate_mbid) DO UPDATE
SET snoozed_until = EXCLUDED.snoozed_until,
candidate_name = EXCLUDED.candidate_name
`
type SnoozeSuggestionParams struct {
UserID pgtype.UUID
CandidateMbid string
CandidateName string
Column4 float64
}
// Time-boxed "not right now" on a Discover artist suggestion (#2374).
//
// See 0049_suggestion_snoozes.up.sql for why this is a snooze and not a
// dismissal: it records no verdict on the music, expires on its own, and
// must never reach the taste profile. Nothing in internal/taste may read
// this table.
// Upsert, so re-snoozing an already-snoozed candidate EXTENDS it instead of
// erroring on the PK. The name is refreshed too — a later suggestion may
// carry a corrected spelling from the similarity feed.
// $1=user_id, $2=candidate_mbid, $3=candidate_name, $4=duration in days.
func (q *Queries) SnoozeSuggestion(ctx context.Context, arg SnoozeSuggestionParams) error {
_, err := q.db.Exec(ctx, snoozeSuggestion,
arg.UserID,
arg.CandidateMbid,
arg.CandidateName,
arg.Column4,
)
return err
}
const unsnoozeSuggestion = `-- name: UnsnoozeSuggestion :execrows
DELETE FROM suggestion_snoozes
WHERE user_id = $1 AND candidate_mbid = $2
`
type UnsnoozeSuggestionParams struct {
UserID pgtype.UUID
CandidateMbid string
}
// Row count is returned so the handler can 404 an MBID that was never
// snoozed rather than reporting success for a no-op.
func (q *Queries) UnsnoozeSuggestion(ctx context.Context, arg UnsnoozeSuggestionParams) (int64, error) {
result, err := q.db.Exec(ctx, unsnoozeSuggestion, arg.UserID, arg.CandidateMbid)
if err != nil {
return 0, err
}
return result.RowsAffected(), nil
}
@@ -0,0 +1,2 @@
DROP INDEX IF EXISTS suggestion_snoozes_expiry_idx;
DROP TABLE IF EXISTS suggestion_snoozes;
@@ -0,0 +1,33 @@
-- 0049_suggestion_snoozes.up.sql — time-boxed "not right now" on a Discover
-- artist suggestion (#2374, milestone #268 slice 3).
--
-- This is NOT a dislike. Rule #101 forbids a "Not for me" / thumbs-down /
-- exclusion UI, and a snooze deliberately isn't one: it records no verdict on
-- the music, expires on its own, and MUST NEVER feed the taste profile. It is
-- acquisition triage — "I don't want to request this right now" — so the same
-- candidate is free to return once snoozed_until passes. Anything that reads
-- this table as negative preference signal is a bug.
--
-- Per-user (rule #47), never global: one household member parking a
-- suggestion must not remove it from anyone else's deck.
--
-- candidate_mbid is text with NO foreign key, on purpose. Suggestions come
-- from artist_similarity_unmatched and are out-of-library BY DEFINITION, so
-- there is no artists row to reference — the MBID is the only stable identity
-- available. candidate_name is denormalized for the same reason: the manage /
-- un-snooze list has nowhere else to resolve a display name from.
CREATE TABLE suggestion_snoozes (
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
candidate_mbid text NOT NULL,
candidate_name text NOT NULL,
snoozed_until timestamptz NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (user_id, candidate_mbid)
);
-- Supports the gc sweep's unqualified `WHERE snoozed_until < now()` scan. The
-- composite PK already covers every per-user read, so this is the only extra
-- index worth its write cost at household-scale row counts (same reasoning as
-- lidarr_quarantine in 0011).
CREATE INDEX suggestion_snoozes_expiry_idx ON suggestion_snoozes (snoozed_until);
@@ -0,0 +1,3 @@
DROP INDEX IF EXISTS candidate_artist_tag_state_source_idx;
DROP TABLE IF EXISTS candidate_artist_tag_state;
DROP TABLE IF EXISTS candidate_artist_tags;
@@ -0,0 +1,54 @@
-- 0050_candidate_artist_tags.up.sql — folksonomy tags for OUT-OF-LIBRARY
-- artists (#2376, milestone #268 slice 5).
--
-- track_tags (0042) cannot hold these: it is FK'd to tracks(id), and a
-- Discover candidate has no local row by definition. So this is a parallel
-- cache keyed by the candidate's MusicBrainz MBID — the only stable identity
-- available for an artist we don't have.
--
-- Purpose is slice 6: rank suggestions by overlap between these tags and the
-- user's taste_profile_tags, turning "neighbour of an artist you play" into
-- "matches the sound you like".
--
-- GLOBAL, not per-user: a candidate's tags are a property of the artist, not
-- of anyone's taste. Nothing here is user-scoped, so rule #47 has nothing to
-- scope — the per-user part lives entirely in slice 6's ranking.
--
-- weight is a normalized folksonomy strength in [0,1], same scale as
-- track_tags, so the two can be compared without a conversion step.
CREATE TABLE candidate_artist_tags (
candidate_mbid text NOT NULL,
tag text NOT NULL,
weight double precision NOT NULL DEFAULT 1,
PRIMARY KEY (candidate_mbid, tag)
);
-- Enrichment bookkeeping. This is a SEPARATE table rather than columns on the
-- tags table, because the "providers had nothing" outcome must be recordable
-- for a candidate with zero tag rows — otherwise every empty candidate stays
-- eligible forever and the worker re-fetches it on every tick.
--
-- tracks solved the same problem with columns on `tracks` (0042), but there is
-- no per-candidate row anywhere to hang them off: artist_similarity_unmatched
-- is keyed (seed_artist_id, candidate_mbid, source) and holds MANY rows per
-- candidate.
--
-- Absence of a row here means "never processed", so unlike tracks.tag_source
-- this column can be NOT NULL — there is no null-means-pending state to model.
-- 'musicbrainz' | 'lastfm' | 'mixed' → found, cached
-- 'none' → providers confirmed nothing
-- tag_sources_version → bump to re-process settled 'none'
--
-- A transient failure writes NO row at all (rather than a row it would then
-- have to distinguish), which leaves the candidate eligible for the next pass.
CREATE TABLE candidate_artist_tag_state (
candidate_mbid text PRIMARY KEY,
tag_source text NOT NULL,
tag_sources_version integer NOT NULL DEFAULT 0,
updated_at timestamptz NOT NULL DEFAULT now()
);
-- Serves the eligibility scan's "settled 'none' under an older version"
-- branch. The PK already covers the per-candidate lookups.
CREATE INDEX candidate_artist_tag_state_source_idx
ON candidate_artist_tag_state (tag_source, tag_sources_version);
@@ -0,0 +1,10 @@
-- Drop any audit rows under the scope the constraint is about to forbid,
-- otherwise re-adding the narrower CHECK fails against existing data.
DELETE FROM recommendation_tuning_audit WHERE scope = 'discover';
ALTER TABLE recommendation_tuning_audit
DROP CONSTRAINT recommendation_tuning_audit_scope_check;
ALTER TABLE recommendation_tuning_audit
ADD CONSTRAINT recommendation_tuning_audit_scope_check
CHECK (scope IN ('radio', 'daily_mix', 'taste'));
DROP TABLE IF EXISTS discover_tuning;
@@ -0,0 +1,38 @@
-- 0051_discover_tuning.up.sql — tunable knobs for the Discover request
-- surface (#2377, milestone #268 slice 6).
--
-- A FOURTH tuning scope alongside radio / daily_mix / taste. Its own scope
-- rather than extra columns on taste_tuning, for a reason that matters:
-- snooze_days lives here, and a snooze must never be read as taste signal
-- (#2374). Filing it under 'taste' would put it one careless join away from
-- exactly the leak that design forbids.
--
-- Per rule #25 these are DB-backed and editable in the admin UI with no
-- restart — the shipped values below are defaults, not settings.
CREATE TABLE discover_tuning (
singleton boolean PRIMARY KEY DEFAULT true
CONSTRAINT discover_tuning_singleton_check CHECK (singleton),
-- How strongly taste-tag overlap boosts a candidate's similarity score.
-- The blend is MULTIPLICATIVE: score * (1 + w * overlap), overlap in
-- [0,1]. So 0 disables the feature outright and leaves pure similarity
-- ranking, 1.0 lets a perfectly-matching candidate double its score, and
-- a candidate with no cached tags is unchanged rather than penalised
-- (rule #131 — tag coverage is permanently partial, see #2376).
tag_overlap_weight double precision NOT NULL,
-- Default snooze duration in days. Was a Go constant in
-- internal/api/suggestions.go; moved here per rule #25.
snooze_days double precision NOT NULL,
updated_at timestamptz NOT NULL DEFAULT now()
);
INSERT INTO discover_tuning (singleton, tag_overlap_weight, snooze_days)
VALUES (true, 1.0, 90);
-- Rule #36: a new value for a CHECK-gated column needs the constraint
-- rewritten in the SAME change, or the first audit row written under the new
-- scope fails at runtime rather than at migrate time.
ALTER TABLE recommendation_tuning_audit
DROP CONSTRAINT recommendation_tuning_audit_scope_check;
ALTER TABLE recommendation_tuning_audit
ADD CONSTRAINT recommendation_tuning_audit_scope_check
CHECK (scope IN ('radio', 'daily_mix', 'taste', 'discover'));
@@ -0,0 +1,103 @@
-- Folksonomy tags for out-of-library Discover candidates (#2376). Parallel to
-- track_tags.sql, but keyed by MBID because the artist has no local row. See
-- 0050_candidate_artist_tags.up.sql for why the bookkeeping is its own table.
-- name: ListCandidateArtistsMissingTags :many
-- Candidates eligible for tag enrichment: never processed (no state row) or
-- settled 'none' under an older provider version.
--
-- artist_similarity_unmatched holds one row per (seed, candidate, source), so
-- this GROUPs to one row per candidate — enriching the same MBID once per seed
-- that pointed at it would multiply the API calls for no gain.
--
-- ORDER BY summed similarity DESC is the load-bearing part. The candidate pool
-- is O(library artists x neighbours per artist) — thousands — and MusicBrainz
-- allows ~1 req/s, so it can NEVER be fully enriched in one pass. Draining in
-- strength order means the candidates most likely to actually reach a user's
-- deck get tags first, and the long tail fills in over subsequent ticks
-- instead of starving behind it.
--
-- Already-in-library candidates are skipped: they have an artists row, so
-- their tags belong in track_tags, and the suggestion query filters them out
-- anyway. $1 = current tag_sources_version, $2 = limit.
-- candidate_name is coalesced to the empty string so it lands non-nullable in
-- Go: the name is only a Last.fm lookup key, and empty simply means "MBID-keyed
-- providers only", which the provider chain already handles. max() is an
-- arbitrary-but-deterministic pick when several seeds spell one MBID
-- differently.
SELECT u.candidate_mbid,
coalesce(max(u.candidate_name), '')::text AS candidate_name,
sum(u.score)::float8 AS total_score
FROM artist_similarity_unmatched u
LEFT JOIN candidate_artist_tag_state s ON s.candidate_mbid = u.candidate_mbid
WHERE NOT EXISTS (SELECT 1 FROM artists a WHERE a.mbid = u.candidate_mbid)
AND (
s.candidate_mbid IS NULL
OR (s.tag_source = 'none' AND s.tag_sources_version < $1)
)
GROUP BY u.candidate_mbid
ORDER BY total_score DESC, u.candidate_mbid
LIMIT $2;
-- name: DeleteCandidateArtistTags :exec
-- Clear a candidate's cached tags before rewriting (atomic replace by the
-- caller, same shape as DeleteTrackTags).
DELETE FROM candidate_artist_tags WHERE candidate_mbid = $1;
-- name: InsertCandidateArtistTag :exec
-- Upsert one (candidate, tag); keep the stronger weight when two providers
-- agree on a tag with different folksonomy strengths.
INSERT INTO candidate_artist_tags (candidate_mbid, tag, weight)
VALUES ($1, $2, $3)
ON CONFLICT (candidate_mbid, tag)
DO UPDATE SET weight = GREATEST(candidate_artist_tags.weight, EXCLUDED.weight);
-- name: SetCandidateArtistTagState :exec
-- Stamp the enrichment outcome so the drainer skips settled candidates.
-- $2 = 'musicbrainz' | 'lastfm' | 'mixed' | 'none', $3 = current version.
INSERT INTO candidate_artist_tag_state (candidate_mbid, tag_source, tag_sources_version)
VALUES ($1, $2, $3)
ON CONFLICT (candidate_mbid) DO UPDATE
SET tag_source = EXCLUDED.tag_source,
tag_sources_version = EXCLUDED.tag_sources_version,
updated_at = now();
-- name: ListCandidateArtistTagsForMbids :many
-- Cached tags for a set of candidates, for slice 6's taste-overlap ranking.
-- One row per (candidate, tag).
SELECT candidate_mbid, tag, weight
FROM candidate_artist_tags
WHERE candidate_mbid = ANY($1::text[]);
-- name: CountCandidateArtistTagCoverage :one
-- Operator-facing coverage: how many distinct candidates have been processed,
-- and how many of those actually yielded tags. The gap is the honest ceiling
-- from the task — obscure artists with no MBID presence or no upstream tags
-- stay thin no matter how long the worker runs, and that is worth being able
-- to see rather than inferring from a silent surface.
SELECT count(*)::bigint AS processed,
count(*) FILTER (WHERE tag_source <> 'none')::bigint AS with_tags
FROM candidate_artist_tag_state;
-- name: GcDeleteOrphanedCandidateArtistTags :execrows
-- Drops cached tags for candidates that no longer appear in the similarity
-- feed, or that have since been added to the library (their tags now live in
-- track_tags). The feed is refetched periodically and churns, so without this
-- the cache only ever grows.
DELETE FROM candidate_artist_tags t
WHERE NOT EXISTS (
SELECT 1 FROM artist_similarity_unmatched u
WHERE u.candidate_mbid = t.candidate_mbid
)
OR EXISTS (SELECT 1 FROM artists a WHERE a.mbid = t.candidate_mbid);
-- name: GcDeleteOrphanedCandidateArtistTagState :execrows
-- Same sweep for the bookkeeping rows. Kept as a separate statement rather
-- than a cascade: the two tables are independent by design (a 'none' outcome
-- has state but no tags), so neither can be the parent of the other.
DELETE FROM candidate_artist_tag_state s
WHERE NOT EXISTS (
SELECT 1 FROM artist_similarity_unmatched u
WHERE u.candidate_mbid = s.candidate_mbid
)
OR EXISTS (SELECT 1 FROM artists a WHERE a.mbid = s.candidate_mbid);
+65 -13
View File
@@ -258,27 +258,67 @@ ORDER BY started_at DESC
LIMIT 1;
-- name: SuggestArtistsForUser :many
-- M5c: per-user artist suggestions ranked by signal x similarity. The
-- seeds CTE collects the user's likes (x5) plus recency-decayed plays
-- (exp(-age_days / $2)). The contributions CTE joins those seeds against
-- artist_similarity_unmatched and filters out candidates already in
-- library or already in a non-terminal lidarr_request. The outer SELECT
-- aggregates per candidate, returning the top-3 contributing seeds for
-- attribution. $1=user_id, $2=half_life_days, $3=limit.
WITH seeds AS (
-- Per-user artist suggestions ranked by taste signal x similarity, projected
-- through artist_similarity_unmatched (out-of-library candidates only).
--
-- Seeds are TIERED (rule #131) so the surface never empties:
-- tier 1 - taste_profile_artists.weight: engagement-graded, time-decayed and
-- SIGNED by internal/taste, so an artist the user has drifted away
-- from stops contributing instead of accumulating forever.
-- tier 2 - likes + completed plays, used ONLY when the profile has no rows
-- (new account, or before the first daily recompute).
--
-- The signal is log-damped: contribution is signal x similarity, and the old
-- undamped sum let one heavily-played artist's neighbours take every slot --
-- entrenching harder the MORE the user listened (issue #2367 mechanism 2).
--
-- Candidates already in the library, already requested and not terminal, or
-- snoozed by this user, are excluded. $1=user_id, $2=half_life_days (tier 2
-- decay), $3=limit.
WITH artist_plays AS (
-- Completed plays only. The previous seed query counted every play_event,
-- so skipping an artist repeatedly INCREASED its signal and pushed more of
-- its neighbours at the user (issue #2367 mechanism 3).
SELECT t.artist_id, count(*)::bigint AS play_count
FROM play_events pe
JOIN tracks t ON t.id = pe.track_id
WHERE pe.user_id = $1 AND pe.was_skipped = false
GROUP BY t.artist_id
),
profile_seeds AS (
SELECT tpa.artist_id, tpa.weight AS raw_signal
FROM taste_profile_artists tpa
WHERE tpa.user_id = $1 AND tpa.weight > 0
),
fallback_seeds AS (
SELECT a.id AS artist_id,
5.0 * (CASE WHEN gla.artist_id IS NOT NULL THEN 1 ELSE 0 END)
+ COALESCE(SUM(EXP(- EXTRACT(epoch FROM now() - pe.started_at) / ($2::float8 * 86400.0))), 0)
AS signal,
(gla.artist_id IS NOT NULL) AS is_liked,
COUNT(pe.id)::bigint AS play_count
AS raw_signal
FROM artists a
LEFT JOIN general_likes_artists gla ON gla.artist_id = a.id AND gla.user_id = $1
LEFT JOIN tracks t ON t.artist_id = a.id
LEFT JOIN play_events pe ON pe.track_id = t.id AND pe.user_id = $1
WHERE gla.artist_id IS NOT NULL OR pe.id IS NOT NULL
LEFT JOIN play_events pe
ON pe.track_id = t.id AND pe.user_id = $1 AND pe.was_skipped = false
WHERE (gla.artist_id IS NOT NULL OR pe.id IS NOT NULL)
AND NOT EXISTS (SELECT 1 FROM profile_seeds)
GROUP BY a.id, gla.artist_id
),
seeds AS (
SELECT s.artist_id,
ln(1.0 + s.raw_signal) AS signal,
(gla.artist_id IS NOT NULL) AS is_liked,
COALESCE(ap.play_count, 0)::bigint AS play_count
FROM (
SELECT artist_id, raw_signal FROM profile_seeds
UNION ALL
SELECT artist_id, raw_signal FROM fallback_seeds
) s
LEFT JOIN general_likes_artists gla
ON gla.artist_id = s.artist_id AND gla.user_id = $1
LEFT JOIN artist_plays ap ON ap.artist_id = s.artist_id
WHERE s.raw_signal > 0
),
contributions AS (
SELECT u.candidate_mbid,
u.candidate_name,
@@ -295,6 +335,18 @@ contributions AS (
AND r.lidarr_artist_mbid = u.candidate_mbid
AND r.status NOT IN ('rejected', 'failed')
)
-- Snoozed by this user and not yet expired (#2374). Time-boxed and
-- per-user: the candidate returns on its own once snoozed_until
-- passes, and stays visible to everyone else meanwhile. Deliberately
-- filtered at the candidate stage, NOT folded into the score — a
-- snooze carries no opinion about the music, so it must not become a
-- ranking signal.
AND NOT EXISTS (
SELECT 1 FROM suggestion_snoozes s
WHERE s.user_id = $1
AND s.candidate_mbid = u.candidate_mbid
AND s.snoozed_until > now()
)
)
SELECT candidate_mbid,
candidate_name,
@@ -53,6 +53,24 @@ UPDATE taste_tuning
WHERE singleton = true
RETURNING *;
-- name: UpsertDiscoverTuningDefaults :exec
-- Boot reconcile for the Discover scope (#2377). Never overwrites
-- operator-tuned values, same contract as the other two.
INSERT INTO discover_tuning (singleton, tag_overlap_weight, snooze_days)
VALUES (true, $1, $2)
ON CONFLICT (singleton) DO NOTHING;
-- name: GetDiscoverTuning :one
SELECT * FROM discover_tuning WHERE singleton = true;
-- name: UpdateDiscoverTuning :one
UPDATE discover_tuning
SET tag_overlap_weight = $1,
snooze_days = $2,
updated_at = now()
WHERE singleton = true
RETURNING *;
-- name: InsertTuningAudit :exec
-- changes is a jsonb array of {field, old, new} objects.
INSERT INTO recommendation_tuning_audit (scope, action, changes)
@@ -0,0 +1,39 @@
-- Time-boxed "not right now" on a Discover artist suggestion (#2374).
--
-- See 0049_suggestion_snoozes.up.sql for why this is a snooze and not a
-- dismissal: it records no verdict on the music, expires on its own, and
-- must never reach the taste profile. Nothing in internal/taste may read
-- this table.
-- name: SnoozeSuggestion :exec
-- Upsert, so re-snoozing an already-snoozed candidate EXTENDS it instead of
-- erroring on the PK. The name is refreshed too — a later suggestion may
-- carry a corrected spelling from the similarity feed.
-- $1=user_id, $2=candidate_mbid, $3=candidate_name, $4=duration in days.
INSERT INTO suggestion_snoozes (user_id, candidate_mbid, candidate_name, snoozed_until)
VALUES ($1, $2, $3, now() + ($4::float8 * INTERVAL '1 day'))
ON CONFLICT (user_id, candidate_mbid) DO UPDATE
SET snoozed_until = EXCLUDED.snoozed_until,
candidate_name = EXCLUDED.candidate_name;
-- name: UnsnoozeSuggestion :execrows
-- Row count is returned so the handler can 404 an MBID that was never
-- snoozed rather than reporting success for a no-op.
DELETE FROM suggestion_snoozes
WHERE user_id = $1 AND candidate_mbid = $2;
-- name: ListActiveSuggestionSnoozes :many
-- Backs the manage / un-snooze surface. Expired rows are filtered HERE
-- rather than left to the sweeper: gc runs on an hourly tick, so a row can
-- outlive its expiry by up to a tick and must not read as still-snoozed in
-- the meantime.
SELECT candidate_mbid, candidate_name, snoozed_until, created_at
FROM suggestion_snoozes
WHERE user_id = $1 AND snoozed_until > now()
ORDER BY snoozed_until, candidate_mbid;
-- name: GcDeleteExpiredSuggestionSnoozes :execrows
-- Keeps the table from growing without bound. Every read already filters on
-- snoozed_until > now(), so deleting an expired row changes no behaviour —
-- this is purely reclamation.
DELETE FROM suggestion_snoozes WHERE snoozed_until < now();
+16
View File
@@ -52,6 +52,18 @@ var dataTables = []string{
"sessions",
"lidarr_quarantine_actions",
"lidarr_quarantine",
// #2374. Keyed by (user_id, candidate_mbid) with no FK to artists —
// candidates are out-of-library — so the CASCADE from artists/users
// does NOT reach it for a leftover row whose user survived. Truncate
// explicitly or a stale snooze silently hides a candidate from the
// next test's suggestion assertions.
"suggestion_snoozes",
// #2376. Same reasoning: keyed by candidate MBID with no FK anywhere,
// so nothing cascades to them. A leftover tag row would make a
// candidate look enriched to the next test, and a leftover state row
// would make it look already-settled and thus ineligible.
"candidate_artist_tags",
"candidate_artist_tag_state",
"playlist_tracks",
"playlists",
"library_changes", // M7 #357 — must reset to keep cursor isolated per test
@@ -70,6 +82,10 @@ var dataTables = []string{
// (#1250), so truncating gives each test pristine tuning values.
"recommendation_weight_profiles",
"taste_tuning",
// #2377. Same reasoning as taste_tuning above: recsettings.New re-seeds
// shipped defaults on every construction, so truncating gives each test
// pristine Discover knobs rather than whatever a previous test tuned.
"discover_tuning",
"recommendation_tuning_audit",
"tracks",
"albums",
+11
View File
@@ -17,6 +17,9 @@
// - GcResetStuckSystemPlaylistRuns (#574)
// - GcDeleteExpiredPasswordResets (#575)
// - GcPruneDiagnostics (M9 — diagnostics 30d retention)
// - GcDeleteExpiredSuggestionSnoozes (#2374 — snoozes expire, then go)
// - GcDeleteOrphanedCandidateArtistTags(+State) (#2376 — the similarity
// feed churns, so cached candidate tags outlive their candidates)
package gc
import (
@@ -84,6 +87,14 @@ func (w *Worker) tickOnce(ctx context.Context) {
w.runSweep(ctx, "reset_stuck_system_runs", q.GcResetStuckSystemPlaylistRuns)
w.runSweep(ctx, "delete_expired_password_resets", q.GcDeleteExpiredPasswordResets)
w.runSweep(ctx, "prune_diagnostics", q.GcPruneDiagnostics)
w.runSweep(ctx, "delete_expired_suggestion_snoozes", q.GcDeleteExpiredSuggestionSnoozes)
// Tags before state: if the process dies between the two, a candidate left
// with a state row and no tags simply reads as "settled, nothing found",
// which is already a valid state. The reverse order could leave tags with
// no state row, which the drainer would treat as never-processed and
// re-fetch on top of rows that are already there.
w.runSweep(ctx, "orphaned_candidate_artist_tags", q.GcDeleteOrphanedCandidateArtistTags)
w.runSweep(ctx, "orphaned_candidate_artist_tag_state", q.GcDeleteOrphanedCandidateArtistTagState)
}
// runSweep is a small adapter so each sweep call site is a one-liner
@@ -0,0 +1,455 @@
package recommendation
import (
"context"
"testing"
"github.com/jackc/pgx/v5/pgtype"
"github.com/jackc/pgx/v5/pgxpool"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
)
// Slice 5 (#2376): the candidate-artist tag cache for out-of-library Discover
// candidates. These cover the SQL rather than the provider chain — the
// eligibility query is the piece with real risk in it (a GROUP BY over a
// many-rows-per-candidate table, a LEFT JOIN to bookkeeping, and two exclusion
// branches), and it's consumed by this package's slice-6 ranking.
//
// Fixtures live here because the harness and seedUnmatched do.
const (
tagVersionCurrent = 2
tagVersionOld = 1
)
// listEligible is the query under test, at the current provider version.
func listEligible(t *testing.T, pool *pgxpool.Pool, limit int32) []dbq.ListCandidateArtistsMissingTagsRow {
t.Helper()
rows, err := dbq.New(pool).ListCandidateArtistsMissingTags(context.Background(),
dbq.ListCandidateArtistsMissingTagsParams{
TagSourcesVersion: tagVersionCurrent,
Limit: limit,
})
if err != nil {
t.Fatalf("ListCandidateArtistsMissingTags: %v", err)
}
return rows
}
func setState(t *testing.T, pool *pgxpool.Pool, mbid, source string, version int32) {
t.Helper()
if err := dbq.New(pool).SetCandidateArtistTagState(context.Background(),
dbq.SetCandidateArtistTagStateParams{
CandidateMbid: mbid, TagSource: source, TagSourcesVersion: version,
}); err != nil {
t.Fatalf("SetCandidateArtistTagState: %v", err)
}
}
func mbidsOf(rows []dbq.ListCandidateArtistsMissingTagsRow) []string {
out := make([]string, 0, len(rows))
for _, r := range rows {
out = append(out, r.CandidateMbid)
}
return out
}
func TestCandidateTags_NeverProcessedIsEligible(t *testing.T) {
pool := newPool(t)
user := seedUser(t, pool, "alice")
seed := seedArtist(t, pool, "Seed", "")
likeArtist(t, pool, user.ID, seed.ID)
seedUnmatched(t, pool, seed.ID, "cand-1", "Candidate One", 0.9)
rows := listEligible(t, pool, 10)
if len(rows) != 1 {
t.Fatalf("len = %d, want 1: %v", len(rows), mbidsOf(rows))
}
if rows[0].CandidateName != "Candidate One" {
t.Errorf("name = %q, want Candidate One", rows[0].CandidateName)
}
if rows[0].TotalScore != 0.9 {
t.Errorf("score = %v, want 0.9", rows[0].TotalScore)
}
}
// An in-library candidate's tags belong in track_tags, and the suggestion query
// filters it out anyway — enriching it would be wasted API budget.
func TestCandidateTags_InLibraryCandidateIsExcluded(t *testing.T) {
pool := newPool(t)
seed := seedArtist(t, pool, "Seed", "")
seedArtist(t, pool, "Already Here", "cand-in-lib")
seedUnmatched(t, pool, seed.ID, "cand-in-lib", "Already Here", 0.9)
if rows := listEligible(t, pool, 10); len(rows) != 0 {
t.Errorf("len = %d, want 0: %v", len(rows), mbidsOf(rows))
}
}
func TestCandidateTags_SettledWithTagsIsExcluded(t *testing.T) {
pool := newPool(t)
seed := seedArtist(t, pool, "Seed", "")
seedUnmatched(t, pool, seed.ID, "cand-1", "Candidate One", 0.9)
setState(t, pool, "cand-1", "musicbrainz", tagVersionCurrent)
if rows := listEligible(t, pool, 10); len(rows) != 0 {
t.Errorf("len = %d, want 0 (already enriched): %v", len(rows), mbidsOf(rows))
}
}
// A candidate that settled 'none' becomes eligible again when the provider set
// widens (version bump) — that's the whole point of the version column. It must
// NOT be eligible at the current version, or the worker re-fetches it forever.
func TestCandidateTags_SettledNoneReopensOnlyOnVersionBump(t *testing.T) {
pool := newPool(t)
seed := seedArtist(t, pool, "Seed", "")
seedUnmatched(t, pool, seed.ID, "cand-1", "Candidate One", 0.9)
setState(t, pool, "cand-1", "none", tagVersionCurrent)
if rows := listEligible(t, pool, 10); len(rows) != 0 {
t.Errorf("current version: len = %d, want 0 (settled)", len(rows))
}
setState(t, pool, "cand-1", "none", tagVersionOld)
if rows := listEligible(t, pool, 10); len(rows) != 1 {
t.Errorf("older version: len = %d, want 1 (eligible again)", len(rows))
}
}
// artist_similarity_unmatched holds one row per (seed, candidate, source).
// Without the GROUP BY, a candidate that five seeds point at would be fetched
// five times — five times the MusicBrainz budget for identical data.
func TestCandidateTags_ManySeedsCollapseToOneRowAndSumScores(t *testing.T) {
pool := newPool(t)
seedA := seedArtist(t, pool, "Seed A", "")
seedB := seedArtist(t, pool, "Seed B", "")
seedUnmatched(t, pool, seedA.ID, "cand-1", "Candidate One", 0.4)
seedUnmatched(t, pool, seedB.ID, "cand-1", "Candidate One", 0.3)
rows := listEligible(t, pool, 10)
if len(rows) != 1 {
t.Fatalf("len = %d, want 1 (grouped): %v", len(rows), mbidsOf(rows))
}
if got := rows[0].TotalScore; got < 0.69 || got > 0.71 {
t.Errorf("total_score = %v, want ~0.7 (summed across seeds)", got)
}
}
// The pool is far larger than one pass can drain at ~1 req/s, so the ordering
// IS the feature: the strongest candidates must be enriched first, or the ones
// that actually reach a user's deck starve behind the long tail.
func TestCandidateTags_StrongestCandidatesComeFirst(t *testing.T) {
pool := newPool(t)
seed := seedArtist(t, pool, "Seed", "")
seedUnmatched(t, pool, seed.ID, "weak", "Weak", 0.1)
seedUnmatched(t, pool, seed.ID, "strong", "Strong", 0.95)
seedUnmatched(t, pool, seed.ID, "middle", "Middle", 0.5)
rows := listEligible(t, pool, 10)
want := []string{"strong", "middle", "weak"}
got := mbidsOf(rows)
if len(got) != 3 {
t.Fatalf("len = %d, want 3: %v", len(got), got)
}
for i := range want {
if got[i] != want[i] {
t.Fatalf("order = %v, want %v", got, want)
}
}
// And the limit takes the strongest, not an arbitrary slice.
if top := mbidsOf(listEligible(t, pool, 1)); len(top) != 1 || top[0] != "strong" {
t.Errorf("limit 1 returned %v, want [strong]", top)
}
}
func TestCandidateTags_InsertKeepsTheStrongerWeight(t *testing.T) {
pool := newPool(t)
q := dbq.New(pool)
ctx := context.Background()
ins := func(w float64) {
if err := q.InsertCandidateArtistTag(ctx, dbq.InsertCandidateArtistTagParams{
CandidateMbid: "cand-1", Tag: "shoegaze", Weight: w,
}); err != nil {
t.Fatalf("InsertCandidateArtistTag: %v", err)
}
}
ins(0.8)
ins(0.3) // weaker second write must not clobber
rows, err := q.ListCandidateArtistTagsForMbids(ctx, []string{"cand-1"})
if err != nil {
t.Fatalf("ListCandidateArtistTagsForMbids: %v", err)
}
if len(rows) != 1 {
t.Fatalf("len = %d, want 1", len(rows))
}
if rows[0].Weight != 0.8 {
t.Errorf("weight = %v, want 0.8 (GREATEST)", rows[0].Weight)
}
}
func TestCandidateTags_ListForMbidsIgnoresUnaskedCandidates(t *testing.T) {
pool := newPool(t)
q := dbq.New(pool)
ctx := context.Background()
for _, mbid := range []string{"want-1", "want-2", "other"} {
if err := q.InsertCandidateArtistTag(ctx, dbq.InsertCandidateArtistTagParams{
CandidateMbid: mbid, Tag: "rock", Weight: 1,
}); err != nil {
t.Fatalf("insert %s: %v", mbid, err)
}
}
rows, err := q.ListCandidateArtistTagsForMbids(ctx, []string{"want-1", "want-2"})
if err != nil {
t.Fatalf("ListCandidateArtistTagsForMbids: %v", err)
}
if len(rows) != 2 {
t.Errorf("len = %d, want 2", len(rows))
}
for _, r := range rows {
if r.CandidateMbid == "other" {
t.Error("returned a candidate that wasn't asked for")
}
}
}
// The similarity feed is refetched and churns, so without the sweep the cache
// only grows. Both halves must survive/die together for the right candidates.
func TestCandidateTags_GcDropsOrphansAndKeepsLiveOnes(t *testing.T) {
pool := newPool(t)
q := dbq.New(pool)
ctx := context.Background()
seed := seedArtist(t, pool, "Seed", "")
seedUnmatched(t, pool, seed.ID, "live", "Live", 0.9)
// "gone" is cached but no longer in the feed; "adopted" got added to the
// library since, so its tags belong in track_tags now.
seedArtist(t, pool, "Adopted", "adopted")
seedUnmatched(t, pool, seed.ID, "adopted", "Adopted", 0.8)
for _, mbid := range []string{"live", "gone", "adopted"} {
if err := q.InsertCandidateArtistTag(ctx, dbq.InsertCandidateArtistTagParams{
CandidateMbid: mbid, Tag: "rock", Weight: 1,
}); err != nil {
t.Fatalf("insert %s: %v", mbid, err)
}
setState(t, pool, mbid, "musicbrainz", tagVersionCurrent)
}
deleted, err := q.GcDeleteOrphanedCandidateArtistTags(ctx)
if err != nil {
t.Fatalf("GcDeleteOrphanedCandidateArtistTags: %v", err)
}
if deleted != 2 {
t.Errorf("deleted %d tag rows, want 2 (gone + adopted)", deleted)
}
deletedState, err := q.GcDeleteOrphanedCandidateArtistTagState(ctx)
if err != nil {
t.Fatalf("GcDeleteOrphanedCandidateArtistTagState: %v", err)
}
if deletedState != 2 {
t.Errorf("deleted %d state rows, want 2", deletedState)
}
rows, err := q.ListCandidateArtistTagsForMbids(ctx, []string{"live", "gone", "adopted"})
if err != nil {
t.Fatalf("ListCandidateArtistTagsForMbids: %v", err)
}
if len(rows) != 1 || rows[0].CandidateMbid != "live" {
t.Errorf("survivors = %v, want [live] only", rows)
}
}
// Coverage is the operator's window onto the honest ceiling: processed vs
// actually-tagged. A big gap means thin upstream data, not a broken worker.
func TestCandidateTags_CoverageCountsProcessedAndTagged(t *testing.T) {
pool := newPool(t)
setState(t, pool, "has-tags", "musicbrainz", tagVersionCurrent)
setState(t, pool, "mixed-tags", "mixed", tagVersionCurrent)
setState(t, pool, "no-tags", "none", tagVersionCurrent)
got, err := dbq.New(pool).CountCandidateArtistTagCoverage(context.Background())
if err != nil {
t.Fatalf("CountCandidateArtistTagCoverage: %v", err)
}
if got.Processed != 3 {
t.Errorf("processed = %d, want 3", got.Processed)
}
if got.WithTags != 2 {
t.Errorf("with_tags = %d, want 2 ('none' excluded)", got.WithTags)
}
}
// --- Slice 6 (#2377): the taste-tag blend, end to end ---
//
// The pure tests in tagoverlap_test.go cover the scoring maths. These cover the
// wiring the pure tests cannot reach: that loadTagInputs actually reads both
// sides from the DB and that the blend reaches the returned deck.
func seedTasteTag(t *testing.T, pool *pgxpool.Pool, userID pgtype.UUID, tag string, weight float64) {
t.Helper()
if _, err := pool.Exec(context.Background(),
`INSERT INTO taste_profile_tags (user_id, tag, weight) VALUES ($1, $2, $3)
ON CONFLICT (user_id, tag) DO UPDATE SET weight = EXCLUDED.weight`,
userID, tag, weight,
); err != nil {
t.Fatalf("seedTasteTag: %v", err)
}
}
func seedCandidateTag(t *testing.T, pool *pgxpool.Pool, mbid, tag string, weight float64) {
t.Helper()
if err := dbq.New(pool).InsertCandidateArtistTag(context.Background(),
dbq.InsertCandidateArtistTagParams{CandidateMbid: mbid, Tag: tag, Weight: weight},
); err != nil {
t.Fatalf("seedCandidateTag: %v", err)
}
}
// twoCandidatePool wires a liked seed with two unmatched neighbours: "loud"
// scores higher on similarity, "match" lower. Skewed on purpose — with equal
// similarity the reorder assertion below could not fail.
func twoCandidatePool(t *testing.T, pool *pgxpool.Pool, userID pgtype.UUID) {
t.Helper()
seed := seedArtist(t, pool, "Seed", "")
likeArtist(t, pool, userID, seed.ID)
seedUnmatched(t, pool, seed.ID, "loud", "Loud Neighbour", 0.9)
seedUnmatched(t, pool, seed.ID, "match", "Taste Match", 0.6)
}
func TestSuggestArtists_TasteTagMatchOvertakesStrongerSimilarity(t *testing.T) {
pool := newPool(t)
user := seedUser(t, pool, "alice")
twoCandidatePool(t, pool, user.ID)
seedTasteTag(t, pool, user.ID, "shoegaze", 10)
seedCandidateTag(t, pool, "match", "shoegaze", 1.0)
seedCandidateTag(t, pool, "loud", "death metal", 1.0)
out, err := SuggestArtists(context.Background(), pool, user.ID, 30, 12, 1.0)
if err != nil {
t.Fatalf("SuggestArtists: %v", err)
}
if len(out) != 2 {
t.Fatalf("len = %d, want 2", len(out))
}
if out[0].MBID != "match" {
t.Errorf("first = %q, want match (0.6×2 beats 0.9×1)", out[0].MBID)
}
// And it explains itself.
if len(out[0].MatchedTags) != 1 || out[0].MatchedTags[0] != "shoegaze" {
t.Errorf("matched tags = %v, want [shoegaze]", out[0].MatchedTags)
}
if out[1].MatchedTags != nil {
t.Errorf("non-matching candidate got matched tags: %v", out[1].MatchedTags)
}
}
// The same fixture with the knob at 0 must return pure similarity order — the
// operator's off switch, verified against a real DB rather than assumed.
func TestSuggestArtists_ZeroTagWeightKeepsSimilarityOrder(t *testing.T) {
pool := newPool(t)
user := seedUser(t, pool, "alice")
twoCandidatePool(t, pool, user.ID)
seedTasteTag(t, pool, user.ID, "shoegaze", 10)
seedCandidateTag(t, pool, "match", "shoegaze", 1.0)
out, err := SuggestArtists(context.Background(), pool, user.ID, 30, 12, 0)
if err != nil {
t.Fatalf("SuggestArtists: %v", err)
}
if out[0].MBID != "loud" {
t.Errorf("first = %q, want loud (tag term disabled)", out[0].MBID)
}
}
// A user with no taste profile must still get a full deck in similarity order:
// the cold-start path, which is every user's first days (rule #131).
func TestSuggestArtists_NoTasteTagsStillReturnsSimilarityOrder(t *testing.T) {
pool := newPool(t)
user := seedUser(t, pool, "alice")
twoCandidatePool(t, pool, user.ID)
// Candidate tags exist, but the user has no taste tags to match them.
seedCandidateTag(t, pool, "match", "shoegaze", 1.0)
out, err := SuggestArtists(context.Background(), pool, user.ID, 30, 12, 1.0)
if err != nil {
t.Fatalf("SuggestArtists: %v", err)
}
if len(out) != 2 {
t.Fatalf("len = %d, want 2 (nothing dropped)", len(out))
}
if out[0].MBID != "loud" {
t.Errorf("first = %q, want loud", out[0].MBID)
}
}
// An untagged candidate must never be dropped or sunk just because another
// candidate has tags — permanently-partial coverage (#2376) must not become a
// permanent ranking penalty.
func TestSuggestArtists_UntaggedCandidateSurvivesAlongsideTagged(t *testing.T) {
pool := newPool(t)
user := seedUser(t, pool, "alice")
twoCandidatePool(t, pool, user.ID)
seedTasteTag(t, pool, user.ID, "shoegaze", 10)
seedCandidateTag(t, pool, "match", "shoegaze", 1.0)
// "loud" is deliberately left with NO cached tags at all.
// Compared against the same request with the term disabled, rather than
// against a literal. The pool score is signal-weighted by the seed query
// (ln(1+signal) x similarity), so hardcoding a number here would assert
// against the seeding maths — a different layer — and break whenever that
// changes. The property under test is only that the blend leaves an
// untagged candidate alone.
blended, err := SuggestArtists(context.Background(), pool, user.ID, 30, 12, 1.0)
if err != nil {
t.Fatalf("SuggestArtists(blended): %v", err)
}
baseline, err := SuggestArtists(context.Background(), pool, user.ID, 30, 12, 0)
if err != nil {
t.Fatalf("SuggestArtists(baseline): %v", err)
}
if len(blended) != 2 {
t.Fatalf("len = %d, want 2 — the untagged candidate must still appear", len(blended))
}
scoreOf := func(out []ArtistSuggestion, mbid string) (float64, bool) {
for _, s := range out {
if s.MBID == mbid {
return s.Score, true
}
}
return 0, false
}
want, ok := scoreOf(baseline, "loud")
if !ok {
t.Fatal("untagged candidate missing from the baseline deck")
}
got, ok := scoreOf(blended, "loud")
if !ok {
t.Fatal("untagged candidate vanished once the tag term was enabled")
}
if got != want {
t.Errorf("untagged score = %v, want %v (identical to the term-disabled run)", got, want)
}
// Sanity: the tagged candidate DID move, so the comparison above is
// meaningful rather than both runs being trivially identical.
if tagged, _ := scoreOf(blended, "match"); tagged == mustScore(t, baseline, "match") {
t.Error("the tagged candidate's score did not change — the term did nothing")
}
}
func mustScore(t *testing.T, out []ArtistSuggestion, mbid string) float64 {
t.Helper()
for _, s := range out {
if s.MBID == mbid {
return s.Score
}
}
t.Fatalf("no candidate %q in %v", mbid, out)
return 0
}
+230 -9
View File
@@ -1,12 +1,23 @@
// suggestions.go is the M5c per-user artist-suggestion service. Reads
// the user's likes + plays, projects them through artist_similarity_unmatched
// via a single CTE, returns top-N candidates with top-3 attribution seeds
// resolved to artist names.
// suggestions.go is the per-user artist-suggestion service behind the
// Discover request surface. Seeds from the taste profile (falling back to
// likes + completed plays for a user who has none yet), projects those seeds
// through artist_similarity_unmatched via a single CTE, and returns top-N
// out-of-library candidates with top-3 attribution seeds resolved to names.
//
// Originally M5c, which seeded from raw likes + plays. That signal grew
// without bound and counted skips as engagement, so a few heavily-played
// artists monopolized every slot and the surface entrenched harder the more
// the user listened. Reworked to seed from the taste profile in issue #2367;
// see internal/db/queries/recommendation.sql for the tiering.
package recommendation
import (
"context"
"crypto/md5"
"encoding/hex"
"fmt"
"sort"
"time"
"github.com/jackc/pgx/v5/pgtype"
"github.com/jackc/pgx/v5/pgxpool"
@@ -20,6 +31,15 @@ type ArtistSuggestion struct {
Name string
Score float64
Attribution []SeedContribution
// MatchedTags are the candidate's own tags that overlap the user's taste
// profile, strongest first (max 3) — the "matches: shoegaze, melancholic"
// explanation (#2377). Empty when the candidate has no cached tags, which
// is common and not an error: coverage is permanently partial (#2376).
MatchedTags []string
// TagOverlap is the [0,1] share of the candidate's tag mass the user likes.
// Exposed for the admin tuning lab — seeing the term's actual distribution
// is how the operator picks a weight rather than guessing at one.
TagOverlap float64
}
// SeedContribution is one of the top-3 contributing seeds for a candidate.
@@ -32,9 +52,16 @@ type SeedContribution struct {
}
// SuggestArtists returns top-N artist suggestions for the user. limit is
// capped at 50 (default 12 when out of range); halfLifeDays is the
// recency-decay half-life for plays (default 30, operator-tunable).
func SuggestArtists(ctx context.Context, pool *pgxpool.Pool, userID pgtype.UUID, halfLifeDays float64, limit int) ([]ArtistSuggestion, error) {
// capped at 50 (default 12 when out of range).
//
// halfLifeDays (default 30) is the recency-decay half-life for the TIER-2
// seed path only — the likes + completed-plays fallback used while the user
// has no taste-profile rows yet. Once the profile is populated it seeds
// instead, carrying its own decay, so this knob stops applying.
func SuggestArtists(
ctx context.Context, pool *pgxpool.Pool, userID pgtype.UUID,
halfLifeDays float64, limit int, tagOverlapWeight float64,
) ([]ArtistSuggestion, error) {
if limit <= 0 || limit > 50 {
limit = 12
}
@@ -42,10 +69,14 @@ func SuggestArtists(ctx context.Context, pool *pgxpool.Pool, userID pgtype.UUID,
halfLifeDays = 30
}
q := dbq.New(pool)
// Over-fetch so there is something to rotate through. A deterministic
// top-N over a score ordering shows the same faces every day until one is
// requested away — the tail of the ranking was unreachable (#2367
// mechanism 1). Selection from this pool happens in selectSuggestions.
rows, err := q.SuggestArtistsForUser(ctx, dbq.SuggestArtistsForUserParams{
UserID: userID,
Column2: halfLifeDays,
Limit: int32(limit),
Limit: int32(poolSizeFor(limit)),
})
if err != nil {
return nil, fmt.Errorf("suggest: query: %w", err)
@@ -98,5 +129,195 @@ func SuggestArtists(ctx context.Context, pool *pgxpool.Pool, userID pgtype.UUID,
Attribution: attribution,
})
}
return out, nil
// Taste-tag term (#2377). Applied to the whole over-fetched pool BEFORE
// selection, so the rotation and diversity rules in selectSuggestions
// operate on taste-blended scores — boosting only the twelve already
// chosen by similarity would leave the actual re-ranking undone.
//
// A query error here is returned, NOT degraded past. Graceful degradation
// is for expected absence — no taste profile yet, no cached tags for a
// candidate — and both of those are handled explicitly as empty inputs
// below. A failing query is neither: swallowing it would hide a broken DB
// behind a subtly worse ranking that nothing reports.
tasteTags, candTags, err := loadTagInputs(ctx, q, userID, out)
if err != nil {
return nil, err
}
out = applyTagOverlap(out, candTags, tasteTags, tagOverlapWeight)
return selectSuggestions(out, limit, rotationDay(time.Now())), nil
}
// tasteTagLimit caps how many of the user's taste tags participate. The
// profile's long tail is near-zero weight and contributes nothing after
// normalization, so this bounds the query rather than the meaning.
const tasteTagLimit = 50
// loadTagInputs fetches both sides of the overlap comparison: the user's taste
// tags and the cached tags for exactly the candidates in this pool.
func loadTagInputs(
ctx context.Context, q *dbq.Queries, userID pgtype.UUID, pool []ArtistSuggestion,
) (TagWeights, map[string]TagWeights, error) {
tasteRows, err := q.ListTasteProfileTagsForUser(ctx, dbq.ListTasteProfileTagsForUserParams{
UserID: userID,
Limit: tasteTagLimit,
})
if err != nil {
return nil, nil, fmt.Errorf("suggest: taste tags: %w", err)
}
// No taste tags is a cold start, not a failure — return early and skip the
// candidate-tag fetch entirely, since nothing could match.
if len(tasteRows) == 0 {
return nil, nil, nil
}
taste := make(TagWeights, len(tasteRows))
for _, r := range tasteRows {
taste[r.Tag] = r.Weight
}
mbids := make([]string, 0, len(pool))
for _, s := range pool {
mbids = append(mbids, s.MBID)
}
tagRows, err := q.ListCandidateArtistTagsForMbids(ctx, mbids)
if err != nil {
return nil, nil, fmt.Errorf("suggest: candidate tags: %w", err)
}
byCandidate := make(map[string]TagWeights, len(pool))
for _, r := range tagRows {
if byCandidate[r.CandidateMbid] == nil {
byCandidate[r.CandidateMbid] = TagWeights{}
}
byCandidate[r.CandidateMbid][r.Tag] = r.Weight
}
return taste, byCandidate, nil
}
// Pool multiplier: how many scored candidates to fetch per slot shown, so the
// rotation has somewhere to rotate. 4x keeps a day's deck genuinely different
// from yesterday's without pulling the whole long tail (whose scores are noise)
// into every request.
const suggestionPoolFactor = 4
// Hard ceiling on the fetched pool. The scored tail past this is low-signal, so
// paying for it would buy churn rather than better suggestions.
const suggestionPoolMax = 60
func poolSizeFor(limit int) int {
n := limit * suggestionPoolFactor
if n > suggestionPoolMax {
return suggestionPoolMax
}
return n
}
// rotationDay is the bucket the daily rotation hashes against. Server-local
// date, matching the `current_date` the Home rows already rotate on, so the
// whole product turns over at the same moment.
func rotationDay(now time.Time) string {
return now.Format("2006-01-02")
}
// selectSuggestions turns the scored pool into the response.
//
// Pure by design — no DB, no clock — so the rotation and diversity rules are
// unit-testable without Postgres. Callers pass the day bucket in.
//
// Three rules, in order:
//
// 1. Diversity cap. Walking in score order, a candidate is dropped once its
// dominant seed already owns maxPerSeed slots. Nothing capped this before,
// so all twelve slots could be neighbours of one artist and the surface
// read as a single narrow cluster (#2367 mechanism 4).
// 2. Head. The best few by score always lead, so the strongest matches are
// never rotated out of sight. Mirrors For You's head/tail shape.
// 3. Tail. The rest of the slots are drawn from the remaining pool ordered by
// md5(mbid + day) — the same daily-stable idiom the Home rows use. Stable
// within a day, different tomorrow, and it needs no stored state.
func selectSuggestions(pool []ArtistSuggestion, limit int, day string) []ArtistSuggestion {
if len(pool) <= limit {
return pool
}
preferred, overflow := partitionPerSeed(pool, maxPerSeedFor(limit))
headCount := limit / 3
if headCount > len(preferred) {
headCount = len(preferred)
}
out := make([]ArtistSuggestion, 0, limit)
out = append(out, preferred[:headCount]...)
// Hash once per candidate, not once per comparison.
rest := preferred[headCount:]
tail := make([]rotatable, 0, len(rest))
for _, s := range rest {
tail = append(tail, rotatable{key: rotationKey(s.MBID, day), suggestion: s})
}
sort.SliceStable(tail, func(i, j int) bool { return tail[i].key < tail[j].key })
for _, r := range tail {
if len(out) >= limit {
break
}
out = append(out, r.suggestion)
}
// Diversity is a PREFERENCE, not a quota that may starve the deck. A user
// whose whole pool hangs off one or two seeds would otherwise get a
// three-card surface, which is worse than the monoculture we were avoiding
// (and is the vanish-or-nothing shape rule #131 exists to prevent). Top up
// in score order from what the cap set aside.
for _, s := range overflow {
if len(out) >= limit {
break
}
out = append(out, s)
}
return out
}
// rotatable pairs a candidate with its precomputed daily rotation key.
type rotatable struct {
key string
suggestion ArtistSuggestion
}
// maxPerSeedFor keeps roughly a quarter of the deck attributable to any single
// seed artist — enough for a strong affinity to be well represented, not enough
// for it to BE the deck. Floored at 1 so a small limit still returns something.
func maxPerSeedFor(limit int) int {
n := limit / 4
if n < 1 {
return 1
}
return n
}
// partitionPerSeed splits the pool by whether each candidate's dominant
// (highest-contributing) seed still has allowance left. Input must be in score
// order; both outputs preserve it.
//
// Candidates with no attribution are always preferred — there is no seed to
// attribute them to, so they cannot be the cause of a monoculture.
func partitionPerSeed(pool []ArtistSuggestion, maxPerSeed int) (preferred, overflow []ArtistSuggestion) {
perSeed := make(map[pgtype.UUID]int, len(pool))
preferred = make([]ArtistSuggestion, 0, len(pool))
for _, s := range pool {
if len(s.Attribution) == 0 {
preferred = append(preferred, s)
continue
}
dominant := s.Attribution[0].ArtistID
if perSeed[dominant] >= maxPerSeed {
overflow = append(overflow, s)
continue
}
perSeed[dominant]++
preferred = append(preferred, s)
}
return preferred, overflow
}
func rotationKey(mbid, day string) string {
sum := md5.Sum([]byte(mbid + day))
return hex.EncodeToString(sum[:])
}
@@ -147,7 +147,7 @@ func TestSuggestArtists_LikesAndPlaysContributeToScore(t *testing.T) {
seedUnmatched(t, pool, seedA.ID, "out-mbid", "Outsider", 0.9)
seedUnmatched(t, pool, seedB.ID, "out-mbid", "Outsider", 0.5)
out, err := SuggestArtists(context.Background(), pool, user.ID, 30, 12)
out, err := SuggestArtists(context.Background(), pool, user.ID, 30, 12, 0)
if err != nil {
t.Fatalf("SuggestArtists: %v", err)
}
@@ -174,7 +174,7 @@ func TestSuggestArtists_Top12Cap(t *testing.T) {
for i := 0; i < 30; i++ {
seedUnmatched(t, pool, seed.ID, fmt.Sprintf("mbid-%02d", i), fmt.Sprintf("Artist %02d", i), 0.99-float64(i)*0.01)
}
out, err := SuggestArtists(context.Background(), pool, user.ID, 30, 12)
out, err := SuggestArtists(context.Background(), pool, user.ID, 30, 12, 0)
if err != nil {
t.Fatalf("SuggestArtists: %v", err)
}
@@ -195,7 +195,7 @@ func TestSuggestArtists_AttributionTopThree(t *testing.T) {
likeArtist(t, pool, user.ID, seeds[i].ID)
seedUnmatched(t, pool, seeds[i].ID, "shared-mbid", "Shared", 0.9-float64(i)*0.1)
}
out, err := SuggestArtists(context.Background(), pool, user.ID, 30, 12)
out, err := SuggestArtists(context.Background(), pool, user.ID, 30, 12, 0)
if err != nil {
t.Fatalf("SuggestArtists: %v", err)
}
@@ -227,7 +227,7 @@ func TestSuggestArtists_RecencyDecayDownweightsOldPlays(t *testing.T) {
seedUnmatched(t, pool, recentSeed.ID, "cand", "Cand", 0.5)
seedUnmatched(t, pool, oldSeed.ID, "cand", "Cand", 0.5)
out, err := SuggestArtists(context.Background(), pool, user.ID, 30, 12)
out, err := SuggestArtists(context.Background(), pool, user.ID, 30, 12, 0)
if err != nil {
t.Fatalf("SuggestArtists: %v", err)
}
@@ -255,7 +255,7 @@ func TestSuggestArtists_FiltersInLibraryCandidates(t *testing.T) {
seedArtist(t, pool, "InLib", inLibMBID)
seedUnmatched(t, pool, seed.ID, inLibMBID, "InLib", 0.9)
out, err := SuggestArtists(context.Background(), pool, user.ID, 30, 12)
out, err := SuggestArtists(context.Background(), pool, user.ID, 30, 12, 0)
if err != nil {
t.Fatalf("SuggestArtists: %v", err)
}
@@ -279,7 +279,7 @@ func TestSuggestArtists_FiltersAlreadyRequested(t *testing.T) {
t.Fatalf("CreateLidarrRequest: %v", err)
}
out, err := SuggestArtists(context.Background(), pool, user.ID, 30, 12)
out, err := SuggestArtists(context.Background(), pool, user.ID, 30, 12, 0)
if err != nil {
t.Fatalf("SuggestArtists: %v", err)
}
@@ -310,7 +310,7 @@ func TestSuggestArtists_RejectedRequestStillShown(t *testing.T) {
t.Fatalf("RejectLidarrRequest: %v", err)
}
out, err := SuggestArtists(context.Background(), pool, user.ID, 30, 12)
out, err := SuggestArtists(context.Background(), pool, user.ID, 30, 12, 0)
if err != nil {
t.Fatalf("SuggestArtists: %v", err)
}
@@ -322,7 +322,7 @@ func TestSuggestArtists_RejectedRequestStillShown(t *testing.T) {
func TestSuggestArtists_EmptyForNewUser(t *testing.T) {
pool := newPool(t)
user := seedUser(t, pool, "newbie")
out, err := SuggestArtists(context.Background(), pool, user.ID, 30, 12)
out, err := SuggestArtists(context.Background(), pool, user.ID, 30, 12, 0)
if err != nil {
t.Fatalf("SuggestArtists: %v", err)
}
@@ -330,3 +330,359 @@ func TestSuggestArtists_EmptyForNewUser(t *testing.T) {
t.Errorf("len = %d, want 0 (new user has no signal)", len(out))
}
}
// --- Slice 1 (#2372): taste-profile seeding, tiering, and the skip fix ---
func setTasteWeight(t *testing.T, pool *pgxpool.Pool, userID, artistID pgtype.UUID, weight float64) {
t.Helper()
if _, err := pool.Exec(context.Background(),
`INSERT INTO taste_profile_artists (user_id, artist_id, weight) VALUES ($1, $2, $3)
ON CONFLICT (user_id, artist_id) DO UPDATE SET weight = EXCLUDED.weight`,
userID, artistID, weight,
); err != nil {
t.Fatalf("set taste weight: %v", err)
}
}
func insertSkippedPlayEvent(t *testing.T, pool *pgxpool.Pool, userID, trackID pgtype.UUID, startedAt time.Time) {
t.Helper()
ctx := context.Background()
var sessionID pgtype.UUID
if err := pool.QueryRow(ctx,
`INSERT INTO play_sessions (user_id, started_at, last_event_at, client_id)
VALUES ($1, $2, $2, 'skip-test') RETURNING id`,
userID, startedAt,
).Scan(&sessionID); err != nil {
t.Fatalf("insert play_session: %v", err)
}
if _, err := pool.Exec(ctx,
`INSERT INTO play_events (user_id, track_id, session_id, started_at, was_skipped)
VALUES ($1, $2, $3, $4, true)`,
userID, trackID, sessionID, startedAt,
); err != nil {
t.Fatalf("insert skipped play_event: %v", err)
}
}
// Tier 1: a taste-profile weight alone seeds a suggestion, with no like and no
// play on the seed artist. Before #2367 the profile was ignored entirely here.
func TestSuggestArtists_TasteProfileWeightSeedsTier1(t *testing.T) {
pool := newPool(t)
user := seedUser(t, pool, "alice")
seed := seedArtist(t, pool, "Profile Seed", "")
setTasteWeight(t, pool, user.ID, seed.ID, 4.0)
seedUnmatched(t, pool, seed.ID, "out-mbid", "Outsider", 0.9)
out, err := SuggestArtists(context.Background(), pool, user.ID, 30, 12, 0)
if err != nil {
t.Fatalf("SuggestArtists: %v", err)
}
if len(out) != 1 {
t.Fatalf("len = %d, want 1 (taste weight alone should seed)", len(out))
}
if out[0].MBID != "out-mbid" {
t.Errorf("mbid = %q, want out-mbid", out[0].MBID)
}
if out[0].Score <= 0 {
t.Errorf("score = %v, want > 0", out[0].Score)
}
}
// Once the profile has any positive row, tier 2 is not consulted — so an artist
// the user played but that the taste engine did not keep does NOT seed. That is
// the point of the rewrite: the taste engine decides what counts as affinity.
func TestSuggestArtists_TasteProfileSupersedesRawPlays(t *testing.T) {
pool := newPool(t)
user := seedUser(t, pool, "alice")
kept := seedArtist(t, pool, "Kept By Taste", "")
dropped := seedArtist(t, pool, "Dropped By Taste", "")
setTasteWeight(t, pool, user.ID, kept.ID, 3.0)
// `dropped` has real completed plays but no profile row.
album := seedAlbumForArtist(t, pool, dropped.ID, "Album")
track := seedTrackOnAlbum(t, pool, album.ID, dropped.ID, "Track")
insertPlayEvent(t, pool, user.ID, track.ID, time.Now().Add(-1*time.Hour))
seedUnmatched(t, pool, kept.ID, "kept-cand", "Kept Candidate", 0.9)
seedUnmatched(t, pool, dropped.ID, "dropped-cand", "Dropped Candidate", 0.9)
out, err := SuggestArtists(context.Background(), pool, user.ID, 30, 12, 0)
if err != nil {
t.Fatalf("SuggestArtists: %v", err)
}
if len(out) != 1 {
t.Fatalf("len = %d, want 1 (tier 2 must not run alongside tier 1)", len(out))
}
if out[0].MBID != "kept-cand" {
t.Errorf("mbid = %q, want kept-cand", out[0].MBID)
}
}
// A non-positive taste weight is not affinity. Guarded by a second, positive
// row so tier 1 stays active — otherwise an empty tier 1 would fall through to
// tier 2 and the assertion would pass for the wrong reason.
func TestSuggestArtists_NonPositiveTasteWeightDoesNotSeed(t *testing.T) {
pool := newPool(t)
user := seedUser(t, pool, "alice")
positive := seedArtist(t, pool, "Still Liked", "")
abandoned := seedArtist(t, pool, "Abandoned", "")
setTasteWeight(t, pool, user.ID, positive.ID, 2.0)
setTasteWeight(t, pool, user.ID, abandoned.ID, -1.5)
seedUnmatched(t, pool, positive.ID, "pos-cand", "Positive Candidate", 0.9)
seedUnmatched(t, pool, abandoned.ID, "neg-cand", "Negative Candidate", 0.9)
out, err := SuggestArtists(context.Background(), pool, user.ID, 30, 12, 0)
if err != nil {
t.Fatalf("SuggestArtists: %v", err)
}
for _, s := range out {
if s.MBID == "neg-cand" {
t.Fatalf("negative-weight artist seeded a suggestion: %+v", s)
}
}
if len(out) != 1 || out[0].MBID != "pos-cand" {
t.Errorf("out = %+v, want only pos-cand", out)
}
}
// Tier 2 counts COMPLETED plays only. Previously every play_event counted, so
// skipping an artist repeatedly increased its signal and pushed more of its
// neighbours at the user (#2367 mechanism 3).
func TestSuggestArtists_SkippedPlaysDoNotSeedTier2(t *testing.T) {
pool := newPool(t)
user := seedUser(t, pool, "alice")
skipped := seedArtist(t, pool, "Only Skipped", "")
album := seedAlbumForArtist(t, pool, skipped.ID, "Album")
track := seedTrackOnAlbum(t, pool, album.ID, skipped.ID, "Track")
for i := 0; i < 5; i++ {
insertSkippedPlayEvent(t, pool, user.ID, track.ID, time.Now().Add(-time.Duration(i+1)*time.Hour))
}
seedUnmatched(t, pool, skipped.ID, "skip-cand", "Skip Candidate", 0.9)
out, err := SuggestArtists(context.Background(), pool, user.ID, 30, 12, 0)
if err != nil {
t.Fatalf("SuggestArtists: %v", err)
}
if len(out) != 0 {
t.Errorf("len = %d, want 0 (skips are not affinity): %+v", len(out), out)
}
}
// --- Slice 3 (#2374): time-boxed suggestion snooze ---
//
// The defining property under test is that a snooze EXPIRES. A permanent
// dismissal would pass most of these; only TestSuggestArtists_ExpiredSnooze
// distinguishes the two, and it is the reason this shape was approved over an
// exclusion UI (rule #101).
// snoozeUntil inserts a snooze row with an explicit absolute expiry, so a
// test can place it in the past without depending on the duration arithmetic
// in SnoozeSuggestion.
func snoozeUntil(t *testing.T, pool *pgxpool.Pool, userID pgtype.UUID, mbid, name string, until time.Time) {
t.Helper()
if _, err := pool.Exec(context.Background(),
`INSERT INTO suggestion_snoozes (user_id, candidate_mbid, candidate_name, snoozed_until)
VALUES ($1, $2, $3, $4)
ON CONFLICT (user_id, candidate_mbid) DO UPDATE SET snoozed_until = EXCLUDED.snoozed_until`,
userID, mbid, name, until,
); err != nil {
t.Fatalf("snoozeUntil: %v", err)
}
}
// seedOneCandidate wires the minimum that puts exactly one candidate in the
// deck: a liked seed artist with one unmatched neighbour.
func seedOneCandidate(t *testing.T, pool *pgxpool.Pool, userID pgtype.UUID, mbid, name string) {
t.Helper()
seed := seedArtist(t, pool, "Seed", "")
likeArtist(t, pool, userID, seed.ID)
seedUnmatched(t, pool, seed.ID, mbid, name, 0.9)
}
func TestSuggestArtists_ActiveSnoozeHidesCandidate(t *testing.T) {
pool := newPool(t)
user := seedUser(t, pool, "alice")
seedOneCandidate(t, pool, user.ID, "snoozed-mbid", "Parked Artist")
if err := dbq.New(pool).SnoozeSuggestion(context.Background(), dbq.SnoozeSuggestionParams{
UserID: user.ID,
CandidateMbid: "snoozed-mbid",
CandidateName: "Parked Artist",
Column4: 90,
}); err != nil {
t.Fatalf("SnoozeSuggestion: %v", err)
}
out, err := SuggestArtists(context.Background(), pool, user.ID, 30, 12, 0)
if err != nil {
t.Fatalf("SuggestArtists: %v", err)
}
if len(out) != 0 {
t.Errorf("len = %d, want 0 (active snooze should hide the candidate): %+v", len(out), out)
}
}
// The whole point of a snooze over a dismissal: it comes back on its own.
func TestSuggestArtists_ExpiredSnoozeShowsCandidateAgain(t *testing.T) {
pool := newPool(t)
user := seedUser(t, pool, "alice")
seedOneCandidate(t, pool, user.ID, "expired-mbid", "Returning Artist")
snoozeUntil(t, pool, user.ID, "expired-mbid", "Returning Artist", time.Now().Add(-time.Hour))
out, err := SuggestArtists(context.Background(), pool, user.ID, 30, 12, 0)
if err != nil {
t.Fatalf("SuggestArtists: %v", err)
}
if len(out) != 1 {
t.Fatalf("len = %d, want 1 (an expired snooze must not hide anything): %+v", len(out), out)
}
if out[0].MBID != "expired-mbid" {
t.Errorf("mbid = %q, want expired-mbid", out[0].MBID)
}
}
// Rule #47: one household member parking a suggestion must not remove it
// from anyone else's deck.
func TestSuggestArtists_SnoozeIsPerUser(t *testing.T) {
pool := newPool(t)
alice := seedUser(t, pool, "alice")
bob := seedUser(t, pool, "bob")
seed := seedArtist(t, pool, "Seed", "")
likeArtist(t, pool, alice.ID, seed.ID)
likeArtist(t, pool, bob.ID, seed.ID)
seedUnmatched(t, pool, seed.ID, "shared-mbid", "Shared Candidate", 0.9)
snoozeUntil(t, pool, alice.ID, "shared-mbid", "Shared Candidate", time.Now().Add(24*time.Hour))
aliceOut, err := SuggestArtists(context.Background(), pool, alice.ID, 30, 12, 0)
if err != nil {
t.Fatalf("SuggestArtists(alice): %v", err)
}
if len(aliceOut) != 0 {
t.Errorf("alice len = %d, want 0 (she snoozed it)", len(aliceOut))
}
bobOut, err := SuggestArtists(context.Background(), pool, bob.ID, 30, 12, 0)
if err != nil {
t.Fatalf("SuggestArtists(bob): %v", err)
}
if len(bobOut) != 1 {
t.Errorf("bob len = %d, want 1 (alice's snooze is not his)", len(bobOut))
}
}
func TestUnsnoozeSuggestion_RestoresImmediately(t *testing.T) {
pool := newPool(t)
user := seedUser(t, pool, "alice")
seedOneCandidate(t, pool, user.ID, "undo-mbid", "Undo Artist")
snoozeUntil(t, pool, user.ID, "undo-mbid", "Undo Artist", time.Now().Add(90*24*time.Hour))
q := dbq.New(pool)
rows, err := q.UnsnoozeSuggestion(context.Background(), dbq.UnsnoozeSuggestionParams{
UserID: user.ID, CandidateMbid: "undo-mbid",
})
if err != nil {
t.Fatalf("UnsnoozeSuggestion: %v", err)
}
if rows != 1 {
t.Errorf("rows = %d, want 1", rows)
}
// A second delete affects nothing — the handler turns this into a 404
// rather than reporting success for a no-op.
rows, err = q.UnsnoozeSuggestion(context.Background(), dbq.UnsnoozeSuggestionParams{
UserID: user.ID, CandidateMbid: "undo-mbid",
})
if err != nil {
t.Fatalf("UnsnoozeSuggestion (repeat): %v", err)
}
if rows != 0 {
t.Errorf("repeat rows = %d, want 0", rows)
}
out, err := SuggestArtists(context.Background(), pool, user.ID, 30, 12, 0)
if err != nil {
t.Fatalf("SuggestArtists: %v", err)
}
if len(out) != 1 {
t.Errorf("len = %d, want 1 (unsnooze restores the candidate)", len(out))
}
}
// Re-snoozing must extend, not conflict on the PK.
func TestSnoozeSuggestion_UpsertExtends(t *testing.T) {
pool := newPool(t)
user := seedUser(t, pool, "alice")
q := dbq.New(pool)
snoozeUntil(t, pool, user.ID, "extend-mbid", "Old Name", time.Now().Add(time.Hour))
if err := q.SnoozeSuggestion(context.Background(), dbq.SnoozeSuggestionParams{
UserID: user.ID,
CandidateMbid: "extend-mbid",
CandidateName: "New Name",
Column4: 90,
}); err != nil {
t.Fatalf("SnoozeSuggestion (re-snooze): %v", err)
}
rows, err := q.ListActiveSuggestionSnoozes(context.Background(), user.ID)
if err != nil {
t.Fatalf("ListActiveSuggestionSnoozes: %v", err)
}
if len(rows) != 1 {
t.Fatalf("len = %d, want 1 (upsert, not a second row)", len(rows))
}
if rows[0].CandidateName != "New Name" {
t.Errorf("name = %q, want New Name (upsert refreshes it)", rows[0].CandidateName)
}
if got := time.Until(rows[0].SnoozedUntil.Time); got < 80*24*time.Hour {
t.Errorf("snoozed_until is %v away, want ~90d (re-snooze should extend)", got)
}
}
// ListActiveSuggestionSnoozes filters expired rows itself rather than
// trusting the hourly gc sweep to have run.
func TestListActiveSuggestionSnoozes_ExcludesExpired(t *testing.T) {
pool := newPool(t)
user := seedUser(t, pool, "alice")
snoozeUntil(t, pool, user.ID, "live-mbid", "Live", time.Now().Add(24*time.Hour))
snoozeUntil(t, pool, user.ID, "dead-mbid", "Dead", time.Now().Add(-24*time.Hour))
rows, err := dbq.New(pool).ListActiveSuggestionSnoozes(context.Background(), user.ID)
if err != nil {
t.Fatalf("ListActiveSuggestionSnoozes: %v", err)
}
if len(rows) != 1 {
t.Fatalf("len = %d, want 1 (expired row must not be listed)", len(rows))
}
if rows[0].CandidateMbid != "live-mbid" {
t.Errorf("mbid = %q, want live-mbid", rows[0].CandidateMbid)
}
}
func TestGcDeleteExpiredSuggestionSnoozes_KeepsActive(t *testing.T) {
pool := newPool(t)
user := seedUser(t, pool, "alice")
snoozeUntil(t, pool, user.ID, "live-mbid", "Live", time.Now().Add(24*time.Hour))
snoozeUntil(t, pool, user.ID, "dead-mbid", "Dead", time.Now().Add(-24*time.Hour))
q := dbq.New(pool)
deleted, err := q.GcDeleteExpiredSuggestionSnoozes(context.Background())
if err != nil {
t.Fatalf("GcDeleteExpiredSuggestionSnoozes: %v", err)
}
if deleted != 1 {
t.Errorf("deleted = %d, want 1 (only the expired row)", deleted)
}
rows, err := q.ListActiveSuggestionSnoozes(context.Background(), user.ID)
if err != nil {
t.Fatalf("ListActiveSuggestionSnoozes: %v", err)
}
if len(rows) != 1 {
t.Errorf("len = %d, want 1 (active snooze survives the sweep)", len(rows))
}
}
@@ -0,0 +1,170 @@
package recommendation
import (
"fmt"
"testing"
"github.com/jackc/pgx/v5/pgtype"
)
// selectSuggestions is deliberately pure (no DB, no clock), so the rotation and
// diversity rules from slice #2373 are covered here in the fast lane rather
// than behind the integration gate.
func seedID(n byte) pgtype.UUID {
var u pgtype.UUID
u.Bytes[0] = n
u.Valid = true
return u
}
// candidate builds a pool entry attributed to the given dominant seed.
func candidate(mbid string, score float64, dominant byte) ArtistSuggestion {
return ArtistSuggestion{
MBID: mbid,
Name: mbid,
Score: score,
Attribution: []SeedContribution{
{ArtistID: seedID(dominant), Contribution: score},
},
}
}
// poolOf returns n candidates in descending score order, spread across
// `seeds` distinct dominant seeds.
func poolOf(n int, seeds int) []ArtistSuggestion {
out := make([]ArtistSuggestion, 0, n)
for i := 0; i < n; i++ {
out = append(out, candidate(fmt.Sprintf("mbid-%02d", i), 1.0-float64(i)*0.01, byte(i%seeds)))
}
return out
}
func mbids(in []ArtistSuggestion) []string {
out := make([]string, 0, len(in))
for _, s := range in {
out = append(out, s.MBID)
}
return out
}
func TestSelectSuggestions_ReturnsPoolUnchangedWhenNotOverfetched(t *testing.T) {
pool := poolOf(5, 5)
got := selectSuggestions(pool, 12, "2026-08-01")
if len(got) != 5 {
t.Fatalf("len = %d, want 5 (nothing to rotate)", len(got))
}
if got[0].MBID != "mbid-00" {
t.Errorf("first = %q, want mbid-00", got[0].MBID)
}
}
func TestSelectSuggestions_FillsTheRequestedLimit(t *testing.T) {
got := selectSuggestions(poolOf(48, 8), 12, "2026-08-01")
if len(got) != 12 {
t.Fatalf("len = %d, want 12", len(got))
}
}
// The reported symptom: the deck must change day to day without the user
// requesting anything.
func TestSelectSuggestions_RotatesAcrossDays(t *testing.T) {
pool := poolOf(48, 8)
day1 := mbids(selectSuggestions(pool, 12, "2026-08-01"))
day2 := mbids(selectSuggestions(pool, 12, "2026-08-02"))
if fmt.Sprint(day1) == fmt.Sprint(day2) {
t.Fatalf("deck identical across days: %v", day1)
}
// ...but stable WITHIN a day, or the surface would reshuffle on every
// pull-to-refresh, which reads as broken rather than fresh.
again := mbids(selectSuggestions(pool, 12, "2026-08-01"))
if fmt.Sprint(day1) != fmt.Sprint(again) {
t.Errorf("same day differed:\n %v\n %v", day1, again)
}
}
// The strongest matches should never rotate out of sight.
func TestSelectSuggestions_HeadIsStableTopScorers(t *testing.T) {
pool := poolOf(48, 8)
for _, day := range []string{"2026-08-01", "2026-08-02", "2026-09-15"} {
got := selectSuggestions(pool, 12, day)
if got[0].MBID != "mbid-00" {
t.Errorf("day %s: first = %q, want mbid-00 (top score leads)", day, got[0].MBID)
}
}
}
// #2367 mechanism 4: all twelve slots could be neighbours of one artist.
//
// The pool is deliberately SKEWED — one seed owns the entire top of the score
// ranking — because that is the only shape where a cap can bite. With scores
// spread evenly across seeds the top-N is already diverse and the cap is
// untestable (an earlier version of this test asserted against an even pool and
// could not fail).
func TestSelectSuggestions_CapsOneDominantSeed(t *testing.T) {
const dominantRun = 20
pool := make([]ArtistSuggestion, 0, 48)
for i := 0; i < dominantRun; i++ { // seed 0 owns the 20 best scores
pool = append(pool, candidate(fmt.Sprintf("dom-%02d", i), 1.0-float64(i)*0.01, 0))
}
for i := 0; i < 28; i++ { // seeds 1..9 hold everything below
pool = append(pool, candidate(fmt.Sprintf("oth-%02d", i), 0.80-float64(i)*0.01, byte(1+i%9)))
}
got := selectSuggestions(pool, 12, "2026-08-01")
if len(got) != 12 {
t.Fatalf("len = %d, want 12", len(got))
}
perSeed := map[pgtype.UUID]int{}
for _, s := range got {
perSeed[s.Attribution[0].ArtistID]++
}
// Uncapped, the top 12 by score would be 12 of seed 0's neighbours.
if n := perSeed[seedID(0)]; n > maxPerSeedFor(12) {
t.Errorf("dominant seed owns %d of 12 slots, want <= %d: %v",
n, maxPerSeedFor(12), mbids(got))
}
if len(perSeed) < 4 {
t.Errorf("deck spans only %d seeds, want >= 4: %v", len(perSeed), mbids(got))
}
}
// Diversity must not starve the deck (rule #131): a pool hanging off a single
// seed should still fill, not collapse to maxPerSeed entries.
func TestSelectSuggestions_SingleSeedPoolStillFills(t *testing.T) {
got := selectSuggestions(poolOf(30, 1), 12, "2026-08-01")
if len(got) != 12 {
t.Fatalf("len = %d, want 12 (cap must not starve the deck)", len(got))
}
if got[0].MBID != "mbid-00" {
t.Errorf("first = %q, want mbid-00", got[0].MBID)
}
}
// A candidate with no attribution has no seed to blame, so it must never be
// held back by the diversity cap.
func TestPartitionPerSeed_UnattributedNeverCapped(t *testing.T) {
pool := []ArtistSuggestion{
candidate("a", 0.9, 1),
candidate("b", 0.8, 1),
{MBID: "orphan", Score: 0.1},
}
preferred, overflow := partitionPerSeed(pool, 1)
if len(preferred) != 2 || preferred[1].MBID != "orphan" {
t.Errorf("preferred = %v, want [a orphan]", mbids(preferred))
}
if len(overflow) != 1 || overflow[0].MBID != "b" {
t.Errorf("overflow = %v, want [b]", mbids(overflow))
}
}
func TestPoolSizeFor_OverfetchesButIsBounded(t *testing.T) {
if got := poolSizeFor(12); got != 48 {
t.Errorf("poolSizeFor(12) = %d, want 48", got)
}
if got := poolSizeFor(50); got != suggestionPoolMax {
t.Errorf("poolSizeFor(50) = %d, want the %d ceiling", got, suggestionPoolMax)
}
}
+156
View File
@@ -0,0 +1,156 @@
// tagoverlap.go — the taste-tag term for the Discover request surface
// (#2377, milestone #268 slice 6).
//
// Slices 1-2 made the deck stop repeating; this is what makes it *relevant*.
// Before this, a candidate's only claim on a slot was "some artist you play is
// similar to it" — a graph-adjacency fact that says nothing about whether the
// music sounds like anything you actually like. Here the candidate's own
// folksonomy tags (cached by slice 5) are compared against the user's
// taste-profile tags, so the surface can rank on "matches the sound you like"
// and say WHY.
//
// Pure by design — no DB, no clock — so the scoring rules are unit-testable in
// the fast lane rather than behind the integration gate.
package recommendation
import "sort"
// maxMatchedTags caps the "matches: …" explanation. Three is what the existing
// seed attribution shows, and a longer list stops being a reason and becomes a
// tag dump.
const maxMatchedTags = 3
// TagWeights is a tag → weight map. Both sides of the comparison use it:
// candidate tags (normalized [0,1] by the enrichment providers) and the user's
// taste-profile tags (accumulated, unbounded — normalized here).
type TagWeights map[string]float64
// tagOverlap scores how much of a candidate's tag identity the user actually
// likes, in [0,1], and returns the matched tags ordered by contribution.
//
// The measure is: of this candidate's total tag mass, what share sits on tags
// the user likes — each weighted by how strongly they like it?
//
// overlap = Σ(shared) candWeight × normalizedTasteWeight ÷ Σ(all) candWeight
//
// Normalizing the taste side by the user's STRONGEST tag is what makes this
// comparable across users: taste weights accumulate with listening, so a
// heavy listener's raw numbers dwarf a new user's while meaning the same
// thing — "this is my favourite tag". Dividing by the candidate's own total
// mass makes it comparable across candidates, so a densely-tagged artist
// can't out-score a sparsely-tagged one just by having more tags.
//
// Returns (0, nil) when either side is empty. That is the load-bearing
// degradation path: tag coverage for out-of-library candidates is permanently
// partial (#2376), and a cold-start user has no taste tags at all. Both must
// leave the candidate's similarity score untouched rather than sink it —
// rule #131, tiered degradation, never vanish-or-nothing.
func tagOverlap(candidate, taste TagWeights) (float64, []string) {
if len(candidate) == 0 || len(taste) == 0 {
return 0, nil
}
maxTaste := 0.0
for _, w := range taste {
if w > maxTaste {
maxTaste = w
}
}
// Every taste weight <= 0 carries no preference to match against. Guarding
// here also avoids dividing by zero below.
if maxTaste <= 0 {
return 0, nil
}
totalMass := 0.0
for _, w := range candidate {
// Negative or zero candidate weights would let a tag subtract from the
// denominator and inflate the ratio past 1.
if w > 0 {
totalMass += w
}
}
if totalMass <= 0 {
return 0, nil
}
type contribution struct {
tag string
score float64
}
var matched []contribution
sum := 0.0
for tag, candWeight := range candidate {
if candWeight <= 0 {
continue
}
tasteWeight, ok := taste[tag]
if !ok || tasteWeight <= 0 {
continue
}
c := candWeight * (tasteWeight / maxTaste)
sum += c
matched = append(matched, contribution{tag: tag, score: c})
}
if len(matched) == 0 {
return 0, nil
}
// Strongest contribution first; tag name breaks ties so the explanation is
// deterministic for a given input rather than map-iteration order.
sort.Slice(matched, func(i, j int) bool {
if matched[i].score != matched[j].score {
return matched[i].score > matched[j].score
}
return matched[i].tag < matched[j].tag
})
names := make([]string, 0, min(len(matched), maxMatchedTags))
for i := 0; i < len(matched) && i < maxMatchedTags; i++ {
names = append(names, matched[i].tag)
}
return sum / totalMass, names
}
// applyTagOverlap re-scores and re-orders a candidate pool by taste-tag
// overlap, stamping the matched tags onto each suggestion for the UI.
//
// The blend is MULTIPLICATIVE — score × (1 + weight × overlap) — not additive,
// and the difference is the whole safety argument:
//
// - A candidate with no tags has overlap 0, so its score is EXACTLY
// unchanged. Partial tag coverage costs a candidate nothing.
// - Nothing can leapfrog on tags alone. An additive term with a large
// weight would let a near-zero-similarity artist outrank a strong match
// just for sharing a popular tag, which reads as noise to the user.
// - weight 0 disables the feature completely and restores pure similarity
// order, so the operator's knob has a real off position.
//
// Callers must pass the pool in similarity order; it is returned in blended
// order. Mutates the elements in place (they're the caller's own slice built
// per request), and re-sorts, because selectSuggestions downstream relies on
// score order for its head/tail split.
func applyTagOverlap(
pool []ArtistSuggestion, candidateTags map[string]TagWeights, taste TagWeights, weight float64,
) []ArtistSuggestion {
// A zero weight is the operator turning the feature off. Skip the work
// AND the re-sort so the ordering is bit-for-bit the pre-slice-6 result.
if weight == 0 || len(taste) == 0 {
return pool
}
for i := range pool {
overlap, matched := tagOverlap(candidateTags[pool[i].MBID], taste)
pool[i].MatchedTags = matched
pool[i].TagOverlap = overlap
pool[i].Score *= 1 + weight*overlap
}
sort.SliceStable(pool, func(i, j int) bool {
if pool[i].Score != pool[j].Score {
return pool[i].Score > pool[j].Score
}
// Stable tiebreak by MBID. Without it, two candidates on equal scores
// could swap between requests within the same day, which the daily
// rotation (#2373) exists to prevent.
return pool[i].MBID < pool[j].MBID
})
return pool
}
+244
View File
@@ -0,0 +1,244 @@
package recommendation
import (
"fmt"
"testing"
)
// tagOverlap / applyTagOverlap are pure, so slice 6's ranking rules are covered
// in the fast lane rather than behind the integration gate.
//
// Fixtures here are deliberately SKEWED — candidates that match the taste
// profile to clearly different degrees. An evenly-matching pool cannot exercise
// a re-ranking at all: every candidate gets the same multiplier and the order is
// unchanged whether the blend works or not. Slice 2's first diversity test had
// exactly that defect, so it's called out explicitly here.
func TestTagOverlap_FullMatchScoresOne(t *testing.T) {
// Every unit of the candidate's tag mass sits on the user's single
// strongest tag → the whole mass matches at full strength.
got, matched := tagOverlap(
TagWeights{"shoegaze": 1.0},
TagWeights{"shoegaze": 5.0},
)
if got != 1.0 {
t.Errorf("overlap = %v, want 1.0", got)
}
if len(matched) != 1 || matched[0] != "shoegaze" {
t.Errorf("matched = %v, want [shoegaze]", matched)
}
}
func TestTagOverlap_NoSharedTagsScoresZero(t *testing.T) {
got, matched := tagOverlap(
TagWeights{"death metal": 1.0},
TagWeights{"shoegaze": 5.0},
)
if got != 0 {
t.Errorf("overlap = %v, want 0", got)
}
if matched != nil {
t.Errorf("matched = %v, want nil", matched)
}
}
// Half the candidate's mass is on a matching tag, and that tag is the user's
// strongest → 0.5.
func TestTagOverlap_PartialMassMatchIsProportional(t *testing.T) {
got, _ := tagOverlap(
TagWeights{"shoegaze": 1.0, "death metal": 1.0},
TagWeights{"shoegaze": 5.0},
)
if got != 0.5 {
t.Errorf("overlap = %v, want 0.5", got)
}
}
// Matching a tag the user barely likes must score below matching one they love.
func TestTagOverlap_WeakTasteTagScoresLowerThanStrong(t *testing.T) {
taste := TagWeights{"shoegaze": 10.0, "polka": 1.0}
strong, _ := tagOverlap(TagWeights{"shoegaze": 1.0}, taste)
weak, _ := tagOverlap(TagWeights{"polka": 1.0}, taste)
if !(strong > weak) {
t.Errorf("strong=%v weak=%v — a favourite tag must outscore a marginal one", strong, weak)
}
if weak != 0.1 { // 1.0 * (1/10) / 1.0
t.Errorf("weak = %v, want 0.1", weak)
}
}
// THE degradation path. Tag coverage for out-of-library candidates is
// permanently partial (#2376) and cold-start users have no taste tags, so both
// must be scored 0 — never negative, never dropped (rule #131).
func TestTagOverlap_EmptyEitherSideIsZeroNotNegative(t *testing.T) {
cases := []struct {
name string
candidate, taste TagWeights
}{
{"no candidate tags", nil, TagWeights{"shoegaze": 5}},
{"no taste tags", TagWeights{"shoegaze": 1}, nil},
{"both empty", nil, nil},
{"taste weights all zero", TagWeights{"shoegaze": 1}, TagWeights{"shoegaze": 0}},
{"candidate weights all zero", TagWeights{"shoegaze": 0}, TagWeights{"shoegaze": 5}},
}
for _, c := range cases {
got, matched := tagOverlap(c.candidate, c.taste)
if got != 0 {
t.Errorf("%s: overlap = %v, want 0", c.name, got)
}
if matched != nil {
t.Errorf("%s: matched = %v, want nil", c.name, matched)
}
}
}
// A densely-tagged artist must not out-score a focused one merely by having
// more tags — that's what dividing by the candidate's own mass buys.
func TestTagOverlap_IsNotAPopularityContest(t *testing.T) {
taste := TagWeights{"shoegaze": 5.0}
focused, _ := tagOverlap(TagWeights{"shoegaze": 1.0}, taste)
sprawling, _ := tagOverlap(TagWeights{
"shoegaze": 1.0, "rock": 1.0, "alternative": 1.0, "90s": 1.0,
}, taste)
if !(focused > sprawling) {
t.Errorf("focused=%v sprawling=%v — extra unmatched tags must dilute, not add",
focused, sprawling)
}
}
// Taste weights accumulate with listening, so raw magnitudes differ wildly
// between a new user and a heavy one while meaning the same thing. Normalizing
// by the user's own strongest tag is what makes the score comparable.
func TestTagOverlap_IsInvariantToTasteMagnitude(t *testing.T) {
candidate := TagWeights{"shoegaze": 1.0, "dream pop": 1.0}
newUser, _ := tagOverlap(candidate, TagWeights{"shoegaze": 2.0, "polka": 1.0})
heavyUser, _ := tagOverlap(candidate, TagWeights{"shoegaze": 2000.0, "polka": 1000.0})
if newUser != heavyUser {
t.Errorf("newUser=%v heavyUser=%v — scaling all taste weights must not change the result",
newUser, heavyUser)
}
}
func TestTagOverlap_MatchedTagsAreOrderedAndCapped(t *testing.T) {
got, matched := tagOverlap(
TagWeights{"a": 0.2, "b": 0.9, "c": 0.5, "d": 0.7},
TagWeights{"a": 10, "b": 10, "c": 10, "d": 10},
)
if got <= 0 {
t.Fatalf("overlap = %v, want > 0", got)
}
// All taste weights equal, so candidate weight decides: b(.9) d(.7) c(.5).
want := []string{"b", "d", "c"}
if fmt.Sprint(matched) != fmt.Sprint(want) {
t.Errorf("matched = %v, want %v (strongest first, capped at %d)",
matched, want, maxMatchedTags)
}
}
// --- applyTagOverlap ---
func poolFor(specs ...struct {
mbid string
score float64
}) []ArtistSuggestion {
out := make([]ArtistSuggestion, 0, len(specs))
for _, s := range specs {
out = append(out, ArtistSuggestion{MBID: s.mbid, Name: s.mbid, Score: s.score})
}
return out
}
type spec = struct {
mbid string
score float64
}
// The payoff, and the reason the fixture is skewed: a taste-matching candidate
// that started BELOW another must be able to overtake it. With an evenly
// matching pool this assertion could not fail.
func TestApplyTagOverlap_TasteMatchOvertakesAStrongerNonMatch(t *testing.T) {
pool := poolFor(spec{"loud", 1.0}, spec{"match", 0.7})
candTags := map[string]TagWeights{
"loud": {"death metal": 1.0},
"match": {"shoegaze": 1.0},
}
taste := TagWeights{"shoegaze": 5.0}
got := applyTagOverlap(pool, candTags, taste, 1.0)
if got[0].MBID != "match" {
t.Errorf("first = %q, want match (0.7×2 = 1.4 beats 1.0×1)", got[0].MBID)
}
if got[0].MatchedTags == nil {
t.Error("matched tags not stamped onto the winner")
}
}
// An untagged candidate keeps its score EXACTLY. This is the multiplicative
// blend's whole safety argument: partial coverage costs a candidate nothing.
func TestApplyTagOverlap_UntaggedCandidateScoreIsUnchanged(t *testing.T) {
pool := poolFor(spec{"untagged", 0.9})
got := applyTagOverlap(pool, map[string]TagWeights{}, TagWeights{"shoegaze": 5}, 1.0)
if got[0].Score != 0.9 {
t.Errorf("score = %v, want 0.9 exactly (no tags must not penalise)", got[0].Score)
}
if got[0].MatchedTags != nil {
t.Errorf("matched = %v, want nil", got[0].MatchedTags)
}
}
// Weight 0 is the operator's off switch: the ordering must be bit-for-bit the
// pre-slice-6 result, not merely similar.
func TestApplyTagOverlap_ZeroWeightIsAnExactNoOp(t *testing.T) {
pool := poolFor(spec{"a", 1.0}, spec{"b", 0.7})
candTags := map[string]TagWeights{"b": {"shoegaze": 1.0}}
got := applyTagOverlap(pool, candTags, TagWeights{"shoegaze": 5.0}, 0)
if got[0].MBID != "a" || got[0].Score != 1.0 || got[1].Score != 0.7 {
t.Errorf("weight 0 changed the pool: %+v", got)
}
// And it must not stamp matched tags either — the UI would otherwise
// explain a boost that never happened.
if got[1].MatchedTags != nil {
t.Errorf("matched tags stamped while the feature is off: %v", got[1].MatchedTags)
}
}
// A user with no taste profile is the cold-start case: every candidate scores
// the same multiplier of 1, so similarity order must survive intact.
func TestApplyTagOverlap_NoTasteProfileLeavesOrderIntact(t *testing.T) {
pool := poolFor(spec{"a", 1.0}, spec{"b", 0.7}, spec{"c", 0.4})
candTags := map[string]TagWeights{"c": {"shoegaze": 1.0}}
got := applyTagOverlap(pool, candTags, nil, 1.0)
for i, want := range []string{"a", "b", "c"} {
if got[i].MBID != want {
t.Fatalf("order = %v..., want a b c", got[i].MBID)
}
}
}
// Bounded boost: even a perfect match at the maximum weight can only multiply
// by (1 + w), so a candidate cannot be catapulted arbitrarily far.
func TestApplyTagOverlap_BoostIsBoundedByOnePlusWeight(t *testing.T) {
pool := poolFor(spec{"perfect", 1.0})
got := applyTagOverlap(pool,
map[string]TagWeights{"perfect": {"shoegaze": 1.0}},
TagWeights{"shoegaze": 5.0}, 2.0)
if got[0].Score != 3.0 {
t.Errorf("score = %v, want 3.0 (1.0 × (1 + 2×1))", got[0].Score)
}
if got[0].TagOverlap != 1.0 {
t.Errorf("TagOverlap = %v, want 1.0", got[0].TagOverlap)
}
}
// Equal blended scores must resolve deterministically, or two candidates could
// swap between requests inside one day — the exact churn the daily rotation
// (#2373) exists to prevent.
func TestApplyTagOverlap_TiesBreakDeterministically(t *testing.T) {
first := applyTagOverlap(poolFor(spec{"zzz", 1.0}, spec{"aaa", 1.0}),
map[string]TagWeights{}, TagWeights{"x": 1}, 1.0)
second := applyTagOverlap(poolFor(spec{"aaa", 1.0}, spec{"zzz", 1.0}),
map[string]TagWeights{}, TagWeights{"x": 1}, 1.0)
if first[0].MBID != "aaa" || second[0].MBID != "aaa" {
t.Errorf("tie order not deterministic: %q then %q", first[0].MBID, second[0].MBID)
}
}
+64
View File
@@ -161,6 +161,70 @@ func applyTastePatch(current TasteTuning, patch map[string]float64) (TasteTuning
return next, changes, nil
}
// Discover tuning bounds.
const (
// A tag-overlap weight above this stops being a boost and becomes the
// ranking — at 10, a perfect match multiplies similarity by 11, which lets
// tag agreement swamp the similarity signal entirely. The bound is for
// typos, not to constrain exploration; the multiplicative blend keeps even
// the maximum from reordering an untagged candidate.
tagOverlapWeightMax = 10.0
// Snooze duration: at least a day (anything less isn't a snooze, it's a
// flicker), at most a year — past that it's a permanent dismissal wearing a
// snooze's clothes, which is exactly the shape rule #101 rules out.
snoozeDaysMin = 1.0
snoozeDaysMax = 365.0
)
// applyDiscoverPatch validates and applies a partial Discover update.
func applyDiscoverPatch(
current DiscoverTuning, patch map[string]float64,
) (DiscoverTuning, []fieldChange, error) {
next := current
var changes []fieldChange
for field, v := range patch {
var target *float64
switch field {
case "tag_overlap_weight":
if v < 0 || v > tagOverlapWeightMax {
return current, nil, fmt.Errorf("%w: %s = %v (must be in [0, %v])",
ErrOutOfRange, field, v, tagOverlapWeightMax)
}
target = &next.TagOverlapWeight
case "snooze_days":
if v < snoozeDaysMin || v > snoozeDaysMax {
return current, nil, fmt.Errorf("%w: %s = %v (must be in [%v, %v])",
ErrOutOfRange, field, v, snoozeDaysMin, snoozeDaysMax)
}
target = &next.SnoozeDays
default:
return current, nil, fmt.Errorf("%w: %q", ErrUnknownField, field)
}
if *target == v {
continue
}
changes = append(changes, fieldChange{Field: field, Old: *target, New: v})
*target = v
}
return next, changes, nil
}
// diffDiscover returns per-field changes from a to b (empty when equal).
func diffDiscover(a, b DiscoverTuning) []fieldChange {
var out []fieldChange
if a.TagOverlapWeight != b.TagOverlapWeight {
out = append(out, fieldChange{
Field: "tag_overlap_weight", Old: a.TagOverlapWeight, New: b.TagOverlapWeight,
})
}
if a.SnoozeDays != b.SnoozeDays {
out = append(out, fieldChange{
Field: "snooze_days", Old: a.SnoozeDays, New: b.SnoozeDays,
})
}
return out
}
// diffWeights returns per-field changes from a to b (empty when equal).
func diffWeights(a, b recommendation.ScoringWeights) []fieldChange {
var out []fieldChange
+99
View File
@@ -35,6 +35,11 @@ const (
ScopeRadio = "radio"
ScopeDailyMix = "daily_mix"
ScopeTaste = "taste"
// ScopeDiscover is the Discover request surface (#2377). Its own scope
// rather than columns on taste: SnoozeDays lives here, and a snooze must
// never be read as taste signal (#2374) — filing it under taste would put
// it one careless join from the leak that design forbids.
ScopeDiscover = "discover"
)
// TasteTuning is the tunable subset of taste.Config: the engagement
@@ -87,6 +92,33 @@ func ShippedDailyMixWeights() recommendation.ScoringWeights {
}
}
// DiscoverTuning is the tunable set for the Discover request surface (#2377).
type DiscoverTuning struct {
// TagOverlapWeight scales the taste-tag term: score × (1 + w × overlap).
// 0 disables it and restores pure similarity ranking.
TagOverlapWeight float64
// SnoozeDays is the default "not right now" duration (#2374).
SnoozeDays float64
}
// ShippedDiscoverTuning are the shipped Discover defaults.
//
// TagOverlapWeight 1.0 lets a perfect tag match at most double a candidate's
// similarity score — enough to reorder the deck meaningfully, not enough for a
// popular-tag coincidence to beat a genuinely strong similarity match. It is a
// starting point for the tuning lab, not a tuned value: the honest way to pick
// it is the metrics trend view after some real use.
//
// SnoozeDays 90 matches the operator's approved shape: long enough that a
// parked suggestion stops nagging, short enough that a taste shift brings it
// back on its own.
func ShippedDiscoverTuning() DiscoverTuning {
return DiscoverTuning{
TagOverlapWeight: 1.0,
SnoozeDays: 90,
}
}
// ShippedTasteTuning mirrors taste.DefaultConfig's tunable subset.
func ShippedTasteTuning() TasteTuning {
d := taste.DefaultConfig()
@@ -110,6 +142,7 @@ type Service struct {
mu sync.RWMutex
profiles map[string]recommendation.ScoringWeights
taste TasteTuning
discover DiscoverTuning
}
// New boots the service: seeds shipped defaults for missing rows,
@@ -151,6 +184,13 @@ func (s *Service) reconcile(ctx context.Context) error {
}); err != nil {
return fmt.Errorf("seed taste tuning: %w", err)
}
sd := ShippedDiscoverTuning()
if err := q.UpsertDiscoverTuningDefaults(ctx, dbq.UpsertDiscoverTuningDefaultsParams{
TagOverlapWeight: sd.TagOverlapWeight,
SnoozeDays: sd.SnoozeDays,
}); err != nil {
return fmt.Errorf("seed discover tuning: %w", err)
}
rows, err := q.ListWeightProfiles(ctx)
if err != nil {
@@ -160,6 +200,10 @@ func (s *Service) reconcile(ctx context.Context) error {
if err != nil {
return fmt.Errorf("get taste tuning: %w", err)
}
dt, err := q.GetDiscoverTuning(ctx)
if err != nil {
return fmt.Errorf("get discover tuning: %w", err)
}
s.mu.Lock()
s.profiles = map[string]recommendation.ScoringWeights{}
@@ -175,6 +219,10 @@ func (s *Service) reconcile(ctx context.Context) error {
EraScale: tt.EraScale,
MoodScale: tt.MoodScale,
}
s.discover = DiscoverTuning{
TagOverlapWeight: dt.TagOverlapWeight,
SnoozeDays: dt.SnoozeDays,
}
s.mu.Unlock()
s.push()
@@ -208,6 +256,15 @@ func (s *Service) Taste() TasteTuning {
return s.taste
}
// Discover returns the cached Discover-tuning values. Read per request by the
// suggestions handler, so an admin change takes effect on the next refresh
// with no restart (rule #25).
func (s *Service) Discover() DiscoverTuning {
s.mu.RLock()
defer s.mu.RUnlock()
return s.discover
}
// TasteConfig assembles the full taste.Config the profile builder
// consumes: shipped non-tunable knobs (like bonuses, floors, caps)
// plus the tuned half-life and curve. WindowDays scales with the
@@ -267,6 +324,19 @@ func (s *Service) UpdateTaste(ctx context.Context, patch map[string]float64) err
return s.persistTaste(ctx, next, "update", changes)
}
// UpdateDiscover applies a partial update to the Discover tuning singleton.
func (s *Service) UpdateDiscover(ctx context.Context, patch map[string]float64) error {
current := s.Discover()
next, changes, err := applyDiscoverPatch(current, patch)
if err != nil {
return err
}
if len(changes) == 0 {
return nil
}
return s.persistDiscover(ctx, next, "update", changes)
}
// Reset restores a scope to its shipped defaults, with one audit row
// carrying the full diff. A scope already at defaults is a no-op.
func (s *Service) Reset(ctx context.Context, scope string) error {
@@ -288,6 +358,13 @@ func (s *Service) Reset(ctx context.Context, scope string) error {
return nil
}
return s.persistTaste(ctx, shipped, "reset", changes)
case ScopeDiscover:
shipped := ShippedDiscoverTuning()
changes := diffDiscover(s.Discover(), shipped)
if len(changes) == 0 {
return nil
}
return s.persistDiscover(ctx, shipped, "reset", changes)
default:
return fmt.Errorf("%w: %q", ErrUnknownScope, scope)
}
@@ -338,6 +415,28 @@ func (s *Service) persistTaste(
return nil
}
// persistDiscover writes the discover row + audit entry and refreshes the
// cache. No push(): unlike taste and daily_mix, nothing precomputes from these
// — the suggestions handler reads Discover() per request.
func (s *Service) persistDiscover(
ctx context.Context, d DiscoverTuning, action string, changes []fieldChange,
) error {
q := dbq.New(s.pool)
if _, err := q.UpdateDiscoverTuning(ctx, dbq.UpdateDiscoverTuningParams{
TagOverlapWeight: d.TagOverlapWeight,
SnoozeDays: d.SnoozeDays,
}); err != nil {
return fmt.Errorf("update discover tuning: %w", err)
}
if err := s.audit(ctx, q, ScopeDiscover, action, changes); err != nil {
return err
}
s.mu.Lock()
s.discover = d
s.mu.Unlock()
return nil
}
// audit writes one recommendation_tuning_audit row. Changes are
// sorted by field so rows are deterministic and diff-friendly.
func (s *Service) audit(
+115
View File
@@ -281,3 +281,118 @@ func TestUpdate_NoOpWritesNoAudit(t *testing.T) {
t.Errorf("no-op update wrote %d audit rows, want 0", len(rows))
}
}
// --- Discover scope (#2377, milestone #268 slice 6) ---
func TestNew_SeedsDiscoverDefaults(t *testing.T) {
pool := newPool(t)
s := newService(t, pool)
got := s.Discover()
want := ShippedDiscoverTuning()
if got != want {
t.Errorf("Discover() = %+v, want shipped %+v", got, want)
}
}
func TestUpdateDiscover_PersistsAndAudits(t *testing.T) {
pool := newPool(t)
s := newService(t, pool)
if err := s.UpdateDiscover(context.Background(), map[string]float64{
"tag_overlap_weight": 2.5,
"snooze_days": 30,
}); err != nil {
t.Fatalf("UpdateDiscover: %v", err)
}
if got := s.Discover().TagOverlapWeight; got != 2.5 {
t.Errorf("TagOverlapWeight = %v, want 2.5", got)
}
if got := s.Discover().SnoozeDays; got != 30 {
t.Errorf("SnoozeDays = %v, want 30", got)
}
// The audit row must land under the new scope. This is the assertion that
// would have caught a missing rule-#36 CHECK migration: without expanding
// recommendation_tuning_audit's whitelist, this INSERT fails at runtime.
rows := auditRows(t, pool)
if len(rows) != 1 {
t.Fatalf("audit rows = %d, want 1", len(rows))
}
if rows[0].Scope != ScopeDiscover {
t.Errorf("audit scope = %q, want %q", rows[0].Scope, ScopeDiscover)
}
if len(rows[0].Changes) != 2 {
t.Errorf("audit changes = %+v, want both fields", rows[0].Changes)
}
// Reload from the DB to prove it persisted rather than only caching.
s2 := newService(t, pool)
if got := s2.Discover().TagOverlapWeight; got != 2.5 {
t.Errorf("after reload TagOverlapWeight = %v, want 2.5 (not re-seeded to shipped)", got)
}
}
func TestUpdateDiscover_Validation(t *testing.T) {
pool := newPool(t)
s := newService(t, pool)
cases := []struct {
name string
patch map[string]float64
}{
{"unknown field", map[string]float64{"nope": 1}},
{"negative weight", map[string]float64{"tag_overlap_weight": -1}},
{"weight past the typo bound", map[string]float64{"tag_overlap_weight": 100}},
// A sub-day snooze isn't a snooze, it's a flicker.
{"snooze under a day", map[string]float64{"snooze_days": 0.5}},
// Past a year it's a permanent dismissal wearing a snooze's clothes —
// the shape rule #101 rules out.
{"snooze past a year", map[string]float64{"snooze_days": 400}},
}
for _, c := range cases {
if err := s.UpdateDiscover(context.Background(), c.patch); err == nil {
t.Errorf("%s: expected rejection, got nil", c.name)
}
}
if got := s.Discover(); got != ShippedDiscoverTuning() {
t.Errorf("a rejected patch mutated state: %+v", got)
}
if rows := auditRows(t, pool); len(rows) != 0 {
t.Errorf("rejected patches wrote %d audit rows, want 0", len(rows))
}
}
// Weight 0 must be accepted — it's the operator's off switch for the whole
// tag term, so a "must be positive" bound would remove their ability to
// disable the feature.
func TestUpdateDiscover_ZeroWeightIsAllowed(t *testing.T) {
pool := newPool(t)
s := newService(t, pool)
if err := s.UpdateDiscover(context.Background(),
map[string]float64{"tag_overlap_weight": 0}); err != nil {
t.Fatalf("UpdateDiscover(0): %v", err)
}
if got := s.Discover().TagOverlapWeight; got != 0 {
t.Errorf("TagOverlapWeight = %v, want 0", got)
}
}
func TestResetDiscover_RestoresShippedDefaults(t *testing.T) {
pool := newPool(t)
s := newService(t, pool)
if err := s.UpdateDiscover(context.Background(),
map[string]float64{"tag_overlap_weight": 4}); err != nil {
t.Fatalf("UpdateDiscover: %v", err)
}
if err := s.Reset(context.Background(), ScopeDiscover); err != nil {
t.Fatalf("Reset: %v", err)
}
if got := s.Discover(); got != ShippedDiscoverTuning() {
t.Errorf("after reset = %+v, want shipped %+v", got, ShippedDiscoverTuning())
}
// Already-at-defaults is a no-op: update + reset = 2 rows, not 3.
if err := s.Reset(context.Background(), ScopeDiscover); err != nil {
t.Fatalf("second Reset: %v", err)
}
if rows := auditRows(t, pool); len(rows) != 2 {
t.Errorf("audit rows = %d, want 2 (the no-op reset must not audit)", len(rows))
}
}
+211 -27
View File
@@ -61,40 +61,25 @@ func NewEnricher(pool *pgxpool.Pool, logger *slog.Logger, settings *SettingsServ
// enabled) the row settles to 'none'. If a provider fails transiently and
// no tags surfaced, the row is left NULL for a next-pass retry.
func (e *Enricher) EnrichTrack(ctx context.Context, trackID pgtype.UUID, ref TrackRef) (outcome, error) {
merged := map[string]float64{}
contributors := map[string]bool{}
anyTransient := false
for _, provider := range e.settings.EnabledTrackTagProviders() {
tags, perr := provider.FetchTrackTags(ctx, ref)
switch {
case perr == nil:
for _, t := range tags {
if t.Weight > merged[t.Name] {
merged[t.Name] = t.Weight
}
}
if len(tags) > 0 {
contributors[provider.ID()] = true
}
case errors.Is(perr, ErrNotFound):
// Clean "no data from this source" — try the next provider.
default:
anyTransient = true
e.logger.Warn("tags: provider fetch failed; continuing",
"track_id", uuidString(trackID), "provider", provider.ID(), "err", perr)
}
providers := e.settings.EnabledTrackTagProviders()
calls := make([]tagFetch, 0, len(providers))
for _, provider := range providers {
calls = append(calls, tagFetch{
providerID: provider.ID(),
fetch: func(c context.Context) ([]Tag, error) { return provider.FetchTrackTags(c, ref) },
})
}
res := e.runChain(ctx, calls, "track_id", uuidString(trackID))
if len(merged) > 0 {
top := topKByWeight(merged, e.topK)
source := sourceLabel(contributorIDs(contributors))
if len(res.merged) > 0 {
top := topKByWeight(res.merged, e.topK)
source := sourceLabel(res.contributors)
if err := e.writeTags(ctx, trackID, top, source, e.settings.CurrentVersion()); err != nil {
return outcomeLeftNull, err
}
return outcomeEnriched, nil
}
if anyTransient {
if res.anyTransient {
// Nothing landed but a source may recover — leave NULL for retry.
return outcomeLeftNull, nil
}
@@ -106,6 +91,65 @@ func (e *Enricher) EnrichTrack(ctx context.Context, trackID pgtype.UUID, ref Tra
return outcomeNone, nil
}
// tagFetch pairs a provider ID with a bound fetch call, so the merge-and-
// classify loop below is shared between the track chain and the candidate-
// artist chain (#2376) instead of being written twice with one word changed.
type tagFetch struct {
providerID string
fetch func(context.Context) ([]Tag, error)
}
// chainResult is what running a provider chain produced. contributors is the
// sorted set of provider IDs that actually returned tags — the input to
// sourceLabel.
type chainResult struct {
merged map[string]float64
contributors []string
anyTransient bool
}
// runChain queries every provider in order and UNIONS the results (max weight
// wins on overlap), unlike coverart's first-success-wins.
//
// A clean ErrNotFound means "this source has nothing" and moves to the next.
// Anything else is transient and recorded, so the caller can leave the row
// eligible for a retry rather than wrongly settling it as "nothing exists" —
// the distinction between those two is the whole point of the return value.
//
// logKey/logVal identify the subject in warnings (a track id or a candidate
// MBID), since this is shared across entity types.
func (e *Enricher) runChain(ctx context.Context, calls []tagFetch, logKey, logVal string) chainResult {
merged := map[string]float64{}
contributors := map[string]bool{}
anyTransient := false
for _, c := range calls {
tags, perr := c.fetch(ctx)
switch {
case perr == nil:
for _, t := range tags {
if t.Weight > merged[t.Name] {
merged[t.Name] = t.Weight
}
}
if len(tags) > 0 {
contributors[c.providerID] = true
}
case errors.Is(perr, ErrNotFound):
// Clean "no data from this source" — try the next provider.
default:
anyTransient = true
e.logger.Warn("tags: provider fetch failed; continuing",
logKey, logVal, "provider", c.providerID, "err", perr)
}
}
return chainResult{
merged: merged,
contributors: contributorIDs(contributors),
anyTransient: anyTransient,
}
}
// writeTags atomically replaces a track's cached tags and stamps the source
// + version. tags may be empty (the 'none' settle path), which just clears
// any prior tags and records the outcome.
@@ -196,6 +240,146 @@ func (e *Enricher) EnrichTrackBatch(ctx context.Context, limit int,
return processed, enriched, settledNone + leftNull + errored, nil
}
// EnrichCandidateArtist runs the artist-tag chain for one out-of-library
// Discover candidate and caches the merged result (#2376).
//
// Mirrors EnrichTrack, with one deliberate difference in the transient case:
// there is no row to "leave NULL", because eligibility is the ABSENCE of a
// candidate_artist_tag_state row. So a transient failure writes nothing at all,
// which leaves the candidate eligible for the next tick. Writing a state row
// here would settle a candidate whose tags we simply failed to fetch.
func (e *Enricher) EnrichCandidateArtist(ctx context.Context, mbid, name string) (outcome, error) {
providers := e.settings.EnabledArtistTagProviders()
ref := ArtistRef{MBID: mbid, Name: name}
calls := make([]tagFetch, 0, len(providers))
for _, provider := range providers {
calls = append(calls, tagFetch{
providerID: provider.ID(),
fetch: func(c context.Context) ([]Tag, error) { return provider.FetchArtistTags(c, ref) },
})
}
res := e.runChain(ctx, calls, "candidate_mbid", mbid)
version := e.settings.CurrentVersion()
if len(res.merged) > 0 {
top := topKByWeight(res.merged, e.topK)
if err := e.writeCandidateTags(ctx, mbid, top, sourceLabel(res.contributors), version); err != nil {
return outcomeLeftNull, err
}
return outcomeEnriched, nil
}
if res.anyTransient {
return outcomeLeftNull, nil
}
if err := e.writeCandidateTags(ctx, mbid, nil, sourceNone, version); err != nil {
return outcomeNone, err
}
return outcomeNone, nil
}
// writeCandidateTags atomically replaces a candidate's cached tags and stamps
// its state. tags may be empty (the 'none' settle path), which clears any prior
// tags and records that the providers had nothing.
func (e *Enricher) writeCandidateTags(
ctx context.Context, mbid string, tags []Tag, source string, version int32,
) error {
tx, err := e.pool.Begin(ctx)
if err != nil {
return fmt.Errorf("begin tx: %w", err)
}
defer func() { _ = tx.Rollback(ctx) }()
q := dbq.New(tx)
if err := q.DeleteCandidateArtistTags(ctx, mbid); err != nil {
return fmt.Errorf("delete candidate artist tags: %w", err)
}
for _, t := range tags {
if err := q.InsertCandidateArtistTag(ctx, dbq.InsertCandidateArtistTagParams{
CandidateMbid: mbid, Tag: t.Name, Weight: t.Weight,
}); err != nil {
return fmt.Errorf("insert candidate artist tag: %w", err)
}
}
if err := q.SetCandidateArtistTagState(ctx, dbq.SetCandidateArtistTagStateParams{
CandidateMbid: mbid,
TagSource: source,
TagSourcesVersion: version,
}); err != nil {
return fmt.Errorf("set candidate artist tag state: %w", err)
}
if err := tx.Commit(ctx); err != nil {
return fmt.Errorf("commit: %w", err)
}
return nil
}
// EnrichCandidateArtistBatch drains up to limit out-of-library candidates and
// enriches each serially. Same limit semantics as EnrichTrackBatch: 0 =
// disabled, >0 = bounded, <0 = unbounded.
//
// The candidate pool is far larger than the track pool (every library artist's
// neighbours) and can never be drained in one pass at MusicBrainz's ~1 req/s,
// so the query hands them back in descending similarity order — see
// ListCandidateArtistsMissingTags. A bounded batch here is therefore normal
// operation, not a degraded mode.
func (e *Enricher) EnrichCandidateArtistBatch(ctx context.Context, limit int) (
processed, succeeded, failed int, err error,
) {
if limit == 0 {
return 0, 0, 0, nil
}
queryLimit := int32(limit)
if limit < 0 {
queryLimit = 1<<31 - 1
}
q := dbq.New(e.pool)
rows, qerr := q.ListCandidateArtistsMissingTags(ctx, dbq.ListCandidateArtistsMissingTagsParams{
TagSourcesVersion: e.settings.CurrentVersion(),
Limit: queryLimit,
})
if qerr != nil {
return 0, 0, 0, fmt.Errorf("list candidate artists missing tags: %w", qerr)
}
var enriched, settledNone, leftNull, errored int
for _, r := range rows {
if ctx.Err() != nil {
e.logCandidateBatchSummary(len(rows), processed, enriched, settledNone, leftNull, errored)
return processed, enriched, settledNone + leftNull + errored, ctx.Err()
}
processed++
oc, eerr := e.EnrichCandidateArtist(ctx, r.CandidateMbid, r.CandidateName)
if eerr != nil {
e.logger.Warn("tags: candidate batch entry failed",
"candidate_mbid", r.CandidateMbid, "err", eerr)
errored++
continue
}
switch oc {
case outcomeEnriched:
enriched++
case outcomeNone:
settledNone++
case outcomeLeftNull:
leftNull++
}
}
e.logCandidateBatchSummary(len(rows), processed, enriched, settledNone, leftNull, errored)
return processed, enriched, settledNone + leftNull + errored, nil
}
// logCandidateBatchSummary mirrors logBatchSummary. `settled_none` is the
// honest-ceiling counter for this surface: candidates whose MBID has no
// upstream tags at all, which no amount of retrying will fix.
func (e *Enricher) logCandidateBatchSummary(eligible, processed, enriched, settledNone, leftNull, errored int) {
e.logger.Info("tags: candidate-artist enrichment batch complete",
"eligible", eligible,
"processed", processed,
"enriched", enriched,
"settled_none", settledNone,
"left_null", leftNull,
"errored", errored)
}
// logBatchSummary emits one Info line with the category breakdown — the
// enriched/settled/left-null split is the operator's diagnostic for a
// "0 enriched" symptom the collapsed tally can't explain.
+142
View File
@@ -0,0 +1,142 @@
package tags
import (
"context"
"errors"
"io"
"log/slog"
"testing"
)
// runChain is the merge-and-classify loop shared by the track and
// candidate-artist drains (#2376). It touches no DB, so it is testable with a
// bare Enricher.
//
// The classification is the part that matters: "every source cleanly had
// nothing" and "a source failed" lead to opposite persistence decisions
// (settle vs. leave eligible for retry), and conflating them either writes off
// an artist over a transient blip or re-fetches a genuinely untagged one
// forever.
func chainEnricher() *Enricher {
return &Enricher{logger: slog.New(slog.NewTextHandler(io.Discard, nil))}
}
func fixedFetch(tags []Tag, err error) func(context.Context) ([]Tag, error) {
return func(context.Context) ([]Tag, error) { return tags, err }
}
func TestRunChain_UnionsAcrossProvidersMaxWeightWins(t *testing.T) {
res := chainEnricher().runChain(context.Background(), []tagFetch{
{providerID: "musicbrainz", fetch: fixedFetch([]Tag{
{Name: "shoegaze", Weight: 0.4},
{Name: "noise", Weight: 0.9},
}, nil)},
{providerID: "lastfm", fetch: fixedFetch([]Tag{
{Name: "shoegaze", Weight: 0.8}, // higher — should win
{Name: "dream pop", Weight: 0.3},
}, nil)},
}, "subject", "x")
if got := res.merged["shoegaze"]; got != 0.8 {
t.Errorf("shoegaze = %v, want 0.8 (max across providers)", got)
}
if got := res.merged["noise"]; got != 0.9 {
t.Errorf("noise = %v, want 0.9", got)
}
if got := res.merged["dream pop"]; got != 0.3 {
t.Errorf("dream pop = %v, want 0.3", got)
}
if len(res.merged) != 3 {
t.Errorf("merged has %d tags, want 3: %v", len(res.merged), res.merged)
}
if res.anyTransient {
t.Error("anyTransient set with no failures")
}
if len(res.contributors) != 2 {
t.Errorf("contributors = %v, want both providers", res.contributors)
}
}
// A lower weight arriving second must not overwrite a higher one — the
// ordering of the chain must not change the result.
func TestRunChain_LowerWeightSecondDoesNotClobber(t *testing.T) {
res := chainEnricher().runChain(context.Background(), []tagFetch{
{providerID: "a", fetch: fixedFetch([]Tag{{Name: "rock", Weight: 0.9}}, nil)},
{providerID: "b", fetch: fixedFetch([]Tag{{Name: "rock", Weight: 0.2}}, nil)},
}, "subject", "x")
if got := res.merged["rock"]; got != 0.9 {
t.Errorf("rock = %v, want 0.9", got)
}
}
func TestRunChain_NotFoundIsSkippedNotTransient(t *testing.T) {
res := chainEnricher().runChain(context.Background(), []tagFetch{
{providerID: "a", fetch: fixedFetch(nil, ErrNotFound)},
{providerID: "b", fetch: fixedFetch([]Tag{{Name: "folk", Weight: 1}}, nil)},
}, "subject", "x")
if res.anyTransient {
t.Error("ErrNotFound must not be treated as transient — it would keep a settled subject eligible forever")
}
if len(res.contributors) != 1 || res.contributors[0] != "b" {
t.Errorf("contributors = %v, want [b] only (a returned nothing)", res.contributors)
}
}
func TestRunChain_TransientIsRecordedEvenWhenAnotherProviderSucceeds(t *testing.T) {
res := chainEnricher().runChain(context.Background(), []tagFetch{
{providerID: "a", fetch: fixedFetch(nil, ErrTransient)},
{providerID: "b", fetch: fixedFetch([]Tag{{Name: "folk", Weight: 1}}, nil)},
}, "subject", "x")
if !res.anyTransient {
t.Error("anyTransient should be set — 'a' may have had tags we never saw")
}
// Tags DID land, so the caller writes them; anyTransient only decides the
// no-tags case. Asserting both here pins that they're independent.
if len(res.merged) != 1 {
t.Errorf("merged = %v, want folk", res.merged)
}
}
// An unexpected error type is transient, not terminal. Defaulting the other way
// would settle a subject on any bug in a provider.
func TestRunChain_UnknownErrorIsTransient(t *testing.T) {
res := chainEnricher().runChain(context.Background(), []tagFetch{
{providerID: "a", fetch: fixedFetch(nil, errors.New("boom"))},
}, "subject", "x")
if !res.anyTransient {
t.Error("unknown error should count as transient")
}
if len(res.merged) != 0 {
t.Errorf("merged = %v, want empty", res.merged)
}
}
// A provider returning (empty, nil) is not a contributor: sourceLabel would
// otherwise stamp its ID onto a subject it gave nothing to.
func TestRunChain_EmptySuccessIsNotAContributor(t *testing.T) {
res := chainEnricher().runChain(context.Background(), []tagFetch{
{providerID: "a", fetch: fixedFetch(nil, nil)},
{providerID: "b", fetch: fixedFetch([]Tag{{Name: "folk", Weight: 1}}, nil)},
}, "subject", "x")
if len(res.contributors) != 1 || res.contributors[0] != "b" {
t.Errorf("contributors = %v, want [b]", res.contributors)
}
}
// No enabled providers is a clean "nothing found", NOT a failure — the caller
// settles the subject rather than retrying an empty chain on every tick.
func TestRunChain_EmptyChainSettlesRatherThanRetries(t *testing.T) {
res := chainEnricher().runChain(context.Background(), nil, "subject", "x")
if res.anyTransient {
t.Error("an empty chain must not look transient")
}
if len(res.merged) != 0 || len(res.contributors) != 0 {
t.Errorf("empty chain produced %v / %v", res.merged, res.contributors)
}
if sourceLabel(res.contributors) != sourceNone {
t.Errorf("sourceLabel = %q, want %q", sourceLabel(res.contributors), sourceNone)
}
}
+26
View File
@@ -87,6 +87,32 @@ type TrackTagProvider interface {
FetchTrackTags(ctx context.Context, ref TrackRef) ([]Tag, error)
}
// ArtistRef is the lookup key for artist-level tags. MBID is the artist's
// MusicBrainz ID (required by MBID-keyed providers); Name is the fallback for
// name-based providers (Last.fm). At least one must be set or every provider
// returns ErrNotFound.
type ArtistRef struct {
MBID string
Name string
}
// ArtistTagProvider is the artist-level tag capability, added for tag-space
// Discover (#2376). Separate from TrackTagProvider — exactly the split that
// interface's doc comment anticipated — so a source can implement either
// without the other, and so the enricher can ask for the capability it needs
// rather than checking at the call site.
//
// The subject here is an artist Minstrel does NOT have locally, so there is no
// track to fall back to and no recording-level tag to prefer.
type ArtistTagProvider interface {
Provider
// FetchArtistTags returns the artist's folksonomy tags, or ErrNotFound
// (terminal — nothing upstream) / ErrTransient (retry). A disabled
// provider, or one missing a required key, returns ErrNotFound so the
// chain simply skips it.
FetchArtistTags(ctx context.Context, ref ArtistRef) ([]Tag, error)
}
// TestableProvider is an opt-in capability for the admin Test-Connection
// button: answer "is my config working?" without a full enrichment cycle.
type TestableProvider interface {
+173
View File
@@ -0,0 +1,173 @@
package tags
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"testing"
)
// Artist-level tag fetching for out-of-library Discover candidates (#2376).
// Reuses the mbEntityServer / newMBProvider / newLastfmProvider helpers from
// the per-provider test files.
func TestMusicBrainzFetchArtistTags_UsesFullWeightNotTheFallbackDiscount(t *testing.T) {
// THE distinction worth a test. artistTagWeightFactor (0.6) exists because
// FetchTrackTags uses artist tags as a *proxy* for a track's tags. Here the
// artist IS the subject, so weights must land unscaled — otherwise these
// are not comparable with track_tags, which is the exact comparison slice 6
// is built on.
srv := mbEntityServer(``, `{"tags":[{"count":4,"name":"shoegaze"},{"count":2,"name":"dream pop"}]}`)
defer srv.Close()
old := mbBaseURL
mbBaseURL = srv.URL
defer func() { mbBaseURL = old }()
tags, err := newMBProvider(true).FetchArtistTags(context.Background(),
ArtistRef{MBID: "art-1", Name: "Some Band"})
if err != nil {
t.Fatalf("fetch: %v", err)
}
m := tagsByName(tags)
if got := m["shoegaze"]; got != 1.0 {
t.Errorf("shoegaze = %v, want 1.0 — not discounted by artistTagWeightFactor", got)
}
if got := m["dream pop"]; got != 0.5 {
t.Errorf("dream pop = %v, want 0.5", got)
}
}
func TestMusicBrainzFetchArtistTags_GatedOff(t *testing.T) {
if _, err := newMBProvider(false).FetchArtistTags(context.Background(),
ArtistRef{MBID: "art-1"}); !errors.Is(err, ErrNotFound) {
t.Errorf("disabled: err = %v, want ErrNotFound", err)
}
// No MBID → nothing MusicBrainz can look up. A name-based guess could
// silently attach the wrong artist's tags, so it deliberately doesn't try.
if _, err := newMBProvider(true).FetchArtistTags(context.Background(),
ArtistRef{Name: "Some Band"}); !errors.Is(err, ErrNotFound) {
t.Errorf("no MBID: err = %v, want ErrNotFound", err)
}
}
// fetchEntityTags reports an existing-but-untagged entity as (empty, nil) so
// FetchTrackTags can fall through to the artist level. FetchArtistTags has no
// next level, so it must convert that to the terminal ErrNotFound — otherwise
// the enricher would read "no error" as success and settle the candidate as
// enriched with zero tags.
func TestMusicBrainzFetchArtistTags_UntaggedArtistIsNotFound(t *testing.T) {
srv := mbEntityServer(``, `{"tags":[]}`)
defer srv.Close()
old := mbBaseURL
mbBaseURL = srv.URL
defer func() { mbBaseURL = old }()
_, err := newMBProvider(true).FetchArtistTags(context.Background(), ArtistRef{MBID: "art-1"})
if !errors.Is(err, ErrNotFound) {
t.Errorf("err = %v, want ErrNotFound", err)
}
}
func TestLastfmFetchArtistTags_CallsArtistGetTopTags(t *testing.T) {
var gotMethod, gotArtist, gotMBID string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotMethod = r.URL.Query().Get("method")
gotArtist = r.URL.Query().Get("artist")
gotMBID = r.URL.Query().Get("mbid")
_, _ = w.Write([]byte(`{"toptags":{"tag":[{"name":"post-punk","count":100},{"name":"moody","count":40}]}}`))
}))
defer srv.Close()
old := lastfmBaseURL
lastfmBaseURL = srv.URL + "/"
defer func() { lastfmBaseURL = old }()
p := &lastfmProvider{client: newHTTPClient(httpClientOptions{Name: "lastfm", MaxRetries: 1})}
_ = p.Configure(ProviderSettings{Enabled: true, APIKey: "k"})
tags, err := p.FetchArtistTags(context.Background(),
ArtistRef{MBID: "art-1", Name: "Some Band"})
if err != nil {
t.Fatalf("fetch: %v", err)
}
if gotMethod != "artist.gettoptags" {
t.Errorf("method = %q, want artist.gettoptags", gotMethod)
}
if gotArtist != "Some Band" {
t.Errorf("artist = %q, want Some Band", gotArtist)
}
// MBID is sent as a disambiguating hint when we have one.
if gotMBID != "art-1" {
t.Errorf("mbid = %q, want art-1", gotMBID)
}
m := tagsByName(tags)
if m["post-punk"] != 1.0 {
t.Errorf("post-punk = %v, want 1.0", m["post-punk"])
}
if m["moody"] != 0.4 {
t.Errorf("moody = %v, want 0.4", m["moody"])
}
}
func TestLastfmFetchArtistTags_GatedOff(t *testing.T) {
unkeyed := &lastfmProvider{client: newHTTPClient(httpClientOptions{Name: "lastfm", MaxRetries: 1})}
_ = unkeyed.Configure(ProviderSettings{Enabled: true})
if _, err := unkeyed.FetchArtistTags(context.Background(),
ArtistRef{Name: "A"}); !errors.Is(err, ErrNotFound) {
t.Errorf("unkeyed: err = %v, want ErrNotFound", err)
}
keyed := &lastfmProvider{client: newHTTPClient(httpClientOptions{Name: "lastfm", MaxRetries: 1})}
_ = keyed.Configure(ProviderSettings{Enabled: false, APIKey: "k"})
if _, err := keyed.FetchArtistTags(context.Background(),
ArtistRef{Name: "A"}); !errors.Is(err, ErrNotFound) {
t.Errorf("disabled: err = %v, want ErrNotFound", err)
}
// Name-based provider with no name → nothing to query. This is the case
// that matters in practice: a candidate whose name coalesced to '' still
// has an MBID, so MusicBrainz can serve it while Last.fm cannot.
enabled := &lastfmProvider{client: newHTTPClient(httpClientOptions{Name: "lastfm", MaxRetries: 1})}
_ = enabled.Configure(ProviderSettings{Enabled: true, APIKey: "k"})
if _, err := enabled.FetchArtistTags(context.Background(),
ArtistRef{MBID: "art-1"}); !errors.Is(err, ErrNotFound) {
t.Errorf("no name: err = %v, want ErrNotFound", err)
}
}
func TestLastfmFetchArtistTags_TransientErrorCodeRetries(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte(`{"error":29}`)) // rate limited
}))
defer srv.Close()
old := lastfmBaseURL
lastfmBaseURL = srv.URL + "/"
defer func() { lastfmBaseURL = old }()
p := &lastfmProvider{client: newHTTPClient(httpClientOptions{Name: "lastfm", MaxRetries: 1})}
_ = p.Configure(ProviderSettings{Enabled: true, APIKey: "k"})
_, err := p.FetchArtistTags(context.Background(), ArtistRef{Name: "A"})
// Must be ErrTransient, not ErrNotFound: the enricher settles a candidate
// to 'none' on ErrNotFound, which would permanently write off an artist we
// were merely throttled on.
if !errors.Is(err, ErrTransient) {
t.Errorf("err = %v, want ErrTransient", err)
}
}
func TestLastfmFetchArtistTags_UnknownArtistIsNotFound(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte(`{"error":6}`)) // "not found" — terminal
}))
defer srv.Close()
old := lastfmBaseURL
lastfmBaseURL = srv.URL + "/"
defer func() { lastfmBaseURL = old }()
p := &lastfmProvider{client: newHTTPClient(httpClientOptions{Name: "lastfm", MaxRetries: 1})}
_ = p.Configure(ProviderSettings{Enabled: true, APIKey: "k"})
if _, err := p.FetchArtistTags(context.Background(),
ArtistRef{Name: "Nobody"}); !errors.Is(err, ErrNotFound) {
t.Errorf("err = %v, want ErrNotFound", err)
}
}
+47 -2
View File
@@ -121,6 +121,50 @@ func (p *lastfmProvider) FetchTrackTags(ctx context.Context, ref TrackRef) ([]Ta
return out, nil
}
// FetchArtistTags looks up an artist's top tags by name, with the MBID as an
// extra hint when present (#2376). Name-first is deliberate and the opposite
// emphasis from MusicBrainz: Last.fm's tag data is keyed on its own artist
// pages, and autocorrect resolves most spelling drift from the similarity feed.
//
// `artist.getTopTags` returns the same `toptags` envelope as
// `track.getTopTags`, so the response type and normalizer are reused as-is —
// the 0-100 popularity scale is identical.
func (p *lastfmProvider) FetchArtistTags(ctx context.Context, ref ArtistRef) ([]Tag, error) {
if !p.enabled.Load() || p.currentKey() == "" {
return nil, ErrNotFound
}
if ref.Name == "" {
return nil, ErrNotFound
}
q := url.Values{
"method": {"artist.gettoptags"},
"api_key": {p.currentKey()},
"format": {"json"},
"artist": {ref.Name},
"autocorrect": {"1"},
}
if ref.MBID != "" {
q.Set("mbid", ref.MBID)
}
var resp lastfmTopTags
if err := p.client.getJSON(ctx, lastfmBaseURL+"?"+q.Encode(), &resp); err != nil {
return nil, err
}
if resp.Error != 0 {
if lastfmTransientErrors[resp.Error] {
return nil, ErrTransient
}
return nil, ErrNotFound
}
out := normalizeLastfmTags(resp.TopTags.Tag)
if len(out) == 0 {
return nil, ErrNotFound
}
return out, nil
}
// TestConnection verifies the key against a well-known track.
func (p *lastfmProvider) TestConnection(ctx context.Context) error {
if p.currentKey() == "" {
@@ -163,6 +207,7 @@ func normalizeLastfmTags(raw []lastfmTag) []Tag {
// Compile-time capability checks.
var (
_ TrackTagProvider = (*lastfmProvider)(nil)
_ TestableProvider = (*lastfmProvider)(nil)
_ TrackTagProvider = (*lastfmProvider)(nil)
_ ArtistTagProvider = (*lastfmProvider)(nil)
_ TestableProvider = (*lastfmProvider)(nil)
)
+30 -2
View File
@@ -96,6 +96,33 @@ func (p *musicbrainzProvider) FetchTrackTags(ctx context.Context, ref TrackRef)
return nil, ErrNotFound
}
// FetchArtistTags looks up an artist's own tags by MBID (#2376). MBID-only:
// MusicBrainz has no name-based tag lookup worth trusting for this, and a
// wrong-artist match would poison the tag cache silently.
//
// Note the scale is 1.0, NOT artistTagWeightFactor. That discount exists
// because FetchTrackTags uses artist tags as a *proxy* for a track's tags, and
// the artist's overall character is the coarser signal of the two. Here the
// artist IS the subject, so there is nothing to discount relative to — and
// applying it would make these weights incomparable with track_tags, which is
// exactly the comparison slice 6 depends on.
func (p *musicbrainzProvider) FetchArtistTags(ctx context.Context, ref ArtistRef) ([]Tag, error) {
if !p.enabled.Load() || ref.MBID == "" {
return nil, ErrNotFound
}
tags, err := p.fetchEntityTags(ctx, "artist", ref.MBID, 1.0)
if err != nil {
return nil, err
}
// fetchEntityTags reports an existing-but-untagged entity as (empty, nil)
// so FetchTrackTags can fall through to the next level. There is no next
// level here, so empty is the terminal "nothing upstream".
if len(tags) == 0 {
return nil, ErrNotFound
}
return tags, nil
}
// fetchEntityTags loads folksonomy tags for a MusicBrainz entity ("recording"
// or "artist") by MBID and scales the normalized weights by `scale`. Returns
// an empty slice (not ErrNotFound) when the entity exists but is untagged, so
@@ -153,6 +180,7 @@ func normalizeMBTags(raw []mbTag) []Tag {
// Compile-time capability checks.
var (
_ TrackTagProvider = (*musicbrainzProvider)(nil)
_ TestableProvider = (*musicbrainzProvider)(nil)
_ TrackTagProvider = (*musicbrainzProvider)(nil)
_ ArtistTagProvider = (*musicbrainzProvider)(nil)
_ TestableProvider = (*musicbrainzProvider)(nil)
)
+21
View File
@@ -135,6 +135,27 @@ func (s *SettingsService) EnabledTrackTagProviders() []TrackTagProvider {
return out
}
// EnabledArtistTagProviders returns the enabled providers implementing
// ArtistTagProvider, in registration order. Snapshot — do not mutate.
//
// Separate from EnabledTrackTagProviders rather than one call with a capability
// argument: the two chains are consumed by different drains, and a provider may
// implement one capability without the other.
func (s *SettingsService) EnabledArtistTagProviders() []ArtistTagProvider {
s.mu.RLock()
defer s.mu.RUnlock()
var out []ArtistTagProvider
for _, p := range AllProviders() {
if !s.enabledIDs[p.ID()] {
continue
}
if ap, ok := p.(ArtistTagProvider); ok {
out = append(out, ap)
}
}
return out
}
// CurrentVersion returns the version the enricher stamps onto rows.
func (s *SettingsService) CurrentVersion() int32 {
s.mu.RLock()
+35 -10
View File
@@ -14,22 +14,32 @@ import (
// providers' httpClients, so a tick just drains a bounded batch and the
// external APIs pace themselves.
type Worker struct {
enricher *Enricher
logger *slog.Logger
tick time.Duration
batch int
enricher *Enricher
logger *slog.Logger
tick time.Duration
batch int
candidateBatch int
}
// NewWorker constructs a worker with production defaults: an initial drain
// shortly after boot, then every 30 minutes, up to 200 tracks per tick.
// MusicBrainz's 1 req/s ceiling is the real throttle, so the batch size
// mainly bounds how long one tick runs, not the request rate.
//
// candidateBatch is smaller than the track batch on purpose. Library tracks are
// a finite set that drains to completion and then costs nothing; out-of-library
// candidates (#2376) are effectively unbounded — every library artist's
// neighbours — so this arm would otherwise monopolise every tick forever and
// starve the track arm. 50/tick at ~1 req/s is roughly a minute of work, and
// the query hands back the highest-similarity candidates first so the ones that
// can actually reach a user's deck are enriched first.
func NewWorker(enricher *Enricher, logger *slog.Logger) *Worker {
return &Worker{
enricher: enricher,
logger: logger,
tick: 30 * time.Minute,
batch: 200,
enricher: enricher,
logger: logger,
tick: 30 * time.Minute,
batch: 200,
candidateBatch: 50,
}
}
@@ -48,12 +58,27 @@ func (w *Worker) Run(ctx context.Context) {
}
}
// tickOnce drains one bounded batch. EnrichTrackBatch already logs a
// category breakdown, so this only surfaces a fatal batch error.
// tickOnce drains one bounded batch of each kind. Both Enrich*Batch methods
// already log a category breakdown, so this only surfaces a fatal batch error.
//
// Tracks first: they back the taste profile the whole app reads from, whereas
// candidate tags only affect the Discover request surface. On a fresh install
// both are cold, and getting the profile warm matters more.
func (w *Worker) tickOnce(ctx context.Context) {
if _, _, _, err := w.enricher.EnrichTrackBatch(ctx, w.batch, nil); err != nil {
if ctx.Err() == nil {
w.logger.Error("tags: enrichment tick failed", "err", err)
}
}
// Not gated on the track arm's success: the two drains share nothing but a
// provider chain, and a track-side failure says nothing about whether
// candidate lookups will work.
if ctx.Err() != nil {
return
}
if _, _, _, err := w.enricher.EnrichCandidateArtistBatch(ctx, w.candidateBatch); err != nil {
if ctx.Err() == nil {
w.logger.Error("tags: candidate-artist enrichment tick failed", "err", err)
}
}
}
+1
View File
@@ -58,6 +58,7 @@ export const qk = {
smtpConfig: () => ['smtpConfig'] as const,
suggestions: (limit?: number) =>
['suggestions', { limit: limit ?? 12 }] as const,
suggestionSnoozes: () => ['suggestionSnoozes'] as const,
home: () => ['home'] as const,
albumsAlpha: () => ['albumsAlpha'] as const,
artistTracks: (artistId: string) => ['artistTracks', artistId] as const,
+61 -3
View File
@@ -1,13 +1,18 @@
import { afterEach, describe, expect, test, vi } from 'vitest';
vi.mock('./client', () => ({
api: { get: vi.fn() }
api: { get: vi.fn(), post: vi.fn(), del: vi.fn() }
}));
import { listSuggestions } from './suggestions';
import {
listSuggestions,
listSnoozes,
snoozeSuggestion,
unsnoozeSuggestion
} from './suggestions';
import { qk } from './queries';
import { api } from './client';
import type { ArtistSuggestion } from './types';
import type { ArtistSuggestion, SuggestionSnooze } from './types';
afterEach(() => vi.clearAllMocks());
@@ -40,3 +45,56 @@ describe('suggestions client', () => {
expect(qk.suggestions(20)).toEqual(['suggestions', { limit: 20 }]);
});
});
describe('suggestion snoozes (#2375)', () => {
test('snoozeSuggestion sends the name — the server 400s without it', async () => {
(api.post as ReturnType<typeof vi.fn>).mockResolvedValueOnce(null);
await snoozeSuggestion('mb-1', 'Parked Artist');
expect(api.post).toHaveBeenCalledWith('/api/discover/suggestions/mb-1/snooze', {
name: 'Parked Artist'
});
});
test('snoozeSuggestion sends no days, leaving the default to the server', async () => {
(api.post as ReturnType<typeof vi.fn>).mockResolvedValueOnce(null);
await snoozeSuggestion('mb-1', 'Parked Artist');
const body = (api.post as ReturnType<typeof vi.fn>).mock.calls[0][1] as Record<string, unknown>;
expect(body).not.toHaveProperty('days');
});
// MBIDs are UUIDs today, but the column is free-text and the value comes
// from an external similarity feed, so it goes through encodeURIComponent.
test('the mbid is URL-encoded into the path', async () => {
(api.post as ReturnType<typeof vi.fn>).mockResolvedValueOnce(null);
await snoozeSuggestion('weird/id?x', 'Odd');
expect(api.post).toHaveBeenCalledWith(
'/api/discover/suggestions/weird%2Fid%3Fx/snooze',
{ name: 'Odd' }
);
});
test('unsnoozeSuggestion DELETEs the same path', async () => {
(api.del as ReturnType<typeof vi.fn>).mockResolvedValueOnce(null);
await unsnoozeSuggestion('mb-1');
expect(api.del).toHaveBeenCalledWith('/api/discover/suggestions/mb-1/snooze');
});
test('listSnoozes hits the snoozes collection', async () => {
const fixture: SuggestionSnooze[] = [
{
mbid: 'mb-1',
name: 'Parked Artist',
snoozed_until: '2026-11-01T00:00:00Z',
created_at: '2026-08-03T00:00:00Z'
}
];
(api.get as ReturnType<typeof vi.fn>).mockResolvedValueOnce(fixture);
const got = await listSnoozes();
expect(api.get).toHaveBeenCalledWith('/api/discover/snoozes');
expect(got).toEqual(fixture);
});
test('qk.suggestionSnoozes key shape', () => {
expect(qk.suggestionSnoozes()).toEqual(['suggestionSnoozes']);
});
});
+34 -1
View File
@@ -1,7 +1,7 @@
import { createQuery } from '@tanstack/svelte-query';
import { api } from './client';
import { qk } from './queries';
import type { ArtistSuggestion } from './types';
import type { ArtistSuggestion, SuggestionSnooze } from './types';
export async function listSuggestions(limit = 12): Promise<ArtistSuggestion[]> {
return api.get<ArtistSuggestion[]>(`/api/discover/suggestions?limit=${limit}`);
@@ -14,3 +14,36 @@ export function createSuggestionsQuery(limit = 12) {
staleTime: 5 * 60_000 // 5 minutes — see M5c spec §5
});
}
// Parks a suggestion for the server's default period (90 days). `name` is
// REQUIRED by the server and is not optional bookkeeping: candidates are
// out-of-library, so there is no artists row to resolve a display name from
// and the snooze list would have nothing to render. Omitting it is a 400.
//
// No `days` is sent. There is deliberately no duration UI yet — that knob is
// slice 6 (#2377) — and hardcoding a value here would pin the default to the
// client instead of the server that owns it.
export async function snoozeSuggestion(mbid: string, name: string): Promise<void> {
await api.post<null>(`/api/discover/suggestions/${encodeURIComponent(mbid)}/snooze`, { name });
}
// Brings a parked suggestion back immediately. The server 404s an MBID that
// was never snoozed; callers treat that as already-unsnoozed rather than as a
// failure, since the end state the user asked for is the one they get.
export async function unsnoozeSuggestion(mbid: string): Promise<void> {
await api.del(`/api/discover/suggestions/${encodeURIComponent(mbid)}/snooze`);
}
export async function listSnoozes(): Promise<SuggestionSnooze[]> {
return api.get<SuggestionSnooze[]>('/api/discover/snoozes');
}
export function createSnoozesQuery() {
return createQuery({
queryKey: qk.suggestionSnoozes(),
queryFn: listSnoozes
// No staleTime, unlike the suggestions query: this list is the only route
// back to an un-snooze, so it must reflect a snooze made seconds ago
// rather than a cached view of the world.
});
}
+11 -1
View File
@@ -26,14 +26,24 @@ export type TasteTuning = {
mood_scale: number;
};
export type TuningScope = 'radio' | 'daily_mix' | 'taste';
// Discover request-surface knobs (#2377). Its own scope rather than part of
// taste: snooze_days belongs here, and a snooze must never be read as taste
// signal (#2374).
export type DiscoverTuning = {
tag_overlap_weight: number;
snooze_days: number;
};
export type TuningScope = 'radio' | 'daily_mix' | 'taste' | 'discover';
export type TuningSnapshot = {
profiles: Record<'radio' | 'daily_mix', WeightProfile>;
taste: TasteTuning;
discover: DiscoverTuning;
shipped: {
profiles: Record<'radio' | 'daily_mix', WeightProfile>;
taste: TasteTuning;
discover: DiscoverTuning;
};
};
+14
View File
@@ -332,6 +332,20 @@ export type ArtistSuggestion = {
score: number;
attribution: SeedContribution[]; // up to 3 entries, ordered by contribution DESC
image_url?: string; // resolved on-demand from Lidarr; absent → card placeholder
// Tags this candidate shares with your taste profile, strongest first (max 3,
// #2377). Absent for most candidates — tag coverage for artists you don't own
// is partial by nature (#2376) — so the card falls back to seed attribution.
matched_tags?: string[];
};
// One parked suggestion — "not right now", not a dislike. The server only
// ever returns rows whose snoozed_until is still in the future, so the client
// never has to compare against the clock to decide what to show.
export type SuggestionSnooze = {
mbid: string;
name: string;
snoozed_until: string; // RFC3339
created_at: string;
};
// Mirrors internal/api/types.go HomePayload. All slices are non-null
@@ -1,10 +1,14 @@
<script lang="ts" module>
export type DiscoverCardKind = 'artist' | 'album' | 'track';
export type DiscoverCardState = 'requestable' | 'kept' | 'requested';
// 'snoozed' is a transient state the card flips to in place after the user
// parks it, so the disappearance is legible and undoable rather than a card
// silently vanishing from under the cursor (rule #24). The row is gone on
// the next refetch; the persistent way back is the snoozed list.
export type DiscoverCardState = 'requestable' | 'kept' | 'requested' | 'snoozed';
</script>
<script lang="ts">
import { Plus, Disc3, Album, Music2 } from 'lucide-svelte';
import { Plus, Disc3, Album, Music2, Clock } from 'lucide-svelte';
let {
kind,
@@ -14,6 +18,8 @@
state,
attribution,
onRequest,
onSnooze,
onUnsnooze,
}: {
kind: DiscoverCardKind;
title: string;
@@ -22,6 +28,10 @@
state: DiscoverCardState;
attribution?: string;
onRequest?: () => void;
// Omit both to get a card with no snooze affordance — the Lidarr search
// results reuse this component and have nothing to park.
onSnooze?: () => void;
onUnsnooze?: () => void;
} = $props();
const FallbackIcon = $derived(
@@ -65,20 +75,50 @@
<div class="badge-row" data-testid="badge-row">
{#if state === 'kept'}
<span class="kept-pill" role="status">Kept</span>
{:else if state === 'snoozed'}
<span class="snoozed-pill" role="status">Not right now</span>
{/if}
</div>
</div>
<div class="actions pt-3" data-testid="actions">
{#if state === 'requestable'}
{#if state === 'snoozed'}
<button
type="button"
aria-label={`Request ${title}`}
class="flex items-center gap-1 rounded-md bg-action-primary px-3 py-1.5 text-sm text-action-fg"
onclick={handleRequest}
aria-label={`Bring ${title} back`}
class="rounded-md border border-border px-3 py-1.5 text-sm text-text-primary hover:bg-surface-hover focus:outline-none focus:ring-2 focus:ring-accent"
onclick={() => onUnsnooze?.()}
>
<Plus size={16} strokeWidth={1} /> Request
Undo
</button>
{:else if state === 'requestable'}
<div class="flex items-center gap-2">
<button
type="button"
aria-label={`Request ${title}`}
class="flex items-center gap-1 rounded-md bg-action-primary px-3 py-1.5 text-sm text-action-fg"
onclick={handleRequest}
>
<Plus size={16} strokeWidth={1} /> Request
</button>
{#if onSnooze}
<!--
Icon-only to keep Request unambiguously the primary action, with
the intent carried by the accessible name. "Not right now" is the
whole point of the wording: this parks a suggestion, it does not
record an opinion about the artist (rule #101).
-->
<button
type="button"
aria-label={`Not right now — hide ${title} for a while`}
title="Not right now"
class="rounded-md border border-border p-1.5 text-text-secondary hover:bg-surface-hover hover:text-text-primary focus:outline-none focus:ring-2 focus:ring-accent"
onclick={() => onSnooze?.()}
>
<Clock size={16} strokeWidth={1} />
</button>
{/if}
</div>
{:else if state === 'kept'}
<button
type="button"
@@ -131,4 +171,20 @@
background: color-mix(in srgb, var(--fs-accent) 15%, transparent);
color: var(--fs-accent);
}
/* Muted rather than accented: a parked card should recede, not compete
with the live suggestions around it. Same geometry as .kept-pill so the
two read as one component in different states. */
.snoozed-pill {
display: inline-flex;
align-items: center;
padding: 2px 8px;
border-radius: 999px;
font-size: 11px;
line-height: 14px;
background: var(--fs-slate);
color: var(--fs-vellum);
}
.card[data-state='snoozed'] {
opacity: 0.6;
}
</style>
@@ -125,4 +125,47 @@ describe('DiscoverResultCard', () => {
});
expect(screen.queryByTestId('attribution')).not.toBeInTheDocument();
});
// --- snooze affordance (#2375) ---
test('snooze button appears only when onSnooze is supplied', () => {
// The Lidarr search results reuse this card and have nothing to park, so
// the affordance must not appear unconditionally.
render(DiscoverResultCard, {
props: { kind: 'artist', title: 'Outsider', state: 'requestable' }
});
expect(screen.queryByRole('button', { name: /not right now/i })).not.toBeInTheDocument();
});
test('snooze button calls onSnooze and reads as "not right now", never as a dislike', async () => {
const onSnooze = vi.fn();
render(DiscoverResultCard, {
props: { kind: 'artist', title: 'Outsider', state: 'requestable', onSnooze }
});
const btn = screen.getByRole('button', { name: /not right now — hide outsider for a while/i });
// Rule #101: the accessible name must carry no verdict on the music.
expect(btn.getAttribute('aria-label')).not.toMatch(/dislike|not for me|never|hate/i);
await fireEvent.click(btn);
expect(onSnooze).toHaveBeenCalledOnce();
});
test('snoozed state swaps Request for Undo and shows a status pill', async () => {
const onUnsnooze = vi.fn();
const onRequest = vi.fn();
render(DiscoverResultCard, {
props: {
kind: 'artist',
title: 'Outsider',
state: 'snoozed',
onRequest,
onUnsnooze
}
});
expect(screen.queryByRole('button', { name: /request outsider/i })).not.toBeInTheDocument();
const status = screen.getByRole('status');
expect(status.textContent).toMatch(/not right now/i);
await fireEvent.click(screen.getByRole('button', { name: /bring outsider back/i }));
expect(onUnsnooze).toHaveBeenCalledOnce();
expect(onRequest).not.toHaveBeenCalled();
});
});
+5 -1
View File
@@ -35,5 +35,9 @@
transition-transform duration-200
{player.queueDrawerOpen ? 'translate-x-0' : 'translate-x-full'}"
>
<QueueList onClose={() => closeQueueDrawer()} bind:closeButtonRef={closeButton} />
<QueueList
onClose={() => closeQueueDrawer()}
active={player.queueDrawerOpen}
bind:closeButtonRef={closeButton}
/>
</aside>
+15 -3
View File
@@ -1,9 +1,17 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/svelte';
import QueueDrawer from './QueueDrawer.svelte';
import { emptyLikesMock } from '../../test-utils/mocks/likes';
import type { TrackRef } from '$lib/api/types';
import { makeTrack } from '$test-utils/fixtures/track';
// QueueTrackRow (rendered inside QueueDrawer → QueueList) now renders a
// LikeButton, which reads createLikedIdsQuery. Stub the likes API so the
// rows don't need a real QueryClient in context. Both mocks — and the
// emptyLikesMock import — must precede the QueueDrawer import below, since
// loading the component evaluates the mocked modules and the hoisted
// factories need their referenced bindings already initialized.
vi.mock('$lib/api/likes', () => emptyLikesMock());
const closeQueueDrawer = vi.fn();
let queueValue: TrackRef[] = [];
let indexValue = 0;
@@ -17,12 +25,16 @@ vi.mock('$lib/player/store.svelte', () => ({
get queueDrawerOpen() { return openValue; }
},
// QueueTrackRow imports these from the store; provide stubs so its
// module-load doesn't break when QueueDrawer renders rows.
// module-load doesn't break when QueueDrawer renders rows. QueueList
// imports clearQueue for its header action.
playFromQueueIndex: vi.fn(),
removeFromQueue: vi.fn(),
moveQueueItem: vi.fn()
moveQueueItem: vi.fn(),
clearQueue: vi.fn()
}));
import QueueDrawer from './QueueDrawer.svelte';
const t = (id: string): TrackRef => makeTrack({ id, title: `Song ${id}` });
describe('QueueDrawer', () => {
+97 -16
View File
@@ -1,6 +1,7 @@
<script lang="ts">
import { X } from 'lucide-svelte';
import { player } from '$lib/player/store.svelte';
import { untrack } from 'svelte';
import { X, Trash2, ArrowDown } from 'lucide-svelte';
import { player, clearQueue } from '$lib/player/store.svelte';
import QueueTrackRow from './QueueTrackRow.svelte';
// onClose: when provided, renders an X button in the header so the
@@ -8,12 +9,66 @@
// now-playing route (visible at lg+ widths) omits it.
// closeButtonRef: bind:this hook so the drawer can focus the X for
// keyboard users on open.
// active: true when the queue is on-screen (drawer open, or the always-
// visible now-playing panel). Gates the scroll-to-current behavior.
type Props = {
onClose?: () => void;
closeButtonRef?: HTMLButtonElement;
active?: boolean;
};
let { onClose, closeButtonRef = $bindable() }: Props = $props();
let { onClose, closeButtonRef = $bindable(), active = true }: Props = $props();
let scrollBody: HTMLElement | undefined = $state();
// Whether the now-playing row is (at least partly) within the scroll
// viewport. Drives auto-follow (only follow while the user is watching the
// current track) and the "Jump to current" pill (shown when it's off-screen).
let currentInView = $state(true);
let sawFirstIndex = false;
function scrollToCurrent(block: ScrollLogicalPosition, behavior: ScrollBehavior = 'auto') {
(scrollBody?.children[player.index] as HTMLElement | undefined)?.scrollIntoView({
block,
behavior,
});
currentInView = true;
}
function recomputeInView() {
const row = scrollBody?.children[player.index] as HTMLElement | undefined;
if (!scrollBody || !row) {
currentInView = true;
return;
}
const b = scrollBody.getBoundingClientRect();
const r = row.getBoundingClientRect();
currentInView = r.bottom > b.top && r.top < b.bottom;
}
// On open (active flips true, or on mount for the always-visible panel),
// center the now-playing row — parity with the Android queue.
$effect(() => {
if (!active) return;
if (untrack(() => player.queue.length) === 0) return;
requestAnimationFrame(() => scrollToCurrent('center'));
});
// Follow the current track as it auto-advances, but only while the user is
// still watching it — if they've scrolled away, leave them there (the pill
// offers the way back). block:'nearest' keeps it minimal (no yank when the
// row is already visible). Index is tracked; currentInView is read untracked
// so a scroll that hides the row doesn't itself re-trigger a scroll.
$effect(() => {
player.index; // subscribe: follow on advance
if (!sawFirstIndex) {
sawFirstIndex = true;
return; // the open effect already handled the initial position
}
if (!active) return;
if (untrack(() => player.queue.length) === 0) return;
if (!untrack(() => currentInView)) return;
requestAnimationFrame(() => scrollToCurrent('nearest'));
});
function totalDurationLabel(tracks: { duration_sec: number }[]): string {
const totalSec = tracks.reduce((s, tr) => s + (tr.duration_sec ?? 0), 0);
@@ -23,7 +78,7 @@
}
</script>
<div class="flex h-full flex-col">
<div class="relative flex h-full flex-col">
<div class="flex items-center justify-between border-b border-border px-4 py-3">
<div>
<h2 class="text-lg font-semibold">Queue</h2>
@@ -32,20 +87,33 @@
{#if player.queue.length > 0} · {totalDurationLabel(player.queue)}{/if}
</p>
</div>
{#if onClose}
<button
type="button"
bind:this={closeButtonRef}
aria-label="Close queue"
onclick={onClose}
class="text-text-secondary hover:text-text-primary"
>
<X size={20} />
</button>
{/if}
<div class="flex items-center gap-1">
{#if player.queue.length > 0}
<button
type="button"
aria-label="Clear queue"
title="Clear queue"
onclick={() => clearQueue()}
class="rounded p-1 text-text-secondary hover:text-text-primary"
>
<Trash2 size={18} />
</button>
{/if}
{#if onClose}
<button
type="button"
bind:this={closeButtonRef}
aria-label="Close queue"
onclick={onClose}
class="rounded p-1 text-text-secondary hover:text-text-primary"
>
<X size={20} />
</button>
{/if}
</div>
</div>
<div class="flex-1 overflow-y-auto">
<div bind:this={scrollBody} onscroll={recomputeInView} class="flex-1 overflow-y-auto">
{#if player.queue.length === 0}
<p class="text-text-secondary text-center p-8">No tracks queued.</p>
{:else}
@@ -54,4 +122,17 @@
{/each}
{/if}
</div>
{#if active && player.queue.length > 0 && !currentInView}
<button
type="button"
onclick={() => scrollToCurrent('center', 'smooth')}
class="absolute bottom-4 left-1/2 flex -translate-x-1/2 items-center gap-1.5
rounded-full bg-action-secondary px-3 py-1.5 text-xs font-medium
text-action-fg shadow-lg"
>
<ArrowDown size={14} />
Jump to current
</button>
{/if}
</div>
@@ -3,7 +3,9 @@
import { draggable, type DragEventData } from '@neodrag/svelte';
import type { TrackRef } from '$lib/api/types';
import { playFromQueueIndex, removeFromQueue, moveQueueItem } from '$lib/player/store.svelte';
import { coverUrl, FALLBACK_COVER } from '$lib/media/covers';
import { offsetToDelta } from './queue-row-math';
import LikeButton from './LikeButton.svelte';
let { track, index, isCurrent } = $props<{
track: TrackRef;
@@ -65,6 +67,13 @@
<GripVertical size={16} />
</button>
<img
src={coverUrl(track.album_id)}
alt=""
onerror={(e) => ((e.currentTarget as HTMLImageElement).src = FALLBACK_COVER)}
class="h-10 w-10 flex-shrink-0 rounded object-cover"
/>
<button
type="button"
onclick={handleBodyClick}
@@ -80,6 +89,8 @@
<div class="text-xs text-text-secondary truncate">{track.artist_name}</div>
</button>
<LikeButton entityType="track" entityId={track.id} />
<button
type="button"
onclick={handleRemove}
+10 -1
View File
@@ -1,8 +1,17 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/svelte';
import QueueTrackRow from './QueueTrackRow.svelte';
import { emptyLikesMock } from '../../test-utils/mocks/likes';
import { makeTrack } from '$test-utils/fixtures/track';
// QueueTrackRow now renders a LikeButton, which reads createLikedIdsQuery.
// Stub the likes API so the row doesn't need a real QueryClient in context.
// The mock (and its emptyLikesMock import) must precede the component import
// below: importing QueueTrackRow transitively loads LikeButton → the mocked
// module, and the hoisted factory needs emptyLikesMock already initialized.
vi.mock('$lib/api/likes', () => emptyLikesMock());
import QueueTrackRow from './QueueTrackRow.svelte';
const playFromQueueIndex = vi.fn();
const removeFromQueue = vi.fn();
const moveQueueItem = vi.fn();
+144 -8
View File
@@ -1,23 +1,63 @@
<script lang="ts">
import { useQueryClient } from '@tanstack/svelte-query';
import { createSuggestionsQuery } from '$lib/api/suggestions';
import {
createSuggestionsQuery,
createSnoozesQuery,
snoozeSuggestion,
unsnoozeSuggestion
} from '$lib/api/suggestions';
import { createRequest } from '$lib/api/requests';
import { qk } from '$lib/api/queries';
import { pushToast } from '$lib/stores/toast.svelte';
import DiscoverResultCard from './DiscoverResultCard.svelte';
import type { ArtistSuggestion, SeedContribution } from '$lib/api/types';
import type { ArtistSuggestion, SeedContribution, SuggestionSnooze } from '$lib/api/types';
const client = useQueryClient();
const queryStore = createSuggestionsQuery();
const query = $derived($queryStore);
const suggestions = $derived((query.data ?? []) as ArtistSuggestion[]);
const snoozeStore = createSnoozesQuery();
const snoozeQuery = $derived($snoozeStore);
const snoozes = $derived((snoozeQuery.data ?? []) as SuggestionSnooze[]);
// Track MBIDs the user just requested so the card flips immediately.
let optimisticRequested = $state(new Set<string>());
// Snoozed-just-now MBIDs. These keep their card in place showing an Undo,
// rather than yanking it out of the grid under the cursor — the card is
// gone on the next refetch, and the snoozed list below is the way back
// after that.
let optimisticSnoozed = $state(new Set<string>());
function visible(s: ArtistSuggestion): boolean {
return !optimisticRequested.has(s.mbid);
}
function cardState(s: ArtistSuggestion): 'requestable' | 'snoozed' {
return optimisticSnoozed.has(s.mbid) ? 'snoozed' : 'requestable';
}
function withMbid(set: Set<string>, mbid: string, present: boolean): Set<string> {
const next = new Set(set);
if (present) next.add(mbid);
else next.delete(mbid);
return next;
}
// "Matches your taste in shoegaze and dream pop." — the reason line when the
// candidate's own tags overlap the taste profile (#2377). Preferred over seed
// attribution because it describes the MUSIC ("sounds like what you like")
// rather than the graph ("adjacent to something you played"), which is the
// whole point of slice 6. Falls back when there are no matched tags, which is
// the common case: coverage is partial by nature (#2376).
function reasonText(s: ArtistSuggestion): string {
const tags = s.matched_tags ?? [];
if (tags.length === 0) return attributionText(s.attribution);
if (tags.length === 1) return `Matches your taste in ${tags[0]}.`;
if (tags.length === 2) return `Matches your taste in ${tags[0]} and ${tags[1]}.`;
return `Matches your taste in ${tags[0]}, ${tags[1]}, and ${tags[2]}.`;
}
function attributionText(attribution: SeedContribution[]): string {
if (attribution.length === 0) return '';
const verb = (s: SeedContribution) => (s.is_liked ? 'liked' : 'played');
@@ -32,6 +72,24 @@
return `Because you ${phrases[0]}, ${phrases[1]}, and ${phrases[2]}.`;
}
// "in 3 months" / "in 12 days" — a relative phrase, because the exact
// calendar date of a 90-day snooze is noise the user never asked for.
function returnsIn(snoozedUntil: string): string {
const ms = new Date(snoozedUntil).getTime() - Date.now();
if (!Number.isFinite(ms) || ms <= 0) return 'shortly';
const days = Math.round(ms / 86_400_000);
if (days < 1) return 'today';
if (days === 1) return 'tomorrow';
// The 30 must not exceed the divisor below, or the singular "in about a
// month" is unreachable — a rounded month count of 1 needs 15..44 days,
// and any higher threshold sends all of those down the days branch. This
// read 45 and the singular case was dead code (caught by the Android
// unit test for the same logic).
if (days < 30) return `in ${days} days`;
const months = Math.round(days / 30);
return months === 1 ? 'in about a month' : `in about ${months} months`;
}
async function onRequest(s: ArtistSuggestion) {
try {
await createRequest({
@@ -39,9 +97,7 @@
lidarr_artist_mbid: s.mbid,
artist_name: s.name
});
const next = new Set(optimisticRequested);
next.add(s.mbid);
optimisticRequested = next;
optimisticRequested = withMbid(optimisticRequested, s.mbid, true);
// The server-side filter hides this candidate on next refetch.
await client.invalidateQueries({ queryKey: qk.suggestions() });
} catch {
@@ -49,6 +105,39 @@
// stays requestable so the user can retry.
}
}
async function onSnooze(s: ArtistSuggestion) {
// Flip first so the tap feels instant, then reconcile. On failure the
// card goes back to requestable and says so — a snooze that silently
// did nothing would leave the user tapping it again.
optimisticSnoozed = withMbid(optimisticSnoozed, s.mbid, true);
try {
await snoozeSuggestion(s.mbid, s.name);
await client.invalidateQueries({ queryKey: qk.suggestionSnoozes() });
} catch {
optimisticSnoozed = withMbid(optimisticSnoozed, s.mbid, false);
pushToast(`Couldn't hide ${s.name}`, 'error');
}
}
async function onUnsnooze(mbid: string, name: string) {
optimisticSnoozed = withMbid(optimisticSnoozed, mbid, false);
try {
await unsnoozeSuggestion(mbid);
} catch (e) {
// 404 means it wasn't snoozed after all — the user's intended end
// state, so it isn't an error worth showing them.
if ((e as { status?: number })?.status !== 404) {
optimisticSnoozed = withMbid(optimisticSnoozed, mbid, true);
pushToast(`Couldn't bring ${name} back`, 'error');
return;
}
}
await Promise.all([
client.invalidateQueries({ queryKey: qk.suggestionSnoozes() }),
client.invalidateQueries({ queryKey: qk.suggestions() })
]);
}
</script>
<div>
@@ -58,7 +147,16 @@
</header>
{#if !query.isPending && suggestions.length === 0}
<p class="text-text-secondary">Listen to something or like an artist to start getting suggestions.</p>
<!--
An empty deck used to mean one thing — no listening signal yet. With
snoozing it can also mean "you parked them all", and telling that user to
go listen to something would be wrong advice.
-->
<p class="text-text-secondary">
{snoozes.length === 0
? 'Listen to something or like an artist to start getting suggestions.'
: "Nothing new right now — the artists you've parked are below."}
</p>
{:else if suggestions.length > 0}
<div class="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5">
{#each suggestions.filter(visible) as s (s.mbid)}
@@ -66,11 +164,49 @@
kind="artist"
title={s.name}
imageUrl={s.image_url}
state="requestable"
attribution={attributionText(s.attribution)}
state={cardState(s)}
attribution={reasonText(s)}
onRequest={() => onRequest(s)}
onSnooze={() => onSnooze(s)}
onUnsnooze={() => onUnsnooze(s.mbid, s.name)}
/>
{/each}
</div>
{/if}
<!--
The snoozed list is not a nicety: a parked suggestion is by definition
absent from the deck above, so without this there is no way back. Only
rendered when non-empty, so the surface stays quiet for the common case.
-->
{#if snoozes.length > 0}
<section class="mt-8 border-t border-border pt-6" aria-labelledby="snoozed-heading">
<h3 id="snoozed-heading" class="font-display text-lg font-medium text-text-primary">
Not right now
</h3>
<p class="mt-1 text-sm text-text-secondary">
These come back on their own. Nothing here counts against your taste profile.
</p>
<ul class="mt-3 divide-y divide-border">
{#each snoozes as snoozed (snoozed.mbid)}
<li class="flex items-center justify-between gap-4 py-2">
<div class="min-w-0">
<div class="truncate text-sm text-text-primary">{snoozed.name}</div>
<div class="text-xs text-text-secondary">
Back {returnsIn(snoozed.snoozed_until)}
</div>
</div>
<button
type="button"
aria-label={`Bring ${snoozed.name} back now`}
class="shrink-0 rounded-md border border-border px-3 py-1.5 text-sm text-text-primary hover:bg-surface-hover focus:outline-none focus:ring-2 focus:ring-accent"
onclick={() => onUnsnooze(snoozed.mbid, snoozed.name)}
>
Bring back
</button>
</li>
{/each}
</ul>
</section>
{/if}
</div>
+203 -20
View File
@@ -1,4 +1,4 @@
import { afterEach, describe, expect, test, vi } from 'vitest';
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/svelte';
import { mockQuery } from '../../test-utils/query';
@@ -9,17 +9,30 @@ vi.mock('@tanstack/svelte-query', async (orig) => {
});
vi.mock('$lib/api/suggestions', () => ({
createSuggestionsQuery: vi.fn()
createSuggestionsQuery: vi.fn(),
createSnoozesQuery: vi.fn(),
snoozeSuggestion: vi.fn().mockResolvedValue(undefined),
unsnoozeSuggestion: vi.fn().mockResolvedValue(undefined)
}));
vi.mock('$lib/api/requests', () => ({
createRequest: vi.fn().mockResolvedValue({})
}));
const pushToastMock = vi.fn();
vi.mock('$lib/stores/toast.svelte', () => ({
pushToast: (...args: unknown[]) => pushToastMock(...args)
}));
import SuggestionFeed from './SuggestionFeed.svelte';
import { createSuggestionsQuery } from '$lib/api/suggestions';
import {
createSuggestionsQuery,
createSnoozesQuery,
snoozeSuggestion,
unsnoozeSuggestion
} from '$lib/api/suggestions';
import { createRequest } from '$lib/api/requests';
import type { ArtistSuggestion } from '$lib/api/types';
import type { ArtistSuggestion, SuggestionSnooze } from '$lib/api/types';
const oneSeed: ArtistSuggestion = {
mbid: 'mb1',
@@ -51,46 +64,50 @@ const threeSeeds: ArtistSuggestion = {
]
};
/** Days from now as an RFC3339 string, for snooze fixtures. */
function inDays(n: number): string {
return new Date(Date.now() + n * 86_400_000).toISOString();
}
function setSuggestions(data: ArtistSuggestion[]) {
(createSuggestionsQuery as ReturnType<typeof vi.fn>).mockReturnValue(mockQuery({ data }));
}
function setSnoozes(data: SuggestionSnooze[]) {
(createSnoozesQuery as ReturnType<typeof vi.fn>).mockReturnValue(mockQuery({ data }));
}
beforeEach(() => setSnoozes([]));
afterEach(() => vi.clearAllMocks());
describe('SuggestionFeed', () => {
test('renders one card per suggestion', () => {
(createSuggestionsQuery as ReturnType<typeof vi.fn>).mockReturnValue(
mockQuery({ data: [oneSeed, twoSeeds] })
);
setSuggestions([oneSeed, twoSeeds]);
render(SuggestionFeed);
expect(screen.getByText('Outsider')).toBeInTheDocument();
expect(screen.getByText('Outsider Two')).toBeInTheDocument();
});
test('attribution copy: 1 seed → "Because you liked X."', () => {
(createSuggestionsQuery as ReturnType<typeof vi.fn>).mockReturnValue(
mockQuery({ data: [oneSeed] })
);
setSuggestions([oneSeed]);
render(SuggestionFeed);
expect(screen.getByText(/because you liked seed\./i)).toBeInTheDocument();
});
test('attribution copy: 2 seeds → "Because you liked A and played B."', () => {
(createSuggestionsQuery as ReturnType<typeof vi.fn>).mockReturnValue(
mockQuery({ data: [twoSeeds] })
);
setSuggestions([twoSeeds]);
render(SuggestionFeed);
expect(screen.getByText(/because you liked a and played b\./i)).toBeInTheDocument();
});
test('attribution copy: 3 seeds → Oxford comma', () => {
(createSuggestionsQuery as ReturnType<typeof vi.fn>).mockReturnValue(
mockQuery({ data: [threeSeeds] })
);
setSuggestions([threeSeeds]);
render(SuggestionFeed);
expect(screen.getByText(/because you liked x, played y, and played z\./i)).toBeInTheDocument();
});
test('Request button calls createRequest with artist-kind body', async () => {
(createSuggestionsQuery as ReturnType<typeof vi.fn>).mockReturnValue(
mockQuery({ data: [oneSeed] })
);
setSuggestions([oneSeed]);
render(SuggestionFeed);
await fireEvent.click(screen.getByRole('button', { name: /request outsider/i }));
expect(createRequest).toHaveBeenCalledWith({
@@ -102,8 +119,174 @@ describe('SuggestionFeed', () => {
});
test('empty state when data is []', () => {
(createSuggestionsQuery as ReturnType<typeof vi.fn>).mockReturnValue(mockQuery({ data: [] }));
setSuggestions([]);
render(SuggestionFeed);
expect(screen.getByText(/listen to something or like an artist/i)).toBeInTheDocument();
});
});
describe('SuggestionFeed snooze (#2375)', () => {
test('snooze sends BOTH mbid and name — the server 400s without the name', async () => {
setSuggestions([oneSeed]);
render(SuggestionFeed);
await fireEvent.click(screen.getByRole('button', { name: /not right now/i }));
expect(snoozeSuggestion).toHaveBeenCalledWith('mb1', 'Outsider');
});
test('the card stays in place showing Undo, rather than vanishing', async () => {
setSuggestions([oneSeed]);
render(SuggestionFeed);
await fireEvent.click(screen.getByRole('button', { name: /not right now/i }));
// Still on screen — the disappearance happens on refetch, not under the
// cursor (rule #24).
expect(screen.getByText('Outsider')).toBeInTheDocument();
expect(screen.getByRole('button', { name: /bring outsider back/i })).toBeInTheDocument();
expect(screen.getByRole('status')).toHaveTextContent('Not right now');
});
test('a failed snooze reverts the card and says so', async () => {
(snoozeSuggestion as ReturnType<typeof vi.fn>).mockRejectedValueOnce(new Error('offline'));
setSuggestions([oneSeed]);
render(SuggestionFeed);
await fireEvent.click(screen.getByRole('button', { name: /not right now/i }));
// Back to requestable — a snooze that silently did nothing would leave
// the user tapping it again.
expect(screen.getByRole('button', { name: /request outsider/i })).toBeInTheDocument();
expect(pushToastMock).toHaveBeenCalledWith("Couldn't hide Outsider", 'error');
});
test('undo on the card calls unsnoozeSuggestion', async () => {
setSuggestions([oneSeed]);
render(SuggestionFeed);
await fireEvent.click(screen.getByRole('button', { name: /not right now/i }));
await fireEvent.click(screen.getByRole('button', { name: /bring outsider back/i }));
expect(unsnoozeSuggestion).toHaveBeenCalledWith('mb1');
});
test('the snoozed list is the way back once the card is gone', async () => {
// Deck empty, one parked artist: exactly the state after a refetch.
setSuggestions([]);
setSnoozes([
{ mbid: 'mbX', name: 'Parked', snoozed_until: inDays(90), created_at: inDays(0) }
]);
render(SuggestionFeed);
expect(screen.getByRole('heading', { name: /not right now/i })).toBeInTheDocument();
expect(screen.getByText('Parked')).toBeInTheDocument();
await fireEvent.click(screen.getByRole('button', { name: /bring parked back now/i }));
expect(unsnoozeSuggestion).toHaveBeenCalledWith('mbX');
});
test('a 404 from unsnooze is not surfaced as an error', async () => {
(unsnoozeSuggestion as ReturnType<typeof vi.fn>).mockRejectedValueOnce({ status: 404 });
setSuggestions([]);
setSnoozes([
{ mbid: 'mbX', name: 'Parked', snoozed_until: inDays(90), created_at: inDays(0) }
]);
render(SuggestionFeed);
await fireEvent.click(screen.getByRole('button', { name: /bring parked back now/i }));
// Already-unsnoozed IS the end state the user asked for.
expect(pushToastMock).not.toHaveBeenCalled();
});
test('a non-404 unsnooze failure does surface', async () => {
(unsnoozeSuggestion as ReturnType<typeof vi.fn>).mockRejectedValueOnce({ status: 500 });
setSuggestions([]);
setSnoozes([
{ mbid: 'mbX', name: 'Parked', snoozed_until: inDays(90), created_at: inDays(0) }
]);
render(SuggestionFeed);
await fireEvent.click(screen.getByRole('button', { name: /bring parked back now/i }));
expect(pushToastMock).toHaveBeenCalledWith("Couldn't bring Parked back", 'error');
});
test('return time reads as a relative phrase, not a calendar date', () => {
setSuggestions([]);
setSnoozes([
{ mbid: 'a', name: 'Quarter', snoozed_until: inDays(90), created_at: inDays(0) },
{ mbid: 'b', name: 'Fortnight', snoozed_until: inDays(14), created_at: inDays(0) }
]);
render(SuggestionFeed);
expect(screen.getByText(/back in about 3 months/i)).toBeInTheDocument();
expect(screen.getByText(/back in 14 days/i)).toBeInTheDocument();
});
// Pins the days→months seam. The singular branch was originally dead code
// here too: the threshold (45) sat above the divisor (30), so no day count
// could round to one month without hitting the days branch first. Kept in
// lockstep with SuggestionSnoozeRefTest on Android.
test('the days-to-months boundary sits at 30 days, so "a month" is reachable', () => {
setSuggestions([]);
setSnoozes([
{ mbid: 'a', name: 'JustUnder', snoozed_until: inDays(29), created_at: inDays(0) },
{ mbid: 'b', name: 'JustOver', snoozed_until: inDays(30), created_at: inDays(0) }
]);
render(SuggestionFeed);
expect(screen.getByText(/back in 29 days/i)).toBeInTheDocument();
expect(screen.getByText(/back in about a month/i)).toBeInTheDocument();
});
test('no snoozed section when nothing is parked', () => {
setSuggestions([oneSeed]);
setSnoozes([]);
render(SuggestionFeed);
expect(screen.queryByRole('heading', { name: /not right now/i })).not.toBeInTheDocument();
});
// An empty deck has two causes now, and the advice differs. Telling someone
// who parked everything to go listen to music would be wrong.
test('empty-deck copy distinguishes "no signal" from "you parked them all"', () => {
setSuggestions([]);
setSnoozes([
{ mbid: 'a', name: 'Parked', snoozed_until: inDays(90), created_at: inDays(0) }
]);
render(SuggestionFeed);
expect(screen.getByText(/nothing new right now/i)).toBeInTheDocument();
expect(screen.queryByText(/listen to something or like an artist/i)).not.toBeInTheDocument();
});
});
describe('SuggestionFeed taste-tag reason (#2377)', () => {
const tagged = (over: Partial<ArtistSuggestion> = {}): ArtistSuggestion => ({
...oneSeed,
mbid: 'mbT',
name: 'Tag Match',
...over
});
test('matched tags replace seed attribution as the reason line', () => {
setSuggestions([tagged({ matched_tags: ['shoegaze', 'dream pop'] })]);
render(SuggestionFeed);
expect(
screen.getByText(/matches your taste in shoegaze and dream pop\./i)
).toBeInTheDocument();
// The graph-adjacency line is superseded — describing the music beats
// describing the graph when we can do both.
expect(screen.queryByText(/because you liked/i)).not.toBeInTheDocument();
});
test('one matched tag reads in the singular', () => {
setSuggestions([tagged({ matched_tags: ['shoegaze'] })]);
render(SuggestionFeed);
expect(screen.getByText(/matches your taste in shoegaze\./i)).toBeInTheDocument();
});
test('three matched tags use an Oxford comma, matching the seed copy', () => {
setSuggestions([tagged({ matched_tags: ['a', 'b', 'c'] })]);
render(SuggestionFeed);
expect(screen.getByText(/matches your taste in a, b, and c\./i)).toBeInTheDocument();
});
// The common case: most candidates have no cached tags, and the card must
// still explain itself rather than going blank.
test('no matched tags falls back to seed attribution', () => {
setSuggestions([oneSeed]);
render(SuggestionFeed);
expect(screen.getByText(/because you liked seed\./i)).toBeInTheDocument();
});
test('an empty matched_tags array also falls back, not to an empty line', () => {
setSuggestions([tagged({ matched_tags: [] })]);
render(SuggestionFeed);
expect(screen.getByText(/because you liked seed\./i)).toBeInTheDocument();
});
});
+15
View File
@@ -505,6 +505,21 @@ export function removeFromQueue(idx: number): void {
_error = null;
}
// Clear the whole queue and stop playback — mirrors removeFromQueue's
// empty-queue branch. Also drops the radio/system source + self-heal closure
// so the emptied player doesn't try to refill from a now-irrelevant source.
export function clearQueue(): void {
_queue = [];
_index = 0;
_state = 'idle';
_position = 0;
_duration = 0;
_error = null;
_radioSeedId = null;
_queueSource = null;
_queueRefetch = null;
}
export function playFromQueueIndex(idx: number): void {
if (idx < 0 || idx >= _queue.length) return;
_radioSeedId = null;
+7 -2
View File
@@ -168,9 +168,14 @@
style="display: none"
></audio>
<QueueDrawer />
<QueryClientProvider client={queryClient}>
<!-- QueueDrawer must be inside the provider: its rows render LikeButton,
which calls useQueryClient() at init. The drawer's <aside> is always
mounted, so the moment the queue is populated (on first play) those
LikeButtons instantiate — outside the provider they throw
"No QueryClient was found" and abort the play flush. -->
<QueueDrawer />
{#if user.value !== null && page.url.pathname !== '/login' && page.url.pathname !== '/now-playing'}
<Shell>{@render children()}</Shell>
{:else}
+79 -2
View File
@@ -9,6 +9,7 @@
type TuningSnapshot,
type WeightProfile,
type TasteTuning,
type DiscoverTuning,
type TrendsResponse,
type TrendSeries,
type TrendMarker
@@ -43,6 +44,11 @@
{ key: 'mood_scale', label: 'Mood weight', hint: 'How strongly a mood-tagged play imprints on the mood facet (from folksonomy tags), in [0, 1]. 0 = mood ignored.' }
];
const discoverFields: { key: keyof DiscoverTuning; label: string; hint: string }[] = [
{ key: 'tag_overlap_weight', label: 'Taste-tag weight', hint: "How strongly a candidate's tags matching your taste boosts it. score x (1 + w x overlap), so 0 turns the tag term off and ranks on similarity alone. An artist with no cached tags is never penalised." },
{ key: 'snooze_days', label: 'Snooze length (days)', hint: 'How long "not right now" parks a suggestion before it returns on its own. Records no opinion about the artist and never feeds the taste profile.' }
];
const profileScopes: { scope: 'radio' | 'daily_mix'; label: string; blurb: string }[] = [
{ scope: 'radio', label: 'Radio', blurb: 'Seed-directed listening — the user picked a direction.' },
{ scope: 'daily_mix', label: 'Daily mixes', blurb: 'For You, Songs like…, and the discovery mixes.' }
@@ -55,11 +61,17 @@
let saving = $state<TuningScope | null>(null);
function fillForm(snap: TuningSnapshot) {
const f: Record<string, Record<string, string>> = { radio: {}, daily_mix: {}, taste: {} };
const f: Record<string, Record<string, string>> = {
radio: {},
daily_mix: {},
taste: {},
discover: {}
};
for (const p of ['radio', 'daily_mix'] as const) {
for (const { key } of weightFields) f[p][key] = String(snap.profiles[p][key]);
}
for (const { key } of tasteFields) f.taste[key] = String(snap.taste[key]);
for (const { key } of discoverFields) f.discover[key] = String(snap.discover[key]);
form = f;
}
@@ -76,11 +88,17 @@
// A knob deviates when its CURRENT SAVED value differs from shipped;
// the dot marks where this install has drifted from defaults.
function deviates(scope: 'radio' | 'daily_mix' | 'taste', key: string): boolean {
function deviates(scope: TuningScope, key: string): boolean {
if (!snapshot) return false;
if (scope === 'taste') {
return snapshot.taste[key as keyof TasteTuning] !== snapshot.shipped.taste[key as keyof TasteTuning];
}
if (scope === 'discover') {
return (
snapshot.discover[key as keyof DiscoverTuning] !==
snapshot.shipped.discover[key as keyof DiscoverTuning]
);
}
return (
snapshot.profiles[scope][key as keyof WeightProfile] !==
snapshot.shipped.profiles[scope][key as keyof WeightProfile]
@@ -90,6 +108,7 @@
function currentValue(scope: TuningScope, key: string): number {
if (!snapshot) return 0;
if (scope === 'taste') return snapshot.taste[key as keyof TasteTuning];
if (scope === 'discover') return snapshot.discover[key as keyof DiscoverTuning];
return snapshot.profiles[scope][key as keyof WeightProfile];
}
@@ -276,6 +295,7 @@
type="button"
disabled={saving !== null}
onclick={() => reset(p.scope)}
aria-label="Reset {p.label} to defaults"
class="rounded border border-border px-3 py-1.5 text-sm text-text-secondary hover:text-text-primary disabled:opacity-50"
>
Reset to defaults
@@ -328,6 +348,63 @@
type="button"
disabled={saving !== null}
onclick={() => reset('taste')}
aria-label="Reset taste profile build to defaults"
class="rounded border border-border px-3 py-1.5 text-sm text-text-secondary hover:text-text-primary disabled:opacity-50"
>
Reset to defaults
</button>
</div>
</section>
<!-- Discover request surface (#2377). Its own scope, not part of taste:
the snooze length lives here, and a snooze deliberately carries no
taste signal (#2374). -->
<section class="space-y-3 rounded border border-border bg-surface p-4 lg:max-w-xl">
<div>
<h2 class="text-lg font-semibold">Discover requests</h2>
<p class="text-xs text-text-secondary">
How the Discover suggestion deck ranks out-of-library artists. Tag coverage for
artists you don't own is partial by nature, so an untagged candidate keeps its
similarity score rather than being pushed down.
</p>
</div>
<div class="space-y-2">
{#each discoverFields as f (f.key)}
<div class="grid grid-cols-[1fr_7rem] items-center gap-2">
<label class="text-sm" for="discover-{f.key}" title={f.hint}>
{f.label}
{#if deviates('discover', f.key)}
<span
class="ml-1 inline-block h-1.5 w-1.5 rounded-full bg-accent align-middle"
title="Deviates from the shipped default ({snapshot.shipped.discover[f.key]})"
></span>
{/if}
</label>
<input
id="discover-{f.key}"
type="number"
step={f.key === 'snooze_days' ? '1' : '0.1'}
min="0"
bind:value={form.discover[f.key]}
class="w-full rounded border border-border bg-background px-2 py-1 text-right text-sm tabular-nums outline-none focus:border-accent"
/>
</div>
{/each}
</div>
<div class="flex gap-2">
<button
type="button"
disabled={saving !== null}
onclick={() => save('discover')}
class="rounded bg-accent px-3 py-1.5 text-sm text-white disabled:opacity-50"
>
Save discover
</button>
<button
type="button"
disabled={saving !== null}
onclick={() => reset('discover')}
aria-label="Reset discover requests to defaults"
class="rounded border border-border px-3 py-1.5 text-sm text-text-secondary hover:text-text-primary disabled:opacity-50"
>
Reset to defaults
+77 -3
View File
@@ -43,13 +43,25 @@ const taste = (over: Partial<Record<string, number>> = {}) => ({
...over
});
const discover = (over: Partial<Record<string, number>> = {}) => ({
tag_overlap_weight: 1,
snooze_days: 90,
...over
});
// NOTE the `as TuningSnapshot` cast below: it silences the type error when the
// server snapshot grows a field, so a missing key here surfaces as every test
// on this page throwing inside fillForm rather than as a compile failure. Add
// new scopes to BOTH this fixture and `shipped`.
function snapshot(over: Partial<TuningSnapshot> = {}): TuningSnapshot {
return {
profiles: { radio: weights({ taste_weight: 1 }), daily_mix: weights() },
taste: taste(),
discover: discover(),
shipped: {
profiles: { radio: weights({ taste_weight: 1 }), daily_mix: weights() },
taste: taste()
taste: taste(),
discover: discover()
},
...over
} as TuningSnapshot;
@@ -99,13 +111,17 @@ describe('Admin tuning page', () => {
await waitFor(() => expect(pushToast).toHaveBeenCalledWith('Nothing changed.'));
});
// Targets the button by its accessible name, not its position. This used to
// click the LAST reset button and assume that meant taste — which silently
// became the wrong scope the moment a card was added below it.
test('reset calls resetTuning for the scope', async () => {
(getTuning as ReturnType<typeof vi.fn>).mockResolvedValue(snapshot());
(resetTuning as ReturnType<typeof vi.fn>).mockResolvedValue(snapshot());
render(TuningPage);
await waitFor(() => expect(screen.getByText('Taste profile build')).toBeInTheDocument());
const resetButtons = screen.getAllByRole('button', { name: /reset to defaults/i });
await fireEvent.click(resetButtons[resetButtons.length - 1]);
await fireEvent.click(
screen.getByRole('button', { name: /reset taste profile build to defaults/i })
);
await waitFor(() => expect(resetTuning).toHaveBeenCalledWith('taste'));
});
@@ -198,4 +214,62 @@ describe('Admin tuning page', () => {
expect(screen.getByText(/trends appear once listening accumulates/i)).toBeInTheDocument()
);
});
// --- Discover scope (#2377) ---
test('renders the Discover card with its two knobs', async () => {
(getTuning as ReturnType<typeof vi.fn>).mockResolvedValue(snapshot());
render(TuningPage);
await waitFor(() => expect(screen.getByText('Discover requests')).toBeInTheDocument());
expect(screen.getByLabelText(/taste-tag weight/i)).toBeInTheDocument();
expect(screen.getByLabelText(/snooze length/i)).toBeInTheDocument();
});
test('saving Discover patches only the changed field under the discover scope', async () => {
(getTuning as ReturnType<typeof vi.fn>).mockResolvedValue(snapshot());
(patchTuning as ReturnType<typeof vi.fn>).mockResolvedValue(snapshot());
render(TuningPage);
await waitFor(() => expect(screen.getByText('Discover requests')).toBeInTheDocument());
await fireEvent.input(screen.getByLabelText(/taste-tag weight/i), {
target: { value: '2.5' }
});
await fireEvent.click(screen.getByRole('button', { name: /save discover/i }));
await waitFor(() =>
expect(patchTuning).toHaveBeenCalledWith('discover', { tag_overlap_weight: 2.5 })
);
});
// 0 is the operator's off switch for the whole tag term, so the form must be
// able to send it — a falsy-value bug here would silently make the feature
// impossible to disable.
test('a Discover weight of 0 is sent, not dropped as falsy', async () => {
(getTuning as ReturnType<typeof vi.fn>).mockResolvedValue(snapshot());
(patchTuning as ReturnType<typeof vi.fn>).mockResolvedValue(snapshot());
render(TuningPage);
await waitFor(() => expect(screen.getByText('Discover requests')).toBeInTheDocument());
await fireEvent.input(screen.getByLabelText(/taste-tag weight/i), { target: { value: '0' } });
await fireEvent.click(screen.getByRole('button', { name: /save discover/i }));
await waitFor(() =>
expect(patchTuning).toHaveBeenCalledWith('discover', { tag_overlap_weight: 0 })
);
});
test('reset targets the discover scope', async () => {
(getTuning as ReturnType<typeof vi.fn>).mockResolvedValue(snapshot());
(resetTuning as ReturnType<typeof vi.fn>).mockResolvedValue(snapshot());
render(TuningPage);
await waitFor(() => expect(screen.getByText('Discover requests')).toBeInTheDocument());
await fireEvent.click(
screen.getByRole('button', { name: /reset discover requests to defaults/i })
);
await waitFor(() => expect(resetTuning).toHaveBeenCalledWith('discover'));
});
test('a deviating Discover knob is dotted', async () => {
const snap = snapshot();
snap.discover = discover({ tag_overlap_weight: 3 });
(getTuning as ReturnType<typeof vi.fn>).mockResolvedValue(snap);
render(TuningPage);
await waitFor(() => expect(screen.getByText('Discover requests')).toBeInTheDocument());
expect(screen.getByTitle(/deviates from the shipped default \(1\)/i)).toBeInTheDocument();
});
});
+14 -2
View File
@@ -21,8 +21,16 @@ vi.mock('$lib/api/lidarr', () => ({
createLidarrSearchQuery: vi.fn()
}));
// SuggestionFeed reaches for the snooze surface too (#2375). These are
// stubbed here even though this page-level suite asserts nothing about
// snoozing: a factory-shaped module mock must export everything the
// component tree imports, or rendering the feed throws before any
// assertion runs.
vi.mock('$lib/api/suggestions', () => ({
createSuggestionsQuery: vi.fn()
createSuggestionsQuery: vi.fn(),
createSnoozesQuery: vi.fn(),
snoozeSuggestion: vi.fn().mockResolvedValue(undefined),
unsnoozeSuggestion: vi.fn().mockResolvedValue(undefined)
}));
vi.mock('$lib/api/requests', () => ({
@@ -42,11 +50,12 @@ vi.mock('@tanstack/svelte-query', async (importOriginal) => {
import DiscoverPage from './+page.svelte';
import { createLidarrSearchQuery } from '$lib/api/lidarr';
import { createSuggestionsQuery } from '$lib/api/suggestions';
import { createSuggestionsQuery, createSnoozesQuery } from '$lib/api/suggestions';
import { createRequest } from '$lib/api/requests';
const mockedCreateQuery = createLidarrSearchQuery as ReturnType<typeof vi.fn>;
const mockedCreateSuggestionsQuery = createSuggestionsQuery as ReturnType<typeof vi.fn>;
const mockedCreateSnoozesQuery = createSnoozesQuery as ReturnType<typeof vi.fn>;
const mockedCreateRequest = createRequest as ReturnType<typeof vi.fn>;
function result(over: Partial<LidarrSearchResult> = {}): LidarrSearchResult {
@@ -69,6 +78,9 @@ beforeEach(() => {
// Default: empty suggestion feed so its empty-state copy renders without
// interfering with search-mode tests.
mockedCreateSuggestionsQuery.mockReturnValue(mockQuery({ data: [] }));
// Nothing parked, which keeps the feed's empty-state copy on the
// "no signal yet" branch that this suite's assertions expect.
mockedCreateSnoozesQuery.mockReturnValue(mockQuery({ data: [] }));
});
afterEach(() => {
+8
View File
@@ -37,6 +37,14 @@ if (typeof window !== 'undefined') {
Object.defineProperty(window, 'sessionStorage', { configurable: true, value: memSession });
}
// jsdom doesn't implement Element.prototype.scrollIntoView. Components that
// call it (queue auto-scroll to the now-playing row, the alphabetical rail)
// would throw an unhandled TypeError in tests — which fails the run even when
// every assertion passes. No-op it; tests never assert on scroll position.
if (typeof Element !== 'undefined' && !Element.prototype.scrollIntoView) {
Element.prototype.scrollIntoView = () => {};
}
// W-T3 moved toast rendering out of per-page markup into a single
// <ToastHost /> mounted in +layout.svelte. Tests render individual pages
// without the layout, so we mount ToastHost here so `pushToast()` calls