63709a433d873e9cf458d47cc1a5b40a8f3a5e0c
102
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
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> |
||
|
|
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>
|
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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 |
||
|
|
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
|
||
|
|
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
|
||
|
|
8d1f2674fd |
feat(web): admin page for files the library has lost — #2527
test-web / test (push) Failing after 32s
Renders GET /api/admin/library/missing under Admin -> Missing files. Folder-grouped, because that is the unit an operator decides about: the case behind #2523 was three reorganised albums, and forty individual rows hides that it is really three decisions. Each row leads with the fact that settles whether a missing file is worth chasing -- "last played 2d ago" against "never played". The group header carries how many tracks and how long they have been gone. Read-only. No remove button anywhere: the row, its play history and its likes survive a file going missing, and the scanner clears the mark by itself when the file returns (or adopts the row if it returns renamed, #2528). The page says so in its own copy rather than leaving the operator to infer it. Empty state explains the feature instead of the emptiness -- what puts a row here (moved outside Minstrel, deleted, a drive that didn't mount) and that rows leave on their own. Someone who has never seen this page should not have to guess. Paging follows the house pattern -- plain offset into the factory, wrapped in $derived so a page change re-creates the query with a new key. Passing a getter instead would capture the key once and paging would silently not refetch. The pager only renders when it can do something. |
||
|
|
4c49ee2cc6 |
feat(web): a playlist entry whose file is missing greys out and is skipped — #2527
test-web / test (push) Successful in 33s
The row treatment for a dead playlist entry already existed -- muted text, no play on click, no drag, no kebab, never "now playing" -- but it only fired for track_id === null, the track-deleted case. A missing file kept a live-looking row that failed on click. The behavioural gate now covers both, and the presentation distinguishes them, because they mean different things to the person reading the list. A removed track is gone for good and keeps the strikethrough. A missing file is a track we still have -- history, likes, the lot -- whose bytes aren't on disk right now, so it gets an explicit "File missing" and a title explaining it stays in the playlist and comes back on its own if the file does. A strikethrough there would claim it was deleted, which is a lie about a file the scanner may well adopt back tomorrow (#2528). Skipping routes through playlistTrackToRef, which already returned null for removed tracks and whose callers already filter nulls. Adding the unavailable check there means every queue builder -- PlaylistCard, systemRefetch, the detail page -- skips a missing file without any of them learning what missing_since is. Remove stays available on a dead row: the owner must still be able to take it out of their own list. |
||
|
|
481f906059 |
feat(metrics): publish margin of error on every delta — #2495, #2524
The metrics card had one volume threshold doing two jobs. recMetricsLowVolume = 20 is a DISPLAY floor — below that a skip rate is anecdote — but the card then presented deltas as though it were also a DECISION floor. Those differ by an order of magnitude: detecting the ~13pp differences that matter needs ~133 plays per arm for 80% power at a=0.05. So Discover's taste-matched (59 plays) and random-unheard (70) both rendered as full-confidence rows with a bold delta beside them, and that comparison sits at p ~ 0.06. The card said "signal"; the arithmetic said "maybe". It produced a recommendation the data didn't support, and any reader with the same numbers would have made the same call. Deltas now carry a 95% margin of error and a `distinguishable` flag, computed server-side so both clients read the same arithmetic instead of each re-deriving it. Skip rate is a two-proportion difference; completion is Welch, which needs a variance — hence completion_sqsum in the query. It is the sum of squares rather than stddev_samp on purpose: raw source rows are merged into surface families in Go, and sums of squares combine across groups exactly whereas standard deviations cannot. recMetricsLowVolume is untouched. "Too thin to show" and "too thin to act on" are different questions. Web renders an indistinguishable delta as dimmed and prefixed "≈", with the range on hover and a legend explaining the glyph. Colour is withheld unless the delta clears its margin — colouring noise red is what made the old card misleading. Breakdown rows go through the same path; those are the thinnest samples on screen and where the old card misled most. Also fixes the admin trends view, which had the same problem worse: its "Latest skip"/"Latest completion" columns are one WEEK while the adjacent Plays column is the whole window. I misread exactly that and briefly concluded Deep cuts was the worst surface, from ~17 plays in a single week — over 180 days it is one of the best. Headers now name their period and the skip cell carries that week's play count. #2524: resolveArtist now recognises a duplicate-MBID unique violation as the expected condition it is, matching resolveAlbum. Two rows mapping to one MusicBrainz artist is a merge candidate, not a fault; without the branch it logged a generic warning plus a Postgres ERROR line on every scan, which teaches an operator to ignore database errors. |
||
|
|
a9ca49dc4e |
feat(library): genre + year quick-jumps on album and artist detail — #367
Last bullet of #367. From an album you like, one click to everything else from that year or in that genre. Year was free — AlbumRef already carried it. Genre was not: AlbumDetail is AlbumRef + tracks and neither carried genre, because genre lives on TRACKS. So both detail responses gained a derived `genres` array, computed from the entity's tracks rather than stored, since an album's tracks can legitimately disagree about genre. Split and trimmed identically to the browse index. That's the invariant this whole task turned on: if the chip's matching diverged from the index's splitting, a chip would lead to a page that doesn't contain the album you clicked from. No year link on artist detail. An artist spans many years, so a single one would be a lie about the discography — genres only there. Genre lookup failure is logged and degrades to no chips rather than failing the request; a navigation nicety must not 404 a detail page that otherwise loaded. `genres` is always an array at JSON, never null, matching how every other list field in this package is emitted. ## Type widening, and the TypeScript version of a lesson from earlier today Adding a required field to AlbumDetail/ArtistDetail breaks every typed fixture that constructs one. Six of them across three test files. That's the same shape as the Go signature changes that cost three CI rounds in #2453 — change a type, then go find everything that builds it — so I searched for the constructions before pushing instead of after. All six updated. Tests: the encoded href for a slash-bearing genre ("Rock/Pop" → ?g=Rock%2FPop), the year href, and the no-tags case rendering no chips at all. gofmt verified clean via docker rather than guessed. |
||
|
|
feb1c2eca8 |
feat(web): genre and year browse pages — #367
test-web / test (push) Successful in 33s
Client half of #367. Two new Library tabs, each an index plus a drill-down. Genres are ordered by track count rather than alphabetically. Raw ID3 carries a long tail of one-off tags, so alphabetical would bury the handful of genres you actually have a library's worth of. Years are grouped into decades — a flat list of every year in a decades-deep library is a wall of numbers, and the decade is how people actually think about it. ## Selection travels in the query string, not the path `?g=Rock%2FPop`, not `/library/genres/Rock%2FPop`. A slash-bearing genre cannot survive a path segment — the server sees two segments, and a hard reload wouldn't reconstruct it through the SPA fallback either. There's a test pinning the encoded href and another pinning that the DECODED value reaches the API. ## Why these two pages don't use svelte-query for their lists The indexes do — fetched once per mount, so static options suffice and the cache survives bouncing in and out of a drill-down. The drill-down lists deliberately don't. Their selection comes from the URL and changes WITHOUT remounting the page, and this codebase has no reactive-query-options pattern anywhere; inventing one here would be a larger change than the feature justifies, and one I can't exercise locally. So they use $effect keyed on the derived selection with an explicit Load more. The stale-response guard is a plain `let`, not $state, and that's load-bearing: as reactive state, reading the token inside the fetch path would make the effect depend on its own writes. Its job is to discard a late response for a previously selected genre instead of painting it over the current one. ## Also Added the year filter to /library/albums' contract but NOT to that page's UI — its infinite scroll is a svelte-query infinite query, and making it react to a filter is the same reactive-options problem. The dedicated pages cover the capability, which is the shape the task offered as its alternative. Library tab bar's comment claims it mirrors Android's LibraryScreen. These two tabs have no Android equivalent, so I noted that inline rather than leaving the claim quietly false. Parity remains an open call. Not yet done from #367's bullet list: genre/year quick-jump links on album and artist detail. Year is free (AlbumRef already carries it) but genre is exposed nowhere client-side — AlbumDetail is AlbumRef + tracks, and neither carries genre — so it needs a small API addition. Following as its own commit. |
||
|
|
381e9cedb7 |
feat(net): trusted-proxy depth so real client IPs survive a proxy — #2453
Fixes the defect the operator spotted in #370 immediately after it shipped: auth.ClientIP ignored X-Forwarded-For whenever RemoteAddr was public, so a proxy on a public address — a separate host, or a CDN, i.e. anyone running this publicly, since public means TLS means a proxy — recorded the PROXY for every session. created_ip and last_ip were then always equal and the "Address changed" signal could never fire. The feature looked like it worked and reported nothing. Replaced with the standard trusted-hop model (Rails, Caddy, Traefik, nginx). XFF grows left-to-right as each proxy appends the peer it received from, so for client -> CDN -> own-proxy -> app the app sees [client, CDN] with RemoteAddr = own-proxy, and the client sits at XFF[len - hops]: 0 RemoteAddr, XFF ignored — no proxy 1 the address your own proxy observed 2 through a CDN in front of your proxy Default 1, per the operator: publicly reachable means a TLS terminator in front. The cost is real and stated rather than hidden. hops >= 1 DECLARES that a proxy exists; set it with no proxy, or deeper than the actual chain, and the index reaches attacker-supplied entries, letting a visitor choose which address their own session shows — defeating exactly the detection #370 is for. That's inherent to the model, which is why 0 is a first-class value and the admin card says "count your proxies, don't guess high" instead of just exposing a number. Both mis-set shapes are pinned by tests so they stay known consequences rather than surprises. Migration 0053 + internal/netsettings, cached under an RWMutex. That's not an optimisation: ClientIP runs in RequireUser for every authenticated request, so a per-request query would put the database on the critical path of the whole API. New() always returns a usable service so a boot-time DB hiccup degrades to the default instead of breaking that path (rule #131), and Hops() is nil-safe because test routers construct middleware without it. RequireUser now takes a func() int rather than an int — the value is operator-editable at runtime while the middleware is built once at boot, and reading it per request is what makes a save take effect with no restart (rule #25). The admin card is verifiable, not just configurable: it reports the address the CURRENT setting resolves THIS request to, the raw forwarded chain, and the socket peer — so you set the number, save, and confirm the address matches the machine you're on. It also counts the arriving chain and says how many proxies that implies. GET/PUT both return that payload, PUT recomputed under the new value, so the effect is visible without a reload. Also fixes styling in the #370 card that CI could not catch: text-destructive and bg-destructive don't exist in this Tailwind config — the palette is colors.action.destructive — so the "Address changed" warning and the sign-out-others button were rendering unstyled. Both now use text-action-destructive / bg-action-destructive / text-action-fg. Not done here: requestlog.go still logs raw RemoteAddr and will disagree with the sessions UI about who connected. Left for its own change. |
||
|
|
bf649f3beb |
feat(web): active sessions card in Settings — #370
test-web / test (push) Successful in 32s
Client half of #370. Lists every device signed in to your account, with a per-row sign-out and a "sign out all other devices" action. The card does one thing the API alone doesn't: it says "Address changed" when created_ip and last_ip differ, rather than printing two addresses and leaving you to compare them. That mismatch — same device string, different origin — is the shape of a stolen token, and it's the reason IP capture was worth a migration. Making the operator spot it by eye would have wasted the data. Placed with Password and API Token rather than at the bottom of the page: those three are the account-security group, and this is the one that tells you the other two need attention. Details worth naming: - The current session gets a "This device" badge and NO sign-out button — offering one would log you out of the page you're standing on. The server already excludes it from logout-others; this makes that visible. - Sign-out-all-others is a two-step confirm and states the count, so the button can't be a surprise. - A 404 on revoke reloads instead of erroring. It means the session is already gone — revoked elsewhere, or expired — so the list was simply stale and showing the truth is the right response. The code is `session_not_found`, not `not_found`: apierror.NotFound(what) prefixes it. - Empty and error states both handled (rule #24); the empty case is practically unreachable since listing requires an authenticated request, and is handled rather than assumed. - User-agent parsing is deliberately coarse. A real UA parser is a dependency and a maintenance burden for a string whose only job is "do you recognise this?" — the addresses carry the actual signal. Tests cover the parts that would be quiet if broken: the current-session badge suppressing its own sign-out button, the address-changed warning appearing and NOT appearing, the two-step confirm not firing on first click, and the load-failure retry. Android parity is a separate decision, not assumed. |
||
|
|
eec59193fa |
feat(discover): explain the taste match on both clients — #2377 (clients)
"Matches your taste in shoegaze and dream pop." replaces the seed
attribution when the candidate's own tags overlap the taste profile.
The preference order is the point of slice 6: the tag reason describes the
MUSIC ("sounds like what you like"), while seed attribution describes the
graph ("adjacent to something you played"). When we can say the former, it
is strictly the better explanation. When we can't — the common case, since
tag coverage for out-of-library artists is partial by nature (#2376) — the
card falls back to attribution rather than going blank.
Both clients share the wording, Oxford comma included, and both have tests
asserting the exact strings. That's deliberate: identical copy across two
codebases silently diverges unless something fails when it does.
Android caps at 3 tags client-side even though the server already does.
The server contract could widen; a run-on subtitle shouldn't be how we
find out.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
ca4832e620 |
feat(discover): Discover tuning card on the admin lab — #2377 (web admin)
test-web / test (push) Successful in 35s
Rule #25/#27: the two knobs slice 6 added server-side are now touchable — taste-tag weight and snooze length, with deviation dots, save, and reset, matching the existing profile/taste cards. Copy states what each knob does AND what it doesn't: the tag-weight hint says 0 turns the term off and that an untagged candidate is never penalised, and the snooze hint says it records no opinion about the artist and never feeds the taste profile. Those are the two properties most likely to be assumed backwards by whoever turns these next. Also fixed a latent fragility the new card exposed rather than caused: all three reset buttons had the accessible name "Reset to defaults", so the existing test picked the LAST one and assumed that meant taste. Adding a card below it would have silently retargeted that assertion at the wrong scope. Each reset button now names its scope — better for screen readers too, since three identical buttons on one page is a real a11y defect — and the test selects by name instead of position. The page's test fixture needed the new `discover` key in both `snapshot` and `shipped`: the `as TuningSnapshot` cast means a missing field is not a compile error, it's every test on the page throwing inside fillForm. Noted that in the fixture so the next scope doesn't rediscover it. Includes a test that a weight of 0 is actually SENT rather than dropped as falsy — the off switch is the one value a truthiness bug would eat. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
6e39471a70 |
feat(discover): snooze affordance on Android + web suggestion cards — #2375
Completes the snooze from slice 3 (#2374), so it's now touchable on both clients (rule #27 — the server side alone was never shippable). Copy is "Not right now" everywhere, never a dislike (rule #101). The parked list even says so out loud: "Nothing here counts against your taste profile." Both clients flip the card in place to a "Not right now" state with an Undo, rather than yanking it out of the grid under the cursor. The row leaves on the next refetch; the persistent way back is a parked-list section below the deck. That list isn't optional garnish — a snoozed candidate is by definition absent from the deck, so without it the DELETE endpoint is unreachable. Android routes the write through the offline MutationQueue per rule #100, as ONE toggle kind (SUGGESTION_SNOOZE_TOGGLE) carrying the desired state rather than two action kinds. That reuses the LIKE_TOGGLE collapse: a queued snooze the user has since undone is dropped unsent instead of replaying after the undo and re-hiding an artist they asked to see. The collapse helper is now a pure top-level function so that rule is unit tested rather than inferred. The repository does NOT enqueue on a 4xx — a permanent rejection would replay to the same failure and would raise a misleading "will sync when online" hint. The common case is a 404 from un-snoozing a row that already lapsed, which is the user's intended end state anyway. Also: an empty deck used to have one meaning (no listening signal yet). It can now also mean "you parked them all", so the empty copy branches — telling that user to go listen to something would be wrong advice. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
f0c08e7326 |
feat(taste): mood taste facet — #1534
Milestone #160 Opt 2b (mood half of the era+mood option). A fourth taste facet alongside artists + genre tags + eras: signed weights over canonical mood buckets (melancholic / energetic / chill / …) derived from a track's enriched folksonomy tags (#1490). - internal/mood: shared vocabulary — Of(tags) maps folksonomy tags to canonical mood buckets (synonyms collapse). Imported by both the taste builder and the scorer so a track's mood is derived identically. - Migration 0047: taste_profile_moods table + taste_tuning.mood_scale (DEFAULT 0.5). - Build side (internal/taste): Config.MoodScale ([0,1] damper, mirrors EraScale); accumulate folds each play/like's mood buckets at base*MoodScale; persist atomic-replaces the mood rows. - Scorer (internal/recommendation): TasteProfile gains a mood term (own tanh scale + additive 0.12 share, so it never weakens the existing signal when a track has no mood tags). Match now takes the candidate's mood buckets; loaded per candidate (ListTrackTagsForTracks → mood.Of) in the primary similarity loader only — the near-whole-library fallback pool passes nil (mood → 0) to avoid a full-library tag scan. - Tuning lab: mood_scale threaded through recsettings + admin API + web card ("Mood weight" row) + Go/web tests. Coverage is partial (grows with tag enrichment; richer once Last.fm is keyed), so mood is a supplement — neutral for tracks with no mood tags. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
65dd132b3d |
feat(taste): time-of-day / weekday context conditioning — #1531
Milestone #160 Opt 3 (temporal half). A new additive scoring term that boosts a candidate when its artist's play history concentrates in the CURRENT daypart × weekday-type cell, in the user's local timezone. - Migration 0046: recommendation_weight_profiles.context_time_weight (per-profile scoring weight, DEFAULT 1.0). - Query ListArtistContextPlayCountsForUser: per-artist completed-play counts split by the current cell (daypart night[22,5)/morning[5,12)/ afternoon[12,17)/evening[17,22) × weekday-vs-weekend) via started_at AT TIME ZONE users.timezone; 365-day window, skips excluded. - internal/recommendation/context.go: LoadContextAffinity computes each artist's shrunk cell-share minus the user's baseline share, clamped to [-1,1]; sparse artists shrink toward baseline (pseudo-count 5), unknown artists → 0 (cold-start neutral). - Score() gains context_affinity_score · ContextTimeWeight; both candidate loaders set it per candidate. - Tuning lab: ContextTimeWeight threaded through recsettings + admin API + web card ("Time-of-day weight" row) + Go/web tests. Shipped 1.0 both profiles (uniform start, re-bakeable). Device-class axis deferred to #1551 (needs a client_id → device-class mapping that doesn't exist yet). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
40384cc05e |
feat(taste): era/decade taste facet — #1530
Milestone #160 Opt 2 (era half). A third taste facet alongside artists + genre tags: signed weights over decade buckets ("1990s") derived from albums.release_date, rebuilt daily and scored into the taste match. - Migration 0045: taste_profile_eras table (mirrors taste_profile_tags) + taste_tuning.era_scale column (DEFAULT 0.5). - Build side (internal/taste): Config.EraScale ([0,1] damper, mirrors EnrichedTagScale), accumulate folds each play/like's decade at base*EraScale, persist atomic-replaces the era rows. - Scorer (internal/recommendation): TasteProfile gains an era term (own tanh scale + additive 0.15 share so it never weakens the existing artist/tag signal when a track is undated); candidate queries return album release_date; decadeOf mirrors the builder helper. - Tuning lab: era_scale threaded through recsettings + admin API + web card (auto-renders the new row) + Go/web tests. Mood facet deferred to #1534 (partial enrichment coverage + needs candidate-side enriched-tag loading). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
2b3be8311a |
feat(tuning): expose EnrichedTagScale in the tuning lab (#1520)
Promote the enriched-tag weight (#1490) from a taste.Config default into the DB-backed tuning lab so operators can dial how much folksonomy tags count vs raw ID3 genre (rule #25). - Migration 0044: taste_tuning.enriched_tag_scale (DEFAULT 0.5, backfills the existing row). - recsettings: TasteTuning gains the field; seeded/read/updated through reconcile + persistTaste; applyTastePatch validates it to [0,1] (generic non-half-life clamp) and diffTaste audits it; TasteConfig maps it into the profile build. - API: tasteTuningResp exposes enriched_tag_scale. - Web tuning card: a data-driven "Enriched tag weight" knob (0 = genre only). Tests: recsettings persist+range, web fixture field. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
797ed1f5ad |
feat(web): tag enrichment sources admin card (#1490 Step 4 web)
Add a "Tag enrichment sources" card to the admin integrations page, mirroring the cover-art providers card: per-provider enable toggle + API-key field + Save + Test connection, over /api/admin/tag-sources. Enabling/keying a source (e.g. pasting a Last.fm key) re-opens settled tracks for re-enrichment via the version bump. - admin.ts: TagProvider types + get/update/test functions + createTagProvidersQuery; qk.tagProviders key. - integrations/+page.svelte: the card + local edit state, with MusicBrainz (keyless baseline) and Last.fm (needs a free key) notes. - Tests: admin.tag-sources API test + integrations component tests (render / enable+key save / test connection), plus the mock plumbing the existing suite needs for the new query. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
9ad4343c76 |
feat(tuning): weekly trend view — per-surface series + knob-turn markers
The verify half of the tune→verify loop (#1251), on the same admin Tuning page as the knobs: - RecommendationWeeklyTrends: weekly per-source outcomes aggregated across all users (the knobs are global, so judging a turn needs global outcomes — rows carry rates only, no track/user identity), with a taste-hit count per bucket: plays whose track's artist has a positive weight in the player's current taste profile. That's the "cheap recompute" reading — retroactive over the whole window, at the cost of profile drift. - GET /api/admin/recommendation-trends?weeks=N (default 12, cap 52): per-family weekly series (skip rate, sample-weighted completion, taste-hit rate) plus the tuning-audit markers inside the window. - Web: sparkline table under the tuning cards — skip rate per week on a shared axis with dashed ticks at knob turns, latest-week columns, window taste-hit rate, low-volume rows dimmed as anecdote, and a plain-text list of the window's tuning changes. Also fixes the revive unused-parameter lint on the tuning GET handler that failed CI run 1903 on the previous commit. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TsF3cNoKrqCYsU78cXC8U6 |
||
|
|
0d0a8f46b1 |
feat(tuning): scoring weights → DB-backed admin tuning lab
The recommendation scoring knobs move out of YAML (radio profile) and out of the systemMixWeights hard-code (daily_mix profile) into DB-backed settings with live effect (#1250) — the defaults-discovery lab per decision #1247: the operator turns knobs to find good values, which then get baked back into shipped defaults; end users and other operators should never need the card. - Migration 0040: recommendation_weight_profiles (radio / daily_mix, 8 weight columns), taste_tuning singleton (engagement half-life + completion-curve points), recommendation_tuning_audit (one row per change with a {field, old, new} diff — the trend view's markers, #1251). - internal/recsettings: boot reconcile seeds shipped defaults without clobbering tuned rows (coverart SettingsService pattern), validates patches (bounds, curve ordering), writes audit rows, and pushes daily_mix weights + taste config into package playlists. No-op patches write no audit row. - playlists gains SetSystemMixWeights / SetTasteConfig swap points under a RWMutex — no signature threading through the producers; the scheduler's taste rebuild reads the pushed config. - Radio reads its weight profile from the service per request; the 8 weight fields leave config.RecommendationConfig (YAML keeps only RecentlyPlayedHours / RadioSize / RadioSizeMax). - Admin API: GET/PATCH/reset under /api/admin/recommendation-tuning, echoing current + shipped values. - Web: new admin Tuning tab — two weight profiles side by side, taste card, per-scope save (changed fields only) + reset, deviation dots against shipped defaults. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TsF3cNoKrqCYsU78cXC8U6 |
||
|
|
5faa57634b |
feat(metrics): provenance as standard — pick_kind for all system mixes
The #1249 mechanism (stamp WHY a track is in the snapshot at build time, freeze it onto the play at ingestion, break it down in metrics) generalizes from a For You one-off to the standard for every system mix (#1270): - Migration 0039 widens both pick_kind CHECKs (drop + re-add in the same change) to taste/fresh + Discover's dormant/cross_user/random + tier1-3 for the rule-#131 eligibility ladders. - GetForYouPickKindForTrack becomes GetSystemPickKindForTrack (user, variant, track); ingestion stamps any systemPlaylistSources play from its own variant's live snapshot, live + offline paths. - Discover stamps its candidate bucket on discoverTrack before the interleave, making the 40/30/30 allocation measurable; dedup keeps the taking bucket's stamp. - Metrics replace the for_you special-case with one pick-kind vocabulary — any family with attributed plays gets a breakdown, future stamping mixes need no metrics change. - Web: breakdown sub-rows are now toggled per surface (collapsed by default) so eight stamping mixes don't swamp the card. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TsF3cNoKrqCYsU78cXC8U6 |
||
|
|
fb4431207d |
feat(recommendation): For You exploration attribution — taste vs fresh picks
Milestone #127 step 2 (#1249). For You deliberately blends two populations — a head of top-scored taste picks and a tail sampled from deeper ranking (the freshness injection) — but the metrics judged it as one blob, so its skip rate couldn't distinguish "the taste engine is missing" from "the freshness tax is too high". That number decides the exploration share before we tune it. - Migration 0038: nullable pick_kind ('taste'|'fresh') on both playlist_tracks (stamped at snapshot build) and play_events (frozen at play-ingestion — the snapshot rebuilds daily, so attribution cannot be reconstructed at read time). - Builder: pickHeadAndTail marks head=taste / tail=fresh; the small-pool fallback is all taste (top-N-by-score IS the taste mechanism). Other variants persist NULL. - Ingestion: for_you plays (live + offline replay) look the track up in the user's current snapshot; not found → unattributed, never guessed. - Metrics: For You's row gains a breakdown (taste / fresh / earlier unattributed plays), parent row stays the sum; web card renders the sub-rows indented with the same baseline deltas + low-data dimming. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TsF3cNoKrqCYsU78cXC8U6 |
||
|
|
60533073ad |
feat(metrics): bucketed surface families + manual-plays baseline (#1248, milestone 127)
The recommendation metrics table was observable but not actionable: raw source strings (album:<uuid> one-offs) drowned the stable surfaces, and manual plays were excluded so skip rates had no control group. - SQL: include NULL-source rows (the baseline) and carry completion_n so family merges can weight avg_completion correctly. - Handler buckets raw sources into stable families (radio:<uuid> → Radio, album:/artist: → direct plays, etc.) grouped by surface intent: go-to / discovery / direct — each band judged against its job, since discovery mixes are expected to skip hotter. Families under 20 plays are flagged low-confidence, not hidden. - Settings card renders the baseline row and per-surface deltas in percentage points vs baseline (worse-than-baseline deltas in danger color), intent hint copy per group, low-data rows dimmed. - Pure-unit test for the bucketing/merge; DB test updated to the new contract (baseline included, radio:<uuid> collapse). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TsF3cNoKrqCYsU78cXC8U6 |
||
|
|
4d42e298dd |
feat(android+web/diagnostics): on-device debug reporter + admin timeline (M9)
Android: a gated DiagnosticsReporter taps connectivity, server-health, UPnP drops/player-state/route, power (Doze/battery-opt/screen), and app fg/bg, plus a heartbeat snapshotting Sonos-vs-local position — the locked-phone desync signal. Events buffer in a Room ring buffer (deliberately NOT the MutationQueue: high-volume best-effort telemetry that must survive the dead zone being debugged) and DiagnosticsUploader drains them on a tick / health-recovery / sign-in. Gating: the account flag (users.debug_mode_enabled) reaches the device via a new /api/me refresh in AuthController; a per-device local OFF switch lives in Settings. Reporter runs only when enabled && !optOut; disabling drops the unsent buffer. Web admin: /admin/diagnostics — pick account+device+kind+time-window, see a chronological timeline, flip an account's debug mode remotely, and Copy-JSON / Download-NDJSON the slice for analysis. Room schema 6→7 (new diagnostic_events table + auth_session.diagnosticsOptOut; pre-v1 destructive fallback). Refs Scribe M9 (#119), tasks #1174 #1175 #1176 #1177. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01K55iTxn95BtshocgdE1shW |
||
|
|
f7278f2417 |
feat(web): drop "Remove from library" from the track kebab
test-web / test (push) Successful in 32s
No single-click destructive action belongs in the kebab. Removing the item orphaned its whole path (RemoveTrackPopover was its only caller, and the admin/tracks API client was the popover's only caller), so per the repo's no-dead-code convention the chain is fully removed: the menu item + its admin/isAdmin plumbing in TrackMenu, RemoveTrackPopover(.svelte/.test), src/lib/api/admin/tracks(.ts/.test), and the now-needless transitive mocks in the CompactTrackCard / PlaylistTrackRow / playlist specs. The kebab is now an 8-item, admin-agnostic menu. The DELETE /api/admin/tracks server endpoint is untouched — a future safer admin surface can rebind it. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
1a7515e6ea |
feat(taste): phase 4 — recommendation observability (#796)
Per-source play outcomes so the operator can see whether each recommendation
surface is landing and tune the now-operator-tunable taste weights.
Server:
- query RecommendationSourceMetricsForUser: groups the user's play_events by
source (system-playlist surface), reporting plays / skips / avg completion
over a window; NULL-source (library/radio) plays excluded.
- GET /api/me/recommendation-metrics?days=30 (default 30, capped 365) →
{window_days, sources:[{source, plays, skips, skip_rate, avg_completion}]}.
- handler test: 401 unauth; per-source aggregation + NULL-source exclusion +
skip_rate / avg_completion math.
Web:
- lib/api/metrics.ts: query + friendly source labels.
- settings page gains a "Recommendation metrics" card (table of surface / plays
/ skip rate / avg completion), with loading/error/empty states.
- settings tests mock the new query (manual subscribe-store, hoisting-safe).
Note: You-might-like plays aren't source-tagged (it's a Home row, not a system
playlist), so this covers For-You / Discover / the mixes. Tagging YML plays
would be a client follow-up.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
||
|
|
3f240bc777 |
feat(web): "You might like" Home row (#790 web client slice)
test-web / test (push) Successful in 32s
The web UI rendered a fixed set of Home sections and had no code for the server's you_might_like_albums / you_might_like_artists (shipped in v2026.06.11), so the row was absent in the web client. Adds it, mirroring the Rediscover block, positioned directly under the system-playlists row. - types.ts HomePayload: two new slices (server always emits them; web ships in lockstep with the server). - +page.svelte: a "You might like" section (albums + artists scrollers) as the first section under the playlists row, with a "still learning your taste" empty state for the cold-start/gated case. Reuses existing AlbumCard / ArtistCard / HorizontalScrollRow. - home.test.ts / page.test.ts: mock payloads gain the two fields. Completes the You-might-like row across all three clients (server already emits it; Android in v2026.06.11; web here). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
e358a92cf4 | feat(web): similar-artists strip + top-tracks panel on artist detail | ||
|
|
7426f6c718 |
feat(web): remove scan-schedule admin UI (scheduler retired)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
47d2f61161 |
fix: drift audit batch 1 — six small mechanical wins
Six findings from the 2026-06-02 multi-system drift audit (Scribe parent task #552): - **#553 (web)** Tailwind class fix: web admin playback-errors Delete confirm button was using `bg-action-danger`, an undefined token — swap to `bg-action-destructive` to match every other destructive button. Restored the Oxblood signal that distinguishes Delete from Cancel. - **#555 (web)** Type the `source` field on `play_started` in the EventRequest discriminated union. Server's eventRequest accepts it; web's TS type was missing the slot, so a "drop extra properties" refactor could silently strip the source tag and break system- playlist rotation attribution. - **#556 + #557 (server)** Coverage rollup whitelist was pinned to ('sidecar','embedded','mbcaa','theaudiodb'); migration 0020 added 'deezer' and 'lastfm' as valid cover_art_source values but those never got wired in, so albums with art from those providers silently counted as MISSING in the admin Coverage dashboard. The rollup test was seeding only the pre-0020 sources, masking the gap in CI. Extend the query to include deezer + lastfm; seed the test with one row per valid source (regression-guards future additions). - **#558 (web)** Auth gate was blocking /forgot-password and /reset-password/<token> — both are entered without a session by definition, so the email-link reset flow was bouncing signed-out users to /login. Add /forgot-password to the public set and a /reset-password/ prefix matcher. New tests assert both routes reach their pages without redirect. - **#571 (server)** Library scanner was indexing only .mp3/.m4a/.flac /.ogg while the stream handler (media.go) had been extended to serve .opus, .aac, and .wav. A user with .opus files in their library never saw them in artist/album listings because the scanner skipped indexing — silent data loss. Aligned the scanner to match the media handler. Scribe statuses updated to in_progress; flipping to done after the push since these are mechanical and verified directly against the cited file:lines. |
||
|
|
de61305fde |
feat(web): admin playback-errors inbox
test-web / test (push) Successful in 34s
Surfaces client-reported playback failures from /api/admin/playback-errors
in a new admin tab. Tabs: Unresolved (default) / Resolved. Each row
shows track + artist + album, error kind badge, who hit it, when,
optional client-supplied detail, and the absolute file path so the
operator can grep the library mount without leaving the page.
Per-row actions (RowActionsMenu):
- Resolve (primary) — modal with Fixed / Ignored dropdown for the
"no further action taken" cases.
- Copy — JSON payload to clipboard with track_id / file_path / kind
/ detail / reporter / client_id / occurred_at. Matches the
operator's "logs with a copy-out function" ask.
- Delete file (danger, modal-confirm) — uses the existing
/api/admin/quarantine/{track_id}/delete-file endpoint AND
auto-stamps resolution='deleted' so a single click closes both
the file and the inbox row.
Deferred to a follow-up: Hide (the existing quarantine flow is
per-user-flag, not a true library-hide), and Re-request via Lidarr
(needs album MBID join — not in the current ListAdminPlaybackErrors
projection).
Also: admin tab list grows from five to six; AdminTabs.test.ts
updated.
|
||
|
|
a7bea43a13 |
feat(discover): on-demand Lidarr artist art for suggestions
Out-of-library suggestion artists (artist_similarity_unmatched rows) have no local art row, so the Discover card always showed a placeholder. Resolve art on-demand from Lidarr's artist lookup, matched by MBID (foreignArtistId == candidate_mbid), and pass the remote image URL straight through — no caching (a suggestion may never be viewed; the browser fetches the URL directly). - suggestionView gains image_url (omitempty); handler resolves it via bounded-concurrency Lidarr LookupArtist, best-effort, never fails the request. - Lidarr is the sole source: disabled / unreachable / no-match → empty → existing placeholder. (No inline TheAudioDB fallback — it's externally rate-limited and there's no cache to amortize it.) - web: ArtistSuggestion.image_url?; SuggestionFeed passes it to DiscoverResultCard (already supports imageUrl). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |