Commit Graph
1843 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
v2026.08.05
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
v2026.08.04
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
v2026.08.03
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
v2026.08.01
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
v2026.07.23
2026-07-23 08:31:34 -04:00