initial state: muzick music player + recommendation engine

This commit is contained in:
kami
2026-07-14 01:35:52 +04:00
commit 737bf19fd1
196 changed files with 32431 additions and 0 deletions
+490
View File
@@ -0,0 +1,490 @@
-- Enums
-- Guarded so this file is idempotent and can be re-applied on every backend boot
-- (CREATE TYPE has no IF NOT EXISTS; swallow the duplicate_object error instead).
DO $$ BEGIN
CREATE TYPE track_state AS ENUM ('LIBRARY', 'RECOMMENDED', 'HIDDEN', 'MISSING', 'DELETED');
EXCEPTION WHEN duplicate_object THEN null; END $$;
DO $$ BEGIN
CREATE TYPE track_source_type AS ENUM ('MANUAL', 'RECOMMENDATION');
EXCEPTION WHEN duplicate_object THEN null; END $$;
DO $$ BEGIN
CREATE TYPE recommendation_status AS ENUM ('ACTIVE', 'RESOLVED', 'FAILED');
EXCEPTION WHEN duplicate_object THEN null; END $$;
DO $$ BEGIN
CREATE TYPE dislike_state AS ENUM ('HIDDEN', 'WARNED', 'DELETED');
EXCEPTION WHEN duplicate_object THEN null; END $$;
-- Reduce an artist string to its PRIMARY (first-billed) artist, so that every
-- form of a collaboration maps to the same canonical identity:
-- "Artist feat. Guest", "Artist ft. Guest", "Artist x Guest",
-- "Artist & Guest", "Artist; Guest", "Artist, Guest", "Artist / Guest"
-- all map to "Artist". This is the identity used by the normalized_name /
-- normalized_artist generated columns, dedup, and the Vibe engine. The full
-- list of co-billed artists is preserved separately in the track_artists table
-- (populated by the scanner and the split-collab-artists migration); this
-- function intentionally only yields the main artist.
-- Also handles parenthesized feature forms like "(feat. X)".
CREATE OR REPLACE FUNCTION normalize_artist(artist TEXT) RETURNS TEXT AS $$
DECLARE
result TEXT;
BEGIN
-- 1. Strip feat/ft/vs/x feature suffixes (+ everything after them), incl.
-- parenthesized forms like "(feat. X)" / "(Feat. X)".
result := REGEXP_REPLACE(
artist,
'\s*\(?\s*([fF]eat(uring)?\.?|[fF]t\.?|[vV]s\.?|[xX])\s+.*$',
''
);
-- 2. Cut at the first collaboration separator ( ; & / , ) and keep the part
-- before it: "$bunny, Metox" -> "$bunny", "Booker & ЗАМАЙ" -> "Booker".
result := REGEXP_REPLACE(result, '\s*[;&/,].*$', '');
result := BTRIM(result);
RETURN result;
END;
$$ LANGUAGE plpgsql IMMUTABLE STRICT;
-- Tables
CREATE TABLE IF NOT EXISTS artists (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
-- Canonical display name (e.g., "P!nk" not "Pink")
canonical_name TEXT NOT NULL,
-- Sort name for alphabetical ordering (e.g., "Pink, P!" or "Beatles, The")
sort_name TEXT,
-- MusicBrainz ID: the canonical identity. NULL for artists not in MB.
-- Unique when present so we never create duplicate MB artists.
mbid UUID UNIQUE,
-- Fallback: legacy name used before MBID resolution.
-- Not unique; multiple rows can have same name before dedup.
name TEXT NOT NULL,
normalized_name TEXT
GENERATED ALWAYS AS (normalize_artist(name)) STORED,
discogs_id TEXT,
image_path TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_artists_mbid ON artists(mbid) WHERE mbid IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_artists_normalized_name ON artists(normalized_name);
-- Artist aliases: alternative names for the same artist.
-- Enables matching "Pink", "P!nk", "PINK" to the same artist_id.
CREATE TABLE IF NOT EXISTS artist_aliases (
artist_id UUID NOT NULL REFERENCES artists(id) ON DELETE CASCADE,
alias TEXT NOT NULL,
alias_normalized TEXT GENERATED ALWAYS AS (normalize_artist(alias)) STORED,
PRIMARY KEY (artist_id, alias)
);
CREATE INDEX IF NOT EXISTS idx_artist_aliases_normalized ON artist_aliases(alias_normalized);
-- Artist lookup cache: avoids repeated MusicBrainz queries.
-- Keyed by normalized artist name.
CREATE TABLE IF NOT EXISTS artist_lookup_cache (
normalized_name TEXT PRIMARY KEY,
mbid UUID,
canonical_name TEXT,
sort_name TEXT,
fetched_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
-- Track if we looked up and found nothing (negative cache)
not_found BOOLEAN DEFAULT FALSE
);
CREATE TABLE IF NOT EXISTS albums (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
artist_id UUID REFERENCES artists(id) ON DELETE CASCADE,
title TEXT NOT NULL,
year INTEGER,
-- Full release date from MusicBrainz first-release-date (YYYY-MM-DD).
-- More precise than `year` (which can also come from Discogs). Used as a
-- deterministic tiebreaker in album dedup (earlier release = keeper).
release_date DATE,
artwork_id TEXT,
mbid UUID UNIQUE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(artist_id, title)
);
CREATE INDEX IF NOT EXISTS idx_albums_mbid ON albums(mbid) WHERE mbid IS NOT NULL;
CREATE TABLE IF NOT EXISTS tracks (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
path TEXT UNIQUE NOT NULL,
hash TEXT NOT NULL,
title TEXT NOT NULL,
artist TEXT NOT NULL,
album_id UUID REFERENCES albums(id) ON DELETE CASCADE,
duration REAL NOT NULL,
state track_state DEFAULT 'LIBRARY',
play_count INTEGER DEFAULT 0,
skip_count INTEGER DEFAULT 0,
dislike_count INTEGER DEFAULT 0,
last_played_at TIMESTAMP,
mtime REAL,
source_type track_source_type DEFAULT 'MANUAL',
quarantined_at TIMESTAMP,
deleted_at TIMESTAMP,
release_date DATE
);
-- Add normalized columns as generated columns for existing databases where the
-- CREATE TABLE IF NOT EXISTS above was a no-op (column didn't exist before).
DO $$ BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'tracks' AND column_name = 'release_date'
) THEN
ALTER TABLE tracks ADD COLUMN release_date DATE;
END IF;
END $$;
CREATE INDEX IF NOT EXISTS idx_tracks_release_date ON tracks (release_date) WHERE release_date IS NOT NULL;
DO $$ BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'tracks' AND column_name = 'normalized_artist'
) THEN
ALTER TABLE tracks ADD COLUMN normalized_artist TEXT
GENERATED ALWAYS AS (normalize_artist(artist)) STORED;
END IF;
END $$;
DO $$ BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'artists' AND column_name = 'normalized_name'
) THEN
ALTER TABLE artists ADD COLUMN normalized_name TEXT
GENERATED ALWAYS AS (normalize_artist(name)) STORED;
END IF;
END $$;
CREATE INDEX IF NOT EXISTS idx_tracks_hash ON tracks(hash);
CREATE INDEX IF NOT EXISTS idx_tracks_normalized_artist ON tracks(normalized_artist);
CREATE INDEX IF NOT EXISTS idx_artists_normalized_name ON artists(normalized_name);
-- Tracks integrity issues found by the periodic integrity-sweep worker.
-- Created with IF NOT EXISTS so the worker can self-provision this table at
-- runtime on databases that predate this schema change (see IntegrityService.ensureSchema).
CREATE TABLE IF NOT EXISTS track_integrity_issues (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
track_id UUID REFERENCES tracks(id) ON DELETE CASCADE,
issue_type TEXT NOT NULL, -- 'CORRUPT_METADATA' | 'MISSING_FILE'
status TEXT NOT NULL DEFAULT 'OPEN', -- 'OPEN' | 'FIXED' | 'NEEDS_REVIEW'
details TEXT, -- human-readable: e.g. the corrupted value
detected_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
resolved_at TIMESTAMP,
UNIQUE(track_id, issue_type)
);
-- Last.fm artist similarity, feeds Vibe discovery. Populated by the worker's
-- `artist_similarity` job (see EnrichmentService.refreshArtistSimilarity).
-- Created with IF NOT EXISTS so the worker can self-provision this table at
-- runtime on databases that predate this schema change.
--
-- mbid storage decision: artists.mbid is typed UUID and MusicBrainz MBIDs are
-- themselves UUID-format strings, so the worker stores the artist MBID directly
-- in the existing artists.mbid column (guarded with a UUID-shape check before
-- the write). No mbid_text column was needed.
CREATE TABLE IF NOT EXISTS artist_similar (
artist_id UUID REFERENCES artists(id) ON DELETE CASCADE,
similar_name TEXT NOT NULL,
match REAL NOT NULL DEFAULT 0,
fetched_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (artist_id, similar_name)
);
CREATE TABLE IF NOT EXISTS genre (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL UNIQUE,
parent_id UUID REFERENCES genre(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS track_genre (
track_id UUID REFERENCES tracks(id) ON DELETE CASCADE,
genre_id UUID REFERENCES genre(id) ON DELETE CASCADE,
weight DECIMAL NOT NULL DEFAULT 1.0,
PRIMARY KEY (track_id, genre_id)
);
CREATE TABLE IF NOT EXISTS dislikes (
track_id UUID PRIMARY KEY REFERENCES tracks(id) ON DELETE CASCADE,
disliked_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
warned_at TIMESTAMP,
deleted_at TIMESTAMP,
grace_hours INTEGER DEFAULT 48,
state dislike_state DEFAULT 'HIDDEN'
);
CREATE TABLE IF NOT EXISTS favorites (
user_id UUID NOT NULL,
track_id UUID PRIMARY KEY REFERENCES tracks(id) ON DELETE CASCADE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS recommendation_batch (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL,
status recommendation_status DEFAULT 'ACTIVE',
last_interaction_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
seed_track_id UUID REFERENCES tracks(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS recommendation_batch_track (
batch_id UUID REFERENCES recommendation_batch(id) ON DELETE CASCADE,
track_id UUID REFERENCES tracks(id) ON DELETE CASCADE,
PRIMARY KEY (batch_id, track_id)
);
-- Play history: one row per playback event. Feeds the Vibe engine's
-- "Success-Driven Center" rule (a completed play moves the active batch's center)
-- and the feedback learning loop. Created with IF NOT EXISTS so it can be
-- self-provisioned on databases that predate this schema change.
CREATE TABLE IF NOT EXISTS play_history (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL,
track_id UUID REFERENCES tracks(id) ON DELETE CASCADE,
batch_id UUID REFERENCES recommendation_batch(id) ON DELETE SET NULL,
completed BOOLEAN NOT NULL DEFAULT false,
played_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_play_history_user_played_at ON play_history(user_id, played_at DESC);
-- Feedback: explicit user signals consumed by the Vibe scorer's feedback
-- learning loop. action is one of 'promoted' | 'disliked' | 'skipped' | 'deleted_permanent'.
CREATE TABLE IF NOT EXISTS feedback (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL,
track_id UUID REFERENCES tracks(id) ON DELETE CASCADE,
action TEXT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_feedback_user_action ON feedback(user_id, action);
CREATE TABLE IF NOT EXISTS track_audio_features (
track_id UUID PRIMARY KEY REFERENCES tracks(id) ON DELETE CASCADE,
bpm REAL,
key TEXT,
energy REAL,
danceability REAL,
valence REAL,
acousticness REAL,
instrumentalness REAL,
liveness REAL,
valence_score REAL,
tempo REAL
);
CREATE TABLE IF NOT EXISTS track_lyrics (
track_id UUID PRIMARY KEY REFERENCES tracks(id) ON DELETE CASCADE,
lyrics_text TEXT,
provider TEXT,
language VARCHAR(10),
synced_lyrics JSONB
);
-- Enrichment settings: toggles that control which external-enrichment steps the
-- worker runs. Default all to true (best-effort, credentials-permitting).
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
INSERT INTO settings (key, value) VALUES ('enrich_metadata', 'true') ON CONFLICT (key) DO NOTHING;
INSERT INTO settings (key, value) VALUES ('enrich_cover_art', 'true') ON CONFLICT (key) DO NOTHING;
INSERT INTO settings (key, value) VALUES ('enrich_genres', 'true') ON CONFLICT (key) DO NOTHING;
INSERT INTO settings (key, value) VALUES ('enrich_lyrics', 'true') ON CONFLICT (key) DO NOTHING;
INSERT INTO settings (key, value) VALUES ('enrich_artist_similarity', 'true') ON CONFLICT (key) DO NOTHING;
INSERT INTO settings (key, value) VALUES ('enrich_audio_analysis', 'false') ON CONFLICT (key) DO NOTHING;
-- ==========================================================================
-- v2 Recommendation Engine — System A: Knowledge Graph (probabilistic fusion)
-- ==========================================================================
-- Source trust weights. One row per source of claims. Tunable.
CREATE TABLE IF NOT EXISTS source_trust (
key TEXT PRIMARY KEY,
trust REAL NOT NULL CHECK (trust >= 0 AND trust <= 1.0),
description TEXT NOT NULL
);
INSERT INTO source_trust (key, trust, description) VALUES
('curated', 1.00, 'Manual / human-curated claim. Never decayed.'),
('mb', 0.90, 'MusicBrainz structural spine. High-trust seed; not infallible.'),
('cover_art_archive', 0.85, 'Cover Art Archive, MB-backed.'),
('discogs', 0.75, 'Discogs release/artist credits.'),
('lastfm', 0.50, 'Last.fm tags + similar. Noisy; used as weak signal.'),
('listener_behavior', 0.40, 'Derived from observed play patterns. User-keyed.'),
('tag', 0.30, 'File-tag-derived via scanner heuristic. Lowest trust.')
ON CONFLICT (key) DO NOTHING;
-- Claims: the spine of the graph. One row per (subject, predicate, object, source).
-- user_id is NULL for objective claims (MB, Discogs, tags), non-NULL for
-- listener-behavior-derived claims.
CREATE TABLE IF NOT EXISTS claims (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID,
subject_type TEXT NOT NULL,
subject_id UUID NOT NULL,
predicate TEXT NOT NULL,
object_type TEXT NOT NULL,
object_id UUID NOT NULL,
source TEXT NOT NULL REFERENCES source_trust(key),
confidence REAL NOT NULL DEFAULT 1.0 CHECK (confidence >= 0 AND confidence <= 1.0),
evidence_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
last_reinforced_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
raw JSONB,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (subject_type, subject_id, predicate, object_type, object_id, source, user_id)
);
CREATE INDEX IF NOT EXISTS idx_claims_subject ON claims (subject_type, subject_id, predicate);
CREATE INDEX IF NOT EXISTS idx_claims_object ON claims (object_type, object_id, predicate);
CREATE INDEX IF NOT EXISTS idx_claims_user ON claims (user_id) WHERE user_id IS NOT NULL;
-- recording_mbid on tracks — the structural spine anchor for the graph
DO $$ BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'tracks' AND column_name = 'recording_mbid'
) THEN
ALTER TABLE tracks ADD COLUMN recording_mbid UUID;
END IF;
END $$;
CREATE INDEX IF NOT EXISTS idx_tracks_recording_mbid ON tracks (recording_mbid) WHERE recording_mbid IS NOT NULL;
-- ==========================================================================
-- System B: Listener Model
-- ==========================================================================
-- Evidence: every observed interaction that should influence a belief.
-- Append-only. Never edited or deleted (purge policy separate).
CREATE TABLE IF NOT EXISTS evidence (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL,
entity_type TEXT NOT NULL,
entity_id UUID NOT NULL,
signal TEXT NOT NULL,
profile TEXT NOT NULL,
weight REAL NOT NULL,
context JSONB,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_evidence_user_entity ON evidence (user_id, entity_type, entity_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_evidence_user_profile ON evidence (user_id, profile, created_at DESC);
-- Listener beliefs: the derived state. Continuously decayed; reinforced by evidence.
CREATE TABLE IF NOT EXISTS listener_beliefs (
user_id UUID NOT NULL,
profile TEXT NOT NULL,
entity_type TEXT NOT NULL,
entity_id UUID NOT NULL,
dimension TEXT NOT NULL,
value REAL NOT NULL CHECK (value >= -1.0 AND value <= 1.0),
confidence REAL NOT NULL CHECK (confidence >= 0 AND confidence <= 1.0),
evidence_count INTEGER NOT NULL DEFAULT 0,
last_reinforced_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
last_decayed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (user_id, profile, entity_type, entity_id, dimension)
);
CREATE INDEX IF NOT EXISTS idx_listener_beliefs_user_profile ON listener_beliefs (user_id, profile, entity_type, entity_id);
-- ==========================================================================
-- System D: Session Director
-- ==========================================================================
-- Per-session state; persisted across heartbeats so resumes stay coherent.
CREATE TABLE IF NOT EXISTS session_state (
session_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL,
started_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
last_interaction TIMESTAMPTZ NOT NULL DEFAULT NOW(),
context TEXT,
state_vector JSONB NOT NULL DEFAULT '{}'::jsonb
);
CREATE INDEX IF NOT EXISTS idx_session_state_user ON session_state (user_id, last_interaction DESC);
-- Diversity budgets for the session director's planner.
CREATE TABLE IF NOT EXISTS diversity_budgets (
user_id UUID NOT NULL,
dimension TEXT NOT NULL,
budget_share REAL NOT NULL,
horizon_min INTEGER NOT NULL,
PRIMARY KEY (user_id, dimension, horizon_min)
);
-- Adaptive minimum-distance repetition rules.
CREATE TABLE IF NOT EXISTS repetition_rules (
user_id UUID NOT NULL,
dimension TEXT NOT NULL,
min_distance INTEGER NOT NULL,
PRIMARY KEY (user_id, dimension)
);
-- ==========================================================================
-- System E: Acquisition Pipeline
-- ==========================================================================
-- Discovery candidates: tracks not yet in the library, identified by E.
CREATE TABLE IF NOT EXISTS discovery_candidates (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
source TEXT NOT NULL,
external_id TEXT NOT NULL,
title TEXT,
artist_credit JSONB,
notes JSONB,
first_seen_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
last_eval_at TIMESTAMPTZ,
status TEXT NOT NULL DEFAULT 'candidate',
UNIQUE (source, external_id)
);
-- Probation status for acquired tracks.
DO $$ BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'tracks' AND column_name = 'probation_status'
) THEN
ALTER TABLE tracks ADD COLUMN probation_status TEXT
DEFAULT 'retained'
CHECK (probation_status IN ('probation', 'retained', 'retired'));
END IF;
END $$;
DO $$ BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'tracks' AND column_name = 'probation_entered_at'
) THEN
ALTER TABLE tracks ADD COLUMN probation_entered_at TIMESTAMPTZ;
END IF;
END $$;
CREATE INDEX IF NOT EXISTS idx_tracks_probation ON tracks (probation_status) WHERE probation_status = 'probation';
-- ==========================================================================
-- Phase 4 (preserved): Image candidates
-- ==========================================================================
CREATE TABLE IF NOT EXISTS image_candidates (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
entity_type TEXT NOT NULL CHECK (entity_type IN ('artist', 'album')),
entity_id UUID NOT NULL,
source TEXT NOT NULL,
url TEXT,
width INTEGER,
verified BOOLEAN DEFAULT FALSE,
fetched_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (entity_type, entity_id, source)
);
CREATE INDEX IF NOT EXISTS idx_image_candidates_entity ON image_candidates (entity_type, entity_id);