DLNA renderers, app updates told like Librarian, acquisition without Lidarr, Collection and admin rework (M502–M505, M542) #156

Merged
bvandeusen merged 117 commits from dev into main 2026-10-26 12:32:33 -04:00
93 changed files with 301 additions and 338 deletions
Showing only changes of commit 6028d78844 - Show all commits
@@ -1,5 +1,5 @@
-- Core library schema per spec §5. Dual-like, session, scrobble, and
-- similarity tables land with their respective feature milestones.
-- Core library schema: artists, albums, tracks and users. Likes, sessions,
-- scrobbling and similarity get tables of their own in later migrations.
CREATE EXTENSION IF NOT EXISTS "pgcrypto";
+3 -4
View File
@@ -1,10 +1,9 @@
-- Session tokens authenticate /api/* requests. We store sha256(token) so a
-- read-only DB leak doesn't grant active sessions; the raw token lives only
-- in the client's cookie (web) or bearer header (Flutter).
-- in the client's cookie (web) or bearer header (native clients).
--
-- last_seen_at enables an "active sessions" UI later (not wired in this plan)
-- without schema churn. user_agent is captured at issue time for the same
-- reason — free metadata now, no migration later.
-- last_seen_at and user_agent (captured at issue time) are for an "active
-- sessions" list.
CREATE TABLE sessions (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
+5 -6
View File
@@ -1,11 +1,10 @@
-- M2 listening telemetry: play_sessions, play_events, skip_events.
-- Note the table is named play_sessions (not sessions — that table already
-- Listening telemetry: play_sessions, play_events, skip_events.
-- The table is named play_sessions (not sessions — that table already
-- exists from migration 0004 and holds HTTP auth sessions). Listening
-- sessions are an entirely separate concept driven by spec §6.
-- sessions are an entirely separate concept.
--
-- session_vector_at_play and contextual_likes ship as nullable now even
-- though M2 writes NULL; M3 will populate them without a follow-up
-- migration.
-- session_vector_at_play is nullable: plays are recorded before the
-- recommendation engine fills it.
CREATE TABLE play_sessions (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
+2 -3
View File
@@ -1,6 +1,5 @@
-- M2 likes: three tables, one per entity type. Schema follows the spec §5
-- pattern (general_likes was originally track-only); this slice extends it
-- to albums and artists for full Subsonic star/unstar support.
-- Likes: three tables, one per entity type. general_likes was track-only;
-- albums and artists get the same shape for Subsonic star/unstar.
CREATE TABLE general_likes (
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
@@ -1,16 +1,12 @@
-- contextual_likes captures a per-session-context snapshot at the time of
-- each like. The recommendation engine in M3 sub-plan #3 uses these rows
-- to compute contextual_match_score per (current session, candidate track).
-- contextual_likes captures a per-session-context snapshot at the time of each
-- like. The recommendation engine uses these rows to compute
-- contextual_match_score per (current session, candidate track).
--
-- Multiple rows per (user, track) are allowed and expected — each row
-- represents a like in a specific session context. Soft-deleted rows
-- remain in the table (deleted_at populated) but are filtered out of the
-- engine's queries via the partial index. Re-liking a previously-unliked
-- track adds a NEW row (does not undelete the old one).
--
-- Note: the M2 events migration (0005) commented that contextual_likes
-- "ships nullable now" but the actual CREATE TABLE was missed. This slice
-- ships the table for the first time.
CREATE TABLE contextual_likes (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
@@ -27,6 +23,6 @@ CREATE INDEX contextual_likes_active_idx
ON contextual_likes (user_id, track_id)
WHERE deleted_at IS NULL;
-- Vector similarity queries from M3 sub-plan #3 will use this.
-- For the engine's vector similarity queries.
CREATE INDEX contextual_likes_vector_idx
ON contextual_likes USING gin (session_vector);
+3 -3
View File
@@ -1,8 +1,8 @@
-- M4a: outbound ListenBrainz scrobble worker.
-- Outbound ListenBrainz scrobble worker.
-- Per-user LB config (plaintext token; users rotate via /settings if leaked).
-- scrobble_queue is a work list, not a log: successfully-sent rows are
-- DELETEd by the worker (the canonical record stays in play_events.scrobbled_at
-- via M2's column). Only `pending` and `failed` states exist.
-- DELETEd by the worker (the canonical record stays in
-- play_events.scrobbled_at). Only `pending` and `failed` states exist.
ALTER TABLE users
ADD COLUMN listenbrainz_token TEXT NULL,
@@ -1,4 +1,4 @@
-- M4b: ListenBrainz similarity ingest. Two symmetric tables — tracks and
-- ListenBrainz similarity ingest. Two symmetric tables — tracks and
-- artists — both keyed by (a_id, b_id, source) so the schema reserves room
-- for additional similarity sources (musicbrainz_tag, user_cooccurrence)
-- alongside listenbrainz. M4b only writes 'listenbrainz' rows.
+2 -2
View File
@@ -1,6 +1,6 @@
-- M5a: Lidarr integration foundation. Two tables:
-- Lidarr integration. Two tables:
--
-- lidarr_config — singleton (CHECK id=1) holding the operator's Lidarr
-- lidarr_config — singleton (CHECK id=1) holding the Lidarr
-- connection. enabled=false is the unconfigured state.
--
-- lidarr_requests — per-request lifecycle row created by users at
@@ -1,4 +1,4 @@
-- M5b: per-user track quarantines + admin action audit log.
-- Per-user track quarantines + admin action audit log.
--
-- lidarr_quarantine — one row per (user, track) complaint. PK matches
-- the general_likes pattern. Re-flagging the same track upserts. Deleted
@@ -1,4 +1,4 @@
-- M5c: persist unmatched-similar-artist MBIDs that the M4b worker would
-- Persist unmatched-similar-artist MBIDs that the M4b worker would
-- otherwise discard. Mirrors artist_similarity shape: same composite PK
-- with source, same (seed_id, score DESC) index, same source enum check.
-- The candidate side is text + name (no FK) — that's the whole point.
@@ -1,9 +1,8 @@
-- M6a follow-up: persist the operator's metadata-profile choice alongside
-- quality profile + root folder. Lidarr POST /api/v1/artist requires
-- metadataProfileId; without storing it we'd refetch and pick first on
-- every Approve, denying the operator a choice. Nullable: existing rows
-- (and operators upgrading from before this migration) get NULL and the
-- service falls back to "fetch list and pick first" until they save.
-- Persist the chosen Lidarr metadata profile alongside quality profile +
-- root folder. Lidarr POST /api/v1/artist requires metadataProfileId;
-- without storing it every Approve would refetch and pick the first.
-- Nullable: existing rows get NULL and the service falls back to "fetch
-- list and pick first" until an admin saves.
ALTER TABLE lidarr_config
ADD COLUMN default_metadata_profile_id integer;
+1 -2
View File
@@ -1,5 +1,4 @@
-- M7 #352 slice 1: playlists CRUD foundation.
-- See docs/superpowers/specs/2026-05-03-m7-playlists-crud-design.md.
-- User playlists.
CREATE TABLE playlists (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
@@ -1,7 +1,7 @@
-- M7 #352 slice 2: system-generated playlists.
-- System-generated playlists.
-- Adds kind/system_variant/seed_artist_id columns to playlists and a
-- per-user run-tracking table. Backfills existing slice-1 playlists
-- to kind='user'.
-- per-user run-tracking table. Backfills existing playlists to
-- kind='user'.
-- Discriminator on existing playlists table.
ALTER TABLE playlists
@@ -1,5 +1,5 @@
-- M7 #353: album cover origin attribution.
-- 'embedded' — extracted from audio file (reserved for future slice).
-- Album cover origin attribution.
-- 'embedded' — extracted from audio file (reserved).
-- 'sidecar' — found in the album directory (cover.jpg/folder.jpg/etc).
-- 'mbcaa' — fetched from MusicBrainz Cover Art Archive.
-- 'none' — tried, no cover available. Skip auto-retry; only manual.
+1 -1
View File
@@ -1,4 +1,4 @@
-- M7 #381: track library-scan / MBID-backfill / cover-enrich runs so the
-- Track library-scan / MBID-backfill / cover-enrich runs so the
-- admin overview can show last-run results + manual trigger state.
CREATE TABLE scan_runs (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
@@ -1,6 +1,5 @@
-- M7 cover-sources: schema for additional cover-art providers (TheAudioDB
-- as the v1 implementation), artist art (thumb + fanart), and the
-- recheck-on-source-change trigger.
-- Schema for additional cover-art providers (TheAudioDB first), artist art
-- (thumb + fanart), and the recheck-on-source-change trigger.
-- (0) Extend the existing albums.cover_art_source CHECK to accept the
-- new 'theaudiodb' value. The constraint was created in migration 0016
@@ -1,4 +1,4 @@
-- M7 recurring-scans: singleton table holding the operator's schedule
-- Recurring scans: singleton table holding the scan schedule
-- config. mode is the discriminator: 'off' / 'interval' / 'daily' / 'weekly'.
-- Per-mode fields are populated only when their mode is selected; others
-- stay NULL. weekly_day uses ISO 8601: 1=Monday … 7=Sunday.
@@ -1,5 +1,5 @@
-- Relax cover-art and artist-art source CHECK constraints so the new
-- Deezer and Last.fm providers (next slice) can persist their IDs as
-- Deezer and Last.fm providers can persist their IDs as
-- accepted source values. Also add the
-- last_registered_providers_hash column to cover_art_sources_meta for
-- boot-time auto-bump detection (when the registered-provider set
@@ -1,14 +1,14 @@
-- User management foundation (U1 of #376):
-- User management:
--
-- - users.display_name: optional human-readable name distinct from username.
-- - user_invites: admin-generated tokens; user redeems during signup.
-- - registration_settings (singleton): controls whether registration is
-- 'invite_only' (default after first admin) or 'open' (admin can
-- re-enable). The handler in U1-T2 also has a special-case for the
-- re-enable). The registration handler also has a special-case for the
-- "no users yet" state where it ignores invites and lets the first
-- register as admin.
-- - audit_log: append-only record of admin-driven user mutations.
-- No retention policy in v1.
-- No retention policy.
ALTER TABLE users ADD COLUMN IF NOT EXISTS display_name text;
@@ -1,7 +1,5 @@
-- Adds users.auto_approve_requests for the per-user auto-approve toggle
-- (#355 sub-feature surface). The handler that honors this flag in
-- the request submission flow is sibling work (U2.5 follow-up); this
-- migration just lands the schema + the column default.
-- Adds users.auto_approve_requests for the per-user auto-approve toggle:
-- the request submission flow approves that user's requests at once.
--
-- Default false: existing users keep manual approval, opt-in via the
-- admin user-management UI.
@@ -1,13 +1,10 @@
-- Append-only change log for library entities. Every mutation on
-- artists/albums/tracks/likes/playlists/playlist_tracks writes a row
-- in the same transaction as the mutation itself. Powers the Flutter
-- delta-sync endpoint (#357) — clients pass back the last-seen `id`
-- as a cursor.
-- in the same transaction as the mutation itself. Powers the delta-sync
-- endpoint — clients pass back the last-seen `id` as a cursor.
--
-- Retention: not capped here. If the table grows unbounded a follow-up
-- adds a periodic compactor (collapse contiguous upserts on the same
-- entity, drop pre-delete history). Cursor reset path (410) covers the
-- compactor's edge.
-- Retention: not capped here. A cursor older than what the table keeps gets
-- the reset path (410).
CREATE TABLE library_changes (
id BIGSERIAL PRIMARY KEY,
@@ -1,5 +1,5 @@
-- 0026_users_timezone.up.sql — per-user timezone for the system-playlist
-- scheduler (#392 Half B). Default 'UTC' so existing rows + brand-new
-- scheduler. Default 'UTC' so existing rows + brand-new
-- users have a safe value before their first client check-in via
-- PUT /api/me/timezone. The scheduler reads this column to decide
-- which IANA zone to anchor each user's daily 03:00 build to.
@@ -1,5 +1,5 @@
-- 0027_system_playlist_rotation.up.sql — sample-history dedup for
-- system playlists (Fable #415). Two parts:
-- system playlists. Two parts:
--
-- 1. play_events.source: which system playlist a play originated
-- from ('for_you' | 'discover' | future kinds), or NULL for any
@@ -1,11 +1,9 @@
-- #411 R3 bugfix: the five discovery mixes (deep_cuts, rediscover,
-- new_for_you, on_this_day, first_listens) were added as producers +
-- registry entries but their system_variant values were never added
-- to the two playlists CHECK constraints. Result: insertSystemPlaylist
-- for any new mix fails with playlists_kind_variant_consistent
-- (SQLSTATE 23514), which — because BuildSystemPlaylists is one
-- all-or-nothing transaction — aborts the WHOLE build, so For-You /
-- Discover refresh 500s and the lazy daily build fails too.
-- Before: the five discovery mixes (deep_cuts, rediscover, new_for_you,
-- on_this_day, first_listens) had producers and registry entries but no
-- system_variant values in the two playlists CHECK constraints, so inserting
-- any of them failed playlists_kind_variant_consistent (SQLSTATE 23514) —
-- and because BuildSystemPlaylists is one all-or-nothing transaction, that
-- aborts the WHOLE build. After: the constraints accept them.
--
-- All five are seedless (like for_you/discover): seed_artist_id IS
-- NULL. Drop + re-add both constraints with the expanded set, same
@@ -2,9 +2,8 @@
-- provider's registry ID. coverart.Register makes the provider set
-- extensible, so a fixed IN-list CHECK (0016 → 0018 → 0020) required a
-- schema migration for every new provider and rejected any
-- out-of-list value (e.g. test stub providers). Same brittleness
-- class as the discovery-mix CHECK incident (#433): a fixed-value
-- CHECK fighting an extensible value set. Relax to "NULL or any
-- out-of-list value (e.g. test stub providers): a fixed-value CHECK
-- fighting an extensible value set. Relax to "NULL or any
-- non-empty string" — the registry, not the schema, is the source of
-- truth for valid provider IDs.
@@ -2,7 +2,7 @@
-- for an approved request. NULL = not yet sent/confirmed; the
-- reconciler idempotently (re)sends the add until this is set, then
-- watches for the import. Decouples the durable admin approval from the
-- best-effort Lidarr call so a transient Lidarr outage no longer forces
-- a manual re-approve.
-- best-effort Lidarr call, so a transient Lidarr outage does not force a
-- manual re-approve.
ALTER TABLE lidarr_requests
ADD COLUMN lidarr_add_confirmed_at timestamptz;
@@ -1,6 +1,6 @@
-- Client-reported playback errors. Populated by clients when a track
-- fails to play (zero duration, decode error, etc.); surfaced in the
-- admin /admin/playback-errors inbox so the operator can hide / delete
-- admin /admin/playback-errors inbox so an admin can hide / delete
-- / re-request the offending track.
--
-- resolved_at + resolved_by + resolution are NULL until an admin acts
@@ -10,8 +10,8 @@
-- unresolved-queue lookup fast even as resolved history accumulates.
--
-- CHECK constraints on kind/resolution gate the enum values so client
-- typos can't pollute the data (per the standing rule about
-- enum-CHECK whitelists needing migrations).
-- typos can't pollute the data; a new value means swapping the constraint
-- in the same migration as the code that writes it.
CREATE TABLE playback_errors (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
@@ -1,4 +1,4 @@
-- Retire the configurable scan scheduler. New-file pickup is now handled by
-- the fsnotify watcher + a hardcoded safety-net delta walk (cmd/minstrel),
-- so the operator-configurable schedule (and its admin UI) is gone.
-- so the configurable schedule (and its admin UI) is gone.
DROP TABLE IF EXISTS scan_schedule;
@@ -1,4 +1,4 @@
-- 0034_you_might_like.up.sql — "You might like" Home rows (#790).
-- 0034_you_might_like.up.sql — "You might like" Home rows.
--
-- Per-user ranked lists of IN-LIBRARY albums/artists the listener
-- doesn't actively spin but is predicted to enjoy, derived from the
@@ -1,11 +1,11 @@
-- 0035_taste_profile.up.sql — per-user taste profile (#796 phase 1).
-- 0035_taste_profile.up.sql — per-user taste profile.
--
-- A persistent, decaying model of each user's taste, rebuilt daily from
-- graded play engagement (completion + skip position) plus explicit
-- likes. Two facet tables: weighted artists and weighted genre tags.
-- Weight is a signed float — positive = drawn to, negative = (passively)
-- avoided; magnitude reflects decayed engagement. Phase 1 only BUILDS
-- these; consumption (candidate sourcing + scoring) lands in phase 2.
-- avoided; magnitude reflects decayed engagement. Candidate sourcing and
-- scoring read them.
--
-- CASCADE on user/artist deletes keeps them clean. Indexed by
-- (user_id, weight DESC) to serve "top taste artists/tags" reads cheaply.
@@ -1,4 +1,4 @@
-- Device diagnostics / debug-reporting subsystem (M9).
-- Device diagnostics / debug-reporting subsystem.
--
-- When an account has users.debug_mode_enabled = true, that account's
-- client(s) stream a timeseries of device state here: connectivity +
@@ -8,12 +8,12 @@
-- /admin/diagnostics view filters by account + device + time window and
-- exports the slice as JSON for offline analysis.
--
-- `kind` is a COARSE category gated by a CHECK whitelist; the finer
-- event discriminator + all event-specific fields live in the `payload`
-- jsonb. This keeps the enum small and stable so new event variants
-- don't require a migration (per the enum-CHECK-whitelist rule, only a
-- new *category* would). If you add a category here, mirror it in the
-- handler whitelist in internal/api/diagnostics.go.
-- `kind` is a COARSE category gated by a CHECK whitelist; the finer event
-- discriminator + all event-specific fields live in the `payload` jsonb. This
-- keeps the enum small and stable so new event variants don't require a
-- migration (only a new *category* would: a new CHECK value means swapping the
-- constraint in the same migration). If you add a category here, mirror it in
-- the handler whitelist in internal/api/diagnostics.go.
--
-- Two timestamps on purpose: occurred_at is the client device clock
-- (when the event happened on the phone — may be skewed/wrong, e.g. in
@@ -1,14 +1,14 @@
-- Add 'playback' to the diagnostic_events kind whitelist (M9 follow-up).
-- Add 'playback' to the diagnostic_events kind whitelist.
--
-- `route` and `player_state` events fire for EVERY output route (phone
-- speaker, Bluetooth, UPnP/Sonos), so bucketing them under 'upnp_sync'
-- was misleading — an operator on Bluetooth earbuds saw "upnp_sync"
-- was misleading — a listener on Bluetooth earbuds produced "upnp_sync"
-- rows. 'upnp_sync' now means genuinely UPnP/Sonos-specific signal
-- (drops, and future resync/desync); general playback + route telemetry
-- moves to the new 'playback' kind.
--
-- Per the enum-CHECK-whitelist rule, the new value lands by dropping and
-- re-adding the constraint in the same change. Existing rows tagged
-- The new value lands by dropping and re-adding the constraint in the same
-- migration. Existing rows tagged
-- 'upnp_sync' stay as-is (debug telemetry on a 30-day retention).
ALTER TABLE diagnostic_events DROP CONSTRAINT diagnostic_events_kind_check;
@@ -1,9 +1,9 @@
-- For You exploration attribution (milestone #127 step 2, Scribe #1249).
-- For You exploration attribution.
--
-- For You is deliberately composed of two populations: a head of
-- top-scored similarity matches ("taste" picks) and a tail sampled from
-- deeper in the candidate ranking ("fresh" picks — the freshness
-- injection). Until now the metrics could only judge For You as one
-- injection). Before this the metrics could only judge For You as one
-- blob, so a high skip rate couldn't be read as "the taste engine is
-- missing" vs "the freshness tax is too high".
--
@@ -1,6 +1,6 @@
-- Provenance as standard (milestone #127, Scribe #1270).
-- Provenance as standard.
--
-- #1249 introduced pick_kind as a For You one-off ('taste'/'fresh').
-- 0038 introduced pick_kind as a For You one-off ('taste'/'fresh').
-- The mechanism — stamp WHY a track is in the snapshot at build time,
-- freeze it onto the play at ingestion, break it down in metrics — is
-- now the standard for every system mix, so the CHECK vocabulary
@@ -11,9 +11,9 @@
-- Discover's three candidate buckets — makes
-- the 40/30/30 allocation measurable instead
-- of a guess
-- tier1 / tier2 / tier3 the tiered-eligibility ladder (project rule
-- #131): tier 1 pins the mix's exact desire,
-- each higher tier steps back a little. Tier
-- tier1 / tier2 / tier3 the tiered-eligibility ladder: tier 1 pins the
-- mix's exact desire, each higher tier steps
-- back a little. Tier
-- provenance is how we measure what the
-- step-back trades away.
--
@@ -1,11 +1,10 @@
-- Recommendation tuning lab (milestone #127, Scribe #1250).
-- Recommendation tuning lab.
--
-- The scoring weights move out of YAML (radio profile) and out of the
-- systemMixWeights hard-code (daily_mix profile) into DB-backed
-- settings with live effect — the defaults-discovery lab (decision
-- #1247): the operator turns knobs here to FIND good values, which
-- then get baked into shipped defaults; end users and other operators
-- should never need this card.
-- settings with live effect — a lab for FINDING good values, which then
-- get baked into shipped defaults; nobody should need this card to get
-- good recommendations.
--
-- Rows are seeded by the recsettings service's boot reconcile (the
-- coverart SettingsService pattern), not by this migration, so shipped
@@ -39,7 +38,7 @@ CREATE TABLE taste_tuning (
);
-- Every knob turn writes one audit row; the metrics trend view
-- (#1251) annotates these on its timeline so cause→effect is visible
-- annotates these on its timeline so cause→effect is visible
-- after a change. changes is a jsonb array of {field, old, new}.
CREATE TABLE recommendation_tuning_audit (
id bigserial PRIMARY KEY,
@@ -1,16 +1,13 @@
-- Discover taste-targeted novelty bucket (milestone #127, Scribe #1252/#1488).
-- Discover taste-targeted novelty bucket.
--
-- The 2-week metrics review found Discover beating the manual baseline on
-- skip rate — which for a discovery surface means it is playing it safe:
-- on a single-user server it is effectively dormant + crude-random, and
-- the per-user taste profile (n_tags, #796) went unused. This adds a
-- Without it, Discover on a single-user server is effectively dormant +
-- crude-random, and the per-user taste profile's tags go unused. This adds a
-- fourth Discover candidate bucket that ranks unheard tracks by the
-- user's taste-profile tag weights ("novelty that fits your vibe"), so
-- its picks need a new pick_kind provenance value.
--
-- Postgres CHECK whitelists can't be altered in place: DROP + re-ADD with
-- the expanded list, in the same migration (family rule — new CHECK-enum
-- value needs a same-change migration).
-- the expanded list, in the same migration.
ALTER TABLE playlist_tracks
DROP CONSTRAINT playlist_tracks_pick_kind_check;
ALTER TABLE playlist_tracks
@@ -1,4 +1,4 @@
-- Track-level folksonomy tags for taste enrichment (milestone #160, #1490).
-- Track-level folksonomy tags for taste enrichment.
--
-- A global (not per-user) cache of style/mood tags fetched from
-- MusicBrainz (keyless) + Last.fm (when a key is configured), keyed by
@@ -1,4 +1,4 @@
-- Tag-enrichment provider settings (milestone #160, #1490 Step 2).
-- Tag-enrichment provider settings.
--
-- Mirrors the cover-art provider-settings substrate (migration 0018) so
-- adding a new tag source later is "register a provider + it auto-upserts
@@ -28,8 +28,9 @@ CREATE TABLE tag_sources_meta (
INSERT INTO tag_sources_meta (id, current_version) VALUES (true, 1);
-- Seed the v1 providers. MusicBrainz is the keyless always-on default;
-- Last.fm ships disabled and is opt-in once the operator supplies a key
-- (Step 4 Settings UI), per the no-coercive-settings rule (#26). The
-- Last.fm ships disabled and is opt-in once an admin supplies a key in
-- Settings: a feature that needs a third-party account is never on by
-- default. The
-- SettingsService also upserts these at boot, so the seed is belt-and-
-- suspenders / documents the intended defaults.
INSERT INTO tag_provider_settings (provider_id, enabled, display_order) VALUES
@@ -1,6 +1,6 @@
-- Expose EnrichedTagScale in the tuning lab (#1520). Adds the enriched-tag
-- weight knob to the taste_tuning singleton so operators can dial how much
-- folksonomy tags (#1490) count vs raw ID3 genre. DEFAULT 0.5 backfills the
-- Expose EnrichedTagScale in the tuning lab. Adds the enriched-tag
-- weight knob to the taste_tuning singleton so an admin can dial how much
-- folksonomy tags count vs raw ID3 genre. DEFAULT 0.5 backfills the
-- existing row and matches taste.DefaultConfig().EnrichedTagScale.
ALTER TABLE taste_tuning
ADD COLUMN enriched_tag_scale double precision NOT NULL DEFAULT 0.5;
@@ -1,5 +1,5 @@
-- 0045_taste_profile_eras.up.sql — era/decade taste facet (#1530,
-- milestone #160 Opt 2). A third taste facet alongside artists + tags:
-- 0045_taste_profile_eras.up.sql — era/decade taste facet. A third taste
-- facet alongside artists + tags:
-- signed weights over decade buckets ("1990s") derived from
-- albums.release_date, rebuilt daily by internal/taste next to the
-- artist/tag facets. Weight is a signed float — positive = drawn to
@@ -18,8 +18,8 @@ CREATE TABLE taste_profile_eras (
CREATE INDEX taste_profile_eras_user_weight_idx
ON taste_profile_eras (user_id, weight DESC);
-- Era-facet build knob, tunable in the lab (mirrors enriched_tag_scale,
-- #1520): scales how strongly a decade-play imprints on the profile.
-- Era-facet build knob, tunable in the lab (mirrors enriched_tag_scale):
-- scales how strongly a decade-play imprints on the profile.
-- DEFAULT 0.5 backfills the existing singleton and matches
-- taste.DefaultConfig().EraScale; 0 disables the era facet entirely.
ALTER TABLE taste_tuning
@@ -1,5 +1,5 @@
-- 0046_context_time_weight.up.sql — time-of-day/weekday context conditioning
-- (#1531, milestone #160 Opt 3). Adds a per-profile scoring weight for the new
-- 0046_context_time_weight.up.sql — time-of-day/weekday context conditioning.
-- Adds a per-profile scoring weight for the new
-- context-affinity term: how strongly a candidate is boosted when its artist's
-- play history concentrates in the current daypart × weekday-type cell (in the
-- user's local timezone). Mirrors the other ScoringWeights columns.
@@ -7,6 +7,6 @@
-- DEFAULT 1.0 backfills both existing profile rows to a modest on-value; the
-- Go ShippedRadioWeights/ShippedDailyMixWeights carry the same 1.0 so fresh
-- installs seed identically. Reconcile only seeds MISSING rows (ON CONFLICT DO
-- NOTHING), so existing rows rely on this DEFAULT until an operator resets.
-- NOTHING), so existing rows rely on this DEFAULT until an admin resets.
ALTER TABLE recommendation_weight_profiles
ADD COLUMN context_time_weight double precision NOT NULL DEFAULT 1.0;
@@ -1,11 +1,11 @@
-- 0047_taste_profile_moods.up.sql — mood taste facet (#1534, milestone #160
-- Opt 2b). A fourth taste facet alongside artists + tags + eras: signed weights
-- over canonical mood buckets (melancholic / energetic / chill / …) derived
-- from a track's enriched folksonomy tags (#1490) via the internal/mood
-- vocabulary, rebuilt daily. Weight is a signed float — positive = drawn to
-- that mood, negative = passively avoided. Coverage is partial (grows with tag
-- enrichment), so it's a supplement, not a foundation. Mirrors
-- taste_profile_tags: CASCADE on user delete, indexed by (user_id, weight DESC).
-- 0047_taste_profile_moods.up.sql — mood taste facet. A fourth taste facet
-- alongside artists + tags + eras: signed weights over canonical mood buckets
-- (melancholic / energetic / chill / …) derived from a track's enriched
-- folksonomy tags via the internal/mood vocabulary, rebuilt daily. Weight is a
-- signed float — positive = drawn to that mood, negative = passively avoided.
-- Coverage is partial (grows with tag enrichment), so it's a supplement, not a
-- foundation. Mirrors taste_profile_tags: CASCADE on user delete, indexed by
-- (user_id, weight DESC).
CREATE TABLE taste_profile_moods (
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
mood text NOT NULL,
@@ -1,9 +1,9 @@
-- 0048_play_events_device_class.up.sql — device-class context conditioning
-- (#1551, milestone #160 Opt 3b). Records which class of device a play came
-- from (mobile / web / …), sent by the client on play-start and normalized
-- server-side. Extends the #1531 time-of-day/weekday affinity cell to
-- (daypart × weekday × device) for the radio path. Nullable + no CHECK: values
-- are normalized in Go (avoids a CHECK-migration each time a client class is
-- added, rule #36), and historical rows stay NULL → excluded from the device
-- dimension (a graceful cold-start; the facet ramps as new plays land).
-- 0048_play_events_device_class.up.sql — device-class context conditioning.
-- Records which class of device a play came from (mobile / web / …), sent by
-- the client on play-start and normalized server-side. Extends the
-- time-of-day/weekday affinity cell (0046) to (daypart × weekday × device) for
-- the radio path. Nullable + no CHECK: values are normalized in Go, so a new
-- client class needs no migration, and historical rows stay NULL → excluded
-- from the device dimension (a graceful cold-start; the facet ramps as new
-- plays land).
ALTER TABLE play_events ADD COLUMN device_class text;
@@ -1,14 +1,15 @@
-- 0049_suggestion_snoozes.up.sql — time-boxed "not right now" on a Discover
-- artist suggestion (#2374, milestone #268 slice 3).
-- artist suggestion.
--
-- This is NOT a dislike. Rule #101 forbids a "Not for me" / thumbs-down /
-- exclusion UI, and a snooze deliberately isn't one: it records no verdict on
-- This is NOT a dislike. Minstrel has no "Not for me" / thumbs-down /
-- exclusion control — taste is learned from listening — and a snooze
-- deliberately isn't one: it records no verdict on
-- the music, expires on its own, and MUST NEVER feed the taste profile. It is
-- acquisition triage — "I don't want to request this right now" — so the same
-- candidate is free to return once snoozed_until passes. Anything that reads
-- this table as negative preference signal is a bug.
--
-- Per-user (rule #47), never global: one household member parking a
-- Per-user, never global: one household member parking a
-- suggestion must not remove it from anyone else's deck.
--
-- candidate_mbid is text with NO foreign key, on purpose. Suggestions come
@@ -1,18 +1,17 @@
-- 0050_candidate_artist_tags.up.sql — folksonomy tags for OUT-OF-LIBRARY
-- artists (#2376, milestone #268 slice 5).
-- artists.
--
-- track_tags (0042) cannot hold these: it is FK'd to tracks(id), and a
-- Discover candidate has no local row by definition. So this is a parallel
-- cache keyed by the candidate's MusicBrainz MBID — the only stable identity
-- available for an artist we don't have.
--
-- Purpose is slice 6: rank suggestions by overlap between these tags and the
-- Purpose: rank Discover suggestions by overlap between these tags and the
-- user's taste_profile_tags, turning "neighbour of an artist you play" into
-- "matches the sound you like".
--
-- GLOBAL, not per-user: a candidate's tags are a property of the artist, not
-- of anyone's taste. Nothing here is user-scoped, so rule #47 has nothing to
-- scope — the per-user part lives entirely in slice 6's ranking.
-- of anyone's taste. The per-user part lives entirely in the ranking.
--
-- weight is a normalized folksonomy strength in [0,1], same scale as
-- track_tags, so the two can be compared without a conversion step.
@@ -1,14 +1,14 @@
-- 0051_discover_tuning.up.sql — tunable knobs for the Discover request
-- surface (#2377, milestone #268 slice 6).
-- surface.
--
-- A FOURTH tuning scope alongside radio / daily_mix / taste. Its own scope
-- rather than extra columns on taste_tuning, for a reason that matters:
-- snooze_days lives here, and a snooze must never be read as taste signal
-- (#2374). Filing it under 'taste' would put it one careless join away from
-- exactly the leak that design forbids.
-- (0049). Filing it under 'taste' would put it one careless join away from
-- exactly that leak.
--
-- Per rule #25 these are DB-backed and editable in the admin UI with no
-- restart — the shipped values below are defaults, not settings.
-- These are DB-backed and editable in the admin UI with no restart — the
-- shipped values below are defaults, not settings.
CREATE TABLE discover_tuning (
singleton boolean PRIMARY KEY DEFAULT true
CONSTRAINT discover_tuning_singleton_check CHECK (singleton),
@@ -17,10 +17,10 @@ CREATE TABLE discover_tuning (
-- [0,1]. So 0 disables the feature outright and leaves pure similarity
-- ranking, 1.0 lets a perfectly-matching candidate double its score, and
-- a candidate with no cached tags is unchanged rather than penalised
-- (rule #131 — tag coverage is permanently partial, see #2376).
-- (tag coverage is permanently partial, so a missing tag is no signal).
tag_overlap_weight double precision NOT NULL,
-- Default snooze duration in days. Was a Go constant in
-- internal/api/suggestions.go; moved here per rule #25.
-- Default snooze duration in days. Before this a Go constant in
-- internal/api/suggestions.go; now an admin setting.
snooze_days double precision NOT NULL,
updated_at timestamptz NOT NULL DEFAULT now()
);
@@ -1,4 +1,4 @@
-- Session provenance for the active-sessions surface (#370).
-- Session provenance for the active-sessions surface.
--
-- TWO addresses, not one, and the pair is the point: a session created at
-- home and now being used from somewhere else is the shape of a stolen
@@ -1,4 +1,4 @@
-- Trusted reverse-proxy depth for client-IP extraction (#2453).
-- Trusted reverse-proxy depth for client-IP extraction.
--
-- X-Forwarded-For grows left-to-right: each proxy APPENDS the peer it
-- received the request from. For client -> CDN -> own-proxy -> Minstrel the
@@ -11,14 +11,15 @@
--
-- Default 1: a publicly reachable Minstrel needs a TLS terminator in front of
-- it, and recording that terminator's own address for every session makes the
-- active-sessions surface (#370) useless — created_ip and last_ip would both
-- active-sessions surface useless — created_ip and last_ip would both
-- be the proxy, so the "address changed" signal could never fire.
--
-- The cost, stated on the admin card rather than buried: hops >= 1 DECLARES
-- that a proxy exists. If one doesn't, a client can forge X-Forwarded-For and
-- choose what its own session row shows, which defeats exactly the compromise
-- detection #370 exists for. That is inherent to the trusted-hop model, which
-- is why 0 is a first-class setting and not a hidden escape hatch.
-- detection the active-sessions surface exists for. That is inherent to the
-- trusted-hop model, which is why 0 is a first-class setting and not a hidden
-- escape hatch.
--
-- Upper bound 10 guards a typo turning into "trust the whole header"; no real
-- deployment chains ten proxies.
@@ -1,11 +1,11 @@
-- Records which version of the scanner's tag-extraction logic last wrote a
-- track's tag-derived columns (#2499).
-- track's tag-derived columns.
--
-- DEFAULT 0 is the point of this migration: every existing row lands below the
-- scanner's current library.tagReadVersion, so the next scan re-reads its tags
-- instead of short-circuiting on the mtime check. That repairs genre values the
-- old reader welded together ("Alternative Rock" + "Rock" -> "Alternative
-- RockRock") without asking the operator to wipe and rebuild the library.
-- RockRock") without anyone wiping and rebuilding the library.
--
-- Bump library.tagReadVersion in Go — not this default — whenever a tag
-- extraction fix needs to reach already-indexed files. That makes tag repairs a
@@ -1,4 +1,4 @@
-- Marks a track whose file the scanner could no longer find (#2523).
-- Marks a track whose file the scanner could no longer find.
--
-- NULL means present. A timestamp means the file was absent as of that scan,
-- and is the point from which "how long has this been gone" is measured — which
@@ -1,14 +1,12 @@
-- Auto re-acquisition of missing files via Lidarr (#2527 slice 3, milestone
-- #290). A file going missing has been a dead end until now: reconcile marks
-- it (#2523), every selection path skips it, the admin surface lists it, and
-- there it sits.
-- Auto re-acquisition of missing files via Lidarr. Before this a missing
-- file was a dead end: reconcile marks it, every selection path skips it,
-- the admin surface lists it, and there it sits.
--
-- The unit here is the ALBUM, not the track, and that is the design decision
-- carrying most of the safety. Lidarr acquires releases; there is no
-- meaningful "fetch me one track" operation, and a track-kind request needs a
-- recording MBID plenty of files simply don't have. Grouping by album means
-- the case that produced #2523 -- three reorganised albums, ~40 missing files
-- -- becomes three requests instead of forty.
-- a reorganised album with dozens of missing files becomes one request.
CREATE TABLE missing_reacquisitions (
album_id uuid PRIMARY KEY REFERENCES albums (id) ON DELETE CASCADE,
@@ -36,12 +34,13 @@ CREATE INDEX missing_reacquisitions_due_idx
WHERE gave_up_at IS NULL;
-- Settings, singleton in the style of network_settings (0053). Every value an
-- operator might want to tune lives here rather than in YAML (rule #25).
-- admin might want to tune lives here rather than in YAML, changed without a
-- restart.
CREATE TABLE reacquisition_settings (
id boolean PRIMARY KEY DEFAULT true,
-- Master switch. Default true: the operator asked for this to happen by
-- itself, and a feature that ships switched off is a feature nobody finds.
-- Master switch. Default true: this is meant to happen by itself, and a
-- feature that ships switched off is a feature nobody finds.
enabled boolean NOT NULL DEFAULT true,
-- How long a file must have been missing before the FIRST attempt. This
@@ -49,7 +48,7 @@ CREATE TABLE reacquisition_settings (
-- transiently -- an unmounted volume, a container that started before its
-- media mount attached, a NAS mid-reboot -- and every one of those
-- resolves itself well inside a day at no cost. tracks.missing_since is
-- never re-stamped (#2523), so it is a true "gone since" clock to measure
-- never re-stamped, so it is a true "gone since" clock to measure
-- against.
grace_hours int NOT NULL DEFAULT 24,
@@ -1,13 +1,9 @@
-- 0057_songs_like_tuning.up.sql — a THIRD weight profile, for Songs-like
-- (Scribe #3881, milestone #398).
-- 0057_songs_like_tuning.up.sql — a THIRD weight profile, for Songs-like.
--
-- Songs-like shared the `daily_mix` profile with For-You, and that is the bug.
-- The two surfaces want opposite things: For-You is a broad "what will they
-- enjoy today", Songs-like answers "what sounds like THIS", and under one set
-- of weights the broad answer wins. Operator, 2026-09-10: "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."
-- Before: Songs-like shared the `daily_mix` profile with For-You. The two
-- surfaces want opposite things: For-You is a broad "what will they enjoy
-- today", Songs-like answers "what sounds like THIS" and should stay in one
-- neighbourhood of sound, and under one set of weights the broad answer wins.
--
-- Under the shared daily_mix weights, an UNRELATED track the user had liked
-- and not played recently scored 1.0 + 2.0 + 1.0 = 4.0 before taste, while a
@@ -1,5 +1,4 @@
-- 0058_track_fingerprints.up.sql — an acoustic identity per track (Scribe
-- milestone #400: #3905, #3906).
-- 0058_track_fingerprints.up.sql — an acoustic identity per track.
--
-- A table of its own rather than columns on tracks, for the hot path's sake:
-- tracks is read with SELECT * by eight queries, among them ListTracksByAlbum,
@@ -16,9 +15,9 @@
-- A failure that says nothing about the file — a timeout, a cancelled scan, a
-- missing binary — writes no row at all, so the backfill tries again.
CREATE TABLE track_fingerprints (
-- CASCADE is right here, unlike for the likes and play history M400's
-- merge has to carry across: a fingerprint describes one file's bytes and
-- means nothing once that file's row is gone.
-- CASCADE is right here, unlike for the likes and play history the
-- duplicate merge has to carry across: a fingerprint describes one file's
-- bytes and means nothing once that file's row is gone.
track_id uuid PRIMARY KEY REFERENCES tracks (id) ON DELETE CASCADE,
-- SHA-256 of the ENCODED audio packets (ffmpeg -c:a copy -f hash), not of
-- decoded samples. internal/library/fingerprint.go says why.
@@ -1,9 +1,9 @@
-- 0059_duplicate_groups.up.sql — proposed duplicates and the sweeps that find
-- them (Scribe milestone #400: #3910).
-- them.
--
-- The sweep compares fingerprints (track_fingerprints, 0058) and proposes groups
-- of tracks that hold one recording. Nothing here merges anything: a group is a
-- proposal the operator reviews, and the merge (#3911) is a separate act.
-- The sweep compares fingerprints (track_fingerprints, 0058) and proposes
-- groups of tracks that hold one recording. Nothing here merges anything: a
-- group is a proposal an admin reviews, and the merge is a separate act.
-- One row per sweep. Lets the report tell "the sweep has never run" apart from
-- "it ran and found nothing", and gives the in-flight guard something to check,
@@ -49,6 +49,6 @@ CREATE TABLE duplicate_group_members (
CREATE INDEX duplicate_group_members_track_idx ON duplicate_group_members (track_id);
-- The sweep streams candidates in (duration_ms, id) order, keyset-paged, so it
-- only ever holds a few seconds' worth of durations in memory. Without this each
-- page would sort the whole library again.
-- only ever holds a few seconds' worth of durations in memory. Without this
-- each page would sort the whole library again.
CREATE INDEX tracks_duration_id_idx ON tracks (duration_ms, id);
@@ -1,8 +1,8 @@
-- 0060_play_events_track_index.up.sql — play_events by track (Scribe #3912, #3911).
-- 0060_play_events_track_index.up.sql — play_events by track.
--
-- play_events is indexed by (user_id, started_at) and (user_id, track_id), both
-- led by user. Nothing reached it by track alone until the duplicates report,
-- which shows each copy's play count — a scan of the whole table per copy — and
-- the merge (#3911), which repoints a duplicate's play history onto the copy
-- the duplicate merge, which repoints a duplicate's play history onto the copy
-- being kept. Both ask "every play of this track", whoever played it.
CREATE INDEX play_events_track_idx ON play_events (track_id);
@@ -1,20 +1,18 @@
-- 0061_fingerprint_settings.up.sql — fingerprinting's knobs, in admin Settings
-- (Scribe #3913, milestone #400). Rule 25: anything an operator might tune is a
-- database row, changed without a restart. Singleton in the style of
-- reacquisition_settings (0056).
-- 0061_fingerprint_settings.up.sql — fingerprinting's knobs, in admin
-- Settings: anything an admin might tune is a database row, changed without a
-- restart. Singleton in the style of reacquisition_settings (0056).
CREATE TABLE fingerprint_settings (
id boolean PRIMARY KEY DEFAULT true,
-- Fingerprinting new files, the backfill, and the duplicate sweep. Off stops
-- the decode work entirely — the reason to turn it off is a slow NAS, and
-- that is the operator's call. On by default: a library that cannot tell its
-- duplicates apart is what milestone #400 exists to end.
-- Fingerprinting new files, the backfill, and the duplicate sweep. Off
-- stops the decode work entirely — the reason to turn it off is a slow NAS.
-- On by default: without it the library cannot tell its duplicates apart.
enabled boolean NOT NULL DEFAULT true,
-- Seconds of audio fpcalc fingerprints. Chromaprints taken at different
-- lengths cannot be compared, which is why track_fingerprints records the
-- length each row was taken at (below): change this and every chromaprint is
-- re-derived, and until then only rows at the new length are compared.
-- length each row was taken at (below): change this and every chromaprint
-- is re-derived, and until then only rows at the new length are compared.
chromaprint_length_sec integer NOT NULL DEFAULT 120,
-- The most disagreement two aligned fingerprints may show and still be
@@ -1,8 +1,8 @@
-- The address users reach Minstrel at, e.g. https://music.example.com.
--
-- Password-reset links used to be built from the request's Host header,
-- which the requester controls: a forgot-password call with a forged Host
-- Before: password-reset links were built from the request's Host header,
-- which the requester controls, so a forgot-password call with a forged Host
-- would email the victim a real reset token on a link to the attacker's
-- server. Links are now built only from this operator-set value, and none
-- are sent while it is empty (M462 #4981).
-- server. After: links are built only from this admin-set value, and none
-- are sent while it is empty.
ALTER TABLE network_settings ADD COLUMN public_url text NOT NULL DEFAULT '';
@@ -2,7 +2,7 @@
-- same way session tokens are. A leaked database row or backup no longer
-- hands out working keys. Existing keys are hashed in place, so every
-- Subsonic client keeps working; the key itself can no longer be shown
-- again, only replaced (M462 #4983).
-- again, only replaced.
ALTER TABLE users ADD COLUMN api_token_hash text;
UPDATE users SET api_token_hash = encode(sha256(convert_to(api_token, 'UTF8')), 'hex');
ALTER TABLE users ALTER COLUMN api_token_hash SET NOT NULL;
@@ -1,9 +1,9 @@
-- `minstrel admin reset-password` used to copy the new login password into
-- Before: `minstrel admin reset-password` copied the new login password into
-- subsonic_password, so every account recovered through the CLI had its login
-- password stored in plain text, and a later password change in Settings left
-- that copy behind (M462 #5026). The CLI no longer writes this column; a
-- Subsonic password is now generated separately in Settings and is never the
-- login password. Clearing every value here removes the copies already made.
-- that copy behind. After: the CLI does not write this column; a Subsonic
-- password is generated separately in Settings and is never the login
-- password. Clearing every value here removes the copies already made.
--
-- Accounts whose Subsonic client signs in with t/s stop working until the user
-- generates a Subsonic password (or switches the client to an API key).
@@ -1,5 +1,5 @@
-- 0065_track_loudness.up.sql — measured loudness per track, for loudness
-- normalization (Scribe milestone #464, #4995).
-- normalization.
--
-- Measured with ffmpeg's EBU R128 filter rather than read from ReplayGain tags:
-- tags in the wild are written against four different reference levels, and
@@ -32,9 +32,9 @@ CREATE TABLE track_loudness (
loudness_range_lu real,
-- How many 400 ms gating blocks fell in each 0.1 LU bin. Bin i holds blocks
-- measuring -70.0 + (block_hist_start + i) / 10 LUFS; the array is trimmed
-- to the first and last non-empty bins. Album loudness is the gated loudness
-- of every block on the album, so it is computed from these exactly, with no
-- second decode (#4996).
-- to the first and last non-empty bins. Album loudness is the gated
-- loudness of every block on the album, so it is computed from these
-- exactly, with no second decode.
block_hist_start smallint,
block_hist integer[],
unreadable boolean NOT NULL DEFAULT false,
@@ -1,5 +1,4 @@
-- 0066_album_loudness.up.sql — album loudness for album-mode normalization
-- (Scribe milestone #464, #4996).
-- 0066_album_loudness.up.sql — album loudness for album-mode normalization.
--
-- An album's loudness is the gated loudness of every 400 ms block on the album,
-- not an average of its tracks' values: a quiet interlude and a loud single
@@ -1,4 +1,4 @@
-- Each user's loudness-normalization preference (M464 #4998). It is kept on
-- Each user's loudness-normalization preference. It is kept on
-- the server, not per device, so the web player, the Android app and the
-- leveled stream for UPnP/Sonos all apply the same one.
--
@@ -1,11 +1,10 @@
-- 0068_leveled_stream_cache.up.sql — the size cap of the leveled-stream render
-- cache (Scribe milestone #464, #5001).
-- cache.
--
-- Sonos and other UPnP renderers fetch a track's URL themselves, so the phone
-- cannot level what they play. The server renders a gain-applied FLAC for them
-- instead and keeps it on disk, oldest-used first out once the cache is over
-- this size. An operator setting (rule 25), on the loudness settings row it
-- belongs with.
-- this size. An admin setting, on the loudness settings row it belongs with.
ALTER TABLE loudness_settings
ADD COLUMN leveled_cache_mb integer NOT NULL DEFAULT 2048,
ADD CONSTRAINT loudness_settings_leveled_cache_range
@@ -1,11 +1,11 @@
-- 0069_acoustid_lookup.up.sql — recording MBIDs looked up through AcoustID
-- for tracks whose tags carry none (Scribe milestone #401).
-- for tracks whose tags carry none.
--
-- A track with no recording MBID is invisible to the ListenBrainz similarity
-- arm in both directions, so this fills the ids the tags leave empty.
-- Where tracks.mbid came from. The file's own tag always outranks a lookup
-- (M401 D4): the scan's upsert keeps a looked-up id while the tag carries
-- Where tracks.mbid came from. The file's own tag always outranks a lookup:
-- the scan's upsert keeps a looked-up id while the tag carries
-- none, and replaces it the moment the tag carries one.
ALTER TABLE tracks ADD COLUMN mbid_source text;
-- Every id stored so far was read from a tag (the scan or the tag backfill),
@@ -25,7 +25,7 @@ ALTER TABLE tracks
-- after disambiguating by title and length; recording_mbid is it
-- ambiguous several recordings cleared it and nothing told them apart.
-- Nothing is written: a wrong MBID would feed similarity the
-- wrong neighbours, which is worse than none (M401 D5)
-- wrong neighbours, which is worse than none
-- no_match AcoustID linked nothing at or above the threshold
-- failed fpcalc rejected the file, or AcoustID rejected its print
-- Every state is settled until the file changes. A failure that says nothing
@@ -50,12 +50,12 @@ CREATE TABLE track_acoustid_lookups (
);
CREATE INDEX track_acoustid_lookups_state ON track_acoustid_lookups (state);
-- AcoustID's knobs, in admin Settings (rule 25). Singleton in the style of
-- AcoustID's knobs, in admin Settings. Singleton in the style of
-- loudness_settings (0065).
CREATE TABLE acoustid_settings (
id boolean PRIMARY KEY DEFAULT true,
-- Off by default (rule 26): AcoustID needs a key the operator registers,
-- and the lookup sends fingerprints to a third party.
-- Off by default: AcoustID needs a key an admin registers, and the lookup
-- sends fingerprints to a third party.
enabled boolean NOT NULL DEFAULT false,
api_key text,
-- The score a recording needs before it is considered at all. AcoustID
@@ -1,4 +1,4 @@
-- MusicBrainz release-group id for an album (#5241). albums.mbid holds the
-- MusicBrainz release-group id for an album. albums.mbid holds the
-- *release* id (Picard's musicbrainz_albumid: one edition), but Lidarr names
-- albums by release group, so re-acquisition and request completion need
-- this one. Not unique: several releases of one group can each be an album
@@ -1,10 +1,10 @@
-- #5263: the reconciler completed album and track requests as soon as the album
-- had any track on disk. Re-acquisition targets albums with only SOME tracks
-- missing, so those requests completed the moment Lidarr accepted the add,
-- with nothing downloaded.
-- Before: the reconciler completed album and track requests as soon as the
-- album had any track on disk. Re-acquisition targets albums with only SOME
-- tracks missing, so those requests completed the moment Lidarr accepted the
-- add, with nothing downloaded.
--
-- Reopen every completed album/track request whose matched album does not meet
-- the corrected test (see albumForRequest in internal/lidarrrequests): a track
-- the corrected test: a track
-- that arrived after the request, or no track that was already missing at the
-- request still missing. The reconciler then judges them again; the Lidarr add
-- stays confirmed, so nothing is re-sent. Requests that genuinely completed
@@ -1,7 +1,7 @@
-- #5296: keep ListenBrainz's whole similar-recordings answer, and record every
-- Keep ListenBrainz's whole similar-recordings answer, and record every
-- fetch, so the library match is made locally and can be made again.
--
-- Until now the worker kept only the recordings already in the library (at most
-- Before: the worker kept only the recordings already in the library (at most
-- 20 of LB's 50) and threw the rest away. Two things followed:
--
-- * A recording that reached the library later — a Lidarr import, or an MBID
@@ -9,8 +9,8 @@
-- fetched again.
-- * A seed with no in-library match wrote nothing at all, and freshness was
-- read from track_similarity, so that seed was never fresh. With the queue
-- ordered by id, 25 such seeds held its head and were re-asked every hour
-- while 2,449 others waited (#3879).
-- ordered by id, such seeds held its head and were re-asked every hour
-- while the rest waited.
--
-- The listenbrainz rows in track_similarity are now derived from this cache by
-- ResolveListenBrainzTrackEdges.
@@ -1,11 +1,11 @@
-- M489: a persistent, per-user notifications inbox (#726).
-- A persistent, per-user notifications inbox.
--
-- The event bus is fire-and-forget: a client that is not connected when a
-- request completes, or when the scanner marks tracks missing, never hears of
-- it. A row here is the durable record; the bus only nudges open clients to
-- come and read it.
--
-- `kind` is CHECK-gated (rule 36). A new kind adds its value here, in the
-- `kind` is CHECK-gated. A new kind adds its value here, in the
-- same migration as the code that produces it. The list is mirrored in
-- internal/notifications/kinds.go, and both CHECKs below must agree with it.
@@ -1,11 +1,11 @@
-- M489 #5346: notifications by email, grouped. Nothing is emailed per event.
-- Notifications by email, grouped. Nothing is emailed per event.
--
-- New music (request_completed) goes out as at most one summary a day, at a set
-- hour in each user's own timezone. Everything else is batched: a batch opens
-- at the first un-emailed item and one email goes out a window later, holding
-- whatever accumulated. internal/notifications/digest.go is the one sender.
-- The two knobs, in admin Settings (rule 25). Singleton in the style of
-- The two knobs, in admin Settings. Singleton in the style of
-- fingerprint_settings (0061).
CREATE TABLE notification_email_settings (
id boolean PRIMARY KEY DEFAULT true,
@@ -1,30 +1,30 @@
-- 0075_duplicate_resolve.up.sql — duplicates resolve themselves (Scribe
-- milestone #498: #5435, #5436, #5437).
-- 0075_duplicate_resolve.up.sql — duplicates resolve themselves.
--
-- The sweep (0059) proposes groups and, until now, every proposal waited for the
-- operator. The resolver classifies each pending group and acts on the classes
-- that are safe. What makes deletion safe is Lidarr's view of the file: Lidarr
-- maps one file to each track of the release it monitors, so deleting a mapped
-- file opens a hole it fills by downloading again. Only a copy Lidarr does not
-- map may go.
-- Before: the sweep (0059) proposed groups and every proposal waited for an
-- admin. After: the resolver classifies each pending group and acts on the
-- classes that are safe. What makes deletion safe is Lidarr's view of the file:
-- Lidarr maps one file to each track of the release it monitors, so deleting a
-- mapped file opens a hole it fills by downloading again. Only a copy Lidarr
-- does not map may go.
-- What kind of duplicate a group is. NULL until the resolver has classified it.
-- same_release every copy is on one album
-- cross_release the same song on different releases (a single and its album)
-- mismatch identical audio under different titles on different albums:
-- a file imported as the wrong song
-- review anything else: the operator decides
-- Rule 36: a new value swaps this constraint in the same migration.
-- review anything else: an admin decides
-- A new value swaps this constraint in the same migration.
ALTER TABLE duplicate_groups ADD COLUMN class text;
ALTER TABLE duplicate_groups ADD CONSTRAINT duplicate_groups_class_check
CHECK (class IN ('same_release', 'cross_release', 'mismatch', 'review'));
-- Why the resolver left a group for the operator, in words the report shows
-- ("Lidarr tracks every copy as its own track"). NULL when it had nothing to say.
-- Why the resolver left a group for an admin, in words the report shows
-- ("Lidarr tracks every copy as its own track"). NULL when it had nothing to
-- say.
ALTER TABLE duplicate_groups ADD COLUMN resolve_note text;
-- True when the resolver merged the group itself, so the report can tell its
-- merges from the operator's.
-- merges from an admin's.
ALTER TABLE duplicate_groups ADD COLUMN resolved_automatically boolean NOT NULL DEFAULT false;
-- Lidarr's view of each copy, as of the resolver's last pass:
@@ -36,12 +36,12 @@ ALTER TABLE duplicate_group_members ADD COLUMN lidarr_state text;
ALTER TABLE duplicate_group_members ADD CONSTRAINT duplicate_group_members_lidarr_state_check
CHECK (lidarr_state IN ('tracked', 'unmapped'));
-- Rule 25: the operator can turn the resolver off. On by default: the operator
-- asked for duplicates to resolve themselves (2026-10-09).
-- An admin can turn the resolver off. On by default: duplicates are meant to
-- resolve themselves.
ALTER TABLE fingerprint_settings ADD COLUMN auto_resolve boolean NOT NULL DEFAULT true;
-- The resolver tells admins what it did (rule 36: the kind CHECKs of 0073 are
-- swapped to carry the new value). Postgres named both from their columns.
-- The resolver tells admins what it did (the kind CHECKs of 0073 are swapped
-- to carry the new value). Postgres named both from their columns.
ALTER TABLE user_notifications DROP CONSTRAINT user_notifications_kind_check;
ALTER TABLE user_notifications ADD CONSTRAINT user_notifications_kind_check
CHECK (kind IN ('request_approved', 'request_rejected', 'request_completed',
@@ -1,5 +1,5 @@
-- 0076_track_song_link.up.sql — the same song on several releases counts as
-- one song (Scribe milestone #498, #5438; operator decision D-b).
-- one song.
--
-- A single and the album it came from each hold the song, and Lidarr keeps
-- both: each fulfils its own release. Minstrel keeps both files and links
@@ -1,13 +1,12 @@
-- 0077_track_source_verdict.up.sql — video rips are held back from mixes and
-- radio until the operator says otherwise (Scribe milestone #498, #5439;
-- operator decision D-d).
-- radio until an admin says otherwise.
--
-- A file whose name carries a video-rip marker ("(Official Video)", "[HD]",
-- #5410) holds the audio of an upload, which can carry intros, skits or a
-- A file whose name carries a video-rip marker ("(Official Video)", "[HD]")
-- holds the audio of an upload, which can carry intros, skits or a
-- different take. The duplicate resolver sets 'suspect' on such a present
-- track each pass, and clears it when the name no longer matches. 'suspect'
-- keeps the track out of radio and the system mixes; it still plays when
-- chosen. 'fine' is the operator's "this one is fine" and is never
-- chosen. 'fine' is an admin's "this one is fine" and is never
-- overwritten by the resolver.
ALTER TABLE tracks ADD COLUMN source_verdict text
CONSTRAINT tracks_source_verdict_check CHECK (source_verdict IN ('suspect', 'fine'));
@@ -1,5 +1,5 @@
-- 0078_drop_duplicate_lidarr_state.up.sql — the duplicate resolver works
-- without Lidarr (Scribe milestone #502, #5517).
-- without Lidarr.
--
-- 0075 recorded Lidarr's view of each duplicate copy (tracked or unmapped)
-- because a copy Lidarr mapped could not be removed without Lidarr downloading
@@ -1,11 +1,9 @@
-- 0079_acquisition_services.up.sql — the indexers and download clients the
-- operator adds in Admin → Integrations (Scribe milestone #502, #5518).
-- admin adds in Admin → Integrations.
--
-- Minstrel replaces Lidarr, so it searches and downloads itself. Nothing is
-- seeded and nothing is copied from Lidarr: a connection is a row the operator
-- adds, from a list of what Minstrel can speak. Ported from Fabled
-- Librarian's acquisition_services (its migrations 0044, 0055, 0062, 0064,
-- 0070), which settled the shape:
-- seeded and nothing is copied from Lidarr: a connection is a row an admin
-- adds, from a list of what Minstrel can speak:
--
-- kind which list the row appears in: search (Indexers) or download
-- (Download clients).
@@ -32,7 +30,7 @@ CREATE TABLE acquisition_services (
label text NOT NULL,
-- A new row is off: it has no address yet.
enabled boolean NOT NULL DEFAULT false,
-- The address as the operator's browser opens it. For a Torznab feed it
-- The address as an admin's browser opens it. For a Torznab feed it
-- is the whole feed URL up to and including /api.
base_url text NOT NULL DEFAULT '',
api_key text NOT NULL DEFAULT '',
@@ -1,5 +1,5 @@
-- 0080_release_catalogue.up.sql — Minstrel's own copy of what artists have
-- released, read from MusicBrainz (Scribe milestone #502, #5519).
-- released, read from MusicBrainz.
--
-- With Lidarr gone, the wanted list, search and import need to know an
-- artist's discography and each release's track list. MusicBrainz allows one
+10 -10
View File
@@ -1,4 +1,4 @@
-- 0081_wanted.up.sql — the wanted list (Scribe milestone #502, #5520).
-- 0081_wanted.up.sql — the wanted list.
--
-- With Lidarr gone, Minstrel keeps its own record of what it is looking for:
--
@@ -19,7 +19,7 @@
-- lidarr_requests becomes requests with no Lidarr columns: approving a request
-- now records a want or a follow instead of sending an add to Lidarr. The
-- missing-file sweeper's attempt bookkeeping goes too; retrying belongs to the
-- search schedule (#5521), which works per want.
-- search schedule, which works per want.
CREATE TABLE release_type_profiles (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
@@ -36,8 +36,8 @@ CREATE TABLE release_type_profiles (
CREATE UNIQUE INDEX release_type_profiles_one_default
ON release_type_profiles (is_default) WHERE is_default;
-- Lidarr's "Standard" metadata profile, which every followed artist used
-- (note #5527): albums, EPs and singles, studio releases only.
-- Lidarr's "Standard" metadata profile, which followed artists used: albums,
-- EPs and singles, studio releases only.
INSERT INTO release_type_profiles (name, primary_types, secondary_types, is_default)
VALUES ('Standard', ARRAY['Album', 'EP', 'Single'], '{}', true);
@@ -53,7 +53,7 @@ CREATE TABLE follows (
baseline_at timestamptz,
-- Whether the baseline also wants what the artist had already released
-- that the profile passes and the library lacks: an approved artist
-- request, or the operator ticking "also want…". Cleared once applied.
-- request, or an admin ticking "also want…". Cleared once applied.
want_existing boolean NOT NULL DEFAULT false,
-- The discography read this follow last applied.
checked_at timestamptz,
@@ -111,7 +111,7 @@ ALTER INDEX lidarr_requests_status_idx RENAME TO requests_status_idx;
ALTER INDEX lidarr_requests_artist_mbid_idx RENAME TO requests_artist_mbid_idx;
ALTER INDEX lidarr_requests_album_mbid_idx RENAME TO requests_album_mbid_idx;
-- Requests made before #5241 name albums by release id. A want is keyed by
-- Older requests may name albums by release id. A want is keyed by
-- release group, so repoint every request the library can translate.
UPDATE requests r
SET album_mbid = a.release_group_mbid,
@@ -133,10 +133,10 @@ SELECT DISTINCT ON (r.album_mbid)
ORDER BY r.album_mbid, r.requested_at
ON CONFLICT (release_group_mbid) DO NOTHING;
-- The library's album artists start out followed on the default profile
-- (operator, 2026-10-09: "we already have artists in minstrel those start as
-- the wanted artists"). Various Artists and MusicBrainz's bracketed
-- placeholders ([unknown], [anonymous]…) are not artists anyone follows.
-- The library's album artists start out followed on the default profile: the
-- artists already in the library are the wanted artists. Various Artists and
-- MusicBrainz's bracketed placeholders ([unknown], [anonymous]…) are not
-- artists anyone follows.
INSERT INTO follows (artist_mbid, artist_name, profile_id)
SELECT ar.mbid, ar.name, p.id
FROM artists ar
@@ -1,5 +1,4 @@
-- 0082_search_and_grab.up.sql — searching for wants and grabbing releases
-- (Scribe milestone #502, #5521).
-- 0082_search_and_grab.up.sql — searching for wants and grabbing releases.
--
-- acquisition_settings one row: whether the schedule takes the best release
-- by itself, how often a want is searched again, which
@@ -16,7 +15,7 @@ CREATE TABLE acquisition_settings (
-- 0 switches the schedule off.
search_interval_hours integer NOT NULL DEFAULT 24
CONSTRAINT acquisition_settings_interval_check CHECK (search_interval_hours BETWEEN 0 AND 720),
-- Lidarr's "Any" profile (note #5527): trash lossy to lossless; unknown off.
-- As Lidarr's "Any" profile: trash lossy to lossless; unknown off.
allowed_qualities text[] NOT NULL DEFAULT ARRAY['lossless_hires', 'lossless', 'lossy_high',
'lossy_standard', 'lossy_low', 'lossy_poor', 'lossy_trash'],
last_pass_at timestamptz,
+1 -1
View File
@@ -1,4 +1,4 @@
-- Import with verification (M502 #5522): a finished download is checked
-- Import with verification: a finished download is checked
-- against the album it was grabbed for before anything reaches the library.
--
-- grabs (columns) how far the download has got, and what the import
@@ -1,6 +1,5 @@
-- Download lifecycle (M502 #5523), ported from Fabled Librarian's System 104:
-- a download that stalls or fails is given up and its album searched again;
-- an imported download seeds, then is removed.
-- Download lifecycle: a download that stalls or fails is given up and its album
-- searched again; an imported download seeds, then is removed.
--
-- download_interventions what was seen wrong with a download or a client,
-- what Minstrel did about it, and whether it
@@ -1,7 +1,7 @@
-- The library's albums go on the wanted list as held, once (M502 #5567,
-- operator 2026-10-10). After that the list is the truth: an album taken
-- off it stays off, and one dropped into the library folder by hand is not
-- added. Re-acquisition fetches back only albums on the list.
-- The library's albums go on the wanted list as held, once. After that the list
-- is the truth: an album taken off it stays off, and one dropped into the
-- library folder by hand is not added. Re-acquisition fetches back only albums
-- on the list.
--
-- wants.reason library: on the list because the library
-- held it when the list began.
@@ -1,10 +1,8 @@
-- Every library album is matched to MusicBrainz before it goes on the list
-- (#5569, operator 2026-10-10: "they should all exist in music brainz we
-- need to use their meta data or a search system to link them up"). The
-- listing in 0085 took only albums whose tags already named their release
-- group and artist; the rest are now matched by the release their tags
-- name, the artist's discography, or a search, and checked against their
-- own tracks.
-- Every library album is matched to MusicBrainz before it goes on the list, so
-- its metadata comes from MusicBrainz. The listing in 0085 took only albums
-- whose tags already named their release group and artist; the rest are now
-- matched by the release their tags name, the artist's discography, or a
-- search, and checked against their own tracks.
--
-- library_album_matches one row per album the listing has decided:
-- listed (with how it was matched) or unmatched
@@ -1,4 +1,4 @@
-- The first matching (#5569) required every track's length to fit as well
-- The first matching (0086) required every track's length to fit as well
-- as its title, and refused singles and near-complete albums the library
-- plainly holds. Matching now takes the titles alone under the album's own
-- name: the albums it refused are matched again.
@@ -1,4 +1,4 @@
-- Which version of the matching (#5569) decided an album. A refusal by an
-- Which version of the matching decided an album. A refusal by an
-- older matcher is tried again by a newer one, so improving the matching
-- reaches the albums it refused without a migration each time.
ALTER TABLE library_album_matches ADD COLUMN matcher integer NOT NULL DEFAULT 0;
@@ -1,4 +1,4 @@
-- The Collection's Unmatched section (#5579): a person matches an album the
-- The Collection's Unmatched section: a person matches an album the
-- listing could not, by a MusicBrainz link or a search (method 'manual'),
-- or leaves it off the list for good (outcome 'dismissed': a local band's
-- album MusicBrainz will never have, a mix that is no album).
+3 -3
View File
@@ -1,11 +1,11 @@
-- Upgrades (M502 #5524): a held album whose copy is below the quality the
-- operator settles for, or that holds a video rip, stays wanted for a better
-- Upgrades: a held album whose copy is below the quality an admin settles
-- for, or that holds a video rip, stays wanted for a better
-- copy. The import replaces the old tracks with the new ones.
--
-- acquisition_settings.upgrade_until the tier a held album is upgraded to;
-- '' switches quality upgrades off.
-- Lidarr's "Any" profile upgraded up to
-- High Quality Lossy (note #5527).
-- High Quality Lossy.
-- wants.upgrade why a held album is wanted again:
-- quality (below upgrade_until) or rip
-- (a track is a video rip); '' not.
@@ -1,4 +1,4 @@
-- Minstrel no longer talks to Lidarr (M502 #5525): it finds, downloads,
-- Minstrel does not talk to Lidarr: it finds, downloads,
-- checks and files music itself.
--
-- lidarr_config the connection's settings: dropped.
@@ -1,4 +1,4 @@
-- The admin Overview (M504) reads plays across every user by time: the ones
-- The admin Overview reads plays across every user by time: the ones
-- in progress and the last day's count. The only index on play_events leads
-- with user_id, so without this every Overview load scanned the table.
CREATE INDEX play_events_started_idx ON play_events (started_at);
@@ -1,4 +1,4 @@
-- The library's folders, set in Settings (#6129). Until someone saves them
-- The library's folders, set in Settings. Until someone saves them
-- there, scan_paths is NULL and MINSTREL_LIBRARY_SCAN_PATHS names the folders,
-- as it always has; from the first save the row is what the server reads.
CREATE TABLE library_settings (
@@ -1,11 +1,11 @@
-- 0094_duplicate_distinct.up.sql — duplicates settle themselves (Scribe
-- milestone #498, operator 2026-10-19: "this needs to be automated away").
-- 0094_duplicate_distinct.up.sql — duplicates settle themselves, without
-- waiting on an admin.
--
-- distinct: one album holds two versions of a song that sound alike ("Lovesick"
-- and "Lovesick (Instrumental)"). They are not duplicates, and the resolver
-- dismisses them itself. mismatch now also covers one album holding the same
-- audio in several track slots, the resolver's wrong-file case.
-- Rule 36: the new value swaps the constraint in the same migration.
-- The new value swaps the constraint in the same migration.
ALTER TABLE duplicate_groups DROP CONSTRAINT duplicate_groups_class_check;
ALTER TABLE duplicate_groups ADD CONSTRAINT duplicate_groups_class_check
CHECK (class IN ('same_release', 'cross_release', 'mismatch', 'distinct', 'review'));
@@ -1,12 +1,12 @@
-- 0095_tracks_emptied_at.up.sql — a slot the duplicate resolver emptied
-- waits for a copy fetched again (Scribe milestone #498).
-- waits for a copy fetched again.
--
-- When the resolver removes a file filed under another song's title and the
-- album holds no copy of that song as long as the song is, the row is marked
-- missing and stamped here with the same time. Missing-pair folding then pairs
-- it by title only with a track added after that time, a copy fetched again,
-- and not with one already on the album: on 2026-10-20 an emptied "Open the
-- Gateway" was folded into a file tagged so that holds another song.
-- and not with one already on the album, which under that title holds
-- another song.
--
-- The stamp counts only while it equals missing_since: a row whose file came
-- back and later went missing again is an ordinary missing row.
@@ -1,7 +1,7 @@
-- #6177: ListenBrainz similarity scores were stored raw. They are counts (16 to
-- 1445 for recordings, 16 to 10,875 for artists on the operator's library), and
-- every reader treats similarity as 0 to 1. Rescale the stored rows relative to
-- each seed's best score, as the worker now writes them.
-- Before: ListenBrainz similarity scores were stored raw. They are counts, into
-- the thousands, and every reader treats similarity as 0 to 1. After: the
-- stored rows are rescaled relative to each seed's best score, as the worker
-- now writes them.
--
-- A seed's best is taken over everything stored for it: the cached LB answer
-- and its edges for tracks, matched and unmatched rows for artists. A seed
@@ -1,8 +1,8 @@
-- #6178: the image's fpcalc exits 3 ("Error decoding audio frame (End of
-- file)") on every file shorter than the span it is asked for, so every track
-- under two minutes failed both prints. The failure was stored as a verdict,
-- and a verdict is not retried until the file changes. fpcalcReadToEnd now
-- keeps those prints; clear the failures so the workers take them again.
-- The image's fpcalc exits 3 ("Error decoding audio frame (End of file)") on
-- every file shorter than the span it is asked for, so every track under two
-- minutes failed both prints. Before: the failure was stored as a verdict, and
-- a verdict is not retried until the file changes. After: fpcalcReadToEnd keeps
-- those prints; clear the failures so the workers take them again.
-- The AcoustID lookup's queue takes a track with no lookup row.
DELETE FROM track_acoustid_lookups
@@ -1,4 +1,4 @@
-- #6176: gone, a release its download client would not let Minstrel follow:
-- gone: a release its download client would not let Minstrel follow:
-- held outside Minstrel's category, or leaving the client twice. Held back
-- until expires_at, as stalled is.
ALTER TABLE release_blocks DROP CONSTRAINT release_blocks_reason_check;