21c698a6169658f6f7ffabca407bf817601479cb
1909
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
21c698a616 |
test(web): give the admin page mock the fingerprint coverage query
|
||
|
|
b8855b480f |
feat(library): backfill fingerprints for the existing library — M400 #3908
test-web / test (push) Failing after 50s
test-go / test (push) Successful in 1m7s
test-go / integration (push) Successful in 3m27s
release / Build + push container image (push) Canceled after 0s
release / Verify release artifacts (tag releases only) (push) Canceled after 0s
release / Build signed APK (releases and dev) (push) Canceled after 4m23s
The scan fingerprints only bytes it has not seen, so everything imported before fingerprinting existed, and any row derived by an older fingerprintVersion, needs a pass of its own. That pass is a background worker, not a stage in RunScan. RunScan runs at boot and then every 12h, and an in-flight scan older than an hour is reaped and a second started beside it. A stage would have to stop inside the hour: a few hundred decodes a run, so about a month for a 50k-track library. It would also hold the run in flight and answer manual rescans with 409 while it worked. FingerprintBackfillWorker runs once at start, then hourly. Nothing a pass does (error or panic) can stop the next tick. A pass walks tracks with no fingerprint or a stale version, skipping missing tracks, keyset-paged on id. The cursor is what lets a pass end: an inconclusive attempt writes no row, so a file that keeps timing out would otherwise be re-listed and retried forever. Two decodes at a time, deliberately: they compete with transcoding for CPU and with streaming for the mount. storeFingerprint is now one package function shared by the scan and the worker, and reports whether the attempt was fingerprinted, rejected, inconclusive or failed to store. Progress is a live gauge on the Admin scan card, served by GET /api/admin/library/fingerprints: fingerprinted / rejected / pending of total, with missing tracks excluded so it can reach the end. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SQ31KQpYbStyK5y58UmPLH |
||
|
|
71d4335584 |
docs(readme): the music mount is writable — Minstrel deletes when asked
test-go / test (push) Successful in 1m8s
test-go / integration (push) Successful in 4m10s
release / Build signed APK (releases and dev) (push) Successful in 5m23s
release / Build + push container image (push) Successful in 1m16s
release / Verify release artifacts (tag releases only) (push) Skipped
The quickstart mounted the library :ro and promised "Minstrel never writes to your library". That stopped being true long before #3918: quarantine's Delete file removes files, and under :ro it failed. The operator has accepted delete ownership (Scribe note #3926). The quickstart now mounts it writable and says exactly what Minstrel writes: it deletes a file when an admin asks, and never moves, renames or retags. It notes that uid 1000 needs write access, and that :ro still works, with deletes refusing and explaining why. Reorganising and tag writes stay out, pending whether Minstrel absorbs Lidarr's role. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SQ31KQpYbStyK5y58UmPLH |
||
|
|
702b48ce36 |
fix(lidarrquarantine): pass dataDir at the four stub-client test constructors
|
||
|
|
d7a8e5f300 |
fix(library): a track delete that cannot remove its file deletes nothing — #3918
test-go / test (push) Failing after 55s
test-web / test (push) Successful in 56s
test-go / integration (push) Failing after 4m50s
android / Build + lint + test (push) Successful in 5m52s
release / Build signed APK (releases and dev) (push) Successful in 6m5s
release / Build + push container image (push) Successful in 1m14s
release / Verify release artifacts (tag releases only) (push) Skipped
Two delete paths had opposite failure policies. tracks.RemoveTrack
logged a failed os.Remove and deleted the row anyway, which CASCADEs
likes, plays, playlist memberships and tags, while the file survived
for the next scan to re-import as a stranger. library.DeleteTrackFile
stopped correctly but reported it as a bare 500 nobody could read.
One path now: library.DeleteTrackFile removes the file first and, on
anything but ErrNotExist, returns *FileRemoveError with nothing
deleted. Only then does it delete the row and tidy an emptied album
and artist in one transaction, log the sync change and clear orphaned
artist art. RemoveTrack calls it, which also fixes RemoveTrack never
logging a sync change. Quarantine Delete file now tidies emptied
albums and artists too.
Both endpoints answer an unwritable library (EROFS, EACCES, EPERM) with
409 library_not_writable. The message names the directory (removal
writes to the parent), the uid:gid the server runs as, and that
nothing was deleted. Other remove errors are 500 file_delete_failed
with the path.
The reachable surface is quarantine Delete file, which failed
silently: no copy for the code on either client, and Android swallowed
the exception so the row just reappeared. Web and Android now have
copy for both codes and append the server message for exactly those
two. Android's quarantine screen shows it in a snackbar.
DELETE /api/admin/tracks/{id} has had no client since
|
||
|
|
cba77a5187 |
feat(library): fingerprint every new or changed file — M400 #3905-#3907
test-go / test (push) Successful in 1m9s
test-go / integration (push) Successful in 3m28s
release / Build signed APK (releases and dev) (push) Successful in 4m38s
release / Build + push container image (push) Successful in 1m26s
release / Verify release artifacts (tag releases only) (push) Skipped
Two identities per track, because they answer different questions: - audio_stream_sha256: SHA-256 of the ENCODED audio packets (ffmpeg -map 0:a -c:a copy -f hash). Equal means identical audio whatever the tags say. Measured against the #3885 pair: the two WWW files hash identically here and differently as whole files. Packets rather than decoded samples, so an ffmpeg upgrade cannot silently change every stored hash, and nothing is decoded. - chromaprint: fpcalc -raw -signed. The same recording at another bitrate or codec, for the acoustic tier. fpcalc ships in the image (libchromaprint-tools); shelled out because CGO_ENABLED=0 rules out bindings. Stored in a track_fingerprints table rather than on tracks: eight queries read tracks with SELECT *, including album pages, search and the Subsonic surface, and a ~4 KB array there would be de-TOASTed on every one of them. The scan fingerprints only bytes it has not seen (a new path, or mtime past the row's). A tag-repair pass leaves fingerprints alone, and unchanged files with no fingerprint are the backfill's job (#3908). Folding that into the skip check would re-decode the whole library on the first scan after upgrade and push a sync change per track. A failure that says nothing about the file (timeout, cancelled scan, tool not installed) is never stored, and on changed bytes it removes the old row. A tool that rejects the file stores NULL at the current version, so the backfill does not retry it every boot. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SQ31KQpYbStyK5y58UmPLH |
||
|
|
eff3d88931 |
fix(recommendation): make the candidate draw reproducible, not accidentally so
test-go / test (push) Successful in 1m18s
test-go / integration (push) Successful in 4m52s
release / Build signed APK (releases and dev) (push) Successful in 6m9s
release / Build + push container image (push) Successful in 2m5s
release / Verify release artifacts (tag releases only) (push) Skipped
Four arms of the candidate query ended in a bare `ORDER BY random()` with no seed: similar_artists, likes_overlap, coplay_artists and random_fill. Such an arm returns a STABLE set only while its LIMIT exceeds the rows eligible for it — at that point it returns all of them and the order stops mattering, because scoreAndSortCandidates sorts by track id before drawing jitter. Below that threshold it returns a random SUBSET, and two builds on the same day draw different ones. So daily determinism held BY ACCIDENT, and only for libraries smaller than the limits. Any real library is larger, which means same-day rebuilds have been producing different mixes since those arms were written — invisible, because a mix that changes after a refresh looks like a feature rather than a broken promise. Found by breaking it: cutting RandomFill to 10 while tuning Songs-like turned TestBuildSystemPlaylists_DailyNonceDeterminism red. That test seeds ~20 tracks against a default RandomFill of 30, so its determinism came from the limit exceeding the library, not from the code being right. It is now a real guard. The arms order by md5(id || $12) instead. The CALLER decides what that means, which is the point: system mixes pass a per-(user, day) seed and get the determinism they promise, radio passes a fresh value per request and keeps varying, which is what a radio should do. Same shape the browse queries in this file already use (`md5(id::text || current_date::text)`) — existing idiom, not a new one. This also unblocks the trim that #3881 wanted and could not have. Shrinking a randomly-ordered arm was what broke membership; a seeded one takes a smaller but REPRODUCIBLE slice. Songs-like's seed-independent share drops from 29% to 12%, which was the original intent before determinism forced it back to 20%. TestSongsLikeLimits_DoNotShrinkTheUnseededRandomArms is DELETED rather than kept passing. It existed to stop anyone trimming those arms while the ordering was broken; the ordering is fixed, so the constraint is gone and a guard enforcing it would now forbid correct code. Was filed as blocked on tooling. It was not: `make generate-go` runs sqlc as a pinned Go tool and is the same path CI takes. One thing worth knowing for next time: three files in internal/db/dbq are owned by root, left by `make generate` running sqlc in Docker. sqlc errored on the first it could not write. They are untouched by this change and the regeneration of recommendation.sql.go completed, but `make generate` will keep failing until they are chowned. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SQ31KQpYbStyK5y58UmPLH |
||
|
|
4ce47397a9 |
fix(recommendation): a nil LibrarySize must degrade, not panic
test-go / test (push) Successful in 1m4s
test-go / integration (push) Successful in 3m41s
release / Build signed APK (releases and dev) (push) Successful in 4m37s
release / Build + push container image (push) Successful in 1m58s
release / Verify release artifacts (tag releases only) (push) Skipped
Fixes the integration failure from
|
||
|
|
721154847e |
fix(recommendation): size the candidate pool to the library
test-go / test (push) Successful in 1m5s
test-go / integration (push) Failing after 3m39s
release / Build signed APK (releases and dev) (push) Successful in 4m55s
release / Verify release artifacts (tag releases only) (push) Canceled after 0s
release / Build + push container image (push) Canceled after 1m38s
Operator, 2026-09-10: "is the pool that we draw from somehow scaled to the
amount of music in the library... my earlier understanding of the tuning and
work may have been skewed by what was in my library."
It was not. DefaultCandidateSourceLimits returns what its own comment calls
"the v1 hardcoded constants per spec" — ~170 candidates for a 500-track
library and a 100,000-track one alike. The pool therefore samples a
shrinking FRACTION of a growing collection: 17% of 1,000 tracks, 1.7% of
10,000, 0.17% of 100,000. RandomFill, whose whole job is exploration,
becomes a thinner and noisier slice at exactly the moment a library gets
more diverse — which is the "starting to feel weird" being reported.
1,000 tracks -> pool 170 (unchanged)
5,000 -> pool 170 (unchanged)
20,000 -> pool 280
80,000 -> pool 500 (ceiling)
THE SCALING IS PER-ARM, and that is the substance rather than a refinement.
A limit only matters if there are rows for it to cut off, so what an arm is
BOUNDED BY decides whether library size can help it. LBSimilar,
SimilarArtist, TagOverlap and RandomFill grow: they are bounded by
similarity/tag data and by the library itself. LikesOverlap, UserCoplay and
TasteOverlap do not: they are bounded by the user's likes, the instance's
co-play graph and the taste profile, none of which grow when the library
does. Raising those would sample more of a set that did not change — churn,
not reach. It also keeps this from inflating the sim_score-0 share, since
TasteOverlap is one of the two zero-similarity arms.
sqrt, not linear: linear would put a 100,000-track library at a
3,400-candidate pool, long past where more candidates improve the answer.
A 4x ceiling bounds it at ~500.
Never shrinks an arm. The base limits are a floor, and #3889 makes that
load-bearing rather than tidy — shrinking an arm ordered by unseeded
random() changes pool membership between same-day rebuilds.
Library size comes from a TTL-cached count reusing CountTracksMatching with
an empty pattern (rule 28 — a new query would need sqlc regeneration, which
is blocked). The ILIKE defeats every index, so it is a full scan and must
not run per request. It degrades rather than fails: an error keeps the last
known value, a never-counted cache returns 0, and 0 scales to the base
limits — today's behaviour exactly. Nothing about sizing a pool justifies
failing the request it is sizing. Bounded by a 3s deadline (rule 156), and
a failed refresh does not stamp the clock, so a blip cannot pin a stale
value for the whole TTL.
THE REFERENCE IS ASSUMED, NOT MEASURED. libraryScaleReference = 5000 is
where growth starts, and the size the v1 constants were really tuned against
is unrecorded. #3879 should replace it; until then that constant is the one
thing to change. Deliberately conservative: below it nothing scales at all,
so no existing install changes behaviour.
Falsification caught a weak guard: the sqrt-vs-linear assertion was written
at SIXTEEN times the reference, where linear has already been clamped by the
ceiling and both curves land on 4x. It proved nothing. Moved to four times
the reference, below the ceiling for both, where sqrt gives 2x and linear
would give 4x.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SQ31KQpYbStyK5y58UmPLH
|
||
|
|
633d4f591f |
fix(radio): cap any one artist's share of a radio session
test-go / test (push) Successful in 1m13s
test-go / integration (push) Successful in 3m53s
release / Build signed APK (releases and dev) (push) Successful in 5m9s
release / Build + push container image (push) Successful in 1m52s
release / Verify release artifacts (tag releases only) (push) Skipped
Operator, 2026-09-10: started radio from a song and "literally all of the songs in the playlist after that were from a single artist which was not expected." There was no per-artist cap anywhere in the radio path. radio.go built the pool and handed it straight to Shuffle, which scores, sorts and takes the top N — nothing between those steps bounded any artist's share, so a pool dominated by one artist produced an output dominated by it. The asymmetry was the tell: discover.go, you_might_like.go and home.go all cap; radio never got one. With the fixture that reproduces it — 20 liked tracks by one artist plus 10 by ten others — the old path returns 10 tracks from 1 artist. It now returns 10 from 8. TWO PASSES, and that is the whole design. A hard cap was the easy mistake: radio asks for 50 tracks by default and 200 at most, so capping at three per artist over a concentrated pool would hand back a six-track "radio". Pass one takes candidates that fit under the caps; pass two fills any remaining slots from those it skipped, still in score order. The result always holds min(limit, len(candidates)) — the caps change WHICH tracks are picked, never HOW MANY. Rule 131's principle past the system mixes it was written for. The caps SCALE with the requested length rather than being a constant. Three-per-artist is a sensible 12% of a 25-track mix and an absurd 1.5% of a 200-track radio, where every selection would sit in the relaxation path and the cap would be decorative. RadioDiversityCaps holds the system mixes' proportion at any length: 3/2 at 25, 6/4 at 50, 24/16 at 200, with floors so a very short radio is not capped down to one track per artist. A BOUND, NOT AN EXCLUSION — the operator asked for the opposite of removal: "again it should be able to add songs from the same artist." The dominant artist still appears, just not exclusively. Guarded, because the tempting wrong fix is the filter songs-like used to carry. Shuffle grew the parameter rather than gaining a capped twin: radio is its only production caller, so a second function would have left the original dead (rule 22). Falsified against each named regression: uncapped gives 10/10 to one artist; a hard cap returns 3 of 10 on a single-artist pool; a cap-as-exclusion drops the artist entirely; a fixed cap stays 3 where the scaled one reaches 24. Caught while writing the guards: the artist-key constant was hand-written hex and wrong — the fixture's artist UUID carries 0001 in its fourth group, so the lookup missed and the assertion measured nothing. Derived from the same construction the fixture uses now. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SQ31KQpYbStyK5y58UmPLH |
||
|
|
f5dd4462de |
test(playlists): the same-artist guard needed a fixture that has same artists
test-go / test (push) Successful in 1m25s
test-go / integration (push) Successful in 4m46s
release / Build signed APK (releases and dev) (push) Successful in 5m45s
release / Build + push container image (push) Successful in 17s
release / Verify release artifacts (tag releases only) (push) Skipped
Fixes the integration failure from
|
||
|
|
31190657d8 |
feat(recommendation): Songs-like can include the seed artist's own music
test-go / test (push) Successful in 1m16s
test-go / integration (push) Failing after 3m55s
release / Build signed APK (releases and dev) (push) Successful in 5m11s
release / Build + push container image (push) Successful in 16s
release / Verify release artifacts (tag releases only) (push) Skipped
Operator, 2026-09-10: "it should also be able to include music from the same artist." Completes #3881 — the weights and pool landed in f367eeaa; this is the eligibility half. produceSeedMixes filtered the seed artist out entirely: // "Songs like X" excludes X's own songs. if !pgtypeUUIDEqual(c.Track.ArtistID, artistID) { ... } That reads as obviously right and is not. The seed is a TRACK — the artist's top-played one — and the tracks most likely to sound like it are usually the rest of that artist's catalogue. The filter threw away the seed's nearest neighbours, then reached FURTHER OUT to replace them. On the one surface whose job is staying in a neighbourhood, that is backwards, and it worked against the coherence tuning rather than with it. Domination is bounded by the cap instead of by exclusion, which is the distinction that makes this safe rather than a new problem: capCandidatesByAlbumAndArtist already allows at most 3 tracks per artist in a 25-track mix, so the seed artist gets 12% at most — a presence, not a takeover. Without that bound this would just be the radio failure (#3882) arriving on a different surface. The seed track itself still cannot appear; it is passed to LoadCandidatesFromSimilarity as an exclusion. Guarded end-to-end rather than by reading the source, for two reasons: the check has to survive the filter returning in a different shape, and an absence check would now match the comment that explains why the filter is gone — rule 167's prose trap exactly. The test asserts both directions, that at least one mix contains its seed artist and that none exceeds the cap. Its falsification is by construction rather than by execution: under the previous code every mix's own-artist count was necessarily zero, so the assertion could not have passed. Running it needs Postgres, which is the integration lane's job. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SQ31KQpYbStyK5y58UmPLH |
||
|
|
ecfa056d4d |
fix(recommendation): don't shrink a candidate arm ordered by unseeded random()
test-go / test (push) Successful in 1m10s
test-go / integration (push) Successful in 3m35s
release / Build signed APK (releases and dev) (push) Successful in 5m6s
release / Build + push container image (push) Successful in 16s
release / Verify release artifacts (tag releases only) (push) Skipped
Fixes the integration failure from
|
||
|
|
f367eeaa9d |
fix(recommendation): Songs-like gets its own profile so it stops wandering
test-web / test (push) Successful in 1m7s
test-go / test (push) Successful in 1m31s
test-go / integration (push) Failing after 4m21s
release / Build signed APK (releases and dev) (push) Successful in 5m11s
release / Build + push container image (push) Successful in 1m52s
release / Verify release artifacts (tag releases only) (push) Skipped
Operator, 2026-09-10: "when I play it I'm expecting to get a consistent
sound and style from the experience... I was getting a seeming wide variety
of music from each one when I was hoping to stay in a certain neighborhood."
Songs-like shared the `daily_mix` weight profile with For-You, and that
sharing WAS the bug. The two surfaces want opposite things: For-You answers
"what will they enjoy today" and is supposed to roam; Songs-like answers
"what sounds like THIS". Under one profile the broad answer wins.
The arithmetic, from the shared weights:
unrelated track, liked, not played recently → 1.0 + 2.0 + 1.0 = 4.0
PERFECT similarity match, not liked → 1.0 + 1.5 = 2.5
Liking something outranked sounding like the seed, because LikeBoost (2.0)
exceeded SimilarityWeight's whole range (1.5) and TasteWeight (1.5, and
seed-INDEPENDENT) matched it outright. Under the new profile the same pair
scores 5.00 vs 2.00.
Two levers, because either alone leaves the other's failure intact:
POOL. Songs-like now takes its own CandidateSourceLimits. The default gave
~29% of candidates a sim_score of literally zero — `taste_overlap` and
`random_fill` are both `0.0::float8` in recommendation.sql, seed-independent
by construction. Same total pool size; composition shifts to arms that
measure distance from the seed, LBSimilar doubled.
WEIGHTS. A third profile beside radio and daily_mix, DB-backed and live per
rule 25, with the property that similarity's range exceeds the combined
range of every seed-independent differentiator — so a closer match cannot
be beaten on likes, freshness and taste alone, while tracks within ~0.39
similarity of each other still get ordered by what the user likes.
Rule 131 changed the pool design mid-way and for the better. Zeroing the
two seed-independent arms was the first instinct and is exactly the
vanish-or-nothing shape that rule forbids: a seed with thin ListenBrainz
coverage would yield a short mix or none. They are the tier-3 FLOOR — cut
hard, never removed — and the weights keep them at the bottom of the
ranking rather than out of the pool. "A few tracks further from the seed
than we'd like" beats "no playlist".
Caught while wiring it: switching only pickTopN's final Score would have
been nearly INERT. scoreAndSortCandidates does the selection sort, and the
caller caps and truncates in that order — so the playlist would still have
been chosen by daily_mix and merely relabelled with songs_like numbers. It
now takes the profile as a parameter, and each surface passes its own.
Also corrects the daily_mix card's blurb, which claimed Songs-like as one
of its surfaces and no longer is.
Guards pin behaviour rather than the numbers, since numbers get retuned:
that similarity beats an unrelated liked track, that daily_mix still
DOESN'T (or the split buys nothing), that the tier-3 floor is non-zero,
and that the UI card shows its own values rather than falling back. Each
falsified against its named regression first.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SQ31KQpYbStyK5y58UmPLH
|
||
|
|
270ad7a71b |
fix(ci): tests do not ship, so they must not re-version an artifact
test-go / test (push) Successful in 1m1s
test-go / integration (push) Successful in 3m17s
release / Build signed APK (releases and dev) (push) Successful in 4m41s
release / Build + push container image (push) Successful in 16s
release / Verify release artifacts (tag releases only) (push) Skipped
Completes the pathspec.
|
||
|
|
17212e9eb4 |
fix(ci): version derives from the shipped set; untrack an 18MB binary
test-go / test (push) Successful in 1m5s
test-go / integration (push) Successful in 3m30s
release / Build signed APK (releases and dev) (push) Successful in 5m10s
release / Build + push container image (push) Successful in 1m31s
release / Verify release artifacts (tag releases only) (push) Skipped
Three build-hygiene fixes that turned up while explaining the pathspec.
**version.sh derives from what SHIPPED.** It read bare HEAD, so any commit
moved the version — including one touching only CI or a README. Rules 148
and 149 both specify the pathspec form. Now a denylist, and the direction
is the point: as an allowlist the list must be updated by whoever adds a
directory and nothing fails if they don't, so the failure mode is a changed
artifact keeping its old version silently on a green run. Inverted, new
content counts by default.
android/ is deliberately NOT excluded, and that is the subtle part. This
repo ships TWO artifacts from ONE derivation: android/ is in no server
image, but it is the APK's entire source, and excluding it would stop an
Android-only commit from moving the APK's own version — the silent
downgrade the versioning rework exists to prevent. So the list is the
union: exclude only what ships in neither, and accept that an Android
commit also nudges the server's reported version. Over-inclusion across the
two, which is the harmless direction. roundtable/roundtable-android each
keep tighter lists because they are one-artifact repos; don't copy theirs.
**.dockerignore excluded the wrong CI directory.** It named .forgejo/ and
.github/, neither of which this repo has. Gitea Actions reads .gitea/, so
the one directory that exists was the one not excluded. The "Flutter mobile
client" block had also lost its PATTERN when flutter_client/ was deleted,
leaving a comment describing an exclusion that was not happening — android/
never took its place, so 4.1MB of Gradle project entered the context and
busted the `COPY . .` layer on every Android-only change. bin/ excluded too.
**bin/minstrel was tracked** — an 18MB binary last refreshed by a commit
about web test mocks, and re-dirtied by every `make build` since. Untracked
and ignored; the file stays on disk.
Guards are behavioural rather than textual: they build throwaway repos with
pinned commit timestamps and run version.sh against them, so they break when
the derivation changes rather than when the wording does. Falsified — drop
the .gitea exclusion and the CI-only commit moves the version; add an
android exclusion and an Android commit stops moving it; exclude everything
and a source commit refuses.
One honest note on the refusal test: the script already refused an empty
result via the downstream date check, so the new explicit check improves the
diagnostic ("no commit touches the shipped file set — shallow clone?") and
not the safety. The test pins the property, which is defended in depth.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SQ31KQpYbStyK5y58UmPLH
|
||
|
|
8f4b76a638 |
fix(ci): artifacts move to stock upload-artifact@v7 / download-artifact@v8
test-go / test (push) Successful in 1m4s
test-go / integration (push) Successful in 3m54s
android / Build + lint + test (push) Successful in 4m58s
release / Build signed APK (releases and dev) (push) Successful in 5m11s
release / Build + push container image (push) Successful in 1m52s
release / Verify release artifacts (tag releases only) (push) Skipped
android.yml's debug upload and release.yml's minstrel-apk pair went through the bvandeusen fork mirrors, with comments saying stock actions refuse this hostname, that the pair had to be matched on the bundled @actions/artifact major, and that download v7 was off-limits for node24. None of that holds on gitea/runner 3.x: the runner edits the GHES refusal out of the action bundles, every download major v4-v8 reads every upload major v4-v7 (Scribe spike #3843, CI-runner run 6312), and every CI image carries Node 24. The mirror pair itself was last verified at tag run 6286. Same artifact names, paths and if-no-files-found. ci-requirements.md drops the pairing table and keeps what is still true: @v3 is invisible. Scribe snippet #2271, milestone 395. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DwoKYuw3qJmUUYsJeNherB |
||
|
|
aeb8781c4e |
fix(release): drop version image tags, mint the rollback unit on main
test-go / test (push) Successful in 1m43s
test-web / test (push) Successful in 1m13s
test-go / integration (push) Successful in 4m12s
release / Build signed APK (releases and dev) (push) Successful in 5m11s
release / Build + push container image (push) Successful in 38s
release / Verify release artifacts (tag releases only) (push) Skipped
The image tag map was the inverse of family rules 145 and 147 on every count: it published :vYYYY.MM.DD.HHMM that nobody pinned, published :main that rule 147 says should not exist, and published no commit-addressable image at all — so the rollback unit the rule names did not exist in this repo. A bad main push had nothing to roll back to but the previous release tag, which may be many commits back. The whole map is now: dev → :dev main → :latest + :<sha> tag → :latest A release refreshes the channel and mints nothing else. The tag build rebuilds the SAME SOURCE as main's build minutes earlier, differing only in which APK is baked in, so rule 145's immutability clause applies directly: move the channel tag, never re-push a commit-addressable one. :latest has to move here rather than waiting for the next main push, or the channel would carry the previous release's APK indefinitely — a channel that cannot refresh itself (rule 146). Two consequences that are not optional: The verify job asserted the :<version> image existed. With version tags gone that would fail every release for a tag nothing mints. Re-pointed at the :<sha> image rather than deleted — deleting it is the tempting way to make a failing guard go green, and it earns its keep twice now: it still catches an image push that silently did not happen, and it additionally proves the ordering, since a tag cut on a commit whose main build never completed has no rollback target. The server's self-reported version was the literal string "main" or "dev". That was survivable while :vYYYY.MM.DD.HHMM existed to identify a build; with version tags gone it is the ONLY thing that says which build is running, and two dev images months apart were indistinguishable. It now carries the derived name from ci/version.sh on every lane, with the channel as a sibling field (rule 149) rather than folded into the string. Surfaced at /healthz and beside the version in Settings. Guards added for each arm of the policy, and every one was falsified against the specific regression it names before committing. That caught two real bugs in the guards themselves: stepBody cut at the next `- name:`, which returns an EMPTY body for the last step in a job and made the assertions pass vacuously, and its replacement cut at any blank line followed by indentation, which truncated a step mid-run-block. The helper now refuses an empty body outright. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SQ31KQpYbStyK5y58UmPLH |
||
|
|
88508b536b |
fix(release): a non-matching grep must not kill the rebundle step
test-go / test (push) Successful in 1m39s
release / Build signed APK (releases and dev) (push) Successful in 6m11s
release / Build + push container image (push) Successful in 14s
release / Verify release artifacts (tag releases only) (push) Skipped
test-go / integration (push) Successful in 5m59s
The first `main` build after the version rework failed, and the bug was
mine. :latest was never moved — "Build and push" was skipped — so nothing
reached production, but every subsequent main push would have failed the
same way.
The runner invokes `shell: bash` as `bash -e -o pipefail`. Under pipefail a
command substitution reports the FIRST non-zero status in its pipeline, not
the last, so
VAR="$(printf ... | grep -oP ... | grep -E '\.apk\.version$' | head -1)"
exits non-zero when that grep matches nothing, even though `head` succeeded.
With -e the step dies AT THE ASSIGNMENT — before reaching the `if` written
to handle exactly the empty case.
Which is what happened: v2026.09.09 predates sidecar assets, so its
`.apk.version` grep matched nothing and the step aborted instead of falling
through to the name-only branch I added in
|
||
|
|
90bb3538c6 |
feat(release): build a dev channel so testing stops requiring a release
test-go / test (push) Successful in 1m11s
release / Build signed APK (releases and dev) (push) Successful in 5m0s
test-go / integration (push) Successful in 5m55s
release / Build + push container image (push) Successful in 1m52s
release / Verify release artifacts (tag releases only) (push) Skipped
There was no test channel at all. release.yml ran only on main and tags, so no :dev image existed and no APK was produced outside a release — the only way to get a build onto a phone was to ship one, which made `main` the staging area by default. A push to dev now builds a signed APK, bundles it, and publishes :dev. Signed with the SAME key as release builds, deliberately. A differently signed APK cannot install over the stable app, so anyone moving between channels would have to uninstall and lose their local data. Same key means both directions work. :dev is published ALONE, with no per-commit tag. A rolling channel is rolling by definition; a commit-addressable image for it would be a rollback target nobody ever pulls, kept forever. Recovery on dev is to fix forward, and that is a deliberate trade rather than an omission. The channel is derived from the REF, not the commit, which is why it is computed in the workflow and not in ci/version.sh. The same commit built on dev and on main reports the same version NAME and differs only in the channel field — that separation is the entire point of keeping the three values apart. What this repo deliberately does NOT get: a cross-repo dispatch to refresh the channel when its bundled APK is rebuilt. That mechanism exists elsewhere in the family because the app and server live in separate repos, and a channel that can only be refreshed by an unrelated commit is not a channel. Minstrel is a monorepo — one push builds the APK and the image in the same run from the same commit, so the channel cannot go stale against its own artifact. The requirement is met structurally; copying the mechanism would add a moving part to fix a problem that does not exist here. Two guards, for the two ways this wiring can fail quietly: A dev push must never move :latest. That would ship untested code to every stable operator on their next pull, with the build green and the image perfectly valid — just the wrong audience. Nothing else in the suite would notice. The two bundling paths must stay mutually exclusive. The rebundle step is now gated to main specifically, not to "not a tag": under the looser condition a dev push would run BOTH steps, staging its fresh APK and then overwriting it with the previous release's. The image still builds, the sidecar still parses, and the channel whose whole job is being current quietly serves stale art. Both falsified against the regressions they name before committing. Scribe task #3819, milestone #390. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SQ31KQpYbStyK5y58UmPLH |
||
|
|
a687ef439c |
fix(release): refuse an ordering key that overflows versionCode
The script asserted the key was positive but never that it fits. Android's
versionCode is a signed 32-bit int and the platform rejects an APK above it,
so a build machine with a badly wrong clock would emit a code the script
happily hands on and the install then refuses.
Worse than a rejected build: an over-ceiling code is also unreachably high,
so every correct build afterwards would fail to outrank it and the update
channel would be permanently stuck. Cheaper to refuse at the source than to
diagnose it from a phone that will not update.
The Go guard already asserted this, but only against a pinned value. The
script is what actually runs at build time, so the check belongs here too.
Falsified at the boundary rather than by eye — exactly at the ceiling exits
0, one minute past exits 1. My first probe used a year-6000 clock and did
NOT fire, which turned out to be the probe being wrong rather than the
check: that epoch still lands under the ceiling. The ceiling is reached in
6103, roughly 4079 years out, so this only ever catches a misconfigured
clock.
This commit deliberately touches ci/version.sh alone, to verify the path
filters added in
|
||
|
|
eaf4654c0a |
test(release): make the version derivation executable, and guard it on dev
Steps 1 and 2 of this milestone shipped with no CI coverage at all, and the
reason generalises: release.yml triggers only on main and tags, so nothing
inside it is exercised until a release is already running. That is the worst
place in the repo to be unguarded, because the failure mode is silence — a
version nobody can compare looks exactly like being up to date, and nobody
reports an update they were never offered.
The fix is not a test that reads YAML. The derivation moved into
ci/version.sh, so it can be RUN, and internal/server/release_version_test.go
runs it on every push. release.yml now calls the same script, so the thing
that ships and the thing under test are one artifact rather than two copies
that agree until they don't.
test-go.yml gains 'ci/**' and '.gitea/workflows/release.yml' in its paths.
Without that the guard exists but never fires on the changes it protects,
which is the same nothing it replaces.
What is pinned, and why each one:
- HHMM is zero-padded. A build at 00:42 must emit "0042"; a stripped
leading zero shifts the segment two orders of magnitude and reverses
comparisons against every other build that day. It only bites for a
tenth of the day, so it will not be found by chance.
- The name derives from the COMMIT and the code from the BUILD. Asserted
by holding one clock and moving the other: the name must not move, the
code must.
- The code clears 1895, the highest versionCode the retired commit-count
scheme shipped. Below that Android refuses the upgrade as a downgrade
and the channel becomes a one-way door.
- The tag is the name with a `v`, never chosen.
- release.yml still calls the script, and does not derive a commit count
again. This pins the WIRING: without it every other assertion keeps
passing while the shipped path silently drifts out of coverage.
The script rejects unusable clocks rather than emitting something plausible,
and those rejections are tested — a guard that cannot fail is worse than
none, because it reads as coverage.
Falsified before committing rather than after: ran the script against good
and broken inputs and watched all three failure paths fire; verified every
asserted value by executing it rather than by reading it; and checked the
two workflow predicates catch their regressions while staying immune to a
comment that merely names the old formula.
Step 5 of 5 — Scribe task #3812, milestone #390.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SQ31KQpYbStyK5y58UmPLH
|
||
|
|
ca1c18bbbb |
fix(android): miniplayer content sat at the top of its bar, not centred
android / Build + lint + test (push) Successful in 4m9s
Reported with a screenshot: the bar drew at full height but the cover, title and transport row hugged its top edge, leaving an empty strip of surface above the gesture area. The Surface is a fixed 80dp. Inside it a plain Column stacked a 4dp progress fill and then MiniRow at its INTRINSIC height — 48dp, set by the cover and the icon buttons. A Column stacks from the top and nothing claimed the remainder, so 80 - 4 - 48 = 28dp collected at the bottom. Measured off the screenshot rather than eyeballed, and the bands agree exactly: progress fill 14px (4dp at 3.5x), surface 280px (80dp), cover 167px (48dp), empty below 99px (28.3dp). That the arithmetic lands on the measurement is what makes this the whole cause rather than one contributor. MiniRow was already centring its content correctly — inside a box that was only ever 48dp tall. Giving it weight(1f) lets it take what the progress fill leaves, so it measures 76dp and centres 48dp of content: 14dp above and below. The fill stays pinned to the top edge, which is where a progress indicator belongs. Not the same bug as issue #2681. That was a dead strip ABOVE the miniplayer from an unclaimed navigation-bar inset, fixed in v2026.08.18. This is inside the bar, pure layout, no insets — the surface already stopped correctly above the gesture area. CI cannot see this one. There are no Compose UI tests in the repo; the Android lane is ktlint, detekt and JVM unit tests, and a layout bug needs an instrumented test to catch. Compilation and lint are all this commit gets from CI — the visual check is on a device, and the APK only builds on a tagged release. Scribe issue #3826. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SQ31KQpYbStyK5y58UmPLH |
||
|
|
68136c64c0 |
fix(android): decide updates on the ordering key, not the version name
android / Build + lint + test (push) Successful in 3m55s
The app compared NAMES while Android installs by versionCode, with nothing keeping the two orderings consistent. So it could offer a build the platform then refused as a downgrade, or stay silent about one it would have accepted. The offer and the install were asking different questions. Both consumers — the shell banner and the About card — now route through one isUpdateAvailable(): decide on the ordering key whenever the server reports one, since that is the same value the package installer compares, so an offer implies an install that will actually be accepted. Name comparison survives only as the fallback for a server predating the field. isVersionNewer is deliberately untouched. It already degrades per segment and is not what was broken; rewriting it while nearby would have put the fallback path at risk for no gain. code is nullable on the wire, and that is load-bearing rather than stylistic. The app's Json sets coerceInputValues = true, which replaces a JSON null with the declared default on a NON-nullable property — so `val code: Long = 0` would have turned "this server reports no ordering key" into "its key is 0" silently, ranking every such server as infinitely behind and offering its build to everyone forever. Reading the field declaration alone would never show that; it lives in AppModule. A third caller turned up during the sweep and was deliberately left alone. NetworkStatusController compares the /healthz minClientVersion, which is a server-declared compatibility floor rather than the bundled APK — there is no ordering key on that wire at all, so names remain the only thing it can compare. Different question, correctly still using the old helper. The update channel had no tests whatsoever before this, which is worth stating: the thing deciding whether anyone is ever offered an update fails silently in both directions. The new suite pins that the key wins when it disagrees with the name, that a null key falls back rather than reading as zero, the recorded migration constraint (a new-scheme name outranks an old-scheme one across a day boundary but NOT within the same day), and the degradation cases — including that an unparseable DECIDING segment reads as zero and loses, which is why the channel must never live inside the name. Every assertion was checked against the real comparison by mirroring it, rather than from reading it: two of my first-draft comments described the wrong mechanism and were corrected on the evidence. Step 4 of 5 — Scribe task #3811, milestone #390. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SQ31KQpYbStyK5y58UmPLH |
||
|
|
9f3e0b8cd3 |
feat(version): sidecar and /api/client/version carry name, code and channel
The client compares names while Android installs by versionCode, and the wire had no way to close that gap: the sidecar was one positional line and the endpoint returned a name only. This is the plumbing that makes the ordering key decidable by the client at all. The sidecar is now JSON rather than a grown positional string. That shape was chosen against a specific failure: the obvious growth path was "<name> <code>", which a first-space split silently mangles the moment a third field appears — the code stops parsing as an integer and the reader falls back to name comparison WITHOUT erroring. JSON cannot mistake a new field for an old one. code is a POINTER on both sides, and omitempty on the wire. Absent has to stay distinguishable from zero: a build published before ordering keys were recorded genuinely has no code, and zero would claim it is infinitely old rather than unknown. A malformed sidecar now fails loudly instead of serving a blank version. If an unreadable file produced an empty name, every client would compare against nothing, conclude it was current, and go quiet — "I cannot read this" and "there is nothing newer" would return the same answer, which is the failure mode nobody reports because nobody is offered anything to report. The non-tag :latest path no longer RECONSTRUCTS the bundled APK's version. android-release now publishes the sidecar as a release asset beside the APK, and the image build downloads it. The old reconstruction duplicated a derivation formula across two files, and could only ever recover the name — the ordering key is build-time minutes and exists nowhere once that build ends. Releases predating the sidecar report their name with a null code, which is the honest answer rather than a guessed one. image-release also drops to a shallow checkout: it needed full history and tags only to re-derive versions from the tagged commit, and now touches git for nothing. MINSTREL_VERSION comes from GITHUB_REF. Two things checked rather than assumed. The Android Json sets ignoreUnknownKeys, so the added fields cannot break already-installed apps. It also sets coerceInputValues, which will silently turn a null code into 0 if step 4 declares the field non-nullable — recorded on task #3811, because reading the field declaration alone would never reveal it. Also fixes a stale comment block describing "the Flutter client", deleted in v2026.08.18. Step 3 of 5 — Scribe task #3810, milestone #390. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SQ31KQpYbStyK5y58UmPLH |
||
|
|
e46c6bcccf |
docs(release): tags become vYYYY.MM.DD.HHMM, and stop telling people to move them
The tag is now the artifact's own version name with a `v` in front, so `v2026.09.10.1432` and `2026.09.10.1432` are one string. Nothing has to reconcile what the tag claims against what the APK reports, and minting one is arithmetic on the tagged commit's timestamp rather than a lookup. The substantive change is the prose. release.yml's header instructed the reader to `git push -f origin vYYYY.MM.DD` on a same-day re-cut. That is the operation the family rulebook forbids outright, and it has incidents behind it — moving a same-day tag forward once took a published release down with it. Anyone who had installed from that tag was holding something it no longer pointed at. With HHMM there is nothing left for mutability to buy: every tag is unique by construction, so a second release the same day is not a collision to resolve, just another tag. The old instruction is recorded as retired rather than deleted. Someone who remembers it should learn it was withdrawn and why, not find it silently absent and assume they misremembered. README contradicted itself inside one sentence — "immutable per-day release tags ... a same-day re-cut moves the tag forward" — and now says which it is, plus a note that pre-2026-09-10 tags keep the old shape and still work. Transition wrinkle, deliberately left for step 3: the non-tag :latest path reconstructs the bundled APK's name from the latest release's commit timestamp, which for the one existing old-shape release yields 2026.09.09.1828 while that APK actually declares 2026.09.09.1895. It fails SAFE — 1828 compares lower, so no false update is offered — and it self-corrects at the first new-scheme release. Step 3 removes the reconstruction entirely by having the sidecar carry recorded values instead of derived ones. Step 2 of 5 — Scribe task #3809, milestone #390. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SQ31KQpYbStyK5y58UmPLH |
||
|
|
bfdaed9365 |
fix(release): derive versionCode from build time, versionName from commit time
android / Build + lint + test (push) Successful in 4m19s
versionCode was `git rev-list --count HEAD`, and build.gradle.kts called it
"monotonic forever". It is not, and that claim was sitting directly above
the bug it denied.
A commit count runs ahead on `dev`. So a dev build carried a HIGHER code
than the `main` release meant to supersede it, and Android refuses that
install as a downgrade — a channel you can enter and cannot leave without
uninstalling and losing local data.
Two clocks now, and the split is deliberate even though it reads like an
inconsistency:
The NAME answers "is this the same code?", so it derives from COMMIT time
and reads identically on every lane building this source. A dev build and
a main build of one commit must report the same string. Build time cannot
do that — it prints two numbers for one thing.
The ORDERING KEY answers "may this be installed over that?", so it must be
monotonic BY CONSTRUCTION: minutes since 2020-01-01. Commit time fails
here for the mirror-image reason — rebuild an older commit and it goes
DOWN, which on a phone is a refused install rather than a confusing label.
The non-tag :latest path reconstructed the bundled APK's name with the old
formula, so it is moved to the same commit-timestamp derivation. That
duplication is temporary: once the tag becomes `v<version-name>` it
collapses to `${TAG#v}` with nothing left to keep in step.
Verified locally by running the derivations rather than reasoning about
them: HEAD yields 2026.09.09.1828; the key yields 3519456 against ~1895
from the old scheme, inside int32 with ~4000 years of headroom; a commit
at 00:42 UTC yields "0042", not "42". The workflow now asserts the emitted
shape too — a malformed name builds, signs and publishes happily and only
surfaces as an update nobody is offered, which nobody reports.
That local check is the only verification this commit gets. release.yml
triggers on main and tags only, so nothing on `dev` executes the new
derivation; CI here proves the Gradle file still parses and nothing else.
Also confirms the migration constraint recorded in milestone #390: this
commit would name a release 2026.09.09.1828, which is LOWER than the
installed 2026.09.09.1895 under name comparison. The first new-scheme
release must be cut on a later calendar day, or existing installs will
never be offered it.
Step 1 of 5 — Scribe task #3808, milestone #390.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SQ31KQpYbStyK5y58UmPLH
|
||
|
|
c27f9d484a |
test(android): guard that the typefaces stay bundled
android / Build + lint + test (push) Successful in 5m17s
The web side has no-external-assets.test.ts; Android had nothing, so the font provider could come back with no test noticing. This is the Android half. Expectations are read out of Typography.kt rather than hardcoded, which is what makes it a structural pin instead of a list that rots: the guard extracts every Font(R.font.X, FontWeight.WN) declaration and checks that X.ttf exists, is really TrueType, and reports N as its OS/2 usWeightClass. Add a face without vendoring it and this fails; change a declared weight without refetching the matching static instance and it fails too. usWeightClass is the check worth having. css2 silently collapses a multi-weight request to 400 for legacy clients, so Medium comes back as Regular — a valid TrueType file that renders at the wrong weight everywhere, and the only field that distinguishes it. Comments are stripped before the absence check, so the KDoc explaining why there is no GoogleFont reference cannot satisfy the assertion that forbids it. Falsified by mirroring every predicate and byte offset against the real files: it passes on what is committed, and trips on HEAD~1's Typography.kt via both the forbidden-symbol check and the no-declarations-found check. A 400 file asserted against a declared 500 fails, so the weight comparison is not vacuous. Compilation itself is unverified locally — no Gradle run here — so CI is the first thing to actually build this. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SQ31KQpYbStyK5y58UmPLH |
||
|
|
b52a00df66 |
fix(android): bundle the typefaces instead of fetching them at runtime
android / Build + lint + test (push) Successful in 4m19s
Typography.kt resolved Fraunces, Inter and JetBrains Mono through the Play Services font provider, which fetches them over the network on first use. Same rule-164 problem the web client had, with a second failure mode on top: the provider is absent entirely on devices without Play Services, so the app fell back to the platform default and stopped looking like Minstrel — quietly, with no error. The five static instances now live in res/font, vendored by the same tools/vendor-fonts.py that produces the web bundle. Both clients draw from one list of faces so they cannot drift apart. Cost is ~0.86 MB of APK; the runtime path is removed rather than kept as a fallback — the ui-text-google-fonts dependency, its version-catalog entry and the provider certificate hashes in font_certs.xml are all gone. Two things about fetching TTFs that are worth writing down, because both fail by succeeding: Google Fonts picks the format from the User-Agent, and there is no parameter to ask for one. A modern UA gets woff2, which res/font cannot load. The obvious "use an old UA" fix gets EOT — an IE-only format that downloads happily, has a plausible size, and is entirely useless here. An Android 4.4 UA is what actually yields TrueType. css2 also collapses a multi-weight request to 400 for legacy clients, so asking for Medium silently returns Regular: a valid TrueType file that renders at the wrong weight everywhere. Each weight is therefore fetched on its own URL, and the script now asserts OS/2 usWeightClass on every download — that field is the only thing distinguishing the two files. Verified before wiring: all five carry TrueType magic, the 400/500 pairs differ, and their usWeightClass reads 400/400/500/500/400 as declared beside them in the FontFamily. Not covered: there is no guard for this on the Android side. The web equivalent is asserted by no-external-assets.test.ts, but the Android tree has no source-inspection test pattern to follow and no way to falsify one without a local Gradle run. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SQ31KQpYbStyK5y58UmPLH |
||
|
|
16005054eb |
fix(web): vendor the web fonts instead of loading them from Google
test-web / test (push) Successful in 42s
app.html linked its stylesheet straight from fonts.googleapis.com, with preconnects to that host and fonts.gstatic.com. A deployed instance has no outbound network, so those requests never arrive and the whole UI renders in fallback faces — Georgia for the display face, whatever the system has for Inter and JetBrains Mono. This is invisible in development, which is why it survived: the dev machine has internet, so the fonts load and everything looks right. Only a real deployment shows the failure. tools/vendor-fonts.py fetches the three families once and writes them under web/static/fonts with a generated stylesheet. static/ is copied into the SvelteKit build, which Go embeds, so the faces travel inside the binary. 32 woff2 files, 912K. Two details that matter for correctness rather than size: Urls in the generated CSS are relative (./Inter-400-latin.woff2), not absolute. A url() resolves against the stylesheet's own address, so the directory keeps working when the app is served under a base path; /fonts/... would not. Every subset Google slices is kept, with unicode-range intact. The browser still fetches only the ranges a page uses, so this costs repository bytes rather than request bytes — and a library full of Cyrillic or Greek artist names renders instead of falling back mid-list. The guard asserts the property, not the vendor: any absolute url in a resource-loading attribute fails, whoever hosts it, since naming Google would pass the day someone reached for a different CDN. It also checks preconnect separately (those carry no fetch of their own, so the url check misses them), strips HTML comments before asserting an absence so prose describing the forbidden thing cannot satisfy the check, and pins the font families to tokens.json rather than a hardcoded list. Falsified against the pre-change app.html: it trips both the external-url and preconnect assertions. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SQ31KQpYbStyK5y58UmPLH |
||
|
|
b06a1adfe8 |
feat(brand): draw a reduced mark so the favicon reads at 16px
The full hat does not resolve below ~32px, which the header worked around by sizing up. A browser tab cannot: it renders the favicon at 16px and does not ask. There the mark was a blob. Deriving a small form from the traced art does not work, and this is the non-obvious part. Hole-filling, morphological smoothing and dropping components were all tried; every one of them preserves the overall silhouette, and the overall silhouette — dominated by a long diagonal plume — is precisely what fails. The result each time was a diagonal smear that reads as no object at all. So the reduced form is drawn rather than derived: a strong horizontal brim under a crown that peaks left of centre, a band slit so the two do not fuse, and a short pointed plume. Same lean and proportions as the full mark, detail removed instead of minified. favicon.svg and favicon.png now use it; apple-touch, icon-512 and the Android launcher icons keep the full art, being large enough for it. The plume carries the accent, which measures 3.04:1 on obsidian and 5.43:1 on the light ground — both clear of the 3:1 graphics floor. Also corrects the accent-on-iron figure in the generator's comment from 2.80:1 to 2.70:1. The real --fs-iron is #1E2228; 2.80 came from measuring against a value I had guessed rather than read. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SQ31KQpYbStyK5y58UmPLH |
||
|
|
5593f7ce17 |
feat(brand): replace the M mark with the traced bard-hat logo
The mark is now a feathered hat with an arc of eighth notes, traced from the operator's reference artwork at 99.74% IoU. The hat takes the text colour and the note arc holds the accent — the same construction the M used, and for the same reason: parchment on a light surface is invisible, so the silhouette has to flip with its background while the accent stays constant. This reverses the subject-neutrality argument recorded in Minstrel's design system, which held that depicting a bard would tell a new user the app is for renaissance-faire music and had twice rejected a hat. The operator commissioned this artwork and chose it with that objection on the table; the record is updated rather than silently contradicted. Both accent-filled alternatives were measured and rejected: #4A6B5C is 3.04:1 on obsidian and 2.80:1 on the raised iron, so an accent hat drops under the 3:1 graphics floor as soon as it sits on a card. tools/gen-brand-assets.py is the single source for the four copies, which cannot share a file because each needs a different colour mechanism — currentColor inlined, a prefers-color-scheme swap in the favicon, literal fills in mark.svg, flat pixels in the rasters. Hand-copying 20KB of path data four ways is how a silhouette change lands in three of them. Two notes on the trace, both non-obvious: it runs on the original antialiased greyscale rather than a binary mask, because tracing a supersampled mask scores ~100% IoU by reproducing the pixel staircase exactly — a perfect number for jagged art at 120KB of path, versus 99.74% at 20KB. And potrace reads PBM where bit 1 is black, so the ink mask is inverted going in; backwards, it traces the background and still emits a plausible-looking SVG. The header lockup moves 20px → 28px: the hat carries far more detail than the M and does not resolve below ~32px. The 16px browser-tab favicon is still a blob at that size and is not addressed here. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SQ31KQpYbStyK5y58UmPLH |
||
|
|
0103953953 |
refactor(diagnostics): split the flap window and episode rule apart
android / Build + lint + test (push) Successful in 4m8s
detekt ReturnCount. Extracting the pruning and the is-this-an-episode predicate reads better than suppressing it, and the cooldown rule now has a name and a docstring of its own. |
||
|
|
72c0e96f92 |
fix(player): stop the local player during a cast; capture transport flap
android / Build + lint + test (push) Failing after 1m11s
Two changes for the Sonos stutter the operator describes as rapid play-pause-play at the start of a track. The measurable one: nothing in the diagnostics could see it. player_state records source/loading/error but not whether we are playing, track_change needs the queue index to move, and the heartbeat samples once every 45s. A few seconds of oscillation that changes no index fell through all three, which is why the symptom has been described repeatedly and measured never. The poll loop now publishes raw GetTransportInfo readings on change, and TransportFlapDetector turns a burst of them into one summary event carrying the sequence alongside local-vs-Sonos track and position -- enough to tell a cursor disagreement from the renderer rebuffering. It samples at the 1Hz poll cadence, so a faster oscillation lands aliased; that still answers whether the renderer is leaving PLAYING, which is the open question. The suspect one: during a cast the wrapped ExoPlayer was paused, not stopped. pause() is only playWhenReady=false -- LoadControl keeps loading, so the phone went on downloading the track the renderer was streaming, over the same WiFi, re-arming at every track change via syncLocalCursorToRemote's seekTo. At FLAC bitrates that is a second full-rate download competing with the speaker, beginning exactly when a new track does. stop() ends it; Media3 keeps media items, index and position, and getPlaybackState() already reports STATE_READY while remote, so cursor sync and handoff are unaffected. The route teardown re-prepares for local playback. Whether that download is the cause is unproven -- hence the instrument landing alongside it rather than after it. |
||
|
|
e87516bbe4 |
refactor(player): split Sonos queue loading out of the picker — #2728
android / Build + lint + test (push) Successful in 3m48s
detekt flagged OutputPickerController as LargeClass once the verify path landed. Extracting rather than suppressing: how the renderer's queue is shaped is a different concern from which route is selected, and it had grown big enough to hide a bug — every write in here is a SOAP call that can fail on its own, and nothing ever read the result back. SonosQueueLoader now owns load / extend / verify / append and the incremental diff. The picker keeps route selection and asks it for queue work. No behaviour change. |
||
|
|
8e21bce103 |
fix(player): verify the Sonos queue actually landed — #2728
android / Build + lint + test (push) Failing after 1m18s
The renderer's queue was written and never read back. loadQueueOnSonos background-appends the tail one AddURIToQueue at a time and gives up after 3 consecutive failures; Sonos rate-limits burst adds, so that happens. The renderer was then left holding fewer tracks than we believed, played what it actually had, and stopped — which looked exactly like playback dying for no reason. GetMediaInfo's NrTracks is the cheap authoritative answer and was not being asked for anywhere in the app. Now: - verifyQueueLength after every load (including when there is no tail to append — the initial batch can be dropped the same way), appending what the renderer is missing, bounded at 2 passes. - RemoteStallWatchdog gains QueueState, so a stop is classified rather than assumed: a stream that died resumes, a truncated queue gets repaired at the next track, and a queue that simply ended does nothing at all. That last case was a bug shipped in #2700: the normal end of a queue is a confirmed STOPPED with play intent, so every cast session would have ended with three resume attempts and a `stalled` error for playback that finished perfectly. No test described the end of a queue, so CI had nothing to catch it with. Queue reads are gated on the transport being stopped and cached for 5s, so this never becomes a third SOAP call per second. |
||
|
|
955a61194e |
fix(library): a fully-missing album leaves the year axis too — #2702
Filed as a product decision, but the code had already made it: the genre queries filter tracks.missing_since inside their EXISTS, so an album whose every file had gone was ALREADY absent from genre while still listed under its year — where opening it found nothing playable. The two browse axes disagreed, and whichever answer won, one of them had to change. Hiding is the answer. Browsing is how you go looking for something to play, and the rule for that case is to take it out of view; the admin missing-files surface is where absence gets reported, with far more detail than a silent gap in a grid. It also means changing the axis that was inconsistent rather than the one that was already right. All three year queries move together — index, list and count. That is the invariant #367 needed care for at the genre level: if the index groups differently from the filter, a year leads to an empty page, and if the count disagrees with the list then "Load more" promises rows that never arrive. The predicate is "has at least one playable track", which also excludes an album carrying no tracks at all. Same answer for the same reason — nothing to play, nothing to browse to — and it is what genre has always done, since an album with no tracks contributes no genres either. That last part changed two existing tests, which had been seeding trackless albums as a convenience. Their intent (undated albums never appear in a range) is untouched; they now seed a track each, which is what a real album looks like anyway. Two new tests pin the actual behaviour: a fully-missing album leaves the axis while a half-missing one stays, and the count agrees with the filtered list. |
||
|
|
b96285d6d9 |
test(android): TrackRef needs albumId and artistId — #2704
android / Build + lint + test (push) Successful in 3m46s
The queue-filter test built TrackRefs without them; they have no defaults, so compileDebugUnitTestKotlin failed. Caught by CI on the Android lane while I was reading the Go one. |
||
|
|
7ba673ed83 |
fix(library): tell clients when a file goes missing or comes back — #2704
The wire field shipped in
|
||
|
|
366692a1fc |
fix: stop the sync feed hiding missing files from clients — #2704
#2523 filtered missing tracks out of every path that CHOOSES music, but the client sync feed was never touched: GetTracksByIDs has no filter and the wire had no field for it. So every Android client held a cached library containing tracks whose files are gone, with no way to tell, and could queue them from any cache-first path -- the exact failure #2523 existed to prevent, reached by a different route. Ships the state rather than filtering the feed, of the two options the ticket weighed. A missing file is expected to come back: the scanner clears the mark, and adopts the row if it returns renamed (#2528). Withholding the row would mean a delete-and-recreate on every client for what is usually a transient unmount, churning caches and throwing away the identity #2528 works to preserve. Room goes to v8. No hand-written migration: the pre-v1 destructive fallback rebuilds from sync, which repopulates every row with the new column -- exactly the case that policy exists for. The interesting part was working out what "missing" means to a client, and it is NOT "unplayable". Two findings shaped the fix: Server search and album detail never filtered missing tracks either, and that turns out to be right rather than an oversight. The consistent rule the codebase already follows is that Minstrel never PICKS a missing track for you -- recommendation, discover, mixes and browse all exclude them -- but it does not hide one you went looking for by name or opened an album to find. Hiding track 4 makes an album look wrong. So the fix is to mark and to keep it out of queues, not to hide it. And a track whose server file is missing still plays perfectly if its audio is already in the device cache. ShuffleSource's offline pools filter to exactly those residents, so it now clears the mark on the way out: the bytes are local and the server's loss is irrelevant. Without that, the queue filter below would have thrown away tracks that work, turning a fix into an offline regression. The queue protection is one choke point rather than five call sites. setQueue is where playlists, album play-all, search, radio and cold-boot resume all converge. dropUnavailable is pure so the index arithmetic is pinned by tests -- removing entries ahead of the requested position would otherwise start playback on the wrong track, and asking to start on a missing track now starts the next playable one, which is the "gets skipped" behaviour the operator asked for. An entirely missing queue returns empty and the caller leaves the player alone rather than replacing what is playing with silence. |
||
|
|
6d729d1512 |
fix(web): timeUntil rounds, so a 4h wait doesn't read as 3h — #2527
test-web / test (push) Successful in 34s
CI caught a real bug, not just a brittle test. The page rendered an attempt four hours away as "in 3h", because timeUntil floored the way relativeTime does. Flooring an elapsed time is honest: "3h ago" means at least three hours have passed. Flooring a countdown is not -- 3h59m away became "in 3h", so the operator comes back an hour early and finds nothing has happened. It rounds now, with the boundary cases pinned: sub-minute is "any moment", 59.6m is "in 1h", 24h is "in 1d". That divergence is now the fourth documented difference between the two formatters, all deliberate, all in snippet #2699 with a test asserting they disagree so nobody unifies them later. The test assertions were also genuinely wrong: they read raw textContent from a template that wraps mid-sentence, so "last 2d ago" arrived as "last\n 2d ago". Added a whitespace-normalising helper — asserting on raw textContent makes a test fail when the markup reflows, which says nothing about the behaviour. |
||
|
|
414dfb23b6 |
feat: show what re-acquisition has done, per folder — #2527
Completes milestone #290. The sweeper has been running and the settings have been editable, but the list itself said nothing about either, so the only way to tell "not tried yet" from "asked twice and nothing came back" was to go and read the Requests queue. Each folder now carries its album's attempt record: how many times, when last, when next -- or that it gave up, with the reassurance that a file coming back and going missing later starts the process over. Null when nothing has been attempted, which is the common case for a folder that just went missing and would be noise on every row. next_attempt_at is computed, not stored. The schedule is a function of the attempt count and the current settings, so persisting it would go stale the moment an operator edited the backoff -- and the card lets them do exactly that. Needed a forward-looking formatter. relativeTime deliberately collapses a future timestamp to "just now" (pinned by its own test) because that is the right answer for a clock-skewed past event; it is the wrong one for a scheduled future attempt, which would have rendered "next just now". timeUntil is its companion rather than a sign-aware rewrite: the two read differently in the same sentence -- "last tried 3d ago, next in 4h" -- and a test asserts they disagree about the future on purpose, so nobody later "fixes" the divergence. The state lookup is one batched query for the whole page and best-effort: this is context on a list whose real job is showing what is missing, so a failure leaves the groups bare rather than failing the page. The settings service is read with a nil guard falling back to the shipped defaults, since contexts that wire routing without services exist and a backoff projection is not worth a nil-pointer panic (rule #48). |
||
|
|
952132714e |
feat(web): re-acquisition settings card on the missing-files page — #2527
test-web / test (push) Successful in 34s
Rule #27: the sweeper has been running since
|
||
|
|
c2862e97bd |
test(api): cover the missing-file admin routes in the Mount test — #2527
go vet caught the Mount signature change: library_test.go calls it from
inside the package, so the earlier grep for "api.Mount(" missed it.
Rather than only appending the argument, the route table now includes
both admin surfaces from this arc. That test exists to prove every route
is actually registered — a 404 there means the route is missing — and
the two paths added today had no such coverage. Both are in the admin
group, so reaching the 401 is what proves they are wired.
The new service is passed as nil, matching the other optional services
in this call: the test asserts routing, never executes an admin handler,
and constructing a settings service would need a pool round-trip for
nothing.
|
||
|
|
30a5ac56ce |
feat(api): admin endpoints for the re-acquisition policy — #2527
GET/PUT /api/admin/library/reacquisition, so every knob the sweeper reads is editable without a restart (rule #25). Routed under /library beside the missing-files list it governs rather than under /lidarr: Lidarr is the mechanism, but missing files are the problem the operator came to solve, and that is the surface they meet it on. The payload carries one thing the settings table doesn't: the count of albums with missing files that can never be auto-requested, because neither they nor their artist has an MBID. Nothing can be asked of Lidarr for a release MusicBrainz cannot name, and a feature that silently does nothing for part of its input reads as broken -- so the card states the number instead of leaving it to be inferred. Counted best-effort: the settings are the point of the endpoint, and failing the whole card because a count query hiccuped would be the wrong trade. Range errors come back as 400 naming the field. The Go-side validation mirrors migration 0056's CHECKs precisely so the operator reads "grace_hours must be 1-720" rather than a constraint-violation string surfacing as a 500. |
||
|
|
bab9b16831 |
feat(library): a missing file asks Lidarr for itself, on a backoff — #2527
Answers the open fork on #2527's last slice: automatic, not a button. Until now missing_since was a dead end -- reconcile marks it, every selection path skips it, the admin surface lists it, and there it sits. Two decisions carry most of the safety, both at the design level rather than as rate limits bolted on afterwards. The unit is the ALBUM, not the track. Lidarr acquires releases; there is no meaningful "fetch me one track", and a track-kind request needs a recording MBID plenty of files lack. Grouping means the loss that produced #2523 -- three reorganised albums, ~40 missing files -- becomes three requests instead of forty. The flood problem mostly dissolves. And nothing is requested until a file has been missing longer than the grace window (24h default). A filesystem lies transiently: an unmounted volume, a container that started before its media mount attached, a NAS mid-reboot. Every one of those resolves itself well inside a day at no cost. missing_since is never re-stamped (#2523), so it is a true "gone since" clock to measure against, not "when we last noticed". This is the difference between automatic and trigger-happy. Then the backoff proper: 6h -> 12h -> 24h -> 48h per album, clamped to a week, three attempts before giving up, and a per-pass ceiling so a genuinely large loss trickles instead of dumping hundreds of rows into the queue. Giving up is stamped as a timestamp rather than inferred from attempts >= max, so the verdict survives an operator later raising the maximum and the surface can say when. A sweeper, not a hook inside reconcile. Reconcile runs inside a scan and has no business deciding to talk to a third-party service; it also re-runs often, which would make "attempt once, then back off" awkward to express. A worker paces itself, survives a restart, and retries without needing another scan. Recovered albums have their state deleted rather than reset -- a future loss is a new problem, not a continuation. Requests are attributed to the oldest admin: lidarr_requests.user_id is NOT NULL and a re-acquisition has no requesting human, so this keeps the row auditable and in the same queue as everything else without inventing a synthetic principal the schema would have to understand. Auto-approve defaults ON. Requests are created pending and nothing reaches Lidarr until approval, so with it off this would be a notification rather than an attempt. Lidarr disabled leaves the request pending rather than counting a failure -- the record of intent is still right and becomes actionable the moment Lidarr is configured. Albums with no MBID are counted, not silently skipped: nothing can be asked of Lidarr for a release MusicBrainz cannot name, and quietly doing nothing would read as the feature being broken. Settings are DB-backed per rule #25 with CHECK-guarded ranges, validated in Go as well so the API answers 400 rather than surfacing a constraint violation. The admin card and the state on the missing-files page are next; this is the engine. |
||
|
|
03a8d12079 |
docs: stop pointing at the deleted Flutter tree — #2710
Every comment naming a path in flutter_client/ now resolves to nothing, which is the failure mode this project has already been bitten by twice -- drift #572 came from delete.go describing behaviour it no longer had, and that same docstring was still wrong when it was fixed last week. A pointer to a deleted directory is the same thing in slower motion: the reader follows it, finds nothing, and cannot tell whether the comment is stale or they are looking in the wrong place. Three treatments, per what each comment was actually doing: - Naming a concept ("mirrors db.dart's CachedTracks Drift table"): keep the concept, drop the path. The Drift table is why the entity looks as it does; the file it lived in is not. - Pure port bookkeeping ("Mirrors <path>." and nothing else): deleted. Git history records the port; the comment only restated it. - Substance introduced by a pointer (a lifecycle list, a 200 px/s threshold, an inverted control-row placement): keep the substance, drop the lead-in. The two Go comments were the valuable ones and got more than a trim. They stated a live contract -- "field names match the client's FromJson helpers exactly, or fields are silently dropped" -- against a client that no longer exists. They now name the real consumer, SyncResponseWire.kt, and say why the failure is silent there too: kotlinx.serialization skips unknown keys, so a renamed field arrives as a default value rather than an error. The ticket counted 64 files by grepping flutter_client/. A second tier turned up during the sweep: 15 more references naming bare Dart files (player_bar.dart, now_playing_screen.dart:464, auth_provider.dart) with no directory prefix. Same dead tree, same treatment, folded in here. Comments only -- verified no non-comment line is touched in the diff. |
||
|
|
0036f534db |
chore: delete the Flutter client — #2710
android / Build + lint + test (push) Successful in 4m1s
Superseded by the M8 native Android rewrite. Last touched 2026-05-31, no workflow has built it since flutter.yml was removed, and rule #22 says a replaced path goes rather than lingering as something a reader has to work out the status of. 245 files, ~24.6k lines. Config references go with it: the .gitignore block (and its now-empty "# Flutter" header), the .dockerignore entry, and renovate's ignorePaths entry, which was suppressing dependency scanning for a directory that no longer exists. ci-requirements.md said ci-flutter "will retire once that directory goes". It has gone, so the doc now says so -- CI-Runner can drop the image, and nothing in this repo needs a Flutter toolchain. One thing is kept rather than deleted: shared/fabledsword.tokens.json. It lived under flutter_client/shared/ but was never Flutter's property -- it is the canonical statement of the palette, the only place the dark, light and flat cohorts are written down together, and FabledSwordTokens.kt names it as its source of truth. Losing it would have been collateral damage, so it moves to the repo root with a README saying what it is and that neither client generates from it. That comment in FabledSwordTokens.kt is repointed here. What deliberately does NOT change: `runs-on: flutter-ci` in android.yml and release.yml. That is a runner LABEL, not a path -- the Android jobs schedule on it while pulling ci-android:36, per the label/image split ci-requirements.md documents. Removing it would break scheduling for a cosmetic win, so the doc now spells that out beside the retirement note. Left for #2710: 64 files whose comments still name flutter_client/ paths. Sweeping them here would have buried the deletion, and each needs a judgement -- keep the substance and drop the dead path, delete pure "ported from" bookkeeping, or leave design rationale that happens to mention the Flutter build. |
||
|
|
bfb6c9acfe |
style(android): satisfy detekt on the new browse tabs — #2467
android / Build + lint + test (push) Successful in 3m54s
Two findings, both fair: LibraryScreen was one line over the 60-line cap once Genres and Years were added to its pager. Split the page bodies into LibraryTabPage, so the screen is the scaffold and tab bar while the routing table lives on its own -- adding a tab is now one line there and one label in LIBRARY_TABS, rather than growing a function that was already at its limit. The decade arithmetic used a bare 10 twice. Named it YEARS_PER_DECADE: floor-to-decade reads as arbitrary without it. |
||
|
|
3eada70aac |
feat(android): Genres and Years browse axes in the Library — #2467
android / Build + lint + test (push) Failing after 1m22s
#367 shipped genre and year browsing on web only, which left the web tab bar's own comment -- "mirrors Android's LibraryScreen" -- half aspirational. Android now has both, straight after Albums, in the same order the web bar uses. Server-backed, and that is the one real decision here. Every other Library tab reads Room, and building these indexes locally was the obvious move: the cache is a full mirror and carries both genre and releaseDate. It does not work. /api/library/sync hydrates through GetTracksByIDs, which has no missing_since filter, and neither SyncTrackWire nor CachedTrackEntity has a field for it -- so the cache holds tracks whose files are gone and cannot tell you which, while the browse index excludes them. A locally-derived index would quietly disagree with the server's and with the web client, and could offer a genre that exists only in missing files. Filed as #2704; until it is resolved these two tabs need a connection, and their empty states say what they are rather than looking broken. Genre is a query parameter end to end, never a path segment: "Rock/Pop" is a real ID3 tag and a slash does not survive a path. That is also why the drill-down is a second state inside the tab instead of a nav destination -- a route would have had to carry the label. Index shapes mirror web because the reasoning was already worked out there: genres default to count order, since raw tags carry a long tail of one-offs that A-Z buries the real genres under, with an A-Z chip for when you already know the name; years group by decade, newest first, because a flat list of every year in a decades-deep library is a wall of numbers. Page size matches web's BROWSE_PAGE_SIZE so "Load more (N left)" steps identically on both. The orderings and grouping are pure functions, tested: server order left alone under count sort, case-insensitive A-Z, no mutation of the loaded state's list, a slashed tag surviving the filter intact, decade bucketing including the boundary year, and a count label that stays blank rather than flashing "0 albums" while the first page loads. |