1b7fa635d81c4736aad4637ce0112771e691c3ec
1843
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
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
|
||
|
|
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. |
||
|
|
a9ca49dc4e |
feat(library): genre + year quick-jumps on album and artist detail — #367
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. |
||
|
|
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. |
||
|
|
f8f2273aec |
style: gofmt alignment in library_browse_test — #367
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
|
||
|
|
1126bfcf78 |
feat(library): genre + year browse queries and endpoints — #367
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. |
||
|
|
5b36d79ff9 |
fix(server): access log reports the real client, not the proxy — #2453
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. |
||
|
|
11538095be |
fix(net): validate hop range before checking availability — #2453
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. |
||
|
|
d5ab3b0764 |
fix(net): update the in-package Mount call site in library_test — #2453
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. |
||
|
|
a07fb3867a |
fix(net): thread hops into session creation; disambiguate card tests — #2453
Two CI failures from
|
||
|
|
381e9cedb7 |
feat(net): trusted-proxy depth so real client IPs survive a proxy — #2453
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. |
||
|
|
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. |
||
|
|
d86af7397d |
feat(auth): active sessions API with origin/current IP — #370
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
57d2299180 |
Merge pull request 'Queue row gestures: album art as grab surface + swipe-to-remove' (#118) from dev into main
|
||
|
|
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 |
||
|
|
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 |
||
|
|
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> |
||
|
|
6dea45a634 |
feat(player): album art is the queue's grab surface — #2395
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>
|
||
|
|
fa7ea41ccf | Merge pull request 'Minstrel gets a mark — favicon, header lockup, Android adaptive icon' (#117) from dev into main | ||
|
|
e1e591b520 |
feat(brand): Minstrel mark — favicon, header lockup, Android adaptive icon
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>
|
||
|
|
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
|
||
|
|
eec59193fa |
feat(discover): explain the taste match on both clients — #2377 (clients)
"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>
|
||
|
|
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> |
||
|
|
cf0d37bf8e |
fix(discover): compare against a baseline run, not a hardcoded score — #2377
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> |
||
|
|
799dab029a |
feat(discover): rank suggestions by taste-tag overlap — #2377 (server)
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>
|
||
|
|
7315e37c15 |
fix(db): apply sqlc's actual output for candidate_artist_tags — #2376
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>
|
||
|
|
4f9b083eec |
feat(discover): artist-tag cache for out-of-library candidates — #2376
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> |
||
|
|
f17356560d |
fix(discover): "in about a month" was unreachable in both clients — #2375
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> |
||
|
|
18a61f1065 |
fix(discover): complete the page-test mock + drop a return from returnsIn — #2375
Two CI failures from
|
||
|
|
6e39471a70 |
feat(discover): snooze affordance on Android + web suggestion cards — #2375
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> |
||
|
|
86af79bd2f |
feat(discover): time-boxed suggestion snooze, server side — #2374
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> |
||
|
|
e006de5d4b |
fix(db): apply sqlc's actual output for SuggestArtistsForUser — #2380
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> |
||
|
|
94e2cac03b |
ci(go): verify committed sqlc output matches its .sql sources — #2380
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> |
||
|
|
b27029f674 |
feat(discover): rotate the suggestion deck daily + cap one seed's share — #2373
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> |
||
|
|
14aa22198f |
feat(discover): seed request suggestions from the taste profile — #2372
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> |
||
|
|
1138d75a45 | Merge pull request 'Playlist-track atomic replace + ci-requirements true-up' (#115) from dev into main | ||
|
|
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> |
||
|
|
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> |
||
|
|
0cea82984c |
Merge pull request 'Home updating-veil rework: change-triggered, settle-driven, with refresh feedback' (#114) from dev into main
|
||
|
|
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> |
||
|
|
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> |
||
|
|
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>
|
||
|
|
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> |
||
|
|
7d45a4e5c7 | ci: artifacts that can actually be downloaded (issue 2270) | ||
|
|
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> |
||
|
|
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>
|
||
|
|
a26ef4e93c |
Merge pull request 'Queue fix + cross-client queue enhancements' (#112) from dev into main
|