Commit Graph

38 Commits

Author SHA1 Message Date
bvandeusen 15b59a214d feat(server): playback_errors table + admin inbox endpoints
test-go / test (push) Successful in 29s
test-go / integration (push) Successful in 11m25s
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.
2026-06-02 11:25:50 -04:00
bvandeusen e68e1b10a6 fix(player+lidarr): mini-player sync race; durable approve + dedup
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>
2026-05-18 12:24:20 -04:00
bvandeusen 53a02322fb fix: A2 (relax art-source CHECK) + D + E integration residual
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>
2026-05-17 20:22:30 -04:00
bvandeusen e2432caa65 fix(library): extract recording MBID → tracks.mbid; unblock LB similarity
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>
2026-05-16 13:49:38 -04:00
bvandeusen 1e2c486356 fix(playlists): allow the 5 discovery-mix variants in DB constraints
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>
2026-05-15 23:39:21 -04:00
bvandeusen d2a0b7d780 feat(playlists): #415 stage 1 — rotation-state schema + play ingest
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>
2026-05-15 07:40:17 -04:00
bvandeusen 230da7bdcb feat(users): timezone column + PUT /api/me/timezone
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>
2026-05-13 11:54:45 -04:00
bvandeusen cb35133843 feat(server): library_changes log table + sqlc queries
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>
2026-05-09 22:28:26 -04:00
bvandeusen a509ec538b feat(db/m7-user-mgmt): migration 0024 + self-service + SMTP queries (U3)
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>
2026-05-07 12:34:44 -04:00
bvandeusen 6b07eaa44d feat(db/m7-user-mgmt): migration 0023 + admin-user CRUD queries
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.
2026-05-07 12:12:18 -04:00
bvandeusen 4845628ae4 feat(db/m7-user-mgmt): migration 0022 + audit helper
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>
2026-05-07 11:51:00 -04:00
bvandeusen a98a106f6f feat(db/m7-discover): migration 0021 + bucket queries
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>
2026-05-07 08:31:17 -04:00
bvandeusen 9a705d0ba4 feat(db/m7-art-providers): migration 0020 + provider-hash queries
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.
2026-05-07 07:39:02 -04:00
bvandeusen e27d030e68 feat(db/m7-recurring-scans): migration 0019 + sqlc queries
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').
2026-05-06 22:12:27 -04:00
bvandeusen a86897b35e feat(db/m7-cover-sources): migration 0018 — artist art + provider settings
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.
2026-05-06 12:24:36 -04:00
bvandeusen 031f4bef8b feat(db/m7-381): scan_runs table for admin scan-status view 2026-05-04 20:04:04 -04:00
bvandeusen 458a183099 feat(db/m7-353): add albums.cover_art_source enum column 2026-05-04 14:29:13 -04:00
bvandeusen 36d05310ce feat(db/m7-352): system playlists schema (kind, variant, seed_artist_id, runs table) 2026-05-04 08:03:06 -04:00
bvandeusen 1226cb7583 feat(db): playlists schema for M7 #352 slice 1
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.
2026-05-03 10:03:36 -04:00
bvandeusen c265b871c3 feat(admin): metadata-profile picker on integrations page
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>
2026-05-02 00:09:13 -04:00
bvandeusen 2ca09749d9 feat(db): add artist_similarity_unmatched schema (migration 0012) 2026-05-01 05:50:22 -04:00
bvandeusen 71a9bd8dee fix(db): drop redundant quarantine user index + tighten ListQuarantineForUser
- 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.
2026-04-30 16:54:03 -04:00
bvandeusen a8df73fe42 feat(db): add lidarr_quarantine + actions schema (migration 0011) 2026-04-30 16:48:33 -04:00
bvandeusen 9ceac5c639 feat(db): add lidarr_config + lidarr_requests schema (migration 0010)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 15:10:01 -04:00
bvandeusen fc2d35d33c feat(db): add migration 0009 for similarity (track_similarity + artist_similarity) 2026-04-28 19:57:02 -04:00
bvandeusen efc2308348 feat(db): add migration 0008 for scrobble (users LB columns + scrobble_queue table) 2026-04-28 08:40:39 -04:00
bvandeusen 92ef53c04d feat(db): add contextual_likes table (M3 session-context capture)
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.
2026-04-27 11:13:31 -04:00
bvandeusen 780beaa248 feat(db): add M2 likes schema (general_likes, _albums, _artists)
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.
2026-04-26 16:20:08 -04:00
bvandeusen 4fcf2c9616 feat(db): add M2 schema — play_sessions, play_events, skip_events
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.
2026-04-25 20:45:44 -04:00
bvandeusen 1741b57a0b feat(db): add sessions table for /api/* auth
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>
2026-04-20 19:37:26 -04:00
bvandeusen b5bf0cc9d6 feat(db): migration 0003 down — drop users.subsonic_password 2026-04-19 17:37:12 +00:00
bvandeusen 9fd3fec149 feat(db): migration 0003 - users.subsonic_password opt-in column
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.
2026-04-19 17:37:11 +00:00
bvandeusen a243357ce6 M1/#292: down migration for core library 2026-04-19 02:31:23 +00:00
bvandeusen 75733a0f7f M1/#292: core library migration (artists/albums/tracks/users) 2026-04-19 02:31:21 +00:00
bvandeusen 79cd25b24a M1/#291: drop goose-style stub migration (replaced by 0001_init.*) 2026-04-19 01:35:23 +00:00
bvandeusen 08a627b10b M1/#291: placeholder down migration 2026-04-19 01:12:39 +00:00
bvandeusen ceb0161677 M1/#291: placeholder up migration to establish runner 2026-04-19 01:12:37 +00:00
bvandeusen ff7cd8d38b feat(skel): placeholder migration so embed.FS has a file to index 2026-04-18 21:20:45 +00:00