Compare commits

...
101 Commits
Author SHA1 Message Date
bvandeusen 1b7fa635d8 Merge pull request 'Silent self-update, active sessions with real client IPs, genre/year browsing, handoff fix' (#119) from dev into main
test-web / test (push) Successful in 1m3s
test-go / test (push) Successful in 1m13s
test-go / integration (push) Successful in 5m29s
android / Build + lint + test (push) Successful in 5m34s
release / Build signed APK (tag releases only) (push) Successful in 5m5s
release / Build + push container image (push) Successful in 16s
2026-08-05 15:14:48 -04:00
bvandeusen 78aa9befb6 fix(connectivity): probe on foreground; a burst can't corroborate ServerDown — #1209
android / Build + lint + test (push) Successful in 4m12s
Two changes so a network handoff stops making the app refuse to play music.

## Correction first: half of what I proposed already existed

I recommended "require corroboration before ServerDown, since Unstable is
non-gating." ReachabilityMachine has done exactly that since it was written —
onProbeFailure takes Reachable → Unstable, and escalates only on corroboration
or the 120s backstop. There is even a test named `single probe failure is
unstable not down`. I proposed building a thing that shipped months ago.

Reading the machine properly turned up the real gap, which is narrower and more
specific.

## 1. Probe when the app returns to the foreground

The genuine missing piece, and #1209's own note had it backwards: it listed
this as "already happens via link probe." It doesn't. `recheck()` had exactly
two callers — a button in VersionTooOldBanner and pull-to-refresh — and nothing
observed ProcessLifecycleOwner. The link probe fires on a connectivity
*change*, so an app backgrounded on stable Wi-Fi gets none.

That made a stale ServerDown outlive its cause: the poll loop's delay() is
throttled while screen-off/doze, so recovery waited for whenever the OS next
let the loop run. June's capture recovering at "EXACTLY 22:31:10 app_foreground"
was the throttled delay resuming, not a deliberate probe — same timestamp,
different mechanism, and that difference is the whole bug.

NetworkStatusController now implements DefaultLifecycleObserver and calls the
existing recheck() on ON_START. force = true, so it also bypasses
ARBITRATE_MIN_GAP_MS: a user opening the app is exactly when a stale banner and
a refused track are most visible, and it's once per foreground.

## 2. A burst of op failures no longer corroborates itself

The actual defect in the escalation path. Corroboration required 2 op failures
within 30s — but a link handoff fails every in-flight request at once, so a
burst is ONE event producing N failures, not N independent observations that
the server is gone. Two simultaneous failures walked straight to Unreachable.

onOpFailure now drops a failure landing within CORROBORATION_MIN_SPACING_MS
(3s) of the last recorded one. Above the sub-second window a handoff occupies,
low enough that a real outage still corroborates within seconds once anything
retries.

## Why this matters more than the task implied

#1209 called the follow-ups "cosmetic in the diagnostics". They aren't.
OfflineGatedDataSource.gateOnHealth() throws OfflineException on ServerDown
BEFORE touching the network, and TrackRow disables rows. So a spurious
ServerDown means the app declines to play uncached tracks that would play
fine — for a blip that already resolved. The note's "captured skips advanced
fine" was timing luck, not evidence the gate is harmless.

## Tests

`two op failures plus a failed probe escalate immediately` used timestamps
500ms apart, which the new rule treats as a burst — so I re-spaced it and
renamed it `two SPACED op failures...`. That's a deliberate reversal of an
encoded expectation, not a broken test being patched.

Also re-spaced `stale op failures do not corroborate` (used 0 and 1_000): left
alone it would still have passed, but for the wrong reason — burst-dropping
rather than staleness — and a test that can't fail for its stated reason is
worse than no test.

Added: a burst of four failures plus a failed probe stays Unstable, and a burst
that never recovers still escalates via the sustained backstop, so dropping
duplicates can't make a real outage undetectable.

The foreground hook itself is unverifiable in a JVM test (ProcessLifecycleOwner
needs the framework, and there's no instrumentation lane). Checked instead that
nothing constructs NetworkStatusController outside Hilt, so init's
ProcessLifecycleOwner.get() only runs on the main thread during
Application.onCreate — the same pattern LiveEventsDispatcher already uses.
2026-08-05 14:41:48 -04:00
bvandeusen a9ca49dc4e feat(library): genre + year quick-jumps on album and artist detail — #367
test-web / test (push) Successful in 44s
test-go / test (push) Successful in 1m1s
test-go / integration (push) Successful in 4m59s
Last bullet of #367. From an album you like, one click to everything else from
that year or in that genre.

Year was free — AlbumRef already carried it. Genre was not: AlbumDetail is
AlbumRef + tracks and neither carried genre, because genre lives on TRACKS. So
both detail responses gained a derived `genres` array, computed from the
entity's tracks rather than stored, since an album's tracks can legitimately
disagree about genre.

Split and trimmed identically to the browse index. That's the invariant this
whole task turned on: if the chip's matching diverged from the index's
splitting, a chip would lead to a page that doesn't contain the album you
clicked from.

No year link on artist detail. An artist spans many years, so a single one
would be a lie about the discography — genres only there.

Genre lookup failure is logged and degrades to no chips rather than failing the
request; a navigation nicety must not 404 a detail page that otherwise loaded.
`genres` is always an array at JSON, never null, matching how every other list
field in this package is emitted.

## Type widening, and the TypeScript version of a lesson from earlier today

Adding a required field to AlbumDetail/ArtistDetail breaks every typed fixture
that constructs one. Six of them across three test files. That's the same shape
as the Go signature changes that cost three CI rounds in #2453 — change a type,
then go find everything that builds it — so I searched for the constructions
before pushing instead of after. All six updated.

Tests: the encoded href for a slash-bearing genre ("Rock/Pop" →
?g=Rock%2FPop), the year href, and the no-tags case rendering no chips at all.
gofmt verified clean via docker rather than guessed.
2026-08-05 13:50:29 -04:00
bvandeusen feb1c2eca8 feat(web): genre and year browse pages — #367
test-web / test (push) Successful in 33s
Client half of #367. Two new Library tabs, each an index plus a drill-down.

Genres are ordered by track count rather than alphabetically. Raw ID3 carries a
long tail of one-off tags, so alphabetical would bury the handful of genres you
actually have a library's worth of. Years are grouped into decades — a flat
list of every year in a decades-deep library is a wall of numbers, and the
decade is how people actually think about it.

## Selection travels in the query string, not the path

`?g=Rock%2FPop`, not `/library/genres/Rock%2FPop`. A slash-bearing genre cannot
survive a path segment — the server sees two segments, and a hard reload
wouldn't reconstruct it through the SPA fallback either. There's a test pinning
the encoded href and another pinning that the DECODED value reaches the API.

## Why these two pages don't use svelte-query for their lists

The indexes do — fetched once per mount, so static options suffice and the
cache survives bouncing in and out of a drill-down.

The drill-down lists deliberately don't. Their selection comes from the URL and
changes WITHOUT remounting the page, and this codebase has no
reactive-query-options pattern anywhere; inventing one here would be a larger
change than the feature justifies, and one I can't exercise locally. So they
use $effect keyed on the derived selection with an explicit Load more.

The stale-response guard is a plain `let`, not $state, and that's load-bearing:
as reactive state, reading the token inside the fetch path would make the
effect depend on its own writes. Its job is to discard a late response for a
previously selected genre instead of painting it over the current one.

## Also

Added the year filter to /library/albums' contract but NOT to that page's UI —
its infinite scroll is a svelte-query infinite query, and making it react to a
filter is the same reactive-options problem. The dedicated pages cover the
capability, which is the shape the task offered as its alternative.

Library tab bar's comment claims it mirrors Android's LibraryScreen. These two
tabs have no Android equivalent, so I noted that inline rather than leaving the
claim quietly false. Parity remains an open call.

Not yet done from #367's bullet list: genre/year quick-jump links on album and
artist detail. Year is free (AlbumRef already carries it) but genre is exposed
nowhere client-side — AlbumDetail is AlbumRef + tracks, and neither carries
genre — so it needs a small API addition. Following as its own commit.
2026-08-05 13:41:51 -04:00
bvandeusen f8f2273aec style: gofmt alignment in library_browse_test — #367
test-go / test (push) Successful in 55s
test-go / integration (push) Successful in 4m58s
One space. `name:` had to align with `query:` inside a composite literal where
both sat on their own lines.

Found via `docker run golang:1.25-alpine gofmt -l`, which is the actual point
of this commit: gofmt is available here the same way sqlc is, and there is no
reason to have let CI discover a formatting nit. Whole tree verified clean, not
just this file.

The substance of 1126bfcf was already sound — verify-generate, vet and the full
integration suite passed, so the genre-splitting behaviour holds against a real
database. Only the formatter objected.
2026-08-05 13:29:28 -04:00
bvandeusen 1126bfcf78 feat(library): genre + year browse queries and endpoints — #367
test-go / test (push) Failing after 46s
test-go / integration (push) Successful in 5m0s
Server half of #367. Web UI follows.

Genres are exposed AS-IS per the operator: split on the delimiter, trimmed,
but no case folding and no synonym mapping. So "Rock" and "rock" appear as
separate rows, as does "Rock/Pop" alongside "Rock" and "Pop". The raw spread
has to be visible before anyone can judge whether it needs normalising, and
the alternative is a mapping table to invent and then maintain.

Trimming is not an exception to that. Splitting "Rock; Pop" yields " Pop", and
showing that as a genre distinct from "Pop" would be a bug in OUR splitting,
not fidelity to the operator's tags.

## The correctness trap this had to avoid

ListAlbumsByGenre compared tracks.genre verbatim, while recommendation.sql and
discover.sql have always split it on [;,]. Building the browse index by
splitting while matching exactly would have listed genres whose pages are
empty — every multi-genre track unreachable from either of its genres.

So ListAlbumsByGenre now splits too. That also fixes Subsonic
getAlbumList?type=byGenre, its only caller, which silently missed every
multi-genre track. Its Genre param went *string → string as a result.

EXISTS rather than JOIN + DISTINCT ON throughout: the lateral split emits one
row per (track, fragment), so a join multiplies rows per album and needs
DISTINCT to undo itself. EXISTS asks the question directly, and the count
query then matches the list query by construction rather than by coincidence.

## Genre is a query parameter, not a path segment

Because "Rock/Pop" is a real ID3 tag — the one the task itself cites — and a
slash cannot survive a path segment: Go normalises %2F and the router would
split the value in two. So filtering rides GET /api/library/albums?genre=,
which also reuses the existing paged album surface instead of adding a
parallel one.

Endpoints:

  GET /api/library/genres                          unpaged index + track counts
  GET /api/library/years                           unpaged index + album counts
  GET /api/library/albums?genre=                   filtered page
  GET /api/library/albums?year_from=&year_to=      filtered page, either edge open

The indexes are unpaged deliberately: a client needs the whole set to render a
browsable picker, and paging would let it show only a prefix of an ordering
the user didn't choose.

Two refusals rather than guesses: genre+year together is a 400 (the UI browses
them as separate axes, and quietly dropping half a filter would report a
narrower result than it returned), and an inverted year range is a 400 rather
than being silently swapped.

Undated albums are absent from the year axis rather than bucketed under 0 —
"unknown" is not a year, and a 0 row would sort to one end of a chronological
list looking like data.

Tests: parseYearFilter is pure and runs in the fast lane. The integration
tests assert the thing that would otherwise be silently broken — that a
"Rock;Pop" track is reachable from BOTH genres, that "Rock/Pop" survives as a
filter value, that fragment whitespace is trimmed, and that undated albums
stay out of every year range. Reused the existing seedAlbum/seedTrackWithGenre
fixtures, which already took exactly the year and genre arguments needed.
2026-08-05 13:22:30 -04:00
bvandeusen 5b36d79ff9 fix(server): access log reports the real client, not the proxy — #2453
test-go / test (push) Successful in 1m3s
test-go / integration (push) Successful in 5m9s
Closes the disagreement left open by #2453: requestlog.go logged raw
r.RemoteAddr while the Active-sessions surface resolved through the operator's
configured proxy depth. Behind a proxy — the normal deployment for anything
public — every access-log line carried the same useless proxy address, and the
two surfaces contradicted each other about who connected. Logs and UI
disagreeing is worse than either being wrong alone, because it costs you trust
in both.

`remote` now holds auth.ClientIP(r, hops). The attribute KEY is deliberately
unchanged so existing log greps keep working; only its accuracy improved.

Wiring note. The access log covers /healthz and the SPA, so it's registered
before the pool-bearing branch that used to build the settings service. Rather
than close over a variable reassigned later — which works, but leaves a
mutable-after-registration seam and an awkward question about races — I hoisted
netsettings.New above the router entirely. It already handles a nil pool by
returning a default-valued service, so no branch is needed and the accessor
stays a plain method value.

Applied the lesson from the last three CI failures BEFORE pushing this time: a
bare-identifier grep for `requestLog(` found three call sites in
requestlog_test.go that a qualified pattern could never have matched, since
the function is package-private and its tests are in-package. Also swept
netsettings.New and ClientIP the same way.

Tests: the behaviour change gets its own table — nil accessor and depth 0 log
the socket peer, depth 1 through a PUBLIC-addressed proxy logs the client
(the exact case the old heuristic got wrong forever), depth 2 reaches through
a CDN. Added `remote` to the required-keys assertion so the attribute can't
quietly disappear.
2026-08-05 13:02:36 -04:00
bvandeusen 11538095be fix(net): validate hop range before checking availability — #2453
test-go / test (push) Successful in 54s
test-go / integration (push) Successful in 5m1s
TestSetHops_RejectsOutOfRange caught a real ordering bug in code I wrote in
the same commit: the nil-pool guard sat ahead of the range check, so
SetHops(-1) on a service with no pool returned "network settings unavailable"
instead of ErrHopsOutOfRange.

Range first is correct, and the distinction is user-visible rather than
cosmetic: the argument is invalid regardless of whether the database is
reachable, and admin_network.go maps ErrHopsOutOfRange to 400 while anything
else becomes 500. The old order blamed the server for the caller's input.

Note this is the first failure in this sequence that wasn't a missed call
site — vet and golangci-lint both passed, and a test asserting a specific
sentinel error found it. Worth the extra assertion; `err != nil` would have
passed happily.
2026-08-05 10:27:10 -04:00
bvandeusen d5ab3b0764 fix(net): update the in-package Mount call site in library_test — #2453
test-go / test (push) Failing after 57s
test-go / integration (push) Failing after 4m57s
Third attempt at the same class of mistake, so worth naming precisely.

TestRoutesRegisteredInMount calls Mount() from INSIDE package api, so the
call reads `Mount(...)` unqualified. My verification grep was `api.Mount(`,
which cannot match it. Same shape as the previous failure, where I grepped
`auth.ClientIP(` and missed nothing — but only because those callers happened
to be in other packages.

The lesson generalises: after changing an exported signature, search for the
bare identifier, not the package-qualified form. In-package callers — which
in Go means most tests — are invisible to the qualified pattern.

This time I swept every signature I touched (Mount, RequireUser, ClientIP,
TouchSessionLastSeen) with an unqualified pattern before pushing, rather than
letting CI enumerate them one per run.

Passing h.netSettings (nil in test handlers) is deliberate, not a placeholder:
this test asserts route registration, and Hops() is nil-safe by design so the
middleware reads "trust nothing" rather than panicking.

Also gave netsettings' logger field a use — it was assigned and never read,
which staticcheck's unused pass can flag. A hop-count change alters how much
of a client-supplied header the server believes, so it earns a log line for
anyone later debugging odd addresses in the sessions list.
2026-08-05 10:21:16 -04:00
bvandeusen a07fb3867a fix(net): thread hops into session creation; disambiguate card tests — #2453
test-web / test (push) Successful in 42s
test-go / test (push) Failing after 43s
test-go / integration (push) Failing after 4m22s
Two CI failures from 381e9ced, both mine.

**Go (vet, which cascaded into the integration job).** Widening
auth.ClientIP to take a hop count, I updated the middleware that TOUCHES a
session but missed the two places that CREATE one — handleLogin and
handleRegister. So `created_ip`, the frozen origin address that the whole
"address changed" comparison rests on, was the one value still being
computed the old way. Both now read h.netSettings.Hops(), which is nil-safe
so test handlers constructed without the service still work.

Worth noting the shape of this miss: I checked call sites by searching for
the middleware's own usage and stopped there, rather than for every caller of
the function whose signature I changed. vet found it in seconds; a grep for
`auth.ClientIP(` would have too.

**Web (vitest).** Three tests waited on `findByText('198.51.100.7')`, which
matches TWO elements in the fixture — the detected client address and the
forwarded chain, identical strings for a single-proxy setup — and findByText
throws on multiple matches. Now they wait on the unique "Your address right
now" label and assert the address with getAllByText where duplication is
legitimate. The duplication is correct behaviour, so the test moved rather
than the component.
2026-08-05 10:14:38 -04:00
bvandeusen 381e9cedb7 feat(net): trusted-proxy depth so real client IPs survive a proxy — #2453
test-go / test (push) Failing after 50s
test-web / test (push) Failing after 50s
test-go / integration (push) Failing after 2m19s
Fixes the defect the operator spotted in #370 immediately after it shipped:
auth.ClientIP ignored X-Forwarded-For whenever RemoteAddr was public, so a
proxy on a public address — a separate host, or a CDN, i.e. anyone running
this publicly, since public means TLS means a proxy — recorded the PROXY for
every session. created_ip and last_ip were then always equal and the
"Address changed" signal could never fire. The feature looked like it worked
and reported nothing.

Replaced with the standard trusted-hop model (Rails, Caddy, Traefik, nginx).
XFF grows left-to-right as each proxy appends the peer it received from, so
for client -> CDN -> own-proxy -> app the app sees [client, CDN] with
RemoteAddr = own-proxy, and the client sits at XFF[len - hops]:

  0  RemoteAddr, XFF ignored — no proxy
  1  the address your own proxy observed
  2  through a CDN in front of your proxy

Default 1, per the operator: publicly reachable means a TLS terminator in
front.

The cost is real and stated rather than hidden. hops >= 1 DECLARES that a
proxy exists; set it with no proxy, or deeper than the actual chain, and the
index reaches attacker-supplied entries, letting a visitor choose which
address their own session shows — defeating exactly the detection #370 is
for. That's inherent to the model, which is why 0 is a first-class value and
the admin card says "count your proxies, don't guess high" instead of just
exposing a number. Both mis-set shapes are pinned by tests so they stay known
consequences rather than surprises.

Migration 0053 + internal/netsettings, cached under an RWMutex. That's not an
optimisation: ClientIP runs in RequireUser for every authenticated request, so
a per-request query would put the database on the critical path of the whole
API. New() always returns a usable service so a boot-time DB hiccup degrades
to the default instead of breaking that path (rule #131), and Hops() is
nil-safe because test routers construct middleware without it.

RequireUser now takes a func() int rather than an int — the value is
operator-editable at runtime while the middleware is built once at boot, and
reading it per request is what makes a save take effect with no restart
(rule #25).

The admin card is verifiable, not just configurable: it reports the address
the CURRENT setting resolves THIS request to, the raw forwarded chain, and the
socket peer — so you set the number, save, and confirm the address matches the
machine you're on. It also counts the arriving chain and says how many proxies
that implies. GET/PUT both return that payload, PUT recomputed under the new
value, so the effect is visible without a reload.

Also fixes styling in the #370 card that CI could not catch: text-destructive
and bg-destructive don't exist in this Tailwind config — the palette is
colors.action.destructive — so the "Address changed" warning and the
sign-out-others button were rendering unstyled. Both now use
text-action-destructive / bg-action-destructive / text-action-fg.

Not done here: requestlog.go still logs raw RemoteAddr and will disagree with
the sessions UI about who connected. Left for its own change.
2026-08-05 10:07:43 -04:00
bvandeusen bf649f3beb feat(web): active sessions card in Settings — #370
test-web / test (push) Successful in 32s
Client half of #370. Lists every device signed in to your account, with a
per-row sign-out and a "sign out all other devices" action.

The card does one thing the API alone doesn't: it says "Address changed" when
created_ip and last_ip differ, rather than printing two addresses and leaving
you to compare them. That mismatch — same device string, different origin —
is the shape of a stolen token, and it's the reason IP capture was worth a
migration. Making the operator spot it by eye would have wasted the data.

Placed with Password and API Token rather than at the bottom of the page:
those three are the account-security group, and this is the one that tells
you the other two need attention.

Details worth naming:

- The current session gets a "This device" badge and NO sign-out button —
  offering one would log you out of the page you're standing on. The server
  already excludes it from logout-others; this makes that visible.
- Sign-out-all-others is a two-step confirm and states the count, so the
  button can't be a surprise.
- A 404 on revoke reloads instead of erroring. It means the session is
  already gone — revoked elsewhere, or expired — so the list was simply
  stale and showing the truth is the right response. The code is
  `session_not_found`, not `not_found`: apierror.NotFound(what) prefixes it.
- Empty and error states both handled (rule #24); the empty case is
  practically unreachable since listing requires an authenticated request,
  and is handled rather than assumed.
- User-agent parsing is deliberately coarse. A real UA parser is a
  dependency and a maintenance burden for a string whose only job is "do you
  recognise this?" — the addresses carry the actual signal.

Tests cover the parts that would be quiet if broken: the current-session
badge suppressing its own sign-out button, the address-changed warning
appearing and NOT appearing, the two-step confirm not firing on first click,
and the load-failure retry.

Android parity is a separate decision, not assumed.
2026-08-05 09:25:20 -04:00
bvandeusen d86af7397d feat(auth): active sessions API with origin/current IP — #370
test-go / test (push) Successful in 55s
test-go / integration (push) Successful in 4m53s
Server half of the active-sessions surface. Web UI follows.

The operator wants this specifically to notice a compromised account, which
sets the bar: the addresses have to be trustworthy, or the feature is worse
than absent because it looks like evidence.

Migration 0052 adds created_ip + last_ip. Two columns, not one, and the pair
is the signal: a session issued at home and now being used from elsewhere is
the shape of a stolen token, and neither column alone can show that. Typed
text, matching the user_agent column beside it — these are displayed, never
queried by subnet, and inet round-trips through pgx as a netip.Prefix that
renders "1.2.3.4/32".

The rest of the schema was already waiting. Migration 0004 anticipated this
exactly: "last_seen_at enables an 'active sessions' UI later (not wired in
this plan) without schema churn." last_seen_at is live data — the auth
middleware already touches it per request — so last_ip rides that same
UPDATE for free.

Getting the address right is the substance here. Nothing extracted a client
IP anywhere before, and both obvious approaches are wrong:

- RemoteAddr alone shows the reverse proxy on every session, which is the
  normal self-hosted deployment. Noise shaped like data.
- Trusting X-Forwarded-For lets any client choose what its victim sees. A
  security surface an attacker can write to is worse than none.

So auth.ClientIP trusts the header only when the request actually arrived
from a proxy range. Public RemoteAddr means a direct connection, so XFF is
attacker-controlled and ignored outright. Private RemoteAddr means we walk
XFF right-to-left — proxies append, so the right end is what our own
infrastructure wrote — and take the first non-proxy address. A forged XFF
only prepends to the left end, which that walk never reaches. Unit-tested,
including both spoofing shapes.

Fails closed on a public-addressed proxy (separate host, CDN): we report the
proxy rather than trusting a forgeable header. Documented at the function.

Endpoints, all scoped by user_id per rule #47:

  GET    /api/me/sessions                → list, flagging the current row
  DELETE /api/me/sessions/{id}           → 204, or 404 if not yours
  POST   /api/me/sessions/logout-others  → {"revoked": n}

Keyed on session id alone, any household member could revoke another's
session by guessing a uuid, so the delete carries user_id in its WHERE and
:execrows distinguishes "not yours" (404) from a false 204. There's a test
that asserts the row actually survives, not merely that we returned 404.

The middleware now also puts the session id in context. logout-others is
defined by exclusion, and without knowing which session is ours the
safe-looking action deletes everything including the caller's — so it
refuses rather than guesses when the id is absent, and that refusal is
tested for non-deletion too.

audit_log.action is plain text with no CHECK, so the two new actions need no
migration (rule #36 checked, not assumed).

Codegen is real sqlc 1.31.1 via the container in `make generate` — docker is
present on this workstation even though Go and sqlc aren't — rather than the
hand-written .sql.go shortcut used in milestone #268.
2026-08-05 09:17:40 -04:00
bvandeusen 2e1a8a62d8 refactor(android): move cleartext opt-out into a networkSecurityConfig — #2439
android / Build + lint + test (push) Successful in 3m46s
`android:usesCleartextTraffic="true"` sat on <application> as a bare opt-out of
the platform's network-security default, with nothing recorded about why. It's
now a res/xml/network_security_config.xml carrying the same permission and the
reasoning behind it.

Behaviour is unchanged. networkSecurityConfig supersedes the attribute on API
24+ and our minSdk is 26, so the attribute is removed rather than kept
alongside.

Cleartext stays permitted because two independent things need it, and neither
can be narrowed to a domain list:

- The Minstrel server's host is user-entered at runtime, and plenty of
  self-hosters run plain HTTP on a LAN.
- UPnP/DLNA/Sonos — device-description and SOAP control URLs arrive in SSDP
  responses at runtime and are plain HTTP essentially always. This one wasn't in
  the original ticket, which only considered the server; it independently rules
  out the "tighten it later to RFC1918" idea, since <domain-config> matches
  literal hostnames, not CIDR ranges, and renderer IPs are unknowable ahead of
  time.

Trust anchors deliberately left at the platform default. Adding
<certificates src="user" /> would let self-hosters use HTTPS with a private CA —
which Mihon does, and which suits this product — but it also trusts every CA on
the device including a corporate MITM proxy. Raised separately rather than
assumed as a default.

tools:ignore="InsecureBaseConfiguration" mirrors Mihon's config and keeps
lintVitalRelease quiet about a choice that is deliberate and now documented.
2026-08-05 08:41:05 -04:00
bvandeusen 1bf0e388cb docs(readme): state scope and responsible use up front
Minstrel integrates with Lidarr, and that integration is the kind of thing a
reader can misread as content sourcing. It isn't, and the README never said so
explicitly. Now it does, before the Quickstart rather than buried at the bottom.

The section states what is simply true: Minstrel indexes files already on disk
and streams them; it ships no indexers, no trackers, no torrent/Usenet/NZB
client, and no DRM circumvention; the Lidarr integration is optional, inert
until an operator supplies a URL and API key, and points at an instance they
already run. Library contents and configured sources are the operator's
responsibility. Plus a non-affiliation line for Lidarr, ListenBrainz,
MusicBrainz and Subsonic.

Also made the Lidarr highlight explicit that the instance is yours and the
integration is off by default — the bullet previously read as though Minstrel
brought Lidarr with it.

Written as scope-setting rather than legalese, deliberately: a confident
description of what the software does is both more useful to a reader and
better evidence of intent than an anxious disclaimer would be.

Docs only — no workflow path filter matches README.md, so no CI lane runs.
2026-08-04 21:26:26 -04:00
bvandeusen a4b6f22d86 feat(update): silent self-update via PackageInstaller session — #2438
android / Build + lint + test (push) Successful in 3m54s
Replaces the ACTION_VIEW + application/vnd.android.package-archive handoff
with a PackageInstaller session, and declares
UPDATE_PACKAGES_WITHOUT_USER_ACTION so the update can land with no confirm
dialog at all.

The platform grants the silent path when the installer opts in via
setRequireUserAction(USER_ACTION_NOT_REQUIRED), the installed app targets
API 29+, the installer holds that permission, and the target is the
installer itself. Minstrel updating Minstrel satisfies all four. Where it
can't be granted — anything pre-S — the platform returns
STATUS_PENDING_USER_ACTION and we show its dialog instead, so this degrades
rather than failing.

Prior art: Mihon, which is out-of-store and self-updating and whose updates
are quiet for exactly this reason. It also confirmed REQUEST_INSTALL_PACKAGES
is not what draws install warnings — Mihon declares it too.

No setRequestUpdateOwnership(true), despite it reading like the obvious
declaration for a self-updater. Ownership can only be claimed on initial
installation (a no-op on update) and additionally wants the privileged
ENFORCE_UPDATE_OWNERSHIP permission. It's an API for app stores claiming the
apps they install.

Also: the install now has an outcome. The old path fired an intent and
assumed, so a failure and a user declining were indistinguishable. Sessions
report back, so InstallOutcome distinguishes Installed / Cancelled / Failed,
and cancelling returns to IDLE rather than showing an error — the user chose
it. DOWNLOADING and INSTALLING became separate stages because the install
half now genuinely waits, and "Downloading…" through a confirm dialog is a
lie.

The FileProvider and res/xml/file_paths.xml are gone. They existed only to
expose the cached APK as a content:// URI for the old intent; a session
takes a stream. Nothing else used that authority.

Two judgement calls worth naming:

- The pending-user-action intent is only launched if it resolves to a system
  component. Below API 34 a dynamically registered receiver can't declare
  itself unexported, so another app can broadcast at us, and an unchecked
  startActivity on an attacker-supplied extra would be an escalation
  primitive. The real confirm activity is a system app, so the check costs
  the legitimate path nothing.
- Cancellation unregisters the receiver but deliberately does NOT abandon the
  session. By then it's committed, and killing an install because the user
  navigated away from the banner misreads their intent.

Untestable here: no androidTest source set and no Robolectric, so the
gesture-level behaviour is operator on-device verification.
2026-08-04 16:19:24 -04:00
bvandeusen 57d2299180 Merge pull request 'Queue row gestures: album art as grab surface + swipe-to-remove' (#118) from dev into main
test-web / test (push) Successful in 48s
android / Build + lint + test (push) Successful in 5m15s
release / Build signed APK (tag releases only) (push) Successful in 4m38s
release / Build + push container image (push) Successful in 1m48s
2026-08-04 11:37:30 -04:00
bvandeusenandClaude Opus 5 8b630e71ca refactor(player): split the queue row out of QueueScreen.kt — #2435
android / Build + lint + test (push) Successful in 3m40s
detekt TooManyFunctions: the swipe work took the file to 12 functions
against a limit of 11. Suppressing it was the option; splitting is the
better one, because the seam was already there — the row carries two
gestures, a swipe background, and its own accessibility surface, which is
more behaviour than the screen that merely lists it.

QueueScreen.kt keeps the screen, list, pill, and summary (4). QueueRow.kt
takes the row and its helpers (8). No behaviour change: same code, same
order, per-file imports recomputed, QueueRow internal so QueueList can
still call it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N6vZoJ4Se5YyaqdtGVkap5
2026-08-04 10:52:14 -04:00
bvandeusenandClaude Opus 5 1910a5ce61 feat(player): swipe a queue row left to remove it — #2435
android / Build + lint + test (push) Failing after 1m25s
Replaces the trailing X button on the Android queue row, for the same
reason #2395 replaced the grip: horizontal space in the narrowest row in
the app. Web keeps its X — the operator's call, and the right one, since
the constraint being solved doesn't exist there.

SwipeToDismissBox with enableDismissFromStartToEnd = false; a right-swipe
means nothing here and would only delete tracks on a mis-aimed gesture.
The red fill under the row is oxblood (LocalActionColors.destructive), not
colorScheme.error — the design system keeps those apart because an error
is a failure that happened and a destructive action is one about to.

Adds a "Remove from queue" custom accessibility action. Both gestures the
row now relies on are touch-only, and each replaced a control TalkBack
could find, so without this the change would have quietly removed
remove-from-queue for anyone not using touch.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N6vZoJ4Se5YyaqdtGVkap5
2026-08-04 10:47:48 -04:00
bvandeusenandClaude Opus 5 a92a9f2198 fix(player): extract the reorder a11y actions to clear detekt LongMethod — #2395
android / Build + lint + test (push) Successful in 3m51s
QueueRow hit 61 statements against detekt's 60 — the semantics block I added
for the screen-reader move actions pushed it one over.

Extracted to a `Modifier.queueReorderActions` extension, which mirrors the
`queueReorderDrag` extension from the same change: the row now composes two
named modifiers, one for the gesture and one for the accessibility actions,
instead of carrying either inline. Better than suppressing the rule — the
suppression would have been permanent and the split reads better anyway.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 08:52:40 -04:00
bvandeusenandClaude Opus 5 6dea45a634 feat(player): album art is the queue's grab surface — #2395
test-web / test (push) Successful in 34s
android / Build + lint + test (push) Failing after 1m30s
The grip icon took a column out of every queue row, competing with the title
for space — worst on Android, where the row is narrowest and the icon plus
its 12dp gap cost roughly 36dp. Operator pre-approved dropping the icon and
making the album art the drag surface; that's what this does.

## Android: the gesture change is the load-bearing part

Moved the drag from the grip onto the thumbnail AND switched
detectDragGestures → detectDragGesturesAfterLongPress. That second half is
not cosmetic. The grip was a small target, so a plain drag detector on it
never competed with anything; a 48dp thumbnail is a large chunk of every
row, and with a plain detector any vertical pan starting on artwork would be
swallowed as a reorder instead of scrolling the queue. The list would have
felt broken exactly where it's easiest to touch. Long-press-then-drag
separates the three gestures: pan scrolls, long-press reorders, tap still
plays (the detector doesn't consume a plain tap, so it reaches the row's
clickable).

Dropping the grip also removed its contentDescription ("Reorder track"),
which was the ONLY thing telling a screen reader this list could be
reordered — and a long-press drag isn't operable with TalkBack regardless.
Added "Move up"/"Move down" custom accessibility actions on the row, the
Android counterpart to the web row's ArrowUp/ArrowDown. Without them this
change would have quietly removed reordering for anyone not using touch.

## Web: the grip was never the drag surface

`use:draggable` is on the row, not the handle, so dragging already worked
from anywhere — the grip's only unique jobs were being the visual cue and
the keyboard target. It now sits OVER the art, costing zero horizontal
space, and keeps both jobs.

Deliberately still VISIBLE at rest, just quiet, with the scrim appearing
only on hover/focus. Overlaying already solved the space complaint, so
hiding it buys nothing and would cost the only cue that the queue is
reorderable — on touch especially, which has no hover.

## Scope walked back

Also considered the web PlaylistTrackRow, which carries an identical grip.
Left alone: it has no album art, so the approved direction doesn't apply,
and its handle is already the smallest of the three at 14px. Forcing
consistency would have meant inventing a third treatment for a surface
nobody complained about. (Android has no playlist reorder at all — that
parity gap is pre-existing and out of scope here.)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 08:43:36 -04:00
bvandeusen fa7ea41ccf Merge pull request 'Minstrel gets a mark — favicon, header lockup, Android adaptive icon' (#117) from dev into main
test-web / test (push) Successful in 47s
android / Build + lint + test (push) Successful in 4m37s
release / Build signed APK (tag releases only) (push) Successful in 8m36s
release / Build + push container image (push) Successful in 1m37s
2026-08-03 20:52:27 -04:00
bvandeusenandClaude Opus 5 e1e591b520 feat(brand): Minstrel mark — favicon, header lockup, Android adaptive icon
test-web / test (push) Successful in 45s
android / Build + lint + test (push) Successful in 4m14s
A Didone M whose right leg is an eighth note: stem, flag and notehead in the
accent, the letter in parchment. Traced from the operator's reference at
99.74% IoU (potrace, 26 + 22 segments), so the geometry is theirs, not an
approximation of it.

Subject-neutral on purpose. "Minstrel" pulls toward a lute or a bard, which
would tell a new user this is a renaissance-faire player rather than one for
all music. A geometric letter plus universal notation says "music" without
saying which music. The family look arrives through palette and drawing
style instead of through the subject — see the design-system discussion.

Starting state: web/static/favicon.png was a 1x1 PIXEL placeholder, so there
was effectively no favicon at all; Android had legacy bitmaps only, so modern
launchers letterboxed the square instead of masking it.

## The colour problem, and why each surface differs

Parchment on white is invisible — the operator caught this. The M therefore
has to flip with its background, while the accent note holds in both:

  - mark.svg / MinstrelMark.svelte use currentColor, so the letter takes the
    surrounding text colour and one asset covers both palettes.
  - favicon.svg bakes colours with a prefers-color-scheme swap, because a
    favicon sits on browser chrome and has no cascade to inherit from.
  - PNG fallback, apple-touch-icon and Android are PLATED. A PNG can't
    respond to scheme and iOS composites onto white regardless.

MinstrelMark is inlined rather than <img src>, because an <img> cannot
inherit currentColor and inheriting it is the entire point.

## Plate colour chosen by measurement

Obsidian (#14171A), not the raised-surface iron. The accent note only clears
the 3:1 non-text contrast threshold against the darker value: 3.04:1 vs iron's
2.70:1. My own earlier suggestion — lighten the plate — is WRONG and the
numbers say so: slate scores 2.21:1, worse, because the note is a dark colour
and lifting the plate closes the gap. Recorded in colors.xml so the reasoning
sits with the value.

## Construction

Traced as a full ink silhouette with the note painted OVER it, rather than as
two separate shapes. Separate shapes needed either a 2px seam where letter and
note touch, or an anti-aliasing fringe (2,430 misclassified pixels) around the
note. Painting over avoids both and yields a monochrome version for free — the
base layer alone is the whole mark in one colour, which is what
mipmap-anydpi-v26's <monochrome> uses for themed icons.

Android foreground sits at 61% of the 108dp canvas so it stays inside the
66dp safe zone and no launcher mask can clip it.

Paths are duplicated between the component and the two static SVGs, since one
needs currentColor and the others need literals. A comment in each names the
others.

Verified by render at 16/20/32/64/180 on obsidian, white, parchment and
plated; one optical size holds across the whole range.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 16:37:25 -04:00
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
291 changed files with 23855 additions and 1403 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 ./...
+13 -1
View File
@@ -11,10 +11,22 @@ A self-hosted music server that thinks for you. Smart shuffle, contextual likes,
- **OpenSubsonic-compatible.** Existing Subsonic clients (DSub, Symfonium, play:Sub, etc.) connect with no special configuration. - **OpenSubsonic-compatible.** Existing Subsonic clients (DSub, Symfonium, play:Sub, etc.) connect with no special configuration.
- **Server-side smart shuffle.** Track-similarity vectors, dual-like model (general + contextual), and session memory keep mixes coherent across devices. - **Server-side smart shuffle.** Track-similarity vectors, dual-like model (general + contextual), and session memory keep mixes coherent across devices.
- **ListenBrainz radio.** Session-aware "more like this" pulls from ListenBrainz similarity data, not a static genre tag. - **ListenBrainz radio.** Session-aware "more like this" pulls from ListenBrainz similarity data, not a static genre tag.
- **Lidarr integration.** Triggered scans, request-driven album imports, and a quarantine flow when something doesn't fit. - **Lidarr integration.** Triggered scans, request-driven album imports, and a quarantine flow when something doesn't fit — against a Lidarr instance *you* run and configure. Optional, and off until you supply a URL and API key.
- **Built-in web SPA.** Full-feature library, search, queue, playlists, and admin — no separate frontend container to deploy. - **Built-in web SPA.** Full-feature library, search, queue, playlists, and admin — no separate frontend container to deploy.
- **Native Android client, shipped with the server.** The signed APK is bundled into every image and attached to each [release](https://git.fabledsword.com/bvandeusen/minstrel/releases) — sideload it once, then the app self-updates straight from your own server (no app store, no separate download to track). - **Native Android client, shipped with the server.** The signed APK is bundled into every image and attached to each [release](https://git.fabledsword.com/bvandeusen/minstrel/releases) — sideload it once, then the app self-updates straight from your own server (no app store, no separate download to track).
## Scope and responsible use
**Minstrel serves music you already have.** It is a library server: it indexes files on disk you point it at, and streams them to your own clients. It does not source, search for, or acquire content, and it has no opinion about where your files came from.
Concretely, Minstrel ships **no** indexers, **no** trackers, **no** torrent / Usenet / NZB client, and **no** DRM circumvention of any kind. There is nothing to point at a content source because Minstrel has no such subsystem.
The **Lidarr integration is optional and inert until you configure it.** You supply the URL and API key of a Lidarr instance you are already running; Minstrel then calls that instance's API to trigger scans, submit album requests, and reconcile imports. Minstrel neither bundles nor installs Lidarr, and configures no indexers on your behalf — Lidarr ships with none either, and any it uses are ones you added yourself.
**What you put in your library, and what sources you configure in your own Lidarr, are your responsibility.** Copyright law applies to your collection the same way it applies to any other software that plays a file. Please respect it, and respect the terms of any service you connect.
Minstrel is not affiliated with or endorsed by Lidarr, ListenBrainz, MusicBrainz, or Subsonic.
## Quickstart ## Quickstart
```yaml ```yaml
+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
}
}
+15 -10
View File
@@ -8,7 +8,16 @@
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" /> <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<!-- In-app self-update. REQUEST_INSTALL_PACKAGES lets us hand an APK to the
platform installer at all; UPDATE_PACKAGES_WITHOUT_USER_ACTION (API 31+)
is what lets that install happen with NO confirm dialog. The platform
grants the silent path only when the installer opts in via
SessionParams.setRequireUserAction(USER_ACTION_NOT_REQUIRED), the
installed app targets API 29+, the installer holds this permission, and
the target is the installer itself — all true here, since Minstrel is
updating Minstrel. See update/data/SelfUpdateSession.kt. -->
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" /> <uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<uses-permission android:name="android.permission.UPDATE_PACKAGES_WITHOUT_USER_ACTION" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" /> <uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<uses-permission android:name="android.permission.CHANGE_WIFI_MULTICAST_STATE" /> <uses-permission android:name="android.permission.CHANGE_WIFI_MULTICAST_STATE" />
@@ -19,9 +28,9 @@
android:fullBackupContent="@xml/backup_rules" android:fullBackupContent="@xml/backup_rules"
android:icon="@mipmap/ic_launcher" android:icon="@mipmap/ic_launcher"
android:label="@string/app_name" android:label="@string/app_name"
android:networkSecurityConfig="@xml/network_security_config"
android:supportsRtl="true" android:supportsRtl="true"
android:theme="@style/Theme.Minstrel" android:theme="@style/Theme.Minstrel"
android:usesCleartextTraffic="true"
tools:targetApi="34"> tools:targetApi="34">
<!-- Portrait-locked until a tablet/landscape layout exists. <!-- Portrait-locked until a tablet/landscape layout exists.
@@ -48,15 +57,11 @@
</intent-filter> </intent-filter>
</service> </service>
<provider <!-- The FileProvider that used to live here existed solely to expose the
android:name="androidx.core.content.FileProvider" downloaded update APK as a content:// URI for the old ACTION_VIEW
android:authorities="${applicationId}.fileprovider" install intent. A PackageInstaller session takes a stream instead,
android:exported="false" so both the provider and res/xml/file_paths.xml are gone — nothing
android:grantUriPermissions="true"> else in the app ever used that authority. -->
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
<!-- On-demand WorkManager initialization: MinstrelApplication <!-- On-demand WorkManager initialization: MinstrelApplication
implements Configuration.Provider and supplies the implements Configuration.Provider and supplies the
@@ -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)
@@ -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) },
)
}
} }
} }
@@ -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 },
)
}
}
@@ -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,6 +1,9 @@
package com.fabledsword.minstrel.connectivity package com.fabledsword.minstrel.connectivity
import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.runtime.staticCompositionLocalOf
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.ProcessLifecycleOwner
import com.fabledsword.minstrel.BuildConfig import com.fabledsword.minstrel.BuildConfig
import com.fabledsword.minstrel.auth.AuthStore import com.fabledsword.minstrel.auth.AuthStore
import com.fabledsword.minstrel.di.ApplicationScope import com.fabledsword.minstrel.di.ApplicationScope
@@ -41,6 +44,12 @@ private const val ARBITRATE_MIN_GAP_MS = 2_000L
* - reportSuccess / reportFailure from the API interceptor, the audio data * - reportSuccess / reportFailure from the API interceptor, the audio data
* source, and the playback-error reporter. * source, and the playback-error reporter.
* - recheck() from pull-to-refresh and the banner. * - recheck() from pull-to-refresh and the banner.
* - a forced probe when the app returns to the foreground (#1209). Without
* it a stale ServerDown outlived the condition that caused it: the poll
* loop's delay() is throttled while screen-off/doze, so recovery waited on
* whenever the OS next let the loop run. Meanwhile ServerDown makes
* OfflineGatedDataSource refuse every uncached track, so the app declined
* to play music that would have played fine.
* *
* Version compatibility is a byproduct of the same /healthz response. * Version compatibility is a byproduct of the same /healthz response.
* *
@@ -53,7 +62,7 @@ class NetworkStatusController @Inject constructor(
connectivity: ConnectivityObserver, connectivity: ConnectivityObserver,
private val authStore: AuthStore, private val authStore: AuthStore,
retrofit: Retrofit, retrofit: Retrofit,
) { ) : DefaultLifecycleObserver {
private val api: HealthzApi = retrofit.create(HealthzApi::class.java) private val api: HealthzApi = retrofit.create(HealthzApi::class.java)
private val machine = ReachabilityMachine() private val machine = ReachabilityMachine()
private val lastProbeAtMs = AtomicLong(0) private val lastProbeAtMs = AtomicLong(0)
@@ -74,6 +83,7 @@ class NetworkStatusController @Inject constructor(
private val intents = Channel<Intent>(Channel.UNLIMITED) private val intents = Channel<Intent>(Channel.UNLIMITED)
init { init {
ProcessLifecycleOwner.get().lifecycle.addObserver(this)
scope.launch { reduceLoop() } scope.launch { reduceLoop() }
scope.launch { scope.launch {
connectivity.online.collect { up -> connectivity.online.collect { up ->
@@ -100,6 +110,20 @@ class NetworkStatusController @Inject constructor(
scope.launch { probeOnce(force = true) } scope.launch { probeOnce(force = true) }
} }
/**
* App returned to the foreground — probe now rather than waiting for the
* poll loop (#1209).
*
* The link-return probe in `init` does NOT cover this: it fires on a
* connectivity *change*, and an app backgrounded on stable Wi-Fi sees none.
* force = true so this also bypasses the ARBITRATE_MIN_GAP_MS throttle —
* a user bringing the app up is exactly when a stale banner and a refused
* track are most visible, and it's a once-per-foreground cost.
*/
override fun onStart(owner: LifecycleOwner) {
recheck()
}
private suspend fun reduceLoop() { private suspend fun reduceLoop() {
for (intent in intents) { for (intent in intents) {
val now = System.currentTimeMillis() val now = System.currentTimeMillis()
@@ -4,6 +4,24 @@ internal const val ESCALATE_AFTER_MS = 120_000L
internal const val CORROBORATION_WINDOW_MS = 30_000L internal const val CORROBORATION_WINDOW_MS = 30_000L
internal const val CORROBORATION_OP_THRESHOLD = 2 internal const val CORROBORATION_OP_THRESHOLD = 2
/**
* Minimum gap between op failures for them to count as SEPARATE evidence
* (#1209).
*
* A link handoff fails every in-flight request at once, so a burst is one
* event producing N failures — not N independent observations that the server
* is gone. Without this, two simultaneous failures corroborated each other
* straight to Unreachable, and ServerDown makes OfflineGatedDataSource refuse
* every uncached track. The app declined to play music that would have played
* fine, for a blip that had already resolved.
*
* 3s is comfortably above the sub-second window an OS handoff occupies while
* still letting a genuine outage corroborate within seconds once a client
* retries. The sustained-time backstop covers the case where nothing retries
* at all — and if nothing is asking, a late ServerDown costs nothing.
*/
internal const val CORROBORATION_MIN_SPACING_MS = 3_000L
/** /**
* Pure reachability state machine. No Android, no coroutines, no real clock — * Pure reachability state machine. No Android, no coroutines, no real clock —
* every entry point takes `nowMs`, so it is fully deterministic and unit- * every entry point takes `nowMs`, so it is fully deterministic and unit-
@@ -46,9 +64,17 @@ class ReachabilityMachine {
recentOpFailures.clear() recentOpFailures.clear()
} }
/** A real network op failed. Ambiguous on its own — records corroboration. */ /**
* A real network op failed. Ambiguous on its own — records corroboration.
*
* Failures arriving within [CORROBORATION_MIN_SPACING_MS] of the last
* recorded one are dropped rather than stacked: see that constant for why
* a burst must not corroborate itself.
*/
fun onOpFailure(nowMs: Long) { fun onOpFailure(nowMs: Long) {
pruneOpFailures(nowMs) pruneOpFailures(nowMs)
val last = recentOpFailures.lastOrNull()
if (last != null && nowMs - last < CORROBORATION_MIN_SPACING_MS) return
recentOpFailures.addLast(nowMs) recentOpFailures.addLast(nowMs)
} }
@@ -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,6 +40,7 @@ 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.ErrorRetry
import com.fabledsword.minstrel.shared.widgets.LoadingCentered import com.fabledsword.minstrel.shared.widgets.LoadingCentered
@@ -104,6 +105,18 @@ 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)
@@ -178,10 +191,23 @@ private fun KindChips(kind: LidarrRequestKind, onChange: (LidarrRequestKind) ->
} }
} }
/**
* The snooze surface's data and callbacks, bundled rather than threaded
* through as four more parameters — the pane grew from one action to three
* with slice 4 and the signatures stopped being readable.
*/
private data class SnoozeUi(
val locallySnoozedMbids: Set<String>,
val snoozes: List<SuggestionSnoozeRef>,
val onSnooze: (ArtistSuggestionRef) -> Unit,
val onUnsnooze: (String, String) -> Unit,
)
@Composable @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, onRetry: () -> Unit,
) { ) {
@@ -194,6 +220,7 @@ private fun SuggestionsPane(
) )
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,
) )
} }
@@ -202,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(
@@ -210,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") }
} }
} }
@@ -10,6 +10,7 @@ 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
@@ -27,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 {
@@ -96,6 +108,47 @@ class DiscoverViewModel @Inject constructor(
) )
} }
} }
// Refresh the parked list alongside the deck: a snooze made on another
// client should show up here, and one whose window lapsed should drop
// off. Sequenced after the deck load rather than raced with it so the
// two panes can't disagree about a candidate mid-refresh.
loadSnoozes()
}
/**
* Loads the parked list. Failure is deliberately silent: this is a
* secondary pane, and an error banner for it would sit above the suggestion
* feed the user actually came for. The list stays as-is and the next
* refresh retries.
*/
private suspend fun loadSnoozes() {
try {
val rows = repository.listSnoozes()
internal.update { it.copy(snoozes = rows) }
} catch (
@Suppress("TooGenericExceptionCaught", "SwallowedException") e: Throwable,
) {
// Keep whatever we last showed rather than blanking the section.
}
}
/**
* Parks a suggestion. Flips the row locally first so the tap registers
* immediately; the repository handles the offline case, so there is no
* failure branch to revert here — unlike the web client, where the fetch
* either lands or doesn't.
*/
suspend fun snoozeSuggestion(s: ArtistSuggestionRef) {
internal.update { it.copy(locallySnoozedMbids = it.locallySnoozedMbids + s.mbid) }
repository.snoozeSuggestion(s.mbid, s.name)
loadSnoozes()
}
/** Brings a parked suggestion back, from either the card or the list. */
suspend fun unsnoozeSuggestion(mbid: String, name: String) {
internal.update { it.copy(locallySnoozedMbids = it.locallySnoozedMbids - mbid) }
repository.unsnoozeSuggestion(mbid, name)
loadSnoozes()
} }
fun runSearch() { fun runSearch() {
@@ -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
@@ -76,29 +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.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
@@ -110,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(
@@ -164,10 +199,13 @@ 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()
/** /**
* Copy for the most recent /home/index refresh failure; null once a * Copy for the most recent /home/index refresh failure; null once a
@@ -177,24 +215,13 @@ class HomeViewModel @Inject constructor(
*/ */
private val refreshError = MutableStateFlow<String?>(null) private val refreshError = MutableStateFlow<String?>(null)
init { /**
refresh() * Cover-art loads in flight on Home, reported by every [ServerImage]
// Screen-level auto-recovery (issue #1245): a Home that failed to * under [LocalArtSettleTracker]. The veil waits on this so artwork
// load while the server was unreachable re-pulls itself the moment * arriving a beat after its tile lands behind the veil rather than
// health returns — same idiom as SyncController, one layer up. * popping in on screen.
viewModelScope.launch { */
networkStatus.recoveries().collect { refresh() } val artTracker = ArtSettleTracker()
}
// #968: the daily 03:00 rebuild (and manual refresh) emit
// playlist.system_rebuilt; re-pull Home so the system-playlist tiles
// and You-might-like rows reflect the new snapshot without a manual
// reload. Mirrors the web SSE consumer.
viewModelScope.launch {
eventsStream.events
.filter { it.kind == "playlist.system_rebuilt" }
.collect { refresh() }
}
}
/** /**
* Tap an offline pool: shuffle + play its cached tracks. Empty * Tap an offline pool: shuffle + play its cached tracks. Empty
@@ -207,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}")
} }
@@ -244,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)}",
) )
} }
@@ -269,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)}",
) )
} }
@@ -294,33 +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 {
refreshError.value = null // /home/index is the load-bearing pull: its failure drives the
val home = launch { // empty-cache Error state. A failure over a populated cache
// /home/index is the load-bearing pull: its failure drives the // stays silent — cached sections beat a full-screen error.
// 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() } runCatching { homeRepository.refreshIndex() }
.onSuccess { refreshError.value = null }
.onFailure { refreshError.value = ErrorCopy.fromThrowable(it) } .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>> =
@@ -371,6 +432,124 @@ class HomeViewModel @Inject constructor(
else -> UiState.Empty 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 ──────────────────────────────────────────────────────────
@@ -398,47 +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 -> ErrorRetry(
title = "Couldn't load home",
message = s.message,
onRetry = { viewModel.refresh() },
)
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,
)
} }
} }
} }
@@ -508,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,
@@ -519,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,
@@ -542,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,
@@ -811,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(
@@ -868,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
@@ -899,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,
@@ -911,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) }
} }
@@ -924,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 }
} }
@@ -948,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
@@ -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
@@ -0,0 +1,348 @@
package com.fabledsword.minstrel.player.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SwipeToDismissBox
import androidx.compose.material3.SwipeToDismissBoxValue
import androidx.compose.material3.Text
import androidx.compose.material3.rememberSwipeToDismissBoxState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.composed
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.semantics.CustomAccessibilityAction
import androidx.compose.ui.semantics.customActions
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.zIndex
import 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.fabledsword.minstrel.models.TrackRef
import com.fabledsword.minstrel.shared.formatDuration
import com.fabledsword.minstrel.shared.widgets.LikeButton
import com.fabledsword.minstrel.shared.widgets.ServerImage
import com.fabledsword.minstrel.theme.LocalActionColors
import kotlin.math.roundToInt
/*
* A single queue row, split out of QueueScreen.kt when swipe-to-remove (#2435)
* pushed that file past detekt's TooManyFunctions limit. The seam is real and
* not just a way to satisfy the analyzer: the row now carries two gestures, a
* swipe background, and its own accessibility surface, which is more behaviour
* than the screen that lists it. `internal` rather than `private` only because
* QueueList (still in QueueScreen.kt) is the caller.
*/
@Suppress("LongParameterList") // Compose row wiring — layout + queue callbacks, not logic.
@Composable
internal fun QueueRow(
track: TrackRef,
index: Int,
queueSize: Int,
isCurrent: Boolean,
liked: Boolean,
onClick: () -> Unit,
onToggleLike: () -> Unit,
onRemove: () -> Unit,
onMove: (Int, Int) -> Unit,
) {
var dragOffsetY by remember { mutableFloatStateOf(0f) }
var rowHeightPx by remember { mutableIntStateOf(0) }
val highlight = if (isCurrent) {
MaterialTheme.colorScheme.primary.copy(alpha = HIGHLIGHT_ALPHA)
} else {
Color.Transparent
}
// Swipe left to remove, replacing the X button (#2395 follow-up). Only
// end-to-start is enabled: a right-swipe has no meaning here, and leaving it
// live would delete tracks on a mis-aimed gesture in either direction.
val dismissState = rememberSwipeToDismissBoxState(
confirmValueChange = { value ->
if (value == SwipeToDismissBoxValue.EndToStart) {
onRemove()
true
} else {
false
}
},
)
SwipeToDismissBox(
state = dismissState,
enableDismissFromStartToEnd = false,
backgroundContent = { RemoveSwipeBackground() },
// The reorder lift lives out here so a row being dragged vertically
// carries its swipe container with it rather than sliding out of one.
modifier = Modifier
.onSizeChanged { rowHeightPx = it.height }
.zIndex(if (dragOffsetY != 0f) 1f else 0f)
.graphicsLayer { translationY = dragOffsetY },
) {
QueueRowContent(
track = track,
index = index,
queueSize = queueSize,
isCurrent = isCurrent,
liked = liked,
highlight = highlight,
rowHeightPx = rowHeightPx,
onClick = onClick,
onToggleLike = onToggleLike,
onRemove = onRemove,
onMove = onMove,
onDragOffset = { dragOffsetY = it },
)
}
}
@Suppress("LongParameterList") // Compose row wiring — layout + queue callbacks, not logic.
@Composable
private fun QueueRowContent(
track: TrackRef,
index: Int,
queueSize: Int,
isCurrent: Boolean,
liked: Boolean,
highlight: Color,
rowHeightPx: Int,
onClick: () -> Unit,
onToggleLike: () -> Unit,
onRemove: () -> Unit,
onMove: (Int, Int) -> Unit,
onDragOffset: (Float) -> Unit,
) {
Row(
modifier = Modifier
.fillMaxWidth()
// Opaque: this sits ON TOP of the red remove background, so a
// transparent row would show the fill through it at rest.
.background(MaterialTheme.colorScheme.surface)
.background(highlight)
.clickable(onClick = onClick)
.queueReorderActions(
index = index,
queueSize = queueSize,
onMove = onMove,
onRemove = onRemove,
)
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
// The album art IS the grab surface (#2395). The grip icon it replaces
// cost ~36dp of every row's width — icon plus its 12dp gap — on the
// narrowest surface in the app, competing with the title for space.
QueueRowThumbnail(
track = track,
dragModifier = Modifier.queueReorderDrag(
index = index,
queueSize = queueSize,
rowHeightPx = rowHeightPx,
onOffsetChange = onDragOffset,
onMove = onMove,
),
)
if (isCurrent) {
Icon(
Lucide.Volume2,
contentDescription = "Now playing",
tint = MaterialTheme.colorScheme.primary,
)
}
QueueRowText(track = track, isCurrent = isCurrent, modifier = Modifier.weight(1f))
if (track.durationSec > 0) {
Text(
text = formatDuration(track.durationSec),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
LikeButton(liked = liked, onToggle = onToggleLike)
}
}
/**
* What the row slides off to reveal: the destructive colour with a trash glyph,
* pinned to the trailing edge because that is the edge the swipe uncovers.
*
* Oxblood (LocalActionColors.destructive), NOT colorScheme.error. The design
* system keeps those apart deliberately — an error is a failure that already
* happened, a destructive action is one about to happen — and using the error
* colour here would dress an intentional gesture as a fault report.
*/
@Composable
private fun RemoveSwipeBackground() {
val actions = LocalActionColors.current
Box(
modifier = Modifier
.fillMaxSize()
.background(actions.destructive)
.padding(horizontal = 24.dp),
contentAlignment = Alignment.CenterEnd,
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Icon(Lucide.Trash2, contentDescription = null, tint = actions.onAction)
Text(
text = "Remove",
style = MaterialTheme.typography.labelLarge,
color = actions.onAction,
)
}
}
}
/**
* Screen-reader reordering and removal for a queue row.
*
* Both gestures this row now relies on — long-press-drag to reorder, swipe to
* remove — are touch-only and unavailable under TalkBack, and each replaced a
* control that a screen reader COULD find (the grip's "Reorder track", the X's
* "Remove from queue"). Without these actions the row would have lost both
* capabilities for anyone not using touch. They're the Android counterpart to
* the web row's ArrowUp/ArrowDown keys and its still-present X button.
*/
private fun Modifier.queueReorderActions(
index: Int,
queueSize: Int,
onMove: (Int, Int) -> Unit,
onRemove: () -> Unit,
): Modifier = semantics {
customActions = listOf(
CustomAccessibilityAction("Move up") {
if (index > 0) { onMove(index, index - 1); true } else false
},
CustomAccessibilityAction("Move down") {
if (index < queueSize - 1) { onMove(index, index + 1); true } else false
},
CustomAccessibilityAction("Remove from queue") { onRemove(); true },
)
}
/**
* Reorder-drag behaviour for a queue row, applied to whatever element is the
* grab surface — the album art, since #2395 removed the grip icon.
*
* Uses **detectDragGesturesAfterLongPress**, not detectDragGestures, and that
* is the load-bearing detail. The grip was a small target, so a plain drag
* gesture on it never competed with anything. A 48dp thumbnail is a large
* chunk of every row, and with a plain drag detector any vertical pan starting
* on artwork would be swallowed as a row-reorder instead of scrolling the
* queue — the list would feel broken precisely where it's easiest to touch.
* Long-press-then-drag separates the two: pan scrolls, long-press reorders,
* tap still plays (the detector doesn't consume a plain tap, so it falls
* through to the row's clickable).
*/
private fun Modifier.queueReorderDrag(
index: Int,
queueSize: Int,
rowHeightPx: Int,
onOffsetChange: (Float) -> Unit,
onMove: (Int, Int) -> Unit,
): Modifier = composed {
// 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) }
pointerInput(index, queueSize, rowHeightPx) {
detectDragGesturesAfterLongPress(
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, dragModifier: Modifier = Modifier) {
Box(
modifier = Modifier
.size(48.dp)
.clip(RoundedCornerShape(4.dp))
.background(MaterialTheme.colorScheme.surfaceVariant)
.then(dragModifier),
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,
)
}
}
}
/** "Artist · Album" — collapses gracefully when either is missing. */
private fun queueSubtitle(track: TrackRef): String = listOf(track.artistName, track.albumTitle)
.filter { it.isNotEmpty() }
.joinToString(" · ")
private const val HIGHLIGHT_ALPHA = 0.12f
@@ -1,17 +1,18 @@
package com.fabledsword.minstrel.player.ui package com.fabledsword.minstrel.player.ui
import androidx.compose.foundation.background import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.clickable
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.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
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.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 +21,24 @@ 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.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
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.Lucide import com.composables.icons.lucide.Lucide
import com.composables.icons.lucide.Volume2 import com.composables.icons.lucide.Trash2
import com.fabledsword.minstrel.models.TrackRef import com.fabledsword.minstrel.models.TrackRef
import com.fabledsword.minstrel.shared.formatDuration
import com.fabledsword.minstrel.shared.widgets.EmptyState import com.fabledsword.minstrel.shared.widgets.EmptyState
import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
@@ -44,16 +47,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,87 +89,106 @@ 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(
val highlight = if (isCurrent) { visible: Boolean,
MaterialTheme.colorScheme.primary.copy(alpha = HIGHLIGHT_ALPHA) onClick: () -> Unit,
} else { modifier: Modifier = Modifier,
Color.Transparent ) {
} AnimatedVisibility(visible = visible, modifier = modifier) {
Row( FilledTonalButton(onClick = onClick) {
modifier = Modifier Icon(Lucide.ArrowDown, contentDescription = null, modifier = Modifier.size(18.dp))
.fillMaxWidth() Spacer(modifier = Modifier.width(6.dp))
.background(highlight) Text("Jump to current")
.clickable(onClick = onClick)
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
if (isCurrent) {
Icon(
Lucide.Volume2,
contentDescription = "Now playing",
tint = MaterialTheme.colorScheme.primary,
)
}
Column(modifier = Modifier.weight(1f)) {
Text(
text = track.title,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurface,
fontWeight = if (isCurrent) FontWeight.Medium else FontWeight.Normal,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
val subtitle = queueSubtitle(track)
if (subtitle.isNotEmpty()) {
Text(
text = subtitle,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
if (track.durationSec > 0) {
Text(
text = formatDuration(track.durationSec),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
} }
} }
} }
/** "Artist · Album" — collapses gracefully when either is missing. */
private fun queueSubtitle(track: TrackRef): String = listOf(track.artistName, track.albumTitle)
.filter { it.isNotEmpty() }
.joinToString(" · ")
private const val HIGHLIGHT_ALPHA = 0.12f /** "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 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,
@@ -6,22 +6,27 @@ import com.fabledsword.minstrel.BuildConfig
import com.fabledsword.minstrel.api.ErrorCopy import com.fabledsword.minstrel.api.ErrorCopy
import com.fabledsword.minstrel.models.UpdateInfo import com.fabledsword.minstrel.models.UpdateInfo
import com.fabledsword.minstrel.update.data.ApkInstaller import com.fabledsword.minstrel.update.data.ApkInstaller
import com.fabledsword.minstrel.update.data.InstallStage
import com.fabledsword.minstrel.update.data.UpdateRepository import com.fabledsword.minstrel.update.data.UpdateRepository
import com.fabledsword.minstrel.update.data.isBusy
import com.fabledsword.minstrel.update.data.isVersionNewer import com.fabledsword.minstrel.update.data.isVersionNewer
import com.fabledsword.minstrel.update.data.message
import com.fabledsword.minstrel.update.data.stage
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.io.File
import javax.inject.Inject import javax.inject.Inject
/** /**
* One of three terminal states the Check-for-updates button surfaces. * One of three terminal states the Check-for-updates button surfaces.
* `Idle` is the pre-check state; `Latest` means the installed build * `Idle` is the pre-check state; `Latest` means the installed build
* matches or exceeds the server's bundled APK; `UpdateAvailable` * matches or exceeds the server's bundled APK; `UpdateAvailable`
* surfaces an "Install vX.Y.Z" button that downloads + launches the * surfaces an "Install vX.Y.Z" button that downloads the APK and
* system installer via [ApkInstaller]. * installs it via [ApkInstaller].
*/ */
sealed interface UpdateCheckResult { sealed interface UpdateCheckResult {
data object Idle : UpdateCheckResult data object Idle : UpdateCheckResult
@@ -33,7 +38,7 @@ sealed interface UpdateCheckResult {
data class AboutUiState( data class AboutUiState(
val installedVersion: String = BuildConfig.VERSION_NAME, val installedVersion: String = BuildConfig.VERSION_NAME,
val isChecking: Boolean = false, val isChecking: Boolean = false,
val isInstalling: Boolean = false, val installStage: InstallStage = InstallStage.IDLE,
val installMessage: String? = null, val installMessage: String? = null,
val result: UpdateCheckResult = UpdateCheckResult.Idle, val result: UpdateCheckResult = UpdateCheckResult.Idle,
) )
@@ -43,9 +48,9 @@ data class AboutUiState(
* [UpdateRepository.getLatest], compares versus the build's * [UpdateRepository.getLatest], compares versus the build's
* VERSION_NAME via [isVersionNewer], and reports the terminal state. * VERSION_NAME via [isVersionNewer], and reports the terminal state.
* When an update is available, [install] downloads the APK via * When an update is available, [install] downloads the APK via
* [ApkInstaller] and hands it to the system installer — routing the * [ApkInstaller] and installs it — routing the user to the "install
* user to the "install unknown apps" settings page first when that * unknown apps" settings page first when that permission hasn't been
* permission hasn't been granted. * granted.
*/ */
@HiltViewModel @HiltViewModel
class AboutCardViewModel @Inject constructor( class AboutCardViewModel @Inject constructor(
@@ -75,7 +80,7 @@ class AboutCardViewModel @Inject constructor(
} }
fun install(info: UpdateInfo) { fun install(info: UpdateInfo) {
if (internal.value.isInstalling) return if (internal.value.installStage.isBusy()) return
if (!installer.canInstall()) { if (!installer.canInstall()) {
installer.requestInstallPermission() installer.requestInstallPermission()
internal.update { internal.update {
@@ -84,21 +89,32 @@ class AboutCardViewModel @Inject constructor(
return return
} }
viewModelScope.launch { viewModelScope.launch {
internal.update { it.copy(isInstalling = true, installMessage = null) } internal.update {
runCatching { installer.downloadApk(info.apkUrl) } it.copy(installStage = InstallStage.DOWNLOADING, installMessage = null)
.onSuccess { apk -> }
installer.launchInstall(apk) val apk = download(info.apkUrl)
internal.update { it.copy(isInstalling = false) } if (apk != null) {
} // The install half now suspends on the platform's verdict, so it
.onFailure { e -> // gets its own stage — reporting "Downloading…" through it would
val why = ErrorCopy.fromThrowable(e) // be a lie once a confirm dialog is on screen.
internal.update { internal.update { it.copy(installStage = InstallStage.INSTALLING) }
it.copy( val outcome = installer.install(apk)
isInstalling = false, internal.update {
installMessage = "Couldn't download update: $why", it.copy(installStage = outcome.stage(), installMessage = outcome.message())
)
}
} }
}
} }
} }
private suspend fun download(apkUrl: String): File? =
runCatching { installer.downloadApk(apkUrl) }
.onFailure { e ->
internal.update {
it.copy(
installStage = InstallStage.ERROR,
installMessage = "Couldn't download update: ${ErrorCopy.fromThrowable(e)}",
)
}
}
.getOrNull()
} }
@@ -58,6 +58,8 @@ import com.fabledsword.minstrel.nav.ServerUrl
import com.fabledsword.minstrel.shared.widgets.MinstrelTopAppBar import com.fabledsword.minstrel.shared.widgets.MinstrelTopAppBar
import com.fabledsword.minstrel.theme.ThemeMode import com.fabledsword.minstrel.theme.ThemeMode
import com.fabledsword.minstrel.theme.ThemePreferenceViewModel import com.fabledsword.minstrel.theme.ThemePreferenceViewModel
import com.fabledsword.minstrel.update.data.InstallStage
import com.fabledsword.minstrel.update.data.isBusy
@Composable @Composable
fun SettingsScreen( fun SettingsScreen(
@@ -381,7 +383,7 @@ private fun UpdateControls(state: AboutUiState, viewModel: AboutCardViewModel) {
UpdateCheckLine(result = state.result) UpdateCheckLine(result = state.result)
Button( Button(
onClick = viewModel::checkForUpdates, onClick = viewModel::checkForUpdates,
enabled = !state.isChecking && !state.isInstalling, enabled = !state.isChecking && !state.installStage.isBusy(),
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
) { ) {
if (state.isChecking) { if (state.isChecking) {
@@ -393,7 +395,7 @@ private fun UpdateControls(state: AboutUiState, viewModel: AboutCardViewModel) {
if (available != null) { if (available != null) {
InstallButton( InstallButton(
version = available.info.version, version = available.info.version,
isInstalling = state.isInstalling, stage = state.installStage,
onClick = { viewModel.install(available.info) }, onClick = { viewModel.install(available.info) },
) )
} }
@@ -407,16 +409,22 @@ private fun UpdateControls(state: AboutUiState, viewModel: AboutCardViewModel) {
} }
@Composable @Composable
private fun InstallButton(version: String, isInstalling: Boolean, onClick: () -> Unit) { private fun InstallButton(version: String, stage: InstallStage, onClick: () -> Unit) {
Button( Button(
onClick = onClick, onClick = onClick,
enabled = !isInstalling, enabled = !stage.isBusy(),
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
) { ) {
if (isInstalling) { if (stage.isBusy()) {
ButtonSpinner() ButtonSpinner()
} }
Text(if (isInstalling) "Downloading…" else "Install $version") Text(
when (stage) {
InstallStage.DOWNLOADING -> "Downloading…"
InstallStage.INSTALLING -> "Installing…"
else -> "Install $version"
},
)
} }
} }
@@ -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()
} }
@@ -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() }
} }
} }
} }
@@ -5,7 +5,6 @@ import android.content.Intent
import android.net.Uri import android.net.Uri
import android.os.Build import android.os.Build
import android.provider.Settings import android.provider.Settings
import androidx.core.content.FileProvider
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
@@ -17,27 +16,26 @@ import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
private const val APK_FILENAME = "minstrel-update.apk" private const val APK_FILENAME = "minstrel-update.apk"
private const val APK_MIME = "application/vnd.android.package-archive"
/** /**
* Downloads the server-bundled APK and hands it to Android's package * Downloads the server-bundled APK and installs it over ourselves.
* installer. Mirrors Flutter's `update/installer.dart` — the native
* side that the Flutter MethodChannel delegated to.
* *
* The download goes through the shared [OkHttpClient] so it inherits * The download goes through the shared [OkHttpClient] so it inherits
* the auth cookie + the BaseUrlInterceptor host rewrite (apkUrl is * the auth cookie + the BaseUrlInterceptor host rewrite (apkUrl is
* server-relative, e.g. `/api/client/apk`). The APK lands in the * server-relative, e.g. `/api/client/apk`). The APK lands in the
* cache dir, exposed to the system installer via the app's * cache dir; [SelfUpdateSession] streams it from there into a
* FileProvider content:// URI. * [android.content.pm.PackageInstaller] session.
* *
* On Android O+ the user must have granted "install unknown apps" * On Android O+ the user must have granted "install unknown apps"
* for Minstrel; [canInstall] reports it and [requestInstallPermission] * for Minstrel; [canInstall] reports it and [requestInstallPermission]
* opens the relevant settings screen. * opens the relevant settings screen. That grant is still required with
* the session API — silent *updates* don't imply silent *permission*.
*/ */
@Singleton @Singleton
class ApkInstaller @Inject constructor( class ApkInstaller @Inject constructor(
@ApplicationContext private val context: Context, @ApplicationContext private val context: Context,
private val okHttpClient: OkHttpClient, private val okHttpClient: OkHttpClient,
private val session: SelfUpdateSession,
) { ) {
suspend fun downloadApk(apkUrl: String): File = withContext(Dispatchers.IO) { suspend fun downloadApk(apkUrl: String): File = withContext(Dispatchers.IO) {
val request = Request.Builder() val request = Request.Builder()
@@ -61,19 +59,13 @@ class ApkInstaller @Inject constructor(
Build.VERSION.SDK_INT < Build.VERSION_CODES.O || Build.VERSION.SDK_INT < Build.VERSION_CODES.O ||
context.packageManager.canRequestPackageInstalls() context.packageManager.canRequestPackageInstalls()
/** Hand the downloaded APK to the system installer's confirm dialog. */ /**
fun launchInstall(apk: File) { * Install [apk] over ourselves, suspending until the platform decides.
val uri: Uri = FileProvider.getUriForFile( *
context, * Note for callers: on a successful silent install this never returns —
"${context.packageName}.fileprovider", * the process is replaced. Don't treat the absence of a verdict as failure.
apk, */
) suspend fun install(apk: File): InstallOutcome = session.run(apk)
val intent = Intent(Intent.ACTION_VIEW).apply {
setDataAndType(uri, APK_MIME)
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK)
}
context.startActivity(intent)
}
/** Open the "install unknown apps" settings page for Minstrel. */ /** Open the "install unknown apps" settings page for Minstrel. */
fun requestInstallPermission() { fun requestInstallPermission() {
@@ -0,0 +1,68 @@
package com.fabledsword.minstrel.update.data
/**
* Terminal verdict from the platform on a self-update install (#2438).
*
* The old `ACTION_VIEW` handoff had no verdict at all — we fired an intent and
* assumed. A [PackageInstaller][android.content.pm.PackageInstaller] session
* reports back, so "declined" and "failed" stop looking identical.
*/
sealed interface InstallOutcome {
/**
* The platform completed the install.
*
* Rarely observed on a self-update: our process is replaced the moment the
* new APK lands, so the coroutine awaiting this usually dies before it
* resumes. Modelled anyway — silently relying on being killed would make
* the success path invisible to anyone reading this.
*/
data object Installed : InstallOutcome
/** The user declined the platform's confirm dialog. Not an error. */
data object Cancelled : InstallOutcome
/** The platform refused. [reason] is its own message, where it gave one. */
data class Failed(val reason: String?) : InstallOutcome
}
/**
* Where an install has got to, for the two surfaces that show it: the shell's
* [UpdateBanner][com.fabledsword.minstrel.update.ui.UpdateBanner] and the
* Settings About card.
*
* DOWNLOADING and INSTALLING are deliberately distinct. They used to be one
* state because the install half was fire-and-forget and took no time from our
* side; now that we await the platform's verdict, collapsing them would leave
* the UI claiming "Downloading…" through an install that can sit on a confirm
* dialog indefinitely.
*/
enum class InstallStage { IDLE, DOWNLOADING, INSTALLING, ERROR }
/** True while an install is underway and a second tap should do nothing. */
fun InstallStage.isBusy(): Boolean =
this == InstallStage.DOWNLOADING || this == InstallStage.INSTALLING
/**
* The stage an outcome lands the UI in. A cancelled install returns to IDLE
* rather than ERROR — the user chose it, so presenting it as a failure would
* be a lie with a red tint.
*/
fun InstallOutcome.stage(): InstallStage = when (this) {
InstallOutcome.Installed, InstallOutcome.Cancelled -> InstallStage.IDLE
is InstallOutcome.Failed -> InstallStage.ERROR
}
/**
* User-facing copy for an outcome; null when there is nothing worth saying.
*
* Lives beside the outcome rather than in either UI package because two
* separate screens surface the same verdicts and must not drift — the same
* reasoning that puts [ErrorCopy][com.fabledsword.minstrel.api.ErrorCopy]
* outside the UI layer.
*/
fun InstallOutcome.message(): String? = when (this) {
InstallOutcome.Installed -> null
InstallOutcome.Cancelled -> "Update cancelled."
is InstallOutcome.Failed -> reason?.let { "Couldn't install update: $it" }
?: "Couldn't install update."
}
@@ -0,0 +1,220 @@
package com.fabledsword.minstrel.update.data
import android.app.PendingIntent
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.content.IntentSender
import android.content.pm.ApplicationInfo
import android.content.pm.PackageInstaller
import android.content.pm.PackageManager
import android.os.Build
import androidx.core.content.ContextCompat
import androidx.core.content.IntentCompat
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withContext
import java.io.File
import javax.inject.Inject
import javax.inject.Singleton
import kotlin.coroutines.resume
private const val STAGED_APK_NAME = "minstrel-update"
/** Whole-file write: openWrite takes a Long offset, and Kotlin won't widen 0. */
private const val WRITE_FROM_START = 0L
/** Our own broadcast, delivered by the platform via the session's IntentSender. */
private const val RESULT_ACTION = "com.fabledsword.minstrel.INSTALL_RESULT"
/**
* Installs an APK over ourselves through a [PackageInstaller] session (#2438).
*
* Split from [ApkInstaller] because the two halves are different work — one
* speaks HTTP, the other speaks to the package manager — and the session half
* carries a receiver, a PendingIntent and version-gated params that would
* crowd the downloader out of its own file.
*
* ## Why a session, rather than the ACTION_VIEW intent this replaced
*
* Two reasons, and the second is the one that matters to users.
*
* The old path fired `ACTION_VIEW` at an `application/vnd.android.package-archive`
* URI and hoped. It could not report an outcome, so a failed install and a
* user who declined looked identical — see [InstallOutcome].
*
* More importantly, a session is where the platform lets a self-updater say it
* is one. [PackageInstaller.SessionParams.setRequireUserAction] with
* `USER_ACTION_NOT_REQUIRED`, paired with the `UPDATE_PACKAGES_WITHOUT_USER_ACTION`
* manifest permission, is the sanctioned way to update with **no dialog at
* all**. The platform grants that when all of: the installer opts in (here),
* the installed app targets API 29+ (we're on 36), the installer holds the
* permission (we do), and the target is the installer itself or something it
* first installed (we are updating ourselves). All four hold.
*
* ## What is deliberately absent
*
* No `setRequestUpdateOwnership(true)`. It reads like the right declaration for
* a self-updater and it is not: ownership can only be claimed on **initial**
* installation — setting it on an update is documented as a no-op — and it also
* wants the privileged `ENFORCE_UPDATE_OWNERSHIP` permission. It exists for app
* stores claiming the apps they install, not for an app updating itself.
*/
@Singleton
class SelfUpdateSession @Inject constructor(
@ApplicationContext private val context: Context,
) {
/**
* Stage [apk] and hand it to the platform, suspending until a terminal
* verdict arrives.
*
* Never returns on the happy path when the install is silent: the platform
* replaces this process the moment the new APK lands, so the coroutine dies
* rather than resuming. Callers must treat that as success, not a hang.
*/
suspend fun run(apk: File): InstallOutcome {
val staged = withContext(Dispatchers.IO) { runCatching { stage(apk) } }
return staged.fold(
onSuccess = { sessionId -> awaitCommit(sessionId) },
onFailure = { InstallOutcome.Failed(it.message) },
)
}
/** Open a session, stream the APK in, return the session id. */
private fun stage(apk: File): Int {
val installer = context.packageManager.packageInstaller
val sessionId = installer.createSession(newParams())
installer.openSession(sessionId).use { session ->
session.openWrite(STAGED_APK_NAME, WRITE_FROM_START, apk.length()).use { sink ->
apk.inputStream().use { source -> source.copyTo(sink) }
// fsync before the session closes: the platform validates the
// staged bytes at commit, and buffered tail bytes read as a
// truncated APK.
session.fsync(sink)
}
}
return sessionId
}
// Explicit `params.` receivers rather than an apply {} block: lintVitalRelease
// runs on assembleRelease, and NewApi is easier for it to reason about when
// the guarded call has a named receiver instead of an implicit one.
private fun newParams(): PackageInstaller.SessionParams {
val params = PackageInstaller.SessionParams(
PackageInstaller.SessionParams.MODE_FULL_INSTALL,
)
params.setAppPackageName(context.packageName)
params.setInstallReason(PackageManager.INSTALL_REASON_USER)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
// The whole point of this class. Pre-S there is no such API, so the
// confirm dialog is unavoidable there — degrade, don't fail.
params.setRequireUserAction(PackageInstaller.SessionParams.USER_ACTION_NOT_REQUIRED)
}
return params
}
/**
* Commit the session and wait for the platform to report back.
*
* A pending-user-action status is *not* terminal — the platform is asking us
* to show its dialog, and the real verdict arrives in a second broadcast
* once the user decides. So the receiver stays registered across it.
*/
private suspend fun awaitCommit(sessionId: Int): InstallOutcome =
suspendCancellableCoroutine { continuation ->
val installer = context.packageManager.packageInstaller
val receiver = object : BroadcastReceiver() {
override fun onReceive(unused: Context, intent: Intent) {
val status = intent.getIntExtra(
PackageInstaller.EXTRA_STATUS,
PackageInstaller.STATUS_FAILURE,
)
if (status == PackageInstaller.STATUS_PENDING_USER_ACTION) {
confirmWithUser(intent)
} else {
context.unregisterReceiver(this)
val why = intent.getStringExtra(PackageInstaller.EXTRA_STATUS_MESSAGE)
if (continuation.isActive) continuation.resume(outcomeOf(status, why))
}
}
}
ContextCompat.registerReceiver(
context,
receiver,
IntentFilter(RESULT_ACTION),
ContextCompat.RECEIVER_NOT_EXPORTED,
)
continuation.invokeOnCancellation {
// Stop listening, but deliberately do NOT abandon the session.
// Cancellation here means our caller's scope died — the user
// navigated away, or the VM cleared — and by this point the
// session is already committed. The user asked for this install;
// killing it because nobody is watching the banner any more
// would be the wrong reading of their intent.
runCatching { context.unregisterReceiver(receiver) }
}
runCatching {
installer.openSession(sessionId).use { it.commit(resultSender(sessionId)) }
}.onFailure { error ->
// Resuming normally means invokeOnCancellation never fires, so
// clean up the staged session here or it sits until it expires.
runCatching { context.unregisterReceiver(receiver) }
runCatching { installer.abandonSession(sessionId) }
if (continuation.isActive) {
continuation.resume(InstallOutcome.Failed(error.message))
}
}
}
private fun resultSender(sessionId: Int): IntentSender {
// Scoped to our own package so the broadcast can't be answered elsewhere.
val intent = Intent(RESULT_ACTION).setPackage(context.packageName)
var flags = PendingIntent.FLAG_UPDATE_CURRENT
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
// The platform writes its status extras into this intent, so it has
// to stay mutable — FLAG_IMMUTABLE would arrive with none of them.
flags = flags or PendingIntent.FLAG_MUTABLE
}
// Session id as the request code keeps concurrent sessions from
// colliding on FLAG_UPDATE_CURRENT.
return PendingIntent.getBroadcast(context, sessionId, intent, flags).intentSender
}
/**
* Show the platform's own confirm dialog, which arrives as an extra.
*
* The system-app check is not ceremony. Below API 34 a dynamically
* registered receiver cannot declare itself unexported, so another app on
* the device can broadcast [RESULT_ACTION] at us — and calling
* `startActivity` on an attacker-supplied extra would hand it whatever we
* can reach. The genuine confirm activity belongs to the platform
* installer, so demanding a system component costs the real path nothing.
*/
private fun confirmWithUser(result: Intent) {
val pending = IntentCompat.getParcelableExtra(
result,
Intent.EXTRA_INTENT,
Intent::class.java,
) ?: return
if (isPlatformActivity(pending)) {
context.startActivity(pending.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
}
}
private fun isPlatformActivity(intent: Intent): Boolean {
val flags = intent.resolveActivityInfo(context.packageManager, 0)
?.applicationInfo
?.flags
?: 0
val systemFlags = ApplicationInfo.FLAG_SYSTEM or ApplicationInfo.FLAG_UPDATED_SYSTEM_APP
return (flags and systemFlags) != 0
}
private fun outcomeOf(status: Int, message: String?): InstallOutcome = when (status) {
PackageInstaller.STATUS_SUCCESS -> InstallOutcome.Installed
PackageInstaller.STATUS_FAILURE_ABORTED -> InstallOutcome.Cancelled
else -> InstallOutcome.Failed(message)
}
}
@@ -28,6 +28,8 @@ import com.composables.icons.lucide.Download
import com.composables.icons.lucide.Lucide import com.composables.icons.lucide.Lucide
import com.composables.icons.lucide.X import com.composables.icons.lucide.X
import com.fabledsword.minstrel.models.UpdateInfo import com.fabledsword.minstrel.models.UpdateInfo
import com.fabledsword.minstrel.update.data.InstallStage
import com.fabledsword.minstrel.update.data.isBusy
/** /**
* Shell-level soft banner that nudges an available update. Renders * Shell-level soft banner that nudges an available update. Renders
@@ -79,7 +81,7 @@ private fun BannerBody(
.padding(start = 16.dp, top = 8.dp, end = 4.dp, bottom = 8.dp), .padding(start = 16.dp, top = 8.dp, end = 4.dp, bottom = 8.dp),
) { ) {
BannerRow(info = info, stage = stage, onInstall = onInstall, onDismiss = onDismiss) BannerRow(info = info, stage = stage, onInstall = onInstall, onDismiss = onDismiss)
if (stage == InstallStage.DOWNLOADING) { if (stage.isBusy()) {
LinearProgressIndicator( LinearProgressIndicator(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
@@ -124,8 +126,17 @@ private fun BannerRow(
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
) )
TextButton(onClick = onInstall, enabled = stage != InstallStage.DOWNLOADING) { TextButton(onClick = onInstall, enabled = !stage.isBusy()) {
Text(if (stage == InstallStage.DOWNLOADING) "Installing…" else "Install") // Downloading and installing are separate words because they're now
// separate waits — the install half suspends on the platform, which
// may be sitting on a confirm dialog.
Text(
when (stage) {
InstallStage.DOWNLOADING -> "Downloading…"
InstallStage.INSTALLING -> "Installing…"
else -> "Install"
},
)
} }
IconButton(onClick = onDismiss) { IconButton(onClick = onDismiss) {
Icon( Icon(
@@ -5,7 +5,11 @@ import androidx.lifecycle.viewModelScope
import com.fabledsword.minstrel.api.ErrorCopy import com.fabledsword.minstrel.api.ErrorCopy
import com.fabledsword.minstrel.models.UpdateInfo import com.fabledsword.minstrel.models.UpdateInfo
import com.fabledsword.minstrel.update.data.ApkInstaller import com.fabledsword.minstrel.update.data.ApkInstaller
import com.fabledsword.minstrel.update.data.InstallStage
import com.fabledsword.minstrel.update.data.UpdateBannerController import com.fabledsword.minstrel.update.data.UpdateBannerController
import com.fabledsword.minstrel.update.data.isBusy
import com.fabledsword.minstrel.update.data.message
import com.fabledsword.minstrel.update.data.stage
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
@@ -13,13 +17,11 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.io.File
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
/** Install lifecycle for the banner's Install button. */
enum class InstallStage { IDLE, DOWNLOADING, ERROR }
data class UpdateBannerUiState( data class UpdateBannerUiState(
val info: UpdateInfo? = null, val info: UpdateInfo? = null,
val stage: InstallStage = InstallStage.IDLE, val stage: InstallStage = InstallStage.IDLE,
@@ -28,9 +30,9 @@ data class UpdateBannerUiState(
/** /**
* Thin VM over [UpdateBannerController]. Surfaces the available update * Thin VM over [UpdateBannerController]. Surfaces the available update
* and runs the download → system-install handoff via [ApkInstaller], * and runs the download → install handoff via [ApkInstaller], mirroring
* mirroring the About card's flow (route to "install unknown apps" * the About card's flow (route to "install unknown apps" settings first
* settings first when the permission is missing). * when the permission is missing).
*/ */
@HiltViewModel @HiltViewModel
class UpdateBannerViewModel @Inject constructor( class UpdateBannerViewModel @Inject constructor(
@@ -38,7 +40,7 @@ class UpdateBannerViewModel @Inject constructor(
private val installer: ApkInstaller, private val installer: ApkInstaller,
) : ViewModel() { ) : ViewModel() {
private val installState = MutableStateFlow(IdleInstall) private val installState = MutableStateFlow(InstallSnapshot(InstallStage.IDLE, null))
val uiState: StateFlow<UpdateBannerUiState> = val uiState: StateFlow<UpdateBannerUiState> =
combine(controller.available, installState) { info, install -> combine(controller.available, installState) { info, install ->
@@ -52,7 +54,7 @@ class UpdateBannerViewModel @Inject constructor(
fun dismiss(version: String) = controller.dismiss(version) fun dismiss(version: String) = controller.dismiss(version)
fun install(info: UpdateInfo) { fun install(info: UpdateInfo) {
if (installState.value.stage == InstallStage.DOWNLOADING) return if (installState.value.stage.isBusy()) return
if (!installer.canInstall()) { if (!installer.canInstall()) {
installer.requestInstallPermission() installer.requestInstallPermission()
installState.value = InstallSnapshot( installState.value = InstallSnapshot(
@@ -63,21 +65,28 @@ class UpdateBannerViewModel @Inject constructor(
} }
viewModelScope.launch { viewModelScope.launch {
installState.value = InstallSnapshot(InstallStage.DOWNLOADING, null) installState.value = InstallSnapshot(InstallStage.DOWNLOADING, null)
runCatching { installer.downloadApk(info.apkUrl) } val apk = download(info.apkUrl)
.onSuccess { apk -> if (apk != null) {
installer.launchInstall(apk) // Await the platform's verdict rather than firing an intent and
installState.value = IdleInstall // assuming it worked. On a silent install this suspends until
} // the process is replaced, so the line below is only reached
.onFailure { e -> // when the install did NOT simply succeed.
installState.value = InstallSnapshot( installState.value = InstallSnapshot(InstallStage.INSTALLING, null)
InstallStage.ERROR, val outcome = installer.install(apk)
"Couldn't download update: ${ErrorCopy.fromThrowable(e)}", installState.value = InstallSnapshot(outcome.stage(), outcome.message())
) }
}
} }
} }
private suspend fun download(apkUrl: String): File? =
runCatching { installer.downloadApk(apkUrl) }
.onFailure { e ->
installState.value = InstallSnapshot(
InstallStage.ERROR,
"Couldn't download update: ${ErrorCopy.fromThrowable(e)}",
)
}
.getOrNull()
} }
private data class InstallSnapshot(val stage: InstallStage, val message: String?) private data class InstallSnapshot(val stage: InstallStage, val message: String?)
private val IdleInstall = InstallSnapshot(InstallStage.IDLE, null)
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Adaptive icon (API 26+). Before this the app shipped legacy bitmaps only,
so modern launchers letterboxed the square instead of masking it to the
device's icon shape. The foreground PNGs are drawn on a 108dp canvas with
the mark inside the 66dp safe zone, so no mask can clip it. -->
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
<monochrome android:drawable="@mipmap/ic_launcher_foreground"/>
</adaptive-icon>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 544 B

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 442 B

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 721 B

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 KiB

After

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 8.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Obsidian. The adaptive icon's plate; chosen over the raised-surface
iron because the accent note only clears the 3:1 graphics contrast
threshold against this darker value (3.04:1 vs 2.70:1). -->
<color name="ic_launcher_background">#14171A</color>
</resources>
@@ -1,9 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<paths>
<!-- The downloaded update APK lives in the app cache dir; the
FileProvider exposes just that directory to the system
installer via a content:// URI. -->
<cache-path
name="updates"
path="." />
</paths>
@@ -0,0 +1,38 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Replaces a bare android:usesCleartextTraffic="true" on <application> (#2439).
Cleartext is still permitted app-wide, and it has to be. Two independent
reasons, neither of which can be narrowed to a domain list:
1. The Minstrel server's host is entered by the user at runtime. Plenty of
self-hosters run it over plain HTTP on a LAN; refusing that would break
real installs rather than secure anyone.
2. UPnP / DLNA / Sonos. Device-description and SOAP control URLs arrive in
SSDP responses at runtime and are plain HTTP essentially without
exception — see player/output/upnp/{UpnpDiscoveryController,SoapClient}.
A <domain-config> would be the way to scope this, but it matches literal
hostnames rather than CIDR ranges, and both sets of hosts above are unknowable
until runtime. So a permissive base-config is an honest description of our
situation — the gain over the manifest attribute is that the reasoning now
lives somewhere, and there is one place to tighten if a future settings screen
can distinguish a LAN server from a WAN one.
Worth stating because it looks worse than it is: this is NOT a tamper risk for
the in-app updater. An APK altered in transit and re-signed is rejected by the
platform as a signature mismatch on update, so the boundary there is enforced
regardless of transport.
Trust anchors are deliberately left at the platform default (system CAs only).
Adding <certificates src="user" /> would let self-hosters use HTTPS with their
own private CA — attractive for this product, and what Mihon does — but it
also makes the app trust every CA on the device, including a corporate MITM
proxy. That's an operator decision, not a default worth assuming.
-->
<network-security-config xmlns:tools="http://schemas.android.com/tools">
<base-config
cleartextTrafficPermitted="true"
tools:ignore="InsecureBaseConfiguration" />
</network-security-config>
@@ -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())
}
}
@@ -50,12 +50,46 @@ class ReachabilityMachineTest {
} }
@Test @Test
fun `two op failures plus a failed probe escalate immediately`() { fun `two SPACED op failures plus a failed probe escalate immediately`() {
val m = machine() val m = machine()
m.onLinkChange(up = true) m.onLinkChange(up = true)
m.onOpFailure(nowMs = 1_000) m.onOpFailure(nowMs = 1_000)
m.onOpFailure(nowMs = 1_500) // corroboration reached // Spacing matters as of #1209: these must be far enough apart to be
m.onProbeFailure(nowMs = 2_000) // probe agrees → fast ServerDown // separate evidence rather than one event's worth of fallout. This
// test previously used 1_500 — 500ms — which is now deliberately
// treated as a burst and does NOT corroborate.
m.onOpFailure(nowMs = 1_000 + CORROBORATION_MIN_SPACING_MS)
m.onProbeFailure(nowMs = 1_000 + CORROBORATION_MIN_SPACING_MS + 500)
assertEquals(ServerHealth.ServerDown, m.health())
}
// The #1209 mechanism: an OS network handoff fails every in-flight request
// at once. That must NOT reach ServerDown, because ServerDown makes
// OfflineGatedDataSource refuse uncached tracks outright — the app would
// decline to play music that plays fine, for a blip already over.
@Test
fun `a burst of op failures does not corroborate itself into ServerDown`() {
val m = machine()
m.onLinkChange(up = true)
m.onOpFailure(nowMs = 1_000)
m.onOpFailure(nowMs = 1_050)
m.onOpFailure(nowMs = 1_100)
m.onOpFailure(nowMs = 1_200)
m.onProbeFailure(nowMs = 1_500)
// Unstable is non-gating, so playback keeps working.
assertEquals(ServerHealth.Unstable, m.health())
}
@Test
fun `a burst still escalates via the sustained backstop if it never recovers`() {
val m = machine()
m.onLinkChange(up = true)
m.onOpFailure(nowMs = 1_000)
m.onOpFailure(nowMs = 1_050)
m.onProbeFailure(nowMs = 1_500) // unstable, streak starts here
// Dropping burst duplicates must not make a REAL outage undetectable —
// the time backstop is what guarantees escalation either way.
m.onProbeFailure(nowMs = 1_500 + ESCALATE_AFTER_MS)
assertEquals(ServerHealth.ServerDown, m.health()) assertEquals(ServerHealth.ServerDown, m.health())
} }
@@ -64,7 +98,7 @@ class ReachabilityMachineTest {
val m = machine() val m = machine()
m.onLinkChange(up = true) m.onLinkChange(up = true)
m.onOpFailure(nowMs = 1_000) m.onOpFailure(nowMs = 1_000)
m.onOpFailure(nowMs = 1_500) m.onOpFailure(nowMs = 1_000 + CORROBORATION_MIN_SPACING_MS)
m.onSuccess() // arbiter says server is fine m.onSuccess() // arbiter says server is fine
assertEquals(ServerHealth.Healthy, m.health()) assertEquals(ServerHealth.Healthy, m.health())
} }
@@ -74,9 +108,11 @@ class ReachabilityMachineTest {
val m = machine() val m = machine()
m.onLinkChange(up = true) m.onLinkChange(up = true)
m.onOpFailure(nowMs = 0) m.onOpFailure(nowMs = 0)
m.onOpFailure(nowMs = 1_000) // Spaced so this test exercises STALENESS, not the burst rule — with
// 1_000 it would have passed for the wrong reason after #1209.
m.onOpFailure(nowMs = CORROBORATION_MIN_SPACING_MS)
// both op failures are now older than the corroboration window: // both op failures are now older than the corroboration window:
m.onProbeFailure(nowMs = 1_000 + CORROBORATION_WINDOW_MS + 1) m.onProbeFailure(nowMs = CORROBORATION_MIN_SPACING_MS + CORROBORATION_WINDOW_MS + 1)
assertEquals(ServerHealth.Unstable, m.health()) // not enough fresh corroboration assertEquals(ServerHealth.Unstable, m.health()) // not enough fresh corroboration
} }
@@ -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())
}
} }
@@ -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,
+65
View File
@@ -0,0 +1,65 @@
package api
import (
"encoding/json"
"errors"
"net/http"
"git.fabledsword.com/bvandeusen/minstrel/internal/apierror"
"git.fabledsword.com/bvandeusen/minstrel/internal/auth"
"git.fabledsword.com/bvandeusen/minstrel/internal/netsettings"
)
type networkSettingsResp struct {
TrustedProxyHops int `json:"trusted_proxy_hops"`
MaxHops int `json:"max_hops"`
// DetectedClientIP is what the CURRENT setting resolves this very request
// to. It's the difference between a number the operator has to reason
// about and one they can verify: set the value, reload, and check the
// address matches the machine you're sitting at.
DetectedClientIP string `json:"detected_client_ip"`
// ForwardedChain is the raw X-Forwarded-For as received, so an operator
// whose detected address looks wrong can see how many hops actually
// arrived and count them rather than guess.
ForwardedChain string `json:"forwarded_chain"`
RemoteAddr string `json:"remote_addr"`
}
type updateNetworkSettingsReq struct {
TrustedProxyHops int `json:"trusted_proxy_hops"`
}
func (h *handlers) handleGetNetworkSettings(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, h.networkSettingsPayload(r))
}
func (h *handlers) handleUpdateNetworkSettings(w http.ResponseWriter, r *http.Request) {
var req updateNetworkSettingsReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeErr(w, apierror.BadRequest("invalid_body", "malformed JSON"))
return
}
if err := h.netSettings.SetHops(r.Context(), req.TrustedProxyHops); err != nil {
if errors.Is(err, netsettings.ErrHopsOutOfRange) {
writeErr(w, apierror.BadRequest("invalid_hops", err.Error()))
return
}
writeErrWithLog(w, h.logger, "admin network: update failed", apierror.Internal(err))
return
}
// Echo the payload recomputed under the NEW value, so the card can show
// immediately what the change did to this request's own address rather
// than making the operator reload to find out.
writeJSON(w, http.StatusOK, h.networkSettingsPayload(r))
}
func (h *handlers) networkSettingsPayload(r *http.Request) networkSettingsResp {
hops := h.netSettings.Hops()
return networkSettingsResp{
TrustedProxyHops: hops,
MaxHops: netsettings.MaxTrustedProxyHops,
DetectedClientIP: auth.ClientIP(r, hops),
ForwardedChain: r.Header.Get("X-Forwarded-For"),
RemoteAddr: r.RemoteAddr,
}
}
+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")
}
}
+42 -2
View File
@@ -20,18 +20,22 @@ import (
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrquarantine" "git.fabledsword.com/bvandeusen/minstrel/internal/lidarrquarantine"
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrrequests" "git.fabledsword.com/bvandeusen/minstrel/internal/lidarrrequests"
"git.fabledsword.com/bvandeusen/minstrel/internal/mailer" "git.fabledsword.com/bvandeusen/minstrel/internal/mailer"
"git.fabledsword.com/bvandeusen/minstrel/internal/netsettings"
"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, netSettings *netsettings.Service) {
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 +44,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,
@@ -47,6 +52,7 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev
eventbus: bus, eventbus: bus,
playlistScheduler: playlistScheduler, playlistScheduler: playlistScheduler,
streamSecret: streamSecret, streamSecret: streamSecret,
netSettings: netSettings,
} }
r.Route("/api", func(api chi.Router) { r.Route("/api", func(api chi.Router) {
@@ -70,7 +76,7 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev
api.With(auth.OptionalUser(pool, logger)).Get("/tracks/{id}/stream.{ext}", h.handleGetStream) api.With(auth.OptionalUser(pool, logger)).Get("/tracks/{id}/stream.{ext}", h.handleGetStream)
api.Group(func(authed chi.Router) { api.Group(func(authed chi.Router) {
authed.Use(auth.RequireUser(pool)) authed.Use(auth.RequireUser(pool, netSettings.Hops))
authed.Post("/auth/logout", h.handleLogout) authed.Post("/auth/logout", h.handleLogout)
authed.Get("/me", h.handleGetMe) authed.Get("/me", h.handleGetMe)
authed.Get("/me/system-playlists-status", h.handleGetSystemPlaylistsStatus) authed.Get("/me/system-playlists-status", h.handleGetSystemPlaylistsStatus)
@@ -83,6 +89,9 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev
authed.Put("/me/timezone", h.handlePutTimezone) authed.Put("/me/timezone", h.handlePutTimezone)
authed.Get("/me/api-token", h.handleGetMyAPIToken) authed.Get("/me/api-token", h.handleGetMyAPIToken)
authed.Post("/me/api-token", h.handleRegenerateMyAPIToken) authed.Post("/me/api-token", h.handleRegenerateMyAPIToken)
authed.Get("/me/sessions", h.handleListMySessions)
authed.Delete("/me/sessions/{id}", h.handleRevokeMySession)
authed.Post("/me/sessions/logout-others", h.handleRevokeMyOtherSessions)
authed.Get("/artists", h.handleListArtists) authed.Get("/artists", h.handleListArtists)
authed.Get("/artists/{id}", h.handleGetArtist) authed.Get("/artists/{id}", h.handleGetArtist)
@@ -93,6 +102,11 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev
authed.Get("/albums/{id}/cover", h.handleGetCover) authed.Get("/albums/{id}/cover", h.handleGetCover)
authed.Get("/library/shuffle", h.handleLibraryShuffle) authed.Get("/library/shuffle", h.handleLibraryShuffle)
authed.Get("/library/albums", h.handleListLibraryAlbums) authed.Get("/library/albums", h.handleListLibraryAlbums)
// Browse indexes (#367). Genre filtering rides
// /library/albums?genre= rather than a path segment, because raw
// ID3 genres contain slashes ("Rock/Pop") that a path can't carry.
authed.Get("/library/genres", h.handleListGenres)
authed.Get("/library/years", h.handleListAlbumYears)
authed.Get("/library/sync", h.handleLibrarySync) authed.Get("/library/sync", h.handleLibrarySync)
authed.Get("/tracks/{id}", h.handleGetTrack) authed.Get("/tracks/{id}", h.handleGetTrack)
// /tracks/{id}/stream is mounted above with OptionalUser so // /tracks/{id}/stream is mounted above with OptionalUser so
@@ -100,6 +114,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)
@@ -173,6 +192,9 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev
admin.Post("/albums/{id}/cover/refetch", h.handleAdminAlbumRefetchCover) admin.Post("/albums/{id}/cover/refetch", h.handleAdminAlbumRefetchCover)
admin.Post("/covers/refetch-missing", h.handleAdminBulkRefetchCovers) admin.Post("/covers/refetch-missing", h.handleAdminBulkRefetchCovers)
admin.Get("/network-settings", h.handleGetNetworkSettings)
admin.Put("/network-settings", h.handleUpdateNetworkSettings)
admin.Get("/scan/status", h.handleGetScanStatus) admin.Get("/scan/status", h.handleGetScanStatus)
admin.Post("/scan/run", h.handleTriggerScan) admin.Post("/scan/run", h.handleTriggerScan)
@@ -198,9 +220,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 +258,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,12 +267,16 @@ 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
mailer mailer.Sender mailer mailer.Sender
eventbus *eventbus.Bus eventbus *eventbus.Bus
playlistScheduler *playlists.Scheduler playlistScheduler *playlists.Scheduler
// netSettings caches the trusted reverse-proxy depth read by the auth
// middleware on every request and edited from the admin network card.
netSettings *netsettings.Service
// streamSecret is the HMAC key used by SignStreamToken / // streamSecret is the HMAC key used by SignStreamToken /
// VerifyStreamToken to authenticate the UPnP-speaker stream path // VerifyStreamToken to authenticate the UPnP-speaker stream path
// (see internal/api/stream_token.go and the design at // (see internal/api/stream_token.go and the design at
+5
View File
@@ -96,6 +96,11 @@ func (h *handlers) handleLogin(w http.ResponseWriter, r *http.Request) {
UserID: user.ID, UserID: user.ID,
TokenHash: auth.HashSessionToken(token), TokenHash: auth.HashSessionToken(token),
UserAgent: r.UserAgent(), UserAgent: r.UserAgent(),
// Origin address, frozen at issue time. Compared against last_ip in
// the active-sessions surface: a session that was born somewhere the
// user recognises but is being used from somewhere they don't is the
// case this whole surface exists to surface.
Ip: auth.ClientIP(r, h.netSettings.Hops()),
}); err != nil { }); err != nil {
h.logger.Error("api: insert session failed", "err", err) h.logger.Error("api: insert session failed", "err", err)
writeErr(w, apierror.InternalMsg("insert failed", err)) writeErr(w, apierror.InternalMsg("insert failed", err))
+1
View File
@@ -175,6 +175,7 @@ func (h *handlers) handleRegister(w http.ResponseWriter, r *http.Request) {
UserID: user.ID, UserID: user.ID,
TokenHash: auth.HashSessionToken(sessionToken), TokenHash: auth.HashSessionToken(sessionToken),
UserAgent: r.UserAgent(), UserAgent: r.UserAgent(),
Ip: auth.ClientIP(r, h.netSettings.Hops()),
}); err != nil { }); err != nil {
h.logger.Error("register: insert session failed", "err", err) h.logger.Error("register: insert session failed", "err", err)
writeErr(w, apierror.Internal(err)) writeErr(w, apierror.Internal(err))
+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
} }
+10
View File
@@ -183,3 +183,13 @@ func parsePaging(raw url.Values) (limit, offset int, err error) {
} }
return limit, offset, nil return limit, offset, nil
} }
// nonNilStrings guarantees a JSON array rather than null. The clients iterate
// these without a null check, matching how every other list field in this
// package is emitted.
func nonNilStrings(in []string) []string {
if in == nil {
return []string{}
}
return in
}
+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))
+14
View File
@@ -82,9 +82,17 @@ func (h *handlers) handleGetAlbum(w http.ResponseWriter, r *http.Request) {
refs = append(refs, ref) refs = append(refs, ref)
durSec += ref.DurationSec durSec += ref.DurationSec
} }
// Genre chips are a navigation nicety, so a failure here must not 404 an
// album that loaded fine. Log and ship the detail without them.
genres, err := q.ListGenresForAlbum(r.Context(), album.ID)
if err != nil {
h.logger.Warn("api: list album genres failed", "err", err, "album_id", uuidToString(album.ID))
genres = nil
}
detail := AlbumDetail{ detail := AlbumDetail{
AlbumRef: albumRefFrom(album, artistName, len(tracks), durSec), AlbumRef: albumRefFrom(album, artistName, len(tracks), durSec),
Tracks: refs, Tracks: refs,
Genres: nonNilStrings(genres),
} }
writeJSON(w, http.StatusOK, detail) writeJSON(w, http.StatusOK, detail)
} }
@@ -114,9 +122,15 @@ func (h *handlers) handleGetArtist(w http.ResponseWriter, r *http.Request) {
// durationSec=0: not aggregated for nested album lists per spec data flow. // durationSec=0: not aggregated for nested album lists per spec data flow.
refs = append(refs, albumRefFrom(row.Album, artist.Name, int(row.TrackCount), 0)) refs = append(refs, albumRefFrom(row.Album, artist.Name, int(row.TrackCount), 0))
} }
genres, err := q.ListGenresForArtist(r.Context(), artist.ID)
if err != nil {
h.logger.Warn("api: list artist genres failed", "err", err, "artist_id", uuidToString(artist.ID))
genres = nil
}
detail := ArtistDetail{ detail := ArtistDetail{
ArtistRef: artistRefFrom(artist, len(rows)), ArtistRef: artistRefFrom(artist, len(rows)),
Albums: refs, Albums: refs,
Genres: nonNilStrings(genres),
} }
writeJSON(w, http.StatusOK, detail) writeJSON(w, http.StatusOK, detail)
} }
+162 -15
View File
@@ -1,42 +1,189 @@
package api package api
import ( import (
"context"
"errors"
"net/http" "net/http"
"net/url"
"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"
) )
// Widest plausible bounds for an open-ended year filter. A missing year_from
// means "from the beginning" rather than "from year zero of the query", and
// likewise for year_to, so the caller can filter on one edge only.
const (
minBrowseYear = 0
maxBrowseYear = 9999
)
var (
errBadYear = errors.New("year_from and year_to must be integers")
errInvertedYearRange = errors.New("year_from must not be greater than year_to")
)
// yearFilter carries a parsed, validated inclusive year range. active is false
// when the request asked for no year filtering at all — distinct from a range
// that happens to cover everything, because the two take different code paths.
type yearFilter struct {
from int32
to int32
active bool
}
// handleListLibraryAlbums implements GET /api/library/albums. Mirrors // handleListLibraryAlbums implements GET /api/library/albums. Mirrors
// /api/artists?sort=alpha but for albums. The new wrapping-grid page on // /api/artists?sort=alpha but for albums. The new wrapping-grid page on
// the SPA infinite-scrolls against this endpoint via TanStack // the SPA infinite-scrolls against this endpoint via TanStack
// createInfiniteQuery. // createInfiniteQuery.
//
// Optional filters (#367): `genre` and `year_from`/`year_to`.
//
// Genre arrives as a QUERY parameter rather than a path segment on purpose.
// Raw ID3 genres routinely contain a slash — "Rock/Pop" is a real tag, and
// the one the task itself cites — which cannot survive a path segment: Go
// normalises %2F and the router would split the value into two segments.
func (h *handlers) handleListLibraryAlbums(w http.ResponseWriter, r *http.Request) { func (h *handlers) handleListLibraryAlbums(w http.ResponseWriter, r *http.Request) {
limit, offset, err := parsePaging(r.URL.Query()) limit, offset, err := parsePaging(r.URL.Query())
if err != nil { if err != nil {
writeErr(w, apierror.BadRequest("bad_request", err.Error())) writeErr(w, apierror.BadRequest("bad_request", err.Error()))
return return
} }
q := dbq.New(h.pool) genre := strings.TrimSpace(r.URL.Query().Get("genre"))
rows, err := q.ListAlbumsAlphaWithArtist(r.Context(), dbq.ListAlbumsAlphaWithArtistParams{ years, err := parseYearFilter(r.URL.Query())
Limit: int32(limit), Offset: int32(offset),
})
if err != nil { if err != nil {
h.logger.Error("api: list library albums", "err", err) writeErr(w, apierror.BadRequest("bad_request", err.Error()))
return
}
if genre != "" && years.active {
// Refused rather than silently honouring one: the UI browses these as
// separate axes (a genres page, a year filter on the albums page), so
// the combination can only arrive from a caller that has misunderstood
// the contract — and quietly dropping half a filter would report a
// narrower result set than it actually returned.
writeErr(w, apierror.BadRequest("unsupported_filter_combination",
"genre and year filters cannot be combined"))
return
}
q := dbq.New(h.pool)
var (
items []AlbumRef
total int64
)
switch {
case genre != "":
items, total, err = albumsByGenre(r.Context(), q, genre, limit, offset)
case years.active:
items, total, err = albumsByYear(r.Context(), q, years, limit, offset)
default:
items, total, err = albumsAlpha(r.Context(), q, limit, offset)
}
if err != nil {
h.logger.Error("api: list library albums", "err", err, "genre", genre, "years", years.active)
writeErr(w, apierror.InternalMsg("lookup failed", err)) writeErr(w, apierror.InternalMsg("lookup failed", err))
return return
} }
total, err := q.CountAlbums(r.Context())
if err != nil {
h.logger.Error("api: count albums", "err", err)
writeErr(w, apierror.InternalMsg("count failed", err))
return
}
items := make([]AlbumRef, 0, len(rows))
for _, row := range rows {
items = append(items, albumRefFrom(row.Album, row.ArtistName, 0, 0))
}
writeJSON(w, http.StatusOK, Page[AlbumRef]{ writeJSON(w, http.StatusOK, Page[AlbumRef]{
Items: items, Total: int(total), Limit: limit, Offset: offset, Items: items, Total: int(total), Limit: limit, Offset: offset,
}) })
} }
func albumsAlpha(
ctx context.Context, q *dbq.Queries, limit, offset int,
) ([]AlbumRef, int64, error) {
rows, err := q.ListAlbumsAlphaWithArtist(ctx, dbq.ListAlbumsAlphaWithArtistParams{
Limit: int32(limit), Offset: int32(offset),
})
if err != nil {
return nil, 0, err
}
total, err := q.CountAlbums(ctx)
if err != nil {
return nil, 0, err
}
items := make([]AlbumRef, 0, len(rows))
for _, row := range rows {
items = append(items, albumRefFrom(row.Album, row.ArtistName, 0, 0))
}
return items, total, nil
}
func albumsByGenre(
ctx context.Context, q *dbq.Queries, genre string, limit, offset int,
) ([]AlbumRef, int64, error) {
rows, err := q.ListAlbumsByGenreWithArtist(ctx, dbq.ListAlbumsByGenreWithArtistParams{
Genre: genre, Lim: int32(limit), Off: int32(offset),
})
if err != nil {
return nil, 0, err
}
total, err := q.CountAlbumsByGenre(ctx, genre)
if err != nil {
return nil, 0, err
}
items := make([]AlbumRef, 0, len(rows))
for _, row := range rows {
items = append(items, albumRefFrom(row.Album, row.ArtistName, 0, 0))
}
return items, total, nil
}
func albumsByYear(
ctx context.Context, q *dbq.Queries, years yearFilter, limit, offset int,
) ([]AlbumRef, int64, error) {
rows, err := q.ListAlbumsByYearRangeWithArtist(ctx,
dbq.ListAlbumsByYearRangeWithArtistParams{
YearFrom: years.from, YearTo: years.to,
Lim: int32(limit), Off: int32(offset),
})
if err != nil {
return nil, 0, err
}
total, err := q.CountAlbumsByYearRange(ctx, dbq.CountAlbumsByYearRangeParams{
YearFrom: years.from, YearTo: years.to,
})
if err != nil {
return nil, 0, err
}
items := make([]AlbumRef, 0, len(rows))
for _, row := range rows {
items = append(items, albumRefFrom(row.Album, row.ArtistName, 0, 0))
}
return items, total, nil
}
// parseYearFilter reads year_from / year_to. Either may be omitted, which
// leaves that edge open — filtering "everything before 1990" shouldn't
// require inventing a lower bound.
func parseYearFilter(raw url.Values) (yearFilter, error) {
fromRaw := strings.TrimSpace(raw.Get("year_from"))
toRaw := strings.TrimSpace(raw.Get("year_to"))
if fromRaw == "" && toRaw == "" {
return yearFilter{}, nil
}
f := yearFilter{from: minBrowseYear, to: maxBrowseYear, active: true}
if fromRaw != "" {
n, err := strconv.Atoi(fromRaw)
if err != nil {
return yearFilter{}, errBadYear
}
f.from = int32(n)
}
if toRaw != "" {
n, err := strconv.Atoi(toRaw)
if err != nil {
return yearFilter{}, errBadYear
}
f.to = int32(n)
}
if f.from > f.to {
// Rejected rather than swapped: silently reordering would return
// results for a range the caller didn't ask for, and an inverted
// range is far more likely a bug than an intent.
return yearFilter{}, errInvertedYearRange
}
return f, nil
}
+65
View File
@@ -0,0 +1,65 @@
package api
import (
"net/http"
"git.fabledsword.com/bvandeusen/minstrel/internal/apierror"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
)
// genreCount is one row of the genre browse index (#367).
//
// Genres are the raw ID3 strings, split on [;,] but otherwise untouched — no
// case folding and no synonym mapping. So "Rock" and "rock" can both appear,
// as can "Rock/Pop" alongside "Rock" and "Pop". That's deliberate for v1: the
// alternative is a normalisation table to invent and maintain, and the raw
// spread has to be visible before anyone can judge whether it's a problem.
type genreCount struct {
Genre string `json:"genre"`
TrackCount int `json:"track_count"`
}
// yearCount is one row of the year browse index.
type yearCount struct {
Year int `json:"year"`
AlbumCount int `json:"album_count"`
}
// handleListGenres implements GET /api/library/genres.
//
// Unpaged on purpose. Even a messy library yields hundreds of distinct tag
// strings, not thousands, and the client needs the whole set at once to render
// a browsable index — paging it would mean the UI could only ever show a
// prefix of an ordering the user didn't choose.
func (h *handlers) handleListGenres(w http.ResponseWriter, r *http.Request) {
rows, err := dbq.New(h.pool).ListGenresWithCount(r.Context())
if err != nil {
h.logger.Error("api: list genres", "err", err)
writeErr(w, apierror.InternalMsg("lookup failed", err))
return
}
out := make([]genreCount, 0, len(rows))
for _, row := range rows {
out = append(out, genreCount{Genre: row.Genre, TrackCount: int(row.TrackCount)})
}
writeJSON(w, http.StatusOK, out)
}
// handleListAlbumYears implements GET /api/library/years.
//
// Albums with no release_date are absent rather than bucketed under 0 — "year
// unknown" isn't a year, and inventing a row for it would put a fake entry at
// one end of a chronological list.
func (h *handlers) handleListAlbumYears(w http.ResponseWriter, r *http.Request) {
rows, err := dbq.New(h.pool).ListAlbumYearsWithCount(r.Context())
if err != nil {
h.logger.Error("api: list album years", "err", err)
writeErr(w, apierror.InternalMsg("lookup failed", err))
return
}
out := make([]yearCount, 0, len(rows))
for _, row := range rows {
out = append(out, yearCount{Year: int(row.Year), AlbumCount: int(row.AlbumCount)})
}
writeJSON(w, http.StatusOK, out)
}
+325
View File
@@ -0,0 +1,325 @@
package api
import (
"encoding/json"
"net/http"
"net/http/httptest"
"net/url"
"testing"
)
// parseYearFilter is pure, so this runs in the fast lane rather than waiting
// on the integration job.
func TestParseYearFilter(t *testing.T) {
tests := []struct {
name string
query string
wantActive bool
wantFrom int32
wantTo int32
wantErr error
}{
{name: "no params means no filtering", query: "", wantActive: false},
{
name: "both bounds", query: "year_from=1990&year_to=1999",
wantActive: true, wantFrom: 1990, wantTo: 1999,
},
{
// "everything from 2000 onward" shouldn't require the caller to
// invent an upper bound.
name: "from only leaves the upper edge open", query: "year_from=2000",
wantActive: true, wantFrom: 2000, wantTo: maxBrowseYear,
},
{
name: "to only leaves the lower edge open", query: "year_to=1979",
wantActive: true, wantFrom: minBrowseYear, wantTo: 1979,
},
{
name: "a single year is a degenerate range", query: "year_from=1985&year_to=1985",
wantActive: true, wantFrom: 1985, wantTo: 1985,
},
{name: "non-numeric from", query: "year_from=nineteen", wantErr: errBadYear},
{name: "non-numeric to", query: "year_to=x", wantErr: errBadYear},
{
// Rejected, not silently swapped — reordering would answer a
// question the caller didn't ask.
name: "inverted range", query: "year_from=2000&year_to=1990",
wantErr: errInvertedYearRange,
},
{
name: "whitespace-only values are treated as absent",
query: "year_from=%20&year_to=%20", wantActive: false,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
raw, err := url.ParseQuery(tc.query)
if err != nil {
t.Fatalf("ParseQuery: %v", err)
}
got, gotErr := parseYearFilter(raw)
if tc.wantErr != nil {
if gotErr != tc.wantErr {
t.Fatalf("error = %v, want %v", gotErr, tc.wantErr)
}
return
}
if gotErr != nil {
t.Fatalf("unexpected error: %v", gotErr)
}
if got.active != tc.wantActive {
t.Errorf("active = %v, want %v", got.active, tc.wantActive)
}
if tc.wantActive && (got.from != tc.wantFrom || got.to != tc.wantTo) {
t.Errorf("range = [%d,%d], want [%d,%d]",
got.from, got.to, tc.wantFrom, tc.wantTo)
}
})
}
}
// The crux of #367: a track tagged "Rock;Pop" must be reachable from BOTH
// genres. An exact-string match — which is what ListAlbumsByGenre did before
// this task — makes every multi-genre track invisible from either of its
// genres, so the index would list a genre whose page is empty.
func TestListGenres_SplitsMultiGenreTags(t *testing.T) {
h, pool := testHandlers(t)
artist := seedArtist(t, pool, "Genre Splitter")
album := seedAlbum(t, pool, artist.ID, "Split Album", 1995)
seedTrackWithGenre(t, pool, album.ID, artist.ID, "Both Genres", 1, 200000, "Rock;Pop")
req := httptest.NewRequest(http.MethodGet, "/api/library/genres", nil)
w := httptest.NewRecorder()
h.handleListGenres(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", w.Code)
}
var got []genreCount
if err := json.NewDecoder(w.Body).Decode(&got); err != nil {
t.Fatalf("decode: %v", err)
}
counts := map[string]int{}
for _, g := range got {
counts[g.Genre] = g.TrackCount
}
for _, want := range []string{"Rock", "Pop"} {
if counts[want] < 1 {
t.Errorf("genre %q missing from index (got %v)", want, counts)
}
}
// The undivided string must NOT appear as its own genre.
if _, ok := counts["Rock;Pop"]; ok {
t.Error(`"Rock;Pop" surfaced as a single genre — the split didn't happen`)
}
}
// Splitting produces leading spaces on every fragment after the first, and
// showing " Pop" as a genre distinct from "Pop" would be a bug. Trimming is a
// repair for our own splitting, not normalisation of the operator's tags.
func TestListGenres_TrimsFragmentWhitespace(t *testing.T) {
h, pool := testHandlers(t)
artist := seedArtist(t, pool, "Spacey Tags")
album := seedAlbum(t, pool, artist.ID, "Spacey Album", 2001)
seedTrackWithGenre(t, pool, album.ID, artist.ID, "Spaced", 1, 200000, "Jazz; Blues ;")
req := httptest.NewRequest(http.MethodGet, "/api/library/genres", nil)
w := httptest.NewRecorder()
h.handleListGenres(w, req)
var got []genreCount
if err := json.NewDecoder(w.Body).Decode(&got); err != nil {
t.Fatalf("decode: %v", err)
}
seen := map[string]bool{}
for _, g := range got {
seen[g.Genre] = true
if g.Genre == "" {
t.Error("empty genre in index — a trailing delimiter leaked through")
}
}
for _, want := range []string{"Jazz", "Blues"} {
if !seen[want] {
t.Errorf("genre %q missing (got %v)", want, keysOf(seen))
}
}
for _, unwanted := range []string{" Blues", "Blues ", " Blues "} {
if seen[unwanted] {
t.Errorf("untrimmed genre %q present", unwanted)
}
}
}
// Genre filtering must agree with the index: every genre the index lists has
// to lead to a non-empty page, which is exactly what the old exact-match
// query could not guarantee.
func TestListLibraryAlbums_GenreFilterReachesMultiGenreTracks(t *testing.T) {
h, pool := testHandlers(t)
artist := seedArtist(t, pool, "Reachable")
album := seedAlbum(t, pool, artist.ID, "Reachable Album", 1998)
seedTrackWithGenre(t, pool, album.ID, artist.ID, "Multi", 1, 200000, "Rock;Pop")
for _, genre := range []string{"Rock", "Pop"} {
t.Run(genre, func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet,
"/api/library/albums?genre="+url.QueryEscape(genre), nil)
w := httptest.NewRecorder()
h.handleListLibraryAlbums(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", w.Code)
}
var page Page[AlbumRef]
if err := json.NewDecoder(w.Body).Decode(&page); err != nil {
t.Fatalf("decode: %v", err)
}
if page.Total < 1 {
t.Fatalf("total = %d, want >=1 — genre %q led to an empty page",
page.Total, genre)
}
found := false
for _, a := range page.Items {
if a.Title == "Reachable Album" {
found = true
}
}
if !found {
t.Errorf("seeded album absent from genre %q results", genre)
}
})
}
}
// A genre containing a slash is why filtering is a query parameter rather
// than a path segment — "Rock/Pop" cannot survive a path.
func TestListLibraryAlbums_GenreWithSlashSurvives(t *testing.T) {
h, pool := testHandlers(t)
artist := seedArtist(t, pool, "Slashed")
album := seedAlbum(t, pool, artist.ID, "Slashed Album", 2003)
seedTrackWithGenre(t, pool, album.ID, artist.ID, "Slashy", 1, 200000, "Rock/Pop")
req := httptest.NewRequest(http.MethodGet,
"/api/library/albums?genre="+url.QueryEscape("Rock/Pop"), nil)
w := httptest.NewRecorder()
h.handleListLibraryAlbums(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", w.Code)
}
var page Page[AlbumRef]
if err := json.NewDecoder(w.Body).Decode(&page); err != nil {
t.Fatalf("decode: %v", err)
}
if page.Total < 1 {
t.Errorf(`total = %d, want >=1 for genre "Rock/Pop"`, page.Total)
}
}
func TestListLibraryAlbums_YearRangeFilter(t *testing.T) {
h, pool := testHandlers(t)
artist := seedArtist(t, pool, "Chronology")
seedAlbum(t, pool, artist.ID, "Old Record", 1972)
seedAlbum(t, pool, artist.ID, "Middle Record", 1995)
seedAlbum(t, pool, artist.ID, "New Record", 2020)
// An undated album must not appear in ANY year range.
seedAlbum(t, pool, artist.ID, "Undated Record", 0)
titles := func(query string) map[string]bool {
t.Helper()
req := httptest.NewRequest(http.MethodGet, "/api/library/albums?"+query, nil)
w := httptest.NewRecorder()
h.handleListLibraryAlbums(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status = %d for %q, want 200", w.Code, query)
}
var page Page[AlbumRef]
if err := json.NewDecoder(w.Body).Decode(&page); err != nil {
t.Fatalf("decode: %v", err)
}
out := map[string]bool{}
for _, a := range page.Items {
out[a.Title] = true
}
return out
}
got := titles("year_from=1990&year_to=2000&limit=200")
if !got["Middle Record"] {
t.Error("Middle Record (1995) missing from 1990-2000")
}
for _, absent := range []string{"Old Record", "New Record", "Undated Record"} {
if got[absent] {
t.Errorf("%s present in 1990-2000 range", absent)
}
}
// Open upper edge.
got = titles("year_from=1990&limit=200")
if !got["Middle Record"] || !got["New Record"] {
t.Error("open-ended year_from should include 1995 and 2020")
}
if got["Old Record"] {
t.Error("Old Record (1972) present in year_from=1990")
}
if got["Undated Record"] {
t.Error("undated album present in an open-ended range")
}
}
func TestListLibraryAlbums_RejectsGenreAndYearTogether(t *testing.T) {
h, _ := testHandlers(t)
req := httptest.NewRequest(http.MethodGet,
"/api/library/albums?genre=Rock&year_from=1990", nil)
w := httptest.NewRecorder()
h.handleListLibraryAlbums(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("status = %d, want 400 for combined filters", w.Code)
}
}
func TestListAlbumYears_ExcludesUndatedAlbums(t *testing.T) {
h, pool := testHandlers(t)
artist := seedArtist(t, pool, "Years Only")
seedAlbum(t, pool, artist.ID, "Dated One", 1984)
seedAlbum(t, pool, artist.ID, "No Date", 0)
req := httptest.NewRequest(http.MethodGet, "/api/library/years", nil)
w := httptest.NewRecorder()
h.handleListAlbumYears(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", w.Code)
}
var got []yearCount
if err := json.NewDecoder(w.Body).Decode(&got); err != nil {
t.Fatalf("decode: %v", err)
}
found1984 := false
for _, y := range got {
if y.Year == 1984 {
found1984 = true
}
if y.Year == 0 {
t.Error("year 0 present — undated albums leaked into the index")
}
}
if !found1984 {
t.Error("1984 missing from the year index")
}
// Newest-first ordering, so a picker reads chronologically without the
// client re-sorting.
for i := 1; i < len(got); i++ {
if got[i-1].Year < got[i].Year {
t.Errorf("years not descending at %d: %d then %d", i, got[i-1].Year, got[i].Year)
}
}
}
func keysOf(m map[string]bool) []string {
out := make([]string, 0, len(m))
for k := range m {
out = append(out, k)
}
return out
}
+4 -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, h.netSettings)
paths := []string{ paths := []string{
"/api/artists", "/api/artists",
@@ -475,6 +475,9 @@ func TestRoutesRegisteredInMount(t *testing.T) {
"/api/tracks/00000000-0000-0000-0000-000000000001", "/api/tracks/00000000-0000-0000-0000-000000000001",
"/api/tracks/00000000-0000-0000-0000-000000000001/stream", "/api/tracks/00000000-0000-0000-0000-000000000001/stream",
"/api/search?q=x", "/api/search?q=x",
// Browse indexes (#367).
"/api/library/genres",
"/api/library/years",
} }
for _, p := range paths { for _, p := range paths {
req := httptest.NewRequest(http.MethodGet, p, nil) req := httptest.NewRequest(http.MethodGet, p, nil)
+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)
} }
} }
+136
View File
@@ -0,0 +1,136 @@
package api
import (
"errors"
"net/http"
"time"
"github.com/go-chi/chi/v5"
"git.fabledsword.com/bvandeusen/minstrel/internal/apierror"
"git.fabledsword.com/bvandeusen/minstrel/internal/audit"
"git.fabledsword.com/bvandeusen/minstrel/internal/auth"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
)
// errNoCurrentSession means the request authenticated but the middleware
// didn't record which session did it — which should be impossible on a route
// behind RequireUser. It matters because "log out everywhere else" is defined
// by exclusion: without knowing which session is ours, the safe-looking
// action would sign the caller out too.
var errNoCurrentSession = errors.New("no session id in request context")
// sessionResp is one row of the active-sessions list.
//
// token_hash is absent, and that is the point of storing only a hash: it
// never leaves the database, so this surface can list sessions without
// handing out anything that could be replayed.
type sessionResp struct {
ID string `json:"id"`
UserAgent string `json:"user_agent"`
// CreatedIP is frozen at issue time; LastIP moves with the session. The
// pair is what makes a stolen token legible — same device string, but an
// address the user doesn't recognise.
CreatedIP string `json:"created_ip"`
LastIP string `json:"last_ip"`
CreatedAt time.Time `json:"created_at"`
LastSeenAt time.Time `json:"last_seen_at"`
// Current marks the session making this request so the UI can label it
// and not offer a "log out" that signs the user out of the page they're
// standing on.
Current bool `json:"current"`
}
type revokedResp struct {
Revoked int `json:"revoked"`
}
// handleListMySessions implements GET /api/me/sessions.
func (h *handlers) handleListMySessions(w http.ResponseWriter, r *http.Request) {
user, ok := requireUser(w, r)
if !ok {
return
}
// Absent id is tolerated here (unlike logout-others): the list still
// renders, it just won't flag a current row.
currentID, _ := auth.SessionIDFromContext(r.Context())
rows, err := dbq.New(h.pool).ListSessionsForUser(r.Context(), user.ID)
if err != nil {
h.logger.Error("list sessions: query failed", "err", err)
writeErr(w, apierror.Internal(err))
return
}
out := make([]sessionResp, 0, len(rows))
for _, s := range rows {
out = append(out, sessionResp{
ID: uuidToString(s.ID),
UserAgent: s.UserAgent,
CreatedIP: s.CreatedIp,
LastIP: s.LastIp,
CreatedAt: s.CreatedAt.Time,
LastSeenAt: s.LastSeenAt.Time,
Current: s.ID == currentID,
})
}
writeJSON(w, http.StatusOK, out)
}
// handleRevokeMySession implements DELETE /api/me/sessions/{id}.
func (h *handlers) handleRevokeMySession(w http.ResponseWriter, r *http.Request) {
user, ok := requireUser(w, r)
if !ok {
return
}
id, ok := parseUUID(chi.URLParam(r, "id"))
if !ok {
// Malformed and belongs-to-someone-else collapse to one answer on
// purpose: a distinguishable response would let a caller probe
// whether another user's session id exists.
writeErr(w, apierror.NotFound("session"))
return
}
n, err := dbq.New(h.pool).DeleteSessionForUser(r.Context(), dbq.DeleteSessionForUserParams{
ID: id,
UserID: user.ID,
})
if err != nil {
h.logger.Error("revoke session: delete failed", "err", err)
writeErr(w, apierror.Internal(err))
return
}
if n == 0 {
writeErr(w, apierror.NotFound("session"))
return
}
audit.WriteOrLog(r.Context(), h.pool, h.logger, user.ID, user.ID, audit.ActionSessionRevoke, nil)
w.WriteHeader(http.StatusNoContent)
}
// handleRevokeMyOtherSessions implements POST /api/me/sessions/logout-others.
func (h *handlers) handleRevokeMyOtherSessions(w http.ResponseWriter, r *http.Request) {
user, ok := requireUser(w, r)
if !ok {
return
}
currentID, ok := auth.SessionIDFromContext(r.Context())
if !ok {
// Refuse rather than guess: deleting "all but unknown" is deleting
// all, which would log the caller out of the page they invoked this
// from and look exactly like the attack they were defending against.
h.logger.Error("revoke other sessions: no session id in context")
writeErr(w, apierror.Internal(errNoCurrentSession))
return
}
n, err := dbq.New(h.pool).DeleteOtherSessionsForUser(r.Context(), dbq.DeleteOtherSessionsForUserParams{
UserID: user.ID,
ID: currentID,
})
if err != nil {
h.logger.Error("revoke other sessions: delete failed", "err", err)
writeErr(w, apierror.Internal(err))
return
}
audit.WriteOrLog(r.Context(), h.pool, h.logger, user.ID, user.ID, audit.ActionSessionRevokeOthers, nil)
writeJSON(w, http.StatusOK, revokedResp{Revoked: int(n)})
}
+226
View File
@@ -0,0 +1,226 @@
package api
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/go-chi/chi/v5"
"github.com/jackc/pgx/v5/pgtype"
"github.com/jackc/pgx/v5/pgxpool"
"git.fabledsword.com/bvandeusen/minstrel/internal/auth"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
)
// seedSession inserts a session for userID and returns its id.
func seedSession(t *testing.T, pool *pgxpool.Pool, userID pgtype.UUID, ip string) pgtype.UUID {
t.Helper()
token, err := auth.MintSessionToken()
if err != nil {
t.Fatalf("mint: %v", err)
}
sess, err := dbq.New(pool).InsertSession(context.Background(), dbq.InsertSessionParams{
UserID: userID,
TokenHash: auth.HashSessionToken(token),
UserAgent: "test-agent",
Ip: ip,
})
if err != nil {
t.Fatalf("insert session: %v", err)
}
return sess.ID
}
// withSession attaches the user and current-session id the handlers expect
// from RequireUser.
func withSession(r *http.Request, user dbq.User, sessionID pgtype.UUID) *http.Request {
ctx := context.WithValue(r.Context(), userCtxKeyForTest(), user)
ctx = context.WithValue(ctx, auth.SessionIDCtxKeyForTest(), sessionID)
return r.WithContext(ctx)
}
// withURLParam wires a chi route param, which handlers read via chi.URLParam.
func withURLParam(r *http.Request, key, value string) *http.Request {
rctx := chi.NewRouteContext()
rctx.URLParams.Add(key, value)
return r.WithContext(context.WithValue(r.Context(), chi.RouteCtxKey, rctx))
}
// The rule #47 assertion. A delete keyed only on session id would let any
// household member revoke any other member's session by id — this pins that
// the user scope is actually in the WHERE clause and not just intended.
func TestRevokeMySession_CannotRevokeAnotherUsersSession(t *testing.T) {
h, pool := testHandlers(t)
alice := seedUser(t, pool, "alice", "hunter2", false)
bob := seedUser(t, pool, "bob", "hunter2", false)
bobSession := seedSession(t, pool, bob.ID, "203.0.113.9")
aliceSession := seedSession(t, pool, alice.ID, "203.0.113.1")
target := uuidToString(bobSession)
req := httptest.NewRequest(http.MethodDelete, "/api/me/sessions/"+target, nil)
req = withURLParam(req, "id", target)
req = withSession(req, alice, aliceSession)
w := httptest.NewRecorder()
h.handleRevokeMySession(w, req)
if w.Code != http.StatusNotFound {
t.Errorf("status = %d, want 404 (not another user's to revoke)", w.Code)
}
// The 404 must mean "didn't happen", not merely "wasn't reported".
var stillThere bool
if err := pool.QueryRow(context.Background(),
`SELECT EXISTS (SELECT 1 FROM sessions WHERE id = $1)`, bobSession,
).Scan(&stillThere); err != nil {
t.Fatalf("exists check: %v", err)
}
if !stillThere {
t.Error("bob's session was deleted by alice's request")
}
}
func TestRevokeMySession_DeletesOwnSession(t *testing.T) {
h, pool := testHandlers(t)
alice := seedUser(t, pool, "alice", "hunter2", false)
current := seedSession(t, pool, alice.ID, "203.0.113.1")
other := seedSession(t, pool, alice.ID, "198.51.100.7")
target := uuidToString(other)
req := httptest.NewRequest(http.MethodDelete, "/api/me/sessions/"+target, nil)
req = withURLParam(req, "id", target)
req = withSession(req, alice, current)
w := httptest.NewRecorder()
h.handleRevokeMySession(w, req)
if w.Code != http.StatusNoContent {
t.Fatalf("status = %d, want 204", w.Code)
}
var gone bool
if err := pool.QueryRow(context.Background(),
`SELECT NOT EXISTS (SELECT 1 FROM sessions WHERE id = $1)`, other,
).Scan(&gone); err != nil {
t.Fatalf("exists check: %v", err)
}
if !gone {
t.Error("session survived its own owner's revoke")
}
}
// "Log out everywhere else" must spare the caller — otherwise the button
// signs you out of the page you pressed it on, which is indistinguishable
// from the compromise it's meant to remedy.
func TestRevokeMyOtherSessions_SparesCurrentAndOtherUsers(t *testing.T) {
h, pool := testHandlers(t)
alice := seedUser(t, pool, "alice", "hunter2", false)
bob := seedUser(t, pool, "bob", "hunter2", false)
current := seedSession(t, pool, alice.ID, "203.0.113.1")
seedSession(t, pool, alice.ID, "198.51.100.7")
seedSession(t, pool, alice.ID, "198.51.100.8")
bobSession := seedSession(t, pool, bob.ID, "203.0.113.9")
req := httptest.NewRequest(http.MethodPost, "/api/me/sessions/logout-others", nil)
req = withSession(req, alice, current)
w := httptest.NewRecorder()
h.handleRevokeMyOtherSessions(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", w.Code)
}
var body revokedResp
if err := json.NewDecoder(w.Body).Decode(&body); err != nil {
t.Fatalf("decode: %v", err)
}
if body.Revoked != 2 {
t.Errorf("revoked = %d, want 2 (alice's other two, not bob's)", body.Revoked)
}
var aliceRemaining, bobRemaining int
if err := pool.QueryRow(context.Background(),
`SELECT count(*) FROM sessions WHERE user_id = $1`, alice.ID,
).Scan(&aliceRemaining); err != nil {
t.Fatalf("count alice: %v", err)
}
if aliceRemaining != 1 {
t.Errorf("alice sessions = %d, want 1 (the current one)", aliceRemaining)
}
if err := pool.QueryRow(context.Background(),
`SELECT count(*) FROM sessions WHERE id = $1`, bobSession,
).Scan(&bobRemaining); err != nil {
t.Fatalf("count bob: %v", err)
}
if bobRemaining != 1 {
t.Error("bob's session was caught in alice's logout-others")
}
}
// Without a current-session id the exclusion has nothing to exclude, so the
// handler must refuse rather than delete everything.
func TestRevokeMyOtherSessions_RefusesWithoutCurrentSession(t *testing.T) {
h, pool := testHandlers(t)
alice := seedUser(t, pool, "alice", "hunter2", false)
seedSession(t, pool, alice.ID, "203.0.113.1")
req := httptest.NewRequest(http.MethodPost, "/api/me/sessions/logout-others", nil)
req = req.WithContext(context.WithValue(req.Context(), userCtxKeyForTest(), alice))
w := httptest.NewRecorder()
h.handleRevokeMyOtherSessions(w, req)
if w.Code != http.StatusInternalServerError {
t.Errorf("status = %d, want 500", w.Code)
}
var remaining int
if err := pool.QueryRow(context.Background(),
`SELECT count(*) FROM sessions WHERE user_id = $1`, alice.ID,
).Scan(&remaining); err != nil {
t.Fatalf("count: %v", err)
}
if remaining != 1 {
t.Errorf("sessions = %d, want 1 — refusing must not delete", remaining)
}
}
func TestListMySessions_FlagsCurrentAndScopesToUser(t *testing.T) {
h, pool := testHandlers(t)
alice := seedUser(t, pool, "alice", "hunter2", false)
bob := seedUser(t, pool, "bob", "hunter2", false)
current := seedSession(t, pool, alice.ID, "203.0.113.1")
seedSession(t, pool, alice.ID, "198.51.100.7")
seedSession(t, pool, bob.ID, "203.0.113.9")
req := httptest.NewRequest(http.MethodGet, "/api/me/sessions", nil)
req = withSession(req, alice, current)
w := httptest.NewRecorder()
h.handleListMySessions(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", w.Code)
}
var got []sessionResp
if err := json.NewDecoder(w.Body).Decode(&got); err != nil {
t.Fatalf("decode: %v", err)
}
if len(got) != 2 {
t.Fatalf("sessions = %d, want 2 (bob's must not appear)", len(got))
}
currentCount := 0
for _, s := range got {
if s.Current {
currentCount++
if s.ID != uuidToString(current) {
t.Errorf("current flagged on %s, want %s", s.ID, uuidToString(current))
}
}
if s.CreatedIP == "" {
t.Error("created_ip empty — the whole point of the surface")
}
}
if currentCount != 1 {
t.Errorf("current-flagged rows = %d, want exactly 1", currentCount)
}
}
+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
View File
@@ -89,12 +89,19 @@ type TrackRef struct {
type ArtistDetail struct { type ArtistDetail struct {
ArtistRef ArtistRef
Albums []AlbumRef `json:"albums"` Albums []AlbumRef `json:"albums"`
// Genres carried by this artist's tracks, for quick-jump chips (#367).
// Always non-nil at JSON so the client can iterate without a null check.
Genres []string `json:"genres"`
} }
// AlbumDetail is the response body of GET /api/albums/{id}. // AlbumDetail is the response body of GET /api/albums/{id}.
type AlbumDetail struct { type AlbumDetail struct {
AlbumRef AlbumRef
Tracks []TrackRef `json:"tracks"` Tracks []TrackRef `json:"tracks"`
// Genres carried by this album's tracks, for quick-jump chips (#367).
// Derived from the tracks rather than stored on the album, because genre
// lives on tracks and an album's tracks can disagree. Non-nil at JSON.
Genres []string `json:"genres"`
} }
// SearchResponse is the body of GET /api/search. Each facet carries its own // SearchResponse is the body of GET /api/search. Each facet carries its own
+7
View File
@@ -48,6 +48,13 @@ const (
ActionTokenRegenerate Action = "token_regenerate" ActionTokenRegenerate Action = "token_regenerate"
ActionForgotPasswordInit Action = "forgot_password_initiated" ActionForgotPasswordInit Action = "forgot_password_initiated"
ActionPasswordResetByEmail Action = "password_reset_via_email" ActionPasswordResetByEmail Action = "password_reset_via_email"
// Active-sessions surface (#370). Worth auditing rather than silent:
// revoking sessions is what a user does when they think an account is
// compromised, so the audit trail is most useful precisely when it's
// exercised.
ActionSessionRevoke Action = "session_revoke"
ActionSessionRevokeOthers Action = "session_revoke_others"
) )
// Write inserts one audit_log row. metadata is marshaled as JSON; // Write inserts one audit_log row. metadata is marshaled as JSON;
+111
View File
@@ -0,0 +1,111 @@
package auth
import (
"net"
"net/http"
"strings"
)
// ClientIP returns the caller's address, reading through trustedProxyHops
// reverse proxies (#2453).
//
// X-Forwarded-For grows left-to-right: every proxy APPENDS the peer it
// received the request from. For client -> CDN -> own-proxy -> Minstrel the
// app sees XFF = [client, CDN] and RemoteAddr = own-proxy. Each trusted proxy
// therefore accounts for one entry counting from the right, and the first
// address we were NOT told to trust is the client:
//
// hops 0 -> RemoteAddr; XFF ignored entirely
// hops 1 -> XFF[1] = CDN — trusting only our own proxy, the most we can
// honestly claim is the address it told us about
// hops 2 -> XFF[0] = client
//
// This replaces an earlier heuristic that ignored XFF whenever RemoteAddr was
// public. That was safe but useless in the deployment that matters: a proxy
// on a public address (separate host, or a CDN) meant every session recorded
// the proxy, so the active-sessions surface could never show an address
// change (#370).
//
// # What the operator is asserting
//
// hops >= 1 is a DECLARATION that a proxy sits in front. Two ways to get it
// wrong, both worth understanding rather than papering over:
//
// - Set to 1+ with NO proxy: any client can forge X-Forwarded-For and pick
// what its own session row shows, defeating the compromise detection.
// - Set HIGHER than the real chain: the index runs past the proxy-written
// entries into attacker-supplied ones, same result.
//
// Both are inherent to the trusted-hop model — Rails, Caddy, Traefik and
// nginx all behave this way — which is why 0 is a first-class value and the
// admin card tells the operator to count their proxies.
func ClientIP(r *http.Request, trustedProxyHops int) string {
remote := hostOf(r.RemoteAddr)
if trustedProxyHops <= 0 {
return remote
}
chain := forwardedChain(r)
if len(chain) == 0 {
// No forwarding header: either there's genuinely no proxy, or one is
// misconfigured. The socket peer is the only thing we actually know.
return remote
}
// Clamp rather than reject: a chain shorter than the configured depth
// means the operator over-counted, and the leftmost entry is the closest
// thing to a client on offer. The caveat above covers the risk.
idx := len(chain) - trustedProxyHops
if idx < 0 {
idx = 0
}
if ip := net.ParseIP(chain[idx]); ip != nil {
return ip.String()
}
// A proxy wrote something that isn't an address. Positional meaning is
// lost, so fall back to what we can verify ourselves.
return remote
}
// forwardedChain returns the X-Forwarded-For entries in wire order, or the
// single X-Real-IP value when XFF is absent.
//
// Entries are kept verbatim, including unparseable ones: their POSITION is
// what carries meaning here, so silently dropping a malformed hop would
// shift every index and could hand back an attacker-supplied entry.
func forwardedChain(r *http.Request) []string {
raw := r.Header.Get("X-Forwarded-For")
if strings.TrimSpace(raw) == "" {
// Some proxies set only X-Real-IP, which by construction is a single
// hop — the address that proxy saw.
if real := strings.TrimSpace(r.Header.Get("X-Real-IP")); real != "" {
return []string{real}
}
return nil
}
parts := strings.Split(raw, ",")
out := make([]string, 0, len(parts))
for _, p := range parts {
if p = strings.TrimSpace(p); p != "" {
out = append(out, p)
}
}
return out
}
// hopsOf reads a trusted-depth accessor, treating a nil one as "trust
// nothing". Test contexts and any future caller that hasn't wired the
// settings service get the safe reading rather than a panic.
func hopsOf(fn func() int) int {
if fn == nil {
return 0
}
return fn()
}
// hostOf strips the port from a RemoteAddr, tolerating values that have none.
func hostOf(remoteAddr string) string {
host, _, err := net.SplitHostPort(remoteAddr)
if err != nil {
return strings.TrimSpace(remoteAddr)
}
return host
}

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