Merge requested by the operator on 2026-10-08, with a redeploy and a db restart to follow.
Notifications inbox (M489)
A per-user inbox store with one writer, coalescing and retention (#5338), plus its API and per-user settings endpoints (#5339). Read-all takes an optional up_to cutoff.
Requests and flags reach the people who act on them (#5340). Library health reaches admins, coalesced (#5341).
Web: a header bell with an unread badge and inbox panel (#5342), and settings per kind and channel (#5345).
Email: a grouped digest, with new music as a daily summary (#5346).
Android: a bell, an inbox screen and settings, offline-first (#5343, #5345). Notifications arrive while the app is closed through a specialUse delivery service (#5347).
Recommendations and library
Discover's taste-matched arm is capped per album and artist before its LIMIT, so two artists no longer fill it (#5356).
The duplicates report flags identical audio under different titles (#3885).
Merge requested by the operator on 2026-10-08, with a redeploy and a db restart to follow.
## Notifications inbox (M489)
- A per-user inbox store with one writer, coalescing and retention (#5338), plus its API and per-user settings endpoints (#5339). Read-all takes an optional `up_to` cutoff.
- Requests and flags reach the people who act on them (#5340). Library health reaches admins, coalesced (#5341).
- Web: a header bell with an unread badge and inbox panel (#5342), and settings per kind and channel (#5345).
- Email: a grouped digest, with new music as a daily summary (#5346).
- Android: a bell, an inbox screen and settings, offline-first (#5343, #5345). Notifications arrive while the app is closed through a specialUse delivery service (#5347).
## Recommendations and library
- Discover's taste-matched arm is capped per album and artist before its LIMIT, so two artists no longer fill it (#5356).
- The duplicates report flags identical audio under different titles (#3885).
## Web tooling (#5021)
- vite 8, vite-plugin-svelte 7, vitest 5.
- Tailwind 4 through @tailwindcss/vite. The theme moves to `@theme inline` in app.css, and v3's preflight defaults are kept so nothing shifts on screen.
- SvelteKit 3, adapter-static 4, TypeScript 6. svelte.config.js moves into vite.config.ts, and `$lib` becomes `#lib`.
- `npm audit` on the full tree reports 0, and the web lane now audits every dependency.
CI on dev ca9917d8: run 8792 green, including the image build.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
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>
M489 step 1. The event bus is fire-and-forget, so a client that isn't
connected never hears that a request completed or that tracks went missing.
user_notifications is the durable record; the bus only nudges.
- Migration 0073: user_notifications (kind CHECK-gated, payload jsonb,
read_at, coalesce_key, emailed_at) and user_notification_prefs (per user,
per kind: inbox, phone, email). A missing pref row means the kind's
defaults, so nothing is seeded.
- internal/notifications.Notifier is the only writer. It resolves recipients
(admin kinds reach admins only, and never the excepted user), honours the
inbox pref (phone and email ride on it), writes, and publishes a
contentless notification.created nudge per recipient.
- Burst-prone admin kinds coalesce into one unread row: tracks_missing and
scan_failed add up their counts, duplicates_found and playback_errors take
the latest total. Once read, the next event is a new row.
- Retention: read rows go after 90 days, anything after a year, on the
library_changes compactor's daily shape.
Tests: unit (channel rules, kind table) and integration (recipients, nudge,
coalescing both ways, prefs, owner-scoped idempotent mark-read, every kind
against both schema CHECKs, retention cut-offs).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
M489 step 2.
- GET /api/me/notifications?limit&before: newest first, keyset-paged on
(created_at, id) with an opaque cursor, plus the unread count.
- GET /api/me/notifications/unread-count: the badge's cheap call.
- POST /api/me/notifications/{id}/read and /read-all. Mark-read is
idempotent; another user's id is a 404, the same as a malformed one.
- GET/PUT /api/me/notification-settings: every kind the caller can receive
(admin kinds only for admins) with inbox/phone/email. PUT is partial, so an
offline replay sends only what was touched, and a batch with any invalid
change applies nothing. The response says whether email can be delivered
at all: no address on file, or SMTP not configured. A failed SMTP config
read is a 500, not "not configured".
notifications.Render turns kind + payload into title, body and link on the
server, so the web inbox, the Android inbox, the phone's shade and the email
digest all say the same thing. mailer.Configured lifts Send's readiness
check out so settings can report it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- A new request still pending after any auto-approval notifies the
admins (request_pending), but not the requester if they are an admin.
A request that dedups into one already in flight is not announced again.
- Approving or rejecting a request notifies the requester, and a
rejection carries the admin's notes as the reason. An admin deciding
their own request gets nothing.
- The reconciler notifies the requester when their request arrives
(request_completed), linking the matched album or artist.
- A request the re-acquisition sweeper files and cannot approve itself
notifies the admins.
- A quarantine flag notifies every admin except the flagger, naming the
track, the flagger and the reason.
lidarrrequests.Service.CreateTracked reports whether a request was
inserted or deduped; Create wraps it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- A failed scan run sends scan_failed. Each failure adds to the count and
the notice shows the latest error. A scan cut short by shutdown says
nothing.
- Marking tracks missing sends tracks_missing with a running count.
- A duplicate sweep that proposes a group it had not proposed before
sends duplicates_found, counting everything awaiting review. A sweep
that only re-finds known groups stays quiet, so a read notice isn't
repeated every sweep (CountDuplicateGroupsDetectedSince).
- A playback-error report sends playback_errors, counting the unresolved
errors (CountUnresolvedPlaybackErrors).
The library package gets its notifier as a package-level SetNotifier
beside SetEventBus, for the same reason the bus is package-level.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- 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>
- 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>
POST /api/me/notifications/read-all accepts {"up_to": RFC3339}. Android
queues "mark all read" for replay when offline, and a replay landing later
must not mark notices that arrived in between, which the user never saw.
A coalesced notice updated since then has a newer created_at, so it stays
unread. An empty body still marks everything.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- The top bar carries a bell with a badge. The badge is inverse, not
error red, and counts to 9, then shows 9+. It opens the Notifications
screen: one line per notice, how long ago, "Mark all read", pull to
refresh. A tap marks the notice read and opens its screen (albums,
artists, requests, admin requests and quarantine; other admin pages
land on Admin).
- The newest 50 notices live in Room (cached_notifications, v11 with
MIGRATION_10_11), so the badge and the list work offline. A refresh
keeps a read made here that the server hasn't seen yet.
- Reads, read-all and setting toggles go through the MutationQueue
(rule 100): NOTIFICATION_READ, NOTIFICATIONS_READ_ALL and
NOTIFICATION_SETTING_SET.
- Read-all sends the newest notice shown, rounded up a millisecond
(Room keeps ms, the server µs), so a late replay leaves newer notices
unread.
- Settings collapse per kind and channel.
- The `notification.created` live event, a return to the foreground and
reconnecting all refresh the inbox.
- Settings → Notifications: a row per kind with Inbox, Phone and Email.
Admin kinds sit under "Library health". Phone and email ride on the
inbox. One line says why email is off. If the system blocks
notifications, a row opens Minstrel's notification settings; it is
re-checked on resume.
- Signing out clears the cached inbox.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
dispatch reached cyclomatic complexity 16 with the three M489 kinds; they
now share one entry that hands off to dispatchNotification.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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>
Roundtable's shape: no FCM. A specialUse foreground service keeps the
process alive; EventsStream stays connected; DeliveryLauncher runs the
catch-up on every notification.created nudge and every reconnect, and is
the single owner of the service's lifetime (signed in AND the device's
"Notifications when the app is closed", on by default).
- NotificationSync pulls the newest page and announces unread notices past
a high-water mark (auth_session.notifiedUpTo, read and written through
the DAO), honouring the per-kind phone pref. A first look sets the mark
without announcing; more than three collapse to one line; nothing is
posted while the app is on screen.
- Two channels: "Your requests" and "Library health"; the ongoing notice
sits on a MIN "Background connection" channel.
- EventsStream: a connected flow, 2s→5min backoff with ±25% jitter, and an
immediate reconnect when a network comes up (a hint, not VALIDATED) or
the app comes to the foreground.
- BootReceiver restarts delivery after a reboot or a self-update.
- A tap opens what the notice links to (routeForLink), a pile the inbox.
- POST_NOTIFICATIONS is asked for on Android 13+ once delivery is wanted,
and again when the toggle is turned on.
- Room v12: auth_session.backgroundDelivery and notifiedUpTo.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Summed tag weight rewards a track for carrying many of the user's tags, so
on the deploy two artists whose every track carries the whole lo-fi profile
took all 120 rows of the taste-unheard query. capByAlbumAndArtist ran after
the LIMIT and left 6, and the arm with the lowest skip rate (12% against
~23%) handed its slots to dormant and random.
The query now ranks within album, then within artist over what the album
cap kept, before the LIMIT: the same walk the Go cap makes, so the bucket
fills from as many artists as match. The caps come from the Go constants.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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>
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>
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>
The image build on fecd030b failed in `vite build`:
[postcss] ENOENT: no such file or directory, open '/web/tailwindcss'
With a PostCSS config present, Vite's own @import inliner resolves
`@import 'tailwindcss'` before @tailwindcss/postcss sees it, and reads it
as a relative file. svelte-check and Vitest never build CSS, so only the
image job caught it. The Vite plugin is Tailwind's documented setup for
Vite projects and handles the import itself, so postcss.config.cjs and
the direct postcss and @tailwindcss/postcss dependencies go.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
CI run 8789 warned on both:
- kit 3 deprecates `config.alias`. The $test-utils alias becomes a
`#test-utils/*` subpath import, matching #lib, and its 43 import sites
move with it.
- Vite's coming native config loader needs the extension on
vitest.config.ts's import of vite.config.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Merge requested by the operator on 2026-10-08, with a redeploy and a db restart to follow.
Notifications inbox (M489)
up_tocutoff.Recommendations and library
Web tooling (#5021)
@theme inlinein app.css, and v3's preflight defaults are kept so nothing shifts on screen.$libbecomes#lib.npm auditon the full tree reports 0, and the web lane now audits every dependency.CI on dev
ca9917d8: run 8792 green, including the image build.🤖 Generated with Claude Code
M489 step 2. - GET /api/me/notifications?limit&before: newest first, keyset-paged on (created_at, id) with an opaque cursor, plus the unread count. - GET /api/me/notifications/unread-count: the badge's cheap call. - POST /api/me/notifications/{id}/read and /read-all. Mark-read is idempotent; another user's id is a 404, the same as a malformed one. - GET/PUT /api/me/notification-settings: every kind the caller can receive (admin kinds only for admins) with inbox/phone/email. PUT is partial, so an offline replay sends only what was touched, and a batch with any invalid change applies nothing. The response says whether email can be delivered at all: no address on file, or SMTP not configured. A failed SMTP config read is a 500, not "not configured". notifications.Render turns kind + payload into title, body and link on the server, so the web inbox, the Android inbox, the phone's shade and the email digest all say the same thing. mailer.Configured lifts Send's readiness check out so settings can report it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>POST /api/me/notifications/read-all accepts {"up_to": RFC3339}. Android queues "mark all read" for replay when offline, and a replay landing later must not mark notices that arrived in between, which the user never saw. A coalesced notice updated since then has a newer created_at, so it stays unread. An empty body still marks everything. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>- The top bar carries a bell with a badge. The badge is inverse, not error red, and counts to 9, then shows 9+. It opens the Notifications screen: one line per notice, how long ago, "Mark all read", pull to refresh. A tap marks the notice read and opens its screen (albums, artists, requests, admin requests and quarantine; other admin pages land on Admin). - The newest 50 notices live in Room (cached_notifications, v11 with MIGRATION_10_11), so the badge and the list work offline. A refresh keeps a read made here that the server hasn't seen yet. - Reads, read-all and setting toggles go through the MutationQueue (rule 100): NOTIFICATION_READ, NOTIFICATIONS_READ_ALL and NOTIFICATION_SETTING_SET. - Read-all sends the newest notice shown, rounded up a millisecond (Room keeps ms, the server µs), so a late replay leaves newer notices unread. - Settings collapse per kind and channel. - The `notification.created` live event, a return to the foreground and reconnecting all refresh the inbox. - Settings → Notifications: a row per kind with Inbox, Phone and Email. Admin kinds sit under "Library health". Phone and email ride on the inbox. One line says why email is off. If the system blocks notifications, a row opens Minstrel's notification settings; it is re-checked on resume. - Signing out clears the cached inbox. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>