feat: enhance discovery, vibe sessions, and library enrichment
This commit is contained in:
@@ -0,0 +1,22 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { MIGRATIONS } from './migrations.js';
|
||||
|
||||
describe('track release-date migration', () => {
|
||||
const migration = MIGRATIONS.find(
|
||||
({ id }) => id === '20260801_track_release_dates_from_albums',
|
||||
);
|
||||
|
||||
it('backfills from albums with a null-safe, deterministic conflict rule', () => {
|
||||
expect(migration).toBeDefined();
|
||||
expect(migration!.sql).toContain('ALTER TABLE albums ADD COLUMN IF NOT EXISTS release_date DATE');
|
||||
expect(migration!.sql).toContain('UPDATE tracks AS t');
|
||||
expect(migration!.sql).toContain('SET release_date = al.release_date');
|
||||
expect(migration!.sql).toContain('t.release_date IS DISTINCT FROM al.release_date');
|
||||
});
|
||||
|
||||
it('keeps new tracks and album metadata updates synchronized', () => {
|
||||
expect(migration!.sql).toContain('BEFORE INSERT OR UPDATE OF album_id, release_date ON tracks');
|
||||
expect(migration!.sql).toContain('AFTER UPDATE OF release_date ON albums');
|
||||
expect(migration!.sql).toContain('WHEN (OLD.release_date IS DISTINCT FROM NEW.release_date)');
|
||||
});
|
||||
});
|
||||
@@ -510,4 +510,138 @@ export const MIGRATIONS: Migration[] = [
|
||||
ON pending_file_deletions(requested_at);
|
||||
`,
|
||||
},
|
||||
{
|
||||
// System E originally wrote graph_exploration claims without registering it
|
||||
// in source_trust, so the FK rejected every graph walk. It also lacked a
|
||||
// durable link from an acquired candidate to the track the scanner created.
|
||||
id: '20260801_acquisition_pipeline',
|
||||
sql: `
|
||||
INSERT INTO source_trust (key, trust, description)
|
||||
VALUES ('graph_exploration', 0.40,
|
||||
'Muzick graph traversal provenance for discovery candidates.')
|
||||
ON CONFLICT (key) DO NOTHING;
|
||||
|
||||
ALTER TABLE discovery_candidates
|
||||
ADD COLUMN IF NOT EXISTS acquired_track_id UUID
|
||||
REFERENCES tracks(id) ON DELETE SET NULL;
|
||||
ALTER TABLE discovery_candidates
|
||||
ADD COLUMN IF NOT EXISTS acquired_at TIMESTAMPTZ;
|
||||
ALTER TABLE discovery_candidates
|
||||
ADD COLUMN IF NOT EXISTS acquisition_attempts INTEGER NOT NULL DEFAULT 0;
|
||||
ALTER TABLE discovery_candidates
|
||||
ADD COLUMN IF NOT EXISTS last_error TEXT;
|
||||
CREATE INDEX IF NOT EXISTS idx_discovery_candidates_status
|
||||
ON discovery_candidates (status, first_seen_at);
|
||||
`,
|
||||
},
|
||||
{
|
||||
// Separate portrait fetching from structural metadata so an operator can
|
||||
// re-enrich missing artist images without implicitly enabling MB writes.
|
||||
id: '20260801_artist_image_enrichment_toggle',
|
||||
sql: `
|
||||
INSERT INTO settings (key, value)
|
||||
VALUES ('enrich_artist_images', 'false')
|
||||
ON CONFLICT (key) DO NOTHING;
|
||||
`,
|
||||
},
|
||||
{
|
||||
// Audio analysis v1 stored an arbitrary linear energy scale, which made
|
||||
// nearly every mastered track look maximally energetic. Track the analysis
|
||||
// contract and source hash so bounded background jobs can safely refresh
|
||||
// stale results without repeatedly decoding unchanged files.
|
||||
id: '20260801_audio_analysis_v2',
|
||||
sql: `
|
||||
ALTER TABLE track_audio_features
|
||||
ADD COLUMN IF NOT EXISTS analysis_version SMALLINT NOT NULL DEFAULT 0;
|
||||
ALTER TABLE track_audio_features
|
||||
ADD COLUMN IF NOT EXISTS source_hash TEXT;
|
||||
ALTER TABLE track_audio_features
|
||||
ADD COLUMN IF NOT EXISTS analyzed_at TIMESTAMPTZ;
|
||||
|
||||
DO $mig$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint
|
||||
WHERE conrelid = 'track_audio_features'::regclass
|
||||
AND conname = 'track_audio_features_bpm_range'
|
||||
) THEN
|
||||
ALTER TABLE track_audio_features
|
||||
ADD CONSTRAINT track_audio_features_bpm_range
|
||||
CHECK (bpm IS NULL OR (bpm >= 30 AND bpm <= 300)) NOT VALID;
|
||||
END IF;
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint
|
||||
WHERE conrelid = 'track_audio_features'::regclass
|
||||
AND conname = 'track_audio_features_energy_range'
|
||||
) THEN
|
||||
ALTER TABLE track_audio_features
|
||||
ADD CONSTRAINT track_audio_features_energy_range
|
||||
CHECK (energy IS NULL OR (energy >= 0 AND energy <= 1)) NOT VALID;
|
||||
END IF;
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint
|
||||
WHERE conrelid = 'track_audio_features'::regclass
|
||||
AND conname = 'track_audio_features_danceability_range'
|
||||
) THEN
|
||||
ALTER TABLE track_audio_features
|
||||
ADD CONSTRAINT track_audio_features_danceability_range
|
||||
CHECK (danceability IS NULL OR (danceability >= 0 AND danceability <= 1)) NOT VALID;
|
||||
END IF;
|
||||
END $mig$;
|
||||
`,
|
||||
},
|
||||
{
|
||||
// tracks.release_date is a denormalised copy of the canonical
|
||||
// MusicBrainz release-group date on albums. Before this migration the
|
||||
// worker populated albums only, leaving every historical track invisible
|
||||
// to the novelty generator. The backfill and triggers make the canonical
|
||||
// album value win deterministically for both old and future rows.
|
||||
id: '20260801_track_release_dates_from_albums',
|
||||
sql: `
|
||||
-- The worker used to create this column lazily, but migration execution
|
||||
-- must not rely on worker startup order.
|
||||
ALTER TABLE albums ADD COLUMN IF NOT EXISTS release_date DATE;
|
||||
|
||||
-- Backfill only mismatches. IS DISTINCT FROM is null-safe and makes the
|
||||
-- statement safe to rerun manually for diagnostics/recovery.
|
||||
UPDATE tracks AS t
|
||||
SET release_date = al.release_date
|
||||
FROM albums AS al
|
||||
WHERE t.album_id = al.id
|
||||
AND t.release_date IS DISTINCT FROM al.release_date;
|
||||
|
||||
CREATE OR REPLACE FUNCTION sync_track_release_date_from_album()
|
||||
RETURNS TRIGGER AS $mig$
|
||||
BEGIN
|
||||
SELECT release_date INTO NEW.release_date
|
||||
FROM albums
|
||||
WHERE id = NEW.album_id;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$mig$ LANGUAGE plpgsql;
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_tracks_sync_release_date ON tracks;
|
||||
CREATE TRIGGER trg_tracks_sync_release_date
|
||||
BEFORE INSERT OR UPDATE OF album_id, release_date ON tracks
|
||||
FOR EACH ROW EXECUTE FUNCTION sync_track_release_date_from_album();
|
||||
|
||||
CREATE OR REPLACE FUNCTION propagate_album_release_date_to_tracks()
|
||||
RETURNS TRIGGER AS $mig$
|
||||
BEGIN
|
||||
UPDATE tracks
|
||||
SET release_date = NEW.release_date
|
||||
WHERE album_id = NEW.id
|
||||
AND release_date IS DISTINCT FROM NEW.release_date;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$mig$ LANGUAGE plpgsql;
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_albums_propagate_release_date ON albums;
|
||||
CREATE TRIGGER trg_albums_propagate_release_date
|
||||
AFTER UPDATE OF release_date ON albums
|
||||
FOR EACH ROW
|
||||
WHEN (OLD.release_date IS DISTINCT FROM NEW.release_date)
|
||||
EXECUTE FUNCTION propagate_album_release_date_to_tracks();
|
||||
`,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -130,6 +130,17 @@ CREATE TABLE IF NOT EXISTS tracks (
|
||||
-- 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).
|
||||
|
||||
-- `albums.release_date` was introduced after the original albums table. It
|
||||
-- must exist before the track synchronisation trigger below is compiled.
|
||||
DO $$ BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name = 'albums' AND column_name = 'release_date'
|
||||
) THEN
|
||||
ALTER TABLE albums ADD COLUMN release_date DATE;
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
DO $$ BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
@@ -140,6 +151,49 @@ DO $$ BEGIN
|
||||
END $$;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_tracks_release_date ON tracks (release_date) WHERE release_date IS NOT NULL;
|
||||
|
||||
-- An album's MusicBrainz first-release-date is the canonical date for every
|
||||
-- track on that album. Keep the denormalised tracks.release_date column in
|
||||
-- lockstep so date-based recommendation queries stay indexable and never have
|
||||
-- to guess which of two conflicting values is authoritative.
|
||||
--
|
||||
-- The trigger intentionally also overwrites a direct tracks.release_date
|
||||
-- update. There is no per-recording release-date provenance in this schema;
|
||||
-- accepting an independent track value would silently make novelty results
|
||||
-- depend on write order. A future per-recording metadata source needs its own
|
||||
-- canonical/provenance column before changing this rule.
|
||||
CREATE OR REPLACE FUNCTION sync_track_release_date_from_album()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
SELECT release_date INTO NEW.release_date
|
||||
FROM albums
|
||||
WHERE id = NEW.album_id;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_tracks_sync_release_date ON tracks;
|
||||
CREATE TRIGGER trg_tracks_sync_release_date
|
||||
BEFORE INSERT OR UPDATE OF album_id, release_date ON tracks
|
||||
FOR EACH ROW EXECUTE FUNCTION sync_track_release_date_from_album();
|
||||
|
||||
CREATE OR REPLACE FUNCTION propagate_album_release_date_to_tracks()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
UPDATE tracks
|
||||
SET release_date = NEW.release_date
|
||||
WHERE album_id = NEW.id
|
||||
AND release_date IS DISTINCT FROM NEW.release_date;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_albums_propagate_release_date ON albums;
|
||||
CREATE TRIGGER trg_albums_propagate_release_date
|
||||
AFTER UPDATE OF release_date ON albums
|
||||
FOR EACH ROW
|
||||
WHEN (OLD.release_date IS DISTINCT FROM NEW.release_date)
|
||||
EXECUTE FUNCTION propagate_album_release_date_to_tracks();
|
||||
DO $$ BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
@@ -306,12 +360,20 @@ CREATE TABLE IF NOT EXISTS track_audio_features (
|
||||
key TEXT,
|
||||
energy REAL,
|
||||
danceability REAL,
|
||||
-- Reserved legacy columns: Vibe readers tolerate these as NULL. The local
|
||||
-- analyzer intentionally does not claim to infer them.
|
||||
valence REAL,
|
||||
acousticness REAL,
|
||||
instrumentalness REAL,
|
||||
liveness REAL,
|
||||
valence_score REAL,
|
||||
tempo REAL
|
||||
tempo REAL,
|
||||
analysis_version SMALLINT NOT NULL DEFAULT 0,
|
||||
source_hash TEXT,
|
||||
analyzed_at TIMESTAMPTZ,
|
||||
CONSTRAINT track_audio_features_bpm_range CHECK (bpm IS NULL OR (bpm >= 30 AND bpm <= 300)),
|
||||
CONSTRAINT track_audio_features_energy_range CHECK (energy IS NULL OR (energy >= 0 AND energy <= 1)),
|
||||
CONSTRAINT track_audio_features_danceability_range CHECK (danceability IS NULL OR (danceability >= 0 AND danceability <= 1))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS track_lyrics (
|
||||
@@ -331,6 +393,10 @@ CREATE TABLE IF NOT EXISTS settings (
|
||||
);
|
||||
|
||||
INSERT INTO settings (key, value) VALUES ('enrich_metadata', 'true') ON CONFLICT (key) DO NOTHING;
|
||||
-- Artist portraits are independent from structural metadata. Keeping this
|
||||
-- separate lets an operator re-fill artwork without re-running MusicBrainz
|
||||
-- canonicalisation across the entire library.
|
||||
INSERT INTO settings (key, value) VALUES ('enrich_artist_images', '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;
|
||||
@@ -355,6 +421,10 @@ INSERT INTO source_trust (key, trust, description) VALUES
|
||||
('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.'),
|
||||
-- A first-party record of how a candidate was reached through the graph.
|
||||
-- This is deliberately separate from Last.fm/MB: it describes the
|
||||
-- traversal strategy, not a claim made by an external provider.
|
||||
('graph_exploration', 0.40, 'Muzick graph traversal provenance for discovery candidates.'),
|
||||
('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;
|
||||
@@ -487,9 +557,19 @@ CREATE TABLE IF NOT EXISTS discovery_candidates (
|
||||
first_seen_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
last_eval_at TIMESTAMPTZ,
|
||||
status TEXT NOT NULL DEFAULT 'candidate',
|
||||
-- Filled only after a worker scanned a successfully acquired file. Keeping
|
||||
-- this FK makes candidate -> local-track provenance auditable and avoids
|
||||
-- guessing from filename metadata later.
|
||||
acquired_track_id UUID REFERENCES tracks(id) ON DELETE SET NULL,
|
||||
acquired_at TIMESTAMPTZ,
|
||||
acquisition_attempts INTEGER NOT NULL DEFAULT 0,
|
||||
last_error TEXT,
|
||||
UNIQUE (source, external_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_discovery_candidates_status
|
||||
ON discovery_candidates (status, first_seen_at);
|
||||
|
||||
-- Probation status for acquired tracks.
|
||||
DO $$ BEGIN
|
||||
IF NOT EXISTS (
|
||||
|
||||
@@ -16,6 +16,8 @@ export interface Album {
|
||||
artist_id: string;
|
||||
title: string;
|
||||
year?: number | null;
|
||||
/** Canonical MusicBrainz release-group first-release-date (YYYY-MM-DD). */
|
||||
release_date?: string | null;
|
||||
artwork_id?: string | null;
|
||||
}
|
||||
|
||||
@@ -38,6 +40,8 @@ export interface Track {
|
||||
skip_count: number;
|
||||
dislike_count: number;
|
||||
last_played_at?: Date | null;
|
||||
/** Denormalised from albums.release_date by a database trigger. */
|
||||
release_date?: string | null;
|
||||
mtime?: number | null;
|
||||
source_type: string;
|
||||
artists?: TrackArtist[];
|
||||
@@ -136,6 +140,30 @@ export interface ListenerBelief {
|
||||
last_decayed_at: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stable pseudo-entity IDs for audio preference buckets. listener_beliefs uses
|
||||
* UUID entity IDs for every entity type, while audio dimensions are values rather
|
||||
* than rows in their own table. Keeping the IDs fixed makes these beliefs usable
|
||||
* by the planner without introducing a second, unbounded vocabulary.
|
||||
*/
|
||||
export const AUDIO_PREFERENCE_BUCKETS = {
|
||||
energy: {
|
||||
low: '10000000-0000-0000-0000-000000000001',
|
||||
medium: '10000000-0000-0000-0000-000000000002',
|
||||
high: '10000000-0000-0000-0000-000000000003',
|
||||
},
|
||||
bpm: {
|
||||
slow: '10000000-0000-0000-0000-000000000011',
|
||||
medium: '10000000-0000-0000-0000-000000000012',
|
||||
fast: '10000000-0000-0000-0000-000000000013',
|
||||
},
|
||||
valence: {
|
||||
low: '10000000-0000-0000-0000-000000000021',
|
||||
neutral: '10000000-0000-0000-0000-000000000022',
|
||||
high: '10000000-0000-0000-0000-000000000023',
|
||||
},
|
||||
} as const;
|
||||
|
||||
export interface ClaimEdge {
|
||||
subjectType: string;
|
||||
subjectId: string;
|
||||
|
||||
Reference in New Issue
Block a user