b1b10b0c77cc4c0c2495875d88e5542a03157919
459
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
b1b10b0c77 |
fix(web): run Tailwind 4 through @tailwindcss/vite, not PostCSS (#5021)
release / govulncheck (push) Successful in 18s
release / web (push) Successful in 56s
release / go (push) Successful in 2m22s
release / integration (push) Successful in 4m56s
release / android (push) Successful in 5m43s
release / Build signed APK (releases and dev) (push) Successful in 5m54s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 1m14s
release / Verify release artifacts (tag releases only) (push) Skipped
The image build on
|
||
|
|
94a9c8cbe3 |
chore(deps): SvelteKit 3 with adapter-static 4 and TypeScript 6, full-tree npm audit (#5021)
Merges Renovate's kit 3 (PR #149) and adapter-static 4 (PR #148) bumps, plus the migration they need. The mechanical part is `sv migrate sveltekit-3`, run one task at a time and reviewed: - svelte.config.js is gone. Its options move into sveltekit() in vite.config.ts, exported as kitOptions so vitest.config.ts runs the same kit setup, including the $test-utils alias the tests import. - $lib becomes #lib through package.json "imports". There is no src/lib/index, so only the "#lib/*" entry is kept. - tsconfig extends $app/tsconfig. - Peer floors raised to kit 3's requirements: svelte ^5.57.1, vite ^8.0.12, svelte-check ^4.7.5. By hand, from the codemod's list of non-automated tasks: - goto's replaceState option is now replace; keepFocus becomes reset: false. For the search typeahead, reset: false also stops the scroll-to-top, which is wanted while typing. - The test setup mocks drop pushState/replaceState and $app/paths base/assets, which kit 3 removed, and mock refreshAll in place of invalidateAll. - The other flagged files only read page.url or goto internal routes, so they needed no change. TypeScript goes to ^6, not the ^7 Renovate offers: kit 3 declares typescript ^6 as a peer and svelte-check 4.7 accepts ^5 || ^6. Move to 7 once both accept it. With Tailwind 4 and kit 3 in, `npm audit` on the whole tree reports 0, so the web lane now audits every dependency rather than only what ships to browsers. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
beba5b2082 | Merge remote-tracking branch 'origin/renovate/sveltejs-kit-3.x' into dev | ||
|
|
fecd030b68 |
chore(deps): Tailwind 4 (#5021)
release / govulncheck (push) Successful in 42s
release / web (push) Successful in 1m39s
release / go (push) Successful in 1m53s
release / integration (push) Successful in 5m37s
release / android (push) Successful in 6m54s
release / Build signed APK (releases and dev) (push) Successful in 7m27s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Failing after 34s
release / Verify release artifacts (tag releases only) (push) Skipped
Renovate's tailwindcss bump (PR #150) plus the migration it needs: - Theme moves from tailwind.config.js into app.css as `@theme inline`, mapping the same FabledSword tokens. tailwind.config.js is gone. - PostCSS runs @tailwindcss/postcss; autoprefixer is dropped, since Tailwind 4 prefixes through Lightning CSS. - Class renames from @tailwindcss/upgrade 4.3.3, reviewed: outline-none -> outline-hidden, focus-visible:outline -> outline-solid, shadow -> shadow-sm, shadow-sm -> shadow-xs, flex-shrink-0 -> shrink-0. Bare `rounded` stays: v4 keeps it at 0.25rem, as before. - Three v3 preflight defaults kept in a base layer so nothing changes on screen: gray-200 default border colour, gray-400 placeholder text and the pointer cursor on buttons. - The unused class-based dark variant is not carried over; no template uses `dark:`. Clears the five high and two moderate npm audit findings that came in through Tailwind 3 (braces, chokidar, micromatch, fast-glob, postcss-selector-parser). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
0202c86338 |
chore(deps): update dependency tailwindcss to v4
renovate/artifacts Artifact file update failure
renovate/stability-days Updates have met minimum release age requirement
|
||
|
|
0500f6aac5 |
chore(deps): update dependency @sveltejs/kit to v3
renovate/stability-days Updates have met minimum release age requirement
renovate/artifacts Artifact file update failure
|
||
|
|
c83933b61a |
chore(deps): update dependency @sveltejs/adapter-static to v4
renovate/stability-days Updates have met minimum release age requirement
renovate/artifacts Artifact file update failure
|
||
|
|
308045d056 |
chore(deps): vite 8, vite-plugin-svelte 7 and vitest 5 together (#5021)
release / govulncheck (push) Successful in 33s
release / web (push) Successful in 1m41s
release / go (push) Successful in 1m50s
release / integration (push) Successful in 4m57s
release / android (push) Successful in 6m25s
release / Build signed APK (releases and dev) (push) Successful in 6m37s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 2m13s
release / Verify release artifacts (tag releases only) (push) Skipped
Merges Renovate's three branches (PRs #145, #146, #147), which fail alone: vite-plugin-svelte 7 requires vite 8, and vitest 5 is the vitest for vite 8. Renovate changed only package.json, so npm ci failed on each. The lockfile is regenerated for just these packages: the stale entries for vite, vitest, @vitest/* and vite-plugin-svelte (with its old inspector) were dropped and re-resolved, leaving everything else locked. SvelteKit stays on 2.70.3, which accepts vite 8 and plugin 7. Vite 8 builds with Rolldown, so esbuild moves to 0.28 and rollup leaves the tree. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
5d5af359b6 | Merge remote-tracking branch 'origin/renovate/vite-8.x' into dev | ||
|
|
67f1975f53 |
chore(deps): update dependency vitest to v5
renovate/artifacts Artifact file update failure
renovate/stability-days Updates have met minimum release age requirement
|
||
|
|
5dfbe1361e |
chore(deps): update dependency vite to v8
renovate/stability-days Updates have met minimum release age requirement
renovate/artifacts Artifact file update failure
|
||
|
|
39c33e4306 |
chore(deps): update dependency @sveltejs/vite-plugin-svelte to v7
renovate/stability-days Updates have met minimum release age requirement
renovate/artifacts Artifact file update failure
|
||
|
|
63709a433d |
feat(notifications): grouped email digest, new music as a daily summary (#5346)
release / govulncheck (push) Successful in 21s
release / web (push) Successful in 1m19s
release / go (push) Successful in 1m39s
release / integration (push) Successful in 5m27s
release / android (push) Successful in 5m47s
release / Build signed APK (releases and dev) (push) Successful in 5m34s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 26s
release / Verify release artifacts (tag releases only) (push) Skipped
Nothing is emailed per event. New music (request_completed) goes out at most once a day, at the summary hour in each user's own timezone, grouped by artist. Everything else is batched: one email a window after the first un-emailed item, holding whatever accumulated. - Migration 0074: notification_email_settings (summary hour, batch window, admin-configurable) and user_notification_email_state (batch start, last sent, failures and retry_after per user and group). Existing rows are stamped emailed so the upgrade sends no backlog. - The Notifier stamps emailed_at at write time when the recipient's email channel is off, so turning email on later doesn't send old items. - Read rows are never selected. A row is stamped only after the mailer accepts, in one transaction with the state, against the read's clock, so a coalesced row updated mid-send stays pending. - A failed send backs off 5m doubling to 6h; SMTP not configured just waits. - Links come from the public address; without one the email has none. - The mailer now RFC 2047-encodes subjects and strips line breaks from them. - Admin → Integrations gains a Notification emails card. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
87f8ed6147 |
feat(web): notification settings per kind and channel (#5345, web half)
- A Notifications section on Settings has a row per kind and a toggle each for Inbox, Phone and Email. Labels are short, menu-style. - Admin kinds sit under "Library health", for admins only. - Toggles are optimistic and send only the kind and channel touched. A failed save reverts unless something newer has happened (snippet #5106's generation counter). - With the inbox off, phone and email are disabled: they ride on it. - When email isn't usable, one line says why. With no address it links to the profile. With no SMTP an admin gets a link to Integrations and a listener is simply told. Saving the profile refreshes the settings so the line clears. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
956058d4a0 |
feat(web): notifications bell, unread badge and inbox panel in the header (#5342)
- The bell sits between search and the user menu. Its badge is parchment on obsidian, not the accent, which the house style keeps off general chrome. It counts up to 9, then shows 9+. - The panel lists the server-rendered title, body and relative time, newest first, with unread rows marked. Clicking a row marks it read and opens its link. "Mark all read" appears while anything is unread, and an empty inbox says "Nothing waiting for you." - createNotificationsQuery and createUnreadCountQuery poll every 60s while the tab is visible. The `notification.created` live event invalidates ['notifications'] so the badge and list refresh promptly. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
5dffe51b95 |
feat(web): duplicates report flags identical audio under different titles (#3885)
release / govulncheck (push) Successful in 15s
release / Build + push container image (push) Successful in 26s
release / Verify release artifacts (tag releases only) (push) Skipped
release / web (push) Successful in 1m10s
release / go (push) Successful in 1m28s
release / integration (push) Successful in 4m21s
release / android (push) Successful in 4m43s
release / Build signed APK (releases and dev) (push) Successful in 4m57s
release / Attach APK to the Release (tag releases only) (push) Skipped
An exact-tier group whose copies carry different titles means at least one file's tags are wrong, and the recording the other title names may be missing from the library. WWW (2020) was this: "WWW" was a second copy of the instrumental, the vocal was absent, and nothing said so. The report now names the titles and says what it implies, so the absence surfaces at the moment of choosing which copy to keep. Titles compare case- and whitespace-insensitively. Acoustic-tier groups are left alone: across encodings a "Remastered" suffix is routine, not a mislabel. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
b46c080d19 |
fix(web): all accent text and icons use accent-fg (#5318)
release / govulncheck (push) Successful in 17s
release / web (push) Successful in 1m11s
release / go (push) Successful in 1m26s
release / integration (push) Successful in 4m18s
release / android (push) Successful in 4m44s
release / Build signed APK (releases and dev) (push) Successful in 4m53s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 26s
release / Verify release artifacts (tag releases only) (push) Skipped
The raw accent fails AA as text on every dark surface, not only on its own tint: 3.04:1 on the page, 2.70 on iron, 2.21 on slate, against 4.5. accent-fg (the house formula, 45% toward parchment) measures 5.62 at worst across both modes. The operator chose the readable colour over the signature teal for text, on 2026-10-08. - 36 sites swap. They are 35 Tailwind uses: links, "Now playing", the ingest progress line, active shuffle/repeat, the liked heart, the app download icon and its hover. The last is the alphabet rail's pending spinner in CSS. Icons follow the text: as graphics they need only 3:1, and the raw accent misses even that on iron. - check-tint-contrast adds accent to TEXT_NEVER_RAW, so a new raw text-accent or color: var(--fs-accent) fails the web lane. Run against the files before the swap, it finds all 36. Borders, rings and fills keep the raw accent. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
743b6f5eac |
fix(web): error text uses error-fg on every surface, not only on tints (#3150)
release / govulncheck (push) Successful in 17s
release / web (push) Successful in 1m6s
release / go (push) Successful in 1m29s
release / integration (push) Successful in 4m30s
release / android (push) Successful in 5m18s
release / Build signed APK (releases and dev) (push) Successful in 5m32s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 25s
release / Verify release artifacts (tag releases only) (push) Skipped
Raw error red fails AA as text even with no tint behind it: in dark mode it measures 3.63:1 on obsidian, 3.23 on iron and 2.64 on slate, against 4.5. error-fg (the house formula, 50% toward parchment) measures 5.30 at worst across both modes. - All 19 text-error uses become text-error-fg: the "Couldn't load" messages on the admin pages, the integrations form errors, the flag popover, and the error toast's text. The toast keeps its error border, since a border is a graphic with a 3:1 floor. - check-tint-contrast flags raw error text anywhere (text-error, class:text-error, color: var(--fs-error)) and leaves borders and outlines alone. Run against the files before the swap, it finds all 19. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
fdee77eaec |
fix(web): text on a tint of its own hue uses the house -fg tokens (#3150)
release / govulncheck (push) Successful in 38s
release / integration (push) Successful in 5m1s
release / Build + push container image (push) Successful in 1m17s
release / Verify release artifacts (tag releases only) (push) Skipped
release / web (push) Successful in 1m22s
release / go (push) Successful in 1m48s
release / android (push) Successful in 5m41s
release / Build signed APK (releases and dev) (push) Successful in 5m11s
release / Attach APK to the Release (tag releases only) (push) Skipped
A hue painted as text on a color-mix tint of itself sits close to the surface under it. On Minstrel's surfaces the raw accent on its 15% tint measures 1.97:1 at worst (dark mode, hover surface), against AA's 4.5. - tokens.json gains colors.fg: the five FabledSword -fg formulas (accent 45%, success 45%, warning, error and info 50%), each mixed toward parchment so one declaration serves both modes. Success is Minstrel's moss. tokens-to-css emits them in :root. - Tailwind exposes them as text-accent-fg, text-warning-fg, text-error-fg and text-info-fg. - 23 sites swapped: 14 Tailwind class strings (PlayerBar and the admin count pills) and 9 CSS rules (StatusPill's four tones and five accent chips). Worst case after: accent-fg 5.03, error-fg 4.75, warning-fg 4.92, success-fg 4.85. - scripts/check-tint-contrast.js finds the pair in either spelling. Its test scans src in the web Vitest lane and fails on any new site, with fixture cases showing it can fail. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
c17f4273c6 |
test(web): stub SvelteKit app modules suite-wide so no test loads the client runtime (#3943)
release / govulncheck (push) Successful in 32s
release / web (push) Successful in 1m25s
release / go (push) Successful in 1m41s
release / integration (push) Successful in 4m55s
release / android (push) Successful in 6m15s
release / Build signed APK (releases and dev) (push) Successful in 6m42s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Verify release artifacts (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 2m4s
The home page reaches the real $app/navigation through AlbumCard and AlbumMenu. That loads SvelteKit's client runtime, whose $app/paths reads __SVELTEKIT_PAYLOAD__ at module load. The global is only there when the kit plugin's define reaches the module, and under vitest that is not reliable: page.test.ts failed to load on CI run 6576 and passed on its re-run. #374 was the same class of failure. vitest.setup.ts now mocks $app/navigation, $app/state and $app/paths for every test. Per-file mocks still win. A small guard test fails if the suite-wide mocks are removed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
0766349397 |
feat(brand): the browser tab icon is the full logo (#5267)
release / web (push) Successful in 1m15s
release / govulncheck (push) Successful in 47s
release / go (push) Successful in 2m19s
release / integration (push) Successful in 5m34s
release / Build signed APK (releases and dev) (push) Successful in 6m23s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / android (push) Successful in 6m10s
release / Build + push container image (push) Successful in 1m30s
release / Verify release artifacts (tag releases only) (push) Skipped
The tab icon was a hand-drawn reduced hat, made because the traced art loses detail at 16px. A redrawing reads as a different logo. The tab icon now uses the same traced mark as the header: a high-resolution screen draws a tab icon from 32px, where it holds, and at 16px it keeps the logo's shape. The drawn reduced mark had no other consumer, so it leaves the generator, and mark-small.svg (referenced nowhere) is removed. Regenerating changed only favicon.svg and favicon.png; every other brand asset is byte-identical. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
e8eee55325 |
fix(web): AcoustID card's loading line names itself (M401 #3922)
release / integration (push) Successful in 6m4s
release / android (push) Successful in 8m1s
release / Build signed APK (releases and dev) (push) Successful in 8m22s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 1m31s
release / Verify release artifacts (tag releases only) (push) Skipped
release / govulncheck (push) Successful in 28s
release / go (push) Successful in 1m41s
release / web (push) Successful in 1m21s
Its bare "Loading…" made the Integrations page's cover-providers test find two matches for /loading…/i (Vitest, run 8487). "Loading AcoustID settings…" also says which card is loading. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
03e07e7c66 |
fix(web): send the empty body api.post requires for AcoustID run-now (M401 #3922)
release / govulncheck (push) Successful in 25s
release / web (push) Failing after 1m18s
release / go (push) Successful in 1m43s
release / Build + push container image (push) Canceled after 0s
release / Attach APK to the Release (tag releases only) (push) Canceled after 0s
release / Verify release artifacts (tag releases only) (push) Canceled after 0s
release / integration (push) Canceled after 4m51s
release / android (push) Canceled after 4m51s
release / Build signed APK (releases and dev) (push) Canceled after 4m6s
svelte-check on
|
||
|
|
0a7f788390 |
feat(web): AcoustID card on Integrations — key, threshold, coverage by source (M401 #3922)
release / go (push) Successful in 2m25s
release / web (push) Failing after 26s
release / govulncheck (push) Successful in 21s
release / Attach APK to the Release (tag releases only) (push) Canceled after 0s
release / Build + push container image (push) Canceled after 0s
release / Verify release artifacts (tag releases only) (push) Canceled after 0s
release / integration (push) Canceled after 3m34s
release / android (push) Canceled after 1m3s
release / Build signed APK (releases and dev) (push) Canceled after 1m3s
The card takes the slot of the unimplemented "MusicBrainz overrides" placeholder. Rows: - the on switch - a write-only key field (the stored key is never sent back), with a link to register an application - the minimum score (0.5 to 1) Below them, recording-id coverage reads as a column: from tags, looked up, none, and of the none how many are waiting, no match, ambiguous or failed. There is a "Look up now" button and a folded list of the tracks the lookup could not settle. Off, keyless and stopped-short passes are each a visible state with the reason (rule 164). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
f34423a0e0 |
feat: leveled FLAC stream for Sonos/UPnP speakers (M464 #5001)
release / go (push) Successful in 2m17s
release / govulncheck (push) Successful in 26s
release / web (push) Successful in 2m4s
release / Attach APK to the Release (tag releases only) (push) Canceled after 0s
release / Build + push container image (push) Canceled after 0s
release / Verify release artifacts (tag releases only) (push) Canceled after 0s
release / integration (push) Canceled after 4m39s
release / android (push) Canceled after 2m53s
release / Build signed APK (releases and dev) (push) Canceled after 2m24s
Speakers fetch their own audio, so the phone cannot level it. A cast
token minted with level=true (and the client's asAlbum, which only the
queue holder knows) now returns GET /api/tracks/{id}/leveled.flac: the
track rendered by ffmpeg at the user's gain (volume=XdB, plus
alimiter at -1 dBFS for a limiter-mode boost), metadata stripped, FLAC
at 16 or 24 bits and at most 48 kHz. The gain is computed server-side
from the user's preference and the stored loudness, carried as
?g=<centi-dB>&lim=0|1 and signed into the token, so an edited URL does
not verify. Unity gains get the plain stream.
Renders are written beside the cache file and renamed in, keyed by the
source's size and mtime, coalesced per file (singleflight, detached
from the requesting speaker so a retry finds the render running),
started at mint time so the fetch finds them ready, and evicted least
recently used past leveled_cache_mb, a new admin setting (migration
0068, Loudness analysis card).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
||
|
|
1013c283da |
feat(android): level playback with a gain processor in the audio sink (M464 #5000)
release / go (push) Successful in 1m43s
release / web (push) Successful in 1m27s
release / govulncheck (push) Successful in 35s
release / integration (push) Successful in 5m16s
release / android (push) Failing after 3m52s
release / Build signed APK (releases and dev) (push) Failing after 3m20s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Skipped
release / Verify release artifacts (tag releases only) (push) Skipped
Media3 1.10.1 -> 1.11.0, the version Renovate proposes; 1.11 flushes the sink's audio processors at every item boundary with the playlist timeline and the new item's period. GainAudioProcessor uses that to find the track and its play-order neighbours (auto mode's album rule) and applies the gain from the first sample, gapless transitions included, with a -1 dBFS peak limiter in limiter mode and a full-scale clamp otherwise. Gains come from the library cache first (sync now carries track and album ReplayGain values; Room v10 adds the columns and rewinds the sync cursor so an existing cache re-pulls them), then GET /api/tracks/replay-gain, then none. The player service refreshes the leveling preference at start. Web: a same-album neighbour without a track number no longer counts as in-order album play, matching Android. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
38290bf8f9 |
feat(web): level playback by the user's normalization preference (M464 #4999)
release / integration (push) Successful in 4m26s
release / android (push) Successful in 5m20s
release / Build signed APK (releases and dev) (push) Successful in 5m31s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 32s
release / Verify release artifacts (tag releases only) (push) Skipped
release / govulncheck (push) Successful in 18s
release / web (push) Successful in 1m13s
release / go (push) Successful in 1m29s
Cuts go through element.volume. Boosts route the element through a Web Audio GainNode and a DynamicsCompressor (a -1 dBFS limiter in limiter mode, a pass-through otherwise), built only when a track wants a boost and only once an AudioContext is confirmed running; iOS never gets the graph. Auto mode takes album gain when a queue neighbour is from the same album in track order. Gains are fetched for the next 50 tracks as the queue moves, with a 10s deadline. The prefetch element is untouched. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
d5dfcf5b7c |
fix: boost control is a switch; MutationQueue keeps one enqueue per kind (M464 #4998)
release / go (push) Successful in 2m15s
release / govulncheck (push) Successful in 29s
release / web (push) Successful in 1m42s
release / android (push) Successful in 5m57s
release / Build signed APK (releases and dev) (push) Successful in 5m49s
release / integration (push) Failing after 20m33s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Skipped
release / Verify release artifacts (tag releases only) (push) Skipped
The ListenBrainz settings test finds the page's one checkbox, and the boost control is a toggle anyway. detekt counts MutationQueue's enqueue functions; suppressed as the replayer's dispatchers already are. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
af36b2f24a |
feat: per-user volume leveling preference, synced across devices (M464 #4998)
release / web (push) Failing after 1m5s
release / govulncheck (push) Successful in 22s
release / go (push) Successful in 1m17s
release / android (push) Failing after 1m51s
release / integration (push) Canceled after 4m12s
release / Attach APK to the Release (tag releases only) (push) Canceled after 0s
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 3m21s
Mode (off, auto, track, album), target (-18, -16, -14 LUFS) and boost (within headroom, or fully with a limiter), stored per user on the server so the web player, the Android app and casts apply the same one. - Server: user_normalization_prefs (migration 0067), GET/PUT /api/me/normalization; a whole-body PUT, validated, last write wins. - Web: Settings > Playback > Volume leveling. Saves at once, restores the old choice if the save fails, and caches the value for the player. - Android: Settings card. The device keeps a copy for offline playback (Room v9 with an explicit migration, so the upgrade wipes nothing). Writes are offline-first: shown at once, PUT best effort, queued on failure (NORMALIZATION_SET, collapsed to the newest). A refresh never overwrites a change still queued. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
f3196b3443 |
feat(library): measure every track's loudness in the background (M464 #4995)
release / go (push) Failing after 1m18s
release / govulncheck (push) Successful in 35s
release / web (push) Successful in 1m27s
release / android (push) Canceled after 5m47s
release / Build signed APK (releases and dev) (push) Canceled after 4m21s
release / integration (push) Failing after 4m13s
release / Attach APK to the Release (tag releases only) (push) Canceled after 0s
release / Build + push container image (push) Canceled after 0s
release / Verify release artifacts (tag releases only) (push) Canceled after 0s
The first step of loudness normalization: the server measures each track with ffmpeg's EBU R128 filter (true peak, mono as dual mono) and stores the integrated loudness, true peak and loudness range in track_loudness (migration 0065). It also keeps a histogram of the 400 ms gating blocks at 0.1 LU, so album loudness can be computed exactly later with no second decode (#4996). The histogram reproduces ffmpeg's own figure (-10.68 against -10.7 on the captured fixture), and the analyzer logs a warning if the two ever drift. - A background worker, cloned from the fingerprint backfill, measures every track, new ones included. Measuring inline in the scan was dropped: the analysis decodes the whole file, and a large import could pass the scan's one-hour stuck threshold. The scan only deletes a changed file's measurement; the worker ticks every 10 minutes. - Timeouts, the cancel/missing-binary split and settled verdicts follow the fingerprint runner. Silence and undecodable files are stored as verdicts; stalls are retried. The deadline scales with track length. - loudness_settings (enabled, files at once) and an admin card with the coverage gauge, under GET/PUT /api/admin/library/loudness-settings and GET /api/admin/library/loudness. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
edd9a3a6db |
fix(auth): the Subsonic password is generated, never the login password (M462 #5026)
release / govulncheck (push) Successful in 39s
release / web (push) Successful in 1m8s
release / go (push) Successful in 1m30s
release / integration (push) Successful in 4m37s
release / android (push) Successful in 5m56s
release / Build signed APK (releases and dev) (push) Successful in 5m55s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 1m23s
release / Verify release artifacts (tag releases only) (push) Skipped
`minstrel admin reset-password` copied the new login password into subsonic_password, which is stored in plain text because Subsonic t/s sign-in needs it. Every account recovered through the CLI had its login password readable in the database, and changing the password later left the copy behind. - reset-password now changes only password_hash. - Migration 0064 clears every subsonic_password, removing the copies. - Settings gets a Subsonic password card: the server generates a random password, shows it once, and it can be regenerated or turned off (GET/POST/DELETE /api/me/subsonic-password, audited). Generated rather than user-chosen so it can never be a reused password. - docs/security.md describes the separate password instead of the known issue. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
3217e10168 |
fix(deps): clear known vulnerabilities in what ships; build on the Go line CI tests (M462 #4984)
- golang.org/x/text v0.37.0 -> v0.39.0 (GO-2026-5970, infinite loop on invalid input, reachable from pgxpool). x/sync follows to v0.21.0. - web lockfile: in-range updates from `npm audit fix` for devalue (high) and svelte (moderate), both of which ship in the browser bundle. package.json is unchanged. - Dockerfile builder golang:1.25 -> golang:1.26. CI has tested on 1.26 since the ci-go migration while the image was still compiled with 1.25, left over from the April skeleton; the shipped binary now uses the toolchain the tests ran on. govulncheck under golang:1.26-bookworm (go1.26.8) reports 0 vulnerabilities reachable from our code. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
327d49428f |
feat(auth): store Subsonic API keys hashed; a new key is shown once (M462 #4983)
test-web / test (push) Successful in 2m4s
test-go / test (push) Successful in 2m23s
test-go / integration (push) Successful in 5m28s
release / Build signed APK (releases and dev) (push) Successful in 6m29s
release / Build + push container image (push) Successful in 29s
release / Verify release artifacts (tag releases only) (push) Skipped
users.api_token held each user's apiKey in plaintext and was looked up by equality, so a leaked row or backup handed out working keys. Migration 0063 replaces it with api_token_hash (sha256, hex), computed in place from the existing keys so every Subsonic client keeps working. The key can no longer be read back: GET /api/me/api-token is gone, and POST returns the new key once. Settings shows it right after Regenerate with a copy button and a "won't be shown again" note. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
2f3fbccab6 |
feat(auth): first account on a new server needs the setup token from the server log (M462 #4982)
test-go / test (push) Successful in 2m3s
test-web / test (push) Successful in 1m14s
test-go / integration (push) Successful in 4m38s
release / Build signed APK (releases and dev) (push) Successful in 5m36s
release / Build + push container image (push) Successful in 1m24s
release / Verify release artifacts (tag releases only) (push) Skipped
While no accounts exist, the server mints a random setup token at boot and logs it. Registering the first account (which becomes admin) must carry it, so whoever reaches a freshly exposed instance first cannot claim it. The register page asks GET /api/auth/setup-status and shows a "Setup token" field in place of the invite field while setup is pending. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
46194a609d |
fix(auth): build password-reset links from an operator-set public address, never the Host header (M462 #4981)
test-go / test (push) Successful in 1m29s
test-web / test (push) Successful in 1m37s
release / Build + push container image (push) Canceled after 0s
release / Verify release artifacts (tag releases only) (push) Canceled after 0s
test-go / integration (push) Canceled after 2m45s
release / Build signed APK (releases and dev) (push) Canceled after 3m40s
buildResetURL used r.Host and r.TLS, so a forgot-password request with a forged Host emailed the victim a real reset token on a link to the attacker's server. Links now come only from network_settings.public_url (migration 0062), and no reset email is sent while it is empty; the response stays the same opaque 200 and the log says why. The address is set on a new "Public address" card under Admin → Integrations, which offers the page's own origin and warns while unset. PUT /api/admin/network-settings takes either field alone, so the proxy card and this one can't overwrite each other. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
d411693bb2 |
feat(server): security headers and a hash-based CSP for the web app (M462 #4980)
test-web / test (push) Successful in 55s
test-go / test (push) Successful in 1m14s
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 2m50s
test-go / integration (push) Canceled after 2m50s
There were no security headers at all. Now: - every response: nosniff, Referrer-Policy strict-origin-when-cross-origin, Permissions-Policy (no camera/mic/geolocation), X-Frame-Options DENY; each set only if the handler hasn't. - HSTS only when the trusted proxy reports HTTPS (rule 94); never a redirect. - index.html carries a Content-Security-Policy whose script-src is 'self' plus the sha256 of each inline script in the page as served, computed after the branding template runs. No 'unsafe-inline' or 'unsafe-eval' for scripts. img-src admits remote https/http because Lidarr suggestion art is a remote poster URL. Hashing in Go rather than via SvelteKit's kit.csp covers the inline scripts SvelteKit doesn't know about (app.html's theme bootstrap and the branding global injected at build) and stays correct whatever the app name is. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
755f997b0d |
feat(auth): sessions expire server-side; password change and reset end other sessions (M462 #4978)
Sessions had no server-side expiry: only the web cookie's 30-day Max-Age limited them, and a bearer token (Android) lived until revoked by hand. GetSessionByTokenHash and ListSessionsForUser now ignore sessions idle for 30 days or older than a year, and the GC worker deletes them hourly. A password change was a plain UPDATE, so a session opened with the old password survived it. Now: - self-service change signs out every other device and keeps this one; - reset by email ends every session the account has; - an admin reset ends the target's sessions (keeping the admin's own when they reset themselves). The success copy on web and Android says the other devices were signed out. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
3bfddd0862 |
feat(auth): throttle login, register, password reset and Subsonic auth failures (M462 #4976)
test-go / test (push) Successful in 1m54s
test-web / test (push) Successful in 1m34s
test-go / integration (push) Successful in 4m56s
android / Build + lint + test (push) Successful in 5m41s
release / Build signed APK (releases and dev) (push) Successful in 5m52s
release / Build + push container image (push) Canceled after 0s
release / Verify release artifacts (tag releases only) (push) Canceled after 0s
Every password-shaped check was mounted bare, so guessing was limited only by bcrypt cost. A shared in-memory AttemptLimiter now sits in front of them: - login: 10 failures per account and 50 per address per 15 min, checked before the user lookup and bcrypt; 429 with Retry-After. A success clears the account's count but not the address's. - unknown usernames run a dummy bcrypt compare, so timing no longer says which accounts exist. - register: 10 per address per hour; forgot-password: 5 per address and 3 per email per hour (applied whether or not the email matches); reset: 20 failed tokens per address per 15 min. - Subsonic /rest: same limits as login, counting only wrong credentials, since clients authenticate on every request. Web login, register, reset and forgot-password screens say how long to wait; web and Android carry copy for the rate_limited code. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
516413f4ca |
fix(admin): re-acquisition settings take effect without a restart, and say why a save was refused (#3936, #3937)
test-web / test (push) Successful in 1m9s
test-go / test (push) Successful in 1m28s
test-go / integration (push) Successful in 3m57s
release / Build signed APK (releases and dev) (push) Successful in 5m20s
release / Build + push container image (push) Successful in 1m23s
release / Verify release artifacts (tag releases only) (push) Skipped
#3936: Router() built a reacquisition.SettingsService of its own, so a save from the admin card refreshed that instance's cache while the sweeper in main.go kept serving what it loaded at boot. The card showed the new policy, the feature ran the old one, and only a restart reconciled them. main.go now hands its instance to the server (srv.ReacqSettings), as it already did for RecSettings, TagSettings and FingerprintSettings, and Router() constructs one only when that field is nil. The regression test saves through the router and reads the sweeper's instance. #3937: the card's catch tested `e instanceof Error`, but api.put throws a plain {code, message, status} object, so every reason the server gave was discarded in favour of "Couldn't save settings." It now uses errMessage, which appends the server's message for invalid_setting. Its test rejected with an Error no code path produces, so it passed throughout; it now rejects with what the client actually throws. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SQ31KQpYbStyK5y58UmPLH |
||
|
|
077ae61235 |
feat(admin): fingerprinting settings — on/off, length, match threshold, concurrency, sweep interval (M400 #3913)
test-go / test (push) Failing after 44s
test-web / test (push) Successful in 49s
test-go / integration (push) Failing after 2m42s
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 4m8s
Rule 25: the fingerprinting knobs move out of source into a DB-backed singleton (migration 0061), edited from a card on the Duplicates page and shared live with the scanner, the backfill and the duplicate sweep through one service instance, so a save needs no restart. The length is the knob that can silently break the library: prints taken at two lengths never match. Each track_fingerprints row now records the length it was taken at, and every reader filters on the current one — the backfill treats another length as stale, the gauge counts it pending, the sweep never streams it. Equivalent to a version bump, except that setting the length back makes rows not yet redone current again. The card warns before a length change re-fingerprints the library. Off stops every decode: the scan takes only the stream hash (a demux, and what recognises a moved file) and stores nothing, dropping a changed file's stale row; the backfill idles. A save also makes a sweep due, since a new threshold or length changes what the same prints group into, and the sweep interval gains slack so an hourly interval on an hourly tick doesn't skip every other tick. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SQ31KQpYbStyK5y58UmPLH |
||
|
|
11ef044ef6 |
feat(library): merge duplicates without losing history (M400 #3911)
test-web / test (push) Successful in 57s
test-go / test (push) Successful in 1m16s
test-go / integration (push) Successful in 3m39s
release / Build signed APK (releases and dev) (push) Successful in 4m46s
release / Build + push container image (push) Successful in 26s
release / Verify release artifacts (tag releases only) (push) Skipped
Merge keeps one copy of a duplicate group and removes the rest. Every
table that references tracks does so ON DELETE CASCADE, so deleting a
duplicate's row outright would silently destroy its likes, plays,
playlist entries and tags. The merge moves all of that onto the kept
copy first, then deletes the empty row.
In one transaction, holding a lock on the group:
- repoints play_events, skip_events, contextual_likes, playback_errors,
lidarr_requests.matched_track_id and playlist_tracks. The last is
keyed by position, so every entry stays where it was.
- merges general_likes one per user, dated to the earlier like
- takes the union of track_tags, keeping the kept copy's own weight on
a shared tag
- rewrites track_similarity onto the kept copy, dropping edges that
would point a track at itself and keeping the kept copy's existing
edge on a collision
- lets the kept copy take a recording MBID only the removed copy had
- deletes the removed copies' rows, tidies emptied albums and artists,
marks the group merged
- logs sync changes: track deletes, and like and playlist-track
delete/upsert pairs
The removed copies' files are deleted first, before any row changes,
through the same helper as DeleteTrackFile (now shared, along with the
album tidy-up). A merge that left the file behind would be undone by
the next scan re-importing it. An unwritable library answers 409
library_not_writable and nothing changes.
tracks.Service.MergeDuplicates wraps it with the opt-in Lidarr unmonitor
from RemoveTrack, skipped when the removed copy is a second file of the
kept copy's own album track: unmonitoring that would stop Lidarr
managing the kept file. It writes a duplicate_merge audit row after
commit, per the audit package's best-effort contract, naming both
paths.
POST /api/admin/library/duplicates/{id}/merge takes an optional
survivor_track_id (the report's proposal otherwise) and unmonitor.
On the report page:
- each copy gets a Keep choice, defaulting to the proposed one
- Merge needs a second click, on a button that says how many files it
removes, with the consequence stated beside an opt-in Lidarr checkbox
Integration tests cover:
- every piece of history landing on the kept copy exactly: likes
deduped at the earlier time, plays and skips counted, playlist
position unchanged, tags unioned, similarity rewritten with no
duplicate or self-edge, MBID inherited
- the removed file gone, and a second merge refused
- an unwritable file leaving likes, plays, row and group untouched
- a survivor outside the group refused
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SQ31KQpYbStyK5y58UmPLH
|
||
|
|
ff493a8c7d |
feat(admin): the duplicates report — review proposed duplicate groups (M400 #3912)
test-web / test (push) Successful in 52s
test-go / test (push) Successful in 1m9s
test-go / integration (push) Successful in 3m31s
release / Build signed APK (releases and dev) (push) Successful in 4m32s
release / Build + push container image (push) Successful in 24s
release / Verify release artifacts (tag releases only) (push) Skipped
A new admin tab, Duplicates, beside Missing files: the proposals from the duplicate sweep, with a Sweep now trigger and a Not duplicates dismissal. Nothing on it merges or deletes; the merge is #3911. Each group shows: - whether it is identical audio or the same recording, with a match percentage from the weakest link between members - every copy's format, size, duration, path, and the likes and plays it carries (every user's; this is admin-only, and it is what decides which copy to keep) - the copy proposed to keep, and the rule that chose it The survivor rule is library.ProposeSurvivor, a pure function the merge will reuse: lossless over lossy, then the larger file, then the copy in the library longest, then lowest id. Bitrate is not in it because the scanner never fills tracks.bitrate, and for one recording at one duration a larger file is the higher bitrate. m4a is not counted as lossless: it may be AAC. The reason names the rule that separated first place from second, not every rule the winner passed. An empty report has three causes, and the page says which: still fingerprinting, the sweep has never run, or it ran and found nothing. The sweep's state and the backfill's progress come back with the groups for that reason. Groups left with fewer than two members since the sweep are not shown. GET /api/admin/library/duplicates, POST .../sweep (202, or 409 sweep_in_progress), POST .../{id}/dismiss (404 duplicate_group_not_pending when already resolved). Migration 0060 indexes play_events by track_id. Its only indexes led with user_id, so each copy's play count, and the merge's repointing of play history, would scan the whole table. Web only, like Missing files: Android has no library-health admin screens. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SQ31KQpYbStyK5y58UmPLH |
||
|
|
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 |
||
|
|
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
|
||
|
|
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
|
||
|
|
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 |
||
|
|
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 |