Compare commits

...
81 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
bvandeusen 4f69c230c4 Merge pull request 'Taste-profile fidelity (M160) + Songs-like row + home polish' (#109) from dev into main
test-go / test (push) Successful in 44s
test-web / test (push) Successful in 49s
test-go / integration (push) Successful in 5m1s
android / Build + lint + test (push) Successful in 5m5s
release / Build signed APK (tag releases only) (push) Successful in 4m9s
release / Build + push container image (push) Successful in 19s
2026-07-14 13:03:12 -04:00
bvandeusenandClaude Opus 4.8 5749f48b4a feat(taste): device-class context conditioning — #1551
test-go / test (push) Successful in 45s
test-web / test (push) Successful in 52s
android / Build + lint + test (push) Successful in 4m23s
test-go / integration (push) Successful in 5m5s
Milestone #160 Opt 3b. Adds device class as a third context axis on top
of the #1531 time-of-day/weekday affinity: on the radio path, a candidate
is boosted when its artist concentrates in the current (daypart × weekday
× device) cell. Client-sent (client_id is opaque; no UA stored), so it's
captured going forward and applies to radio only (daily mixes are
cron-built with no device → stay device-agnostic).

Server:
- Migration 0048: play_events.device_class text NULL (no CHECK; normalized
  in Go — one whitelist entry per new client class, not a migration).
- events.go: eventRequest.device_class + normalizeDeviceClass (whitelist →
  mobile/web/…, else "other", empty → NULL); threaded through both
  RecordPlayStartedWithSource and RecordOfflinePlay into InsertPlayEvent.
- ListArtistContextPlayCountsForUser gains a current-device param; the cell
  FILTER adds AND ($2='' OR device_class=$2) — '' reproduces the #1531
  time-only behaviour exactly (used by mixes). SessionVector.DeviceClass
  carries it; the radio handler derives the current device from the user's
  latest play (GetLatestPlayDeviceClassForUser) — request-free proxy.
- No new tuning knob: device narrows the existing ContextAffinityScore
  (reuses context_time_weight).

Clients:
- web: play_started sends device_class 'web'.
- android: play_started + offline replay send 'mobile' (EventsWire +
  PlayOfflinePayload + MutationReplayer + PlayEventsReporter).

Test: LoadContextAffinity device-narrowing integration test (mobile vs web
artist separation; device-agnostic parity).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 12:47:59 -04:00
bvandeusenandClaude Opus 4.8 f0c08e7326 feat(taste): mood taste facet — #1534
test-go / test (push) Successful in 34s
test-web / test (push) Successful in 40s
test-go / integration (push) Successful in 4m44s
Milestone #160 Opt 2b (mood half of the era+mood option). A fourth taste
facet alongside artists + genre tags + eras: signed weights over canonical
mood buckets (melancholic / energetic / chill / …) derived from a track's
enriched folksonomy tags (#1490).

- internal/mood: shared vocabulary — Of(tags) maps folksonomy tags to
  canonical mood buckets (synonyms collapse). Imported by both the taste
  builder and the scorer so a track's mood is derived identically.
- Migration 0047: taste_profile_moods table + taste_tuning.mood_scale
  (DEFAULT 0.5).
- Build side (internal/taste): Config.MoodScale ([0,1] damper, mirrors
  EraScale); accumulate folds each play/like's mood buckets at
  base*MoodScale; persist atomic-replaces the mood rows.
- Scorer (internal/recommendation): TasteProfile gains a mood term
  (own tanh scale + additive 0.12 share, so it never weakens the existing
  signal when a track has no mood tags). Match now takes the candidate's
  mood buckets; loaded per candidate (ListTrackTagsForTracks → mood.Of) in
  the primary similarity loader only — the near-whole-library fallback
  pool passes nil (mood → 0) to avoid a full-library tag scan.
- Tuning lab: mood_scale threaded through recsettings + admin API + web
  card ("Mood weight" row) + Go/web tests.

Coverage is partial (grows with tag enrichment; richer once Last.fm is
keyed), so mood is a supplement — neutral for tracks with no mood tags.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 10:32:41 -04:00
bvandeusenandClaude Opus 4.8 199fec2058 feat(taste): household co-play similarity — #1533
test-go / test (push) Successful in 29s
test-go / integration (push) Successful in 4m48s
Milestone #160 Opt 5. A collaborative candidate arm: tracks by artists
co-played across the instance with the seed's artist.

Minstrel is a single shared-library, multi-user server (no per-user
library ACL — verified: no owner/share/group model), so the "household"
is the whole instance's user set; the rule #47 scoping is satisfied by
the shared-library boundary. Single-user servers produce no edges.

- No migration: source='user_cooccurrence' was pre-whitelisted in the
  0009 similarity CHECK from day one.
- internal/db/queries/coplay.sql: Delete + Insert artist co-play edges.
  Score = Jaccard of the two artists' distinct-player sets (controls for
  globally-popular artists); >= 2 co-players AND Jaccard >= floor kept
  (the floor also self-limits hub artists). Completed plays, 365d window.
- internal/coplay: periodic worker (6h) that atomic-replaces the
  user_cooccurrence edge set from play_events — pure local SQL, no
  external calls. Wired in main.go alongside the similarity worker.
- LoadRadioCandidatesV2: new coplay_artists arm (source='user_cooccurrence',
  seed-artist based, 0.5 damp like similar_artists) + $11 limit;
  CandidateSourceLimits.UserCoplay (default 20, For-You 40).
- Integration tests: perfect-overlap Jaccard=1.0 edge + single-user
  empty-set gate.

Device axis and AcousticBrainz (Opt 4) are separately tracked; this
closes the milestone-#160 sequential options.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 10:07:19 -04:00
bvandeusenandClaude Opus 4.8 65dd132b3d feat(taste): time-of-day / weekday context conditioning — #1531
test-go / test (push) Successful in 35s
test-web / test (push) Successful in 42s
test-go / integration (push) Successful in 4m46s
Milestone #160 Opt 3 (temporal half). A new additive scoring term that
boosts a candidate when its artist's play history concentrates in the
CURRENT daypart × weekday-type cell, in the user's local timezone.

- Migration 0046: recommendation_weight_profiles.context_time_weight
  (per-profile scoring weight, DEFAULT 1.0).
- Query ListArtistContextPlayCountsForUser: per-artist completed-play
  counts split by the current cell (daypart night[22,5)/morning[5,12)/
  afternoon[12,17)/evening[17,22) × weekday-vs-weekend) via
  started_at AT TIME ZONE users.timezone; 365-day window, skips excluded.
- internal/recommendation/context.go: LoadContextAffinity computes each
  artist's shrunk cell-share minus the user's baseline share, clamped to
  [-1,1]; sparse artists shrink toward baseline (pseudo-count 5), unknown
  artists → 0 (cold-start neutral).
- Score() gains context_affinity_score · ContextTimeWeight; both
  candidate loaders set it per candidate.
- Tuning lab: ContextTimeWeight threaded through recsettings + admin API
  + web card ("Time-of-day weight" row) + Go/web tests. Shipped 1.0 both
  profiles (uniform start, re-bakeable).

Device-class axis deferred to #1551 (needs a client_id → device-class
mapping that doesn't exist yet).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 09:31:43 -04:00
bvandeusenandClaude Opus 4.8 40384cc05e feat(taste): era/decade taste facet — #1530
test-go / test (push) Successful in 34s
test-web / test (push) Successful in 41s
test-go / integration (push) Successful in 4m40s
Milestone #160 Opt 2 (era half). A third taste facet alongside artists
+ genre tags: signed weights over decade buckets ("1990s") derived from
albums.release_date, rebuilt daily and scored into the taste match.

- Migration 0045: taste_profile_eras table (mirrors taste_profile_tags)
  + taste_tuning.era_scale column (DEFAULT 0.5).
- Build side (internal/taste): Config.EraScale ([0,1] damper, mirrors
  EnrichedTagScale), accumulate folds each play/like's decade at
  base*EraScale, persist atomic-replaces the era rows.
- Scorer (internal/recommendation): TasteProfile gains an era term (own
  tanh scale + additive 0.15 share so it never weakens the existing
  artist/tag signal when a track is undated); candidate queries return
  album release_date; decadeOf mirrors the builder helper.
- Tuning lab: era_scale threaded through recsettings + admin API + web
  card (auto-renders the new row) + Go/web tests.

Mood facet deferred to #1534 (partial enrichment coverage + needs
candidate-side enriched-tag loading).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 09:01:00 -04:00
bvandeusenandClaude Opus 4.8 40056d2e9a feat(android): admin tag-enrichment sources screen (#1521)
android / Build + lint + test (push) Successful in 3m43s
Bring the tag-sources settings surface to the Android admin, over
/api/admin/tag-sources — the operator can enable/disable each provider,
paste an API key (e.g. Last.fm), and test the connection from the phone,
mirroring the web integrations card.

New vertical stack (mirrors the AdminUsers/AdminRequests pattern):
- AdminTagSourcesApi (Retrofit, api/admin/tag-sources GET/PATCH/{id}/test)
  + UpdateTagSourceBody
- AdminTagSourceWire / list envelope / TestTagSourceWire + domain
  AdminTagSourceRef / TagSourceTestResult
- AdminTagSourcesRepository (shared Retrofit, .toDomain() at bottom)
- AdminTagSourcesViewModel (@HiltViewModel, sealed UiState, optimistic
  toggle + key-save + per-row test result, network auto-recovery)
- AdminTagSourcesScreen (MinstrelTopAppBar + PullToRefreshScaffold; per
  provider: Switch, password key field + Save, Test connection + result)
- nav route + graph registration; AdminLanding gains a "Tag sources"
  section card (count = enabled providers).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 08:16:43 -04:00
bvandeusenandClaude Opus 4.8 2b3be8311a feat(tuning): expose EnrichedTagScale in the tuning lab (#1520)
test-go / test (push) Successful in 35s
test-web / test (push) Successful in 41s
test-go / integration (push) Successful in 4m45s
Promote the enriched-tag weight (#1490) from a taste.Config default into
the DB-backed tuning lab so operators can dial how much folksonomy tags
count vs raw ID3 genre (rule #25).

- Migration 0044: taste_tuning.enriched_tag_scale (DEFAULT 0.5, backfills
  the existing row).
- recsettings: TasteTuning gains the field; seeded/read/updated through
  reconcile + persistTaste; applyTastePatch validates it to [0,1]
  (generic non-half-life clamp) and diffTaste audits it; TasteConfig maps
  it into the profile build.
- API: tasteTuningResp exposes enriched_tag_scale.
- Web tuning card: a data-driven "Enriched tag weight" knob (0 = genre
  only). Tests: recsettings persist+range, web fixture field.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 07:51:36 -04:00
bvandeusenandClaude Opus 4.8 c30511e71b feat(tags): MusicBrainz artist-MBID tag fallback (#1519)
test-go / test (push) Successful in 30s
test-go / integration (push) Successful in 4m44s
Widen keyless coverage: when a track's recording is untagged or has no
recording MBID, fall back to the artist's tags (/ws/2/artist/{mbid}
?inc=tags), down-weighted 0.6 as a coarser signal. Recording tags still
win outright when present.

- ListTracksMissingTags returns a.mbid AS artist_mbid; TrackRef gains
  ArtistMBID; the enricher threads it through.
- MB provider: recording-first, artist-fallback via a shared
  fetchEntityTags helper. A transient error at the recording step is
  returned (retry) rather than masked by the fallback.

Enricher, registry, and settings are untouched — the pluggable design
absorbs the wider lookup. Tests cover fallback-when-untagged,
artist-only-when-no-recording-MBID, and recording-preferred.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 07:43:22 -04:00
bvandeusenandClaude Opus 4.8 797ed1f5ad feat(web): tag enrichment sources admin card (#1490 Step 4 web)
test-go / test (push) Successful in 34s
test-web / test (push) Successful in 40s
test-go / integration (push) Successful in 4m45s
Add a "Tag enrichment sources" card to the admin integrations page,
mirroring the cover-art providers card: per-provider enable toggle +
API-key field + Save + Test connection, over /api/admin/tag-sources.
Enabling/keying a source (e.g. pasting a Last.fm key) re-opens settled
tracks for re-enrichment via the version bump.

- admin.ts: TagProvider types + get/update/test functions +
  createTagProvidersQuery; qk.tagProviders key.
- integrations/+page.svelte: the card + local edit state, with
  MusicBrainz (keyless baseline) and Last.fm (needs a free key) notes.
- Tests: admin.tag-sources API test + integrations component tests
  (render / enable+key save / test connection), plus the mock plumbing
  the existing suite needs for the new query.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 22:33:21 -04:00
bvandeusenandClaude Opus 4.8 96c2eb6afb fix(dbtest): reset tag-sources settings between tests (#1490)
Add tag_provider_settings to the truncation list and reset
tag_sources_meta.current_version to 1 in ResetDB, mirroring the cover-art
settings reset. Without it the migration-seeded musicbrainz/lastfm rows
leaked across tests and desynced the enabled-set signature, so a key-only
PATCH spuriously reported version_bumped=true
(TestAdminUpdateTagSource_KeyOnlyDoesNotBump).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 22:32:51 -04:00
bvandeusenandClaude Opus 4.8 cce928e584 feat(api): admin tag-sources endpoints (#1490 Step 4 backend)
test-go / test (push) Successful in 29s
test-go / integration (push) Failing after 4m43s
Expose the tag-enrichment provider settings over the admin API, mirroring
the cover-sources surface so a Last.fm key can be pasted and sources
toggled from the web admin UI (rules #25/#27).

- GET  /api/admin/tag-sources                 — list providers + version
- PATCH/api/admin/tag-sources/{id}            — enable / set api_key
- POST /api/admin/tag-sources/{id}/test       — test connection
- POST /api/admin/tag-sources/research        — bump version, re-open
                                                 settled rows for re-enrich
- Thread tags.SettingsService through server.New (struct field, like
  RecSettings) → Router → api.Mount → handlers.tagSettings; main.go sets
  srv.TagSettings.

Handler tests mirror admin_cover_sources_test (list / flip-bumps-version /
key-only-no-bump / unknown-404 / non-admin-403 / not-testable-ok-false),
integration-tier (skip without MINSTREL_TEST_DATABASE_URL).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 22:24:28 -04:00
bvandeusenandClaude Opus 4.8 7d18a3c808 feat(taste): fold enriched folksonomy tags into the profile (#1490 Step 3)
test-go / test (push) Successful in 30s
test-go / integration (push) Successful in 4m43s
The taste recompute's tag facet now unions the cached track_tags
(MusicBrainz/Last.fm folksonomy tags) alongside raw ID3 genre, so a coarse
"Rock" gains "post-punk / shoegaze / melancholic".

- taste_profile.sql: ListPlayEngagementInputsForUser +
  ListLikedTrackTasteInputsForUser now return track_id to key the
  enriched-tag lookup.
- accumulate(): for each play, fold its track's enriched tags weighted by
  engagement × tag.weight × EnrichedTagScale; for each liked track, by the
  tag-like bonus × tag.weight × scale. A track with no cached tags
  contributes genre only (graceful).
- New Config.EnrichedTagScale (default 0.5) — enriched tags augment the
  ID3 signal without swamping it; 0 = genre-only. Flows through
  recsettings.TasteConfig() (starts from DefaultConfig). Promoting it into
  the admin tuning lab is a small follow-up.

Unit-tested the pure foldEnrichedTags helper (overlap accumulation +
scale=0 disable).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 22:09:31 -04:00
bvandeusenandClaude Opus 4.8 c34753f5b0 feat(tags): wire tag-enrichment worker at startup (#1490 wiring)
Construct the tag SettingsService + Enricher at boot (mirroring coverart:
reconcile providers, bump the sources version if the provider set changed
to re-open settled rows), then run a standalone background Worker that
drains tracks needing folksonomy tags on a periodic tick.

Standalone (not threaded through the file-scan chain like cover art)
because tag lookups need only DB fields — recording MBID / artist / title
— so it mirrors the ListenBrainz similarity worker instead: an initial
drain shortly after boot, then every 30 min, up to 200 tracks per tick.
MusicBrainz's 1 req/s ceiling is the real throttle.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 22:09:31 -04:00
bvandeusenandClaude Opus 4.8 fbfd5550ff feat(tags): folksonomy tag enricher — pluggable provider chain (#1490 Step 2)
test-go / test (push) Successful in 30s
test-go / integration (push) Successful in 4m46s
Milestone #160 Option 1, Step 2. New internal/tags package that enriches
the taste profile's tag facet beyond raw ID3 genre, built so adding a
source later is "implement TrackTagProvider + Register()" — no enricher,
settings, or schema change (operator directive).

Mirrors the internal/coverart pattern:
- Provider interface + package registry (Register/AllProviders/ByID);
  TrackTagProvider fetch capability + TestableProvider for the admin test.
- DB-backed SettingsService over new tag_provider_settings +
  tag_sources_meta (migration 0043) — enable/key/version, boot
  reconciliation, and a provider-hash bump that re-opens 'none' rows when
  the compiled-in provider set changes.
- Slim self-contained httpClient (rate-limit + retry + User-Agent), kept
  local so tag enrichment never depends on coverart internals.

Providers:
- MusicBrainz: keyless, default-ON, recording tags by MBID (rule #26 baseline).
- Last.fm: keyed, default-OFF, track.getTopTags by artist+track — opt-in
  once a key is supplied.

Enricher uses MERGE semantics (differs from coverart's first-success-wins
for a single image): unions tags across every enabled provider, caps to
top-K by weight, and stamps tag_source musicbrainz|lastfm|mixed|none.
Writes are transactional (atomic replace of track_tags).

Unit-tested without a DB: registry mechanics, provider JSON parsing +
weight normalization via httptest, and the pure merge/top-K/source-label
helpers. Wiring (startup + on-scan), integration tests, and the taste
union (Step 3) + Settings UI (Step 4) come next.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 21:43:58 -04:00
bvandeusenandClaude Opus 4.8 d6ee5a304d fix(home): un-clip CoverTile overlay so play button isn't cut off (#1495)
android / Build + lint + test (push) Successful in 3m58s
The play button overlaid on artist circles sat under the circular frame:
CoverTile clipped the Box that held both the artwork and the overlay, so
a BottomEnd button on a CircleShape avatar — which falls in the square's
corner, outside the circle — got clipped away.

Draw the overlay on an outer un-clipped Box; clip only the inner artwork
+ background to `shape`. Corner-anchored overlays (play button, variant
pill) now sit on top of the frame. Bounds/alignment unchanged, so Album
and Playlist tiles keep their layout (pill is padding-inset; their play
buttons are simply no longer clipped at the corner radius).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 20:56:07 -04:00
bvandeusenandClaude Opus 4.8 d497c57d6c fix(home/test): kotlin.test assertTrue message overload
android / Build + lint + test (push) Successful in 3m46s
The trailing-lambda assertTrue overload treats the block as the
*condition*, not a lazy message — switch to assertTrue(Boolean, String).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 20:49:10 -04:00
bvandeusenandClaude Opus 4.8 2e92ba498c fix(home): buildSongsLikeRow ReturnCount ≤ 2 (detekt)
android / Build + lint + test (push) Failing after 3m4s
Collapse the three early returns into a single `when` expression —
detekt's ReturnCount capped at 2. No behavior change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 20:45:05 -04:00
bvandeusenandClaude Opus 4.8 c1d143cf4a feat(home): Songs-like → dedicated row + wider spread (#1491)
test-go / test (push) Successful in 33s
test-web / test (push) Successful in 43s
android / Build + lint + test (push) Failing after 1m29s
test-go / integration (push) Successful in 4m42s
Promote the best-performing surface ("Songs like {artist}", ~8% skip /
~86% completion) out of the shared Playlists carousel into its own Home
row on both Android and web, and widen the daily build from 3 to 6 mixes
so the dedicated row shows a wider spread.

Server (internal/playlists):
- PickSeedArtists candidate pool 5 → 12; pickSeedArtistsForDay now takes
  songsLikeSeedCount (6) instead of a hardcoded 3. Graceful degradation
  and daily rotation preserved.

Android (HomeScreen.kt):
- New songsLikeSection + buildSongsLikeRow; PlaylistsRow takes a title so
  it renders both the "Playlists" and "Songs like…" rows. buildOnlineRow
  / orderedRealPlaylists no longer reserve the 3 songs-like slots.
  Offline shows cached mixes (available-first), hides the row when none.

Web (+page.svelte):
- Dedicated "Songs like…" row from songsLikeRow; dropped the 3-slot cap
  and removed songs-like from the Playlists carousel.

Tests: seed_selection_test.go, BuildPlaylistsRowTest.kt, page.test.ts.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 20:41:08 -04:00
bvandeusen cb0af5efd3 fix(dbq): commit the rest of the 0042 regen (Track model + embedders)
test-go / test (push) Successful in 30s
test-go / integration (push) Successful in 4m37s
The previous commit staged only track_tags.sql.go and left the rest of
the sqlc regen uncommitted, so HEAD referenced TrackTag / the new
tracks.tag_source columns without their definitions — a broken tree.

Adding tracks.tag_source + tag_sources_version to the tracks table
regenerated every generated file that returns/embeds the Track model
(models.go, tracks/events/history/likes/recommendation). Commit them all
together so dev HEAD compiles.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 18:47:34 -04:00
bvandeusen 20a76f4b39 feat(taste): track_tags schema + enrichment queries (Opt 1 foundation)
test-go / test (push) Failing after 10s
test-go / integration (push) Has been cancelled
First step of taste-profile fidelity via metadata enrichment (milestone
#160, task #1490) — no ML sidecar, operator's constraint.

The taste profile's tag facet is built purely from raw ID3 tracks.genre
(splitGenres in internal/taste/profile.go). This lands the data layer for
enriching it with track-level folksonomy tags:

- track_tags(track_id, tag, weight) — a global cache of style/mood tags,
  top-K per track, weight = normalized folksonomy strength [0,1].
- tracks.tag_source / tag_sources_version — versioned enrichment
  bookkeeping mirroring artists.artist_art_source (NULL = eligible,
  provider name = found, 'none' = settled, version bump = re-process).
- Queries: ListTracksMissingTags (batch drainer), DeleteTrackTags +
  InsertTrackTag (atomic per-track replace), SetTrackTagSource, and
  ListPlayed/LikedTrackTagsForUser for the recompute to union enriched
  tags into the tag facet alongside genre.

No consumer yet — the enricher (MusicBrainz + Last.fm providers,
track-level) and the taste-recompute integration land next.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 18:46:49 -04:00
bvandeusen 7226dab9ff feat(discover): taste-targeted novelty bucket + rebalance toward discovery
test-go / test (push) Successful in 30s
test-go / integration (push) Successful in 4m38s
The 2-week metrics review found Discover beating the manual baseline on
skip rate — which for a discovery surface means it plays it safe. On a
single-user server it's effectively dormant + crude-random, and the
per-user taste profile (taste_profile_tags, #796) went unused (#1254 gap).

Add a fourth Discover candidate bucket, ListTasteUnheardTracksForDiscover:
unheard / non-liked / non-quarantined tracks ranked by summed taste-tag
weight over tracks.genre (split like the radio tag_overlap arm), md5
tiebreak. Its picks are stamped pick_kind = 'taste_unheard' (migration
0041 widens the CHECK on playlist_tracks + play_events, rule #36).

Rebalance the slot allocation 40/30/30 → taste_unheard 35 / dormant 30 /
cross_user 20 / random 15, and lead the interleave with taste_unheard so
a track shared with another bucket keeps the taste stamp and the
targeted-novelty arm stays measurable. Metrics label "Taste-matched" +
order entry added to the single server-side pickKindLabels map, so web
and Android surface the new breakdown row with no client change.

Cold start (empty taste_profile_tags) yields an empty taste bucket that
redistributes to the survivors, so Discover still fills.

Scribe #1488. Companion review outcomes: Songs-like starvation already
fixed (#1255); For You v2 ratified as-is (fresh-injection cost disproven).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 18:16:04 -04:00
bvandeusenandClaude Opus 4.8 772a52b23e feat(home): "Updating your mixes…" veil over daily-rebuild churn
android / Build + lint + test (push) Successful in 3m58s
The 03:00 system-playlist rebuild (playlist.system_rebuilt) re-pulls
Home, and refreshIndex() rewrites every section delete-then-insert — so
all 7 rows + the Playlists row visibly collapse to empty, refill with
skeletons, then pop in per-tile as metadata hydrates. Read as a lot of
busy on-screen movement.

Raise an "Updating your mixes…" veil for automatic refreshes only (daily
rebuild + reconnect re-pull): HomeViewModel.isUpdating, driven by a new
refreshBehindVeil() the event/recovery collectors call in place of
refresh(). It holds through the pull plus a short settle so hydration
lands behind the veil, then wipes off. Manual pull-to-refresh keeps its
PullToRefreshBox spinner; cold start keeps the skeleton.

The veil is a near-opaque, background-tinted overlay that wipes in from
the left and swallows taps while raised. Extracted HomeStateCrossfade so
HomeScreen stays under detekt's LongMethod.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 17:52:42 -04:00
bvandeusen 725ddca950 Merge pull request 'Milestone 127: recommendation quality — provenance, tiered mixes, For You v2, tuning lab' (#107) from dev into main
release / Build signed APK (tag releases only) (push) Has been skipped
test-go / test (push) Successful in 34s
release / Build + push container image (push) Successful in 36s
test-web / test (push) Successful in 41s
test-go / integration (push) Successful in 4m41s
2026-07-03 10:13:49 -04:00
bvandeusenandClaude Fable 5 29e1e7c64c test(web/tuning): marker summary appears in tooltips too — use getAllByText
test-web / test (push) Successful in 32s
The knob-turn summary renders both as the sparkline tick tooltips (one
per series) and in the list under the chart; the single-element query
tripped on the duplicates in CI run 1906.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TsF3cNoKrqCYsU78cXC8U6
2026-07-03 09:35:51 -04:00
bvandeusenandClaude Fable 5 9ad4343c76 feat(tuning): weekly trend view — per-surface series + knob-turn markers
test-go / integration (push) Successful in 4m44s
test-go / test (push) Successful in 33s
test-web / test (push) Failing after 38s
The verify half of the tune→verify loop (#1251), on the same admin
Tuning page as the knobs:

- RecommendationWeeklyTrends: weekly per-source outcomes aggregated
  across all users (the knobs are global, so judging a turn needs
  global outcomes — rows carry rates only, no track/user identity),
  with a taste-hit count per bucket: plays whose track's artist has a
  positive weight in the player's current taste profile. That's the
  "cheap recompute" reading — retroactive over the whole window, at
  the cost of profile drift.
- GET /api/admin/recommendation-trends?weeks=N (default 12, cap 52):
  per-family weekly series (skip rate, sample-weighted completion,
  taste-hit rate) plus the tuning-audit markers inside the window.
- Web: sparkline table under the tuning cards — skip rate per week on
  a shared axis with dashed ticks at knob turns, latest-week columns,
  window taste-hit rate, low-volume rows dimmed as anecdote, and a
  plain-text list of the window's tuning changes.

Also fixes the revive unused-parameter lint on the tuning GET handler
that failed CI run 1903 on the previous commit.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TsF3cNoKrqCYsU78cXC8U6
2026-07-03 09:29:33 -04:00
bvandeusenandClaude Fable 5 0d0a8f46b1 feat(tuning): scoring weights → DB-backed admin tuning lab
test-go / test (push) Failing after 14s
test-web / test (push) Successful in 34s
test-go / integration (push) Successful in 4m42s
The recommendation scoring knobs move out of YAML (radio profile) and
out of the systemMixWeights hard-code (daily_mix profile) into
DB-backed settings with live effect (#1250) — the defaults-discovery
lab per decision #1247: the operator turns knobs to find good values,
which then get baked back into shipped defaults; end users and other
operators should never need the card.

- Migration 0040: recommendation_weight_profiles (radio / daily_mix,
  8 weight columns), taste_tuning singleton (engagement half-life +
  completion-curve points), recommendation_tuning_audit (one row per
  change with a {field, old, new} diff — the trend view's markers,
  #1251).
- internal/recsettings: boot reconcile seeds shipped defaults without
  clobbering tuned rows (coverart SettingsService pattern), validates
  patches (bounds, curve ordering), writes audit rows, and pushes
  daily_mix weights + taste config into package playlists. No-op
  patches write no audit row.
- playlists gains SetSystemMixWeights / SetTasteConfig swap points
  under a RWMutex — no signature threading through the producers; the
  scheduler's taste rebuild reads the pushed config.
- Radio reads its weight profile from the service per request; the 8
  weight fields leave config.RecommendationConfig (YAML keeps only
  RecentlyPlayedHours / RadioSize / RadioSizeMax).
- Admin API: GET/PATCH/reset under /api/admin/recommendation-tuning,
  echoing current + shipped values.
- Web: new admin Tuning tab — two weight profiles side by side, taste
  card, per-scope save (changed fields only) + reset, deviation dots
  against shipped defaults.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TsF3cNoKrqCYsU78cXC8U6
2026-07-03 09:22:03 -04:00
bvandeusenandClaude Fable 5 9e02878b61 feat(playlists): For You composition v2 — multi-seed blend + weighted fresh tail
test-go / test (push) Successful in 29s
test-go / integration (push) Successful in 4m37s
Two approved composition changes (#1269), mechanism only — the
taste/fresh share stays data-decided (#1252) and pick_kind
attribution is unchanged.

Multi-seed blending: each day's build now seeds from up to 3 of the
user's top-5 tracks (pickDailySeeds, the generalized daily shuffle)
instead of one rotating anchor, so the mix spans neighborhoods within
a day and stops feeling bipolar as the rotation swings between
dissimilar seeds. Per-seed pools merge first-seen-deduped; the head
is filled best-first under 50/30/20 per-seed quotas (60/40 for two
seeds) so one neighborhood can't monopolize it, with thin-seed quota
spilling best-first.

Score-weighted fresh tail: the tail sample (rank 2*headN onward) was
uniform — the 380th-best candidate as likely as the 101st. It now
uses deterministic Efraimidis-Spirakis keys with weight halving every
50 ranks, so freshness keeps its "you'll probably enjoy this" half
while still rotating daily.

The retired single-seed picker's one other caller, You-might-like,
moves to pickDailySeeds(n=1) — a single neighborhood per day is right
for a short shelf, and the behavior note is inline.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TsF3cNoKrqCYsU78cXC8U6
2026-07-03 09:02:35 -04:00
bvandeusenandClaude Fable 5 48f288e2e5 feat(mixes): tiered rebuilds for New for you + First listens (rule #131)
test-go / integration (push) Successful in 4m39s
test-go / test (push) Successful in 29s
Both mixes move from a single hard eligibility rule to the tiered
ladder, with their tier stamped onto playlist_tracks.pick_kind via the
#1270 provenance pipeline.

New for you (#1267) — consume on play, degrade by stepping back:
- "Consumed" = any track attempted >=30s; played albums leave the mix
  at the next build instead of crowding it until the calendar window
  expires.
- Tier 1: unconsumed albums added <30d by direct-affinity artists.
  Tier 2: unconsumed affinity albums from the wider 30-90d window —
  added while you weren't looking. Tier 3: any unconsumed album added
  <90d, newest first.

First listens (#1268) — track-level "attempted" threshold:
- A 2-second accidental brush no longer disqualifies a whole album;
  "attempted" is duration_played_ms >= 30000 per track.
- Tier 1: albums with zero attempted tracks. Tier 2: barely-attempted
  albums (<=25% of tracks reached 30s), minus the attempted tracks
  themselves. The artist-affinity ordering signal also moves to the
  >=30s definition so skip-only contact doesn't read as trust.

Producer plumbing: fetch adapters map the tier column onto pick kinds,
finishMix propagates PickKind into the persisted candidates, and
rotateForDay now rotates within contiguous same-pick-kind blocks so
daily rotation can't hoist tier-3 filler above tier-1's exact fits
(untiered pools are one block — original behavior).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TsF3cNoKrqCYsU78cXC8U6
2026-07-03 08:54:20 -04:00
bvandeusenandClaude Fable 5 2be07ef271 fix(mixes): close three intent gaps found in the system-playlists audit
test-go / test (push) Successful in 29s
test-go / integration (push) Successful in 4m36s
Three discovery-mix defects from the intent audit (Scribe note #1254),
all sharing the same root pattern — skips treated as non-events:

- Deep Cuts (#1257): eligibility counted only unskipped plays, so a
  track skipped twice with zero completed listens read as "barely
  heard" and kept being re-offered. Tracks with >=2 skips no longer
  qualify; a single accidental skip doesn't banish.

- Rediscover (#1258): a skip on a rediscover-sourced play — the user
  explicitly declining the resurfacing invitation — changed nothing,
  so declined tracks re-qualified the next day forever. Such tracks
  now sit out 90 days.

- On This Day (#1256): day-of-year distance used plain ABS, so
  Dec 28 vs Jan 3 read as 359 days apart and the window silently
  gutted itself for ~3 weeks around every New Year. Now circular
  (LEAST(d, 365-d)), anchored on the build-date parameter instead of
  now() so it's testable and consistent with the mix's daily
  determinism.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TsF3cNoKrqCYsU78cXC8U6
2026-07-03 08:47:01 -04:00
bvandeusenandClaude Fable 5 a670840114 fix(playlists): Songs-like mixes no longer vanish after a quiet week
test-go / test (push) Successful in 29s
test-go / integration (push) Successful in 4m30s
PickSeedArtists had a hard 7-day window with no fallback: a week
without listening emptied the seed pool, produceSeedMixes returned
zero playlists, and the daily atomic-replace build deleted every
existing "Songs like X" mix until the user played something again
(#1255).

The query now falls back through widening engagement windows — 7d →
30d → all-time → liked artists — the same tiered shape that fixed the
identical vanish for For You's seeds (PickTopPlayedTracksForUser).
Like-boost scoring is preserved in every tier.

All returned rows share the winning tier, and produceSeedMixes maps it
onto the rule-#131 pick-kind ladder (7d = tier1 exact, 30d = tier2,
all-time/liked = tier3) and stamps the built tracks — the #1270
provenance pipeline then attributes plays and skips to seed freshness,
so the metrics card can say whether stale-seeded mixes actually
perform worse.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TsF3cNoKrqCYsU78cXC8U6
2026-07-03 08:38:54 -04:00
bvandeusenandClaude Fable 5 5faa57634b feat(metrics): provenance as standard — pick_kind for all system mixes
test-go / test (push) Successful in 31s
test-web / test (push) Successful in 38s
test-go / integration (push) Successful in 4m36s
The #1249 mechanism (stamp WHY a track is in the snapshot at build
time, freeze it onto the play at ingestion, break it down in metrics)
generalizes from a For You one-off to the standard for every system
mix (#1270):

- Migration 0039 widens both pick_kind CHECKs (drop + re-add in the
  same change) to taste/fresh + Discover's dormant/cross_user/random
  + tier1-3 for the rule-#131 eligibility ladders.
- GetForYouPickKindForTrack becomes GetSystemPickKindForTrack
  (user, variant, track); ingestion stamps any systemPlaylistSources
  play from its own variant's live snapshot, live + offline paths.
- Discover stamps its candidate bucket on discoverTrack before the
  interleave, making the 40/30/30 allocation measurable; dedup keeps
  the taking bucket's stamp.
- Metrics replace the for_you special-case with one pick-kind
  vocabulary — any family with attributed plays gets a breakdown,
  future stamping mixes need no metrics change.
- Web: breakdown sub-rows are now toggled per surface (collapsed by
  default) so eight stamping mixes don't swamp the card.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TsF3cNoKrqCYsU78cXC8U6
2026-07-02 23:26:52 -04:00
bvandeusenandClaude Fable 5 fb4431207d feat(recommendation): For You exploration attribution — taste vs fresh picks
test-go / test (push) Successful in 31s
test-web / test (push) Successful in 37s
test-go / integration (push) Successful in 4m37s
Milestone #127 step 2 (#1249). For You deliberately blends two
populations — a head of top-scored taste picks and a tail sampled from
deeper ranking (the freshness injection) — but the metrics judged it as
one blob, so its skip rate couldn't distinguish "the taste engine is
missing" from "the freshness tax is too high". That number decides the
exploration share before we tune it.

- Migration 0038: nullable pick_kind ('taste'|'fresh') on both
  playlist_tracks (stamped at snapshot build) and play_events (frozen at
  play-ingestion — the snapshot rebuilds daily, so attribution cannot be
  reconstructed at read time).
- Builder: pickHeadAndTail marks head=taste / tail=fresh; the small-pool
  fallback is all taste (top-N-by-score IS the taste mechanism). Other
  variants persist NULL.
- Ingestion: for_you plays (live + offline replay) look the track up in
  the user's current snapshot; not found → unattributed, never guessed.
- Metrics: For You's row gains a breakdown (taste / fresh / earlier
  unattributed plays), parent row stays the sum; web card renders the
  sub-rows indented with the same baseline deltas + low-data dimming.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TsF3cNoKrqCYsU78cXC8U6
2026-07-02 20:39:41 -04:00
bvandeusenandClaude Fable 5 60533073ad feat(metrics): bucketed surface families + manual-plays baseline (#1248, milestone 127)
test-go / test (push) Successful in 31s
test-web / test (push) Successful in 37s
test-go / integration (push) Successful in 4m30s
The recommendation metrics table was observable but not actionable: raw
source strings (album:<uuid> one-offs) drowned the stable surfaces, and
manual plays were excluded so skip rates had no control group.

- SQL: include NULL-source rows (the baseline) and carry completion_n
  so family merges can weight avg_completion correctly.
- Handler buckets raw sources into stable families (radio:<uuid> →
  Radio, album:/artist: → direct plays, etc.) grouped by surface
  intent: go-to / discovery / direct — each band judged against its
  job, since discovery mixes are expected to skip hotter. Families
  under 20 plays are flagged low-confidence, not hidden.
- Settings card renders the baseline row and per-surface deltas in
  percentage points vs baseline (worse-than-baseline deltas in danger
  color), intent hint copy per group, low-data rows dimmed.
- Pure-unit test for the bucketing/merge; DB test updated to the new
  contract (baseline included, radio:<uuid> collapse).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TsF3cNoKrqCYsU78cXC8U6
2026-07-02 18:00:40 -04:00
bvandeusenandClaude Fable 5 4b150a277e fix(recommendation): Rediscover no longer ships a one-song playlist (#1246)
test-go / test (push) Successful in 29s
test-go / integration (push) Successful in 4m35s
Confirmed against prod: exactly one track (17 plays, cold since May 21)
met the c>=5 + 30d-cold bar, and three process defects turned that into
a 1-track playlist instead of the locked placeholder.

- ListRediscoverTracks: collapse the two-tier UNION into one blended
  pool. The old shallow-tier gate (WHERE NOT EXISTS deep) was
  all-or-nothing — one 6-month row suppressed the entire 30-day tier —
  and deep was a strict subset of shallow anyway. Eligibility drops to
  >=3 non-skip plays (on a weeks-old history the >=5-play tracks are
  precisely the ones still in rotation); ordering prefers >=6mo cold,
  then >=5 plays, then raw count.
- Minimum viable mix floor for all five discovery mixes: below
  minLen (15; 5 for the album-coherent NewForYou/FirstListens) the
  variant is withheld so Home renders the 'listen more to unlock'
  placeholder instead of a mix that reads as built-wrong.
- /api/events: clamp client-supplied 'at' to [user.created_at,
  now+5m]. Unbounded client clocks could write arbitrarily old plays
  and poison the 6-month ordering (prod data verified clean — no
  scrub needed).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TsF3cNoKrqCYsU78cXC8U6
2026-07-02 17:29:41 -04:00
bvandeusen d145fee35d Merge pull request 'fix(android/resilience): auto-recover failed loads on reconnect' (#106) from dev into main
android / Build + lint + test (push) Successful in 4m44s
release / Build signed APK (tag releases only) (push) Successful in 4m31s
release / Build + push container image (push) Successful in 16s
2026-07-02 16:45:13 -04:00
bvandeusenandClaude Fable 5 7628330f72 test(android/library): stub SyncController.lastSyncError with a real StateFlow
android / Build + lint + test (push) Successful in 3m45s
The VM now combines lastSyncError into uiState; a relaxed-mock flow
never emits, so the combine never fired and the state sat at Loading.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TsF3cNoKrqCYsU78cXC8U6
2026-07-02 16:10:45 -04:00
bvandeusenandClaude Fable 5 09102dc615 fix(android/resilience): auto-recover failed loads on reconnect (#1245)
android / Build + lint + test (push) Failing after 3m3s
Failed loads used to stay failed forever: no screen ViewModel listened
to server-health recovery, and the cache-first screens swallowed refresh
errors so a cold load against a down server looked like an empty account.

- connectivity/Recovery.kt: recoveries() — per-collector flow of
  down→Healthy transitions; the screen-level half of the idiom
  SyncController/MutationReplayer/DiagnosticsUploader already use.
- Every screen VM now re-runs its load on recovery (cache-first screens
  unconditionally; direct-load screens when sitting in Error).
- Cache-first error surfacing: Home / Playlists / Liked track refresh
  failure; Library reads SyncController.lastSyncError (new) — empty
  cache + failed refresh now renders Error-with-Retry, not welcome copy.
- Requests: 12s poll also retries from Error (was structurally unable
  to escape it — the in-flight predicate required a Success state).
- Search: retry() bypasses the distinctUntilChanged query pipeline so a
  same-text resubmit after a transient failure actually re-runs.
- ArtistDetail: secondary sections (similar artists / top tracks)
  re-fetch on recovery instead of staying silently absent.
- ErrorRetry: LazyColumn wrapper (pull-to-refresh works on error states,
  same rationale as EmptyState) + optional title; adopted on every error
  branch; PlaylistDetail's one-shot ErrorBlock removed in its favor.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TsF3cNoKrqCYsU78cXC8U6
2026-07-02 16:02:14 -04:00
231 changed files with 19294 additions and 1281 deletions
+10 -4
View File
@@ -80,10 +80,16 @@ jobs:
- name: Upload debug APK - name: Upload debug APK
if: github.event_name == 'push' && github.ref == 'refs/heads/main' if: github.event_name == 'push' && github.ref == 'refs/heads/main'
# Gitea Actions runs in GHES-emulation mode; @actions/artifact v2+ # Mirrored action, never actions/upload-artifact. @v4+ throws
# (i.e. upload-artifact@v4+) errors with "GHESNotSupportedError". # GHESNotSupportedError client-side on the hostname (no server setting
# Pin to @v3 until act_runner or the artifact backend catches up. # reaches that check), and @v3 is worse — it reports success while Gitea
uses: actions/upload-artifact@v3 # 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: with:
name: minstrel-android-debug-${{ github.sha }} name: minstrel-android-debug-${{ github.sha }}
path: android/app/build/outputs/apk/debug/app-debug.apk 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 }} -PMINSTREL_VERSION_CODE=${{ steps.ver.outputs.code }}
- name: Upload APK as workflow artifact - name: Upload APK as workflow artifact
# @v3 because Gitea Actions emulates GHES and the v2 artifact # Mirrored action, never actions/upload-artifact — @v4+ refuses on the
# backend used by upload-artifact@v4 errors with GHESNotSupportedError. # hostname, @v3 uploads something Gitea will never serve back. This is
uses: actions/upload-artifact@v3 # 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: with:
name: minstrel-apk name: minstrel-apk
path: android/app/build/outputs/apk/release/app-release.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 - name: Attach APK to gitea Release
shell: bash shell: bash
@@ -238,7 +245,20 @@ jobs:
# Tag pushes only — android-release just produced this. Non-tag # Tag pushes only — android-release just produced this. Non-tag
# builds take the "Bundle latest release APK" path below instead. # builds take the "Bundle latest release APK" path below instead.
if: steps.guard.outputs.ready == 'true' && startsWith(github.ref, 'refs/tags/v') 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: with:
name: minstrel-apk name: minstrel-apk
path: client/ path: client/
+4
View File
@@ -27,6 +27,7 @@ on:
- 'go.mod' - 'go.mod'
- 'go.sum' - 'go.sum'
- 'sqlc.yaml' - 'sqlc.yaml'
- 'Makefile'
- 'internal/**' - 'internal/**'
- 'cmd/**' - 'cmd/**'
- '.golangci.yml' - '.golangci.yml'
@@ -53,6 +54,9 @@ jobs:
go version go version
golangci-lint --version golangci-lint --version
- name: Generated code matches queries (sqlc)
run: make verify-generate
- name: go vet - name: go vet
run: 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 SQLC_VERSION := 1.31.1
# Local codegen. Containerised so a dev needs no sqlc install.
generate: generate:
docker run --rm -v "$(CURDIR):/src" -w /src sqlc/sqlc:$(SQLC_VERSION) 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: test:
go test -race ./... go test -race ./...
+14 -1
View File
@@ -210,4 +210,17 @@ dependencies {
debugImplementation(libs.compose.ui.test.manifest) 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.ImageLoader
import coil3.SingletonImageLoader import coil3.SingletonImageLoader
import coil3.network.okhttp.OkHttpNetworkFetcherFactory import coil3.network.okhttp.OkHttpNetworkFetcherFactory
import coil3.request.crossfade
import com.fabledsword.minstrel.cache.CacheIndexer import com.fabledsword.minstrel.cache.CacheIndexer
import com.fabledsword.minstrel.cache.mutations.MutationReplayer import com.fabledsword.minstrel.cache.mutations.MutationReplayer
import com.fabledsword.minstrel.cache.sync.SyncController import com.fabledsword.minstrel.cache.sync.SyncController
@@ -29,6 +30,10 @@ import okhttp3.OkHttpClient
import timber.log.Timber import timber.log.Timber
import javax.inject.Inject 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 @HiltAndroidApp
class MinstrelApplication : class MinstrelApplication :
Application(), Application(),
@@ -213,11 +218,18 @@ class MinstrelApplication :
* OkHttp client as the network fetcher. The `callFactory` lambda * OkHttp client as the network fetcher. The `callFactory` lambda
* is invoked lazily so Hilt has time to inject `okHttpClient` * is invoked lazily so Hilt has time to inject `okHttpClient`
* before Coil makes its first request. * 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 = override fun newImageLoader(context: android.content.Context): ImageLoader =
ImageLoader.Builder(context) ImageLoader.Builder(context)
.components { .components {
add(OkHttpNetworkFetcherFactory(callFactory = { okHttpClient })) add(OkHttpNetworkFetcherFactory(callFactory = { okHttpClient }))
} }
.crossfade(ART_CROSSFADE_MS)
.build() .build()
} }
@@ -0,0 +1,48 @@
package com.fabledsword.minstrel.admin.data
import com.fabledsword.minstrel.api.endpoints.AdminTagSourcesApi
import com.fabledsword.minstrel.api.endpoints.UpdateTagSourceBody
import com.fabledsword.minstrel.models.AdminTagSourceRef
import com.fabledsword.minstrel.models.TagSourceTestResult
import com.fabledsword.minstrel.models.wire.AdminTagSourceWire
import com.fabledsword.minstrel.models.wire.TestTagSourceWire
import retrofit2.Retrofit
import retrofit2.create
import javax.inject.Inject
import javax.inject.Singleton
/**
* Read-through accessor for the tag-enrichment provider settings (#1521).
* No Room caching — admin settings are infrequent point-and-shoot edits;
* mutations fire direct REST and the ViewModel reconciles on failure.
* Exceptions propagate to the ViewModel (which maps them via ErrorCopy).
*/
@Singleton
class AdminTagSourcesRepository @Inject constructor(
retrofit: Retrofit,
) {
private val api: AdminTagSourcesApi = retrofit.create()
suspend fun list(): List<AdminTagSourceRef> = api.list().providers.map { it.toDomain() }
suspend fun setEnabled(id: String, enabled: Boolean): AdminTagSourceRef =
api.update(id, UpdateTagSourceBody(enabled = enabled)).toDomain()
suspend fun setApiKey(id: String, apiKey: String): AdminTagSourceRef =
api.update(id, UpdateTagSourceBody(apiKey = apiKey)).toDomain()
suspend fun test(id: String): TagSourceTestResult = api.test(id).toResult()
}
private fun AdminTagSourceWire.toDomain(): AdminTagSourceRef = AdminTagSourceRef(
id = id,
displayName = displayName,
requiresApiKey = requiresApiKey,
supports = supports,
enabled = enabled,
apiKeySet = apiKeySet,
testable = testable,
)
private fun TestTagSourceWire.toResult(): TagSourceTestResult =
TagSourceTestResult(ok = ok, durationMs = durationMs, error = error)
@@ -4,6 +4,8 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.fabledsword.minstrel.admin.data.AdminInvitesRepository import com.fabledsword.minstrel.admin.data.AdminInvitesRepository
import com.fabledsword.minstrel.api.ErrorCopy import com.fabledsword.minstrel.api.ErrorCopy
import com.fabledsword.minstrel.connectivity.NetworkStatusController
import com.fabledsword.minstrel.connectivity.recoveries
import com.fabledsword.minstrel.models.Invite import com.fabledsword.minstrel.models.Invite
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.Channel
@@ -25,6 +27,7 @@ data class AdminInvitesUiState(
@HiltViewModel @HiltViewModel
class AdminInvitesViewModel @Inject constructor( class AdminInvitesViewModel @Inject constructor(
private val repository: AdminInvitesRepository, private val repository: AdminInvitesRepository,
networkStatus: NetworkStatusController,
) : ViewModel() { ) : ViewModel() {
private val internal = MutableStateFlow(AdminInvitesUiState()) private val internal = MutableStateFlow(AdminInvitesUiState())
@@ -36,6 +39,13 @@ class AdminInvitesViewModel @Inject constructor(
init { init {
refresh() refresh()
// Screen-level auto-recovery (issue #1245): reload a failed list
// when server health returns instead of waiting for a manual pull.
viewModelScope.launch {
networkStatus.recoveries().collect {
if (internal.value.message != null) refresh()
}
}
} }
fun refresh() { fun refresh() {
@@ -28,15 +28,18 @@ import androidx.lifecycle.viewModelScope
import androidx.navigation.NavHostController import androidx.navigation.NavHostController
import com.composables.icons.lucide.Inbox import com.composables.icons.lucide.Inbox
import com.composables.icons.lucide.Lucide import com.composables.icons.lucide.Lucide
import com.composables.icons.lucide.Music
import com.composables.icons.lucide.TriangleAlert import com.composables.icons.lucide.TriangleAlert
import com.composables.icons.lucide.Users import com.composables.icons.lucide.Users
import com.fabledsword.minstrel.admin.data.AdminQuarantineRepository import com.fabledsword.minstrel.admin.data.AdminQuarantineRepository
import com.fabledsword.minstrel.admin.data.AdminRequestsRepository import com.fabledsword.minstrel.admin.data.AdminRequestsRepository
import com.fabledsword.minstrel.admin.data.AdminTagSourcesRepository
import com.fabledsword.minstrel.admin.data.AdminUsersRepository import com.fabledsword.minstrel.admin.data.AdminUsersRepository
import com.fabledsword.minstrel.api.ErrorCopy import com.fabledsword.minstrel.api.ErrorCopy
import com.fabledsword.minstrel.nav.Admin import com.fabledsword.minstrel.nav.Admin
import com.fabledsword.minstrel.nav.AdminQuarantine import com.fabledsword.minstrel.nav.AdminQuarantine
import com.fabledsword.minstrel.nav.AdminRequests import com.fabledsword.minstrel.nav.AdminRequests
import com.fabledsword.minstrel.nav.AdminTagSources
import com.fabledsword.minstrel.nav.AdminUsers import com.fabledsword.minstrel.nav.AdminUsers
import com.fabledsword.minstrel.shared.widgets.EmptyState import com.fabledsword.minstrel.shared.widgets.EmptyState
import com.fabledsword.minstrel.shared.widgets.LoadingCentered import com.fabledsword.minstrel.shared.widgets.LoadingCentered
@@ -54,7 +57,7 @@ import javax.inject.Inject
// ─── State ─────────────────────────────────────────────────────────── // ─── State ───────────────────────────────────────────────────────────
data class AdminCounts(val requests: Int, val quarantine: Int, val users: Int) data class AdminCounts(val requests: Int, val quarantine: Int, val users: Int, val tagSources: Int)
sealed interface AdminLandingUiState { sealed interface AdminLandingUiState {
data object Loading : AdminLandingUiState data object Loading : AdminLandingUiState
@@ -69,6 +72,7 @@ class AdminLandingViewModel @Inject constructor(
private val requestsRepo: AdminRequestsRepository, private val requestsRepo: AdminRequestsRepository,
private val quarantineRepo: AdminQuarantineRepository, private val quarantineRepo: AdminQuarantineRepository,
private val usersRepo: AdminUsersRepository, private val usersRepo: AdminUsersRepository,
private val tagSourcesRepo: AdminTagSourcesRepository,
) : ViewModel() { ) : ViewModel() {
private val internal = MutableStateFlow<AdminLandingUiState>(AdminLandingUiState.Loading) private val internal = MutableStateFlow<AdminLandingUiState>(AdminLandingUiState.Loading)
@@ -85,7 +89,8 @@ class AdminLandingViewModel @Inject constructor(
val req = async { requestsRepo.list().size } val req = async { requestsRepo.list().size }
val qua = async { quarantineRepo.list().size } val qua = async { quarantineRepo.list().size }
val usr = async { usersRepo.list().size } val usr = async { usersRepo.list().size }
AdminCounts(req.await(), qua.await(), usr.await()) val tag = async { tagSourcesRepo.list().count { it.enabled } }
AdminCounts(req.await(), qua.await(), usr.await(), tag.await())
} }
internal.value = AdminLandingUiState.Success(counts) internal.value = AdminLandingUiState.Success(counts)
} catch ( } catch (
@@ -170,6 +175,15 @@ private fun SectionList(counts: AdminCounts, navController: NavHostController) {
onClick = { navController.navigate(AdminUsers) }, onClick = { navController.navigate(AdminUsers) },
) )
} }
item {
SectionCard(
icon = Lucide.Music,
title = "Tag sources",
subtitle = "Metadata enrichment providers",
count = counts.tagSources,
onClick = { navController.navigate(AdminTagSources) },
)
}
} }
} }
@@ -29,6 +29,7 @@ import androidx.navigation.NavHostController
import com.fabledsword.minstrel.models.AdminQuarantineItemRef import com.fabledsword.minstrel.models.AdminQuarantineItemRef
import com.fabledsword.minstrel.nav.AdminQuarantine import com.fabledsword.minstrel.nav.AdminQuarantine
import com.fabledsword.minstrel.shared.widgets.EmptyState import com.fabledsword.minstrel.shared.widgets.EmptyState
import com.fabledsword.minstrel.shared.widgets.ErrorRetry
import com.fabledsword.minstrel.shared.widgets.LoadingCentered import com.fabledsword.minstrel.shared.widgets.LoadingCentered
import com.fabledsword.minstrel.shared.widgets.MinstrelTopAppBar import com.fabledsword.minstrel.shared.widgets.MinstrelTopAppBar
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
@@ -62,9 +63,10 @@ fun AdminQuarantineScreen(
body = "When users flag tracks as bad rips, wrong tags, or " + body = "When users flag tracks as bad rips, wrong tags, or " +
"duplicates, their reports get aggregated and surfaced here.", "duplicates, their reports get aggregated and surfaced here.",
) )
is AdminQuarantineUiState.Error -> EmptyState( is AdminQuarantineUiState.Error -> ErrorRetry(
title = "Couldn't load queue", title = "Couldn't load queue",
body = s.message, message = s.message,
onRetry = { viewModel.refresh() },
) )
is AdminQuarantineUiState.Success -> QueueList( is AdminQuarantineUiState.Success -> QueueList(
rows = s.rows, rows = s.rows,
@@ -4,6 +4,8 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.fabledsword.minstrel.admin.data.AdminQuarantineRepository import com.fabledsword.minstrel.admin.data.AdminQuarantineRepository
import com.fabledsword.minstrel.api.ErrorCopy import com.fabledsword.minstrel.api.ErrorCopy
import com.fabledsword.minstrel.connectivity.NetworkStatusController
import com.fabledsword.minstrel.connectivity.recoveries
import com.fabledsword.minstrel.events.EventsStream import com.fabledsword.minstrel.events.EventsStream
import com.fabledsword.minstrel.models.AdminQuarantineItemRef import com.fabledsword.minstrel.models.AdminQuarantineItemRef
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
@@ -26,6 +28,7 @@ sealed interface AdminQuarantineUiState {
class AdminQuarantineViewModel @Inject constructor( class AdminQuarantineViewModel @Inject constructor(
private val repository: AdminQuarantineRepository, private val repository: AdminQuarantineRepository,
private val eventsStream: EventsStream, private val eventsStream: EventsStream,
networkStatus: NetworkStatusController,
) : ViewModel() { ) : ViewModel() {
private val internal = MutableStateFlow<AdminQuarantineUiState>(AdminQuarantineUiState.Loading) private val internal = MutableStateFlow<AdminQuarantineUiState>(AdminQuarantineUiState.Loading)
@@ -38,6 +41,13 @@ class AdminQuarantineViewModel @Inject constructor(
.filter { it.kind.startsWith("quarantine.") } .filter { it.kind.startsWith("quarantine.") }
.collect { refresh() } .collect { refresh() }
} }
// Screen-level auto-recovery (issue #1245): reload a failed list
// when server health returns instead of waiting for a manual pull.
viewModelScope.launch {
networkStatus.recoveries().collect {
if (internal.value is AdminQuarantineUiState.Error) refresh()
}
}
} }
fun refresh(): Job = viewModelScope.launch { fun refresh(): Job = viewModelScope.launch {
@@ -28,6 +28,7 @@ import androidx.navigation.NavHostController
import com.fabledsword.minstrel.models.RequestRef import com.fabledsword.minstrel.models.RequestRef
import com.fabledsword.minstrel.nav.AdminRequests import com.fabledsword.minstrel.nav.AdminRequests
import com.fabledsword.minstrel.shared.widgets.EmptyState import com.fabledsword.minstrel.shared.widgets.EmptyState
import com.fabledsword.minstrel.shared.widgets.ErrorRetry
import com.fabledsword.minstrel.shared.widgets.LoadingCentered import com.fabledsword.minstrel.shared.widgets.LoadingCentered
import com.fabledsword.minstrel.shared.widgets.MinstrelTopAppBar import com.fabledsword.minstrel.shared.widgets.MinstrelTopAppBar
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
@@ -61,9 +62,10 @@ fun AdminRequestsScreen(
body = "When users ask Lidarr for new music, their pending " + body = "When users ask Lidarr for new music, their pending " +
"requests show up here for approval.", "requests show up here for approval.",
) )
is AdminRequestsUiState.Error -> EmptyState( is AdminRequestsUiState.Error -> ErrorRetry(
title = "Couldn't load requests", title = "Couldn't load requests",
body = s.message, message = s.message,
onRetry = { viewModel.refresh() },
) )
is AdminRequestsUiState.Success -> RequestList( is AdminRequestsUiState.Success -> RequestList(
rows = s.rows, rows = s.rows,
@@ -5,6 +5,8 @@ import androidx.lifecycle.viewModelScope
import com.fabledsword.minstrel.admin.data.AdminRequestsRepository import com.fabledsword.minstrel.admin.data.AdminRequestsRepository
import com.fabledsword.minstrel.admin.data.AdminUsersRepository import com.fabledsword.minstrel.admin.data.AdminUsersRepository
import com.fabledsword.minstrel.api.ErrorCopy import com.fabledsword.minstrel.api.ErrorCopy
import com.fabledsword.minstrel.connectivity.NetworkStatusController
import com.fabledsword.minstrel.connectivity.recoveries
import com.fabledsword.minstrel.events.EventsStream import com.fabledsword.minstrel.events.EventsStream
import com.fabledsword.minstrel.models.RequestRef import com.fabledsword.minstrel.models.RequestRef
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
@@ -35,6 +37,7 @@ class AdminRequestsViewModel @Inject constructor(
private val repository: AdminRequestsRepository, private val repository: AdminRequestsRepository,
private val usersRepository: AdminUsersRepository, private val usersRepository: AdminUsersRepository,
private val eventsStream: EventsStream, private val eventsStream: EventsStream,
networkStatus: NetworkStatusController,
) : ViewModel() { ) : ViewModel() {
private val internal = MutableStateFlow<AdminRequestsUiState>(AdminRequestsUiState.Loading) private val internal = MutableStateFlow<AdminRequestsUiState>(AdminRequestsUiState.Loading)
@@ -47,6 +50,13 @@ class AdminRequestsViewModel @Inject constructor(
.filter { it.kind in RELEVANT_EVENT_KINDS } .filter { it.kind in RELEVANT_EVENT_KINDS }
.collect { refresh() } .collect { refresh() }
} }
// Screen-level auto-recovery (issue #1245): reload a failed list
// when server health returns instead of waiting for a manual pull.
viewModelScope.launch {
networkStatus.recoveries().collect {
if (internal.value is AdminRequestsUiState.Error) refresh()
}
}
} }
fun refresh(): Job = viewModelScope.launch { fun refresh(): Job = viewModelScope.launch {
@@ -0,0 +1,220 @@
package com.fabledsword.minstrel.admin.ui
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.Button
import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
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.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavHostController
import com.fabledsword.minstrel.models.AdminTagSourceRef
import com.fabledsword.minstrel.models.TagSourceTestResult
import com.fabledsword.minstrel.nav.AdminTagSources
import com.fabledsword.minstrel.shared.widgets.EmptyState
import com.fabledsword.minstrel.shared.widgets.ErrorRetry
import com.fabledsword.minstrel.shared.widgets.LoadingCentered
import com.fabledsword.minstrel.shared.widgets.MinstrelTopAppBar
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AdminTagSourcesScreen(
navController: NavHostController,
viewModel: AdminTagSourcesViewModel = hiltViewModel(),
) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
Scaffold(
modifier = Modifier.fillMaxSize(),
topBar = {
MinstrelTopAppBar(
title = "Admin · Tag sources",
navController = navController,
currentRouteName = AdminTagSources::class.qualifiedName,
onBack = { navController.popBackStack() },
)
},
) { inner ->
PullToRefreshScaffold(
onRefresh = { viewModel.refresh().join() },
modifier = Modifier.fillMaxSize().padding(inner),
) {
when (val s = state) {
AdminTagSourcesUiState.Loading -> LoadingCentered()
AdminTagSourcesUiState.Empty -> EmptyState(
title = "No tag sources",
body = "Tag-enrichment providers register on the server; none are available.",
)
is AdminTagSourcesUiState.Error -> ErrorRetry(
title = "Couldn't load tag sources",
message = s.message,
onRetry = { viewModel.refresh() },
)
is AdminTagSourcesUiState.Success -> TagSourceList(
providers = s.providers,
testResults = s.testResults,
onToggle = viewModel::setEnabled,
onSaveKey = viewModel::saveApiKey,
onTest = viewModel::test,
)
}
}
}
}
@Composable
private fun TagSourceList(
providers: List<AdminTagSourceRef>,
testResults: Map<String, TagSourceTestResult>,
onToggle: (String, Boolean) -> Unit,
onSaveKey: (String, String) -> Unit,
onTest: (String) -> Unit,
) {
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
items(items = providers, key = { it.id }) { provider ->
TagSourceCard(
provider = provider,
testResult = testResults[provider.id],
onToggle = onToggle,
onSaveKey = onSaveKey,
onTest = onTest,
)
}
}
}
@Composable
private fun TagSourceCard(
provider: AdminTagSourceRef,
testResult: TagSourceTestResult?,
onToggle: (String, Boolean) -> Unit,
onSaveKey: (String, String) -> Unit,
onTest: (String) -> Unit,
) {
ElevatedCard(modifier = Modifier.fillMaxWidth()) {
Column(
modifier = Modifier.fillMaxWidth().padding(16.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Column(modifier = Modifier.weight(1f)) {
Text(provider.displayName, style = MaterialTheme.typography.titleMedium)
Text(
text = provider.supports.joinToString(", ") { it.replace('_', ' ') },
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Switch(
checked = provider.enabled,
onCheckedChange = { onToggle(provider.id, it) },
)
}
if (provider.requiresApiKey) {
ApiKeyRow(provider = provider, onSaveKey = onSaveKey)
}
if (provider.testable) {
TestRow(provider = provider, testResult = testResult, onTest = onTest)
}
}
}
}
@Composable
private fun ApiKeyRow(provider: AdminTagSourceRef, onSaveKey: (String, String) -> Unit) {
var key by remember(provider.id) { mutableStateOf("") }
OutlinedTextField(
value = key,
onValueChange = { key = it },
modifier = Modifier.fillMaxWidth(),
label = { Text("API key") },
placeholder = {
Text(
if (provider.apiKeySet) {
"••• saved — leave blank to keep"
} else {
"Paste your API key to enable this source"
},
)
},
singleLine = true,
visualTransformation = PasswordVisualTransformation(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
)
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Button(
onClick = {
onSaveKey(provider.id, key)
key = ""
},
enabled = key.isNotBlank(),
) { Text("Save key") }
if (provider.apiKeySet) {
Text("✓ Set", style = MaterialTheme.typography.bodySmall)
}
}
}
@Composable
private fun TestRow(
provider: AdminTagSourceRef,
testResult: TagSourceTestResult?,
onTest: (String) -> Unit,
) {
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
OutlinedButton(onClick = { onTest(provider.id) }) { Text("Test connection") }
testResult?.let { result ->
if (result.ok) {
Text(
text = "OK (${result.durationMs}ms)",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.primary,
)
} else {
Text(
text = "Failed — ${result.error}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
)
}
}
}
}
@@ -0,0 +1,116 @@
package com.fabledsword.minstrel.admin.ui
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.fabledsword.minstrel.admin.data.AdminTagSourcesRepository
import com.fabledsword.minstrel.api.ErrorCopy
import com.fabledsword.minstrel.connectivity.NetworkStatusController
import com.fabledsword.minstrel.connectivity.recoveries
import com.fabledsword.minstrel.models.AdminTagSourceRef
import com.fabledsword.minstrel.models.TagSourceTestResult
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import javax.inject.Inject
sealed interface AdminTagSourcesUiState {
data object Loading : AdminTagSourcesUiState
data object Empty : AdminTagSourcesUiState
data class Success(
val providers: List<AdminTagSourceRef>,
val testResults: Map<String, TagSourceTestResult>,
) : AdminTagSourcesUiState
data class Error(val message: String) : AdminTagSourcesUiState
}
@HiltViewModel
class AdminTagSourcesViewModel @Inject constructor(
private val repository: AdminTagSourcesRepository,
networkStatus: NetworkStatusController,
) : ViewModel() {
private val internal = MutableStateFlow<AdminTagSourcesUiState>(AdminTagSourcesUiState.Loading)
val uiState: StateFlow<AdminTagSourcesUiState> = internal.asStateFlow()
init {
refresh()
// Screen-level auto-recovery: reload a failed list when server
// health returns instead of waiting for a manual pull (issue #1245).
viewModelScope.launch {
networkStatus.recoveries().collect {
if (internal.value is AdminTagSourcesUiState.Error) refresh()
}
}
}
fun refresh(): Job = viewModelScope.launch {
internal.value = AdminTagSourcesUiState.Loading
try {
val providers = repository.list()
internal.value = if (providers.isEmpty()) {
AdminTagSourcesUiState.Empty
} else {
AdminTagSourcesUiState.Success(providers, emptyMap())
}
} catch (
@Suppress("TooGenericExceptionCaught") e: Throwable,
) {
internal.value = AdminTagSourcesUiState.Error(ErrorCopy.fromThrowable(e))
}
}
fun setEnabled(id: String, enabled: Boolean) {
val before = internal.value as? AdminTagSourcesUiState.Success ?: return
// Optimistic toggle; reconcile via refresh() if the server rejects.
internal.value = before.copy(
providers = before.providers.map {
if (it.id == id) it.copy(enabled = enabled) else it
},
)
viewModelScope.launch {
try {
repository.setEnabled(id, enabled)
} catch (
@Suppress("TooGenericExceptionCaught", "SwallowedException") e: Throwable,
) {
refresh()
}
}
}
fun saveApiKey(id: String, apiKey: String) {
viewModelScope.launch {
try {
replaceProvider(repository.setApiKey(id, apiKey))
} catch (
@Suppress("TooGenericExceptionCaught", "SwallowedException") e: Throwable,
) {
refresh()
}
}
}
fun test(id: String) {
viewModelScope.launch {
val result = try {
repository.test(id)
} catch (
@Suppress("TooGenericExceptionCaught") e: Throwable,
) {
TagSourceTestResult(ok = false, error = ErrorCopy.fromThrowable(e))
}
val current = internal.value as? AdminTagSourcesUiState.Success ?: return@launch
internal.value = current.copy(testResults = current.testResults + (id to result))
}
}
private fun replaceProvider(updated: AdminTagSourceRef) {
val current = internal.value as? AdminTagSourcesUiState.Success ?: return
internal.value = current.copy(
providers = current.providers.map { if (it.id == updated.id) updated else it },
)
}
}
@@ -4,6 +4,8 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.fabledsword.minstrel.admin.data.AdminUsersRepository import com.fabledsword.minstrel.admin.data.AdminUsersRepository
import com.fabledsword.minstrel.api.ErrorCopy import com.fabledsword.minstrel.api.ErrorCopy
import com.fabledsword.minstrel.connectivity.NetworkStatusController
import com.fabledsword.minstrel.connectivity.recoveries
import com.fabledsword.minstrel.models.AdminUserRef import com.fabledsword.minstrel.models.AdminUserRef
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
@@ -23,6 +25,7 @@ sealed interface AdminUsersUiState {
@HiltViewModel @HiltViewModel
class AdminUsersViewModel @Inject constructor( class AdminUsersViewModel @Inject constructor(
private val repository: AdminUsersRepository, private val repository: AdminUsersRepository,
networkStatus: NetworkStatusController,
) : ViewModel() { ) : ViewModel() {
private val internal = MutableStateFlow<AdminUsersUiState>(AdminUsersUiState.Loading) private val internal = MutableStateFlow<AdminUsersUiState>(AdminUsersUiState.Loading)
@@ -30,6 +33,13 @@ class AdminUsersViewModel @Inject constructor(
init { init {
refresh() refresh()
// Screen-level auto-recovery (issue #1245): reload a failed list
// when server health returns instead of waiting for a manual pull.
viewModelScope.launch {
networkStatus.recoveries().collect {
if (internal.value is AdminUsersUiState.Error) refresh()
}
}
} }
fun refresh(): Job = viewModelScope.launch { fun refresh(): Job = viewModelScope.launch {
@@ -0,0 +1,41 @@
package com.fabledsword.minstrel.api.endpoints
import com.fabledsword.minstrel.models.wire.AdminTagSourceWire
import com.fabledsword.minstrel.models.wire.AdminTagSourcesListWire
import com.fabledsword.minstrel.models.wire.TestTagSourceWire
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import retrofit2.http.Body
import retrofit2.http.GET
import retrofit2.http.PATCH
import retrofit2.http.POST
import retrofit2.http.Path
/**
* Retrofit interface for `/api/admin/tag-sources` (#1521) — the admin
* surface for the tag-enrichment provider settings (#1490). Mirrors the
* web integrations "Tag enrichment sources" card. Same shape as the
* cover-sources admin surface; kept independent so a new tag source is
* added without touching art settings.
*/
interface AdminTagSourcesApi {
@GET("api/admin/tag-sources")
suspend fun list(): AdminTagSourcesListWire
@PATCH("api/admin/tag-sources/{id}")
suspend fun update(@Path("id") id: String, @Body body: UpdateTagSourceBody): AdminTagSourceWire
@POST("api/admin/tag-sources/{id}/test")
suspend fun test(@Path("id") id: String): TestTagSourceWire
}
/**
* PATCH body. Both fields are nullable + default-null so kotlinx omits the
* untouched one (the server reads a missing/null field as "leave
* unchanged"): send only `enabled` to toggle, only `apiKey` to set a key.
*/
@Serializable
data class UpdateTagSourceBody(
val enabled: Boolean? = null,
@SerialName("api_key") val apiKey: String? = null,
)
@@ -3,9 +3,13 @@ package com.fabledsword.minstrel.api.endpoints
import com.fabledsword.minstrel.models.wire.ArtistSuggestionWire import com.fabledsword.minstrel.models.wire.ArtistSuggestionWire
import com.fabledsword.minstrel.models.wire.CreateRequestBody import com.fabledsword.minstrel.models.wire.CreateRequestBody
import com.fabledsword.minstrel.models.wire.LidarrSearchResultWire 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.Body
import retrofit2.http.DELETE
import retrofit2.http.GET import retrofit2.http.GET
import retrofit2.http.POST import retrofit2.http.POST
import retrofit2.http.Path
import retrofit2.http.Query import retrofit2.http.Query
/** /**
@@ -30,4 +34,30 @@ interface DiscoverApi {
@POST("api/requests") @POST("api/requests")
suspend fun createRequest(@Body body: CreateRequestBody) 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.Insert
import androidx.room.OnConflictStrategy import androidx.room.OnConflictStrategy
import androidx.room.Query import androidx.room.Query
import androidx.room.Transaction
import com.fabledsword.minstrel.cache.db.entities.CachedHomeIndexEntity import com.fabledsword.minstrel.cache.db.entities.CachedHomeIndexEntity
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
@@ -21,12 +22,32 @@ interface CachedHomeIndexDao {
) )
suspend fun getBySection(section: String): List<CachedHomeIndexEntity> 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) @Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsertAll(rows: List<CachedHomeIndexEntity>) suspend fun upsertAll(rows: List<CachedHomeIndexEntity>)
/** Replace-all pattern; sync wipes a section then re-inserts. */ @Query("DELETE FROM cached_home_index WHERE section IN (:sections)")
@Query("DELETE FROM cached_home_index WHERE section = :section") suspend fun deleteSections(sections: List<String>)
suspend fun deleteBySection(section: 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") @Query("DELETE FROM cached_home_index")
suspend fun clear() suspend fun clear()
@@ -4,6 +4,7 @@ import androidx.room.Dao
import androidx.room.Insert import androidx.room.Insert
import androidx.room.OnConflictStrategy import androidx.room.OnConflictStrategy
import androidx.room.Query import androidx.room.Query
import androidx.room.Transaction
import com.fabledsword.minstrel.cache.db.entities.CachedPlaylistTrackEntity import com.fabledsword.minstrel.cache.db.entities.CachedPlaylistTrackEntity
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
@@ -35,10 +36,33 @@ interface CachedPlaylistTrackDao {
@Query("SELECT MAX(position) FROM cached_playlist_tracks WHERE playlistId = :playlistId") @Query("SELECT MAX(position) FROM cached_playlist_tracks WHERE playlistId = :playlistId")
suspend fun maxPosition(playlistId: String): Int? 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") @Query("DELETE FROM cached_playlist_tracks WHERE playlistId = :playlistId")
suspend fun deleteByPlaylist(playlistId: String) 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( @Query(
"DELETE FROM cached_playlist_tracks " + "DELETE FROM cached_playlist_tracks " +
"WHERE playlistId = :playlistId AND trackId IN (:trackIds)", "WHERE playlistId = :playlistId AND trackId IN (:trackIds)",
@@ -35,6 +35,12 @@ object MutationKind {
// background avoids the duplicate + orphan row the old offline-on-stop // background avoids the duplicate + orphan row the old offline-on-stop
// path produced (see 2026-06-11 contract audit). // path produced (see 2026-06-11 contract audit).
const val PLAY_ENDED: String = "play_ended" 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( suspend fun enqueueRequestCancel(requestId: String): Long = insertUserDriven(
MutationKind.REQUEST_CANCEL, MutationKind.REQUEST_CANCEL,
json.encodeToString( 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 * Persisted payload for `MutationKind.QUARANTINE_UNFLAG` — the
* `DELETE /api/quarantine/{trackId}` call lost during a connectivity * `DELETE /api/quarantine/{trackId}` call lost during a connectivity
@@ -239,6 +279,9 @@ data class PlayOfflinePayload(
val atIso: String, val atIso: String,
val durationPlayedMs: Long, val durationPlayedMs: Long,
val source: String? = null, val source: String? = null,
// #1551: device class for context conditioning; null on payloads queued
// before this field existed (decodes to null → server stores NULL).
val deviceClass: String? = null,
) )
/** /**
@@ -16,6 +16,7 @@ import com.fabledsword.minstrel.connectivity.NetworkStatusController
import com.fabledsword.minstrel.connectivity.ServerHealth import com.fabledsword.minstrel.connectivity.ServerHealth
import com.fabledsword.minstrel.models.wire.PlayEndedRequest import com.fabledsword.minstrel.models.wire.PlayEndedRequest
import com.fabledsword.minstrel.models.wire.PlayOfflineRequest import com.fabledsword.minstrel.models.wire.PlayOfflineRequest
import com.fabledsword.minstrel.models.wire.SnoozeSuggestionBody
import com.fabledsword.minstrel.auth.AuthStore import com.fabledsword.minstrel.auth.AuthStore
import com.fabledsword.minstrel.cache.db.dao.CachedMutationDao import com.fabledsword.minstrel.cache.db.dao.CachedMutationDao
import com.fabledsword.minstrel.cache.db.entities.CachedMutationEntity import com.fabledsword.minstrel.cache.db.entities.CachedMutationEntity
@@ -114,11 +115,12 @@ class MutationReplayer @Inject constructor(
private suspend fun drain() { private suspend fun drain() {
val rows = dao.getAll() val rows = dao.getAll()
// Collapse superseded like-toggles: only the latest desired state per // Collapse superseded toggles (likes, suggestion snoozes): only the
// (entity) is replayed; older toggles for the same entity are dropped // latest desired state per entity is replayed; older toggles for the
// unsent. Without this, partial-failure + differential retry could // same entity are dropped unsent. Without this, partial-failure +
// replay an older toggle last and invert the final like state. // differential retry could replay an older toggle last and invert the
val superseded = supersededLikeToggleIds(rows) // final state — a snooze the user already undid would come back.
val superseded = supersededToggleIds(rows, json)
for (row in rows) { for (row in rows) {
if (row.id in superseded) { if (row.id in superseded) {
dao.delete(row.id) 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 { private suspend fun outcomeFor(row: CachedMutationEntity): Outcome = try {
dispatch(row) dispatch(row)
} catch (e: HttpException) { } catch (e: HttpException) {
@@ -182,6 +165,7 @@ class MutationReplayer @Inject constructor(
MutationKind.PLAY_ENDED -> dispatchPlayEnded(row.payload) MutationKind.PLAY_ENDED -> dispatchPlayEnded(row.payload)
MutationKind.REQUEST_CANCEL -> dispatchRequestCancel(row.payload) MutationKind.REQUEST_CANCEL -> dispatchRequestCancel(row.payload)
MutationKind.PLAYBACK_ERROR_REPORT -> dispatchPlaybackErrorReport(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. // Unknown kind — drop so a stale schema entry can't wedge the queue.
else -> Outcome.DROP else -> Outcome.DROP
} }
@@ -254,6 +238,7 @@ class MutationReplayer @Inject constructor(
at = decoded.atIso, at = decoded.atIso,
durationPlayedMs = decoded.durationPlayedMs, durationPlayedMs = decoded.durationPlayedMs,
source = decoded.source, source = decoded.source,
deviceClass = decoded.deviceClass,
), ),
) )
return Outcome.SENT return Outcome.SENT
@@ -276,6 +261,24 @@ class MutationReplayer @Inject constructor(
return Outcome.SENT 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 { private suspend fun dispatchPlaybackErrorReport(payload: String): Outcome {
val decoded = json.decodeFromString(PlaybackErrorReportPayload.serializer(), payload) val decoded = json.decodeFromString(PlaybackErrorReportPayload.serializer(), payload)
playbackErrorsApi.report( playbackErrorsApi.report(
@@ -296,3 +299,46 @@ class MutationReplayer @Inject constructor(
const val HTTP_TOO_MANY = 429 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
}
@@ -1,5 +1,6 @@
package com.fabledsword.minstrel.cache.sync package com.fabledsword.minstrel.cache.sync
import com.fabledsword.minstrel.api.ErrorCopy
import com.fabledsword.minstrel.api.endpoints.SyncApi import com.fabledsword.minstrel.api.endpoints.SyncApi
import com.fabledsword.minstrel.auth.AuthStore import com.fabledsword.minstrel.auth.AuthStore
import com.fabledsword.minstrel.connectivity.NetworkStatusController import com.fabledsword.minstrel.connectivity.NetworkStatusController
@@ -17,6 +18,9 @@ import com.fabledsword.minstrel.models.wire.SyncAlbumWire
import com.fabledsword.minstrel.models.wire.SyncArtistWire import com.fabledsword.minstrel.models.wire.SyncArtistWire
import com.fabledsword.minstrel.models.wire.SyncTrackWire import com.fabledsword.minstrel.models.wire.SyncTrackWire
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.filterNotNull
@@ -64,6 +68,17 @@ class SyncController @Inject constructor(
// pull-to-refresh) can fire concurrently; coalesce them into one pass. // pull-to-refresh) can fire concurrently; coalesce them into one pass.
private val mutex = Mutex() private val mutex = Mutex()
private val lastSyncErrorInternal = MutableStateFlow<String?>(null)
/**
* Human copy for the most recent sync failure; null after any clean
* pass. Lets the Library screen distinguish "empty because the first
* sync failed" (show error + retry) from "genuinely empty library"
* (show welcome copy) — a failed sync over a populated cache stays
* silent, since the cached content is still the better surface.
*/
val lastSyncError: StateFlow<String?> = lastSyncErrorInternal.asStateFlow()
init { init {
scope.launch { scope.launch {
authStore.sessionCookie authStore.sessionCookie
@@ -82,11 +97,13 @@ class SyncController @Inject constructor(
} }
} }
/** Public entry-point for "Sync now" affordances. Swallows errors. */ /** Public entry-point for "Sync now" affordances. Swallows errors into [lastSyncError]. */
suspend fun syncSafe() { suspend fun syncSafe() {
if (!mutex.tryLock()) return if (!mutex.tryLock()) return
try { try {
runCatching { sync() } runCatching { sync() }
.onSuccess { lastSyncErrorInternal.value = null }
.onFailure { lastSyncErrorInternal.value = ErrorCopy.fromThrowable(it) }
} finally { } finally {
mutex.unlock() mutex.unlock()
} }
@@ -0,0 +1,27 @@
package com.fabledsword.minstrel.connectivity
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.map
/**
* Emits once each time server health RETURNS to [ServerHealth.Healthy]
* after the collector subscribed — the StateFlow's replayed current value
* is dropped, so only genuine down→up transitions fire (a screen that
* subscribes while already Healthy doesn't double-load).
*
* This is the screen-level half of the recovery idiom the app-lifetime
* singletons (SyncController / MutationReplayer / DiagnosticsUploader)
* already use: a ViewModel collects this in its viewModelScope and re-runs
* its load, so a surface that failed while the server was unreachable
* heals itself the moment connectivity returns instead of sitting in the
* failed state until a manual pull-to-refresh.
*/
fun NetworkStatusController.recoveries(): Flow<Unit> = state
.map { it == ServerHealth.Healthy }
.distinctUntilChanged()
.drop(1)
.filter { it }
.map { }
@@ -7,10 +7,14 @@ import com.fabledsword.minstrel.models.ArtistSuggestionRef
import com.fabledsword.minstrel.models.LidarrRequestKind import com.fabledsword.minstrel.models.LidarrRequestKind
import com.fabledsword.minstrel.models.LidarrSearchResultRef import com.fabledsword.minstrel.models.LidarrSearchResultRef
import com.fabledsword.minstrel.models.SeedContributionRef 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.ArtistSuggestionWire
import com.fabledsword.minstrel.models.wire.CreateRequestBody import com.fabledsword.minstrel.models.wire.CreateRequestBody
import com.fabledsword.minstrel.models.wire.LidarrSearchResultWire import com.fabledsword.minstrel.models.wire.LidarrSearchResultWire
import com.fabledsword.minstrel.models.wire.SeedContributionWire 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.Retrofit
import retrofit2.create import retrofit2.create
import javax.inject.Inject import javax.inject.Inject
@@ -46,6 +50,69 @@ class DiscoverRepository @Inject constructor(
suspend fun listSuggestions(): List<ArtistSuggestionRef> = suspend fun listSuggestions(): List<ArtistSuggestionRef> =
api.listSuggestions().map { it.toDomain() } 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> = suspend fun search(query: String, kind: LidarrRequestKind): List<LidarrSearchResultRef> =
api.search(query = query, kind = kind.wire).map { it.toDomain() } api.search(query = query, kind = kind.wire).map { it.toDomain() }
@@ -85,6 +152,13 @@ class DiscoverRepository @Inject constructor(
RequestOutcome.QUEUED 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) ── // ── Mappers (internal — wire types stay out of UI) ──
@@ -105,6 +179,7 @@ private fun ArtistSuggestionWire.toDomain(): ArtistSuggestionRef = ArtistSuggest
name = name, name = name,
imageUrl = imageUrl, imageUrl = imageUrl,
attribution = attribution.map { it.toDomain() }, attribution = attribution.map { it.toDomain() },
matchedTags = matchedTags,
) )
private fun SeedContributionWire.toDomain(): SeedContributionRef = SeedContributionRef( private fun SeedContributionWire.toDomain(): SeedContributionRef = SeedContributionRef(
@@ -112,6 +187,12 @@ private fun SeedContributionWire.toDomain(): SeedContributionRef = SeedContribut
isLiked = isLiked, isLiked = isLiked,
) )
private fun SuggestionSnoozeWire.toDomain(): SuggestionSnoozeRef = SuggestionSnoozeRef(
mbid = mbid,
name = name,
snoozedUntil = snoozedUntil,
)
private fun RequestCreatePayload.toBody(): CreateRequestBody = CreateRequestBody( private fun RequestCreatePayload.toBody(): CreateRequestBody = CreateRequestBody(
kind = kind, kind = kind,
artistMbid = artistMbid, artistMbid = artistMbid,
@@ -40,7 +40,9 @@ import com.fabledsword.minstrel.discover.data.RequestOutcome
import com.fabledsword.minstrel.models.ArtistSuggestionRef import com.fabledsword.minstrel.models.ArtistSuggestionRef
import com.fabledsword.minstrel.models.LidarrRequestKind import com.fabledsword.minstrel.models.LidarrRequestKind
import com.fabledsword.minstrel.models.LidarrSearchResultRef import com.fabledsword.minstrel.models.LidarrSearchResultRef
import com.fabledsword.minstrel.models.SuggestionSnoozeRef
import com.fabledsword.minstrel.nav.Discover import com.fabledsword.minstrel.nav.Discover
import com.fabledsword.minstrel.shared.widgets.ErrorRetry
import com.fabledsword.minstrel.shared.widgets.LoadingCentered import com.fabledsword.minstrel.shared.widgets.LoadingCentered
import com.fabledsword.minstrel.shared.widgets.MinstrelTopAppBar import com.fabledsword.minstrel.shared.widgets.MinstrelTopAppBar
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
@@ -103,12 +105,25 @@ private fun DiscoverBody(
ResultsState.Idle -> SuggestionsPane( ResultsState.Idle -> SuggestionsPane(
state = state.suggestions, state = state.suggestions,
locallyRequestedMbids = state.locallyRequestedMbids, 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 -> onRequest = { s ->
scope.launch { scope.launch {
val outcome = viewModel.requestSuggestion(s) val outcome = viewModel.requestSuggestion(s)
snackbar.showSnackbar(snackbarFor(outcome, s.name)) snackbar.showSnackbar(snackbarFor(outcome, s.name))
} }
}, },
onRetry = { viewModel.loadSuggestions() },
) )
ResultsState.Loading -> LoadingCentered() ResultsState.Loading -> LoadingCentered()
is ResultsState.Loaded -> ResultsList( is ResultsState.Loaded -> ResultsList(
@@ -121,7 +136,11 @@ private fun DiscoverBody(
} }
}, },
) )
is ResultsState.Error -> CenteredMessage("Search failed: ${r.message}") is ResultsState.Error -> ErrorRetry(
title = "Search failed",
message = r.message,
onRetry = viewModel::runSearch,
)
} }
} }
} }
@@ -172,17 +191,36 @@ 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 @Composable
private fun SuggestionsPane( private fun SuggestionsPane(
state: SuggestionState, state: SuggestionState,
locallyRequestedMbids: Set<String>, locallyRequestedMbids: Set<String>,
snoozeUi: SnoozeUi,
onRequest: (ArtistSuggestionRef) -> Unit, onRequest: (ArtistSuggestionRef) -> Unit,
onRetry: () -> Unit,
) { ) {
when (state) { when (state) {
SuggestionState.Loading -> LoadingCentered() SuggestionState.Loading -> LoadingCentered()
is SuggestionState.Error -> CenteredMessage("Couldn't load suggestions.") is SuggestionState.Error -> ErrorRetry(
title = "Couldn't load suggestions",
message = state.message,
onRetry = onRetry,
)
is SuggestionState.Loaded -> SuggestionsList( is SuggestionState.Loaded -> SuggestionsList(
items = state.items.filter { it.mbid !in locallyRequestedMbids }, items = state.items.filter { it.mbid !in locallyRequestedMbids },
snoozeUi = snoozeUi,
onRequest = onRequest, onRequest = onRequest,
) )
} }
@@ -191,6 +229,7 @@ private fun SuggestionsPane(
@Composable @Composable
private fun SuggestionsList( private fun SuggestionsList(
items: List<ArtistSuggestionRef>, items: List<ArtistSuggestionRef>,
snoozeUi: SnoozeUi,
onRequest: (ArtistSuggestionRef) -> Unit, onRequest: (ArtistSuggestionRef) -> Unit,
) { ) {
LazyColumn( LazyColumn(
@@ -199,13 +238,61 @@ private fun SuggestionsList(
) { ) {
item { SuggestionsHeader() } item { SuggestionsHeader() }
if (items.isEmpty()) { 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 { } else {
items(items = items, key = { it.mbid }) { s -> 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() 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.AssistChip
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier 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.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage import coil3.compose.AsyncImage
import com.composables.icons.lucide.Clock
import com.composables.icons.lucide.Disc3 import com.composables.icons.lucide.Disc3
import com.composables.icons.lucide.Lucide import com.composables.icons.lucide.Lucide
import com.composables.icons.lucide.User import com.composables.icons.lucide.User
import com.fabledsword.minstrel.models.ArtistSuggestionRef import com.fabledsword.minstrel.models.ArtistSuggestionRef
import com.fabledsword.minstrel.models.LidarrSearchResultRef import com.fabledsword.minstrel.models.LidarrSearchResultRef
import com.fabledsword.minstrel.models.SuggestionSnoozeRef
@Composable @Composable
internal fun SuggestionTile(s: ArtistSuggestionRef, onRequest: () -> Unit) { internal fun SuggestionTile(
s: ArtistSuggestionRef,
snoozed: Boolean,
onRequest: () -> Unit,
onSnooze: () -> Unit,
onUnsnooze: () -> Unit,
) {
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
@@ -48,9 +58,13 @@ internal fun SuggestionTile(s: ArtistSuggestionRef, onRequest: () -> Unit) {
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis, 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(
text = s.attributionText, text = secondary,
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 2, 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") }
} }
} }
@@ -3,11 +3,14 @@ package com.fabledsword.minstrel.discover.ui
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.fabledsword.minstrel.api.ErrorCopy import com.fabledsword.minstrel.api.ErrorCopy
import com.fabledsword.minstrel.connectivity.NetworkStatusController
import com.fabledsword.minstrel.connectivity.recoveries
import com.fabledsword.minstrel.discover.data.DiscoverRepository import com.fabledsword.minstrel.discover.data.DiscoverRepository
import com.fabledsword.minstrel.discover.data.RequestOutcome import com.fabledsword.minstrel.discover.data.RequestOutcome
import com.fabledsword.minstrel.models.ArtistSuggestionRef import com.fabledsword.minstrel.models.ArtistSuggestionRef
import com.fabledsword.minstrel.models.LidarrRequestKind import com.fabledsword.minstrel.models.LidarrRequestKind
import com.fabledsword.minstrel.models.LidarrSearchResultRef import com.fabledsword.minstrel.models.LidarrSearchResultRef
import com.fabledsword.minstrel.models.SuggestionSnoozeRef
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
@@ -25,6 +28,17 @@ data class DiscoverState(
val suggestions: SuggestionState = SuggestionState.Loading, val suggestions: SuggestionState = SuggestionState.Loading,
val results: ResultsState = ResultsState.Idle, val results: ResultsState = ResultsState.Idle,
val locallyRequestedMbids: Set<String> = emptySet(), 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 { sealed interface SuggestionState {
@@ -46,6 +60,7 @@ sealed interface ResultsState {
@HiltViewModel @HiltViewModel
class DiscoverViewModel @Inject constructor( class DiscoverViewModel @Inject constructor(
private val repository: DiscoverRepository, private val repository: DiscoverRepository,
networkStatus: NetworkStatusController,
) : ViewModel() { ) : ViewModel() {
private val internal = MutableStateFlow(DiscoverState()) private val internal = MutableStateFlow(DiscoverState())
@@ -53,6 +68,15 @@ class DiscoverViewModel @Inject constructor(
init { init {
loadSuggestions() loadSuggestions()
// Screen-level auto-recovery (issue #1245): re-run whichever pane
// failed while the server was unreachable once health returns.
viewModelScope.launch {
networkStatus.recoveries().collect {
val s = internal.value
if (s.suggestions is SuggestionState.Error) loadSuggestions()
if (s.results is ResultsState.Error) runSearch()
}
}
} }
fun setQuery(value: String) { fun setQuery(value: String) {
@@ -84,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() { fun runSearch() {
@@ -19,6 +19,8 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.fabledsword.minstrel.api.ErrorCopy import com.fabledsword.minstrel.api.ErrorCopy
import com.fabledsword.minstrel.connectivity.NetworkStatusController
import com.fabledsword.minstrel.connectivity.recoveries
import com.fabledsword.minstrel.history.data.HistoryEntry import com.fabledsword.minstrel.history.data.HistoryEntry
import com.fabledsword.minstrel.history.data.HistoryRepository import com.fabledsword.minstrel.history.data.HistoryRepository
import com.fabledsword.minstrel.models.TrackRef import com.fabledsword.minstrel.models.TrackRef
@@ -26,6 +28,7 @@ import com.fabledsword.minstrel.player.PlayerController
import com.fabledsword.minstrel.shared.UiState import com.fabledsword.minstrel.shared.UiState
import com.fabledsword.minstrel.shared.widgets.TrackRow import com.fabledsword.minstrel.shared.widgets.TrackRow
import com.fabledsword.minstrel.shared.widgets.EmptyState import com.fabledsword.minstrel.shared.widgets.EmptyState
import com.fabledsword.minstrel.shared.widgets.ErrorRetry
import com.fabledsword.minstrel.shared.widgets.LoadingCentered import com.fabledsword.minstrel.shared.widgets.LoadingCentered
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
import com.fabledsword.minstrel.shared.widgets.TrackCoverThumb import com.fabledsword.minstrel.shared.widgets.TrackCoverThumb
@@ -56,6 +59,7 @@ private const val SHARE_STOP_TIMEOUT_MS = 5_000L
class HistoryTabViewModel @Inject constructor( class HistoryTabViewModel @Inject constructor(
private val repository: HistoryRepository, private val repository: HistoryRepository,
private val player: PlayerController, private val player: PlayerController,
networkStatus: NetworkStatusController,
) : ViewModel() { ) : ViewModel() {
private val refreshError = MutableStateFlow<String?>(null) private val refreshError = MutableStateFlow<String?>(null)
@@ -82,6 +86,11 @@ class HistoryTabViewModel @Inject constructor(
init { init {
refresh() refresh()
// Screen-level auto-recovery (issue #1245): re-pull when server
// health returns, so a load that failed offline heals unprompted.
viewModelScope.launch {
networkStatus.recoveries().collect { refresh() }
}
} }
fun refresh(): Job = viewModelScope.launch { fun refresh(): Job = viewModelScope.launch {
@@ -116,9 +125,10 @@ fun HistoryTab(
title = "No listening history yet", title = "No listening history yet",
body = "Play something — your recent plays will show up here.", body = "Play something — your recent plays will show up here.",
) )
is UiState.Error -> EmptyState( is UiState.Error -> ErrorRetry(
title = "Couldn't load history", title = "Couldn't load history",
body = s.message, message = s.message,
onRetry = { viewModel.refresh() },
) )
is UiState.Success -> HistoryList( is UiState.Success -> HistoryList(
entries = s.data, entries = s.data,
@@ -14,8 +14,10 @@ import com.fabledsword.minstrel.models.TrackRef
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
import retrofit2.Retrofit import retrofit2.Retrofit
import retrofit2.create import retrofit2.create
import javax.inject.Inject import javax.inject.Inject
@@ -34,9 +36,10 @@ import javax.inject.Singleton
* reveals when the fetch lands and Room re-emits. Mirrors Flutter's * reveals when the fetch lands and Room re-emits. Mirrors Flutter's
* per-item tile providers. * per-item tile providers.
* *
* `refreshIndex()` pulls `GET /api/home/index`, replaces each section * `refreshIndex()` pulls `GET /api/home/index`, swaps all sections in
* in-place (delete-then-insert, so the section Flows re-fire), and * one transaction (so the rows update together in a single emission
* pre-warms the top artists via [HomeArtistPrewarmer]. * rather than collapsing and refilling), and pre-warms the top artists
* via [HomeArtistPrewarmer].
*/ */
@Singleton @Singleton
// Per-section observe accessors (one per Home row) inflate the function // 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>>> = fun observeYouMightLikeArtists(): Flow<List<HomeTile<ArtistRef>>> =
observeArtistSection(SECTION_YOU_MIGHT_LIKE_ARTISTS) 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, * Pulls /api/home/index and swaps every cached_home_index section in
* and pre-warms the top artists. The section Flows re-fire on the * a single transaction, then pre-warms the top artists. Missing
* index change; missing entity rows hydrate via the on-miss path. * 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() { suspend fun refreshIndex() {
val wire = api.getHomeIndex() val wire = api.getHomeIndex()
replaceSection(SECTION_RECENTLY_ADDED_ALBUMS, "album", wire.recentlyAddedAlbums) homeIndexDao.replaceSections(
replaceSection(SECTION_REDISCOVER_ALBUMS, "album", wire.rediscoverAlbums) sections = ALL_SECTIONS,
replaceSection(SECTION_REDISCOVER_ARTISTS, "artist", wire.rediscoverArtists) rows = rowsFor(SECTION_RECENTLY_ADDED_ALBUMS, "album", wire.recentlyAddedAlbums) +
replaceSection(SECTION_MOST_PLAYED_TRACKS, "track", wire.mostPlayedTracks) rowsFor(SECTION_REDISCOVER_ALBUMS, "album", wire.rediscoverAlbums) +
replaceSection(SECTION_LAST_PLAYED_ARTISTS, "artist", wire.lastPlayedArtists) rowsFor(SECTION_REDISCOVER_ARTISTS, "artist", wire.rediscoverArtists) +
replaceSection(SECTION_YOU_MIGHT_LIKE_ALBUMS, "album", wire.youMightLikeAlbums) rowsFor(SECTION_MOST_PLAYED_TRACKS, "track", wire.mostPlayedTracks) +
replaceSection(SECTION_YOU_MIGHT_LIKE_ARTISTS, "artist", wire.youMightLikeArtists) 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( prewarmer.warm(
wire.rediscoverArtists + wire.lastPlayedArtists + wire.youMightLikeArtists, wire.rediscoverArtists + wire.lastPlayedArtists + wire.youMightLikeArtists,
) )
} }
private suspend fun replaceSection(section: String, entityType: String, ids: List<String>) { private fun rowsFor(
homeIndexDao.deleteBySection(section) section: String,
if (ids.isEmpty()) return entityType: String,
homeIndexDao.upsertAll( ids: List<String>,
ids.mapIndexed { index, id -> ): List<CachedHomeIndexEntity> = ids.mapIndexed { index, id ->
CachedHomeIndexEntity( CachedHomeIndexEntity(
section = section, section = section,
position = index, position = index,
entityType = entityType, entityType = entityType,
entityId = id, 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) @OptIn(ExperimentalCoroutinesApi::class)
private fun observeAlbumSection(section: String): Flow<List<HomeTile<AlbumRef>>> = private fun observeAlbumSection(section: String): Flow<List<HomeTile<AlbumRef>>> =
homeIndexDao.observeBySection(section).flatMapLatest { rows -> observeSectionIds(section).flatMapLatest { ids ->
if (rows.isEmpty()) { if (ids.isEmpty()) {
flowOf(emptyList()) flowOf(emptyList())
} else { } else {
combine(rows.map { metadataProvider.observeAlbum(it.entityId) }) { refs -> combine(ids.map { metadataProvider.observeAlbum(it) }) { refs ->
rows.mapIndexed { i, r -> HomeTile(r.entityId, refs[i]) } ids.mapIndexed { i, id -> HomeTile(id, refs[i]) }
} }
} }
} }
@OptIn(ExperimentalCoroutinesApi::class) @OptIn(ExperimentalCoroutinesApi::class)
private fun observeArtistSection(section: String): Flow<List<HomeTile<ArtistRef>>> = private fun observeArtistSection(section: String): Flow<List<HomeTile<ArtistRef>>> =
homeIndexDao.observeBySection(section).flatMapLatest { rows -> observeSectionIds(section).flatMapLatest { ids ->
if (rows.isEmpty()) { if (ids.isEmpty()) {
flowOf(emptyList()) flowOf(emptyList())
} else { } else {
combine(rows.map { metadataProvider.observeArtist(it.entityId) }) { refs -> combine(ids.map { metadataProvider.observeArtist(it) }) { refs ->
rows.mapIndexed { i, r -> HomeTile(r.entityId, refs[i]) } ids.mapIndexed { i, id -> HomeTile(id, refs[i]) }
} }
} }
} }
@OptIn(ExperimentalCoroutinesApi::class) @OptIn(ExperimentalCoroutinesApi::class)
private fun observeTrackSection(section: String): Flow<List<HomeTile<TrackRef>>> = private fun observeTrackSection(section: String): Flow<List<HomeTile<TrackRef>>> =
homeIndexDao.observeBySection(section).flatMapLatest { rows -> observeSectionIds(section).flatMapLatest { ids ->
if (rows.isEmpty()) { if (ids.isEmpty()) {
flowOf(emptyList()) flowOf(emptyList())
} else { } else {
combine(rows.map { metadataProvider.observeTrack(it.entityId) }) { refs -> combine(ids.map { metadataProvider.observeTrack(it) }) { refs ->
rows.mapIndexed { i, r -> HomeTile(r.entityId, refs[i]) } 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_LAST_PLAYED_ARTISTS = "last_played_artists"
const val SECTION_YOU_MIGHT_LIKE_ALBUMS = "you_might_like_albums" const val SECTION_YOU_MIGHT_LIKE_ALBUMS = "you_might_like_albums"
const val SECTION_YOU_MIGHT_LIKE_ARTISTS = "you_might_like_artists" 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,
)
} }
} }
@@ -2,11 +2,19 @@
package com.fabledsword.minstrel.home.ui package com.fabledsword.minstrel.home.ui
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.Crossfade import androidx.compose.animation.Crossfade
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInHorizontally
import androidx.compose.animation.slideOutHorizontally
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
@@ -27,6 +35,7 @@ import androidx.compose.foundation.lazy.grid.items as gridItems
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
@@ -34,6 +43,7 @@ import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
@@ -55,6 +65,7 @@ import com.composables.icons.lucide.Lucide
import com.composables.icons.lucide.Music import com.composables.icons.lucide.Music
import com.fabledsword.minstrel.api.ErrorCopy import com.fabledsword.minstrel.api.ErrorCopy
import com.fabledsword.minstrel.connectivity.ServerHealth import com.fabledsword.minstrel.connectivity.ServerHealth
import com.fabledsword.minstrel.connectivity.recoveries
import com.fabledsword.minstrel.home.data.HomeRepository import com.fabledsword.minstrel.home.data.HomeRepository
import com.fabledsword.minstrel.library.data.LibraryRepository import com.fabledsword.minstrel.library.data.LibraryRepository
import com.fabledsword.minstrel.library.widgets.AlbumCard import com.fabledsword.minstrel.library.widgets.AlbumCard
@@ -75,28 +86,38 @@ import com.fabledsword.minstrel.playlists.widgets.OfflinePoolCard
import com.fabledsword.minstrel.playlists.widgets.PlaylistCard import com.fabledsword.minstrel.playlists.widgets.PlaylistCard
import com.fabledsword.minstrel.playlists.widgets.PlaylistPlaceholderCard import com.fabledsword.minstrel.playlists.widgets.PlaylistPlaceholderCard
import com.fabledsword.minstrel.shared.UiState 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.asCacheFirstStateFlow
import com.fabledsword.minstrel.shared.widgets.ArtSettleTracker
import com.fabledsword.minstrel.shared.widgets.EmptyState 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.HorizontalScrollRow
import com.fabledsword.minstrel.shared.widgets.LocalArtSettleTracker
import com.fabledsword.minstrel.shared.widgets.MinstrelTopAppBar import com.fabledsword.minstrel.shared.widgets.MinstrelTopAppBar
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
import com.fabledsword.minstrel.shared.widgets.SkeletonAlbumTile import com.fabledsword.minstrel.shared.widgets.SkeletonAlbumTile
import com.fabledsword.minstrel.shared.widgets.SkeletonArtistTile import com.fabledsword.minstrel.shared.widgets.SkeletonArtistTile
import com.fabledsword.minstrel.shared.widgets.SkeletonSectionHeader import com.fabledsword.minstrel.shared.widgets.SkeletonSectionHeader
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Job import kotlinx.coroutines.async
import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeoutOrNull
import javax.inject.Inject import javax.inject.Inject
private const val SHARE_STOP_TIMEOUT_MS = 5_000L private const val SHARE_STOP_TIMEOUT_MS = 5_000L
@@ -108,6 +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_ROWS = 2
private const val RECENTLY_ADDED_GRID_HEIGHT_DP = 440 private const val RECENTLY_ADDED_GRID_HEIGHT_DP = 440
// "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 ─────────────────────────────────────────────────────────── // ─── State ───────────────────────────────────────────────────────────
data class HomeSections( data class HomeSections(
@@ -162,23 +199,29 @@ class HomeViewModel @Inject constructor(
initialValue = false, 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()
init { /**
refresh() * Copy for the most recent /home/index refresh failure; null once a
// #968: the daily 03:00 rebuild (and manual refresh) emit * refresh succeeds. Folded into [uiState] so an empty cache + failed
// playlist.system_rebuilt; re-pull Home so the system-playlist tiles * refresh renders as Error (with retry) instead of masquerading as
// and You-might-like rows reflect the new snapshot without a manual * the "Welcome to Minstrel" empty state.
// reload. Mirrors the web SSE consumer. */
viewModelScope.launch { private val refreshError = MutableStateFlow<String?>(null)
eventsStream.events
.filter { it.kind == "playlist.system_rebuilt" } /**
.collect { refresh() } * 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 artTracker = ArtSettleTracker()
/** /**
* Tap an offline pool: shuffle + play its cached tracks. Empty * Tap an offline pool: shuffle + play its cached tracks. Empty
@@ -191,7 +234,7 @@ class HomeViewModel @Inject constructor(
OfflinePoolKind.LIKED -> shuffleSource.liked() OfflinePoolKind.LIKED -> shuffleSource.liked()
}.shuffled() }.shuffled()
if (tracks.isEmpty()) { if (tracks.isEmpty()) {
poolMessages.trySend("No cached ${kind.label} tracks yet") snackbarMessages.trySend("No cached ${kind.label} tracks yet")
} else { } else {
player.setQueue(tracks, initialIndex = 0, source = "offline:${kind.name}") player.setQueue(tracks, initialIndex = 0, source = "offline:${kind.name}")
} }
@@ -228,14 +271,14 @@ class HomeViewModel @Inject constructor(
try { try {
val detail = libraryRepository.refreshAlbumDetail(albumId) val detail = libraryRepository.refreshAlbumDetail(albumId)
if (detail.tracks.isEmpty()) { if (detail.tracks.isEmpty()) {
poolMessages.trySend("This album has no tracks to play.") snackbarMessages.trySend("This album has no tracks to play.")
} else { } else {
player.setQueue(detail.tracks, initialIndex = 0, source = "album:$albumId") player.setQueue(detail.tracks, initialIndex = 0, source = "album:$albumId")
} }
} catch ( } catch (
@Suppress("TooGenericExceptionCaught") e: Throwable, @Suppress("TooGenericExceptionCaught") e: Throwable,
) { ) {
poolMessages.trySend( snackbarMessages.trySend(
"Couldn't start playback: ${ErrorCopy.fromThrowable(e)}", "Couldn't start playback: ${ErrorCopy.fromThrowable(e)}",
) )
} }
@@ -253,14 +296,14 @@ class HomeViewModel @Inject constructor(
try { try {
val tracks = libraryRepository.fetchArtistTracks(artistId).shuffled() val tracks = libraryRepository.fetchArtistTracks(artistId).shuffled()
if (tracks.isEmpty()) { if (tracks.isEmpty()) {
poolMessages.trySend("This artist has no tracks to play.") snackbarMessages.trySend("This artist has no tracks to play.")
} else { } else {
player.setQueue(tracks, initialIndex = 0, source = "artist:$artistId") player.setQueue(tracks, initialIndex = 0, source = "artist:$artistId")
} }
} catch ( } catch (
@Suppress("TooGenericExceptionCaught") e: Throwable, @Suppress("TooGenericExceptionCaught") e: Throwable,
) { ) {
poolMessages.trySend( snackbarMessages.trySend(
"Couldn't start playback: ${ErrorCopy.fromThrowable(e)}", "Couldn't start playback: ${ErrorCopy.fromThrowable(e)}",
) )
} }
@@ -278,26 +321,67 @@ class HomeViewModel @Inject constructor(
suspend fun playPlaylist(playlist: PlaylistRef) { suspend fun playPlaylist(playlist: PlaylistRef) {
viewModelScope.launch { viewModelScope.launch {
playPlaylistShuffled(playlist, playlistsRepository, player) { playPlaylistShuffled(playlist, playlistsRepository, player) {
poolMessages.trySend(it) snackbarMessages.trySend(it)
} }
}.join() }.join()
} }
/** /**
* Pulls both /home/index and the playlists list. Returns the Job * Pulls /home/index, the playlists list and the system-playlist
* for the combined refresh so a pull-to-refresh wrapper can await * status. Returns true when the load-bearing /home/index pull
* actual completion before hiding the indicator. * 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 { private suspend fun runRefresh(): Boolean = coroutineScope {
val home = launch { runCatching { homeRepository.refreshIndex() } } // /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 lists = launch { runCatching { playlistsRepository.refreshList() } }
val status = launch { val status = launch {
runCatching { homeRepository.getSystemPlaylistsStatus() } runCatching { homeRepository.getSystemPlaylistsStatus() }
.onSuccess { systemStatusInternal.value = it } .onSuccess { systemStatusInternal.value = it }
} }
home.join()
lists.join() lists.join()
status.join() status.join()
home.await()
}
/**
* 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.
*/
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 }
}
} }
val uiState: StateFlow<UiState<HomeSections>> = val uiState: StateFlow<UiState<HomeSections>> =
@@ -336,10 +420,136 @@ class HomeViewModel @Inject constructor(
} }
private fun combineHomeFlows() = private fun combineHomeFlows() =
observeHomeSections().combine(playlistsRepository.observeAll()) { sections, playlists -> combine(
observeHomeSections(),
playlistsRepository.observeAll(),
refreshError,
) { sections, playlists, err ->
val merged = sections.copy(playlists = playlists) val merged = sections.copy(playlists = playlists)
if (merged.isAllEmpty) UiState.Empty else UiState.Success(merged) when {
!merged.isAllEmpty -> UiState.Success(merged)
err != null -> UiState.Error(err)
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 ────────────────────────────────────────────────────────── // ─── Screen ──────────────────────────────────────────────────────────
@@ -367,46 +577,114 @@ fun HomeScreen(
val state by viewModel.uiState.collectAsStateWithLifecycle() val state by viewModel.uiState.collectAsStateWithLifecycle()
val systemStatus by viewModel.systemStatus.collectAsStateWithLifecycle() val systemStatus by viewModel.systemStatus.collectAsStateWithLifecycle()
val offline by viewModel.offline.collectAsStateWithLifecycle() val offline by viewModel.offline.collectAsStateWithLifecycle()
val updating by viewModel.isUpdating.collectAsStateWithLifecycle()
PullToRefreshScaffold( PullToRefreshScaffold(
onRefresh = { viewModel.refresh().join() }, onRefresh = { viewModel.refreshFromPull() },
modifier = Modifier.fillMaxSize().padding(inner), modifier = Modifier.fillMaxSize().padding(inner),
) { ) {
// Key Crossfade on the state CLASS, not the instance. Each Box(Modifier.fillMaxSize()) {
// section emission produces a new UiState.Success(data); if // Every cover below reports its load state to the tracker,
// we keyed on `state` directly, every per-section // so the veil can wait for artwork instead of guessing.
// hydration tick would re-run the 300ms crossfade, and CompositionLocalProvider(
// first-sign-in (six sections cascading in) reads as LocalArtSettleTracker provides viewModel.artTracker,
// continuous flicker. Keying on the class restricts the ) {
// animation to Loading↔Success↔Empty↔Error transitions and HomeStateCrossfade(state, systemStatus, offline, navController, viewModel)
// lets normal Success→Success recompositions update the
// LazyColumn without a fade.
Crossfade(targetState = state::class, label = "home-state") { _ ->
when (val s = state) {
UiState.Loading -> HomeSkeletonContent()
UiState.Empty -> EmptyState(
title = "Welcome to Minstrel",
body = "Nothing to show yet — scan a folder in your server " +
"settings, then come back here for system playlists " +
"and recommendations.",
)
is UiState.Error -> EmptyState(
title = "Couldn't load home",
body = s.message,
)
is UiState.Success -> HomeSuccessContent(
sections = s.data,
systemStatus = systemStatus,
offline = offline,
onAlbumClick = { id -> navController.navigate(AlbumDetail(id)) },
onArtistClick = { id -> navController.navigate(ArtistDetail(id)) },
onPlaylistClick = { id -> navController.navigate(PlaylistDetail(id)) },
onMostPlayedTap = viewModel::playMostPlayed,
onPlayPool = viewModel::playPool,
onPlayAlbum = viewModel::playAlbum,
onPlayArtist = viewModel::playArtistShuffled,
onPlayPlaylist = viewModel::playPlaylist,
)
} }
// 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)
}
}
}
}
/**
* The Loading/Empty/Error/Success switch for Home, crossfaded on state.
*
* Key the Crossfade on the state CLASS, not the instance. Each section
* emission produces a new UiState.Success(data); if we keyed on `state`
* directly, every per-section hydration tick would re-run the 300ms
* crossfade, and first-sign-in (six sections cascading in) reads as
* continuous flicker. Keying on the class restricts the animation to
* Loading↔Success↔Empty↔Error transitions and lets normal Success→Success
* recompositions update the LazyColumn without a fade.
*/
@Composable
private fun HomeStateCrossfade(
state: UiState<HomeSections>,
systemStatus: SystemPlaylistsStatus,
offline: Boolean,
navController: NavHostController,
viewModel: HomeViewModel,
) {
Crossfade(targetState = state::class, label = "home-state") { _ ->
when (val s = state) {
UiState.Loading -> HomeSkeletonContent()
UiState.Empty -> EmptyState(
title = "Welcome to Minstrel",
body = "Nothing to show yet — scan a folder in your server " +
"settings, then come back here for system playlists " +
"and recommendations.",
)
is UiState.Error -> ErrorRetry(
title = "Couldn't load home",
message = s.message,
onRetry = { viewModel.retry() },
)
is UiState.Success -> HomeSuccessContent(
sections = s.data,
systemStatus = systemStatus,
offline = offline,
onAlbumClick = { id -> navController.navigate(AlbumDetail(id)) },
onArtistClick = { id -> navController.navigate(ArtistDetail(id)) },
onPlaylistClick = { id -> navController.navigate(PlaylistDetail(id)) },
onMostPlayedTap = viewModel::playMostPlayed,
onPlayPool = viewModel::playPool,
onPlayAlbum = viewModel::playAlbum,
onPlayArtist = viewModel::playArtistShuffled,
onPlayPlaylist = viewModel::playPlaylist,
)
}
}
}
/**
* Full-bleed "Updating your mixes…" veil that wipes in from the left,
* holds while an automatic refresh repopulates Home, then wipes off.
* Near-opaque so the section churn underneath never shows; swallows taps
* while raised so a mid-hydration tile can't be hit.
*/
@Composable
private fun BoxScope.UpdatingVeil(visible: Boolean) {
AnimatedVisibility(
visible = visible,
enter = slideInHorizontally(tween(VEIL_WIPE_MS)) { -it } + fadeIn(tween(VEIL_WIPE_MS)),
exit = slideOutHorizontally(tween(VEIL_WIPE_MS)) { it } + fadeOut(tween(VEIL_WIPE_MS)),
modifier = Modifier.matchParentSize(),
) {
Box(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background.copy(alpha = VEIL_ALPHA))
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null,
) {},
contentAlignment = Alignment.Center,
) {
Row(verticalAlignment = Alignment.CenterVertically) {
CircularProgressIndicator(
modifier = Modifier.size(VEIL_SPINNER_DP.dp),
strokeWidth = VEIL_SPINNER_STROKE_DP.dp,
color = MaterialTheme.colorScheme.primary,
)
Spacer(Modifier.width(VEIL_LABEL_GAP_DP.dp))
Text(
text = "Updating your mixes…",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onBackground,
)
} }
} }
} }
@@ -476,10 +754,12 @@ private fun HomeSuccessContent(
) { ) {
item { item {
// Always rendered: real system/user playlists, with // Always rendered: real system/user playlists, with
// placeholder cards filling the For You / Discover / // placeholder cards filling the For You / Discover slots
// 3× Songs-like slots that haven't generated yet. When // that haven't generated yet. When offline, the cache-backed
// offline, the cache-backed pool cards lead the row. // pool cards lead the row. Songs-like now lives in its own
// dedicated row below (#1491), no longer inside this carousel.
PlaylistsRow( PlaylistsRow(
title = "Playlists",
rowItems = buildPlaylistsRow(sections.playlists, systemStatus, offline), rowItems = buildPlaylistsRow(sections.playlists, systemStatus, offline),
offline = offline, offline = offline,
onPlaylistClick = onPlaylistClick, onPlaylistClick = onPlaylistClick,
@@ -487,6 +767,18 @@ private fun HomeSuccessContent(
onPlayPlaylist = onPlayPlaylist, onPlayPlaylist = onPlayPlaylist,
) )
} }
// Songs-like is the best-performing surface (#1491) — promoted out
// of the Playlists carousel into its own row so it shows a wider
// spread of "Songs like {artist}" mixes. Hidden when there's
// nothing to show (offline with none cached).
songsLikeSection(
playlists = sections.playlists,
status = systemStatus,
offline = offline,
onPlaylistClick = onPlaylistClick,
onPlayPool = onPlayPool,
onPlayPlaylist = onPlayPlaylist,
)
youMightLikeSection( youMightLikeSection(
albums = sections.youMightLikeAlbums, albums = sections.youMightLikeAlbums,
artists = sections.youMightLikeArtists, artists = sections.youMightLikeArtists,
@@ -510,6 +802,35 @@ private fun HomeSuccessContent(
} }
} }
/**
* The dedicated "Songs like…" row (#1491). Reuses [PlaylistsRow]'s card
* rendering with a distinct title; hidden entirely when there's nothing
* to show (offline with no cached mixes). Online with none generated yet
* still shows a few placeholders so the building / seed-needed state is
* visible, matching the pre-promotion carousel behavior.
*/
private fun LazyListScope.songsLikeSection(
playlists: List<PlaylistRef>,
status: SystemPlaylistsStatus,
offline: Boolean,
onPlaylistClick: (String) -> Unit,
onPlayPool: (OfflinePoolKind) -> Unit,
onPlayPlaylist: suspend (PlaylistRef) -> Unit,
) {
val rowItems = buildSongsLikeRow(playlists, status, offline)
if (rowItems.isEmpty()) return
item {
PlaylistsRow(
title = "Songs like…",
rowItems = rowItems,
offline = offline,
onPlaylistClick = onPlaylistClick,
onPlayPool = onPlayPool,
onPlayPlaylist = onPlayPlaylist,
)
}
}
private fun LazyListScope.recentlyAddedSection( private fun LazyListScope.recentlyAddedSection(
albums: List<HomeTile<AlbumRef>>, albums: List<HomeTile<AlbumRef>>,
onAlbumClick: (String) -> Unit, onAlbumClick: (String) -> Unit,
@@ -779,13 +1100,14 @@ private fun AlbumsRow(
@Composable @Composable
private fun PlaylistsRow( private fun PlaylistsRow(
title: String,
rowItems: List<PlaylistRowItem>, rowItems: List<PlaylistRowItem>,
offline: Boolean, offline: Boolean,
onPlaylistClick: (String) -> Unit, onPlaylistClick: (String) -> Unit,
onPlayPool: (OfflinePoolKind) -> Unit, onPlayPool: (OfflinePoolKind) -> Unit,
onPlayPlaylist: suspend (PlaylistRef) -> Unit, onPlayPlaylist: suspend (PlaylistRef) -> Unit,
) { ) {
HorizontalScrollRow(title = "Playlists") { HorizontalScrollRow(title = title) {
itemsIndexed(items = rowItems) { _, item -> itemsIndexed(items = rowItems) { _, item ->
when (item) { when (item) {
is PlaylistRowItem.OfflinePool -> OfflinePoolCard( is PlaylistRowItem.OfflinePool -> OfflinePoolCard(
@@ -836,11 +1158,12 @@ enum class OfflinePoolKind(val label: String) {
/** /**
* Builds the Home Playlists row. * Builds the Home Playlists row.
* *
* Online: For You + Discover + 3× Songs-like fixed slots (real card when * Online: For You + Discover fixed slots (real card when generated,
* generated, placeholder otherwise), then the secondary system kinds (deep cuts * placeholder otherwise), then the secondary system kinds (deep cuts /
* / rediscover / new for you / on this day / first listens) when they exist — * rediscover / new for you / on this day / first listens) when they exist —
* no placeholders for these since they're conditional on library shape — then * no placeholders for these since they're conditional on library shape — then
* user-owned playlists. * user-owned playlists. Songs-like has its own dedicated row (#1491) via
* [buildSongsLikeRow] and no longer appears in this carousel.
* *
* Offline: the two cache-backed pools (Recently played, Liked) lead, then the * Offline: the two cache-backed pools (Recently played, Liked) lead, then the
* same real playlists in curated order but stably partitioned fully-cached * same real playlists in curated order but stably partitioned fully-cached
@@ -867,7 +1190,7 @@ internal fun buildPlaylistsRow(
return out return out
} }
/** The online layout: fixed system slots (with placeholders), secondary, user. */ /** The online layout: For You + Discover slots (with placeholders), secondary, user. */
private fun buildOnlineRow( private fun buildOnlineRow(
owned: List<PlaylistRef>, owned: List<PlaylistRef>,
status: SystemPlaylistsStatus, status: SystemPlaylistsStatus,
@@ -879,12 +1202,7 @@ private fun buildOnlineRow(
out += owned.firstOrNull { it.systemVariant == "discover" } out += owned.firstOrNull { it.systemVariant == "discover" }
?.let { PlaylistRowItem.Real(it) } ?.let { PlaylistRowItem.Real(it) }
?: PlaylistRowItem.Placeholder("Discover", variantFor("discover", status)) ?: PlaylistRowItem.Placeholder("Discover", variantFor("discover", status))
val songsLike = owned.filter { it.systemVariant == "songs_like_artist" }.take(SONGS_LIKE_SLOTS) // Songs-like is no longer here — it has its own dedicated row (#1491).
for (i in 0 until SONGS_LIKE_SLOTS) {
out += songsLike.getOrNull(i)
?.let { PlaylistRowItem.Real(it) }
?: PlaylistRowItem.Placeholder("Songs like…", variantFor("songs-like", status))
}
for (variant in SECONDARY_SYSTEM_VARIANTS) { for (variant in SECONDARY_SYSTEM_VARIANTS) {
owned.firstOrNull { it.systemVariant == variant }?.let { out += PlaylistRowItem.Real(it) } owned.firstOrNull { it.systemVariant == variant }?.let { out += PlaylistRowItem.Real(it) }
} }
@@ -892,16 +1210,44 @@ private fun buildOnlineRow(
return out return out
} }
/**
* Builds the dedicated Songs-like row (#1491): all "Songs like {artist}"
* mixes the server generated, no longer capped to the 3 carousel slots.
*
* Online: every generated mix as a real card; when none exist yet, a few
* placeholders so the building / seed-needed state stays visible.
* Offline: the cached mixes only (fully-cached first, greyed after), and
* an empty list — hiding the whole section — when nothing is cached.
*/
internal fun buildSongsLikeRow(
owned: List<PlaylistRef>,
status: SystemPlaylistsStatus,
offline: Boolean,
): List<PlaylistRowItem> {
val mixes = owned.filter { it.systemVariant == "songs_like_artist" }
return when {
offline -> {
val (available, greyed) = mixes.partition { !it.unavailableOffline }
(available + greyed).map { PlaylistRowItem.Real(it) }
}
mixes.isNotEmpty() -> mixes.map { PlaylistRowItem.Real(it) }
else -> List(SONGS_LIKE_PLACEHOLDER_SLOTS) {
PlaylistRowItem.Placeholder("Songs like…", variantFor("songs-like", status))
}
}
}
/** /**
* Curated real-playlist order (system primaries, then secondary, then user). * Curated real-playlist order (system primaries, then secondary, then user).
* Must mirror [buildOnlineRow]'s slot order — the offline row reuses this and * Must mirror [buildOnlineRow]'s slot order — the offline row reuses this and
* only differs by dropping placeholders + partitioning available-first. * only differs by dropping placeholders + partitioning available-first.
* Songs-like is excluded here too — it renders in its own row via
* [buildSongsLikeRow] in both online and offline modes (#1491).
*/ */
private fun orderedRealPlaylists(owned: List<PlaylistRef>): List<PlaylistRef> { private fun orderedRealPlaylists(owned: List<PlaylistRef>): List<PlaylistRef> {
val out = mutableListOf<PlaylistRef>() val out = mutableListOf<PlaylistRef>()
owned.firstOrNull { it.systemVariant == "for_you" }?.let { out += it } owned.firstOrNull { it.systemVariant == "for_you" }?.let { out += it }
owned.firstOrNull { it.systemVariant == "discover" }?.let { out += it } owned.firstOrNull { it.systemVariant == "discover" }?.let { out += it }
out += owned.filter { it.systemVariant == "songs_like_artist" }.take(SONGS_LIKE_SLOTS)
for (variant in SECONDARY_SYSTEM_VARIANTS) { for (variant in SECONDARY_SYSTEM_VARIANTS) {
owned.firstOrNull { it.systemVariant == variant }?.let { out += it } owned.firstOrNull { it.systemVariant == variant }?.let { out += it }
} }
@@ -916,7 +1262,10 @@ private fun variantFor(slot: String, s: SystemPlaylistsStatus): String = when {
else -> "pending" else -> "pending"
} }
private const val SONGS_LIKE_SLOTS = 3 // How many "Songs like…" placeholder cards the dedicated row shows while
// the mixes haven't generated yet (building / seed-needed). Real mixes,
// once generated, are shown in full and no longer capped by this (#1491).
private const val SONGS_LIKE_PLACEHOLDER_SLOTS = 3
/** /**
* The 5 system playlist kinds the server generates that aren't pinned * The 5 system playlist kinds the server generates that aren't pinned
@@ -53,7 +53,7 @@ import com.fabledsword.minstrel.nav.AlbumDetail
import com.fabledsword.minstrel.nav.ArtistDetail import com.fabledsword.minstrel.nav.ArtistDetail
import com.fabledsword.minstrel.shared.formatDuration import com.fabledsword.minstrel.shared.formatDuration
import com.fabledsword.minstrel.shared.widgets.TrackRow import com.fabledsword.minstrel.shared.widgets.TrackRow
import com.fabledsword.minstrel.shared.widgets.EmptyState import com.fabledsword.minstrel.shared.widgets.ErrorRetry
import com.fabledsword.minstrel.shared.widgets.LikeButton import com.fabledsword.minstrel.shared.widgets.LikeButton
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
import com.fabledsword.minstrel.shared.widgets.ServerImage import com.fabledsword.minstrel.shared.widgets.ServerImage
@@ -116,9 +116,10 @@ private fun AlbumDetailStateContent(
when (val s = state) { when (val s = state) {
is AlbumDetailUiState.Loading -> is AlbumDetailUiState.Loading ->
if (s.seed != null) SeededAlbumLoading(s.seed) else SkeletonTrackList() if (s.seed != null) SeededAlbumLoading(s.seed) else SkeletonTrackList()
is AlbumDetailUiState.Error -> EmptyState( is AlbumDetailUiState.Error -> ErrorRetry(
title = "Couldn't load album", title = "Couldn't load album",
body = s.message, message = s.message,
onRetry = { viewModel.refresh() },
) )
is AlbumDetailUiState.Success -> { is AlbumDetailUiState.Success -> {
val albumLiked by viewModel.albumLiked.collectAsStateWithLifecycle() val albumLiked by viewModel.albumLiked.collectAsStateWithLifecycle()
@@ -5,6 +5,8 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import androidx.navigation.toRoute import androidx.navigation.toRoute
import com.fabledsword.minstrel.api.ErrorCopy import com.fabledsword.minstrel.api.ErrorCopy
import com.fabledsword.minstrel.connectivity.NetworkStatusController
import com.fabledsword.minstrel.connectivity.recoveries
import com.fabledsword.minstrel.library.data.LibraryRepository import com.fabledsword.minstrel.library.data.LibraryRepository
import com.fabledsword.minstrel.likes.data.LikesRepository import com.fabledsword.minstrel.likes.data.LikesRepository
import com.fabledsword.minstrel.models.AlbumDetailRef import com.fabledsword.minstrel.models.AlbumDetailRef
@@ -36,6 +38,7 @@ class AlbumDetailViewModel @Inject constructor(
private val likes: LikesRepository, private val likes: LikesRepository,
private val player: PlayerController, private val player: PlayerController,
private val seedCache: DetailSeedCache, private val seedCache: DetailSeedCache,
networkStatus: NetworkStatusController,
savedStateHandle: SavedStateHandle, savedStateHandle: SavedStateHandle,
) : ViewModel() { ) : ViewModel() {
@@ -64,6 +67,14 @@ class AlbumDetailViewModel @Inject constructor(
init { init {
refresh() refresh()
// Screen-level auto-recovery (issue #1245): a detail that failed to
// load while the server was unreachable re-fetches when health
// returns, so coming back to the screen shows content, not an error.
viewModelScope.launch {
networkStatus.recoveries().collect {
if (internal.value is AlbumDetailUiState.Error) refresh()
}
}
} }
fun refresh(): Job = viewModelScope.launch { fun refresh(): Job = viewModelScope.launch {
@@ -56,7 +56,7 @@ import com.fabledsword.minstrel.models.albumCoverPath
import com.fabledsword.minstrel.nav.AlbumDetail import com.fabledsword.minstrel.nav.AlbumDetail
import com.fabledsword.minstrel.nav.ArtistDetail import com.fabledsword.minstrel.nav.ArtistDetail
import com.fabledsword.minstrel.shared.formatDuration import com.fabledsword.minstrel.shared.formatDuration
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.HorizontalScrollRow
import com.fabledsword.minstrel.shared.widgets.LikeButton import com.fabledsword.minstrel.shared.widgets.LikeButton
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
@@ -107,9 +107,10 @@ fun ArtistDetailScreen(
} else { } else {
SkeletonArtistAlbumsGrid() SkeletonArtistAlbumsGrid()
} }
is ArtistDetailUiState.Error -> EmptyState( is ArtistDetailUiState.Error -> ErrorRetry(
title = "Couldn't load artist", title = "Couldn't load artist",
body = s.message, message = s.message,
onRetry = { viewModel.refresh() },
) )
is ArtistDetailUiState.Success -> is ArtistDetailUiState.Success ->
ArtistSuccessBody(s, viewModel, playerViewModel, navController) ArtistSuccessBody(s, viewModel, playerViewModel, navController)
@@ -5,6 +5,8 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import androidx.navigation.toRoute import androidx.navigation.toRoute
import com.fabledsword.minstrel.api.ErrorCopy import com.fabledsword.minstrel.api.ErrorCopy
import com.fabledsword.minstrel.connectivity.NetworkStatusController
import com.fabledsword.minstrel.connectivity.recoveries
import com.fabledsword.minstrel.library.data.LibraryRepository import com.fabledsword.minstrel.library.data.LibraryRepository
import com.fabledsword.minstrel.likes.data.LikesRepository import com.fabledsword.minstrel.likes.data.LikesRepository
import com.fabledsword.minstrel.models.ArtistDetailRef import com.fabledsword.minstrel.models.ArtistDetailRef
@@ -45,6 +47,7 @@ class ArtistDetailViewModel @Inject constructor(
private val likes: LikesRepository, private val likes: LikesRepository,
private val player: PlayerController, private val player: PlayerController,
private val seedCache: DetailSeedCache, private val seedCache: DetailSeedCache,
networkStatus: NetworkStatusController,
savedStateHandle: SavedStateHandle, savedStateHandle: SavedStateHandle,
) : ViewModel() { ) : ViewModel() {
@@ -69,6 +72,19 @@ class ArtistDetailViewModel @Inject constructor(
init { init {
refresh() refresh()
// Screen-level auto-recovery (issue #1245). From Error, redo the
// whole load; from Success, re-pull just the secondary sections —
// their fetch failures are swallowed to emptyList, so a dead zone
// during the first load leaves them permanently absent otherwise.
viewModelScope.launch {
networkStatus.recoveries().collect {
when (internal.value) {
is ArtistDetailUiState.Error -> refresh()
is ArtistDetailUiState.Success -> loadSecondarySections()
is ArtistDetailUiState.Loading -> Unit
}
}
}
} }
fun toggleLikeArtist() { fun toggleLikeArtist() {
@@ -24,11 +24,17 @@ class LibraryViewModel @Inject constructor(
combine( combine(
repository.observeArtists(), repository.observeArtists(),
repository.observeAlbums(), repository.observeAlbums(),
) { artists, albums -> syncController.lastSyncError,
if (artists.isEmpty() && albums.isEmpty()) { ) { artists, albums, syncError ->
UiState.Empty when {
} else { artists.isNotEmpty() || albums.isNotEmpty() ->
UiState.Success(LibraryData(artists, albums)) UiState.Success(LibraryData(artists, albums))
// Empty cache + failed sync is a load failure, not an empty
// library — surface it so the user gets a Retry instead of
// the welcome copy. Heals automatically: SyncController
// re-syncs on health recovery, clearing lastSyncError.
syncError != null -> UiState.Error(syncError)
else -> UiState.Empty
} }
} }
.asCacheFirstStateFlow(viewModelScope) .asCacheFirstStateFlow(viewModelScope)
@@ -25,6 +25,9 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import androidx.navigation.NavHostController import androidx.navigation.NavHostController
import com.fabledsword.minstrel.api.ErrorCopy
import com.fabledsword.minstrel.connectivity.NetworkStatusController
import com.fabledsword.minstrel.connectivity.recoveries
import com.fabledsword.minstrel.likes.data.LikesRepository import com.fabledsword.minstrel.likes.data.LikesRepository
import com.fabledsword.minstrel.library.widgets.AlbumCard import com.fabledsword.minstrel.library.widgets.AlbumCard
import com.fabledsword.minstrel.library.widgets.ArtistCard import com.fabledsword.minstrel.library.widgets.ArtistCard
@@ -38,6 +41,7 @@ import com.fabledsword.minstrel.shared.UiState
import com.fabledsword.minstrel.shared.asCacheFirstStateFlow import com.fabledsword.minstrel.shared.asCacheFirstStateFlow
import com.fabledsword.minstrel.shared.widgets.TrackRow import com.fabledsword.minstrel.shared.widgets.TrackRow
import com.fabledsword.minstrel.shared.widgets.EmptyState 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.HorizontalScrollRow
import com.fabledsword.minstrel.shared.widgets.LikeButton import com.fabledsword.minstrel.shared.widgets.LikeButton
import com.fabledsword.minstrel.shared.widgets.LoadingCentered import com.fabledsword.minstrel.shared.widgets.LoadingCentered
@@ -46,6 +50,7 @@ import com.fabledsword.minstrel.shared.widgets.TrackCoverThumb
import com.fabledsword.minstrel.shared.widgets.trackactions.TrackActionsButton import com.fabledsword.minstrel.shared.widgets.trackactions.TrackActionsButton
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -68,14 +73,25 @@ data class LikedSections(
class LikedTabViewModel @Inject constructor( class LikedTabViewModel @Inject constructor(
private val repository: LikesRepository, private val repository: LikesRepository,
private val player: PlayerController, private val player: PlayerController,
networkStatus: NetworkStatusController,
) : ViewModel() { ) : ViewModel() {
/** Copy for the most recent refresh failure; null once one succeeds. */
private val refreshError = MutableStateFlow<String?>(null)
init { init {
refresh() refresh()
// Screen-level auto-recovery (issue #1245): re-pull when server
// health returns, so a load that failed offline heals unprompted.
viewModelScope.launch {
networkStatus.recoveries().collect { refresh() }
}
} }
fun refresh(): Job = viewModelScope.launch { fun refresh(): Job = viewModelScope.launch {
refreshError.value = null
runCatching { repository.refreshIds() } runCatching { repository.refreshIds() }
.onFailure { refreshError.value = ErrorCopy.fromThrowable(it) }
} }
/** /**
@@ -92,9 +108,16 @@ class LikedTabViewModel @Inject constructor(
repository.observeLikedArtists(), repository.observeLikedArtists(),
repository.observeLikedAlbums(), repository.observeLikedAlbums(),
repository.observeLikedTracks(), repository.observeLikedTracks(),
) { artists, albums, tracks -> refreshError,
) { artists, albums, tracks, err ->
val sections = LikedSections(artists, albums, tracks) val sections = LikedSections(artists, albums, tracks)
if (sections.isAllEmpty) UiState.Empty else UiState.Success(sections) when {
!sections.isAllEmpty -> UiState.Success(sections)
// Empty cache + failed refresh is a load failure, not "no
// likes yet" — surface Error so the user gets a Retry.
err != null -> UiState.Error(err)
else -> UiState.Empty
}
}.asCacheFirstStateFlow(viewModelScope) }.asCacheFirstStateFlow(viewModelScope)
/** /**
@@ -130,9 +153,10 @@ fun LikedTab(
body = "Tap the heart on an artist, album, or track to start " + body = "Tap the heart on an artist, album, or track to start " +
"building your liked collection.", "building your liked collection.",
) )
is UiState.Error -> EmptyState( is UiState.Error -> ErrorRetry(
title = "Couldn't load likes", title = "Couldn't load likes",
body = s.message, message = s.message,
onRetry = { viewModel.refresh() },
) )
is UiState.Success -> LikedContent( is UiState.Success -> LikedContent(
sections = s.data, sections = s.data,
@@ -0,0 +1,19 @@
package com.fabledsword.minstrel.models
/** Domain model for one tag-enrichment provider in the admin surface (#1521). */
data class AdminTagSourceRef(
val id: String,
val displayName: String,
val requiresApiKey: Boolean,
val supports: List<String>,
val enabled: Boolean,
val apiKeySet: Boolean,
val testable: Boolean,
)
/** Result of a provider "test connection" call. */
data class TagSourceTestResult(
val ok: Boolean,
val durationMs: Long = 0,
val error: String = "",
)
@@ -1,5 +1,8 @@
package com.fabledsword.minstrel.models package com.fabledsword.minstrel.models
import kotlinx.datetime.Instant
import kotlin.math.roundToInt
/** /**
* Kind of Lidarr request being created. Wire form is the lowercase * Kind of Lidarr request being created. Wire form is the lowercase
* enum name; the helper [wire] keeps that mapping in one place. * enum name; the helper [wire] keeps that mapping in one place.
@@ -49,6 +52,8 @@ data class ArtistSuggestionRef(
val name: String, val name: String,
val imageUrl: String = "", val imageUrl: String = "",
val attribution: List<SeedContributionRef> = emptyList(), val attribution: List<SeedContributionRef> = emptyList(),
/** Taste-profile tags this candidate matches, strongest first (#2377). */
val matchedTags: List<String> = emptyList(),
) { ) {
val attributionText: String val attributionText: String
get() { 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 { companion object {
private const val MAX_ATTRIBUTION_PHRASES = 3 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
}
}
@@ -0,0 +1,35 @@
package com.fabledsword.minstrel.models.wire
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/**
* Wire DTOs for the `/api/admin/tag-sources` admin surface (#1521).
* Every field defaults defensively so a missing JSON key never crashes
* deserialization. `versionBumped` is present only on the PATCH response.
*/
@Serializable
data class AdminTagSourceWire(
val id: String = "",
@SerialName("display_name") val displayName: String = "",
@SerialName("requires_api_key") val requiresApiKey: Boolean = false,
val supports: List<String> = emptyList(),
val enabled: Boolean = false,
@SerialName("api_key_set") val apiKeySet: Boolean = false,
@SerialName("display_order") val displayOrder: Int = 0,
val testable: Boolean = false,
@SerialName("version_bumped") val versionBumped: Boolean = false,
)
@Serializable
data class AdminTagSourcesListWire(
val providers: List<AdminTagSourceWire> = emptyList(),
@SerialName("sources_version") val sourcesVersion: Int = 0,
)
@Serializable
data class TestTagSourceWire(
val ok: Boolean = false,
@SerialName("duration_ms") val durationMs: Long = 0,
val error: String = "",
)
@@ -35,6 +35,13 @@ data class ArtistSuggestionWire(
val name: String = "", val name: String = "",
@SerialName("image_url") val imageUrl: String = "", @SerialName("image_url") val imageUrl: String = "",
val attribution: List<SeedContributionWire> = emptyList(), 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, @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` * Body posted to `POST /api/requests`. Mirrors the Flutter `createRequest`
* payload shape. Optional fields are emitted only when non-null * payload shape. Optional fields are emitted only when non-null
@@ -27,6 +27,8 @@ data class PlayStartedRequest(
@SerialName("track_id") val trackId: String, @SerialName("track_id") val trackId: String,
@SerialName("client_id") val clientId: String, @SerialName("client_id") val clientId: String,
val source: String? = null, val source: String? = null,
// #1551: device class for context conditioning (server normalizes).
@SerialName("device_class") val deviceClass: String? = null,
) )
@Serializable @Serializable
@@ -56,4 +58,6 @@ data class PlayOfflineRequest(
val at: String, val at: String,
@SerialName("duration_played_ms") val durationPlayedMs: Long, @SerialName("duration_played_ms") val durationPlayedMs: Long,
val source: String? = null, val source: String? = null,
// #1551: device class for context conditioning (server normalizes).
@SerialName("device_class") val deviceClass: String? = null,
) )
@@ -16,6 +16,7 @@ import androidx.navigation.compose.composable
import com.fabledsword.minstrel.admin.ui.AdminLandingScreen import com.fabledsword.minstrel.admin.ui.AdminLandingScreen
import com.fabledsword.minstrel.admin.ui.AdminQuarantineScreen import com.fabledsword.minstrel.admin.ui.AdminQuarantineScreen
import com.fabledsword.minstrel.admin.ui.AdminRequestsScreen import com.fabledsword.minstrel.admin.ui.AdminRequestsScreen
import com.fabledsword.minstrel.admin.ui.AdminTagSourcesScreen
import com.fabledsword.minstrel.admin.ui.AdminUsersScreen import com.fabledsword.minstrel.admin.ui.AdminUsersScreen
import com.fabledsword.minstrel.auth.ui.LoginScreen import com.fabledsword.minstrel.auth.ui.LoginScreen
import com.fabledsword.minstrel.auth.ui.ServerUrlScreen import com.fabledsword.minstrel.auth.ui.ServerUrlScreen
@@ -190,6 +191,13 @@ private fun NavGraphBuilder.inShellDetail(
} }
} }
} }
composable<AdminTagSources> {
WithAnimatedScope {
ShellScaffold(onExpandPlayer = expandPlayer) {
AdminTagSourcesScreen(navController = navController)
}
}
}
} }
private fun NavGraphBuilder.outsideShell(navController: NavHostController) { private fun NavGraphBuilder.outsideShell(navController: NavHostController) {
@@ -22,6 +22,7 @@ import kotlinx.serialization.Serializable
@Serializable data object AdminRequests @Serializable data object AdminRequests
@Serializable data object AdminQuarantine @Serializable data object AdminQuarantine
@Serializable data object AdminUsers @Serializable data object AdminUsers
@Serializable data object AdminTagSources
// ── Outside-shell full-screen routes ────────────────────────────────── // ── Outside-shell full-screen routes ──────────────────────────────────
@@ -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.SoapFaultException
import com.fabledsword.minstrel.player.output.upnp.TransportState import com.fabledsword.minstrel.player.output.upnp.TransportState
import java.io.IOException import java.io.IOException
import kotlin.math.abs
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
@@ -58,8 +59,10 @@ import timber.log.Timber
* native queue via ClearQueue + AddURIToQueue, then points the * native queue via ClearQueue + AddURIToQueue, then points the
* transport at x-rincon-queue:<udn>#0. Skip/prev/seekTo delegate to * transport at x-rincon-queue:<udn>#0. Skip/prev/seekTo delegate to
* AVTransport Next/Previous/SeekToTrack so Sonos manages gap-free * AVTransport Next/Previous/SeekToTrack so Sonos manages gap-free
* advance natively. PollLoop syncs the local cursor by comparing the * advance natively. PollLoop keeps the local cursor aligned to the
* 1-based Track index from GetPositionInfo. * 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( class MinstrelForwardingPlayer(
private val delegate: Player, 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 = override fun getCurrentPosition(): Long =
if (isRemote()) remoteState.positionMs else super.getCurrentPosition() if (isRemote()) remoteState.positionMs else super.getCurrentPosition()
@@ -526,18 +546,8 @@ class MinstrelForwardingPlayer(
/** /**
* One poll tick: read position + transport state from Sonos, apply to * One poll tick: read position + transport state from Sonos, apply to
* [remoteState], and forward-sync the local cursor to Sonos's Track * [remoteState], and sync the local cursor to the track the renderer is
* index when not in queue load. * actually playing (see [syncLocalCursorToRemote]).
*
* 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.
*/ */
private suspend fun pollOnce(active: ActiveUpnp) { private suspend fun pollOnce(active: ActiveUpnp) {
val info = active.avTransport.getPositionInfo() val info = active.avTransport.getPositionInfo()
@@ -553,7 +563,7 @@ class MinstrelForwardingPlayer(
trackUri = info.trackUri, trackUri = info.trackUri,
trackNumber = info.track, trackNumber = info.track,
) )
maybeSyncLocalCursor(info.track) syncLocalCursorToRemote(sonosTrack = info.track, trackUri = info.trackUri)
val transport = active.avTransport.getTransportInfo() val transport = active.avTransport.getTransportInfo()
when (transport.state) { when (transport.state) {
TransportState.PLAYING -> { TransportState.PLAYING -> {
@@ -577,23 +587,95 @@ class MinstrelForwardingPlayer(
notifyRemoteStateChanged() notifyRemoteStateChanged()
} }
private fun maybeSyncLocalCursor(sonosTrack: Int) { /**
if (holder.target.value != null) return * Align the paused local delegate cursor to the track the renderer is
if (sonosTrack <= 0) return * actually playing, so the un-overridden current-item getters
val sonosIdx = sonosTrack - 1 * (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 { handler.post {
val localIdx = delegate.currentMediaItemIndex val target = resolveRemoteCursorIndex(sonosTrack, trackUri) ?: return@post
if (sonosIdx > localIdx && sonosIdx < delegate.mediaItemCount) { if (target != delegate.currentMediaItemIndex) {
Timber.w( Timber.w(
"UPnP cursor catch-up: local=%d -> sonos=%d", "UPnP cursor sync: local=%d -> %d",
localIdx, sonosIdx, 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 { 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 POLL_INTERVAL_MS = 1_000L
const val NON_PLAYING_CONFIRM = 2 const val NON_PLAYING_CONFIRM = 2
const val SEEK_ACK_WINDOW_MS = 2_000L const val SEEK_ACK_WINDOW_MS = 2_000L
@@ -155,6 +155,7 @@ class PlayEventsReporter @Inject constructor(
trackId = trackId, trackId = trackId,
clientId = cid, clientId = cid,
source = source.takeIf { !it.isNullOrEmpty() }, source = source.takeIf { !it.isNullOrEmpty() },
deviceClass = DEVICE_CLASS,
), ),
) )
if (curTrackId == trackId) { if (curTrackId == trackId) {
@@ -248,6 +249,7 @@ class PlayEventsReporter @Inject constructor(
atIso = startedAt.toString(), atIso = startedAt.toString(),
durationPlayedMs = durationPlayedMs, durationPlayedMs = durationPlayedMs,
source = source.takeIf { !it.isNullOrEmpty() }, source = source.takeIf { !it.isNullOrEmpty() },
deviceClass = DEVICE_CLASS,
), ),
) )
} }
@@ -268,6 +270,12 @@ class PlayEventsReporter @Inject constructor(
// ── Lifecycle: durable-close on app background / detach ──────── // ── Lifecycle: durable-close on app background / detach ────────
companion object {
// #1551: Android is a mobile client. Refine to tablet/tv via device
// configuration later if the metrics trend view shows it matters.
private const val DEVICE_CLASS = "mobile"
}
override fun onStop(owner: LifecycleOwner) { override fun onStop(owner: LifecycleOwner) {
// App backgrounded. If the tracked play is still PLAYING, leave it // App backgrounded. If the tracked play is still PLAYING, leave it
// alone: the foreground media service keeps the process — and this // alone: the foreground media service keeps the process — and this
@@ -288,6 +288,37 @@ class PlayerController @Inject constructor(
controller.addMediaItem(track.toMediaItem(source = null)) 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 * Seed a fresh radio queue from [trackId]. The `source` tag is
* "radio:<id>" so the server-side rotation reporter can * "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 * One position-polling tick. Patches ONLY the smoothly-changing transport
* is active and the wrapped ExoPlayer is paused, `delegate.seekTo` from * fields (position / duration / play-pause / buffer) via `.copy()`. The
* `maybeSyncLocalCursor` may not fire `onMediaItemTransition`, leaving * queue index + current track are owned solely by [onEvents], which reads
* uiState.queueIndex stuck on the old track even after Sonos has * the authoritative delegate cursor: during a cast,
* advanced. So the tick reads Sonos's reported Track as the source of * [MinstrelForwardingPlayer.syncLocalCursorToRemote] keeps that cursor on
* truth, rebuilds the index/title fields itself, and force-syncs the * the track the renderer is actually playing (matched by identity), so there
* wrapped player as defense in depth. * 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) { private fun tickPositionPoll(controller: MediaController) {
val upnpActive = activeUpnpHolder.active.value != null val upnpActive = activeUpnpHolder.active.value != null
resolvePendingTransport(controller, upnpActive) resolvePendingTransport(controller, upnpActive)
val pendingTransport = upnpActive && remoteState.pendingTransportDeadlineMs > 0L
val effectiveIsPlaying = val effectiveIsPlaying =
if (upnpActive) remoteState.isPlaying else controller.isPlaying if (upnpActive) remoteState.isPlaying else controller.isPlaying
val effectivePosition = if (upnpActive) { val effectivePosition = if (upnpActive) {
@@ -617,27 +649,29 @@ class PlayerController @Inject constructor(
} else { } else {
controller.currentPosition controller.currentPosition
} }
val desiredIdx = desiredQueueIndex(controller, upnpActive) val idx = controller.currentMediaItemIndex
val current = uiStateInternal.value val current = uiStateInternal.value
val newPos = effectivePosition.coerceAtLeast(0) val newPos = effectivePosition.coerceAtLeast(0)
val newDur = effectiveDuration( val newDur = effectiveDuration(
upnpActive, upnpActive,
remoteState.durationMs, remoteState.durationMs,
controller.duration, controller.duration,
desiredIdx = desiredIdx, desiredIdx = idx,
controllerIdx = controller.currentMediaItemIndex, controllerIdx = idx,
) )
val newBuf = controller.bufferedPosition.coerceAtLeast(0) val newBuf = controller.bufferedPosition.coerceAtLeast(0)
// Track adjustments are forward-only AND suppressed while a user val somethingChanged = current.isPlaying != effectiveIsPlaying ||
// transport press is pending Sonos confirmation. Together those keep current.positionMs != newPos ||
// either direction of user input from being undone by a stale poll. current.durationMs != newDur ||
val trackChanged = !pendingTransport && current.bufferedPositionMs != newBuf
desiredIdx > current.queueIndex && if (somethingChanged) {
desiredIdx in queueRefs.indices uiStateInternal.value = current.copy(
publishTickIfChanged( isPlaying = effectiveIsPlaying,
current, trackChanged, desiredIdx, positionMs = newPos,
effectiveIsPlaying, newPos, newDur, newBuf, 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 ────────────────────────────── // ── Remote position interpolation state ──────────────────────────────
// remoteState.positionMs is only refreshed by ForwardingPlayer's 1Hz // remoteState.positionMs is only refreshed by ForwardingPlayer's 1Hz
// SOAP poll (and only when the round-trip completes -- screen-off WiFi // SOAP poll (and only when the round-trip completes -- screen-off WiFi
@@ -1,11 +1,16 @@
package com.fabledsword.minstrel.player.ui package com.fabledsword.minstrel.player.ui
import androidx.lifecycle.ViewModel 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.PlayerController
import com.fabledsword.minstrel.player.PlayerUiState import com.fabledsword.minstrel.player.PlayerUiState
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import javax.inject.Inject import javax.inject.Inject
/** /**
@@ -20,13 +25,29 @@ import javax.inject.Inject
* stub-test for ViewModel-level logic when it grows). * stub-test for ViewModel-level logic when it grows).
*/ */
@HiltViewModel @HiltViewModel
@Suppress("TooManyFunctions") // Thin transport facade — each fun forwards to PlayerController.
class PlayerViewModel @Inject constructor( class PlayerViewModel @Inject constructor(
private val controller: PlayerController, private val controller: PlayerController,
private val likes: LikesRepository,
) : ViewModel() { ) : ViewModel() {
val uiState: StateFlow<PlayerUiState> = controller.uiState val uiState: StateFlow<PlayerUiState> = controller.uiState
val dropEvents: SharedFlow<String> = controller.dropEvents 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 play() = controller.play()
fun pause() = controller.pause() fun pause() = controller.pause()
fun seekTo(positionMs: Long) = controller.seekTo(positionMs) fun seekTo(positionMs: Long) = controller.seekTo(positionMs)
@@ -35,4 +56,16 @@ class PlayerViewModel @Inject constructor(
fun seekToIndex(index: Int) = controller.seekToIndex(index) fun seekToIndex(index: Int) = controller.seekToIndex(index)
fun toggleShuffle() = controller.toggleShuffle() fun toggleShuffle() = controller.toggleShuffle()
fun cycleRepeat() = controller.cycleRepeat() 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 package com.fabledsword.minstrel.player.ui
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectDragGestures
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding 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.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed 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.ExperimentalMaterial3Api
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
@@ -20,22 +28,43 @@ import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue 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.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color 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.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.zIndex
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavHostController import androidx.navigation.NavHostController
import com.composables.icons.lucide.ArrowDown
import com.composables.icons.lucide.ArrowLeft import com.composables.icons.lucide.ArrowLeft
import com.composables.icons.lucide.GripVertical
import com.composables.icons.lucide.Lucide 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.Volume2
import com.composables.icons.lucide.X
import com.fabledsword.minstrel.models.TrackRef import com.fabledsword.minstrel.models.TrackRef
import com.fabledsword.minstrel.shared.formatDuration import com.fabledsword.minstrel.shared.formatDuration
import com.fabledsword.minstrel.shared.widgets.EmptyState 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) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
@@ -44,16 +73,35 @@ fun QueueScreen(
viewModel: PlayerViewModel = hiltViewModel(), viewModel: PlayerViewModel = hiltViewModel(),
) { ) {
val state by viewModel.uiState.collectAsStateWithLifecycle() val state by viewModel.uiState.collectAsStateWithLifecycle()
val likedTrackIds by viewModel.likedTrackIds.collectAsStateWithLifecycle()
Scaffold( Scaffold(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
topBar = { topBar = {
TopAppBar( 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 = { navigationIcon = {
IconButton(onClick = { navController.popBackStack() }) { IconButton(onClick = { navController.popBackStack() }) {
Icon(Lucide.ArrowLeft, contentDescription = "Back") Icon(Lucide.ArrowLeft, contentDescription = "Back")
} }
}, },
actions = {
if (state.queue.isNotEmpty()) {
IconButton(onClick = viewModel::clearQueue) {
Icon(Lucide.Trash2, contentDescription = "Clear queue")
}
}
},
) )
}, },
) { inner -> ) { inner ->
@@ -67,33 +115,109 @@ fun QueueScreen(
QueueList( QueueList(
tracks = state.queue, tracks = state.queue,
currentIndex = state.queueIndex, currentIndex = state.queueIndex,
likedTrackIds = likedTrackIds,
onJumpTo = viewModel::seekToIndex, onJumpTo = viewModel::seekToIndex,
onToggleLike = viewModel::toggleLikeTrack,
onMove = viewModel::moveInQueue,
onRemove = viewModel::removeFromQueue,
) )
} }
} }
} }
} }
@Suppress("LongParameterList") // Compose list wiring — layout + queue callbacks, not logic.
@Composable @Composable
private fun QueueList( private fun QueueList(
tracks: List<TrackRef>, tracks: List<TrackRef>,
currentIndex: Int, currentIndex: Int,
likedTrackIds: Set<String>,
onJumpTo: (Int) -> Unit, onJumpTo: (Int) -> Unit,
onToggleLike: (String) -> Unit,
onMove: (Int, Int) -> Unit,
onRemove: (Int) -> Unit,
) { ) {
LazyColumn(modifier = Modifier.fillMaxSize()) { val listState = rememberLazyListState(
itemsIndexed(items = tracks, key = { _, track -> track.id }) { index, track -> initialFirstVisibleItemIndex = currentIndex.coerceIn(0, tracks.lastIndex),
QueueRow( )
track = track, val scope = rememberCoroutineScope()
isCurrent = index == currentIndex,
onClick = { onJumpTo(index) }, // 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
HorizontalDivider() // 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 @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) { val highlight = if (isCurrent) {
MaterialTheme.colorScheme.primary.copy(alpha = HIGHLIGHT_ALPHA) MaterialTheme.colorScheme.primary.copy(alpha = HIGHLIGHT_ALPHA)
} else { } else {
@@ -102,12 +226,23 @@ private fun QueueRow(track: TrackRef, isCurrent: Boolean, onClick: () -> Unit) {
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.onSizeChanged { rowHeightPx = it.height }
.zIndex(if (dragOffsetY != 0f) 1f else 0f)
.graphicsLayer { translationY = dragOffsetY }
.background(highlight) .background(highlight)
.clickable(onClick = onClick) .clickable(onClick = onClick)
.padding(horizontal = 16.dp, vertical = 12.dp), .padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp), horizontalArrangement = Arrangement.spacedBy(12.dp),
) { ) {
DragHandle(
index = index,
queueSize = queueSize,
rowHeightPx = rowHeightPx,
onOffsetChange = { dragOffsetY = it },
onMove = onMove,
)
QueueRowThumbnail(track = track)
if (isCurrent) { if (isCurrent) {
Icon( Icon(
Lucide.Volume2, Lucide.Volume2,
@@ -115,26 +250,7 @@ private fun QueueRow(track: TrackRef, isCurrent: Boolean, onClick: () -> Unit) {
tint = MaterialTheme.colorScheme.primary, tint = MaterialTheme.colorScheme.primary,
) )
} }
Column(modifier = Modifier.weight(1f)) { QueueRowText(track = track, isCurrent = isCurrent, 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,
)
}
}
if (track.durationSec > 0) { if (track.durationSec > 0) {
Text( Text(
text = formatDuration(track.durationSec), text = formatDuration(track.durationSec),
@@ -142,6 +258,95 @@ private fun QueueRow(track: TrackRef, isCurrent: Boolean, onClick: () -> Unit) {
color = MaterialTheme.colorScheme.onSurfaceVariant, 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() } .filter { it.isNotEmpty() }
.joinToString(" · ") .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 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 throw e
} }
playlistDao.upsertAll(listOf(wire.toPlaylistEntity())) playlistDao.upsertAll(listOf(wire.toPlaylistEntity()))
playlistTrackDao.deleteByPlaylist(id) playlistTrackDao.replacePlaylistTracks(
playlistTrackDao.upsertAll( playlistId = id,
wire.tracks.mapNotNull { row -> rows = wire.tracks.mapNotNull { row ->
row.trackId?.let { trackId -> row.trackId?.let { trackId ->
CachedPlaylistTrackEntity( CachedPlaylistTrackEntity(
playlistId = id, playlistId = id,
@@ -38,9 +38,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
@@ -60,6 +58,8 @@ import com.composables.icons.lucide.Play
import com.composables.icons.lucide.RefreshCw import com.composables.icons.lucide.RefreshCw
import com.composables.icons.lucide.Shuffle import com.composables.icons.lucide.Shuffle
import com.fabledsword.minstrel.api.ErrorCopy import com.fabledsword.minstrel.api.ErrorCopy
import com.fabledsword.minstrel.connectivity.NetworkStatusController
import com.fabledsword.minstrel.connectivity.recoveries
import com.fabledsword.minstrel.events.EventsStream import com.fabledsword.minstrel.events.EventsStream
import com.fabledsword.minstrel.events.LiveEvent import com.fabledsword.minstrel.events.LiveEvent
import com.fabledsword.minstrel.models.PlaylistRef import com.fabledsword.minstrel.models.PlaylistRef
@@ -76,6 +76,7 @@ import com.fabledsword.minstrel.playlists.data.PlaylistsRepository
import com.fabledsword.minstrel.playlists.data.toPlayableTrackRefs import com.fabledsword.minstrel.playlists.data.toPlayableTrackRefs
import com.fabledsword.minstrel.shared.formatDuration import com.fabledsword.minstrel.shared.formatDuration
import com.fabledsword.minstrel.shared.widgets.TrackRow import com.fabledsword.minstrel.shared.widgets.TrackRow
import com.fabledsword.minstrel.shared.widgets.ErrorRetry
import com.fabledsword.minstrel.shared.widgets.LikeButton import com.fabledsword.minstrel.shared.widgets.LikeButton
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
import com.fabledsword.minstrel.shared.widgets.SkeletonTrackRow import com.fabledsword.minstrel.shared.widgets.SkeletonTrackRow
@@ -117,6 +118,7 @@ class PlaylistDetailViewModel @Inject constructor(
private val player: PlayerController, private val player: PlayerController,
private val eventsStream: EventsStream, private val eventsStream: EventsStream,
private val seedCache: DetailSeedCache, private val seedCache: DetailSeedCache,
networkStatus: NetworkStatusController,
savedStateHandle: SavedStateHandle, savedStateHandle: SavedStateHandle,
) : ViewModel() { ) : ViewModel() {
@@ -167,6 +169,14 @@ class PlaylistDetailViewModel @Inject constructor(
.filter { it.kind.startsWith("playlist.") } .filter { it.kind.startsWith("playlist.") }
.collect { handlePlaylistEvent(it) } .collect { handlePlaylistEvent(it) }
} }
// Screen-level auto-recovery (issue #1245): a detail that failed to
// load while the server was unreachable re-fetches when health
// returns, instead of waiting for the Retry tap.
viewModelScope.launch {
networkStatus.recoveries().collect {
if (internal.value is PlaylistDetailUiState.Error) refresh()
}
}
} }
/** /**
@@ -369,7 +379,11 @@ private fun PlaylistDetailContent(
Crossfade(targetState = state::class, label = "playlist-detail") { _ -> when (val s = state) { Crossfade(targetState = state::class, label = "playlist-detail") { _ -> when (val s = state) {
is PlaylistDetailUiState.Loading -> is PlaylistDetailUiState.Loading ->
s.seed?.let { SeededPlaylistLoading(it) } ?: SkeletonPlaylistTrackList() s.seed?.let { SeededPlaylistLoading(it) } ?: SkeletonPlaylistTrackList()
is PlaylistDetailUiState.Error -> ErrorBlock(s.message, viewModel::refresh) is PlaylistDetailUiState.Error -> ErrorRetry(
title = "Couldn't load playlist",
message = s.message,
onRetry = { viewModel.refresh() },
)
is PlaylistDetailUiState.Success -> { is PlaylistDetailUiState.Success -> {
val likedTrackIds by viewModel.likedTrackIds.collectAsState() val likedTrackIds by viewModel.likedTrackIds.collectAsState()
val playerState by playerViewModel.uiState.collectAsState() val playerState by playerViewModel.uiState.collectAsState()
@@ -653,33 +667,6 @@ private fun SeededPlaylistLoading(seed: PlaylistRef) {
} }
} }
@Composable
private fun ErrorBlock(message: String, onRetry: () -> Unit) {
var retried by remember { mutableStateOf(false) }
Column(
modifier = Modifier
.fillMaxSize()
.padding(24.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
text = message,
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodyLarge,
)
Spacer(Modifier.height(12.dp))
OutlinedButton(
onClick = {
if (!retried) {
retried = true
onRetry()
}
},
) { Text("Retry") }
}
}
// ─── Helpers ───────────────────────────────────────────────────────── // ─── Helpers ─────────────────────────────────────────────────────────
private const val UNAVAILABLE_ALPHA = 0.4f private const val UNAVAILABLE_ALPHA = 0.4f
@@ -27,8 +27,10 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import androidx.navigation.NavHostController import androidx.navigation.NavHostController
import com.fabledsword.minstrel.api.ErrorCopy
import com.fabledsword.minstrel.connectivity.NetworkStatusController import com.fabledsword.minstrel.connectivity.NetworkStatusController
import com.fabledsword.minstrel.connectivity.ServerHealth import com.fabledsword.minstrel.connectivity.ServerHealth
import com.fabledsword.minstrel.connectivity.recoveries
import com.fabledsword.minstrel.events.EventsStream import com.fabledsword.minstrel.events.EventsStream
import com.fabledsword.minstrel.models.PlaylistRef import com.fabledsword.minstrel.models.PlaylistRef
import com.fabledsword.minstrel.nav.PlaylistDetail import com.fabledsword.minstrel.nav.PlaylistDetail
@@ -40,6 +42,7 @@ import com.fabledsword.minstrel.shared.UiState
import com.fabledsword.minstrel.shared.asCacheFirstStateFlow import com.fabledsword.minstrel.shared.asCacheFirstStateFlow
import com.fabledsword.minstrel.playlists.widgets.PlaylistCard import com.fabledsword.minstrel.playlists.widgets.PlaylistCard
import com.fabledsword.minstrel.shared.widgets.EmptyState import com.fabledsword.minstrel.shared.widgets.EmptyState
import com.fabledsword.minstrel.shared.widgets.ErrorRetry
import com.fabledsword.minstrel.shared.widgets.LoadingCentered import com.fabledsword.minstrel.shared.widgets.LoadingCentered
import com.fabledsword.minstrel.shared.widgets.MinstrelTopAppBar import com.fabledsword.minstrel.shared.widgets.MinstrelTopAppBar
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
@@ -47,8 +50,10 @@ import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.receiveAsFlow
@@ -84,6 +89,9 @@ class PlaylistsListViewModel @Inject constructor(
initialValue = false, initialValue = false,
) )
/** Copy for the most recent list-refresh failure; null once one succeeds. */
private val refreshError = MutableStateFlow<String?>(null)
init { init {
refresh() refresh()
// Live updates: a playlist created/updated/deleted from another // Live updates: a playlist created/updated/deleted from another
@@ -93,11 +101,18 @@ class PlaylistsListViewModel @Inject constructor(
.filter { it.kind.startsWith("playlist.") } .filter { it.kind.startsWith("playlist.") }
.collect { refresh() } .collect { refresh() }
} }
// Screen-level auto-recovery (issue #1245): re-pull when server
// health returns, so a load that failed offline heals unprompted.
viewModelScope.launch {
networkStatus.recoveries().collect { refresh() }
}
} }
/** Re-pull the playlists list. Returns the Job for pull-to-refresh awaits. */ /** Re-pull the playlists list. Returns the Job for pull-to-refresh awaits. */
fun refresh(): Job = viewModelScope.launch { fun refresh(): Job = viewModelScope.launch {
refreshError.value = null
runCatching { repository.refreshList() } runCatching { repository.refreshList() }
.onFailure { refreshError.value = ErrorCopy.fromThrowable(it) }
} }
/** Tile play button: shuffle the playlist's tracks and start at index 0. */ /** Tile play button: shuffle the playlist's tracks and start at index 0. */
@@ -110,14 +125,15 @@ class PlaylistsListViewModel @Inject constructor(
} }
val uiState: StateFlow<UiState<List<PlaylistRef>>> = val uiState: StateFlow<UiState<List<PlaylistRef>>> =
repository.observeAll() combine(repository.observeAll(), refreshError) { list, err ->
.map { list -> when {
if (list.isEmpty()) { list.isNotEmpty() -> UiState.Success(list)
UiState.Empty // Empty cache + failed refresh is a load failure, not "no
} else { // playlists yet" — surface Error so the user gets a Retry.
UiState.Success(list) err != null -> UiState.Error(err)
} else -> UiState.Empty
} }
}
.asCacheFirstStateFlow(viewModelScope) .asCacheFirstStateFlow(viewModelScope)
} }
@@ -157,9 +173,10 @@ fun PlaylistsListScreen(
"appear once your library has enough plays. Create your " + "appear once your library has enough plays. Create your " +
"own playlist from any album or track in the meantime.", "own playlist from any album or track in the meantime.",
) )
is UiState.Error -> EmptyState( is UiState.Error -> ErrorRetry(
title = "Couldn't load playlists", title = "Couldn't load playlists",
body = s.message, message = s.message,
onRetry = { viewModel.refresh() },
) )
is UiState.Success -> PlaylistsGrid( is UiState.Success -> PlaylistsGrid(
playlists = s.data, playlists = s.data,
@@ -27,6 +27,7 @@ import com.composables.icons.lucide.Lucide
import com.fabledsword.minstrel.models.QuarantineRef import com.fabledsword.minstrel.models.QuarantineRef
import com.fabledsword.minstrel.shared.UiState import com.fabledsword.minstrel.shared.UiState
import com.fabledsword.minstrel.shared.widgets.EmptyState import com.fabledsword.minstrel.shared.widgets.EmptyState
import com.fabledsword.minstrel.shared.widgets.ErrorRetry
import com.fabledsword.minstrel.shared.widgets.LoadingCentered import com.fabledsword.minstrel.shared.widgets.LoadingCentered
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
import com.fabledsword.minstrel.shared.widgets.TrackCoverThumb import com.fabledsword.minstrel.shared.widgets.TrackCoverThumb
@@ -43,9 +44,10 @@ fun HiddenTab(viewModel: HiddenTabViewModel = hiltViewModel()) {
"and it'll show up here. The track stays out of system " + "and it'll show up here. The track stays out of system " +
"playlists until you unhide it.", "playlists until you unhide it.",
) )
is UiState.Error -> EmptyState( is UiState.Error -> ErrorRetry(
title = "Couldn't load hidden tracks", title = "Couldn't load hidden tracks",
body = s.message, message = s.message,
onRetry = { viewModel.refresh() },
) )
is UiState.Success -> HiddenList( is UiState.Success -> HiddenList(
rows = s.data, rows = s.data,
@@ -3,6 +3,8 @@ package com.fabledsword.minstrel.quarantine.ui
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.fabledsword.minstrel.api.ErrorCopy import com.fabledsword.minstrel.api.ErrorCopy
import com.fabledsword.minstrel.connectivity.NetworkStatusController
import com.fabledsword.minstrel.connectivity.recoveries
import com.fabledsword.minstrel.events.EventsStream import com.fabledsword.minstrel.events.EventsStream
import com.fabledsword.minstrel.models.QuarantineRef import com.fabledsword.minstrel.models.QuarantineRef
import com.fabledsword.minstrel.shared.UiState import com.fabledsword.minstrel.shared.UiState
@@ -24,6 +26,7 @@ private const val SHARE_STOP_TIMEOUT_MS = 5_000L
class HiddenTabViewModel @Inject constructor( class HiddenTabViewModel @Inject constructor(
private val repository: QuarantineRepository, private val repository: QuarantineRepository,
private val eventsStream: EventsStream, private val eventsStream: EventsStream,
networkStatus: NetworkStatusController,
) : ViewModel() { ) : ViewModel() {
private val refreshError = MutableStateFlow<String?>(null) private val refreshError = MutableStateFlow<String?>(null)
@@ -61,6 +64,11 @@ class HiddenTabViewModel @Inject constructor(
.filter { it.kind.startsWith("quarantine.") } .filter { it.kind.startsWith("quarantine.") }
.collect { refresh() } .collect { refresh() }
} }
// Screen-level auto-recovery (issue #1245): re-pull when server
// health returns, so a load that failed offline heals unprompted.
viewModelScope.launch {
networkStatus.recoveries().collect { refresh() }
}
} }
fun refresh(): Job = viewModelScope.launch { fun refresh(): Job = viewModelScope.launch {
@@ -46,6 +46,7 @@ import com.fabledsword.minstrel.shared.UiState
import com.fabledsword.minstrel.nav.ArtistDetail import com.fabledsword.minstrel.nav.ArtistDetail
import com.fabledsword.minstrel.nav.Requests import com.fabledsword.minstrel.nav.Requests
import com.fabledsword.minstrel.shared.widgets.EmptyState import com.fabledsword.minstrel.shared.widgets.EmptyState
import com.fabledsword.minstrel.shared.widgets.ErrorRetry
import com.fabledsword.minstrel.shared.widgets.LoadingCentered import com.fabledsword.minstrel.shared.widgets.LoadingCentered
import com.fabledsword.minstrel.shared.widgets.MinstrelTopAppBar import com.fabledsword.minstrel.shared.widgets.MinstrelTopAppBar
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
@@ -78,9 +79,10 @@ fun RequestsScreen(
body = "Use Discover to ask Lidarr for new music; your " + body = "Use Discover to ask Lidarr for new music; your " +
"requests show up here.", "requests show up here.",
) )
is UiState.Error -> EmptyState( is UiState.Error -> ErrorRetry(
title = "Couldn't load requests", title = "Couldn't load requests",
body = s.message, message = s.message,
onRetry = { viewModel.refresh() },
) )
is UiState.Success -> RequestList( is UiState.Success -> RequestList(
rows = s.data, rows = s.data,
@@ -3,6 +3,8 @@ package com.fabledsword.minstrel.requests.ui
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.fabledsword.minstrel.api.ErrorCopy import com.fabledsword.minstrel.api.ErrorCopy
import com.fabledsword.minstrel.connectivity.NetworkStatusController
import com.fabledsword.minstrel.connectivity.recoveries
import com.fabledsword.minstrel.events.EventsStream import com.fabledsword.minstrel.events.EventsStream
import com.fabledsword.minstrel.models.RequestRef import com.fabledsword.minstrel.models.RequestRef
import com.fabledsword.minstrel.models.RequestStatus import com.fabledsword.minstrel.models.RequestStatus
@@ -30,6 +32,7 @@ private const val POLL_INTERVAL_MS = 12_000L
class RequestsViewModel @Inject constructor( class RequestsViewModel @Inject constructor(
private val repository: RequestsRepository, private val repository: RequestsRepository,
private val eventsStream: EventsStream, private val eventsStream: EventsStream,
networkStatus: NetworkStatusController,
) : ViewModel() { ) : ViewModel() {
private val internal = MutableStateFlow<UiState<List<RequestRef>>>(UiState.Loading) private val internal = MutableStateFlow<UiState<List<RequestRef>>>(UiState.Loading)
@@ -45,6 +48,14 @@ class RequestsViewModel @Inject constructor(
.collect { silentReload() } .collect { silentReload() }
} }
viewModelScope.launch { pollWhileInFlight() } viewModelScope.launch { pollWhileInFlight() }
// Screen-level auto-recovery (issue #1245): a load that failed while
// the server was unreachable heals the moment health returns, without
// waiting for the next poll tick.
viewModelScope.launch {
networkStatus.recoveries().collect {
if (internal.value is UiState.Error) silentReload()
}
}
} }
fun refresh(): Job = viewModelScope.launch { fun refresh(): Job = viewModelScope.launch {
@@ -71,7 +82,11 @@ class RequestsViewModel @Inject constructor(
*/ */
private suspend fun pollWhileInFlight() { private suspend fun pollWhileInFlight() {
while (true) { while (true) {
if (hasInFlightRequest()) { // Also retry from a failed load (issue #1245): from UiState.Error
// the in-flight predicate can never become true, so without this
// the poll never self-heals the screen — e.g. after a transient
// 500 that health monitoring (transport-level) never saw.
if (hasInFlightRequest() || internal.value is UiState.Error) {
silentReload() silentReload()
} }
delay(POLL_INTERVAL_MS) delay(POLL_INTERVAL_MS)
@@ -56,6 +56,7 @@ import com.fabledsword.minstrel.nav.AlbumDetail
import com.fabledsword.minstrel.nav.ArtistDetail import com.fabledsword.minstrel.nav.ArtistDetail
import com.fabledsword.minstrel.nav.Search as SearchRoute import com.fabledsword.minstrel.nav.Search as SearchRoute
import com.fabledsword.minstrel.shared.widgets.TrackRow import com.fabledsword.minstrel.shared.widgets.TrackRow
import com.fabledsword.minstrel.shared.widgets.ErrorRetry
import com.fabledsword.minstrel.shared.widgets.LoadingCentered import com.fabledsword.minstrel.shared.widgets.LoadingCentered
import com.fabledsword.minstrel.shared.widgets.MainAppBarActions import com.fabledsword.minstrel.shared.widgets.MainAppBarActions
import com.fabledsword.minstrel.shared.widgets.TrackCoverThumb import com.fabledsword.minstrel.shared.widgets.TrackCoverThumb
@@ -113,6 +114,7 @@ fun SearchScreen(
onTrackPlay = viewModel::playTrack, onTrackPlay = viewModel::playTrack,
onNavigateToAlbum = { id -> navController.navigate(AlbumDetail(id)) }, onNavigateToAlbum = { id -> navController.navigate(AlbumDetail(id)) },
onNavigateToArtist = { id -> navController.navigate(ArtistDetail(id)) }, onNavigateToArtist = { id -> navController.navigate(ArtistDetail(id)) },
onRetry = viewModel::retry,
) )
} }
} }
@@ -151,11 +153,16 @@ private fun ResultsPane(
onTrackPlay: (TrackRef) -> Unit, onTrackPlay: (TrackRef) -> Unit,
onNavigateToAlbum: (String) -> Unit, onNavigateToAlbum: (String) -> Unit,
onNavigateToArtist: (String) -> Unit, onNavigateToArtist: (String) -> Unit,
onRetry: () -> Unit,
) { ) {
when (state) { when (state) {
SearchResultsState.Idle -> CenteredHint("Type to search your library.") SearchResultsState.Idle -> CenteredHint("Type to search your library.")
SearchResultsState.Loading -> LoadingCentered() SearchResultsState.Loading -> LoadingCentered()
is SearchResultsState.Error -> CenteredHint("Search failed: ${state.message}") is SearchResultsState.Error -> ErrorRetry(
title = "Search failed",
message = state.message,
onRetry = onRetry,
)
is SearchResultsState.Loaded -> { is SearchResultsState.Loaded -> {
if (state.response.isEmpty) { if (state.response.isEmpty) {
CenteredHint( CenteredHint(
@@ -3,6 +3,8 @@ package com.fabledsword.minstrel.search.ui
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.fabledsword.minstrel.api.ErrorCopy import com.fabledsword.minstrel.api.ErrorCopy
import com.fabledsword.minstrel.connectivity.NetworkStatusController
import com.fabledsword.minstrel.connectivity.recoveries
import com.fabledsword.minstrel.models.SearchResponseRef import com.fabledsword.minstrel.models.SearchResponseRef
import com.fabledsword.minstrel.models.TrackRef import com.fabledsword.minstrel.models.TrackRef
import com.fabledsword.minstrel.player.PlayerController import com.fabledsword.minstrel.player.PlayerController
@@ -43,6 +45,7 @@ data class SearchState(
class SearchViewModel @Inject constructor( class SearchViewModel @Inject constructor(
private val repository: SearchRepository, private val repository: SearchRepository,
private val player: PlayerController, private val player: PlayerController,
networkStatus: NetworkStatusController,
) : ViewModel() { ) : ViewModel() {
private val queryFlow = MutableStateFlow("") private val queryFlow = MutableStateFlow("")
@@ -69,6 +72,14 @@ class SearchViewModel @Inject constructor(
runSearch(q) runSearch(q)
} }
} }
// Screen-level auto-recovery (issue #1245): a search that failed
// while the server was unreachable re-runs when health returns —
// the queryFlow pipeline can't, since the text hasn't changed.
viewModelScope.launch {
networkStatus.recoveries().collect {
if (internal.value.results is SearchResultsState.Error) retry()
}
}
} }
fun setQuery(value: String) { fun setQuery(value: String) {
@@ -80,6 +91,18 @@ class SearchViewModel @Inject constructor(
setQuery("") setQuery("")
} }
/**
* Re-run the current query after a failure. Bypasses the queryFlow
* pipeline, whose distinctUntilChanged drops a same-text resubmit —
* without this, a transient error pins "Search failed" until the
* user edits the query.
*/
fun retry() {
val q = internal.value.query.trim()
if (q.isEmpty()) return
viewModelScope.launch { runSearch(q) }
}
/** /**
* Tapping a search result builds a queue from the full visible * Tapping a search result builds a queue from the full visible
* track-results list starting at the tapped entry, matching Flutter. * track-results list starting at the tapped entry, matching Flutter.
@@ -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 }
@@ -23,8 +23,12 @@ import com.fabledsword.minstrel.theme.FabledSwordFlatTokens
* ServerImage + fallback structure was identical at all three sites. * ServerImage + fallback structure was identical at all three sites.
* *
* [overlay] is a `BoxScope` slot for things drawn on top of the cover * [overlay] is a `BoxScope` slot for things drawn on top of the cover
* (e.g. the `VariantPill` system-playlist label) — callers can use * (e.g. the `VariantPill` system-playlist label, the play button) —
* `Modifier.align(...)` inside it. * callers can use `Modifier.align(...)` inside it. The overlay sits on
* an outer, UN-clipped box so corner-anchored widgets are not cut off by
* [shape]: a `BottomEnd` play button on a `CircleShape` avatar falls in
* the square's corner, outside the circle, and would otherwise be clipped
* away. Only the artwork + background are clipped to [shape].
*/ */
@Composable @Composable
fun CoverTile( fun CoverTile(
@@ -38,18 +42,23 @@ fun CoverTile(
overlay: @Composable BoxScope.() -> Unit = {}, overlay: @Composable BoxScope.() -> Unit = {},
) { ) {
Box( Box(
modifier = modifier modifier = modifier.size(size),
.size(size)
.clip(shape)
.background(background),
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
) { ) {
ServerImage( Box(
url = url, modifier = Modifier
contentDescription = contentDescription, .fillMaxSize()
modifier = Modifier.fillMaxSize(), .clip(shape)
.background(background),
contentAlignment = Alignment.Center,
) { ) {
fallback() ServerImage(
url = url,
contentDescription = contentDescription,
modifier = Modifier.fillMaxSize(),
) {
fallback()
}
} }
overlay() overlay()
} }
@@ -5,6 +5,7 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
@@ -23,44 +24,63 @@ import com.fabledsword.minstrel.theme.LocalActionColors
* Shared error-with-retry widget. Surfaces a brief message and a * Shared error-with-retry widget. Surfaces a brief message and a
* primary action button. Per the design system, the button uses * primary action button. Per the design system, the button uses
* `LocalActionColors.primary` (Moss) — NEVER the accent. * `LocalActionColors.primary` (Moss) — NEVER the accent.
*
* Renders inside a single-item LazyColumn (same rationale as
* [EmptyState]) so the widget participates in nested-scroll dispatch
* under PullToRefreshBox — both the Retry button and a pull gesture
* can recover from the error state.
*/ */
@Composable @Composable
fun ErrorRetry( fun ErrorRetry(
message: String, message: String,
onRetry: () -> Unit, onRetry: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
title: String? = null,
retryLabel: String = "Retry", retryLabel: String = "Retry",
) { ) {
val actionColors = LocalActionColors.current val actionColors = LocalActionColors.current
Column( LazyColumn(modifier = modifier.fillMaxSize()) {
modifier = modifier item {
.fillMaxSize() Column(
.padding(32.dp), modifier = Modifier
verticalArrangement = Arrangement.Center, .fillParentMaxSize()
horizontalAlignment = Alignment.CenterHorizontally, .padding(32.dp),
) { verticalArrangement = Arrangement.Center,
Icon( horizontalAlignment = Alignment.CenterHorizontally,
imageVector = Lucide.TriangleAlert, ) {
contentDescription = null, Icon(
modifier = Modifier.size(48.dp), imageVector = Lucide.TriangleAlert,
tint = MaterialTheme.colorScheme.error, contentDescription = null,
) modifier = Modifier.size(48.dp),
Text( tint = MaterialTheme.colorScheme.error,
text = message, )
modifier = Modifier.padding(top = 16.dp), if (title != null) {
style = MaterialTheme.typography.bodyMedium, Text(
color = MaterialTheme.colorScheme.onSurface, text = title,
textAlign = TextAlign.Center, modifier = Modifier.padding(top = 16.dp),
) style = MaterialTheme.typography.titleMedium,
Button( color = MaterialTheme.colorScheme.onSurface,
onClick = onRetry, textAlign = TextAlign.Center,
modifier = Modifier.padding(top = 16.dp), )
colors = ButtonDefaults.buttonColors( }
containerColor = actionColors.primary, Text(
contentColor = actionColors.onAction, text = message,
), modifier = Modifier.padding(top = if (title != null) 8.dp else 16.dp),
) { style = MaterialTheme.typography.bodyMedium,
Text(retryLabel) color = MaterialTheme.colorScheme.onSurface,
textAlign = TextAlign.Center,
)
Button(
onClick = onRetry,
modifier = Modifier.padding(top = 16.dp),
colors = ButtonDefaults.buttonColors(
containerColor = actionColors.primary,
contentColor = actionColors.onAction,
),
) {
Text(retryLabel)
}
}
} }
} }
} }
@@ -1,25 +1,40 @@
package com.fabledsword.minstrel.shared.widgets 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.Box
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import coil3.compose.AsyncImage import coil3.compose.AsyncImage
import coil3.compose.AsyncImagePainter import coil3.compose.AsyncImagePainter
import com.fabledsword.minstrel.shared.resolveServerUrl 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 * Renders a server-hosted image, resolving relative URLs centrally so
* every cover surface loads consistently. Shows [fallback] when the URL * every cover surface loads consistently. Shows [fallback] when the URL
* is blank/unresolvable, while the image is still loading, and when the * 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, * load fails — so a tile is never left blank (e.g. art not yet backfilled,
* which the "You might like" row hits often). * 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 @Composable
fun ServerImage( fun ServerImage(
@@ -40,6 +55,23 @@ fun ServerImage(
var state by remember(resolved) { var state by remember(resolved) {
mutableStateOf<AsyncImagePainter.State>(AsyncImagePainter.State.Empty) 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) { Box(modifier = modifier, contentAlignment = Alignment.Center) {
AsyncImage( AsyncImage(
model = resolved, model = resolved,
@@ -48,10 +80,10 @@ fun ServerImage(
contentScale = contentScale, contentScale = contentScale,
onState = { state = it }, onState = { state = it },
) )
if (state is AsyncImagePainter.State.Loading || if (fallbackAlpha > 0f) {
state is AsyncImagePainter.State.Error Box(Modifier.alpha(fallbackAlpha), contentAlignment = Alignment.Center) {
) { fallback()
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())
}
}
@@ -56,4 +56,51 @@ class BuildPlaylistsRowTest {
assertTrue(row.none { it is PlaylistRowItem.OfflinePool }) assertTrue(row.none { it is PlaylistRowItem.OfflinePool })
assertTrue(row.any { it is PlaylistRowItem.Placeholder }) assertTrue(row.any { it is PlaylistRowItem.Placeholder })
} }
private fun songsLike(id: String, cached: Boolean) = PlaylistRef(
id = id,
userId = "u",
name = "Songs like $id",
systemVariant = "songs_like_artist",
trackCount = 25,
fullyCached = cached,
)
@Test
fun `songs-like no longer appears in the main playlists carousel`() {
val owned = listOf(songsLike("a", cached = true), user("u1", cached = true))
val row = buildPlaylistsRow(owned, SystemPlaylistsStatus(), offline = false)
val reals = row.filterIsInstance<PlaylistRowItem.Real>().map { it.playlist.id }
assertTrue("a" !in reals, "songs-like mix leaked into the Playlists row: $reals")
assertTrue("u1" in reals)
}
@Test
fun `online songs-like row shows every generated mix uncapped`() {
// Six generated mixes — the old carousel capped at 3; the dedicated row shows all.
val owned = (1..6).map { songsLike("a$it", cached = true) }
val row = buildSongsLikeRow(owned, SystemPlaylistsStatus(), offline = false)
val reals = row.filterIsInstance<PlaylistRowItem.Real>().map { it.playlist.id }
assertEquals((1..6).map { "a$it" }, reals)
}
@Test
fun `online songs-like row shows placeholders when none generated`() {
val row = buildSongsLikeRow(emptyList(), SystemPlaylistsStatus(), offline = false)
assertTrue(row.isNotEmpty())
assertTrue(row.all { it is PlaylistRowItem.Placeholder })
}
@Test
fun `offline songs-like row shows cached mixes available-first, none hides it`() {
val owned = listOf(songsLike("partial", cached = false), songsLike("full", cached = true))
val row = buildSongsLikeRow(owned, SystemPlaylistsStatus(), offline = true)
val reals = row.filterIsInstance<PlaylistRowItem.Real>().map { it.playlist.id }
// Fully-cached songs-like mix (not refreshable) is available; the
// un-cached one greys and sorts after. No placeholders offline.
assertEquals(listOf("full", "partial"), reals)
assertTrue(row.none { it is PlaylistRowItem.Placeholder })
assertTrue(buildSongsLikeRow(emptyList(), SystemPlaylistsStatus(), offline = true).isEmpty())
}
} }
@@ -11,6 +11,7 @@ import com.fabledsword.minstrel.testutil.MainDispatcherExtension
import io.mockk.every import io.mockk.every
import io.mockk.mockk import io.mockk.mockk
import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
@@ -21,6 +22,13 @@ import kotlin.test.assertEquals
@ExtendWith(MainDispatcherExtension::class) @ExtendWith(MainDispatcherExtension::class)
class LibraryViewModelTest { class LibraryViewModelTest {
// lastSyncError needs a real StateFlow: the VM combines it into uiState,
// and a relaxed-mock flow never emits, so the combine would never fire.
private fun syncControllerMock(): SyncController =
mockk<SyncController>(relaxed = true) {
every { lastSyncError } returns MutableStateFlow(null)
}
@Test @Test
fun `initial state is Loading before any DAO emission`() = runTest { fun `initial state is Loading before any DAO emission`() = runTest {
val repo = mockk<LibraryRepository>() val repo = mockk<LibraryRepository>()
@@ -29,7 +37,7 @@ class LibraryViewModelTest {
val vm = LibraryViewModel( val vm = LibraryViewModel(
repo, repo,
mockk<SyncController>(relaxed = true), syncControllerMock(),
mockk<PlayerController>(relaxed = true), mockk<PlayerController>(relaxed = true),
) )
@@ -51,7 +59,7 @@ class LibraryViewModelTest {
val vm = LibraryViewModel( val vm = LibraryViewModel(
repo, repo,
mockk<SyncController>(relaxed = true), syncControllerMock(),
mockk<PlayerController>(relaxed = true), mockk<PlayerController>(relaxed = true),
) )
@@ -71,7 +79,7 @@ class LibraryViewModelTest {
val vm = LibraryViewModel( val vm = LibraryViewModel(
repo, repo,
mockk<SyncController>(relaxed = true), syncControllerMock(),
mockk<PlayerController>(relaxed = true), mockk<PlayerController>(relaxed = true),
) )
@@ -94,7 +102,7 @@ class LibraryViewModelTest {
val vm = LibraryViewModel( val vm = LibraryViewModel(
repo, repo,
mockk<SyncController>(relaxed = true), syncControllerMock(),
mockk<PlayerController>(relaxed = true), mockk<PlayerController>(relaxed = true),
) )
@@ -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-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-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-flutter:3.44` — Flutter client tests + debug/release APK builds (`.gitea/workflows/flutter.yml`). - `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 ## Image deps used
@@ -25,13 +31,20 @@ git.fabledsword.com/bvandeusen/ci-flutter:3.44
- **docker buildx** — release container build + push in `release.yml`. - **docker buildx** — release container build + push in `release.yml`.
- **curl** — release-asset polling / upload in `release.yml`. - **curl** — release-asset polling / upload in `release.yml`.
### From `ci-flutter:3.44` ### From `ci-android:36`
- **Flutter** (3.44 stable channel) — `flutter pub get`, `flutter analyze --fatal-infos`, `flutter test`, `flutter build apk` (debug + signed release). - **JDK 25** — Gradle launcher + Android build. Requires Gradle 9.1.0+ in
- **Dart** — `dart run tool/gen_tokens.dart`, `dart run build_runner build` (drift codegen). `android/gradle/wrapper`; older Gradle rejects JDK 25 with an opaque `"25.0.3"`
- **Android SDK + NDK + cmdline-tools + build-tools** — APK assembly + signing. error. The workflows also set `JAVA_TOOL_OPTIONS=--enable-native-access=ALL-UNNAMED`
- **Java 25** — Gradle / Android build. 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). - **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 ## Per-job tool installs
@@ -39,9 +52,69 @@ None.
## Notes ## 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. - **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. - **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. - **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. - **Friction asks.** None pending. The two images cover everything Minstrel needs.
+40
View File
@@ -15,6 +15,7 @@ import (
"github.com/jackc/pgx/v5/pgxpool" "github.com/jackc/pgx/v5/pgxpool"
"git.fabledsword.com/bvandeusen/minstrel/internal/config" "git.fabledsword.com/bvandeusen/minstrel/internal/config"
"git.fabledsword.com/bvandeusen/minstrel/internal/coplay"
"git.fabledsword.com/bvandeusen/minstrel/internal/coverart" "git.fabledsword.com/bvandeusen/minstrel/internal/coverart"
"git.fabledsword.com/bvandeusen/minstrel/internal/db" "git.fabledsword.com/bvandeusen/minstrel/internal/db"
"git.fabledsword.com/bvandeusen/minstrel/internal/eventbus" "git.fabledsword.com/bvandeusen/minstrel/internal/eventbus"
@@ -25,12 +26,14 @@ import (
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrrequests" "git.fabledsword.com/bvandeusen/minstrel/internal/lidarrrequests"
"git.fabledsword.com/bvandeusen/minstrel/internal/logging" "git.fabledsword.com/bvandeusen/minstrel/internal/logging"
"git.fabledsword.com/bvandeusen/minstrel/internal/playlists" "git.fabledsword.com/bvandeusen/minstrel/internal/playlists"
"git.fabledsword.com/bvandeusen/minstrel/internal/recsettings"
"git.fabledsword.com/bvandeusen/minstrel/internal/scrobble" "git.fabledsword.com/bvandeusen/minstrel/internal/scrobble"
"git.fabledsword.com/bvandeusen/minstrel/internal/scrobble/listenbrainz" "git.fabledsword.com/bvandeusen/minstrel/internal/scrobble/listenbrainz"
"git.fabledsword.com/bvandeusen/minstrel/internal/server" "git.fabledsword.com/bvandeusen/minstrel/internal/server"
"git.fabledsword.com/bvandeusen/minstrel/internal/similarity" "git.fabledsword.com/bvandeusen/minstrel/internal/similarity"
"git.fabledsword.com/bvandeusen/minstrel/internal/subsonic" "git.fabledsword.com/bvandeusen/minstrel/internal/subsonic"
syncpkg "git.fabledsword.com/bvandeusen/minstrel/internal/sync" syncpkg "git.fabledsword.com/bvandeusen/minstrel/internal/sync"
"git.fabledsword.com/bvandeusen/minstrel/internal/tags"
) )
func main() { func main() {
@@ -146,6 +149,16 @@ func run() error {
coverEnricher := coverart.NewEnricher(pool, logger.With("component", "coverart"), coverSettings) coverEnricher := coverart.NewEnricher(pool, logger.With("component", "coverart"), coverSettings)
coverEnricher.DataDir = cfg.Storage.DataDir coverEnricher.DataDir = cfg.Storage.DataDir
// Recommendation tuning lab (#1250): seeds shipped defaults on first
// boot and pushes the daily_mix weights + taste config into package
// playlists — must precede the scheduler so the first builds score
// with the operator's tuned values, not the pre-push literals.
recSettings, err := recsettings.New(ctx, pool, logger.With("component", "recsettings"))
if err != nil {
logger.Error("recommendation settings service init failed", "err", err)
os.Exit(1)
}
// One unified scan chain: library walk → MBID backfill → cover enrich. // One unified scan chain: library walk → MBID backfill → cover enrich.
// Boot-time scan and manual-trigger scans share this path; results land // Boot-time scan and manual-trigger scans share this path; results land
// in scan_runs for the admin overview. // in scan_runs for the admin overview.
@@ -194,6 +207,31 @@ func run() error {
similarityWorker := similarity.NewWorker(pool, listenbrainz.NewClient(), logger.With("component", "similarity")) similarityWorker := similarity.NewWorker(pool, listenbrainz.NewClient(), logger.With("component", "similarity"))
go similarityWorker.Run(ctx) go similarityWorker.Run(ctx)
// Start the household co-play worker (#1533). Recomputes artistartist
// co-occurrence edges (source='user_cooccurrence') from play_events every
// 6h — a collaborative candidate arm for the radio/mix pools. Pure local
// SQL, no external calls; empty on single-user servers.
go coplay.NewWorker(pool, logger.With("component", "coplay")).Run(ctx)
// Start the tag-enrichment worker (#1490). Reconciles the compiled-in
// tag providers with tag_provider_settings, bumps the sources version if
// the provider set changed (re-opening settled rows), then drains tracks
// needing folksonomy tags on a periodic tick. Standalone (not in the file
// scan chain) because tag lookups need only DB fields — MBID / artist /
// title — that a scan has already imported.
tagSettings, err := tags.NewSettingsService(ctx, pool, logger.With("component", "tags"))
if err != nil {
logger.Error("tag settings service init failed", "err", err)
os.Exit(1)
}
if newVer, bumped, berr := tagSettings.BumpVersionIfProvidersChanged(ctx); berr != nil {
logger.Warn("tags: provider-hash boot check failed", "err", berr)
} else if bumped {
logger.Info("tags: registered provider set changed; version bumped", "new_version", newVer)
}
tagEnricher := tags.NewEnricher(pool, logger.With("component", "tags"), tagSettings)
go tags.NewWorker(tagEnricher, logger.With("component", "tags")).Run(ctx)
// Start the GC worker. Runs every 1h and sweeps lifecycle tables // Start the GC worker. Runs every 1h and sweeps lifecycle tables
// that have no writer-side close path or retention policy: // that have no writer-side close path or retention policy:
// orphan play_events, stale play_sessions, expired // orphan play_events, stale play_sessions, expired
@@ -294,6 +332,8 @@ func run() error {
}, cfg.Events, cfg.Recommendation, cfg.Storage.DataDir, cfg.Branding, coverEnricher, coverSettings, scanner, scanCfg) }, cfg.Events, cfg.Recommendation, cfg.Storage.DataDir, cfg.Branding, coverEnricher, coverSettings, scanner, scanCfg)
srv.Bus = bus srv.Bus = bus
srv.PlaylistScheduler = playlistScheduler srv.PlaylistScheduler = playlistScheduler
srv.RecSettings = recSettings
srv.TagSettings = tagSettings
srv.StreamSecret = cfg.StreamSecret srv.StreamSecret = cfg.StreamSecret
httpServer := &http.Server{ httpServer := &http.Server{
Addr: cfg.Server.Address, Addr: cfg.Server.Address,
+202
View File
@@ -0,0 +1,202 @@
// Admin recommendation-trends endpoint (#1251): weekly per-surface
// outcome series with tuning-audit markers — the verify half of the
// tune→verify loop the tuning lab (#1250) opens. Aggregated across
// all users because the knobs are global; rows carry rates only.
package api
import (
"encoding/json"
"net/http"
"sort"
"strconv"
"time"
"git.fabledsword.com/bvandeusen/minstrel/internal/apierror"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
)
const (
trendsDefaultWeeks = 12
trendsMaxWeeks = 52
// trendsAuditFetchCap bounds the audit fetch; markers older than
// the window are dropped in Go. Far above any real knob-turn count
// inside a year.
trendsAuditFetchCap = 500
)
// trendPoint is one week of one surface family's outcomes.
type trendPoint struct {
WeekStart string `json:"week_start"` // ISO date (Monday)
Plays int64 `json:"plays"`
Skips int64 `json:"skips"`
SkipRate float64 `json:"skip_rate"`
AvgCompletion float64 `json:"avg_completion"`
// TasteHitRate is the share of plays whose track's artist carries a
// positive weight in the player's current taste profile — a drifted
// but retroactive read on whether the surface feeds taste-fitting
// tracks.
TasteHitRate float64 `json:"taste_hit_rate"`
// completionN carries the completion sample count through same-week
// merges so avg_completion stays sample-weighted; not serialized.
completionN int64
}
// trendSeries is one surface family's weekly series.
type trendSeries struct {
Key string `json:"key"`
Label string `json:"label"`
Intent string `json:"intent"` // go_to | discovery | direct; "" for the manual baseline
Plays int64 `json:"plays"` // window total, for sorting/volume-gating
Points []trendPoint `json:"points"`
}
// trendMarker is one tuning-audit event annotated on the timeline.
type trendMarker struct {
ChangedAt string `json:"changed_at"`
Scope string `json:"scope"`
Action string `json:"action"`
Changes json.RawMessage `json:"changes"`
}
type trendsResp struct {
Weeks int `json:"weeks"`
Series []trendSeries `json:"series"`
Markers []trendMarker `json:"markers"`
}
// handleGetRecommendationTrends implements
// GET /api/admin/recommendation-trends?weeks=N (default 12, cap 52).
func (h *handlers) handleGetRecommendationTrends(w http.ResponseWriter, r *http.Request) {
weeks := trendsDefaultWeeks
if v := r.URL.Query().Get("weeks"); v != "" {
n, err := strconv.Atoi(v)
if err != nil || n < 1 {
writeErr(w, apierror.BadRequest("bad_request", "invalid weeks"))
return
}
if n > trendsMaxWeeks {
n = trendsMaxWeeks
}
weeks = n
}
q := dbq.New(h.pool)
rows, err := q.RecommendationWeeklyTrends(r.Context(), int32(weeks))
if err != nil {
h.logger.Error("api: recommendation trends", "err", err)
writeErr(w, apierror.InternalMsg("lookup failed", err))
return
}
audits, err := q.ListTuningAudit(r.Context(), trendsAuditFetchCap)
if err != nil {
h.logger.Error("api: recommendation trends audit", "err", err)
writeErr(w, apierror.InternalMsg("lookup failed", err))
return
}
writeJSON(w, http.StatusOK, buildTrendsResponse(weeks, time.Now().UTC(), rows, audits))
}
// buildTrendsResponse folds weekly rows into per-family series and
// windows the audit markers. Split from the handler for pure-unit
// testability.
func buildTrendsResponse(
weeks int, now time.Time,
rows []dbq.RecommendationWeeklyTrendsRow,
audits []dbq.RecommendationTuningAudit,
) trendsResp {
type accum struct {
fam recFamily
plays int64
points []trendPoint
}
families := map[string]*accum{}
for _, row := range rows {
fam := recFamily{key: "manual", label: "Manual library plays"}
if row.Source != nil && *row.Source != "" {
fam = bucketRecSource(*row.Source)
}
acc, ok := families[fam.key]
if !ok {
acc = &accum{fam: fam}
families[fam.key] = acc
}
acc.plays += row.Plays
p := trendPoint{
WeekStart: row.WeekStart.Time.Format("2006-01-02"),
Plays: row.Plays,
Skips: row.Skips,
AvgCompletion: row.AvgCompletion,
completionN: row.CompletionN,
}
if row.Plays > 0 {
p.SkipRate = float64(row.Skips) / float64(row.Plays)
p.TasteHitRate = float64(row.TasteHits) / float64(row.Plays)
}
// Same family can arrive as several raw sources (radio:<uuid>);
// merge same-week points play-weighted.
if n := len(acc.points); n > 0 && acc.points[n-1].WeekStart == p.WeekStart {
acc.points[n-1] = mergeTrendPoints(acc.points[n-1], p)
} else {
acc.points = append(acc.points, p)
}
}
resp := trendsResp{Weeks: weeks, Series: []trendSeries{}, Markers: []trendMarker{}}
for _, acc := range families {
resp.Series = append(resp.Series, trendSeries{
Key: acc.fam.key,
Label: acc.fam.label,
Intent: acc.fam.intent,
Plays: acc.plays,
Points: acc.points,
})
}
sort.Slice(resp.Series, func(i, j int) bool {
if resp.Series[i].Plays != resp.Series[j].Plays {
return resp.Series[i].Plays > resp.Series[j].Plays
}
return resp.Series[i].Key < resp.Series[j].Key
})
cutoff := now.Add(-time.Duration(weeks) * 7 * 24 * time.Hour)
for _, a := range audits {
if a.ChangedAt.Time.Before(cutoff) {
continue
}
resp.Markers = append(resp.Markers, trendMarker{
ChangedAt: a.ChangedAt.Time.UTC().Format(time.RFC3339),
Scope: a.Scope,
Action: a.Action,
Changes: json.RawMessage(a.Changes),
})
}
// ListTuningAudit returns newest-first; the timeline reads better
// oldest-first.
sort.Slice(resp.Markers, func(i, j int) bool {
return resp.Markers[i].ChangedAt < resp.Markers[j].ChangedAt
})
return resp
}
// mergeTrendPoints combines two same-week points of one family:
// counts add, skip/taste rates re-derive from the merged counts, and
// avg_completion is weighted by each side's completion sample count.
func mergeTrendPoints(a, b trendPoint) trendPoint {
out := trendPoint{
WeekStart: a.WeekStart,
Plays: a.Plays + b.Plays,
Skips: a.Skips + b.Skips,
completionN: a.completionN + b.completionN,
}
if out.Plays > 0 {
out.SkipRate = float64(out.Skips) / float64(out.Plays)
out.TasteHitRate = (a.TasteHitRate*float64(a.Plays) + b.TasteHitRate*float64(b.Plays)) /
float64(out.Plays)
}
if out.completionN > 0 {
out.AvgCompletion = (a.AvgCompletion*float64(a.completionN) + b.AvgCompletion*float64(b.completionN)) /
float64(out.completionN)
}
return out
}
@@ -0,0 +1,152 @@
package api
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/go-chi/chi/v5"
"github.com/jackc/pgx/v5/pgtype"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
)
func date(s string) pgtype.Date {
t, _ := time.Parse("2006-01-02", s)
return pgtype.Date{Time: t, Valid: true}
}
func TestBuildTrendsResponse_SeriesMergingAndMarkers(t *testing.T) {
src := func(s string) *string { return &s }
now := time.Date(2026, 7, 3, 12, 0, 0, 0, time.UTC)
rows := []dbq.RecommendationWeeklyTrendsRow{
// Two radio session sources in the same week collapse into one
// family point; completion weighting by sample count.
{WeekStart: date("2026-06-22"), Source: src("radio:aaaa"),
Plays: 3, Skips: 1, CompletionN: 3, AvgCompletion: 0.6, TasteHits: 3},
{WeekStart: date("2026-06-22"), Source: src("radio:bbbb"),
Plays: 1, Skips: 1, CompletionN: 1, AvgCompletion: 0.2, TasteHits: 0},
{WeekStart: date("2026-06-29"), Source: src("radio:aaaa"),
Plays: 2, Skips: 0, CompletionN: 2, AvgCompletion: 0.9, TasteHits: 1},
// NULL source = manual baseline family.
{WeekStart: date("2026-06-29"), Source: nil,
Plays: 5, Skips: 1, CompletionN: 5, AvgCompletion: 0.8, TasteHits: 4},
}
audits := []dbq.RecommendationTuningAudit{
{ID: 2, ChangedAt: pgtype.Timestamptz{Time: now.Add(-24 * time.Hour), Valid: true},
Scope: "radio", Action: "update", Changes: []byte(`[{"field":"taste_weight","old":1,"new":2}]`)},
// Older than the window → dropped.
{ID: 1, ChangedAt: pgtype.Timestamptz{Time: now.Add(-100 * 7 * 24 * time.Hour), Valid: true},
Scope: "taste", Action: "reset", Changes: []byte(`[]`)},
}
resp := buildTrendsResponse(12, now, rows, audits)
if len(resp.Series) != 2 {
t.Fatalf("series = %d, want 2 (radio + manual)", len(resp.Series))
}
radio := resp.Series[0] // 6 plays > manual's 5 → sorted first
if radio.Key != "radio" || radio.Plays != 6 {
t.Fatalf("series[0] = %s/%d, want radio/6", radio.Key, radio.Plays)
}
if len(radio.Points) != 2 {
t.Fatalf("radio points = %d, want 2 weeks", len(radio.Points))
}
wk1 := radio.Points[0]
if wk1.WeekStart != "2026-06-22" || wk1.Plays != 4 || wk1.Skips != 2 {
t.Errorf("week1 = %+v, want 2026-06-22 with 4 plays / 2 skips", wk1)
}
if wk1.SkipRate != 0.5 {
t.Errorf("week1 skip_rate = %v, want 0.5", wk1.SkipRate)
}
// Completion weighted by sample count: (0.6*3 + 0.2*1) / 4 = 0.5.
if wk1.AvgCompletion < 0.49 || wk1.AvgCompletion > 0.51 {
t.Errorf("week1 avg_completion = %v, want 0.5", wk1.AvgCompletion)
}
// Taste hits: 3 of 4 plays.
if wk1.TasteHitRate != 0.75 {
t.Errorf("week1 taste_hit_rate = %v, want 0.75", wk1.TasteHitRate)
}
if manual := resp.Series[1]; manual.Key != "manual" || manual.Intent != "" {
t.Errorf("series[1] = %+v, want the manual baseline family", manual)
}
if len(resp.Markers) != 1 {
t.Fatalf("markers = %d, want 1 (out-of-window marker dropped)", len(resp.Markers))
}
if resp.Markers[0].Scope != "radio" || resp.Markers[0].Action != "update" {
t.Errorf("marker = %+v, want radio/update", resp.Markers[0])
}
}
func newTrendsRouter(h *handlers) chi.Router {
r := chi.NewRouter()
r.Get("/api/admin/recommendation-trends", h.handleGetRecommendationTrends)
return r
}
func TestRecommendationTrends_EndToEnd(t *testing.T) {
h, pool := testHandlers(t)
user := seedUser(t, pool, "trends", "pw", true)
artist := seedArtist(t, pool, "TrendArtist")
album := seedAlbum(t, pool, artist.ID, "TrendAlbum", 2020)
tk := seedTrack(t, pool, album.ID, artist.ID, "TrendTrack", 1, 200000)
session := seedPlaySession(t, pool, user.ID, time.Now())
// Positive taste weight for the artist → plays count as taste hits.
if _, err := pool.Exec(context.Background(),
`INSERT INTO taste_profile_artists (user_id, artist_id, weight) VALUES ($1, $2, 1.5)`,
user.ID, artist.ID); err != nil {
t.Fatalf("seed taste profile: %v", err)
}
radio := "radio"
seedSourcedPlay(t, h, user.ID, tk.ID, session, &radio, nil, 0.9, false)
// A knob turn to mark the timeline.
if err := h.recSettings.UpdateProfile(context.Background(), "radio",
map[string]float64{"taste_weight": 2}); err != nil {
t.Fatalf("UpdateProfile: %v", err)
}
req := httptest.NewRequest(http.MethodGet, "/api/admin/recommendation-trends", nil)
rec := httptest.NewRecorder()
newTrendsRouter(h).ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200 (%s)", rec.Code, rec.Body.String())
}
var resp trendsResp
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode: %v", err)
}
if resp.Weeks != trendsDefaultWeeks {
t.Errorf("weeks = %d, want %d", resp.Weeks, trendsDefaultWeeks)
}
var radioSeries *trendSeries
for i := range resp.Series {
if resp.Series[i].Key == "radio" {
radioSeries = &resp.Series[i]
}
}
if radioSeries == nil || len(radioSeries.Points) != 1 {
t.Fatalf("radio series = %+v, want one point", radioSeries)
}
if radioSeries.Points[0].TasteHitRate != 1.0 {
t.Errorf("taste_hit_rate = %v, want 1.0 (positive-weight artist)",
radioSeries.Points[0].TasteHitRate)
}
if len(resp.Markers) != 1 || resp.Markers[0].Scope != "radio" {
t.Errorf("markers = %+v, want the radio knob turn", resp.Markers)
}
}
func TestRecommendationTrends_InvalidWeeks(t *testing.T) {
h, _ := testHandlers(t)
req := httptest.NewRequest(http.MethodGet, "/api/admin/recommendation-trends?weeks=zero", nil)
rec := httptest.NewRecorder()
newTrendsRouter(h).ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest {
t.Errorf("status = %d, want 400", rec.Code)
}
}
+180
View File
@@ -0,0 +1,180 @@
// Admin recommendation-tuning endpoints (#1250): the defaults-
// discovery lab. GET returns current values + shipped defaults for
// every scope; PATCH applies a partial update to one scope; reset
// restores a scope to shipped defaults. Every change writes an audit
// row (consumed by the metrics trend view, #1251).
package api
import (
"encoding/json"
"errors"
"net/http"
"github.com/go-chi/chi/v5"
"git.fabledsword.com/bvandeusen/minstrel/internal/apierror"
"git.fabledsword.com/bvandeusen/minstrel/internal/recommendation"
"git.fabledsword.com/bvandeusen/minstrel/internal/recsettings"
)
// weightsResp is one weight profile on the wire, keyed by the same
// snake_case field names the PATCH body accepts.
type weightsResp struct {
BaseWeight float64 `json:"base_weight"`
LikeBoost float64 `json:"like_boost"`
RecencyWeight float64 `json:"recency_weight"`
SkipPenalty float64 `json:"skip_penalty"`
JitterMagnitude float64 `json:"jitter_magnitude"`
ContextWeight float64 `json:"context_weight"`
SimilarityWeight float64 `json:"similarity_weight"`
TasteWeight float64 `json:"taste_weight"`
ContextTimeWeight float64 `json:"context_time_weight"`
}
func weightsRespFrom(w recommendation.ScoringWeights) weightsResp {
return weightsResp{
BaseWeight: w.BaseWeight,
LikeBoost: w.LikeBoost,
RecencyWeight: w.RecencyWeight,
SkipPenalty: w.SkipPenalty,
JitterMagnitude: w.JitterMagnitude,
ContextWeight: w.ContextWeight,
SimilarityWeight: w.SimilarityWeight,
TasteWeight: w.TasteWeight,
ContextTimeWeight: w.ContextTimeWeight,
}
}
type tasteTuningResp struct {
HalfLifeDays float64 `json:"half_life_days"`
EngagementHardSkip float64 `json:"engagement_hard_skip"`
EngagementNeutral float64 `json:"engagement_neutral"`
EngagementFull float64 `json:"engagement_full"`
EnrichedTagScale float64 `json:"enriched_tag_scale"`
EraScale float64 `json:"era_scale"`
MoodScale float64 `json:"mood_scale"`
}
func tasteRespFrom(t recsettings.TasteTuning) tasteTuningResp {
return tasteTuningResp{
HalfLifeDays: t.HalfLifeDays,
EngagementHardSkip: t.EngagementHardSkip,
EngagementNeutral: t.EngagementNeutral,
EngagementFull: t.EngagementFull,
EnrichedTagScale: t.EnrichedTagScale,
EraScale: t.EraScale,
MoodScale: t.MoodScale,
}
}
// 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"`
}
func (h *handlers) tuningSnapshot() tuningSnapshot {
var out tuningSnapshot
out.Profiles = map[string]weightsResp{
recsettings.ScopeRadio: weightsRespFrom(h.recSettings.Weights(recsettings.ScopeRadio)),
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
}
// handleGetRecommendationTuning implements GET /api/admin/recommendation-tuning.
func (h *handlers) handleGetRecommendationTuning(w http.ResponseWriter, _ *http.Request) {
writeJSON(w, http.StatusOK, h.tuningSnapshot())
}
// patchTuningReq carries the partial update: field name → new value,
// using the same snake_case names the GET response emits.
type patchTuningReq struct {
Values map[string]float64 `json:"values"`
}
// handlePatchRecommendationTuning implements
// PATCH /api/admin/recommendation-tuning/{scope}.
func (h *handlers) handlePatchRecommendationTuning(w http.ResponseWriter, r *http.Request) {
scope := chi.URLParam(r, "scope")
var body patchTuningReq
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeErr(w, apierror.BadRequest("bad_body", "invalid JSON"))
return
}
if len(body.Values) == 0 {
writeErr(w, apierror.BadRequest("bad_body", "values is empty"))
return
}
var err error
switch scope {
case recsettings.ScopeTaste:
err = h.recSettings.UpdateTaste(r.Context(), body.Values)
case recsettings.ScopeDiscover:
err = h.recSettings.UpdateDiscover(r.Context(), body.Values)
default:
err = h.recSettings.UpdateProfile(r.Context(), scope, body.Values)
}
if err != nil {
writeTuningErr(w, h, scope, err)
return
}
writeJSON(w, http.StatusOK, h.tuningSnapshot())
}
// handleResetRecommendationTuning implements
// POST /api/admin/recommendation-tuning/{scope}/reset.
func (h *handlers) handleResetRecommendationTuning(w http.ResponseWriter, r *http.Request) {
scope := chi.URLParam(r, "scope")
if err := h.recSettings.Reset(r.Context(), scope); err != nil {
writeTuningErr(w, h, scope, err)
return
}
writeJSON(w, http.StatusOK, h.tuningSnapshot())
}
// writeTuningErr maps recsettings validation errors to 400s and
// everything else to a logged 500.
func writeTuningErr(w http.ResponseWriter, h *handlers, scope string, err error) {
switch {
case errors.Is(err, recsettings.ErrUnknownScope):
writeErr(w, &apierror.Error{
Status: http.StatusNotFound, Code: "not_found", Message: "no such tuning scope",
})
case errors.Is(err, recsettings.ErrUnknownField), errors.Is(err, recsettings.ErrOutOfRange):
writeErr(w, apierror.BadRequest("invalid_tuning", err.Error()))
default:
h.logger.Error("admin: recommendation tuning", "scope", scope, "err", err)
writeErr(w, apierror.InternalMsg("tuning update failed", err))
}
}
@@ -0,0 +1,110 @@
package api
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/go-chi/chi/v5"
"git.fabledsword.com/bvandeusen/minstrel/internal/recsettings"
)
func newTuningRouter(h *handlers) chi.Router {
r := chi.NewRouter()
r.Get("/api/admin/recommendation-tuning", h.handleGetRecommendationTuning)
r.Patch("/api/admin/recommendation-tuning/{scope}", h.handlePatchRecommendationTuning)
r.Post("/api/admin/recommendation-tuning/{scope}/reset", h.handleResetRecommendationTuning)
return r
}
func decodeTuning(t *testing.T, rec *httptest.ResponseRecorder) tuningSnapshot {
t.Helper()
var snap tuningSnapshot
if err := json.Unmarshal(rec.Body.Bytes(), &snap); err != nil {
t.Fatalf("decode: %v", err)
}
return snap
}
func TestRecommendationTuning_GetReturnsShippedDefaults(t *testing.T) {
h, _ := testHandlers(t)
req := httptest.NewRequest(http.MethodGet, "/api/admin/recommendation-tuning", nil)
rec := httptest.NewRecorder()
newTuningRouter(h).ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rec.Code)
}
snap := decodeTuning(t, rec)
if snap.Profiles["radio"].TasteWeight != 1.0 || snap.Profiles["daily_mix"].TasteWeight != 1.5 {
t.Errorf("profiles = %+v, want shipped taste weights 1.0 / 1.5", snap.Profiles)
}
if snap.Taste.HalfLifeDays != 75 {
t.Errorf("taste half-life = %v, want shipped 75", snap.Taste.HalfLifeDays)
}
if snap.Shipped.Profiles["radio"] != snap.Profiles["radio"] {
t.Error("untouched values must equal shipped defaults")
}
}
func TestRecommendationTuning_PatchAndReset(t *testing.T) {
h, _ := testHandlers(t)
r := newTuningRouter(h)
req := httptest.NewRequest(http.MethodPatch, "/api/admin/recommendation-tuning/radio",
strings.NewReader(`{"values":{"taste_weight": 2.5}}`))
rec := httptest.NewRecorder()
r.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("patch status = %d, want 200 (%s)", rec.Code, rec.Body.String())
}
snap := decodeTuning(t, rec)
if snap.Profiles["radio"].TasteWeight != 2.5 {
t.Errorf("patched taste_weight = %v, want 2.5", snap.Profiles["radio"].TasteWeight)
}
// The change is live for the radio scoring path.
if got := h.recSettings.Weights(recsettings.ScopeRadio).TasteWeight; got != 2.5 {
t.Errorf("service taste_weight = %v, want 2.5 (live effect)", got)
}
req = httptest.NewRequest(http.MethodPost, "/api/admin/recommendation-tuning/radio/reset", nil)
rec = httptest.NewRecorder()
r.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("reset status = %d, want 200", rec.Code)
}
snap = decodeTuning(t, rec)
if snap.Profiles["radio"].TasteWeight != 1.0 {
t.Errorf("reset taste_weight = %v, want shipped 1.0", snap.Profiles["radio"].TasteWeight)
}
}
func TestRecommendationTuning_PatchErrors(t *testing.T) {
h, _ := testHandlers(t)
r := newTuningRouter(h)
cases := []struct {
name, path, body string
want int
}{
{"unknown scope", "/api/admin/recommendation-tuning/banana",
`{"values":{"taste_weight":1}}`, http.StatusNotFound},
{"unknown field", "/api/admin/recommendation-tuning/radio",
`{"values":{"vibes":1}}`, http.StatusBadRequest},
{"out of range", "/api/admin/recommendation-tuning/taste",
`{"values":{"engagement_neutral":2}}`, http.StatusBadRequest},
{"empty values", "/api/admin/recommendation-tuning/radio",
`{"values":{}}`, http.StatusBadRequest},
{"bad json", "/api/admin/recommendation-tuning/radio",
`{`, http.StatusBadRequest},
}
for _, c := range cases {
req := httptest.NewRequest(http.MethodPatch, c.path, strings.NewReader(c.body))
rec := httptest.NewRecorder()
r.ServeHTTP(rec, req)
if rec.Code != c.want {
t.Errorf("%s: status = %d, want %d", c.name, rec.Code, c.want)
}
}
}
+153
View File
@@ -0,0 +1,153 @@
package api
import (
"encoding/json"
"errors"
"net/http"
"time"
"github.com/go-chi/chi/v5"
"git.fabledsword.com/bvandeusen/minstrel/internal/apierror"
"git.fabledsword.com/bvandeusen/minstrel/internal/tags"
)
// tagSourceResp is the wire shape for one tag-enrichment provider in the
// GET /api/admin/tag-sources list. Mirrors the cover-sources admin surface
// (admin_cover_sources.go) — a separate, independent settings card so a new
// tag source is added without touching art settings (#1490).
type tagSourceResp struct {
ID string `json:"id"`
DisplayName string `json:"display_name"`
RequiresAPIKey bool `json:"requires_api_key"`
Supports []string `json:"supports"`
Enabled bool `json:"enabled"`
APIKeySet bool `json:"api_key_set"`
DisplayOrder int32 `json:"display_order"`
Testable bool `json:"testable"`
}
func tagSourceRespFrom(info tags.ProviderInfo) tagSourceResp {
supports := info.Supports
if supports == nil {
supports = []string{}
}
return tagSourceResp{
ID: info.ID,
DisplayName: info.DisplayName,
RequiresAPIKey: info.RequiresAPIKey,
Supports: supports,
Enabled: info.Enabled,
APIKeySet: info.APIKeySet,
DisplayOrder: info.DisplayOrder,
Testable: info.Testable,
}
}
type tagSourcesListResp struct {
Providers []tagSourceResp `json:"providers"`
SourcesVersion int32 `json:"sources_version"`
}
// handleListTagSources implements GET /api/admin/tag-sources.
func (h *handlers) handleListTagSources(w http.ResponseWriter, _ *http.Request) {
infos := h.tagSettings.ListProviderInfo()
out := tagSourcesListResp{
Providers: make([]tagSourceResp, 0, len(infos)),
SourcesVersion: h.tagSettings.CurrentVersion(),
}
for _, info := range infos {
out.Providers = append(out.Providers, tagSourceRespFrom(info))
}
writeJSON(w, http.StatusOK, out)
}
// updateTagSourceReq is the PATCH body. nil pointers mean "leave
// unchanged"; non-nil sets (empty api_key clears).
type updateTagSourceReq struct {
Enabled *bool `json:"enabled,omitempty"`
APIKey *string `json:"api_key,omitempty"`
}
type updateTagSourceResp struct {
tagSourceResp
VersionBumped bool `json:"version_bumped"`
}
// handleUpdateTagSource implements PATCH /api/admin/tag-sources/{provider_id}.
func (h *handlers) handleUpdateTagSource(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "provider_id")
var body updateTagSourceReq
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeErr(w, apierror.BadRequest("bad_body", "invalid JSON"))
return
}
bumped, err := h.tagSettings.UpdateProvider(r.Context(), id, tags.ProviderUpdatePatch{
Enabled: body.Enabled,
APIKey: body.APIKey,
})
if err != nil {
if errors.Is(err, tags.ErrProviderNotFound) {
writeErr(w, &apierror.Error{Status: http.StatusNotFound, Code: "not_found", Message: "no such provider"})
return
}
h.logger.Error("admin: update tag source", "id", id, "err", err)
writeErr(w, apierror.InternalMsg("update failed", err))
return
}
var found tagSourceResp
for _, info := range h.tagSettings.ListProviderInfo() {
if info.ID == id {
found = tagSourceRespFrom(info)
break
}
}
writeJSON(w, http.StatusOK, updateTagSourceResp{tagSourceResp: found, VersionBumped: bumped})
}
type testTagSourceResp struct {
OK bool `json:"ok"`
DurationMs int64 `json:"duration_ms,omitempty"`
Error string `json:"error,omitempty"`
}
// handleTestTagSource implements POST /api/admin/tag-sources/{provider_id}/test.
func (h *handlers) handleTestTagSource(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "provider_id")
start := time.Now()
err := h.tagSettings.TestProvider(r.Context(), id)
duration := time.Since(start).Milliseconds()
if err == nil {
writeJSON(w, http.StatusOK, testTagSourceResp{OK: true, DurationMs: duration})
return
}
if errors.Is(err, tags.ErrProviderNotFound) {
writeErr(w, &apierror.Error{Status: http.StatusNotFound, Code: "not_found", Message: "no such provider"})
return
}
// Not testable, or the test failed — both surface as ok=false with a
// 200 (the operation completed; the result is data, not an error).
writeJSON(w, http.StatusOK, testTagSourceResp{OK: false, DurationMs: duration, Error: err.Error()})
}
type researchTagsResp struct {
SourcesVersion int32 `json:"sources_version"`
}
// handleResearchTags implements POST /api/admin/tag-sources/research: bump
// the sources version so every settled ('none') track becomes eligible for
// a re-enrichment pass, then the background worker re-processes them.
func (h *handlers) handleResearchTags(w http.ResponseWriter, r *http.Request) {
newVer, err := h.tagSettings.BumpVersion(r.Context())
if err != nil {
h.logger.Error("admin: research tags", "err", err)
writeErr(w, apierror.InternalMsg("research failed", err))
return
}
writeJSON(w, http.StatusOK, researchTagsResp{SourcesVersion: newVer})
}
+243
View File
@@ -0,0 +1,243 @@
package api
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"testing"
"github.com/go-chi/chi/v5"
"git.fabledsword.com/bvandeusen/minstrel/internal/auth"
"git.fabledsword.com/bvandeusen/minstrel/internal/tags"
)
// apiTestTagProvider is a minimal TrackTagProvider for api-package tests.
// Does not implement TestableProvider.
type apiTestTagProvider struct {
id string
display string
}
func (p *apiTestTagProvider) ID() string { return p.id }
func (p *apiTestTagProvider) DisplayName() string { return p.display }
func (p *apiTestTagProvider) RequiresAPIKey() bool { return false }
func (p *apiTestTagProvider) DefaultEnabled() bool { return true }
func (p *apiTestTagProvider) Configure(_ tags.ProviderSettings) error { return nil }
func (p *apiTestTagProvider) FetchTrackTags(_ context.Context, _ tags.TrackRef) ([]tags.Tag, error) {
return []tags.Tag{{Name: "x", Weight: 1}}, nil
}
// apiTestTestableTagProvider also implements TestableProvider.
type apiTestTestableTagProvider struct {
apiTestTagProvider
}
func (p *apiTestTestableTagProvider) TestConnection(_ context.Context) error { return nil }
func newAdminTagSourcesRouter(h *handlers) chi.Router {
r := chi.NewRouter()
r.Route("/api/admin", func(admin chi.Router) {
admin.Use(auth.RequireAdmin())
admin.Get("/tag-sources", h.handleListTagSources)
admin.Patch("/tag-sources/{provider_id}", h.handleUpdateTagSource)
admin.Post("/tag-sources/{provider_id}/test", h.handleTestTagSource)
})
return r
}
// testHandlersWithTagSettings installs a tags.SettingsService backed by the
// test DB. Register the needed fake providers BEFORE calling this, and pair
// with tags.ResetRegistryForTests in t.Cleanup.
func testHandlersWithTagSettings(t *testing.T) *handlers {
t.Helper()
h, pool := testHandlers(t)
s, err := tags.NewSettingsService(context.Background(), pool, h.logger)
if err != nil {
t.Fatalf("NewSettingsService: %v", err)
}
h.tagSettings = s
return h
}
func TestAdminListTagSources_ReturnsRegisteredProviders(t *testing.T) {
if os.Getenv("MINSTREL_TEST_DATABASE_URL") == "" {
t.Skip("MINSTREL_TEST_DATABASE_URL not set")
}
tags.ResetRegistryForTests()
t.Cleanup(tags.ResetRegistryForTests)
tags.Register(&apiTestTagProvider{id: "test-tag-prov", display: "Test Tag Provider"})
h := testHandlersWithTagSettings(t)
admin := seedUser(t, h.pool, "tslist", "pw", true)
req := httptest.NewRequest(http.MethodGet, "/api/admin/tag-sources", nil)
req = withUser(req, admin)
rec := httptest.NewRecorder()
newAdminTagSourcesRouter(h).ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
}
var resp tagSourcesListResp
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode: %v", err)
}
if len(resp.Providers) != 1 || resp.Providers[0].ID != "test-tag-prov" {
t.Fatalf("providers = %+v, want 1 (test-tag-prov)", resp.Providers)
}
found := false
for _, s := range resp.Providers[0].Supports {
if s == "track_tags" {
found = true
}
}
if !found {
t.Errorf("supports = %v, want to include track_tags", resp.Providers[0].Supports)
}
if resp.Providers[0].Testable {
t.Error("testable = true, want false (no TestConnection)")
}
}
func TestAdminUpdateTagSource_FlippingEnabledBumpsVersion(t *testing.T) {
if os.Getenv("MINSTREL_TEST_DATABASE_URL") == "" {
t.Skip("MINSTREL_TEST_DATABASE_URL not set")
}
tags.ResetRegistryForTests()
t.Cleanup(tags.ResetRegistryForTests)
tags.Register(&apiTestTagProvider{id: "flip-tag", display: "Flip Tag"})
h := testHandlersWithTagSettings(t)
admin := seedUser(t, h.pool, "tsflip", "pw", true)
disabled := false
body, _ := json.Marshal(updateTagSourceReq{Enabled: &disabled})
req := httptest.NewRequest(http.MethodPatch, "/api/admin/tag-sources/flip-tag", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req = withUser(req, admin)
rec := httptest.NewRecorder()
newAdminTagSourcesRouter(h).ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
}
var resp updateTagSourceResp
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode: %v", err)
}
if !resp.VersionBumped {
t.Error("version_bumped = false, want true after flipping enabled")
}
}
func TestAdminUpdateTagSource_KeyOnlyDoesNotBump(t *testing.T) {
if os.Getenv("MINSTREL_TEST_DATABASE_URL") == "" {
t.Skip("MINSTREL_TEST_DATABASE_URL not set")
}
tags.ResetRegistryForTests()
t.Cleanup(tags.ResetRegistryForTests)
tags.Register(&apiTestTagProvider{id: "key-tag", display: "Key Tag"})
h := testHandlersWithTagSettings(t)
admin := seedUser(t, h.pool, "tskey", "pw", true)
newKey := "newkey"
body, _ := json.Marshal(updateTagSourceReq{APIKey: &newKey})
req := httptest.NewRequest(http.MethodPatch, "/api/admin/tag-sources/key-tag", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req = withUser(req, admin)
rec := httptest.NewRecorder()
newAdminTagSourcesRouter(h).ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
}
var resp updateTagSourceResp
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode: %v", err)
}
if resp.VersionBumped {
t.Error("version_bumped = true, want false for key-only change")
}
if !resp.APIKeySet {
t.Error("api_key_set = false, want true after setting key")
}
}
func TestAdminUpdateTagSource_UnknownProvider404(t *testing.T) {
if os.Getenv("MINSTREL_TEST_DATABASE_URL") == "" {
t.Skip("MINSTREL_TEST_DATABASE_URL not set")
}
tags.ResetRegistryForTests()
t.Cleanup(tags.ResetRegistryForTests)
h := testHandlersWithTagSettings(t)
admin := seedUser(t, h.pool, "ts404", "pw", true)
body, _ := json.Marshal(updateTagSourceReq{})
req := httptest.NewRequest(http.MethodPatch, "/api/admin/tag-sources/missing", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req = withUser(req, admin)
rec := httptest.NewRecorder()
newAdminTagSourcesRouter(h).ServeHTTP(rec, req)
if rec.Code != http.StatusNotFound {
t.Fatalf("status = %d, want 404; body=%s", rec.Code, rec.Body.String())
}
}
func TestAdminTestTagSource_NonAdminReturns403(t *testing.T) {
if os.Getenv("MINSTREL_TEST_DATABASE_URL") == "" {
t.Skip("MINSTREL_TEST_DATABASE_URL not set")
}
tags.ResetRegistryForTests()
t.Cleanup(tags.ResetRegistryForTests)
tags.Register(&apiTestTestableTagProvider{apiTestTagProvider{id: "testable-tag", display: "Testable Tag"}})
h := testHandlersWithTagSettings(t)
nonAdmin := seedUser(t, h.pool, "tsnoadmin", "pw", false)
req := httptest.NewRequest(http.MethodPost, "/api/admin/tag-sources/testable-tag/test", nil)
req = withUser(req, nonAdmin)
rec := httptest.NewRecorder()
newAdminTagSourcesRouter(h).ServeHTTP(rec, req)
if rec.Code != http.StatusForbidden {
t.Fatalf("status = %d, want 403; body=%s", rec.Code, rec.Body.String())
}
}
func TestAdminTestTagSource_NotTestableReturnsOkFalse(t *testing.T) {
if os.Getenv("MINSTREL_TEST_DATABASE_URL") == "" {
t.Skip("MINSTREL_TEST_DATABASE_URL not set")
}
tags.ResetRegistryForTests()
t.Cleanup(tags.ResetRegistryForTests)
tags.Register(&apiTestTagProvider{id: "nontestable-tag", display: "Non-Testable Tag"})
h := testHandlersWithTagSettings(t)
admin := seedUser(t, h.pool, "tsntest", "pw", true)
req := httptest.NewRequest(http.MethodPost, "/api/admin/tag-sources/nontestable-tag/test", nil)
req = withUser(req, admin)
rec := httptest.NewRecorder()
newAdminTagSourcesRouter(h).ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
}
var resp testTagSourceResp
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode: %v", err)
}
if resp.OK {
t.Error("ok = true, want false for non-testable provider")
}
if resp.Error == "" {
t.Error("error string empty, want a message")
}
}
+25 -1
View File
@@ -22,16 +22,19 @@ import (
"git.fabledsword.com/bvandeusen/minstrel/internal/mailer" "git.fabledsword.com/bvandeusen/minstrel/internal/mailer"
"git.fabledsword.com/bvandeusen/minstrel/internal/playevents" "git.fabledsword.com/bvandeusen/minstrel/internal/playevents"
"git.fabledsword.com/bvandeusen/minstrel/internal/playlists" "git.fabledsword.com/bvandeusen/minstrel/internal/playlists"
"git.fabledsword.com/bvandeusen/minstrel/internal/recsettings"
"git.fabledsword.com/bvandeusen/minstrel/internal/tags"
"git.fabledsword.com/bvandeusen/minstrel/internal/tracks" "git.fabledsword.com/bvandeusen/minstrel/internal/tracks"
) )
// Mount attaches /api/* handlers to r. Public endpoints (login) are outside // Mount attaches /api/* handlers to r. Public endpoints (login) are outside
// RequireUser; everything else is gated by the middleware. The events writer // RequireUser; everything else is gated by the middleware. The events writer
// is shared with the Subsonic mount so /rest/scrobble feeds the same store. // is shared with the Subsonic mount so /rest/scrobble feeds the same store.
func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playevents.Writer, recCfg config.RecommendationConfig, lidarrCfg *lidarrconfig.Service, lidarrReqs *lidarrrequests.Service, lidarrQuar *lidarrquarantine.Service, tracksSvc *tracks.Service, playlistsSvc *playlists.Service, coverEnricher *coverart.Enricher, coverSettings *coverart.SettingsService, scanner *library.Scanner, scanCfg library.RunScanConfig, dataDir string, sender mailer.Sender, bus *eventbus.Bus, playlistScheduler *playlists.Scheduler, streamSecret []byte) { func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playevents.Writer, recCfg config.RecommendationConfig, recSettings *recsettings.Service, lidarrCfg *lidarrconfig.Service, lidarrReqs *lidarrrequests.Service, lidarrQuar *lidarrquarantine.Service, tracksSvc *tracks.Service, playlistsSvc *playlists.Service, coverEnricher *coverart.Enricher, coverSettings *coverart.SettingsService, tagSettings *tags.SettingsService, scanner *library.Scanner, scanCfg library.RunScanConfig, dataDir string, sender mailer.Sender, bus *eventbus.Bus, playlistScheduler *playlists.Scheduler, streamSecret []byte) {
rng := rand.New(rand.NewSource(rand.Int63())) rng := rand.New(rand.NewSource(rand.Int63()))
h := &handlers{ h := &handlers{
pool: pool, logger: logger, events: events, recCfg: recCfg, pool: pool, logger: logger, events: events, recCfg: recCfg,
recSettings: recSettings,
rng: rng.Float64, rng: rng.Float64,
lidarrCfg: lidarrCfg, lidarrCfg: lidarrCfg,
lidarrRequests: lidarrReqs, lidarrRequests: lidarrReqs,
@@ -40,6 +43,7 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev
playlists: playlistsSvc, playlists: playlistsSvc,
coverart: coverEnricher, coverart: coverEnricher,
coverSettings: coverSettings, coverSettings: coverSettings,
tagSettings: tagSettings,
scanner: scanner, scanner: scanner,
scanCfg: scanCfg, scanCfg: scanCfg,
dataDir: dataDir, dataDir: dataDir,
@@ -100,6 +104,11 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev
authed.Get("/search", h.handleSearch) authed.Get("/search", h.handleSearch)
authed.Get("/radio", h.handleRadio) authed.Get("/radio", h.handleRadio)
authed.Get("/discover/suggestions", h.handleListSuggestions) 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", h.handleGetHome)
authed.Get("/home/index", h.handleGetHomeIndex) authed.Get("/home/index", h.handleGetHomeIndex)
authed.Post("/events", h.handleEvents) authed.Post("/events", h.handleEvents)
@@ -198,9 +207,22 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev
admin.Post("/cover-sources/{provider_id}/test", h.handleTestCoverSource) admin.Post("/cover-sources/{provider_id}/test", h.handleTestCoverSource)
admin.Post("/cover-sources/research", h.handleResearchMissingArt) admin.Post("/cover-sources/research", h.handleResearchMissingArt)
admin.Get("/tag-sources", h.handleListTagSources)
admin.Patch("/tag-sources/{provider_id}", h.handleUpdateTagSource)
admin.Post("/tag-sources/{provider_id}/test", h.handleTestTagSource)
admin.Post("/tag-sources/research", h.handleResearchTags)
admin.Get("/smtp-config", h.handleGetSMTPConfig) admin.Get("/smtp-config", h.handleGetSMTPConfig)
admin.Put("/smtp-config", h.handleUpdateSMTPConfig) admin.Put("/smtp-config", h.handleUpdateSMTPConfig)
admin.Post("/smtp-config/test", h.handleTestSMTPConfig) admin.Post("/smtp-config/test", h.handleTestSMTPConfig)
// Recommendation tuning lab (#1250): scoring-weight
// profiles + taste-build knobs, DB-backed, live effect.
admin.Get("/recommendation-tuning", h.handleGetRecommendationTuning)
admin.Patch("/recommendation-tuning/{scope}", h.handlePatchRecommendationTuning)
admin.Post("/recommendation-tuning/{scope}/reset", h.handleResetRecommendationTuning)
// Weekly outcome trends + knob-turn markers (#1251).
admin.Get("/recommendation-trends", h.handleGetRecommendationTrends)
}) })
authed.Get("/playlists", h.handleListPlaylists) authed.Get("/playlists", h.handleListPlaylists)
@@ -223,6 +245,7 @@ type handlers struct {
logger *slog.Logger logger *slog.Logger
events *playevents.Writer events *playevents.Writer
recCfg config.RecommendationConfig recCfg config.RecommendationConfig
recSettings *recsettings.Service
rng func() float64 rng func() float64
lidarrCfg *lidarrconfig.Service lidarrCfg *lidarrconfig.Service
lidarrRequests *lidarrrequests.Service lidarrRequests *lidarrrequests.Service
@@ -231,6 +254,7 @@ type handlers struct {
playlists *playlists.Service playlists *playlists.Service
coverart *coverart.Enricher coverart *coverart.Enricher
coverSettings *coverart.SettingsService coverSettings *coverart.SettingsService
tagSettings *tags.SettingsService
scanner *library.Scanner scanner *library.Scanner
scanCfg library.RunScanConfig scanCfg library.RunScanConfig
dataDir string dataDir string
+6 -4
View File
@@ -29,6 +29,7 @@ import (
"git.fabledsword.com/bvandeusen/minstrel/internal/mailer" "git.fabledsword.com/bvandeusen/minstrel/internal/mailer"
"git.fabledsword.com/bvandeusen/minstrel/internal/playevents" "git.fabledsword.com/bvandeusen/minstrel/internal/playevents"
"git.fabledsword.com/bvandeusen/minstrel/internal/playlists" "git.fabledsword.com/bvandeusen/minstrel/internal/playlists"
"git.fabledsword.com/bvandeusen/minstrel/internal/recsettings"
"git.fabledsword.com/bvandeusen/minstrel/internal/tracks" "git.fabledsword.com/bvandeusen/minstrel/internal/tracks"
) )
@@ -56,11 +57,12 @@ func testHandlers(t *testing.T) (*handlers, *pgxpool.Pool) {
dbtest.ResetDB(t, pool) dbtest.ResetDB(t, pool)
w := playevents.NewWriter(pool, logger, 30*time.Minute, 0.5, 30000) w := playevents.NewWriter(pool, logger, 30*time.Minute, 0.5, 30000)
recCfg := config.RecommendationConfig{ recCfg := config.RecommendationConfig{
BaseWeight: 1.0, LikeBoost: 2.0, RecencyWeight: 1.0,
SkipPenalty: 1.0, JitterMagnitude: 0.1,
ContextWeight: 2.0, SimilarityWeight: 2.0,
RecentlyPlayedHours: 1, RadioSize: 50, RadioSizeMax: 200, RecentlyPlayedHours: 1, RadioSize: 50, RadioSizeMax: 200,
} }
recSettings, err := recsettings.New(context.Background(), pool, logger)
if err != nil {
t.Fatalf("recsettings: %v", err)
}
lidarrCfg := lidarrconfig.New(pool) lidarrCfg := lidarrconfig.New(pool)
lidarrReqs := lidarrrequests.NewService(pool, lidarrCfg, nil, nil) lidarrReqs := lidarrrequests.NewService(pool, lidarrCfg, nil, nil)
lidarrQuar := lidarrquarantine.NewService(pool, lidarrCfg, nil) lidarrQuar := lidarrquarantine.NewService(pool, lidarrCfg, nil)
@@ -70,7 +72,7 @@ func testHandlers(t *testing.T) (*handlers, *pgxpool.Pool) {
dataDir := t.TempDir() dataDir := t.TempDir()
tracksSvc := tracks.NewService(pool, logger, nil, dataDir) tracksSvc := tracks.NewService(pool, logger, nil, dataDir)
playlistsSvc := playlists.NewService(pool, logger, dataDir) playlistsSvc := playlists.NewService(pool, logger, dataDir)
h := &handlers{pool: pool, logger: logger, events: w, recCfg: recCfg, rng: func() float64 { return 0.5 }, lidarrCfg: lidarrCfg, lidarrRequests: lidarrReqs, lidarrQuarantine: lidarrQuar, tracks: tracksSvc, playlists: playlistsSvc, dataDir: dataDir, scanner: nil, scanCfg: library.RunScanConfig{}, mailer: &mailer.FakeSender{}} h := &handlers{pool: pool, logger: logger, events: w, recCfg: recCfg, recSettings: recSettings, rng: func() float64 { return 0.5 }, lidarrCfg: lidarrCfg, lidarrRequests: lidarrReqs, lidarrQuarantine: lidarrQuar, tracks: tracksSvc, playlists: playlistsSvc, dataDir: dataDir, scanner: nil, scanCfg: library.RunScanConfig{}, mailer: &mailer.FakeSender{}}
return h, pool return h, pool
} }
+54 -3
View File
@@ -4,6 +4,7 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"net/http" "net/http"
"strings"
"time" "time"
"github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5"
@@ -24,6 +25,33 @@ type eventRequest struct {
// / "discover" feed the system-playlist rotation dedup (#415); // / "discover" feed the system-playlist rotation dedup (#415);
// absent / "" for library, user-playlist, radio, Subsonic. // absent / "" for library, user-playlist, radio, Subsonic.
Source *string `json:"source"` Source *string `json:"source"`
// DeviceClass is the client's self-reported device class (#1551),
// normalized + stored per play to condition radio on the current device.
DeviceClass *string `json:"device_class"`
}
// knownDeviceClasses whitelists the device classes the context-affinity facet
// buckets by (#1551). A client sends one; anything unrecognized normalizes to
// "other", and empty/absent to "" (stored NULL → excluded from the device
// dimension). Kept permissive (no DB CHECK) so a new client class is one
// whitelist entry, not a migration.
var knownDeviceClasses = map[string]bool{
"mobile": true, "tablet": true, "desktop": true,
"web": true, "tv": true, "watch": true,
}
func normalizeDeviceClass(dc *string) string {
if dc == nil {
return ""
}
s := strings.ToLower(strings.TrimSpace(*dc))
if s == "" {
return ""
}
if knownDeviceClasses[s] {
return s
}
return "other"
} }
type playStartedResponse struct { type playStartedResponse struct {
@@ -31,6 +59,26 @@ type playStartedResponse struct {
SessionID string `json:"session_id"` SessionID string `json:"session_id"`
} }
// eventFutureSkew is the tolerated client-clock drift into the future
// before a timestamp is treated as bogus and replaced with now.
const eventFutureSkew = 5 * time.Minute
// clampEventTime bounds a client-supplied event timestamp to sanity:
// no earlier than the account's creation (offline replays can
// legitimately be days old, but no play can predate the user) and no
// later than now + a small skew allowance. Unbounded client clocks
// previously let a skewed device write arbitrarily old plays, which
// poisoned Rediscover's "not played in 6 months" ordering (#1246).
func clampEventTime(at, userCreatedAt, now time.Time) time.Time {
if at.Before(userCreatedAt) {
return userCreatedAt
}
if at.After(now.Add(eventFutureSkew)) {
return now
}
return at
}
type okResponse struct { type okResponse struct {
OK bool `json:"ok"` OK bool `json:"ok"`
} }
@@ -52,7 +100,7 @@ func (h *handlers) handleEvents(w http.ResponseWriter, r *http.Request) {
writeErr(w, apierror.BadRequest("bad_request", "invalid `at` timestamp")) writeErr(w, apierror.BadRequest("bad_request", "invalid `at` timestamp"))
return return
} }
at = parsed at = clampEventTime(parsed.UTC(), user.CreatedAt.Time, time.Now().UTC())
} }
clientID := "" clientID := ""
if req.ClientID != nil { if req.ClientID != nil {
@@ -94,7 +142,9 @@ func (h *handlers) handleEventPlayStarted(
if req.Source != nil { if req.Source != nil {
source = *req.Source source = *req.Source
} }
res, err := h.events.RecordPlayStartedWithSource(r.Context(), user.ID, trackID, clientID, source, at) deviceClass := normalizeDeviceClass(req.DeviceClass)
res, err := h.events.RecordPlayStartedWithSource(
r.Context(), user.ID, trackID, clientID, source, deviceClass, at)
if err != nil { if err != nil {
h.logger.Error("api: events: play_started", "err", err) h.logger.Error("api: events: play_started", "err", err)
writeErr(w, apierror.InternalMsg("record failed", err)) writeErr(w, apierror.InternalMsg("record failed", err))
@@ -139,7 +189,8 @@ func (h *handlers) handleEventPlayOffline(
source = *req.Source source = *req.Source
} }
if err := h.events.RecordOfflinePlay( if err := h.events.RecordOfflinePlay(
r.Context(), user.ID, trackID, clientID, source, at, *req.DurationPlayedMs, r.Context(), user.ID, trackID, clientID, source,
normalizeDeviceClass(req.DeviceClass), at, *req.DurationPlayedMs,
); err != nil { ); err != nil {
h.logger.Error("api: events: play_offline", "err", err) h.logger.Error("api: events: play_offline", "err", err)
writeErr(w, apierror.InternalMsg("record failed", err)) writeErr(w, apierror.InternalMsg("record failed", err))
+1 -1
View File
@@ -465,7 +465,7 @@ func TestRoutesRegisteredInMount(t *testing.T) {
r := chi.NewRouter() r := chi.NewRouter()
w := playevents.NewWriter(h.pool, slog.New(slog.NewTextHandler(io.Discard, nil)), w := playevents.NewWriter(h.pool, slog.New(slog.NewTextHandler(io.Discard, nil)),
30*time.Minute, 0.5, 30000) 30*time.Minute, 0.5, 30000)
Mount(r, h.pool, h.logger, w, config.RecommendationConfig{RadioSize: 50, RadioSizeMax: 200, RecentlyPlayedHours: 1}, h.lidarrCfg, h.lidarrRequests, h.lidarrQuarantine, h.tracks, h.playlists, h.coverart, h.coverSettings, h.scanner, h.scanCfg, h.dataDir, nil, eventbus.New(), nil, nil) Mount(r, h.pool, h.logger, w, config.RecommendationConfig{RadioSize: 50, RadioSizeMax: 200, RecentlyPlayedHours: 1}, h.recSettings, h.lidarrCfg, h.lidarrRequests, h.lidarrQuarantine, h.tracks, h.playlists, h.coverart, h.coverSettings, h.tagSettings, h.scanner, h.scanCfg, h.dataDir, nil, eventbus.New(), nil, nil)
paths := []string{ paths := []string{
"/api/artists", "/api/artists",
+258 -28
View File
@@ -2,7 +2,9 @@ package api
import ( import (
"net/http" "net/http"
"sort"
"strconv" "strconv"
"strings"
"git.fabledsword.com/bvandeusen/minstrel/internal/apierror" "git.fabledsword.com/bvandeusen/minstrel/internal/apierror"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
@@ -11,28 +13,142 @@ import (
const ( const (
recMetricsDefaultDays = 30 recMetricsDefaultDays = 30
recMetricsMaxDays = 365 recMetricsMaxDays = 365
// recMetricsLowVolume marks a family as low-confidence rather than
// hiding it: with fewer plays than this a skip rate is anecdote, not
// signal, but silently dropping the row would misread as "surface
// unused". The web renders low-confidence rows dimmed.
recMetricsLowVolume = 20
) )
// recommendationMetric is one recommendation surface's outcomes. // Surface intents (milestone #127): each family is judged against its
type recommendationMetric struct { // job, not one global bar — discovery mixes are EXPECTED to run higher
Source string `json:"source"` // 'for_you' | 'discover' | mixes // skip rates than the go-to surfaces.
Plays int64 `json:"plays"` // plays launched from this surface const (
intentGoTo = "go_to"
intentDiscovery = "discovery"
intentDirect = "direct"
)
// surfaceMetric is one bucketed surface family's outcomes.
type surfaceMetric struct {
Key string `json:"key"` // stable family key ("for_you", "radio", …)
Label string `json:"label"` // display label
Plays int64 `json:"plays"` // plays launched from this family
Skips int64 `json:"skips"` // of those, marked skipped Skips int64 `json:"skips"` // of those, marked skipped
SkipRate float64 `json:"skip_rate"` // skips / plays, [0,1] SkipRate float64 `json:"skip_rate"` // skips / plays, [0,1]
AvgCompletion float64 `json:"avg_completion"` // mean completion ratio, [0,1] AvgCompletion float64 `json:"avg_completion"` // mean completion ratio, [0,1]
LowConfidence bool `json:"low_confidence"` // plays < recMetricsLowVolume
// Breakdown splits the family into the pick-kind populations its
// builder stamped (#1249, generalized #1270): For You's taste/fresh,
// Discover's buckets, tier1-3 for tiered mixes — plus earlier plays
// that predate attribution. Present only when the family has at
// least one attributed play; the parent row remains the sum of its
// breakdown.
Breakdown []surfaceMetric `json:"breakdown,omitempty"`
}
// surfaceGroup is one intent band of surface families.
type surfaceGroup struct {
Intent string `json:"intent"` // go_to | discovery | direct
Label string `json:"label"`
Surfaces []surfaceMetric `json:"surfaces"`
} }
type recommendationMetricsResp struct { type recommendationMetricsResp struct {
WindowDays int `json:"window_days"` WindowDays int `json:"window_days"`
Sources []recommendationMetric `json:"sources"` // Baseline is the control group: plays the user picked manually
// (source IS NULL). Surfaces are judged as deltas against it; nil
// when the window holds no manual plays.
Baseline *surfaceMetric `json:"baseline"`
Groups []surfaceGroup `json:"groups"`
}
// recFamily is the bucketing target for a raw play_events.source value.
type recFamily struct {
key string
label string
intent string
}
// bucketRecSource maps a raw client-stamped source string to its stable
// family. One-off sources (album:<uuid>, radio:<uuid>) collapse into
// their family so the table stays readable at any library size.
func bucketRecSource(src string) recFamily {
switch {
case src == "for_you":
return recFamily{"for_you", "For You", intentGoTo}
case src == "songs_like_artist":
return recFamily{"songs_like_artist", "Songs like…", intentGoTo}
case src == "radio" || strings.HasPrefix(src, "radio:"):
return recFamily{"radio", "Radio", intentGoTo}
case src == "discover":
return recFamily{"discover", "Discover", intentDiscovery}
case src == "deep_cuts":
return recFamily{"deep_cuts", "Deep cuts", intentDiscovery}
case src == "rediscover":
return recFamily{"rediscover", "Rediscover", intentDiscovery}
case src == "new_for_you":
return recFamily{"new_for_you", "New for you", intentDiscovery}
case src == "on_this_day":
return recFamily{"on_this_day", "On this day", intentDiscovery}
case src == "first_listens":
return recFamily{"first_listens", "First listens", intentDiscovery}
case strings.HasPrefix(src, "album:"):
return recFamily{"direct_album", "Album plays", intentDirect}
case strings.HasPrefix(src, "artist:"):
return recFamily{"direct_artist", "Artist plays", intentDirect}
case strings.HasPrefix(src, "offline:"):
return recFamily{"offline", "Offline pools", intentDirect}
case strings.HasPrefix(src, "home:"):
return recFamily{"home", "Home sections", intentDirect}
case src == "history":
return recFamily{"history", "History", intentDirect}
default:
return recFamily{"other", "Other", intentDirect}
}
}
// familyAccum merges raw source rows into one family, carrying the
// completion sample count so the merged average stays play-weighted.
type familyAccum struct {
fam recFamily
plays int64
skips int64
completionN int64
// completionSum is avg*count re-expanded, so merging N raw rows
// reduces to a single weighted division at the end.
completionSum float64
}
func (a *familyAccum) add(row dbq.RecommendationSourceMetricsForUserRow) {
a.plays += row.Plays
a.skips += row.Skips
a.completionN += row.CompletionN
a.completionSum += row.AvgCompletion * float64(row.CompletionN)
}
func (a *familyAccum) metric() surfaceMetric {
m := surfaceMetric{
Key: a.fam.key,
Label: a.fam.label,
Plays: a.plays,
Skips: a.skips,
LowConfidence: a.plays < recMetricsLowVolume,
}
if a.plays > 0 {
m.SkipRate = float64(a.skips) / float64(a.plays)
}
if a.completionN > 0 {
m.AvgCompletion = a.completionSum / float64(a.completionN)
}
return m
} }
// handleGetRecommendationMetrics implements GET /api/me/recommendation-metrics. // handleGetRecommendationMetrics implements GET /api/me/recommendation-metrics.
// Per-source play outcomes (plays / skips / skip-rate / avg-completion) for the // Bucketed per-surface-family outcomes for the caller over the last `days`
// caller over the last `days` (default 30, capped at 365), so the operator can // (default 30, capped at 365), grouped by surface intent and anchored by the
// see which recommendation surfaces are landing and tune the taste weights. // manual-plays baseline so the numbers are judgeable, not just observable.
// Only plays tagged with a system-playlist source count; library/radio plays
// (no source) are excluded.
func (h *handlers) handleGetRecommendationMetrics(w http.ResponseWriter, r *http.Request) { func (h *handlers) handleGetRecommendationMetrics(w http.ResponseWriter, r *http.Request) {
caller, ok := requireUser(w, r) caller, ok := requireUser(w, r)
if !ok { if !ok {
@@ -51,28 +167,142 @@ func (h *handlers) handleGetRecommendationMetrics(w http.ResponseWriter, r *http
return return
} }
out := recommendationMetricsResp{ writeJSON(w, http.StatusOK, bucketMetricsResponse(days, rows))
WindowDays: days, }
Sources: make([]recommendationMetric, 0, len(rows)),
// pickKindLabels is the display vocabulary for play_events.pick_kind
// values (mirrors the CHECK in migration 0041). Every system mix that
// stamps provenance gets its breakdown from this one map — adding a
// stamping mix needs no metrics change.
var pickKindLabels = map[string]string{
"taste": "Taste picks",
"fresh": "Fresh picks",
"dormant": "Dormant artists",
"taste_unheard": "Taste-matched",
"cross_user": "Liked by others",
"random": "Random unheard",
"tier1": "Tier 1 (exact)",
"tier2": "Tier 2 (relaxed)",
"tier3": "Tier 3 (stretched)",
}
// pickKindOrder fixes breakdown row order; unattributed ("", i.e. NULL
// pick_kind — plays recorded before the mix stamped provenance, or
// whose track had rotated out of the snapshot at ingestion) renders
// last, kept visible so the parent row's sums stay transparent instead
// of silently shrinking. The DB CHECK gates pick_kind to exactly this
// vocabulary, so iterating the list is exhaustive.
var pickKindOrder = []string{
"taste", "fresh", "dormant", "taste_unheard", "cross_user", "random",
"tier1", "tier2", "tier3", "",
}
// pickKindFamily derives the sub-family for one (family, pick_kind)
// population, e.g. ("for_you", "taste") → for_you_taste "Taste picks".
func pickKindFamily(parent recFamily, kind string) recFamily {
if kind == "" {
return recFamily{parent.key + "_unattributed", "Earlier plays", parent.intent}
} }
label, ok := pickKindLabels[kind]
if !ok {
label = kind
}
return recFamily{parent.key + "_" + kind, label, parent.intent}
}
// pickKindBreakdown folds a family's per-pick-kind accums into its
// Breakdown rows. Attached only when at least one attributed play
// exists — an all-unattributed breakdown would just repeat the parent
// row, and families that never stamp (radio, direct plays) stay flat.
func pickKindBreakdown(picks map[string]*familyAccum) []surfaceMetric {
attributed := int64(0)
for kind, acc := range picks {
if kind != "" {
attributed += acc.plays
}
}
if attributed == 0 {
return nil
}
out := make([]surfaceMetric, 0, len(picks))
for _, kind := range pickKindOrder {
if acc, ok := picks[kind]; ok && acc.plays > 0 {
out = append(out, acc.metric())
}
}
return out
}
// bucketMetricsResponse folds the raw per-source rows into the grouped,
// baseline-anchored response shape. Split from the handler for pure-unit
// testability.
func bucketMetricsResponse(
days int, rows []dbq.RecommendationSourceMetricsForUserRow,
) recommendationMetricsResp {
baseline := &familyAccum{fam: recFamily{"manual", "Manual library plays", ""}}
families := map[string]*familyAccum{}
picks := map[string]map[string]*familyAccum{}
for _, row := range rows { for _, row := range rows {
source := "" if row.Source == nil || *row.Source == "" {
if row.Source != nil { baseline.add(row)
source = *row.Source continue
} }
var skipRate float64 fam := bucketRecSource(*row.Source)
if row.Plays > 0 { acc, exists := families[fam.key]
skipRate = float64(row.Skips) / float64(row.Plays) if !exists {
acc = &familyAccum{fam: fam}
families[fam.key] = acc
} }
out.Sources = append(out.Sources, recommendationMetric{ acc.add(row)
Source: source, // Accumulate the pick-kind population unconditionally; families
Plays: row.Plays, // that never stamp end up all-unattributed and get no breakdown.
Skips: row.Skips, kind := ""
SkipRate: skipRate, if row.PickKind != nil {
AvgCompletion: row.AvgCompletion, kind = *row.PickKind
}) }
byKind, ok := picks[fam.key]
if !ok {
byKind = map[string]*familyAccum{}
picks[fam.key] = byKind
}
pick, ok := byKind[kind]
if !ok {
pick = &familyAccum{fam: pickKindFamily(fam, kind)}
byKind[kind] = pick
}
pick.add(row)
} }
writeJSON(w, http.StatusOK, out)
resp := recommendationMetricsResp{WindowDays: days, Groups: []surfaceGroup{}}
if baseline.plays > 0 {
m := baseline.metric()
resp.Baseline = &m
}
for _, g := range []struct{ intent, label string }{
{intentGoTo, "Go-to surfaces"},
{intentDiscovery, "Discovery mixes"},
{intentDirect, "Direct plays"},
} {
group := surfaceGroup{Intent: g.intent, Label: g.label}
for _, acc := range families {
if acc.fam.intent == g.intent {
m := acc.metric()
m.Breakdown = pickKindBreakdown(picks[acc.fam.key])
group.Surfaces = append(group.Surfaces, m)
}
}
if len(group.Surfaces) == 0 {
continue
}
sort.Slice(group.Surfaces, func(i, j int) bool {
if group.Surfaces[i].Plays != group.Surfaces[j].Plays {
return group.Surfaces[i].Plays > group.Surfaces[j].Plays
}
return group.Surfaces[i].Key < group.Surfaces[j].Key
})
resp.Groups = append(resp.Groups, group)
}
return resp
} }
// parseMetricsDays reads the `days` query param (default 30, capped at 365). // parseMetricsDays reads the `days` query param (default 30, capped at 365).
+197 -24
View File
@@ -11,6 +11,8 @@ import (
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"github.com/jackc/pgx/v5/pgtype" "github.com/jackc/pgx/v5/pgtype"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
) )
func newMetricsRouter(h *handlers) chi.Router { func newMetricsRouter(h *handlers) chi.Router {
@@ -20,17 +22,19 @@ func newMetricsRouter(h *handlers) chi.Router {
} }
// seedSourcedPlay inserts a play_event with an explicit source + completion + // seedSourcedPlay inserts a play_event with an explicit source + completion +
// skip flag. A nil source inserts NULL (library/radio play). // skip flag. A nil source inserts NULL (manual library play → baseline).
// pickKind is the For You taste/fresh attribution (#1249); nil everywhere
// except attributed for_you plays.
func seedSourcedPlay( func seedSourcedPlay(
t *testing.T, h *handlers, userID, trackID, sessionID pgtype.UUID, t *testing.T, h *handlers, userID, trackID, sessionID pgtype.UUID,
source *string, completion float64, skipped bool, source, pickKind *string, completion float64, skipped bool,
) { ) {
t.Helper() t.Helper()
if _, err := h.pool.Exec(context.Background(), if _, err := h.pool.Exec(context.Background(),
`INSERT INTO play_events `INSERT INTO play_events
(user_id, track_id, session_id, started_at, source, completion_ratio, was_skipped) (user_id, track_id, session_id, started_at, source, pick_kind, completion_ratio, was_skipped)
VALUES ($1, $2, $3, now(), $4, $5, $6)`, VALUES ($1, $2, $3, now(), $4, $5, $6, $7)`,
userID, trackID, sessionID, source, completion, skipped); err != nil { userID, trackID, sessionID, source, pickKind, completion, skipped); err != nil {
t.Fatalf("seed sourced play: %v", err) t.Fatalf("seed sourced play: %v", err)
} }
} }
@@ -45,7 +49,168 @@ func TestRecommendationMetrics_NoSession401(t *testing.T) {
} }
} }
func TestRecommendationMetrics_AggregatesBySourceExcludingNull(t *testing.T) { // findSurface returns the named family from any group, or nil.
func findSurface(resp recommendationMetricsResp, key string) *surfaceMetric {
for _, g := range resp.Groups {
for i := range g.Surfaces {
if g.Surfaces[i].Key == key {
return &g.Surfaces[i]
}
}
}
return nil
}
func TestBucketMetricsResponse_FamiliesGroupsBaseline(t *testing.T) {
src := func(s string) *string { return &s }
rows := []dbq.RecommendationSourceMetricsForUserRow{
{Source: src("for_you"), Plays: 3, Skips: 1, CompletionN: 3, AvgCompletion: 2.0 / 3},
// Two radio sessions collapse into one "radio" family; weighted
// completion = (0.2*1 + 0.8*1) / 2 = 0.5.
{Source: src("radio:aaaa"), Plays: 1, Skips: 1, CompletionN: 1, AvgCompletion: 0.2},
{Source: src("radio:bbbb"), Plays: 1, Skips: 0, CompletionN: 1, AvgCompletion: 0.8},
{Source: src("album:cccc"), Plays: 1, Skips: 0, CompletionN: 0, AvgCompletion: 0},
{Source: src("discover"), Plays: 1, Skips: 0, CompletionN: 1, AvgCompletion: 0.8},
// NULL source = manual plays → baseline, not a group row.
{Source: nil, Plays: 25, Skips: 5, CompletionN: 20, AvgCompletion: 0.9},
}
resp := bucketMetricsResponse(recMetricsDefaultDays, rows)
if resp.Baseline == nil {
t.Fatal("baseline missing")
}
if resp.Baseline.Plays != 25 || resp.Baseline.SkipRate != 0.2 {
t.Errorf("baseline = %+v, want plays=25 skip_rate=0.2", resp.Baseline)
}
if resp.Baseline.LowConfidence {
t.Error("baseline with 25 plays should not be low-confidence")
}
radio := findSurface(resp, "radio")
if radio == nil {
t.Fatal("radio family missing")
}
if radio.Plays != 2 || radio.Skips != 1 {
t.Errorf("radio plays/skips = %d/%d, want 2/1", radio.Plays, radio.Skips)
}
if radio.AvgCompletion < 0.49 || radio.AvgCompletion > 0.51 {
t.Errorf("radio avg_completion = %.3f, want 0.5 (play-weighted merge)", radio.AvgCompletion)
}
if !radio.LowConfidence {
t.Error("radio with 2 plays should be low-confidence")
}
if s := findSurface(resp, "direct_album"); s == nil || s.Plays != 1 {
t.Errorf("direct_album = %+v, want plays=1", s)
}
if s := findSurface(resp, ""); s != nil {
t.Error("NULL source must not appear as a surface family")
}
// No attributed (taste/fresh) plays in this fixture → no breakdown;
// an all-unattributed breakdown would just repeat the parent row.
if fy := findSurface(resp, "for_you"); fy == nil || fy.Breakdown != nil {
t.Errorf("for_you breakdown = %+v, want nil without attributed plays", fy)
}
// Group ordering is intent-banded: go_to before discovery before direct.
wantOrder := []string{intentGoTo, intentDiscovery, intentDirect}
if len(resp.Groups) != len(wantOrder) {
t.Fatalf("groups = %d, want %d", len(resp.Groups), len(wantOrder))
}
for i, g := range resp.Groups {
if g.Intent != wantOrder[i] {
t.Errorf("group[%d].intent = %s, want %s", i, g.Intent, wantOrder[i])
}
}
}
func TestBucketMetricsResponse_ForYouBreakdown(t *testing.T) {
src := func(s string) *string { return &s }
kind := func(s string) *string { return &s }
rows := []dbq.RecommendationSourceMetricsForUserRow{
{Source: src("for_you"), PickKind: kind("taste"), Plays: 30, Skips: 3,
CompletionN: 30, AvgCompletion: 0.9},
{Source: src("for_you"), PickKind: kind("fresh"), Plays: 10, Skips: 4,
CompletionN: 10, AvgCompletion: 0.5},
// NULL pick_kind = plays that predate attribution.
{Source: src("for_you"), Plays: 5, Skips: 1, CompletionN: 5, AvgCompletion: 0.7},
}
resp := bucketMetricsResponse(recMetricsDefaultDays, rows)
fy := findSurface(resp, "for_you")
if fy == nil {
t.Fatal("for_you family missing")
}
// The parent row stays the sum of its breakdown.
if fy.Plays != 45 || fy.Skips != 8 {
t.Errorf("for_you plays/skips = %d/%d, want 45/8", fy.Plays, fy.Skips)
}
if len(fy.Breakdown) != 3 {
t.Fatalf("breakdown rows = %d, want 3 (taste, fresh, earlier)", len(fy.Breakdown))
}
wantKeys := []string{"for_you_taste", "for_you_fresh", "for_you_unattributed"}
for i, k := range wantKeys {
if fy.Breakdown[i].Key != k {
t.Errorf("breakdown[%d].key = %s, want %s", i, fy.Breakdown[i].Key, k)
}
}
taste, fresh := fy.Breakdown[0], fy.Breakdown[1]
if taste.Plays != 30 || taste.SkipRate != 0.1 || taste.LowConfidence {
t.Errorf("taste = %+v, want plays=30 skip_rate=0.1 confident", taste)
}
if fresh.Plays != 10 || fresh.SkipRate != 0.4 || !fresh.LowConfidence {
t.Errorf("fresh = %+v, want plays=10 skip_rate=0.4 low-confidence", fresh)
}
// Breakdown rows never appear as their own surface families.
if s := findSurface(resp, "for_you_taste"); s != nil {
t.Error("for_you_taste must not be a top-level surface")
}
}
func TestBucketMetricsResponse_DiscoverBucketBreakdown(t *testing.T) {
// Provenance is standard (#1270): Discover's bucket stamps surface as
// a breakdown exactly like For You's taste/fresh — this is what makes
// the bucket allocation judgeable instead of a guess.
src := func(s string) *string { return &s }
kind := func(s string) *string { return &s }
rows := []dbq.RecommendationSourceMetricsForUserRow{
{Source: src("discover"), PickKind: kind("dormant"), Plays: 8, Skips: 2,
CompletionN: 8, AvgCompletion: 0.8},
{Source: src("discover"), PickKind: kind("cross_user"), Plays: 6, Skips: 3,
CompletionN: 6, AvgCompletion: 0.6},
{Source: src("discover"), PickKind: kind("random"), Plays: 4, Skips: 3,
CompletionN: 4, AvgCompletion: 0.4},
// NULL pick_kind = plays that predate bucket stamping.
{Source: src("discover"), Plays: 2, Skips: 0, CompletionN: 2, AvgCompletion: 0.9},
}
resp := bucketMetricsResponse(recMetricsDefaultDays, rows)
d := findSurface(resp, "discover")
if d == nil {
t.Fatal("discover family missing")
}
if d.Plays != 20 || d.Skips != 8 {
t.Errorf("discover plays/skips = %d/%d, want 20/8", d.Plays, d.Skips)
}
wantKeys := []string{
"discover_dormant", "discover_cross_user", "discover_random",
"discover_unattributed",
}
if len(d.Breakdown) != len(wantKeys) {
t.Fatalf("breakdown rows = %d, want %d", len(d.Breakdown), len(wantKeys))
}
for i, k := range wantKeys {
if d.Breakdown[i].Key != k {
t.Errorf("breakdown[%d].key = %s, want %s", i, d.Breakdown[i].Key, k)
}
}
if b := d.Breakdown[0]; b.Label != "Dormant artists" || b.Plays != 8 {
t.Errorf("dormant row = %+v, want label=Dormant artists plays=8", b)
}
}
func TestRecommendationMetrics_BucketsWithBaseline(t *testing.T) {
if os.Getenv("MINSTREL_TEST_DATABASE_URL") == "" { if os.Getenv("MINSTREL_TEST_DATABASE_URL") == "" {
t.Skip("MINSTREL_TEST_DATABASE_URL not set") t.Skip("MINSTREL_TEST_DATABASE_URL not set")
} }
@@ -57,15 +222,17 @@ func TestRecommendationMetrics_AggregatesBySourceExcludingNull(t *testing.T) {
session := seedPlaySession(t, pool, user.ID, time.Now()) session := seedPlaySession(t, pool, user.ID, time.Now())
forYou := "for_you" forYou := "for_you"
discover := "discover" radioA := "radio:11111111-1111-1111-1111-111111111111"
taste, fresh := "taste", "fresh"
// for_you: 3 plays, 1 skipped; completions 1.0, 0.95, 0.05 → mean 0.6667. // for_you: 3 plays, 1 skipped; completions 1.0, 0.95, 0.05 → mean 0.6667.
seedSourcedPlay(t, h, user.ID, tk.ID, session, &forYou, 1.0, false) // Two attributed as taste picks, the skipped one as a fresh pick (#1249).
seedSourcedPlay(t, h, user.ID, tk.ID, session, &forYou, 0.95, false) seedSourcedPlay(t, h, user.ID, tk.ID, session, &forYou, &taste, 1.0, false)
seedSourcedPlay(t, h, user.ID, tk.ID, session, &forYou, 0.05, true) seedSourcedPlay(t, h, user.ID, tk.ID, session, &forYou, &taste, 0.95, false)
// discover: 1 play. seedSourcedPlay(t, h, user.ID, tk.ID, session, &forYou, &fresh, 0.05, true)
seedSourcedPlay(t, h, user.ID, tk.ID, session, &discover, 0.8, false) // A radio session play collapses into the "radio" family.
// library play (NULL source) — must be excluded. seedSourcedPlay(t, h, user.ID, tk.ID, session, &radioA, nil, 0.8, false)
seedSourcedPlay(t, h, user.ID, tk.ID, session, nil, 1.0, false) // Manual play (NULL source) — the baseline row.
seedSourcedPlay(t, h, user.ID, tk.ID, session, nil, nil, 1.0, false)
req := httptest.NewRequest(http.MethodGet, "/api/me/recommendation-metrics", nil) req := httptest.NewRequest(http.MethodGet, "/api/me/recommendation-metrics", nil)
req = withUser(req, user) req = withUser(req, user)
@@ -82,15 +249,11 @@ func TestRecommendationMetrics_AggregatesBySourceExcludingNull(t *testing.T) {
if resp.WindowDays != recMetricsDefaultDays { if resp.WindowDays != recMetricsDefaultDays {
t.Errorf("window_days = %d, want %d", resp.WindowDays, recMetricsDefaultDays) t.Errorf("window_days = %d, want %d", resp.WindowDays, recMetricsDefaultDays)
} }
bySource := map[string]recommendationMetric{} if resp.Baseline == nil || resp.Baseline.Plays != 1 {
for _, m := range resp.Sources { t.Fatalf("baseline = %+v, want plays=1", resp.Baseline)
bySource[m.Source] = m
} }
if _, present := bySource[""]; present { fy := findSurface(resp, "for_you")
t.Error("NULL-source (library) plays should be excluded") if fy == nil {
}
fy, ok := bySource["for_you"]
if !ok {
t.Fatal("for_you metrics missing") t.Fatal("for_you metrics missing")
} }
if fy.Plays != 3 || fy.Skips != 1 { if fy.Plays != 3 || fy.Skips != 1 {
@@ -102,7 +265,17 @@ func TestRecommendationMetrics_AggregatesBySourceExcludingNull(t *testing.T) {
if fy.AvgCompletion < 0.66 || fy.AvgCompletion > 0.67 { if fy.AvgCompletion < 0.66 || fy.AvgCompletion > 0.67 {
t.Errorf("for_you avg_completion = %.4f, want ~0.6667", fy.AvgCompletion) t.Errorf("for_you avg_completion = %.4f, want ~0.6667", fy.AvgCompletion)
} }
if d, ok := bySource["discover"]; !ok || d.Plays != 1 || d.Skips != 0 { // Pick-kind attribution surfaces as the For You breakdown (#1249).
t.Errorf("discover metrics = %+v, want plays=1 skips=0", d) if len(fy.Breakdown) != 2 {
t.Fatalf("for_you breakdown rows = %d, want 2 (taste, fresh)", len(fy.Breakdown))
}
if b := fy.Breakdown[0]; b.Key != "for_you_taste" || b.Plays != 2 || b.Skips != 0 {
t.Errorf("breakdown[0] = %+v, want for_you_taste plays=2 skips=0", b)
}
if b := fy.Breakdown[1]; b.Key != "for_you_fresh" || b.Plays != 1 || b.Skips != 1 {
t.Errorf("breakdown[1] = %+v, want for_you_fresh plays=1 skips=1", b)
}
if radio := findSurface(resp, "radio"); radio == nil || radio.Plays != 1 {
t.Errorf("radio family = %+v, want plays=1 (collapsed from radio:<uuid>)", radio)
} }
} }
+26 -10
View File
@@ -1,6 +1,7 @@
package api package api
import ( import (
"context"
"encoding/json" "encoding/json"
"errors" "errors"
"log/slog" "log/slog"
@@ -15,6 +16,7 @@ import (
"git.fabledsword.com/bvandeusen/minstrel/internal/apierror" "git.fabledsword.com/bvandeusen/minstrel/internal/apierror"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
"git.fabledsword.com/bvandeusen/minstrel/internal/recommendation" "git.fabledsword.com/bvandeusen/minstrel/internal/recommendation"
"git.fabledsword.com/bvandeusen/minstrel/internal/recsettings"
) )
// RadioResponse is the body of GET /api/radio. // RadioResponse is the body of GET /api/radio.
@@ -80,6 +82,9 @@ func (h *handlers) handleRadio(w http.ResponseWriter, r *http.Request) {
} }
currentVec := loadCurrentSessionVector(r, q, user.ID, h.logger) currentVec := loadCurrentSessionVector(r, q, user.ID, h.logger)
// Condition on the current device (#1551): the latest play's device is a
// cheap, request-free proxy for what the user is on right now.
currentVec.DeviceClass = latestDeviceClass(r.Context(), q, user.ID, h.logger)
exclude := parseExcludeParam(r.URL.Query().Get("exclude")) exclude := parseExcludeParam(r.URL.Query().Get("exclude"))
limits := recommendation.DefaultCandidateSourceLimits() limits := recommendation.DefaultCandidateSourceLimits()
@@ -100,16 +105,9 @@ func (h *handlers) handleRadio(w http.ResponseWriter, r *http.Request) {
} }
} }
weights := recommendation.ScoringWeights{ // Scoring weights come from the DB-backed tuning lab (#1250) —
BaseWeight: h.recCfg.BaseWeight, // read per request so an admin change takes effect live.
LikeBoost: h.recCfg.LikeBoost, weights := h.recSettings.Weights(recsettings.ScopeRadio)
RecencyWeight: h.recCfg.RecencyWeight,
SkipPenalty: h.recCfg.SkipPenalty,
JitterMagnitude: h.recCfg.JitterMagnitude,
ContextWeight: h.recCfg.ContextWeight,
SimilarityWeight: h.recCfg.SimilarityWeight,
TasteWeight: h.recCfg.TasteWeight,
}
picks := recommendation.Shuffle(candidates, weights, time.Now().UTC(), h.rng, limit-1) picks := recommendation.Shuffle(candidates, weights, time.Now().UTC(), h.rng, limit-1)
out := make([]TrackRef, 0, len(picks)+1) out := make([]TrackRef, 0, len(picks)+1)
@@ -156,6 +154,24 @@ func loadCurrentSessionVector(r *http.Request, q *dbq.Queries, userID pgtype.UUI
return v return v
} }
// latestDeviceClass returns the device_class of the user's most recent play as
// the "current device" for context conditioning (#1551), or "" when unknown
// (no plays yet, or the latest play predates device capture). Best-effort: a
// lookup failure yields a device-agnostic ("") affinity cell.
func latestDeviceClass(ctx context.Context, q *dbq.Queries, userID pgtype.UUID, logger *slog.Logger) string {
dc, err := q.GetLatestPlayDeviceClassForUser(ctx, userID)
if err != nil {
if !errors.Is(err, pgx.ErrNoRows) {
logger.Warn("api: radio: latest device class", "err", err)
}
return ""
}
if dc == nil {
return ""
}
return *dc
}
// parseExcludeParam parses a comma-separated list of UUIDs from the // parseExcludeParam parses a comma-separated list of UUIDs from the
// `exclude` query string, silently dropping malformed entries. Returns // `exclude` query string, silently dropping malformed entries. Returns
// nil for empty or all-malformed input. // nil for empty or all-malformed input.
+159 -1
View File
@@ -2,13 +2,19 @@ package api
import ( import (
"context" "context"
"encoding/json"
"errors"
"io"
"net/http" "net/http"
"strconv" "strconv"
"strings"
"sync" "sync"
"github.com/go-chi/chi/v5"
"github.com/jackc/pgx/v5/pgtype" "github.com/jackc/pgx/v5/pgtype"
"git.fabledsword.com/bvandeusen/minstrel/internal/apierror" "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/lidarr"
"git.fabledsword.com/bvandeusen/minstrel/internal/recommendation" "git.fabledsword.com/bvandeusen/minstrel/internal/recommendation"
) )
@@ -19,6 +25,12 @@ type suggestionView struct {
Name string `json:"name"` Name string `json:"name"`
Score float64 `json:"score"` Score float64 `json:"score"`
Attribution []seedContributionView `json:"attribution"` 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 // ImageURL is resolved on-demand from Lidarr (out-of-library
// artists have no local art row). Omitted when Lidarr is disabled // artists have no local art row). Omitted when Lidarr is disabled
// or has no match — the client falls back to a placeholder. Not // 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 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 { if err != nil {
h.logger.Error("api: list suggestions", "err", err) h.logger.Error("api: list suggestions", "err", err)
writeErr(w, apierror.InternalMsg("failed to load suggestions", 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{ out = append(out, suggestionView{
MBID: s.MBID, Name: s.Name, Score: s.Score, Attribution: attr, MBID: s.MBID, Name: s.Name, Score: s.Score, Attribution: attr,
MatchedTags: s.MatchedTags,
}) })
} }
h.resolveSuggestionArt(r.Context(), out) h.resolveSuggestionArt(r.Context(), out)
writeJSON(w, http.StatusOK, 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 // resolveSuggestionArt fills ImageURL on-demand from Lidarr's artist
// lookup, matched by MBID (foreignArtistId). Best-effort and cache-free: // lookup, matched by MBID (foreignArtistId). Best-effort and cache-free:
// Lidarr is the only source — when it's disabled, unreachable, or has // Lidarr is the only source — when it's disabled, unreachable, or has
+7 -23
View File
@@ -88,20 +88,14 @@ type EventsConfig struct {
SkipMaxDurationPlayedMs int `yaml:"skip_max_duration_played_ms"` SkipMaxDurationPlayedMs int `yaml:"skip_max_duration_played_ms"`
} }
// RecommendationConfig governs the M3 weighted-shuffle scoring (spec §6). // RecommendationConfig holds the radio path's operational knobs. The
// All weights are operator-tunable; defaults match the spec recommendations. // scoring WEIGHTS moved to DB-backed admin settings (#1250,
// internal/recsettings) — YAML is bootstrap-only; anything an operator
// tunes lives in the UI with live effect.
type RecommendationConfig struct { type RecommendationConfig struct {
BaseWeight float64 `yaml:"base_weight"` RecentlyPlayedHours int `yaml:"recently_played_hours"`
LikeBoost float64 `yaml:"like_boost"` RadioSize int `yaml:"radio_size"`
RecencyWeight float64 `yaml:"recency_weight"` RadioSizeMax int `yaml:"radio_size_max"`
SkipPenalty float64 `yaml:"skip_penalty"`
JitterMagnitude float64 `yaml:"jitter_magnitude"`
ContextWeight float64 `yaml:"context_weight"`
SimilarityWeight float64 `yaml:"similarity_weight"`
TasteWeight float64 `yaml:"taste_weight"`
RecentlyPlayedHours int `yaml:"recently_played_hours"`
RadioSize int `yaml:"radio_size"`
RadioSizeMax int `yaml:"radio_size_max"`
} }
func Default() Config { func Default() Config {
@@ -120,16 +114,6 @@ func Default() Config {
SkipMaxDurationPlayedMs: 30000, SkipMaxDurationPlayedMs: 30000,
}, },
Recommendation: RecommendationConfig{ Recommendation: RecommendationConfig{
BaseWeight: 1.0,
LikeBoost: 2.0,
RecencyWeight: 1.0,
SkipPenalty: 1.0,
JitterMagnitude: 0.1,
ContextWeight: 2.0,
SimilarityWeight: 2.0,
// Radio is seed-directed (the user picked a direction), so taste
// is a lighter nudge here than in the daily mixes (1.5).
TasteWeight: 1.0,
RecentlyPlayedHours: 1, RecentlyPlayedHours: 1,
RadioSize: 50, RadioSize: 50,
RadioSizeMax: 200, RadioSizeMax: 200,
+86
View File
@@ -0,0 +1,86 @@
// Package coplay builds household co-play similarity edges (#1533, milestone
// #160 Opt 5). Minstrel is a single shared-library, multi-user server (no
// per-user library ACL), so the "household" is the whole instance's user set.
// A periodic worker recomputes artistartist co-occurrence from play_events
// entirely in SQL (no external API) and stores the edges in artist_similarity
// under source='user_cooccurrence' — the pre-provisioned co-occurrence source
// from the 0009 schema — which the radio/mix candidate pool consumes as a
// collaborative arm. Single-user servers produce no edges (the >= 2 co-player
// gate), so the feature is a graceful no-op there.
package coplay
import (
"context"
"log/slog"
"time"
"github.com/jackc/pgx/v5/pgxpool"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
)
// minJaccard is the co-play edge floor: prune weak pairs and self-limit hub
// artists, whose large combined-audience denominators pull their Jaccard down.
// 0.1 keeps only pairs whose co-player overlap is a real fraction of their
// combined audience.
const minJaccard = 0.1
// Worker periodically rebuilds the household co-play edge set. Pure local SQL,
// so there are no rate limits — but the pair self-join is roughly
// O(users × artists_per_user²), so it ticks slowly: co-listening shifts over
// days, not minutes, and a frequent rebuild would burn CPU for no fresher
// signal.
type Worker struct {
pool *pgxpool.Pool
logger *slog.Logger
tick time.Duration
}
// NewWorker constructs a worker with the production default 6h tick.
func NewWorker(pool *pgxpool.Pool, logger *slog.Logger) *Worker {
return &Worker{pool: pool, logger: logger, tick: 6 * time.Hour}
}
// Run rebuilds once at startup (so edges exist without waiting a full tick),
// then every w.tick until ctx is cancelled. Runs in its own goroutine, so a
// slow initial rebuild never blocks boot.
func (w *Worker) Run(ctx context.Context) {
if err := w.rebuild(ctx); err != nil {
w.logger.Error("coplay: initial rebuild failed", "err", err)
}
t := time.NewTicker(w.tick)
defer t.Stop()
for {
select {
case <-ctx.Done():
return
case <-t.C:
if err := w.rebuild(ctx); err != nil {
w.logger.Error("coplay: rebuild failed", "err", err)
}
}
}
}
// rebuild atomic-replaces the user_cooccurrence edge set inside one
// transaction — delete the old edges, recompute from current play history — so
// the candidate pool never observes a half-built set.
func (w *Worker) rebuild(ctx context.Context) error {
tx, err := w.pool.Begin(ctx)
if err != nil {
return err
}
defer func() { _ = tx.Rollback(ctx) }()
q := dbq.New(tx)
if err := q.DeleteArtistCoplayEdges(ctx); err != nil {
return err
}
if err := q.InsertArtistCoplayEdges(ctx, minJaccard); err != nil {
return err
}
if err := tx.Commit(ctx); err != nil {
return err
}
w.logger.Debug("coplay: edges rebuilt")
return nil
}
+162
View File
@@ -0,0 +1,162 @@
package coplay
import (
"context"
"io"
"log/slog"
"os"
"testing"
"time"
"github.com/jackc/pgx/v5/pgtype"
"github.com/jackc/pgx/v5/pgxpool"
"git.fabledsword.com/bvandeusen/minstrel/internal/db"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
"git.fabledsword.com/bvandeusen/minstrel/internal/dbtest"
)
func testPool(t *testing.T) *pgxpool.Pool {
t.Helper()
if testing.Short() {
t.Skip("skipping in -short mode")
}
dsn := os.Getenv("MINSTREL_TEST_DATABASE_URL")
if dsn == "" {
t.Skip("MINSTREL_TEST_DATABASE_URL not set")
}
if err := db.Migrate(dsn, slog.New(slog.NewTextHandler(io.Discard, nil))); err != nil {
t.Fatalf("migrate: %v", err)
}
pool, err := pgxpool.New(context.Background(), dsn)
if err != nil {
t.Fatalf("pool: %v", err)
}
t.Cleanup(pool.Close)
dbtest.ResetDB(t, pool)
return pool
}
func discardLogger() *slog.Logger { return slog.New(slog.NewTextHandler(io.Discard, nil)) }
func seedUser(t *testing.T, q *dbq.Queries, name string) pgtype.UUID {
t.Helper()
u, err := q.CreateUser(context.Background(), dbq.CreateUserParams{
Username: dbtest.TestUserPrefix + name, PasswordHash: "x", ApiToken: name + "-tok", IsAdmin: false,
})
if err != nil {
t.Fatalf("seed user %s: %v", name, err)
}
return u.ID
}
// seedArtistTrack creates an artist + album + one track, returning both ids.
func seedArtistTrack(t *testing.T, q *dbq.Queries, name string) (pgtype.UUID, pgtype.UUID) {
t.Helper()
ctx := context.Background()
a, err := q.UpsertArtist(ctx, dbq.UpsertArtistParams{Name: name, SortName: name})
if err != nil {
t.Fatalf("seed artist: %v", err)
}
al, err := q.UpsertAlbum(ctx, dbq.UpsertAlbumParams{Title: name, SortTitle: name, ArtistID: a.ID})
if err != nil {
t.Fatalf("seed album: %v", err)
}
tr, err := q.UpsertTrack(ctx, dbq.UpsertTrackParams{
Title: name, AlbumID: al.ID, ArtistID: a.ID,
DurationMs: 1000, FilePath: "/tmp/coplay-" + name + ".mp3",
FileSize: 1, FileFormat: "mp3",
})
if err != nil {
t.Fatalf("seed track: %v", err)
}
return a.ID, tr.ID
}
func play(t *testing.T, pool *pgxpool.Pool, userID, trackID pgtype.UUID) {
t.Helper()
ctx := context.Background()
at := time.Now()
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, 'coplay-test') RETURNING id`,
userID, at).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, false)`,
userID, trackID, sessionID, at); err != nil {
t.Fatalf("insert play_event: %v", err)
}
}
func coplayEdgeCount(t *testing.T, pool *pgxpool.Pool) int {
t.Helper()
var n int
if err := pool.QueryRow(context.Background(),
`SELECT count(*) FROM artist_similarity WHERE source='user_cooccurrence'`).Scan(&n); err != nil {
t.Fatalf("count edges: %v", err)
}
return n
}
// TestCoplayWorker_BuildsEdges: two users who both play two artists produce a
// perfect-overlap co-play edge (Jaccard 1.0) in each direction.
func TestCoplayWorker_BuildsEdges(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
q := dbq.New(pool)
u1 := seedUser(t, q, "cop1")
u2 := seedUser(t, q, "cop2")
aA, tA := seedArtistTrack(t, q, "ArtA")
aB, tB := seedArtistTrack(t, q, "ArtB")
for _, u := range []pgtype.UUID{u1, u2} {
play(t, pool, u, tA)
play(t, pool, u, tB)
}
if err := NewWorker(pool, discardLogger()).rebuild(ctx); err != nil {
t.Fatalf("rebuild: %v", err)
}
var score float64
if err := pool.QueryRow(ctx,
`SELECT score FROM artist_similarity
WHERE artist_a_id=$1 AND artist_b_id=$2 AND source='user_cooccurrence'`,
aA, aB).Scan(&score); err != nil {
t.Fatalf("expected co-play edge ArtA→ArtB: %v", err)
}
// Jaccard of perfectly-overlapping player sets (both users play both):
// coplayers=2, players each=2 → 2/(2+2-2) = 1.0.
if score != 1.0 {
t.Errorf("edge score = %v, want 1.0 (perfect overlap)", score)
}
if got := coplayEdgeCount(t, pool); got != 2 {
t.Errorf("edge count = %d, want 2 (A→B and B→A)", got)
}
}
// TestCoplayWorker_SingleUserNoEdges: one user's own co-listening yields no
// edges — the >= 2 distinct-co-player gate keeps single-user servers empty.
func TestCoplayWorker_SingleUserNoEdges(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
q := dbq.New(pool)
u := seedUser(t, q, "solo")
_, tA := seedArtistTrack(t, q, "SoloA")
_, tB := seedArtistTrack(t, q, "SoloB")
play(t, pool, u, tA)
play(t, pool, u, tB)
if err := NewWorker(pool, discardLogger()).rebuild(ctx); err != nil {
t.Fatalf("rebuild: %v", err)
}
if got := coplayEdgeCount(t, pool); got != 0 {
t.Errorf("single-user edge count = %d, want 0 (>= 2 co-player gate)", got)
}
}
@@ -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
}
+78
View File
@@ -0,0 +1,78 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
// source: coplay.sql
package dbq
import (
"context"
)
const deleteArtistCoplayEdges = `-- name: DeleteArtistCoplayEdges :exec
DELETE FROM artist_similarity WHERE source = 'user_cooccurrence'
`
// Household co-play similarity (#1533, milestone #160 Opt 5). Minstrel is a
// single shared-library, multi-user server (no per-user library ACL), so the
// "household" is the whole instance's user set. These queries recompute
// artistartist co-occurrence edges from play_events and store them in
// artist_similarity under the pre-provisioned source = 'user_cooccurrence'
// (whitelisted in the 0009 CHECK from day one). A periodic worker DELETEs then
// re-INSERTs so edges that fall below threshold disappear. Single-user servers
// produce no edges (the >= 2 co-player gate), so the arm is empty there.
func (q *Queries) DeleteArtistCoplayEdges(ctx context.Context) error {
_, err := q.db.Exec(ctx, deleteArtistCoplayEdges)
return err
}
const insertArtistCoplayEdges = `-- name: InsertArtistCoplayEdges :exec
WITH user_artists AS (
SELECT DISTINCT pe.user_id, t.artist_id
FROM play_events pe
JOIN tracks t ON t.id = pe.track_id
WHERE pe.was_skipped = false
AND pe.started_at > now() - interval '365 days'
),
artist_players AS (
SELECT artist_id, count(*)::float8 AS players
FROM user_artists
GROUP BY artist_id
),
pairs AS (
SELECT ua.artist_id AS a_id,
ub.artist_id AS b_id,
count(*)::float8 AS coplayers
FROM user_artists ua
JOIN user_artists ub
ON ua.user_id = ub.user_id AND ua.artist_id <> ub.artist_id
GROUP BY ua.artist_id, ub.artist_id
HAVING count(*) >= 2
),
scored AS (
SELECT p.a_id, p.b_id,
p.coplayers / (pa.players + pb.players - p.coplayers) AS score
FROM pairs p
JOIN artist_players pa ON pa.artist_id = p.a_id
JOIN artist_players pb ON pb.artist_id = p.b_id
)
INSERT INTO artist_similarity (artist_a_id, artist_b_id, score, source, fetched_at)
SELECT a_id, b_id, score, 'user_cooccurrence', now()
FROM scored
WHERE score >= $1
ON CONFLICT (artist_a_id, artist_b_id, source)
DO UPDATE SET score = EXCLUDED.score, fetched_at = EXCLUDED.fetched_at
`
// Two artists are "co-played" when the same users play both. score is the
// Jaccard of their distinct-player sets — coplayers / (playersA + playersB
// coplayers) in (0,1] — which controls for globally-popular artists (an artist
// everyone plays would otherwise co-occur with everything). Only pairs with
// >= 2 distinct co-players AND Jaccard >= $1 (a floor that both prunes weak
// edges and self-limits hub artists, whose large denominators drag their
// Jaccard down) are kept. Completed plays only, 365-day window.
func (q *Queries) InsertArtistCoplayEdges(ctx context.Context, score float64) error {
_, err := q.db.Exec(ctx, insertArtistCoplayEdges, score)
return err
}
+66
View File
@@ -211,3 +211,69 @@ func (q *Queries) ListRandomUnheardTracksForDiscover(ctx context.Context, arg Li
} }
return items, nil return items, nil
} }
const listTasteUnheardTracksForDiscover = `-- name: ListTasteUnheardTracksForDiscover :many
SELECT t.id, t.album_id, t.artist_id
FROM tracks t
JOIN LATERAL regexp_split_to_table(coalesce(t.genre, ''), '[;,]') AS g_split(g) ON true
JOIN taste_profile_tags nt ON nt.user_id = $1 AND trim(g_split.g) = nt.tag
WHERE nt.weight > 0
AND trim(g_split.g) <> ''
AND NOT EXISTS (
SELECT 1 FROM play_events pe
WHERE pe.user_id = $1
AND pe.track_id = t.id
AND pe.was_skipped = false
)
AND NOT EXISTS (
SELECT 1 FROM general_likes gl
WHERE gl.user_id = $1 AND gl.track_id = t.id
)
AND NOT EXISTS (
SELECT 1 FROM lidarr_quarantine q
WHERE q.user_id = $1 AND q.track_id = t.id
)
GROUP BY t.id, t.album_id, t.artist_id
ORDER BY SUM(nt.weight) DESC, md5(t.id::text || $2::text)
LIMIT 120
`
type ListTasteUnheardTracksForDiscoverParams struct {
UserID pgtype.UUID
Column2 string
}
type ListTasteUnheardTracksForDiscoverRow struct {
ID pgtype.UUID
AlbumID pgtype.UUID
ArtistID pgtype.UUID
}
// Taste-targeted novelty: unheard tracks whose genres overlap the user's
// taste-profile tags (taste_profile_tags, #796), ranked by summed tag
// weight — "new to you, but your vibe" rather than the crude random arm.
// Genres live inline on tracks.genre as a delimited string, split the
// same way the radio tag_overlap arm does (regexp_split_to_table on
// [;,]). Same exclusion filters as the other buckets. Returns nothing
// when the user has no taste tags yet (cold start), so the caller
// redistributes its slots to the other buckets. Stamped 'taste_unheard'.
// $1 = user_id, $2 = date string for md5 tiebreak ordering.
func (q *Queries) ListTasteUnheardTracksForDiscover(ctx context.Context, arg ListTasteUnheardTracksForDiscoverParams) ([]ListTasteUnheardTracksForDiscoverRow, error) {
rows, err := q.db.Query(ctx, listTasteUnheardTracksForDiscover, arg.UserID, arg.Column2)
if err != nil {
return nil, err
}
defer rows.Close()
var items []ListTasteUnheardTracksForDiscoverRow
for rows.Next() {
var i ListTasteUnheardTracksForDiscoverRow
if err := rows.Scan(&i.ID, &i.AlbumID, &i.ArtistID); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
+64 -13
View File
@@ -54,7 +54,7 @@ func (q *Queries) GetMostRecentPlaySessionForUser(ctx context.Context, userID pg
} }
const getOpenPlayEventForUser = `-- name: GetOpenPlayEventForUser :one const getOpenPlayEventForUser = `-- name: GetOpenPlayEventForUser :one
SELECT id, user_id, track_id, session_id, started_at, ended_at, duration_played_ms, completion_ratio, was_skipped, client_id, session_vector_at_play, scrobbled_at, source FROM play_events SELECT id, user_id, track_id, session_id, started_at, ended_at, duration_played_ms, completion_ratio, was_skipped, client_id, session_vector_at_play, scrobbled_at, source, pick_kind, device_class FROM play_events
WHERE user_id = $1 AND ended_at IS NULL WHERE user_id = $1 AND ended_at IS NULL
ORDER BY started_at DESC ORDER BY started_at DESC
LIMIT 1 LIMIT 1
@@ -79,12 +79,14 @@ func (q *Queries) GetOpenPlayEventForUser(ctx context.Context, userID pgtype.UUI
&i.SessionVectorAtPlay, &i.SessionVectorAtPlay,
&i.ScrobbledAt, &i.ScrobbledAt,
&i.Source, &i.Source,
&i.PickKind,
&i.DeviceClass,
) )
return i, err return i, err
} }
const getPlayEventByID = `-- name: GetPlayEventByID :one const getPlayEventByID = `-- name: GetPlayEventByID :one
SELECT id, user_id, track_id, session_id, started_at, ended_at, duration_played_ms, completion_ratio, was_skipped, client_id, session_vector_at_play, scrobbled_at, source FROM play_events WHERE id = $1 SELECT id, user_id, track_id, session_id, started_at, ended_at, duration_played_ms, completion_ratio, was_skipped, client_id, session_vector_at_play, scrobbled_at, source, pick_kind, device_class FROM play_events WHERE id = $1
` `
func (q *Queries) GetPlayEventByID(ctx context.Context, id pgtype.UUID) (PlayEvent, error) { func (q *Queries) GetPlayEventByID(ctx context.Context, id pgtype.UUID) (PlayEvent, error) {
@@ -104,26 +106,67 @@ func (q *Queries) GetPlayEventByID(ctx context.Context, id pgtype.UUID) (PlayEve
&i.SessionVectorAtPlay, &i.SessionVectorAtPlay,
&i.ScrobbledAt, &i.ScrobbledAt,
&i.Source, &i.Source,
&i.PickKind,
&i.DeviceClass,
) )
return i, err return i, err
} }
const getSystemPickKindForTrack = `-- name: GetSystemPickKindForTrack :one
SELECT pt.pick_kind
FROM playlist_tracks pt
JOIN playlists p ON p.id = pt.playlist_id
WHERE p.user_id = $1
AND p.system_variant = $2
AND pt.track_id = $3
LIMIT 1
`
type GetSystemPickKindForTrackParams struct {
UserID pgtype.UUID
SystemVariant *string
TrackID pgtype.UUID
}
// Looks a track up in the user's CURRENT snapshot of the given system
// variant and returns its pick_kind. Used at play-ingestion time to
// freeze provenance onto the play_event — snapshots rebuild daily, so
// attribution can't be reconstructed at read time (#1249/#1270). No
// row = track not in today's snapshot (caller stores NULL); a row with
// NULL pick_kind = the variant doesn't stamp (yet). songs_like_artist
// is non-singleton (up to 3 mixes/user); a track in two of them takes
// whichever LIMIT 1 hits — acceptable, tier stamps there describe the
// same eligibility ladder.
func (q *Queries) GetSystemPickKindForTrack(ctx context.Context, arg GetSystemPickKindForTrackParams) (*string, error) {
row := q.db.QueryRow(ctx, getSystemPickKindForTrack, arg.UserID, arg.SystemVariant, arg.TrackID)
var pick_kind *string
err := row.Scan(&pick_kind)
return pick_kind, err
}
const insertPlayEvent = `-- name: InsertPlayEvent :one const insertPlayEvent = `-- name: InsertPlayEvent :one
INSERT INTO play_events ( INSERT INTO play_events (
user_id, track_id, session_id, started_at, client_id, source user_id, track_id, session_id, started_at, client_id, source, pick_kind,
) VALUES ($1, $2, $3, $4, $5, $6) device_class
RETURNING id, user_id, track_id, session_id, started_at, ended_at, duration_played_ms, completion_ratio, was_skipped, client_id, session_vector_at_play, scrobbled_at, source ) VALUES ($1, $2, $3, $4, $5, $6, $7::text,
$8::text)
RETURNING id, user_id, track_id, session_id, started_at, ended_at, duration_played_ms, completion_ratio, was_skipped, client_id, session_vector_at_play, scrobbled_at, source, pick_kind, device_class
` `
type InsertPlayEventParams struct { type InsertPlayEventParams struct {
UserID pgtype.UUID UserID pgtype.UUID
TrackID pgtype.UUID TrackID pgtype.UUID
SessionID pgtype.UUID SessionID pgtype.UUID
StartedAt pgtype.Timestamptz StartedAt pgtype.Timestamptz
ClientID *string ClientID *string
Source *string Source *string
PickKind *string
DeviceClass *string
} }
// pick_kind is non-NULL only for system-playlist plays whose track was
// found (with a stamped kind) in the user's live snapshot for that
// variant at ingestion time (#1249, generalized in #1270).
func (q *Queries) InsertPlayEvent(ctx context.Context, arg InsertPlayEventParams) (PlayEvent, error) { func (q *Queries) InsertPlayEvent(ctx context.Context, arg InsertPlayEventParams) (PlayEvent, error) {
row := q.db.QueryRow(ctx, insertPlayEvent, row := q.db.QueryRow(ctx, insertPlayEvent,
arg.UserID, arg.UserID,
@@ -132,6 +175,8 @@ func (q *Queries) InsertPlayEvent(ctx context.Context, arg InsertPlayEventParams
arg.StartedAt, arg.StartedAt,
arg.ClientID, arg.ClientID,
arg.Source, arg.Source,
arg.PickKind,
arg.DeviceClass,
) )
var i PlayEvent var i PlayEvent
err := row.Scan( err := row.Scan(
@@ -148,6 +193,8 @@ func (q *Queries) InsertPlayEvent(ctx context.Context, arg InsertPlayEventParams
&i.SessionVectorAtPlay, &i.SessionVectorAtPlay,
&i.ScrobbledAt, &i.ScrobbledAt,
&i.Source, &i.Source,
&i.PickKind,
&i.DeviceClass,
) )
return i, err return i, err
} }
@@ -214,7 +261,7 @@ func (q *Queries) InsertSkipEvent(ctx context.Context, arg InsertSkipEventParams
} }
const listRecentSessionTracks = `-- name: ListRecentSessionTracks :many const listRecentSessionTracks = `-- name: ListRecentSessionTracks :many
SELECT t.id, t.title, t.album_id, t.artist_id, t.track_number, t.disc_number, t.duration_ms, t.file_path, t.file_size, t.file_format, t.bitrate, t.mbid, t.genre, t.added_at, t.updated_at FROM tracks t SELECT t.id, t.title, t.album_id, t.artist_id, t.track_number, t.disc_number, t.duration_ms, t.file_path, t.file_size, t.file_format, t.bitrate, t.mbid, t.genre, t.added_at, t.updated_at, t.tag_source, t.tag_sources_version FROM tracks t
JOIN play_events pe ON pe.track_id = t.id JOIN play_events pe ON pe.track_id = t.id
WHERE pe.session_id = $1 WHERE pe.session_id = $1
AND pe.started_at < $2 AND pe.started_at < $2
@@ -256,6 +303,8 @@ func (q *Queries) ListRecentSessionTracks(ctx context.Context, arg ListRecentSes
&i.Genre, &i.Genre,
&i.AddedAt, &i.AddedAt,
&i.UpdatedAt, &i.UpdatedAt,
&i.TagSource,
&i.TagSourcesVersion,
); err != nil { ); err != nil {
return nil, err return nil, err
} }
@@ -291,7 +340,7 @@ SET ended_at = $2,
completion_ratio = $4, completion_ratio = $4,
was_skipped = $5 was_skipped = $5
WHERE id = $1 WHERE id = $1
RETURNING id, user_id, track_id, session_id, started_at, ended_at, duration_played_ms, completion_ratio, was_skipped, client_id, session_vector_at_play, scrobbled_at, source RETURNING id, user_id, track_id, session_id, started_at, ended_at, duration_played_ms, completion_ratio, was_skipped, client_id, session_vector_at_play, scrobbled_at, source, pick_kind, device_class
` `
type UpdatePlayEventEndedParams struct { type UpdatePlayEventEndedParams struct {
@@ -328,6 +377,8 @@ func (q *Queries) UpdatePlayEventEnded(ctx context.Context, arg UpdatePlayEventE
&i.SessionVectorAtPlay, &i.SessionVectorAtPlay,
&i.ScrobbledAt, &i.ScrobbledAt,
&i.Source, &i.Source,
&i.PickKind,
&i.DeviceClass,
) )
return i, err return i, err
} }
+3 -1
View File
@@ -14,7 +14,7 @@ import (
const listUserHistory = `-- name: ListUserHistory :many const listUserHistory = `-- name: ListUserHistory :many
SELECT pe.id AS event_id, SELECT pe.id AS event_id,
pe.started_at, pe.started_at,
t.id, t.title, t.album_id, t.artist_id, t.track_number, t.disc_number, t.duration_ms, t.file_path, t.file_size, t.file_format, t.bitrate, t.mbid, t.genre, t.added_at, t.updated_at, t.id, t.title, t.album_id, t.artist_id, t.track_number, t.disc_number, t.duration_ms, t.file_path, t.file_size, t.file_format, t.bitrate, t.mbid, t.genre, t.added_at, t.updated_at, t.tag_source, t.tag_sources_version,
albums.title AS album_title, albums.title AS album_title,
artists.name AS artist_name artists.name AS artist_name
FROM play_events pe FROM play_events pe
@@ -77,6 +77,8 @@ func (q *Queries) ListUserHistory(ctx context.Context, arg ListUserHistoryParams
&i.Track.Genre, &i.Track.Genre,
&i.Track.AddedAt, &i.Track.AddedAt,
&i.Track.UpdatedAt, &i.Track.UpdatedAt,
&i.Track.TagSource,
&i.Track.TagSourcesVersion,
&i.AlbumTitle, &i.AlbumTitle,
&i.ArtistName, &i.ArtistName,
); err != nil { ); err != nil {
+3 -1
View File
@@ -259,7 +259,7 @@ func (q *Queries) ListLikedTrackIDs(ctx context.Context, userID pgtype.UUID) ([]
} }
const listLikedTrackRows = `-- name: ListLikedTrackRows :many const listLikedTrackRows = `-- name: ListLikedTrackRows :many
SELECT t.id, t.title, t.album_id, t.artist_id, t.track_number, t.disc_number, t.duration_ms, t.file_path, t.file_size, t.file_format, t.bitrate, t.mbid, t.genre, t.added_at, t.updated_at FROM tracks t SELECT t.id, t.title, t.album_id, t.artist_id, t.track_number, t.disc_number, t.duration_ms, t.file_path, t.file_size, t.file_format, t.bitrate, t.mbid, t.genre, t.added_at, t.updated_at, t.tag_source, t.tag_sources_version FROM tracks t
JOIN general_likes l ON l.track_id = t.id JOIN general_likes l ON l.track_id = t.id
WHERE l.user_id = $1 WHERE l.user_id = $1
ORDER BY l.liked_at DESC ORDER BY l.liked_at DESC
@@ -297,6 +297,8 @@ func (q *Queries) ListLikedTrackRows(ctx context.Context, arg ListLikedTrackRows
&i.Genre, &i.Genre,
&i.AddedAt, &i.AddedAt,
&i.UpdatedAt, &i.UpdatedAt,
&i.TagSource,
&i.TagSourcesVersion,
); err != nil { ); err != nil {
return nil, err return nil, err
} }
+117 -15
View File
@@ -240,6 +240,19 @@ type AuditLog struct {
CreatedAt pgtype.Timestamptz 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 { type ContextualLike struct {
ID pgtype.UUID ID pgtype.UUID
UserID pgtype.UUID UserID pgtype.UUID
@@ -277,6 +290,13 @@ type DiagnosticEvent struct {
ReceivedAt pgtype.Timestamptz ReceivedAt pgtype.Timestamptz
} }
type DiscoverTuning struct {
Singleton bool
TagOverlapWeight float64
SnoozeDays float64
UpdatedAt pgtype.Timestamptz
}
type GeneralLike struct { type GeneralLike struct {
UserID pgtype.UUID UserID pgtype.UUID
TrackID pgtype.UUID TrackID pgtype.UUID
@@ -383,6 +403,8 @@ type PlayEvent struct {
SessionVectorAtPlay []byte SessionVectorAtPlay []byte
ScrobbledAt pgtype.Timestamptz ScrobbledAt pgtype.Timestamptz
Source *string Source *string
PickKind *string
DeviceClass *string
} }
type PlaySession struct { type PlaySession struct {
@@ -433,6 +455,29 @@ type PlaylistTrack struct {
AlbumTitle string AlbumTitle string
DurationSec int32 DurationSec int32
AddedAt pgtype.Timestamptz AddedAt pgtype.Timestamptz
PickKind *string
}
type RecommendationTuningAudit struct {
ID int64
ChangedAt pgtype.Timestamptz
Scope string
Action string
Changes []byte
}
type RecommendationWeightProfile struct {
Profile string
BaseWeight float64
LikeBoost float64
RecencyWeight float64
SkipPenalty float64
JitterMagnitude float64
ContextWeight float64
SimilarityWeight float64
TasteWeight float64
UpdatedAt pgtype.Timestamptz
ContextTimeWeight float64
} }
type RegistrationSetting struct { type RegistrationSetting struct {
@@ -493,6 +538,14 @@ type SmtpConfig struct {
UpdatedAt pgtype.Timestamptz UpdatedAt pgtype.Timestamptz
} }
type SuggestionSnooze struct {
UserID pgtype.UUID
CandidateMbid string
CandidateName string
SnoozedUntil pgtype.Timestamptz
CreatedAt pgtype.Timestamptz
}
type SystemPlaylistRotationState struct { type SystemPlaylistRotationState struct {
UserID pgtype.UUID UserID pgtype.UUID
PlaylistKind string PlaylistKind string
@@ -509,6 +562,21 @@ type SystemPlaylistRun struct {
LastError *string LastError *string
} }
type TagProviderSetting struct {
ProviderID string
Enabled bool
ApiKey *string
DisplayOrder int32
CreatedAt pgtype.Timestamptz
UpdatedAt pgtype.Timestamptz
}
type TagSourcesMetum struct {
ID bool
CurrentVersion int32
LastRegisteredProvidersHash string
}
type TasteProfileArtist struct { type TasteProfileArtist struct {
UserID pgtype.UUID UserID pgtype.UUID
ArtistID pgtype.UUID ArtistID pgtype.UUID
@@ -516,6 +584,20 @@ type TasteProfileArtist struct {
UpdatedAt pgtype.Timestamptz UpdatedAt pgtype.Timestamptz
} }
type TasteProfileEra struct {
UserID pgtype.UUID
Era string
Weight float64
UpdatedAt pgtype.Timestamptz
}
type TasteProfileMood struct {
UserID pgtype.UUID
Mood string
Weight float64
UpdatedAt pgtype.Timestamptz
}
type TasteProfileTag struct { type TasteProfileTag struct {
UserID pgtype.UUID UserID pgtype.UUID
Tag string Tag string
@@ -523,22 +605,36 @@ type TasteProfileTag struct {
UpdatedAt pgtype.Timestamptz UpdatedAt pgtype.Timestamptz
} }
type TasteTuning struct {
Singleton bool
HalfLifeDays float64
EngagementHardSkip float64
EngagementNeutral float64
EngagementFull float64
UpdatedAt pgtype.Timestamptz
EnrichedTagScale float64
EraScale float64
MoodScale float64
}
type Track struct { type Track struct {
ID pgtype.UUID ID pgtype.UUID
Title string Title string
AlbumID pgtype.UUID AlbumID pgtype.UUID
ArtistID pgtype.UUID ArtistID pgtype.UUID
TrackNumber *int32 TrackNumber *int32
DiscNumber *int32 DiscNumber *int32
DurationMs int32 DurationMs int32
FilePath string FilePath string
FileSize int64 FileSize int64
FileFormat string FileFormat string
Bitrate *int32 Bitrate *int32
Mbid *string Mbid *string
Genre *string Genre *string
AddedAt pgtype.Timestamptz AddedAt pgtype.Timestamptz
UpdatedAt pgtype.Timestamptz UpdatedAt pgtype.Timestamptz
TagSource *string
TagSourcesVersion int32
} }
type TrackSimilarity struct { type TrackSimilarity struct {
@@ -549,6 +645,12 @@ type TrackSimilarity struct {
FetchedAt pgtype.Timestamptz FetchedAt pgtype.Timestamptz
} }
type TrackTag struct {
TrackID pgtype.UUID
Tag string
Weight float64
}
type User struct { type User struct {
ID pgtype.UUID ID pgtype.UUID
Username string Username string
+13 -7
View File
@@ -12,7 +12,7 @@ import (
) )
const appendPlaylistTrack = `-- name: AppendPlaylistTrack :one const appendPlaylistTrack = `-- name: AppendPlaylistTrack :one
INSERT INTO playlist_tracks (playlist_id, position, track_id, title, artist_name, album_title, duration_sec) INSERT INTO playlist_tracks (playlist_id, position, track_id, title, artist_name, album_title, duration_sec, pick_kind)
SELECT SELECT
$1::uuid, $1::uuid,
COALESCE((SELECT MAX(position) + 1 FROM playlist_tracks WHERE playlist_id = $1::uuid), 0), COALESCE((SELECT MAX(position) + 1 FROM playlist_tracks WHERE playlist_id = $1::uuid), 0),
@@ -20,24 +20,27 @@ SELECT
t.title, t.title,
artists.name, artists.name,
albums.title, albums.title,
(t.duration_ms / 1000)::integer (t.duration_ms / 1000)::integer,
$2::text
FROM tracks t FROM tracks t
JOIN albums ON albums.id = t.album_id JOIN albums ON albums.id = t.album_id
JOIN artists ON artists.id = t.artist_id JOIN artists ON artists.id = t.artist_id
WHERE t.id = $2::uuid WHERE t.id = $3::uuid
RETURNING playlist_id, position, track_id, title, artist_name, album_title, duration_sec, added_at RETURNING playlist_id, position, track_id, title, artist_name, album_title, duration_sec, added_at, pick_kind
` `
type AppendPlaylistTrackParams struct { type AppendPlaylistTrackParams struct {
PlaylistID pgtype.UUID PlaylistID pgtype.UUID
PickKind *string
TrackID pgtype.UUID TrackID pgtype.UUID
} }
// Inserts at the next available position. Snapshot fields are copied // Inserts at the next available position. Snapshot fields are copied
// from the tracks/albums/artists join at insert time. tracks.duration_ms // from the tracks/albums/artists join at insert time. tracks.duration_ms
// is converted to seconds for the snapshot. // is converted to seconds for the snapshot. pick_kind is NULL for manual
// playlists; the For You builder stamps 'taste'/'fresh' (#1249).
func (q *Queries) AppendPlaylistTrack(ctx context.Context, arg AppendPlaylistTrackParams) (PlaylistTrack, error) { func (q *Queries) AppendPlaylistTrack(ctx context.Context, arg AppendPlaylistTrackParams) (PlaylistTrack, error) {
row := q.db.QueryRow(ctx, appendPlaylistTrack, arg.PlaylistID, arg.TrackID) row := q.db.QueryRow(ctx, appendPlaylistTrack, arg.PlaylistID, arg.PickKind, arg.TrackID)
var i PlaylistTrack var i PlaylistTrack
err := row.Scan( err := row.Scan(
&i.PlaylistID, &i.PlaylistID,
@@ -48,6 +51,7 @@ func (q *Queries) AppendPlaylistTrack(ctx context.Context, arg AppendPlaylistTra
&i.AlbumTitle, &i.AlbumTitle,
&i.DurationSec, &i.DurationSec,
&i.AddedAt, &i.AddedAt,
&i.PickKind,
) )
return i, err return i, err
} }
@@ -257,7 +261,7 @@ func (q *Queries) ListAllPlaylistTracksForCollage(ctx context.Context, arg ListA
} }
const listPlaylistTracks = `-- name: ListPlaylistTracks :many const listPlaylistTracks = `-- name: ListPlaylistTracks :many
SELECT pt.playlist_id, pt.position, pt.track_id, pt.title, pt.artist_name, pt.album_title, pt.duration_sec, pt.added_at, SELECT pt.playlist_id, pt.position, pt.track_id, pt.title, pt.artist_name, pt.album_title, pt.duration_sec, pt.added_at, pt.pick_kind,
t.id AS live_track_id, t.id AS live_track_id,
albums.id AS album_id, albums.id AS album_id,
artists.id AS artist_id artists.id AS artist_id
@@ -278,6 +282,7 @@ type ListPlaylistTracksRow struct {
AlbumTitle string AlbumTitle string
DurationSec int32 DurationSec int32
AddedAt pgtype.Timestamptz AddedAt pgtype.Timestamptz
PickKind *string
LiveTrackID pgtype.UUID LiveTrackID pgtype.UUID
AlbumID pgtype.UUID AlbumID pgtype.UUID
ArtistID pgtype.UUID ArtistID pgtype.UUID
@@ -305,6 +310,7 @@ func (q *Queries) ListPlaylistTracks(ctx context.Context, playlistID pgtype.UUID
&i.AlbumTitle, &i.AlbumTitle,
&i.DurationSec, &i.DurationSec,
&i.AddedAt, &i.AddedAt,
&i.PickKind,
&i.LiveTrackID, &i.LiveTrackID,
&i.AlbumID, &i.AlbumID,
&i.ArtistID, &i.ArtistID,
+216 -20
View File
@@ -11,6 +11,110 @@ import (
"github.com/jackc/pgx/v5/pgtype" "github.com/jackc/pgx/v5/pgtype"
) )
const getLatestPlayDeviceClassForUser = `-- name: GetLatestPlayDeviceClassForUser :one
SELECT device_class
FROM play_events
WHERE user_id = $1
ORDER BY started_at DESC
LIMIT 1
`
// The device_class of the user's most recent play (#1551), used as the
// "current device" for radio context conditioning. NULL when the latest play
// predates device capture or came from a client that didn't send one.
func (q *Queries) GetLatestPlayDeviceClassForUser(ctx context.Context, userID pgtype.UUID) (*string, error) {
row := q.db.QueryRow(ctx, getLatestPlayDeviceClassForUser, userID)
var device_class *string
err := row.Scan(&device_class)
return device_class, err
}
const listArtistContextPlayCountsForUser = `-- name: ListArtistContextPlayCountsForUser :many
WITH tz AS (
SELECT COALESCE(NULLIF(u.timezone, ''), 'UTC') AS zone
FROM users u WHERE u.id = $1
),
now_cell AS (
SELECT
CASE
WHEN EXTRACT(hour FROM now() AT TIME ZONE tz.zone) < 5 THEN 3
WHEN EXTRACT(hour FROM now() AT TIME ZONE tz.zone) < 12 THEN 0
WHEN EXTRACT(hour FROM now() AT TIME ZONE tz.zone) < 17 THEN 1
WHEN EXTRACT(hour FROM now() AT TIME ZONE tz.zone) < 22 THEN 2
ELSE 3
END AS daypart,
(EXTRACT(isodow FROM now() AT TIME ZONE tz.zone) >= 6) AS is_weekend
FROM tz
),
plays AS (
SELECT t.artist_id,
pe.device_class,
CASE
WHEN EXTRACT(hour FROM pe.started_at AT TIME ZONE tz.zone) < 5 THEN 3
WHEN EXTRACT(hour FROM pe.started_at AT TIME ZONE tz.zone) < 12 THEN 0
WHEN EXTRACT(hour FROM pe.started_at AT TIME ZONE tz.zone) < 17 THEN 1
WHEN EXTRACT(hour FROM pe.started_at AT TIME ZONE tz.zone) < 22 THEN 2
ELSE 3
END AS daypart,
(EXTRACT(isodow FROM pe.started_at AT TIME ZONE tz.zone) >= 6) AS is_weekend
FROM play_events pe
JOIN tracks t ON t.id = pe.track_id
CROSS JOIN tz
WHERE pe.user_id = $1
AND pe.was_skipped = false
AND pe.started_at > now() - interval '365 days'
)
SELECT p.artist_id,
count(*) AS total_plays,
count(*) FILTER (
WHERE p.daypart = (SELECT daypart FROM now_cell)
AND p.is_weekend = (SELECT is_weekend FROM now_cell)
AND ($2::text = '' OR p.device_class = $2::text)
) AS cell_plays
FROM plays p
GROUP BY p.artist_id
`
type ListArtistContextPlayCountsForUserParams struct {
ID pgtype.UUID
Column2 string
}
type ListArtistContextPlayCountsForUserRow struct {
ArtistID pgtype.UUID
TotalPlays int64
CellPlays int64
}
// Per-artist completed-play counts split by whether each play falls in the
// CURRENT context cell, in the user's local timezone (#1531). The cell is
// daypart × weekday-type, optionally narrowed by device class (#1551): when
// $2 (the current device) is ”, the device dimension is ignored (identical to
// the time-only #1531 behaviour, used by the daily mixes which have no device);
// when $2 is set (radio), a play only counts in the cell if its device_class
// matches. Feeds the context-affinity scoring term. Skips excluded; a 365-day
// window bounds cost. Daypart buckets: night [22,5) morning [5,12)
// afternoon [12,17) evening [17,22). Weekend = ISO days 67 (Sat/Sun).
func (q *Queries) ListArtistContextPlayCountsForUser(ctx context.Context, arg ListArtistContextPlayCountsForUserParams) ([]ListArtistContextPlayCountsForUserRow, error) {
rows, err := q.db.Query(ctx, listArtistContextPlayCountsForUser, arg.ID, arg.Column2)
if err != nil {
return nil, err
}
defer rows.Close()
var items []ListArtistContextPlayCountsForUserRow
for rows.Next() {
var i ListArtistContextPlayCountsForUserRow
if err := rows.Scan(&i.ArtistID, &i.TotalPlays, &i.CellPlays); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listLastPlayedArtistsForUser = `-- name: ListLastPlayedArtistsForUser :many const listLastPlayedArtistsForUser = `-- name: ListLastPlayedArtistsForUser :many
WITH user_plays AS ( WITH user_plays AS (
SELECT t.artist_id, max(pe.started_at) AS last_started SELECT t.artist_id, max(pe.started_at) AS last_started
@@ -104,7 +208,7 @@ WITH plays AS (
WHERE user_id = $2 AND was_skipped = false WHERE user_id = $2 AND was_skipped = false
GROUP BY track_id GROUP BY track_id
) )
SELECT t.id, t.title, t.album_id, t.artist_id, t.track_number, t.disc_number, t.duration_ms, t.file_path, t.file_size, t.file_format, t.bitrate, t.mbid, t.genre, t.added_at, t.updated_at, SELECT t.id, t.title, t.album_id, t.artist_id, t.track_number, t.disc_number, t.duration_ms, t.file_path, t.file_size, t.file_format, t.bitrate, t.mbid, t.genre, t.added_at, t.updated_at, t.tag_source, t.tag_sources_version,
albums.title AS album_title, albums.title AS album_title,
artists.name AS artist_name artists.name AS artist_name
FROM plays p FROM plays p
@@ -161,6 +265,8 @@ func (q *Queries) ListMostPlayedTracksForArtist(ctx context.Context, arg ListMos
&i.Track.Genre, &i.Track.Genre,
&i.Track.AddedAt, &i.Track.AddedAt,
&i.Track.UpdatedAt, &i.Track.UpdatedAt,
&i.Track.TagSource,
&i.Track.TagSourcesVersion,
&i.AlbumTitle, &i.AlbumTitle,
&i.ArtistName, &i.ArtistName,
); err != nil { ); err != nil {
@@ -181,7 +287,7 @@ WITH plays AS (
WHERE user_id = $1 AND was_skipped = false WHERE user_id = $1 AND was_skipped = false
GROUP BY track_id GROUP BY track_id
) )
SELECT t.id, t.title, t.album_id, t.artist_id, t.track_number, t.disc_number, t.duration_ms, t.file_path, t.file_size, t.file_format, t.bitrate, t.mbid, t.genre, t.added_at, t.updated_at, SELECT t.id, t.title, t.album_id, t.artist_id, t.track_number, t.disc_number, t.duration_ms, t.file_path, t.file_size, t.file_format, t.bitrate, t.mbid, t.genre, t.added_at, t.updated_at, t.tag_source, t.tag_sources_version,
albums.title AS album_title, albums.title AS album_title,
artists.name AS artist_name artists.name AS artist_name
FROM plays p FROM plays p
@@ -240,6 +346,8 @@ func (q *Queries) ListMostPlayedTracksForUser(ctx context.Context, arg ListMostP
&i.Track.Genre, &i.Track.Genre,
&i.Track.AddedAt, &i.Track.AddedAt,
&i.Track.UpdatedAt, &i.Track.UpdatedAt,
&i.Track.TagSource,
&i.Track.TagSourcesVersion,
&i.AlbumTitle, &i.AlbumTitle,
&i.ArtistName, &i.ArtistName,
); err != nil { ); err != nil {
@@ -577,12 +685,14 @@ func (q *Queries) ListRediscoverArtistsForUser(ctx context.Context, arg ListRedi
const loadRadioCandidates = `-- name: LoadRadioCandidates :many const loadRadioCandidates = `-- name: LoadRadioCandidates :many
SELECT SELECT
t.id, t.title, t.album_id, t.artist_id, t.track_number, t.disc_number, t.duration_ms, t.file_path, t.file_size, t.file_format, t.bitrate, t.mbid, t.genre, t.added_at, t.updated_at, t.id, t.title, t.album_id, t.artist_id, t.track_number, t.disc_number, t.duration_ms, t.file_path, t.file_size, t.file_format, t.bitrate, t.mbid, t.genre, t.added_at, t.updated_at, t.tag_source, t.tag_sources_version,
(l.user_id IS NOT NULL)::bool AS is_liked, (l.user_id IS NOT NULL)::bool AS is_liked,
pe.last_played_at::timestamptz AS last_played_at, pe.last_played_at::timestamptz AS last_played_at,
pe.play_count, pe.play_count,
pe.skip_count pe.skip_count,
al.release_date AS release_date
FROM tracks t FROM tracks t
JOIN albums al ON al.id = t.album_id
LEFT JOIN general_likes l ON l.user_id = $1 AND l.track_id = t.id LEFT JOIN general_likes l ON l.user_id = $1 AND l.track_id = t.id
LEFT JOIN LATERAL ( LEFT JOIN LATERAL (
SELECT SELECT
@@ -616,6 +726,7 @@ type LoadRadioCandidatesRow struct {
LastPlayedAt pgtype.Timestamptz LastPlayedAt pgtype.Timestamptz
PlayCount int64 PlayCount int64
SkipCount int64 SkipCount int64
ReleaseDate pgtype.Date
} }
// Returns all tracks except the seed and any played by the user within // Returns all tracks except the seed and any played by the user within
@@ -650,10 +761,13 @@ func (q *Queries) LoadRadioCandidates(ctx context.Context, arg LoadRadioCandidat
&i.Track.Genre, &i.Track.Genre,
&i.Track.AddedAt, &i.Track.AddedAt,
&i.Track.UpdatedAt, &i.Track.UpdatedAt,
&i.Track.TagSource,
&i.Track.TagSourcesVersion,
&i.IsLiked, &i.IsLiked,
&i.LastPlayedAt, &i.LastPlayedAt,
&i.PlayCount, &i.PlayCount,
&i.SkipCount, &i.SkipCount,
&i.ReleaseDate,
); err != nil { ); err != nil {
return nil, err return nil, err
} }
@@ -748,6 +862,22 @@ taste_overlap AS (
ORDER BY tpa.weight DESC, t.id ORDER BY tpa.weight DESC, t.id
LIMIT $10 LIMIT $10
), ),
coplay_artists AS (
-- Household co-play (#1533): tracks by artists co-played across the
-- instance with the seed's artist (source='user_cooccurrence', built by
-- the coplay worker). Mirrors similar_artists but from local co-occurrence
-- instead of ListenBrainz; empty on single-user servers. Same 0.5 damp as
-- similar_artists since it's artist-level.
SELECT t.id AS track_id, asim.score * 0.5 AS sim_score
FROM artist_similarity asim
JOIN tracks t ON t.artist_id = asim.artist_b_id
JOIN seed_artist sa ON asim.artist_a_id = sa.artist_id
WHERE asim.source = 'user_cooccurrence'
AND t.id NOT IN (SELECT id FROM excluded_ids)
AND t.id <> $2
ORDER BY asim.score DESC, random()
LIMIT $11
),
random_fill AS ( random_fill AS (
SELECT t.id AS track_id, 0.0::float8 AS sim_score SELECT t.id AS track_id, 0.0::float8 AS sim_score
FROM tracks t FROM tracks t
@@ -759,16 +889,18 @@ random_fill AS (
UNION SELECT track_id FROM tag_overlap UNION SELECT track_id FROM tag_overlap
UNION SELECT track_id FROM likes_overlap UNION SELECT track_id FROM likes_overlap
UNION SELECT track_id FROM taste_overlap UNION SELECT track_id FROM taste_overlap
UNION SELECT track_id FROM coplay_artists
) )
ORDER BY random() ORDER BY random()
LIMIT $9 LIMIT $9
) )
SELECT SELECT
t.id, t.title, t.album_id, t.artist_id, t.track_number, t.disc_number, t.duration_ms, t.file_path, t.file_size, t.file_format, t.bitrate, t.mbid, t.genre, t.added_at, t.updated_at, t.id, t.title, t.album_id, t.artist_id, t.track_number, t.disc_number, t.duration_ms, t.file_path, t.file_size, t.file_format, t.bitrate, t.mbid, t.genre, t.added_at, t.updated_at, t.tag_source, t.tag_sources_version,
(l.user_id IS NOT NULL)::bool AS is_liked, (l.user_id IS NOT NULL)::bool AS is_liked,
pe.last_played_at::timestamptz AS last_played_at, pe.last_played_at::timestamptz AS last_played_at,
pe.play_count, pe.play_count,
pe.skip_count, pe.skip_count,
al.release_date AS release_date,
COALESCE(max(u.sim_score), 0.0) AS similarity_score COALESCE(max(u.sim_score), 0.0) AS similarity_score
FROM ( FROM (
SELECT track_id, sim_score FROM lb_similar SELECT track_id, sim_score FROM lb_similar
@@ -776,9 +908,11 @@ FROM (
UNION ALL SELECT track_id, sim_score FROM tag_overlap UNION ALL SELECT track_id, sim_score FROM tag_overlap
UNION ALL SELECT track_id, sim_score FROM likes_overlap UNION ALL SELECT track_id, sim_score FROM likes_overlap
UNION ALL SELECT track_id, sim_score FROM taste_overlap UNION ALL SELECT track_id, sim_score FROM taste_overlap
UNION ALL SELECT track_id, sim_score FROM coplay_artists
UNION ALL SELECT track_id, sim_score FROM random_fill UNION ALL SELECT track_id, sim_score FROM random_fill
) u ) u
JOIN tracks t ON t.id = u.track_id JOIN tracks t ON t.id = u.track_id
JOIN albums al ON al.id = t.album_id
LEFT JOIN general_likes l ON l.user_id = $1 AND l.track_id = t.id LEFT JOIN general_likes l ON l.user_id = $1 AND l.track_id = t.id
LEFT JOIN LATERAL ( LEFT JOIN LATERAL (
SELECT max(started_at) AS last_played_at, SELECT max(started_at) AS last_played_at,
@@ -790,7 +924,8 @@ LEFT JOIN LATERAL (
GROUP BY t.id, t.title, t.album_id, t.artist_id, t.duration_ms, t.file_path, GROUP BY t.id, t.title, t.album_id, t.artist_id, t.duration_ms, t.file_path,
t.file_format, t.file_size, t.bitrate, t.track_number, t.disc_number, t.file_format, t.file_size, t.bitrate, t.track_number, t.disc_number,
t.mbid, t.genre, t.added_at, t.updated_at, t.mbid, t.genre, t.added_at, t.updated_at,
l.user_id, pe.last_played_at, pe.play_count, pe.skip_count l.user_id, pe.last_played_at, pe.play_count, pe.skip_count,
al.release_date
` `
type LoadRadioCandidatesV2Params struct { type LoadRadioCandidatesV2Params struct {
@@ -804,6 +939,7 @@ type LoadRadioCandidatesV2Params struct {
Limit_4 int32 Limit_4 int32
Limit_5 int32 Limit_5 int32
Limit_6 int32 Limit_6 int32
Limit_7 int32
} }
type LoadRadioCandidatesV2Row struct { type LoadRadioCandidatesV2Row struct {
@@ -812,6 +948,7 @@ type LoadRadioCandidatesV2Row struct {
LastPlayedAt pgtype.Timestamptz LastPlayedAt pgtype.Timestamptz
PlayCount int64 PlayCount int64
SkipCount int64 SkipCount int64
ReleaseDate pgtype.Date
SimilarityScore interface{} SimilarityScore interface{}
} }
@@ -823,7 +960,9 @@ type LoadRadioCandidatesV2Row struct {
// $10 taste_overlap K (#796 phase 2b — tracks by the user's top // $10 taste_overlap K (#796 phase 2b — tracks by the user's top
// positively-weighted taste-profile artists, so taste-relevant tracks // positively-weighted taste-profile artists, so taste-relevant tracks
// enter the pool even when the similarity/random arms miss them; scored // enter the pool even when the similarity/random arms miss them; scored
// in Go via TasteMatch, so sim_score here is 0 pool-inclusion). // in Go via TasteMatch, so sim_score here is 0 pool-inclusion),
// $11 coplay_artists K (#1533 — tracks by artists co-played across the
// instance with the seed's artist; source='user_cooccurrence').
// //
// Returns same shape as LoadRadioCandidates plus similarity_score column. // Returns same shape as LoadRadioCandidates plus similarity_score column.
func (q *Queries) LoadRadioCandidatesV2(ctx context.Context, arg LoadRadioCandidatesV2Params) ([]LoadRadioCandidatesV2Row, error) { func (q *Queries) LoadRadioCandidatesV2(ctx context.Context, arg LoadRadioCandidatesV2Params) ([]LoadRadioCandidatesV2Row, error) {
@@ -838,6 +977,7 @@ func (q *Queries) LoadRadioCandidatesV2(ctx context.Context, arg LoadRadioCandid
arg.Limit_4, arg.Limit_4,
arg.Limit_5, arg.Limit_5,
arg.Limit_6, arg.Limit_6,
arg.Limit_7,
) )
if err != nil { if err != nil {
return nil, err return nil, err
@@ -862,10 +1002,13 @@ func (q *Queries) LoadRadioCandidatesV2(ctx context.Context, arg LoadRadioCandid
&i.Track.Genre, &i.Track.Genre,
&i.Track.AddedAt, &i.Track.AddedAt,
&i.Track.UpdatedAt, &i.Track.UpdatedAt,
&i.Track.TagSource,
&i.Track.TagSourcesVersion,
&i.IsLiked, &i.IsLiked,
&i.LastPlayedAt, &i.LastPlayedAt,
&i.PlayCount, &i.PlayCount,
&i.SkipCount, &i.SkipCount,
&i.ReleaseDate,
&i.SimilarityScore, &i.SimilarityScore,
); err != nil { ); err != nil {
return nil, err return nil, err
@@ -879,20 +1022,50 @@ func (q *Queries) LoadRadioCandidatesV2(ctx context.Context, arg LoadRadioCandid
} }
const suggestArtistsForUser = `-- name: SuggestArtistsForUser :many 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, SELECT a.id AS artist_id,
5.0 * (CASE WHEN gla.artist_id IS NOT NULL THEN 1 ELSE 0 END) 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) + COALESCE(SUM(EXP(- EXTRACT(epoch FROM now() - pe.started_at) / ($2::float8 * 86400.0))), 0)
AS signal, AS raw_signal
(gla.artist_id IS NOT NULL) AS is_liked,
COUNT(pe.id)::bigint AS play_count
FROM artists a FROM artists a
LEFT JOIN general_likes_artists gla ON gla.artist_id = a.id AND gla.user_id = $1 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 tracks t ON t.artist_id = a.id
LEFT JOIN play_events pe ON pe.track_id = t.id AND pe.user_id = $1 LEFT JOIN play_events pe
WHERE gla.artist_id IS NOT NULL OR pe.id IS NOT NULL 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 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 ( contributions AS (
SELECT u.candidate_mbid, SELECT u.candidate_mbid,
u.candidate_name, u.candidate_name,
@@ -909,6 +1082,18 @@ contributions AS (
AND r.lidarr_artist_mbid = u.candidate_mbid AND r.lidarr_artist_mbid = u.candidate_mbid
AND r.status NOT IN ('rejected', 'failed') 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, SELECT candidate_mbid,
candidate_name, candidate_name,
@@ -939,13 +1124,24 @@ type SuggestArtistsForUserRow struct {
TopPlayCounts []int64 TopPlayCounts []int64
} }
// M5c: per-user artist suggestions ranked by signal x similarity. The // Per-user artist suggestions ranked by taste signal x similarity, projected
// seeds CTE collects the user's likes (x5) plus recency-decayed plays // through artist_similarity_unmatched (out-of-library candidates only).
// (exp(-age_days / $2)). The contributions CTE joins those seeds against //
// artist_similarity_unmatched and filters out candidates already in // Seeds are TIERED (rule #131) so the surface never empties:
// library or already in a non-terminal lidarr_request. The outer SELECT //
// aggregates per candidate, returning the top-3 contributing seeds for // tier 1 - taste_profile_artists.weight: engagement-graded, time-decayed and
// attribution. $1=user_id, $2=half_life_days, $3=limit. // 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) { func (q *Queries) SuggestArtistsForUser(ctx context.Context, arg SuggestArtistsForUserParams) ([]SuggestArtistsForUserRow, error) {
rows, err := q.db.Query(ctx, suggestArtistsForUser, arg.UserID, arg.Column2, arg.Limit) rows, err := q.db.Query(ctx, suggestArtistsForUser, arg.UserID, arg.Column2, arg.Limit)
if err != nil { if err != nil {
+91 -12
View File
@@ -12,19 +12,17 @@ import (
) )
const recommendationSourceMetricsForUser = `-- name: RecommendationSourceMetricsForUser :many const recommendationSourceMetricsForUser = `-- name: RecommendationSourceMetricsForUser :many
SELECT SELECT
pe.source, pe.source,
pe.pick_kind,
count(*)::bigint AS plays, count(*)::bigint AS plays,
count(*) FILTER (WHERE pe.was_skipped)::bigint AS skips, count(*) FILTER (WHERE pe.was_skipped)::bigint AS skips,
COALESCE( count(pe.completion_ratio)::bigint AS completion_n,
avg(pe.completion_ratio) FILTER (WHERE pe.completion_ratio IS NOT NULL), COALESCE(avg(pe.completion_ratio), 0)::float8 AS avg_completion
0)::float8 AS avg_completion
FROM play_events pe FROM play_events pe
WHERE pe.user_id = $1 WHERE pe.user_id = $1
AND pe.source IS NOT NULL
AND pe.started_at > now() - ($2::float8 * INTERVAL '1 day') AND pe.started_at > now() - ($2::float8 * INTERVAL '1 day')
GROUP BY pe.source GROUP BY pe.source, pe.pick_kind
ORDER BY plays DESC ORDER BY plays DESC
` `
@@ -35,18 +33,17 @@ type RecommendationSourceMetricsForUserParams struct {
type RecommendationSourceMetricsForUserRow struct { type RecommendationSourceMetricsForUserRow struct {
Source *string Source *string
PickKind *string
Plays int64 Plays int64
Skips int64 Skips int64
CompletionN int64
AvgCompletion float64 AvgCompletion float64
} }
// Recommendation observability (#796 phase 4). Per-source play outcomes so the
// operator can see whether each recommendation surface is landing and tune the
// taste weights. Source is stamped on play_events when a play is launched from
// a system-playlist surface ('for_you' | 'discover' | the discovery mixes);
// NULL for library / radio / user-playlist plays, which are excluded here.
// $1 user_id, $2 window_days. plays/skips are counts; avg_completion is the // $1 user_id, $2 window_days. plays/skips are counts; avg_completion is the
// mean completion ratio over plays that recorded one (0 when none did). // mean completion ratio over the completion_n plays that recorded one.
// pick_kind splits For You plays into taste/fresh/unattributed (#1249);
// it is NULL for every other source, so those still group to one row.
func (q *Queries) RecommendationSourceMetricsForUser(ctx context.Context, arg RecommendationSourceMetricsForUserParams) ([]RecommendationSourceMetricsForUserRow, error) { func (q *Queries) RecommendationSourceMetricsForUser(ctx context.Context, arg RecommendationSourceMetricsForUserParams) ([]RecommendationSourceMetricsForUserRow, error) {
rows, err := q.db.Query(ctx, recommendationSourceMetricsForUser, arg.UserID, arg.Column2) rows, err := q.db.Query(ctx, recommendationSourceMetricsForUser, arg.UserID, arg.Column2)
if err != nil { if err != nil {
@@ -58,8 +55,10 @@ func (q *Queries) RecommendationSourceMetricsForUser(ctx context.Context, arg Re
var i RecommendationSourceMetricsForUserRow var i RecommendationSourceMetricsForUserRow
if err := rows.Scan( if err := rows.Scan(
&i.Source, &i.Source,
&i.PickKind,
&i.Plays, &i.Plays,
&i.Skips, &i.Skips,
&i.CompletionN,
&i.AvgCompletion, &i.AvgCompletion,
); err != nil { ); err != nil {
return nil, err return nil, err
@@ -71,3 +70,83 @@ func (q *Queries) RecommendationSourceMetricsForUser(ctx context.Context, arg Re
} }
return items, nil return items, nil
} }
const recommendationWeeklyTrends = `-- name: RecommendationWeeklyTrends :many
SELECT
date_trunc('week', pe.started_at)::date AS week_start,
pe.source,
count(*)::bigint AS plays,
count(*) FILTER (WHERE pe.was_skipped)::bigint AS skips,
count(pe.completion_ratio)::bigint AS completion_n,
COALESCE(avg(pe.completion_ratio), 0)::float8 AS avg_completion,
count(*) FILTER (WHERE tpa.artist_id IS NOT NULL)::bigint AS taste_hits
FROM play_events pe
JOIN tracks t ON t.id = pe.track_id
LEFT JOIN taste_profile_artists tpa
ON tpa.user_id = pe.user_id
AND tpa.artist_id = t.artist_id
AND tpa.weight > 0
WHERE pe.started_at > now() - ($1::int * INTERVAL '1 week')
GROUP BY 1, 2
ORDER BY 1, 2
`
type RecommendationWeeklyTrendsRow struct {
WeekStart pgtype.Date
Source *string
Plays int64
Skips int64
CompletionN int64
AvgCompletion float64
TasteHits int64
}
// Recommendation observability (#796 phase 4). Per-source play outcomes so the
// operator can see whether each recommendation surface is landing and tune the
// taste weights. Source is stamped on play_events when a play is launched from
// a recommendation surface; NULL means the user picked the track manually —
// those rows are INCLUDED here as the baseline control group the surfaces are
// judged against (milestone #127: delta-vs-baseline is what makes the numbers
// actionable). Raw source strings are bucketed into stable surface families in
// the Go handler; completion_n is carried so family merges can weight
// avg_completion correctly.
// Weekly per-source outcome series for the tuning lab's trend view
// (#1251). Aggregated across ALL users: the tuning knobs are global,
// so judging a knob turn needs global outcomes — rows carry rates
// only, no track or user identity. NULL-source (manual) rows are
// included as the baseline family.
//
// taste_hits counts plays whose track's artist has a positive weight
// in that user's CURRENT taste profile — the "cheap recompute" option:
// retroactive over the whole window, at the cost of drift (the profile
// is today's, the play may be weeks old). Good enough to read whether
// a surface is feeding taste-fitting tracks.
// $1 window in weeks.
func (q *Queries) RecommendationWeeklyTrends(ctx context.Context, weeks int32) ([]RecommendationWeeklyTrendsRow, error) {
rows, err := q.db.Query(ctx, recommendationWeeklyTrends, weeks)
if err != nil {
return nil, err
}
defer rows.Close()
var items []RecommendationWeeklyTrendsRow
for rows.Next() {
var i RecommendationWeeklyTrendsRow
if err := rows.Scan(
&i.WeekStart,
&i.Source,
&i.Plays,
&i.Skips,
&i.CompletionN,
&i.AvgCompletion,
&i.TasteHits,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
@@ -0,0 +1,362 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
// source: recommendation_tuning.sql
package dbq
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
`
func (q *Queries) GetTasteTuning(ctx context.Context) (TasteTuning, error) {
row := q.db.QueryRow(ctx, getTasteTuning)
var i TasteTuning
err := row.Scan(
&i.Singleton,
&i.HalfLifeDays,
&i.EngagementHardSkip,
&i.EngagementNeutral,
&i.EngagementFull,
&i.UpdatedAt,
&i.EnrichedTagScale,
&i.EraScale,
&i.MoodScale,
)
return i, err
}
const insertTuningAudit = `-- name: InsertTuningAudit :exec
INSERT INTO recommendation_tuning_audit (scope, action, changes)
VALUES ($1, $2, $3)
`
type InsertTuningAuditParams struct {
Scope string
Action string
Changes []byte
}
// changes is a jsonb array of {field, old, new} objects.
func (q *Queries) InsertTuningAudit(ctx context.Context, arg InsertTuningAuditParams) error {
_, err := q.db.Exec(ctx, insertTuningAudit, arg.Scope, arg.Action, arg.Changes)
return err
}
const listTuningAudit = `-- name: ListTuningAudit :many
SELECT id, changed_at, scope, action, changes
FROM recommendation_tuning_audit
ORDER BY changed_at DESC, id DESC
LIMIT $1
`
// Newest first; consumed by the metrics trend view (#1251) to annotate
// knob turns on the timeline.
func (q *Queries) ListTuningAudit(ctx context.Context, limit int32) ([]RecommendationTuningAudit, error) {
rows, err := q.db.Query(ctx, listTuningAudit, limit)
if err != nil {
return nil, err
}
defer rows.Close()
var items []RecommendationTuningAudit
for rows.Next() {
var i RecommendationTuningAudit
if err := rows.Scan(
&i.ID,
&i.ChangedAt,
&i.Scope,
&i.Action,
&i.Changes,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listWeightProfiles = `-- name: ListWeightProfiles :many
SELECT profile, base_weight, like_boost, recency_weight, skip_penalty, jitter_magnitude, context_weight, similarity_weight, taste_weight, updated_at, context_time_weight FROM recommendation_weight_profiles ORDER BY profile
`
func (q *Queries) ListWeightProfiles(ctx context.Context) ([]RecommendationWeightProfile, error) {
rows, err := q.db.Query(ctx, listWeightProfiles)
if err != nil {
return nil, err
}
defer rows.Close()
var items []RecommendationWeightProfile
for rows.Next() {
var i RecommendationWeightProfile
if err := rows.Scan(
&i.Profile,
&i.BaseWeight,
&i.LikeBoost,
&i.RecencyWeight,
&i.SkipPenalty,
&i.JitterMagnitude,
&i.ContextWeight,
&i.SimilarityWeight,
&i.TasteWeight,
&i.UpdatedAt,
&i.ContextTimeWeight,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
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,
engagement_hard_skip = $2,
engagement_neutral = $3,
engagement_full = $4,
enriched_tag_scale = $5,
era_scale = $6,
mood_scale = $7,
updated_at = now()
WHERE singleton = true
RETURNING singleton, half_life_days, engagement_hard_skip, engagement_neutral, engagement_full, updated_at, enriched_tag_scale, era_scale, mood_scale
`
type UpdateTasteTuningParams struct {
HalfLifeDays float64
EngagementHardSkip float64
EngagementNeutral float64
EngagementFull float64
EnrichedTagScale float64
EraScale float64
MoodScale float64
}
func (q *Queries) UpdateTasteTuning(ctx context.Context, arg UpdateTasteTuningParams) (TasteTuning, error) {
row := q.db.QueryRow(ctx, updateTasteTuning,
arg.HalfLifeDays,
arg.EngagementHardSkip,
arg.EngagementNeutral,
arg.EngagementFull,
arg.EnrichedTagScale,
arg.EraScale,
arg.MoodScale,
)
var i TasteTuning
err := row.Scan(
&i.Singleton,
&i.HalfLifeDays,
&i.EngagementHardSkip,
&i.EngagementNeutral,
&i.EngagementFull,
&i.UpdatedAt,
&i.EnrichedTagScale,
&i.EraScale,
&i.MoodScale,
)
return i, err
}
const updateWeightProfile = `-- name: UpdateWeightProfile :one
UPDATE recommendation_weight_profiles
SET base_weight = $2,
like_boost = $3,
recency_weight = $4,
skip_penalty = $5,
jitter_magnitude = $6,
context_weight = $7,
similarity_weight = $8,
taste_weight = $9,
context_time_weight = $10,
updated_at = now()
WHERE profile = $1
RETURNING profile, base_weight, like_boost, recency_weight, skip_penalty, jitter_magnitude, context_weight, similarity_weight, taste_weight, updated_at, context_time_weight
`
type UpdateWeightProfileParams struct {
Profile string
BaseWeight float64
LikeBoost float64
RecencyWeight float64
SkipPenalty float64
JitterMagnitude float64
ContextWeight float64
SimilarityWeight float64
TasteWeight float64
ContextTimeWeight float64
}
func (q *Queries) UpdateWeightProfile(ctx context.Context, arg UpdateWeightProfileParams) (RecommendationWeightProfile, error) {
row := q.db.QueryRow(ctx, updateWeightProfile,
arg.Profile,
arg.BaseWeight,
arg.LikeBoost,
arg.RecencyWeight,
arg.SkipPenalty,
arg.JitterMagnitude,
arg.ContextWeight,
arg.SimilarityWeight,
arg.TasteWeight,
arg.ContextTimeWeight,
)
var i RecommendationWeightProfile
err := row.Scan(
&i.Profile,
&i.BaseWeight,
&i.LikeBoost,
&i.RecencyWeight,
&i.SkipPenalty,
&i.JitterMagnitude,
&i.ContextWeight,
&i.SimilarityWeight,
&i.TasteWeight,
&i.UpdatedAt,
&i.ContextTimeWeight,
)
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,
engagement_neutral, engagement_full, enriched_tag_scale, era_scale,
mood_scale
) VALUES (true, $1, $2, $3, $4, $5, $6, $7)
ON CONFLICT (singleton) DO NOTHING
`
type UpsertTasteTuningDefaultsParams struct {
HalfLifeDays float64
EngagementHardSkip float64
EngagementNeutral float64
EngagementFull float64
EnrichedTagScale float64
EraScale float64
MoodScale float64
}
func (q *Queries) UpsertTasteTuningDefaults(ctx context.Context, arg UpsertTasteTuningDefaultsParams) error {
_, err := q.db.Exec(ctx, upsertTasteTuningDefaults,
arg.HalfLifeDays,
arg.EngagementHardSkip,
arg.EngagementNeutral,
arg.EngagementFull,
arg.EnrichedTagScale,
arg.EraScale,
arg.MoodScale,
)
return err
}
const upsertWeightProfileDefaults = `-- name: UpsertWeightProfileDefaults :exec
INSERT INTO recommendation_weight_profiles (
profile, base_weight, like_boost, recency_weight, skip_penalty,
jitter_magnitude, context_weight, similarity_weight, taste_weight,
context_time_weight
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
ON CONFLICT (profile) DO NOTHING
`
type UpsertWeightProfileDefaultsParams struct {
Profile string
BaseWeight float64
LikeBoost float64
RecencyWeight float64
SkipPenalty float64
JitterMagnitude float64
ContextWeight float64
SimilarityWeight float64
TasteWeight float64
ContextTimeWeight float64
}
// Recommendation tuning lab queries (#1250). Seeding happens via the
// recsettings boot reconcile; shipped defaults live in Go only.
// Boot reconcile: insert the shipped defaults for a profile if the row
// doesn't exist yet. Never overwrites operator-tuned values.
func (q *Queries) UpsertWeightProfileDefaults(ctx context.Context, arg UpsertWeightProfileDefaultsParams) error {
_, err := q.db.Exec(ctx, upsertWeightProfileDefaults,
arg.Profile,
arg.BaseWeight,
arg.LikeBoost,
arg.RecencyWeight,
arg.SkipPenalty,
arg.JitterMagnitude,
arg.ContextWeight,
arg.SimilarityWeight,
arg.TasteWeight,
arg.ContextTimeWeight,
)
return err
}
+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
}

Some files were not shown because too many files have changed in this diff Show More