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
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
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
Relabel (#1204): route + player_state events fire for every output route,
not just UPnP — split them into a new 'playback' kind; 'upnp_sync' now
means genuinely UPnP/Sonos signal (drops, resync). Migration 0037 adds
'playback' to the kind CHECK; server whitelist, Android reporter labels,
and the web kind filter updated.
Web sort: the diagnostics list gains a Newest/Oldest-first sort (default
newest at top); export follows the displayed order.
Fix (#1205): OutputRoute.isConnected was derived from RouteInfo.connectionState,
which stays DISCONNECTED for local SYSTEM routes even when active — so a
connected Bluetooth device showed "Available" and reported connected:false.
The picker subtitle now uses isSelected (route == selected route); the dead
isConnected field is removed and the misleading `connected` field dropped
from the diagnostics route event (it only ever logs the active route).
Refs Scribe M9 (#119), tasks #1204#1205.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K55iTxn95BtshocgdE1shW
New diagnostic_events table + per-account users.debug_mode_enabled flag.
When an account's flag is on, its client(s) POST a batch timeseries of
connectivity / UPnP-sync / power / lifecycle events to /api/diagnostics
(no-op 204 when off, kind whitelist mirrors the CHECK constraint).
Admin surface: GET /api/admin/diagnostics (optional account/device/kind/
time-window filters, RFC3339-or-epoch-ms, export-sized paging) + a
/diagnostics/devices overview + PUT /api/admin/users/{id}/debug-mode to
flip an account remotely while a bug is live. debug_mode_enabled is now
exposed on /api/me (client gate) and the admin user views.
Retention: a 30-day gc-worker sweep (GcPruneDiagnostics), keyed on the
server clock so a skewed device clock can't keep rows alive.
Refs Scribe M9 (#119), tasks #1172#1173.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K55iTxn95BtshocgdE1shW
Build a persistent, decaying model of each user's taste, recomputed daily,
that later phases consume across every recommendation surface. Phase 1 only
BUILDS the object — no behaviour change to what's surfaced yet.
Core mechanic — graded engagement (replaces binary was_skipped for learning;
was_skipped stays for History): a play's completion ratio maps to a signal in
[-1,+1] via two linear ramps (instant-skip → -1, ~0.30 neutral, ≥0.90 → +1).
Time-decayed (half-life ~75d) so recent behaviour dominates and the profile
tracks drift.
Per operator constraints:
- No explicit dislike button — negatives come only from passive behaviour
(early skips). Nothing recorded to regret or opt out of.
- Negatives are track-scoped; artist/tag weight is the decayed SUM of their
tracks' engagement, so one skip nets out against many good plays (a
DB test asserts a liked artist stays positive despite an early-skipped
track). A floor clamp bounds how negative any single entity can get.
- migration 0035: taste_profile_artists / taste_profile_tags (signed weight,
indexed by (user, weight DESC)).
- internal/taste: engagement.go (pure curve + decay) + profile.go
(accumulate plays + like bonuses, floor damping, size caps, atomic-replace).
- scheduler: rebuildUserDaily recomputes the profile before the playlist
build (so phase 2 can read it), best-effort — a taste failure never blocks
playlist building. Wired into the daily job + startup catch-up only (not
manual/lazy rebuilds).
- tests: pure (engagement curve, decay, ranking, floor, genre split) +
DB-backed (positive/negative weights, aggregation-protects-artist, like
bonus, atomic replace). All green vs real Postgres.
Config knobs live in taste.DefaultConfig() for now; wiring them into the
server RecommendationConfig is a later follow-up.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Surface in-library albums/artists the listener doesn't actively spin but
is predicted to enjoy, derived from the same similarity + like-weighted
candidate engine that powers For-You — rolled up from track scores to
album/artist granularity. Built in the daily 3am BuildSystemPlaylists
pass, atomic-replaced alongside the system playlists, and read back by
/api/home (+ /api/home/index).
Cold-start gate: skips generation entirely below 20 distinct unskipped
tracks AND 5 distinct artists, so a thin profile ships empty rows rather
than near-random tiles.
- migration 0034: you_might_like_albums / you_might_like_artists (id+rank,
CASCADE, per-user rank index).
- playlists/you_might_like.go: cold-start gate + similarity roll-up
(sum-of-top-3 aggregation, per-artist album cap, daily-rotating via the
same userIDHash jitter as For-You) + atomic-replace persist in the tx.
- recommendation/home.go: two new HomePayload sections with read-time
cross-section dedup vs Most Played / Rediscover / Last Played, trimmed
to 10 each.
- api: you_might_like_albums / you_might_like_artists on /api/home and
/api/home/index, reusing albumRefFrom / artistRefFromCovered.
- tests: pure roll-up/aggregation/cap unit tests + DB-backed gate,
sufficiency, and atomic-replace tests (all green vs real Postgres).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Wire the fsnotify watcher and a fixed 12h safety-net delta walk in main;
remove the configurable scan scheduler (scheduler.go, scan_schedule table via
migration 0033, GET/PATCH /api/admin/scan/schedule, and the server/api
plumbing). Manual scan + scan status are unchanged.
New client-reported playback-error log. Surfaces zero-duration
tracks (and future load_failed / stalled kinds) into an admin
inbox so the operator can hide / delete / re-request the
offending track.
Schema (migration 0032):
- playback_errors table with CHECK constraints on the kind +
resolution enums (per the standing rule that new enum values
need a migration to add)
- Partial index on unresolved rows for fast inbox lookup
- ON DELETE CASCADE from tracks + users so cleanup is automatic
Endpoints:
- POST /api/playback-errors: any signed-in user reports. Body
validates track existence + kind whitelist; client_id required
so support can correlate reports from the same device.
- GET /api/admin/playback-errors?resolved=false&offset=&limit=:
admin list with join to track/album/artist for table render
without per-row round-trips. Pagination capped at 200/page.
- POST /api/admin/playback-errors/{id}/resolve: admin marks
resolved with a resolution enum string.
Auto-resolve on Hide/Delete/Re-request from the inbox row is
driven from the web client (two sequential calls) — keeps the
existing track-action endpoints unchanged.
player: setQueueFromTracks fast-starts a single source at player-index 0
while the full queue is broadcast, so the transient currentIndexStream→0
emission clobbered the correct mediaItem with queue.value[0] (the first
track). Mini bar / playlist marker pinned to the wrong track until a
later index event (~the "passive ~30s recovery"). Track a logical-index
base so the player→queue mapping stays correct during the fill window;
also fixes the latent forward-fill auto-advance off-by-base.
lidarr #50: approving no longer fails when Lidarr is down. Approve
records the decision durably first, then best-effort adds; the
reconciler idempotently (re)sends unconfirmed adds every tick until they
stick (new additive lidarr_add_confirmed_at; AddArtist/AddAlbum map
Lidarr's "already exists" 400 → ErrAlreadyExists). No failed-state or
expiry by design — Lidarr keeps trying, operator monitors.
lidarr #51: Create() is now idempotent — a non-terminal request for the
same MBID returns the existing row instead of inserting a duplicate.
Rewrites the obsolete LidarrUnreachable_503 test to assert the durable-
approve contract; threads a client factory into NewReconciler.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
A2 — migration 0030: the albums/artists art `*_source` CHECK was a
fixed provider-ID allowlist fighting the extensible coverart.Register
registry (per-provider migration churn at 0016/0018/0020; rejected
test stub providers → ~11 enricher tests failed). Relax to "NULL or
non-empty"; the registry is the source of truth. Same brittleness
class as the #433 discovery-mix CHECK.
D — lidarr Approve resolves metadata/quality profiles (JSON arrays)
before the add; the test stubs returned {"id":1} for every path, so
ListMetadataProfiles failed to unmarshal → 500/Approve errors. Make
the lidarrrequests + admin_requests stubs path-aware (arrays for
/metadataprofile, /qualityprofile).
E:
- auth: requireUser (prelude.go) emitted code "auth_required"; the
canonical code is "unauthenticated" (operator decision). Change the
code; drop the now-dead "auth_required" web error-copy key
("unauthenticated" already has copy).
- playlists_system_test wrapped the real auth.RequireUser middleware
but only injected withUser() context → 401. Like every other api
handler test, drop the middleware and rely on requireUser().
- admin_users dup-username test seeded "test-existing" (seedUser
prefixes) but POSTed "existing" → no collision; POST the prefixed
name.
- me_timezone test decoded a top-level {"code"} but the envelope is
{"error":{"code"}}; decode the nested shape.
- audit test asserted compact JSON; Postgres jsonb::text is spaced.
- put-lidarr-config tests predated the missing_defaults gate (correct
handler behavior); supply the required defaults in the bodies.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
tracks.mbid was 100% NULL: the scanner only extracted album + artist
MBIDs, never the recording MBID. The ListenBrainz similarity worker is
gated on tracks.mbid IS NOT NULL, so track_similarity could never
populate — starving For-You/radio's strongest candidate source
(lb_similar) on every library.
- mbids.go: add extractRecordingMBID (mbz.Recording / Picard
musicbrainz_recordingid). Separate fn so extractMBIDs' signature +
unit tests stay untouched.
- scanner.go: persist recording MBID via UpsertTrack (heals on the
file_path conflict, so re-scans backfill for free).
- BackfillTrackMBIDs: one-shot pass mirroring BackfillMBIDs, wired as
scan Stage 2b (idempotent via SetTrackMbidIfNull, gated by
BackfillCap, log-only progress).
- migration 0029: tracks_mbid_unique (0002, written when the column
was always NULL) wrongly assumes one MBID == one track. A recording
appears on multiple releases, so rows legitimately share a recording
MBID. Replace with a non-unique partial index. Zero-risk: column is
100% NULL at migration time.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
HOTFIX for v2026.05.15.0. R3 added deep_cuts/rediscover/new_for_you/
on_this_day/first_listens producers + registry entries but not their
system_variant values to the playlists_kind_variant_consistent /
playlists_seed_consistent CHECK constraints (0021's whitelist).
insertSystemPlaylist for any new mix → SQLSTATE 23514, and since
BuildSystemPlaylists is one all-or-nothing txn that aborts the
ENTIRE build — For-You/Discover refresh 500s and the daily lazy
build fails too. System playlists are fully broken in prod.
Migration 0028 drops + re-adds both constraints with all five new
seedless variants (mirrors the 0021 drop-and-readd pattern).
CHECK-only — sqlc/dbq unaffected (regen produced no drift).
Bumps to 2026.5.15+7 for the v2026.05.15.1 patch release.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
First of three stages for system-playlist sample-history dedup
(Fable #415, server-side rotation per the operator's choice).
Schema (migration 0027):
- play_events.source (nullable text): which surface a play came
from. 'for_you' / 'discover' feed rotation; NULL for library /
user-playlist / radio / Subsonic.
- system_playlist_rotation_state(user_id, playlist_kind,
played_track_ids uuid[], rotation_started_at, updated_at): the
per-(user,kind) set of already-heard tracks this rotation.
Ingest:
- New RecordPlayStartedWithSource on the writer; RecordPlayStarted
is now a thin source="" wrapper so the frozen Subsonic shim is
untouched (no signature ripple).
- When source is a known system kind, the same txn appends the
track to rotation state (AppendRotationPlayed keeps the array a
set via the conflict CASE).
- /api/events play_started accepts an optional "source".
No serve-behavior change yet — Stage 2 makes shuffle prefer the
unplayed tail + resets on exhaustion; Stage 3 wires the clients to
send source and consume the rotation-aware order.
Tests: rotation appends + dedupes for a system source; source-less
play writes no rotation row. (Existing RecordPlayStarted tests are
unchanged — same wrapper signature, identical behavior at source="".)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Schema + endpoint scaffolding for #392 Half B (per-user timezone
scheduling). Adds two columns to the users table:
- timezone text NOT NULL DEFAULT 'UTC' (IANA name)
- timezone_updated_at timestamptz (nullable; populated on each PUT)
PUT /api/me/timezone validates the IANA value via time.LoadLocation
and writes the row. No scheduler integration yet — the scheduler
struct lands in the next commit and the handler-side Refresh call
in the commit after.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Append-only change log for library entities. Every mutation on
artists/albums/tracks/likes/playlists/playlist_tracks will write a row
in the same transaction as the mutation itself (wired in subsequent
commits). Powers the Flutter delta-sync endpoint (#357).
- 0025_library_changes migration (up + down)
- internal/db/queries/library_changes.sql (Insert, GetSince, MaxCursor, MinCursor)
- regenerated dbq from sqlc
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds users.email (optional, lowercase-unique via partial index),
smtp_config singleton, and password_resets tokens. Plus the
queries U3-T2 / T3 / T4 use:
- ChangeUserPassword: self-service password change. HTTP layer
verifies the current password before this fires.
- UpdateUserProfile: set display_name + email together.
- RegenerateApiToken: invalidate old API token.
- GetUserByEmail: case-insensitive lookup for forgot-password.
- GetSMTPConfig + UpdateSMTPConfig: admin SMTP settings CRUD.
- CreatePasswordReset / GetPasswordReset / UsePasswordReset
(:execrows for atomic claim) / DeleteExpiredPasswordResets
(cron-style cleanup, not yet wired).
Email column is nullable; users without email have admin-reset
as their only password-recovery path. The lower() unique index
allows many NULLs and prevents case-insensitive duplicates.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds users.auto_approve_requests boolean default false (the #355
sub-feature surface; the request-flow handler that honors this
flag is U2.5 follow-up work).
Four new sqlc queries for the U2 admin endpoints:
- CreateUserAdmin: admin-driven user creation, accepts all five
fields explicitly.
- DeleteUser: hard delete; schema's ON DELETE CASCADE foreign
keys handle plays/likes/sessions cleanup. Last-admin guard
lives in the HTTP layer (next task).
- ResetUserPassword: admin sets a new hashed password without
knowing the old one.
- UpdateUserAutoApprove: toggles the new boolean.
Self-service equivalents (knows-current-password change, etc.)
are U3 work and use distinct queries.
Schema for user management U1: display_name on users; user_invites;
registration_settings singleton (default 'invite_only'); audit_log.
Plus the internal/audit package centralizing the action-name
vocabulary and JSON metadata marshaling so handlers don't repeat
boilerplate.
Race-safe first-admin uses a query-shape primitive
(CreateUserFirstAdminRace's WHERE NOT EXISTS subquery) rather than
a schema-level constraint. Concurrent empty-state registrations
both see 'no users yet' and both insert as admin — fine, having
two admins from the start is benign; what matters is at-least-one.
users.username uniqueness arbitrates if the two callers picked the
same username.
CreateUser signature gains display_name (nullable); existing
bootstrap call sites pass nil. The audit package declares the full
U1+U2+U3 action vocabulary upfront so subsequent slices are purely
additive on the caller side.
Tests cover audit Write with + without metadata and that every
declared action constant persists correctly.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds 'discover' as an accepted system_variant on the playlists
table — alongside 'for_you' and 'songs_like_artist' — and three
sqlc bucket queries that back the new playlist's daily candidate
selection.
The three buckets surface tracks the user hasn't played and
hasn't liked:
- Dormant artists: artists this user has played < 10 times total.
Surfaces the long tail of their library.
- Cross-user likes: tracks any OTHER user has liked but this one
hasn't engaged with. Empty on single-user servers; the Go-side
allocator redistributes the deficit across the other two
buckets.
- Random unheard: pure random sample as the safety net and the
cold-start fallback.
All three share exclusion filters: not-played by this user, not
liked by this user, not in lidarr_quarantine for this user. All
order by md5(track_id || dateStr) for daily determinism — same
pattern as the existing tieBreakHash logic in system.go.
LIMIT values are generous (80/60/200) so the per-album (<=2) /
per-artist (<=3) caps and slot redistribution in T2 have headroom.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Relaxes the artists/albums art_source CHECK constraints to allow the
new 'deezer' and 'lastfm' source values alongside the existing
accepted set. Adds cover_art_sources_meta.last_registered_providers_hash
for the boot-time auto-bump detection: when the registered-provider
set changes between deploys, we bump current_version once so 'none'
rows become eligible for retry against the new chain.
Two sqlc queries added: GetCoverArtProvidersHash reads the stored
hash; BumpVersionAndSetProvidersHash atomically increments the
version and stores a new hash, returning the new version. Both used
by the boot logic in a later task.
Adds the singleton scan_schedule table holding the operator's
recurring-scan config. mode is the discriminator (off/interval/
daily/weekly); CHECK constraints enforce per-mode field validity
(interval_hours required for interval; time_of_day for daily and
weekly; weekly_day 1..7 for weekly). Seed row is mode='off'.
Two new queries: GetScanSchedule (read singleton; used by scheduler
boot + admin GET) and UpdateScanSchedule (admin PATCH; application
normalizes per-mode fields to NULL when mode='off').
Adds the schema for the pluggable cover-art provider abstraction:
- Extends albums.cover_art_source CHECK to accept 'theaudiodb' (the
constraint from migration 0016 only allowed embedded/sidecar/mbcaa/
none, which would block writes from the new provider).
- Adds artist_thumb_path / artist_fanart_path / artist_art_source
on artists, with a parallel CHECK constraint and source index.
- Adds *_sources_version stamps on both albums and artists for the
per-row recheck eligibility logic. Default 0 so every existing row
becomes stale relative to the seeded current_version=1, retrying
through the new chain on first scan after migrate.
- New cover_art_provider_settings table (per-provider enabled / api_key
/ display_order) and cover_art_sources_meta singleton holding
current_version. Seeds both v1 providers (mbcaa + theaudiodb) as
enabled.
- New artist_art_enrich jsonb column on scan_runs for the 4th
scan-orchestrator stage tally.
The seed leaves theaudiodb.api_key NULL; the application supplies the
upstream's documented test key (2) as the default when the column is
NULL, per the no-coercive-settings principle.
Migration 0014 adds playlists + playlist_tracks. track_id is nullable
with ON DELETE SET NULL — tracks can be removed from the library
without silently dropping playlist entries; the denormalized snapshot
(title/artist/album/duration) keeps the row legible afterwards. UI
renders such rows greyed-out.
Indexes: playlists by (user_id, updated_at DESC) and a partial public
index for cross-user discovery; playlist_tracks partial index on
track_id to support the FK SET NULL lookup.
Queries provide CRUD + rollup recompute (track_count, duration_sec)
+ append/remove primitives. Reorder is service-layer orchestrated via
raw tx.Exec; no SQL primitive needed.
Persists the operator's metadata-profile choice alongside quality
profile + root folder. Defaults to the first profile Lidarr returns
(usually 'Standard' on a vanilla install) so the common case is
zero-click; operators with custom profiles like 'Singles only' can pick
explicitly.
Backend:
- Migration 0013: adds nullable default_metadata_profile_id to
lidarr_config. Existing rows get NULL and the service falls back to
fetch-and-pick-first until they save.
- Updated lidarr_config queries + sqlc + lidarrconfig.Config + admin
view/put body to round-trip the new field.
- handlePutLidarrConfig requires it (along with QP and root folder)
when enabled=true — matches the existing missing_defaults gate.
- New GET /api/admin/lidarr/metadata-profiles handler + lidarr.Client
ListMetadataProfiles (GET /api/v1/metadataprofile, same shape as
the quality-profile endpoint).
- lidarrrequests.Approve prefers cfg.DefaultMetadataProfileID; falls
back to the fetch-list path only when 0 (back-compat for upgraders).
Frontend:
- LidarrConfig type + LidarrMetadataProfile type + qk.lidarrMetadataProfiles.
- listMetadataProfiles + createMetadataProfilesQuery client helpers.
- Integrations page: third <select> picker, auto-defaults to first
profile when the saved value is 0, sends the new field on save and
clears it on disconnect.
- Updated test fixtures + the duplicate 'Standard' option string in
the dropdown-populates assertion.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Composite PK (user_id, track_id) already serves WHERE user_id queries;
the secondary (user_id, created_at DESC) index just amplified writes.
- ListQuarantineForUser now selects only the joined fields the SPA card
actually renders (~10 columns) rather than embedding three full structs
(~35 columns); halves wire/DB bandwidth before consumers exist.
- max(q.created_at)::timestamptz cast emits pgtype.Timestamptz instead of
interface{} so handlers can read latest_at without a type-assert.
Table was referenced in migration 0005's comment but never created —
this slice fills the gap. Soft-delete via deleted_at column; hot-path
partial index on active rows; GIN index on session_vector for
M3 sub-plan #3's similarity queries.
Three tables keyed on (user_id, entity_id) with liked_at. Per-table
indexes on (user_id, liked_at DESC) for the recently-liked feed.
sqlc queries cover like/unlike/list-rows/count/list-ids per entity.
Tables and indexes per spec §5. session_vector_at_play ships nullable
so M3 doesn't need a follow-up migration. Table is named play_sessions
to avoid collision with the existing sessions (HTTP auth) table from
migration 0004.
Stores sha256(token) plus user_agent + last_seen_at so future
active-sessions UI doesn't need another migration.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Subsonic token auth (t=md5(password+salt)) needs a reversibly stored
credential; bcrypt password_hash can't serve. NULL means the user has
not opted in, forcing apiKey (OpenSubsonic) instead.