Compare commits
40 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 57120b872d | |||
| a75d36b821 | |||
| 5a019bd35c | |||
| 0a01085ed0 | |||
| d28a92803b | |||
| 60085c1d72 | |||
| 5a73a6a6f3 | |||
| 0749f6ad10 | |||
| 78f5feea11 | |||
| 93c737ee49 | |||
| dea08f9c47 | |||
| a88ae62db1 | |||
| 85ca9cf543 | |||
| 4ead344aec | |||
| bfe22745bc | |||
| d371bd97f3 | |||
| 93619824d8 | |||
| a7d126787f | |||
| 8f33744f8c | |||
| ce16bb94f8 | |||
| c0a3eeee4b | |||
| 68bf299296 | |||
| 03746a3899 | |||
| 092a43b981 | |||
| ec141b32f4 | |||
| 1f44af5893 | |||
| 6b40cf7a9c | |||
| dfb8ed6f28 | |||
| 9eba247a58 | |||
| 61a1373ca9 | |||
| fe13798c99 | |||
| 89a23e3703 | |||
| 57df1cfe9f | |||
| 51ef7c84db | |||
| 515cab2f89 | |||
| 3641ec9e8e | |||
| 5378af6af1 | |||
| e62b7e8d10 | |||
| 4c48d11e9d | |||
| a0c9f42a89 |
@@ -28,7 +28,9 @@ npm run build # vite build
|
||||
npm run typecheck
|
||||
|
||||
# whole stack
|
||||
docker-compose up -d --build
|
||||
# Use `docker compose` (v2). The old `docker-compose` v1 binary on this machine
|
||||
# crashes with KeyError: 'ContainerConfig' when recreating a container.
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
There is no lint step. `typecheck` is the gate; the `prebuild` hook fails the build on type errors.
|
||||
|
||||
@@ -39,6 +39,11 @@ A high-performance, distributed music orchestration and recommendation platform.
|
||||
- Docker & Docker Compose
|
||||
|
||||
### Running Locally
|
||||
|
||||
Vibe uses the same per-user identity convention as the rest of the API:
|
||||
`x-user-id` when supplied, otherwise the local default user. Each user's Vibe
|
||||
session and listening history are isolated from other users.
|
||||
|
||||
```bash
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
+56
-3
@@ -14,9 +14,17 @@ import quarantineRoutes from './routes/quarantine.routes.js';
|
||||
import settingsRoutes from './routes/settings.routes.js';
|
||||
import graphRoutes from './routes/graph.routes.js';
|
||||
import { SessionDirector } from './services/session-director.service.js';
|
||||
import v2Routes from './routes/v2.routes.js';
|
||||
import vibeSessionsRoutes from './routes/vibe-sessions.routes.js';
|
||||
import discoveryRoutes from './routes/discovery.routes.js';
|
||||
import imagesRoutes from './routes/images.routes.js';
|
||||
import { VibeSessionCoordinator } from './services/vibe-session-coordinator.service.js';
|
||||
import { DiscoveryService } from './services/discovery.service.js';
|
||||
import { PlaybackSyncService } from './services/playback-sync.service.js';
|
||||
import playbackRoutes from './routes/playback.routes.js';
|
||||
|
||||
// Single-listener deployment: the same placeholder the vibe-session routes use
|
||||
// when no x-user-id header is supplied.
|
||||
const DEFAULT_USER_ID = '00000000-0000-0000-0000-000000000000';
|
||||
|
||||
export interface AppConfig {
|
||||
port: number;
|
||||
@@ -90,6 +98,44 @@ export async function buildApp(config: AppConfig) {
|
||||
dbService.decayBeliefs().catch(() => {});
|
||||
dbService.deriveForgottenProfile().catch(() => {});
|
||||
|
||||
// Close the discovery loop without an operator in it. The worker's cron jobs
|
||||
// generate candidates; this timer is what decides which of them earn a
|
||||
// probation slot and enqueues the download. Evaluation is cheap SQL against a
|
||||
// capped batch, and every acquisition gate (relevance, novelty tolerance,
|
||||
// backlog, per-artist diversity) lives inside evalCandidates.
|
||||
const discoveryService = new DiscoveryService(dbService);
|
||||
const DISCOVERY_EVAL_INTERVAL_MS = 30 * 60 * 1000;
|
||||
const runDiscoveryEval = async () => {
|
||||
const results = await discoveryService.evalCandidates(DEFAULT_USER_ID);
|
||||
let enqueued = 0;
|
||||
for (const result of results) {
|
||||
if (!result.shouldAcquire) continue;
|
||||
try {
|
||||
await jobService.enqueueDiscoveryAcquisition(result.candidateId);
|
||||
enqueued++;
|
||||
} catch (err) {
|
||||
const reason = err instanceof Error ? err.message : 'failed to enqueue acquisition';
|
||||
await discoveryService.markEnqueueFailed(result.candidateId, reason);
|
||||
}
|
||||
}
|
||||
if (results.length > 0) {
|
||||
console.log(`[Discovery] Evaluated ${results.length} candidates, enqueued ${enqueued}`);
|
||||
}
|
||||
};
|
||||
const discoveryEvalTimer = setInterval(() => {
|
||||
runDiscoveryEval().catch((e) => console.error('[Discovery] eval failed:', e));
|
||||
}, DISCOVERY_EVAL_INTERVAL_MS);
|
||||
|
||||
// Cross-device playback. A device that dies without closing its stream — a
|
||||
// phone going to sleep, a laptop lid — leaves the session owned by something
|
||||
// that will never play again, so a sweep frees ownership once its heartbeat
|
||||
// has been silent past the staleness window.
|
||||
const playbackSync = new PlaybackSyncService(pgPool);
|
||||
const DEVICE_REAP_INTERVAL_MS = 60 * 1000;
|
||||
const deviceReapTimer = setInterval(() => {
|
||||
playbackSync.reapStaleDevices().catch((e) => console.error('[Playback] device reap failed:', e));
|
||||
}, DEVICE_REAP_INTERVAL_MS);
|
||||
|
||||
// Ensure the Typesense 'tracks' collection schema exists on boot so that
|
||||
// the first search request doesn't hit a 404.
|
||||
await searchService.ensureCollection();
|
||||
@@ -158,9 +204,14 @@ export async function buildApp(config: AppConfig) {
|
||||
fastify.register(graphRoutes, { prefix: '/api', dbService });
|
||||
|
||||
const sessionDirector = new SessionDirector(dbService);
|
||||
const vibeSessionCoordinator = new VibeSessionCoordinator(dbService, sessionDirector);
|
||||
|
||||
fastify.register(v2Routes, { prefix: '/api', dbService, sessionDirector });
|
||||
fastify.register(discoveryRoutes, { prefix: '/api', dbService });
|
||||
fastify.register(vibeSessionsRoutes, {
|
||||
prefix: '/api',
|
||||
coordinator: vibeSessionCoordinator,
|
||||
});
|
||||
fastify.register(discoveryRoutes, { prefix: '/api', dbService, jobService });
|
||||
fastify.register(playbackRoutes, { prefix: '/api', playbackSync });
|
||||
// ponytail: /api/test/enqueue-job (manual job-enqueue test endpoint) removed —
|
||||
// nothing in the deployed app or its tests called it, and deployment never
|
||||
// sets NODE_ENV so an env gate would've stayed live in prod anyway.
|
||||
@@ -171,6 +222,8 @@ export async function buildApp(config: AppConfig) {
|
||||
clearInterval(fusionTimer);
|
||||
clearInterval(decayTimer);
|
||||
clearInterval(forgottenTimer);
|
||||
clearInterval(discoveryEvalTimer);
|
||||
clearInterval(deviceReapTimer);
|
||||
} catch (err) {
|
||||
fastify.log.error(err);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
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)');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Vibe durable session migration', () => {
|
||||
const migration = MIGRATIONS.find(
|
||||
({ id }) => id === '20260801_vibe_session_persistence',
|
||||
);
|
||||
|
||||
it('creates the event ledger, retry key, and revisioned plan tables', () => {
|
||||
expect(migration).toBeDefined();
|
||||
expect(migration!.sql).toContain('CREATE TABLE IF NOT EXISTS vibe_sessions');
|
||||
expect(migration!.sql).toContain('CREATE TABLE IF NOT EXISTS vibe_events');
|
||||
expect(migration!.sql).toContain('UNIQUE (session_id, client_event_id)');
|
||||
expect(migration!.sql).toContain('CREATE TABLE IF NOT EXISTS vibe_plan_versions');
|
||||
expect(migration!.sql).toContain('UNIQUE (session_id, version)');
|
||||
expect(migration!.sql).toContain('CREATE TABLE IF NOT EXISTS vibe_plan_items');
|
||||
expect(migration!.sql).toContain('UNIQUE (plan_version_id, track_id)');
|
||||
});
|
||||
|
||||
it('indexes session and event reads along their required time axes', () => {
|
||||
expect(migration!.sql).toContain('idx_vibe_sessions_user_last_event');
|
||||
expect(migration!.sql).toContain('idx_vibe_events_session_occurred');
|
||||
expect(migration!.sql).toContain('idx_vibe_events_user_occurred');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Vibe context memory migration', () => {
|
||||
it('adds bounded session exploration state and exactly-once projection storage', () => {
|
||||
const migration = MIGRATIONS.find(({ id }) => id === '20260802_vibe_context_memory_exploration');
|
||||
expect(migration?.sql).toContain('CREATE TABLE IF NOT EXISTS vibe_session_profiles');
|
||||
expect(migration?.sql).toContain('exploration_coefficient');
|
||||
expect(migration?.sql).toContain('vibe_session_feedback_projections');
|
||||
});
|
||||
|
||||
it('backfills profiles for durable sessions created before context memory', () => {
|
||||
const migration = MIGRATIONS.find(({ id }) => id === '20260802_vibe_session_profile_backfill');
|
||||
expect(migration?.sql).toContain('INSERT INTO vibe_session_profiles');
|
||||
expect(migration?.sql).toContain('SELECT id, user_id FROM vibe_sessions');
|
||||
expect(migration?.sql).toContain('ON CONFLICT (session_id) DO NOTHING');
|
||||
});
|
||||
});
|
||||
@@ -510,4 +510,397 @@ 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();
|
||||
`,
|
||||
},
|
||||
{
|
||||
// Vibe v2 needs an immutable event ledger and revisioned plans. The
|
||||
// legacy session_state table remains in place as a derived-state cache so
|
||||
// existing v2 endpoints can migrate independently.
|
||||
id: '20260801_vibe_session_persistence',
|
||||
sql: `
|
||||
CREATE TABLE IF NOT EXISTS vibe_sessions (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID NOT NULL,
|
||||
status TEXT NOT NULL CHECK (status IN ('active', 'paused', 'ended', 'expired', 'replaced')),
|
||||
seed_track_id UUID REFERENCES tracks(id) ON DELETE SET NULL,
|
||||
context JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
policy_version TEXT NOT NULL,
|
||||
started_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
last_event_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
ended_at TIMESTAMPTZ
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_vibe_sessions_user_last_event
|
||||
ON vibe_sessions (user_id, last_event_at DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS vibe_events (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
client_event_id UUID,
|
||||
session_id UUID NOT NULL REFERENCES vibe_sessions(id) ON DELETE CASCADE,
|
||||
user_id UUID NOT NULL,
|
||||
track_id UUID REFERENCES tracks(id) ON DELETE SET NULL,
|
||||
type TEXT NOT NULL,
|
||||
occurred_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
position_ms INTEGER,
|
||||
duration_ms INTEGER,
|
||||
payload JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
UNIQUE (session_id, client_event_id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_vibe_events_session_occurred
|
||||
ON vibe_events (session_id, occurred_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_vibe_events_user_occurred
|
||||
ON vibe_events (user_id, occurred_at DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS vibe_plan_versions (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
session_id UUID NOT NULL REFERENCES vibe_sessions(id) ON DELETE CASCADE,
|
||||
version INTEGER NOT NULL CHECK (version > 0),
|
||||
reason TEXT NOT NULL,
|
||||
state_snapshot JSONB NOT NULL,
|
||||
objective_snapshot JSONB NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
UNIQUE (session_id, version)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS vibe_plan_items (
|
||||
plan_version_id UUID NOT NULL REFERENCES vibe_plan_versions(id) ON DELETE CASCADE,
|
||||
ordinal INTEGER NOT NULL CHECK (ordinal >= 0),
|
||||
track_id UUID NOT NULL REFERENCES tracks(id) ON DELETE CASCADE,
|
||||
slot_role TEXT,
|
||||
candidate_source TEXT NOT NULL,
|
||||
score REAL NOT NULL,
|
||||
score_breakdown JSONB NOT NULL,
|
||||
explanation JSONB NOT NULL,
|
||||
committed BOOLEAN NOT NULL DEFAULT false,
|
||||
PRIMARY KEY (plan_version_id, ordinal),
|
||||
UNIQUE (plan_version_id, track_id)
|
||||
);
|
||||
`,
|
||||
},
|
||||
{
|
||||
// A material Vibe event is projected into the legacy listener inputs in
|
||||
// the same transaction as its ledger write. This marker makes that bridge
|
||||
// auditable and exactly-once even when a client retries an event id.
|
||||
id: '20260801_vibe_event_projections',
|
||||
sql: `
|
||||
CREATE TABLE IF NOT EXISTS vibe_event_projections (
|
||||
event_id UUID PRIMARY KEY REFERENCES vibe_events(id) ON DELETE CASCADE,
|
||||
projected_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
`,
|
||||
},
|
||||
{
|
||||
// Session-specific exploration, goals, and deliberately lossy session
|
||||
// fingerprints are derived from the immutable Vibe ledger. Keeping them
|
||||
// separate from listener_beliefs prevents a transient session from
|
||||
// rewriting permanent taste.
|
||||
id: '20260802_vibe_context_memory_exploration',
|
||||
sql: `
|
||||
CREATE TABLE IF NOT EXISTS vibe_session_profiles (
|
||||
session_id UUID PRIMARY KEY REFERENCES vibe_sessions(id) ON DELETE CASCADE,
|
||||
user_id UUID NOT NULL,
|
||||
fingerprint JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
goals JSONB NOT NULL DEFAULT '{"type":"discovery","target":1,"progress":0}'::jsonb,
|
||||
exploration_coefficient REAL NOT NULL DEFAULT 0.30
|
||||
CHECK (exploration_coefficient >= 0 AND exploration_coefficient <= 1),
|
||||
discovery_radius REAL NOT NULL DEFAULT 0.38
|
||||
CHECK (discovery_radius >= 0 AND discovery_radius <= 1),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_vibe_session_profiles_user_updated
|
||||
ON vibe_session_profiles (user_id, updated_at DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS vibe_session_feedback_projections (
|
||||
event_id UUID PRIMARY KEY REFERENCES vibe_events(id) ON DELETE CASCADE,
|
||||
projected_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
`,
|
||||
},
|
||||
{
|
||||
// The profile table was introduced after durable sessions. Backfill every
|
||||
// pre-existing session before feedback can claim its exactly-once marker;
|
||||
// newly created sessions receive their context-derived initial goals in
|
||||
// createVibeSession's transaction.
|
||||
id: '20260802_vibe_session_profile_backfill',
|
||||
sql: `
|
||||
INSERT INTO vibe_session_profiles (session_id, user_id)
|
||||
SELECT id, user_id FROM vibe_sessions
|
||||
ON CONFLICT (session_id) DO NOTHING;
|
||||
`,
|
||||
},
|
||||
{
|
||||
// Two external candidate strategies, kept as separate source_trust keys so
|
||||
// meta-learning can compare their retention independently: "artists you
|
||||
// already play just released something" is a much stronger prior than
|
||||
// "Last.fm thinks this sounds similar", and the trust values say so.
|
||||
id: '20260806_external_discovery_sources',
|
||||
sql: `
|
||||
INSERT INTO source_trust (key, trust, description) VALUES
|
||||
('new_release', 0.60,
|
||||
'New release by an artist already played from the local library.'),
|
||||
('similar_recommendation', 0.45,
|
||||
'External similarity (Last.fm) seeded from local play history.')
|
||||
ON CONFLICT (key) DO NOTHING;
|
||||
`,
|
||||
},
|
||||
{
|
||||
// play_history is the only durable record of what was listened to and when,
|
||||
// and it had two holes that only show up when you try to read a year back:
|
||||
//
|
||||
// 1. ON DELETE CASCADE meant the gated cleanup sweep silently erased the
|
||||
// plays of every file it removed. A play happened; deleting the file
|
||||
// later does not un-happen it. The FK becomes SET NULL and the track's
|
||||
// identity is denormalised onto the row so it stays readable.
|
||||
// 2. No duration, so listening time was only ever inferable from the
|
||||
// track's current duration — itself gone once the file is.
|
||||
id: '20260806_play_history_durable_facts',
|
||||
sql: `
|
||||
ALTER TABLE play_history ADD COLUMN IF NOT EXISTS listened_ms INTEGER;
|
||||
ALTER TABLE play_history ADD COLUMN IF NOT EXISTS track_title TEXT;
|
||||
ALTER TABLE play_history ADD COLUMN IF NOT EXISTS track_artist TEXT;
|
||||
|
||||
DO $$
|
||||
DECLARE fk_name TEXT;
|
||||
BEGIN
|
||||
SELECT con.conname INTO fk_name
|
||||
FROM pg_constraint con
|
||||
JOIN pg_class rel ON rel.oid = con.conrelid
|
||||
JOIN pg_attribute att ON att.attrelid = rel.oid AND att.attnum = con.conkey[1]
|
||||
WHERE rel.relname = 'play_history'
|
||||
AND con.contype = 'f'
|
||||
AND att.attname = 'track_id'
|
||||
AND con.confdeltype = 'c'
|
||||
LIMIT 1;
|
||||
IF fk_name IS NOT NULL THEN
|
||||
EXECUTE format('ALTER TABLE play_history DROP CONSTRAINT %I', fk_name);
|
||||
ALTER TABLE play_history
|
||||
ADD CONSTRAINT play_history_track_id_fkey
|
||||
FOREIGN KEY (track_id) REFERENCES tracks(id) ON DELETE SET NULL;
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- Backfill identity for rows written before the columns existed. Rows whose
|
||||
-- track was already cascade-deleted are unrecoverable; this at least stops
|
||||
-- the bleeding from here on.
|
||||
UPDATE play_history ph
|
||||
SET track_title = t.title, track_artist = t.artist
|
||||
FROM tracks t
|
||||
WHERE t.id = ph.track_id AND ph.track_title IS NULL;
|
||||
`,
|
||||
},
|
||||
{
|
||||
// One listener, many browsers. `playback_state` is the single authority for
|
||||
// what is playing and which device owns the audio, so a phone can take over
|
||||
// from a desktop mid-track. The queue is stored as whole track objects, not
|
||||
// ids: the device taking over needs to render the queue immediately, and a
|
||||
// snapshot of what was queued at handoff time is the honest thing to move.
|
||||
id: '20260808_playback_devices',
|
||||
sql: `
|
||||
CREATE TABLE IF NOT EXISTS playback_devices (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
last_seen_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_playback_devices_user
|
||||
ON playback_devices (user_id, last_seen_at DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS playback_state (
|
||||
user_id UUID PRIMARY KEY,
|
||||
device_id UUID REFERENCES playback_devices(id) ON DELETE SET NULL,
|
||||
track_id UUID REFERENCES tracks(id) ON DELETE SET NULL,
|
||||
queue JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||
queue_index INTEGER NOT NULL DEFAULT -1,
|
||||
position_ms INTEGER NOT NULL DEFAULT 0,
|
||||
is_playing BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
-- Monotonic per user. A device applies a snapshot only when it is newer
|
||||
-- than the last one it saw, so a delayed delivery cannot rewind anyone.
|
||||
version BIGINT NOT NULL DEFAULT 0,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
`,
|
||||
},
|
||||
{
|
||||
// Every library scan used to reset a disliked track from HIDDEN back to
|
||||
// LIBRARY, so tracks the listener had rejected kept returning in the Vibe.
|
||||
// The scanner no longer does that; this puts the tracks it already undid
|
||||
// back where the dislike left them. A track whose dislike was deliberately
|
||||
// restored has no `dislikes` row and is untouched here.
|
||||
// `20260707_backfill_claims` wrote tag-derived credits once, and nothing
|
||||
// wrote them afterwards: the scanner filled `track_artists` but left the
|
||||
// claim spine to MusicBrainz. Every track added since, and every track
|
||||
// MusicBrainz does not know, therefore had no edge in `claim_fusion` and
|
||||
// was unreachable by all but one Vibe generator. The scanner now writes
|
||||
// these itself; this catches up the tracks it already missed.
|
||||
id: '20260810_backfill_tag_credits_since_first_scan',
|
||||
sql: `
|
||||
INSERT INTO claims (subject_type, subject_id, predicate, object_type, object_id, source, confidence)
|
||||
SELECT 'track', ta.track_id,
|
||||
CASE WHEN ta.role = 'main' THEN 'credited_main_on' ELSE 'featured_on' END,
|
||||
'artist', ta.artist_id, 'tag', 1.0
|
||||
FROM track_artists ta
|
||||
JOIN tracks t ON t.id = ta.track_id
|
||||
WHERE t.state IN ('LIBRARY', 'RECOMMENDED')
|
||||
ON CONFLICT (subject_type, subject_id, predicate, object_type, object_id, source, user_id)
|
||||
DO NOTHING;
|
||||
`,
|
||||
},
|
||||
{
|
||||
// Vibe control has to follow the audio. The running session's id rides with
|
||||
// the playback snapshot, so the device taking the audio can adopt the
|
||||
// session instead of walking whatever queue it happened to receive — which
|
||||
// is what used to happen, silently, every time a listener moved the audio to
|
||||
// their phone mid-Vibe.
|
||||
//
|
||||
// No foreign key on purpose: a report from the owning device carries the
|
||||
// whole session, and losing that to a session row that has since been
|
||||
// deleted would cost far more than a dangling id.
|
||||
id: '20260810_playback_state_vibe_session',
|
||||
sql: `
|
||||
ALTER TABLE playback_state
|
||||
ADD COLUMN IF NOT EXISTS vibe_session_id UUID;
|
||||
`,
|
||||
},
|
||||
{
|
||||
id: '20260810_rehide_resurrected_dislikes',
|
||||
sql: `
|
||||
UPDATE tracks t
|
||||
SET state = 'HIDDEN'
|
||||
FROM dislikes d
|
||||
WHERE d.track_id = t.id
|
||||
AND d.deleted_at IS NULL
|
||||
AND t.state IN ('LIBRARY', 'RECOMMENDED');
|
||||
`,
|
||||
},
|
||||
];
|
||||
|
||||
+185
-2
@@ -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
|
||||
@@ -248,12 +302,20 @@ CREATE TABLE IF NOT EXISTS recommendation_batch_track (
|
||||
-- "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.
|
||||
-- A play is a historical fact: it stays true after the file is gone. Hence
|
||||
-- ON DELETE SET NULL rather than CASCADE, plus the denormalised title/artist so
|
||||
-- a row whose track was hard-deleted is still readable. listened_ms is what was
|
||||
-- actually heard, recorded at play time; tracks.duration is not a substitute
|
||||
-- because it disappears with the track.
|
||||
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,
|
||||
track_id UUID REFERENCES tracks(id) ON DELETE SET NULL,
|
||||
batch_id UUID REFERENCES recommendation_batch(id) ON DELETE SET NULL,
|
||||
completed BOOLEAN NOT NULL DEFAULT false,
|
||||
listened_ms INTEGER,
|
||||
track_title TEXT,
|
||||
track_artist TEXT,
|
||||
played_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
@@ -306,12 +368,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 +401,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 +429,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;
|
||||
@@ -455,6 +533,101 @@ CREATE TABLE IF NOT EXISTS session_state (
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_session_state_user ON session_state (user_id, last_interaction DESC);
|
||||
|
||||
-- Durable Vibe v2 session ledger. session_state remains a rebuildable cache for
|
||||
-- the existing director; these tables are the authoritative record for the
|
||||
-- next-generation, versioned planner.
|
||||
CREATE TABLE IF NOT EXISTS vibe_sessions (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID NOT NULL,
|
||||
status TEXT NOT NULL CHECK (status IN ('active', 'paused', 'ended', 'expired', 'replaced')),
|
||||
seed_track_id UUID REFERENCES tracks(id) ON DELETE SET NULL,
|
||||
context JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
policy_version TEXT NOT NULL,
|
||||
started_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
last_event_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
ended_at TIMESTAMPTZ
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_vibe_sessions_user_last_event
|
||||
ON vibe_sessions (user_id, last_event_at DESC);
|
||||
|
||||
-- Compact, derived memory for the session director. Fingerprints are a
|
||||
-- deliberately lossy description of session shape (not a track list) and are
|
||||
-- used only as a soft freshness signal against recent sessions.
|
||||
CREATE TABLE IF NOT EXISTS vibe_session_profiles (
|
||||
session_id UUID PRIMARY KEY REFERENCES vibe_sessions(id) ON DELETE CASCADE,
|
||||
user_id UUID NOT NULL,
|
||||
fingerprint JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
goals JSONB NOT NULL DEFAULT '{"type":"discovery","target":1,"progress":0}'::jsonb,
|
||||
exploration_coefficient REAL NOT NULL DEFAULT 0.30
|
||||
CHECK (exploration_coefficient >= 0 AND exploration_coefficient <= 1),
|
||||
discovery_radius REAL NOT NULL DEFAULT 0.38
|
||||
CHECK (discovery_radius >= 0 AND discovery_radius <= 1),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_vibe_session_profiles_user_updated
|
||||
ON vibe_session_profiles (user_id, updated_at DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS vibe_events (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
client_event_id UUID,
|
||||
session_id UUID NOT NULL REFERENCES vibe_sessions(id) ON DELETE CASCADE,
|
||||
user_id UUID NOT NULL,
|
||||
track_id UUID REFERENCES tracks(id) ON DELETE SET NULL,
|
||||
type TEXT NOT NULL,
|
||||
occurred_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
position_ms INTEGER,
|
||||
duration_ms INTEGER,
|
||||
payload JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
UNIQUE (session_id, client_event_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_vibe_events_session_occurred
|
||||
ON vibe_events (session_id, occurred_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_vibe_events_user_occurred
|
||||
ON vibe_events (user_id, occurred_at DESC);
|
||||
|
||||
-- Exactly-once projection marker for material Vibe feedback. The immutable
|
||||
-- event remains authoritative; this row proves its effect was applied to the
|
||||
-- listener inputs without double-counting an idempotent client retry.
|
||||
CREATE TABLE IF NOT EXISTS vibe_event_projections (
|
||||
event_id UUID PRIMARY KEY REFERENCES vibe_events(id) ON DELETE CASCADE,
|
||||
projected_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Separate from listener-belief projection because exploration is session
|
||||
-- state. It lets a failed post-event replan safely retry the exact same
|
||||
-- adaptation without counting the feedback twice.
|
||||
CREATE TABLE IF NOT EXISTS vibe_session_feedback_projections (
|
||||
event_id UUID PRIMARY KEY REFERENCES vibe_events(id) ON DELETE CASCADE,
|
||||
projected_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS vibe_plan_versions (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
session_id UUID NOT NULL REFERENCES vibe_sessions(id) ON DELETE CASCADE,
|
||||
version INTEGER NOT NULL CHECK (version > 0),
|
||||
reason TEXT NOT NULL,
|
||||
state_snapshot JSONB NOT NULL,
|
||||
objective_snapshot JSONB NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
UNIQUE (session_id, version)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS vibe_plan_items (
|
||||
plan_version_id UUID NOT NULL REFERENCES vibe_plan_versions(id) ON DELETE CASCADE,
|
||||
ordinal INTEGER NOT NULL CHECK (ordinal >= 0),
|
||||
track_id UUID NOT NULL REFERENCES tracks(id) ON DELETE CASCADE,
|
||||
slot_role TEXT,
|
||||
candidate_source TEXT NOT NULL,
|
||||
score REAL NOT NULL,
|
||||
score_breakdown JSONB NOT NULL,
|
||||
explanation JSONB NOT NULL,
|
||||
committed BOOLEAN NOT NULL DEFAULT false,
|
||||
PRIMARY KEY (plan_version_id, ordinal),
|
||||
UNIQUE (plan_version_id, track_id)
|
||||
);
|
||||
|
||||
-- Diversity budgets for the session director's planner.
|
||||
CREATE TABLE IF NOT EXISTS diversity_budgets (
|
||||
user_id UUID NOT NULL,
|
||||
@@ -487,9 +660,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;
|
||||
@@ -166,3 +194,80 @@ export interface RepetitionRule {
|
||||
dimension: string;
|
||||
min_distance: number;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Vibe v2 durable session ledger
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const VIBE_SESSION_STATUSES = ['active', 'paused', 'ended', 'expired', 'replaced'] as const;
|
||||
export type VibeSessionStatus = (typeof VIBE_SESSION_STATUSES)[number];
|
||||
|
||||
export interface VibeSession {
|
||||
id: string;
|
||||
user_id: string;
|
||||
status: VibeSessionStatus;
|
||||
seed_track_id: string | null;
|
||||
context: Record<string, unknown>;
|
||||
policy_version: string;
|
||||
started_at: Date;
|
||||
last_event_at: Date;
|
||||
ended_at: Date | null;
|
||||
}
|
||||
|
||||
export interface VibeEvent {
|
||||
id: string;
|
||||
client_event_id: string | null;
|
||||
session_id: string;
|
||||
user_id: string;
|
||||
track_id: string | null;
|
||||
type: string;
|
||||
occurred_at: Date;
|
||||
position_ms: number | null;
|
||||
duration_ms: number | null;
|
||||
payload: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface RecordedVibeEvent {
|
||||
event: VibeEvent;
|
||||
/** False when a retried client_event_id returned the original event. */
|
||||
inserted: boolean;
|
||||
}
|
||||
|
||||
export interface VibePlanVersion {
|
||||
id: string;
|
||||
session_id: string;
|
||||
version: number;
|
||||
reason: string;
|
||||
state_snapshot: Record<string, unknown>;
|
||||
objective_snapshot: Record<string, unknown>;
|
||||
created_at: Date;
|
||||
}
|
||||
|
||||
export interface VibePlanItem {
|
||||
plan_version_id: string;
|
||||
ordinal: number;
|
||||
track_id: string;
|
||||
slot_role: string | null;
|
||||
candidate_source: string;
|
||||
score: number;
|
||||
score_breakdown: Record<string, unknown>;
|
||||
explanation: unknown;
|
||||
committed: boolean;
|
||||
}
|
||||
|
||||
export interface VibePlan extends VibePlanVersion {
|
||||
items: VibePlanItem[];
|
||||
}
|
||||
|
||||
/** Derived session-director memory. The ledger remains authoritative; this
|
||||
* compact row makes fingerprints, bounded goals, and exploration state cheap
|
||||
* to read while planning. */
|
||||
export interface VibeSessionProfile {
|
||||
session_id: string;
|
||||
user_id: string;
|
||||
fingerprint: Record<string, unknown>;
|
||||
goals: Record<string, unknown>;
|
||||
exploration_coefficient: number;
|
||||
discovery_radius: number;
|
||||
updated_at: Date;
|
||||
}
|
||||
|
||||
@@ -2,6 +2,87 @@ import { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
|
||||
import { JobService } from '../services/job.service.js';
|
||||
import { DbService } from '../services/db.service.js';
|
||||
|
||||
const ENRICHMENT_SETTING_KEYS = [
|
||||
'enrich_metadata',
|
||||
'enrich_artist_images',
|
||||
'enrich_cover_art',
|
||||
'enrich_genres',
|
||||
'enrich_lyrics',
|
||||
'enrich_artist_similarity',
|
||||
'enrich_audio_analysis',
|
||||
] as const;
|
||||
|
||||
type ReenrichmentScope = {
|
||||
metadata?: boolean;
|
||||
artistImages?: boolean;
|
||||
albumCovers?: boolean;
|
||||
};
|
||||
|
||||
type ReenrichmentRequest = {
|
||||
/** Preview by default. Jobs are only added with an explicit confirmation. */
|
||||
confirm?: boolean;
|
||||
/** Missing-only is the safe default; false means refresh the selected scope. */
|
||||
missingOnly?: boolean;
|
||||
/** Per-scope batch cap. Defaults to 250 and never exceeds 1,000. */
|
||||
limit?: number;
|
||||
scope?: ReenrichmentScope;
|
||||
};
|
||||
|
||||
function getReenrichmentOptions(body: ReenrichmentRequest = {}) {
|
||||
const requestedScope = body.scope ?? {};
|
||||
const scope = {
|
||||
metadata: requestedScope.metadata ?? true,
|
||||
artistImages: requestedScope.artistImages ?? true,
|
||||
albumCovers: requestedScope.albumCovers ?? true,
|
||||
};
|
||||
const rawLimit = Number(body.limit ?? 250);
|
||||
const limit = Number.isInteger(rawLimit) ? Math.max(1, Math.min(rawLimit, 1000)) : 250;
|
||||
return { confirm: body.confirm === true, missingOnly: body.missingOnly !== false, limit, scope };
|
||||
}
|
||||
|
||||
async function getReenrichmentStatus(dbService: DbService, jobService: JobService) {
|
||||
const db = dbService.pgClient;
|
||||
const [settingsRes, coverageRes, queue] = await Promise.all([
|
||||
db.query<{ key: string; value: string }>(
|
||||
`SELECT key, value FROM settings WHERE key = ANY($1::text[])`,
|
||||
[ENRICHMENT_SETTING_KEYS],
|
||||
),
|
||||
db.query<{
|
||||
library_tracks: number;
|
||||
tracks_without_release_date: number;
|
||||
tracks_without_album_mbid: number;
|
||||
artists_total: number;
|
||||
artists_without_image: number;
|
||||
artists_without_mbid: number;
|
||||
albums_total: number;
|
||||
albums_without_artwork: number;
|
||||
albums_without_release_date: number;
|
||||
}>(`
|
||||
SELECT
|
||||
COUNT(*) FILTER (WHERE t.state = 'LIBRARY')::int AS library_tracks,
|
||||
COUNT(*) FILTER (WHERE t.state = 'LIBRARY' AND t.release_date IS NULL)::int AS tracks_without_release_date,
|
||||
COUNT(*) FILTER (WHERE t.state = 'LIBRARY' AND al.mbid IS NULL)::int AS tracks_without_album_mbid,
|
||||
(SELECT COUNT(*)::int FROM artists) AS artists_total,
|
||||
(SELECT COUNT(*)::int FROM artists WHERE image_path IS NULL OR image_path = '') AS artists_without_image,
|
||||
(SELECT COUNT(*)::int FROM artists WHERE mbid IS NULL) AS artists_without_mbid,
|
||||
(SELECT COUNT(*)::int FROM albums) AS albums_total,
|
||||
(SELECT COUNT(*)::int FROM albums WHERE artwork_id IS NULL OR artwork_id = '') AS albums_without_artwork,
|
||||
(SELECT COUNT(*)::int FROM albums WHERE release_date IS NULL) AS albums_without_release_date
|
||||
FROM tracks t
|
||||
LEFT JOIN albums al ON al.id = t.album_id
|
||||
`),
|
||||
jobService.getEnrichmentQueueDiagnostics(),
|
||||
]);
|
||||
const settings = Object.fromEntries(
|
||||
ENRICHMENT_SETTING_KEYS.map((key) => [
|
||||
key,
|
||||
settingsRes.rows.find((row) => row.key === key)?.value === 'true',
|
||||
]),
|
||||
) as Record<(typeof ENRICHMENT_SETTING_KEYS)[number], boolean>;
|
||||
|
||||
return { settings, coverage: coverageRes.rows[0], queue };
|
||||
}
|
||||
|
||||
export default async function adminRoutes(fastify: FastifyInstance, options: { jobService: JobService; dbService: DbService }) {
|
||||
const { jobService, dbService } = options;
|
||||
|
||||
@@ -24,6 +105,60 @@ export default async function adminRoutes(fastify: FastifyInstance, options: { j
|
||||
return { status: 'Artist reprocessing job enqueued' };
|
||||
});
|
||||
|
||||
/** Rebuild artist/genre/audio beliefs from durable history after a model upgrade. */
|
||||
fastify.post('/vibe/rebuild-beliefs', async (request: FastifyRequest, reply: FastifyReply) => {
|
||||
const { userId } = (request.body ?? {}) as { userId?: string };
|
||||
const resolvedUserId = userId || (request.headers['x-user-id'] as string) || '00000000-0000-0000-0000-000000000000';
|
||||
const result = await dbService.rebuildDerivedListenerBeliefs(resolvedUserId);
|
||||
return reply.send({ status: 'rebuilt', userId: resolvedUserId, ...result });
|
||||
});
|
||||
|
||||
/**
|
||||
* Attach a human/resolver-vetted source to a System E candidate. This is an
|
||||
* admin-only hand-off: graph traversal identifies an artist/path, but must
|
||||
* never turn that into an arbitrary web search and download. The worker still
|
||||
* applies its own host allow-list immediately before invoking yt-dlp.
|
||||
*/
|
||||
fastify.post('/discovery/candidates/:id/acquisition-source', async (request, reply) => {
|
||||
const { id } = request.params as { id: string };
|
||||
const body = request.body as { url?: string; expectedTitle?: string; expectedArtist?: string };
|
||||
if (!body?.url || typeof body.url !== 'string' || body.url.length > 4000) {
|
||||
return reply.code(400).send({ error: 'HTTPS url is required' });
|
||||
}
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(body.url);
|
||||
} catch {
|
||||
return reply.code(400).send({ error: 'url is invalid' });
|
||||
}
|
||||
if (url.protocol !== 'https:' || url.username || url.password) {
|
||||
return reply.code(400).send({ error: 'url must be credential-free HTTPS' });
|
||||
}
|
||||
const source = {
|
||||
url: url.toString(),
|
||||
...(typeof body.expectedTitle === 'string' ? { expectedTitle: body.expectedTitle.slice(0, 500) } : {}),
|
||||
...(typeof body.expectedArtist === 'string' ? { expectedArtist: body.expectedArtist.slice(0, 500) } : {}),
|
||||
};
|
||||
try {
|
||||
const result = await dbService.pgClient.query(
|
||||
`UPDATE discovery_candidates
|
||||
SET notes = jsonb_set(COALESCE(notes, '{}'::jsonb), '{acquisition}', $2::jsonb, true),
|
||||
status = 'candidate', last_eval_at = NULL, last_error = NULL
|
||||
WHERE id = $1::uuid
|
||||
AND status IN ('candidate', 'awaiting_resolution', 'acquisition_disabled', 'failed')
|
||||
RETURNING id, status`,
|
||||
[id, JSON.stringify(source)]
|
||||
);
|
||||
if (result.rows.length === 0) {
|
||||
return reply.code(404).send({ error: 'candidate does not exist or cannot be re-queued' });
|
||||
}
|
||||
return reply.send({ candidate: result.rows[0] });
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'could not attach acquisition source';
|
||||
return reply.code(400).send({ error: message });
|
||||
}
|
||||
});
|
||||
|
||||
fastify.post('/dedup-albums', async (_request: FastifyRequest, reply: FastifyReply) => {
|
||||
// Merge duplicate album rows directly (synchronous — it's just SQL, no
|
||||
// external API calls). Returns the number of albums merged away.
|
||||
@@ -92,17 +227,132 @@ export default async function adminRoutes(fastify: FastifyInstance, options: { j
|
||||
return { status: 'Albums deduplicated', merged: res.rows[0]?.count ?? 0 };
|
||||
});
|
||||
|
||||
/**
|
||||
* Return coverage, active toggles, queued work, and recent worker failures.
|
||||
* This is deliberately separate from enqueueing so an operator can diagnose
|
||||
* a disabled provider or a failing worker before launching another batch.
|
||||
*/
|
||||
fastify.get('/reenrichment/status', async () => {
|
||||
return getReenrichmentStatus(dbService, jobService);
|
||||
});
|
||||
|
||||
/**
|
||||
* Safe re-enrichment control plane. A call is a preview unless confirm=true;
|
||||
* the default is a 250-entity, missing-only batch. The three job types remain
|
||||
* independent so artwork work is not hidden behind track metadata work.
|
||||
*/
|
||||
fastify.post<{ Body: ReenrichmentRequest }>('/reenrichment', async (request, reply) => {
|
||||
const options = getReenrichmentOptions(request.body);
|
||||
const status = await getReenrichmentStatus(dbService, jobService);
|
||||
const blockedBySettings: Partial<Record<keyof ReenrichmentScope, string>> = {};
|
||||
const metadataEnabled = status.settings.enrich_metadata
|
||||
|| status.settings.enrich_genres
|
||||
|| status.settings.enrich_lyrics;
|
||||
if (options.scope.metadata && !metadataEnabled) {
|
||||
blockedBySettings.metadata = 'All track enrichment toggles are disabled.';
|
||||
}
|
||||
if (options.scope.artistImages && !status.settings.enrich_artist_images) {
|
||||
blockedBySettings.artistImages = 'enrich_artist_images is disabled.';
|
||||
}
|
||||
if (options.scope.albumCovers && !status.settings.enrich_cover_art) {
|
||||
blockedBySettings.albumCovers = 'enrich_cover_art is disabled.';
|
||||
}
|
||||
|
||||
const db = dbService.pgClient;
|
||||
const [tracks, artists, albums] = await Promise.all([
|
||||
options.scope.metadata && !blockedBySettings.metadata
|
||||
? db.query<{ id: string }>(
|
||||
`SELECT t.id
|
||||
FROM tracks t
|
||||
LEFT JOIN albums al ON al.id = t.album_id
|
||||
LEFT JOIN track_artists ta ON ta.track_id = t.id AND ta.role = 'main'
|
||||
LEFT JOIN artists ar ON ar.id = ta.artist_id
|
||||
WHERE t.state = 'LIBRARY'
|
||||
AND ($1::boolean = false OR t.release_date IS NULL OR al.mbid IS NULL OR ar.mbid IS NULL)
|
||||
ORDER BY t.id
|
||||
LIMIT $2`,
|
||||
[options.missingOnly, options.limit],
|
||||
)
|
||||
: Promise.resolve({ rows: [] as Array<{ id: string }> }),
|
||||
options.scope.artistImages && !blockedBySettings.artistImages
|
||||
? db.query<{ id: string }>(
|
||||
`SELECT id FROM artists
|
||||
WHERE $1::boolean = false OR image_path IS NULL OR image_path = ''
|
||||
ORDER BY id
|
||||
LIMIT $2`,
|
||||
[options.missingOnly, options.limit],
|
||||
)
|
||||
: Promise.resolve({ rows: [] as Array<{ id: string }> }),
|
||||
options.scope.albumCovers && !blockedBySettings.albumCovers
|
||||
? db.query<{ id: string }>(
|
||||
`SELECT id FROM albums
|
||||
WHERE $1::boolean = false OR artwork_id IS NULL OR artwork_id = ''
|
||||
ORDER BY id
|
||||
LIMIT $2`,
|
||||
[options.missingOnly, options.limit],
|
||||
)
|
||||
: Promise.resolve({ rows: [] as Array<{ id: string }> }),
|
||||
]);
|
||||
|
||||
const plan = {
|
||||
metadataTrackIds: tracks.rows.map((row) => row.id),
|
||||
artistIds: artists.rows.map((row) => row.id),
|
||||
albumIds: albums.rows.map((row) => row.id),
|
||||
};
|
||||
const wouldQueue = {
|
||||
metadata: plan.metadataTrackIds.length,
|
||||
artistImages: plan.artistIds.length,
|
||||
albumCovers: plan.albumIds.length,
|
||||
};
|
||||
|
||||
if (!options.confirm) {
|
||||
return {
|
||||
status: 'preview',
|
||||
message: 'No jobs were queued. Repeat with confirm=true to enqueue this bounded plan.',
|
||||
options,
|
||||
blockedBySettings,
|
||||
wouldQueue,
|
||||
diagnostics: status,
|
||||
};
|
||||
}
|
||||
|
||||
if (Object.keys(blockedBySettings).length === 3 ||
|
||||
(wouldQueue.metadata + wouldQueue.artistImages + wouldQueue.albumCovers === 0)) {
|
||||
return reply.code(409).send({
|
||||
status: 'not_queued',
|
||||
message: Object.keys(blockedBySettings).length === 3
|
||||
? 'Every selected scope is disabled by enrichment settings.'
|
||||
: 'No eligible entities matched this re-enrichment batch.',
|
||||
options,
|
||||
blockedBySettings,
|
||||
wouldQueue,
|
||||
diagnostics: status,
|
||||
});
|
||||
}
|
||||
|
||||
const queued = await jobService.enqueueReenrichment(plan);
|
||||
const enqueueFailures = queued.metadata.failed.length
|
||||
+ queued.artistImages.failed.length
|
||||
+ queued.albumCovers.failed.length;
|
||||
return {
|
||||
status: enqueueFailures === 0 ? 'queued' : 'partially_queued',
|
||||
message: enqueueFailures === 0
|
||||
? 'Re-enrichment jobs were queued. Check /reenrichment/status and job history for outcomes.'
|
||||
: 'Some jobs could not be queued; see queued.*.failed for exact errors.',
|
||||
options,
|
||||
blockedBySettings,
|
||||
queued,
|
||||
diagnostics: await getReenrichmentStatus(dbService, jobService),
|
||||
};
|
||||
});
|
||||
|
||||
// The prior endpoint queued every track and implied cover/image work that it
|
||||
// never scheduled. Keep the failure explicit instead of silently claiming a
|
||||
// successful library-wide refresh.
|
||||
fastify.post('/reenrich-tracks', async (_request: FastifyRequest, reply: FastifyReply) => {
|
||||
// Re-enqueue metadata_refresh for every LIBRARY track without re-reading
|
||||
// files from disk. This re-runs the MusicBrainz canonicalisation (artist
|
||||
// names, album titles, MBIDs) and re-triggers album_cover jobs — much
|
||||
// faster than a full scan when only metadata needs refreshing.
|
||||
const res = await dbService.pgClient.query<{ id: string }>(
|
||||
`SELECT id FROM tracks WHERE state = 'LIBRARY' ORDER BY id`
|
||||
);
|
||||
const trackIds = res.rows.map((r) => r.id);
|
||||
const enqueued = await jobService.enqueueMetadataRefreshBatch(trackIds);
|
||||
return { status: 'Re-enrich enqueued', trackCount: enqueued };
|
||||
return reply.code(410).send({
|
||||
error: 'Deprecated endpoint. Use POST /admin/reenrichment (preview first; confirm=true to queue).',
|
||||
});
|
||||
});
|
||||
|
||||
fastify.get('/queue-stats', async () => {
|
||||
|
||||
@@ -2,9 +2,11 @@ import { FastifyInstance } from 'fastify';
|
||||
import { DbService } from '../services/db.service.js';
|
||||
import { DiscoveryService } from '../services/discovery.service.js';
|
||||
import { ImageEnrichmentService } from '../services/image-enrichment.service.js';
|
||||
import { JobService } from '../services/job.service.js';
|
||||
|
||||
export default async function discoveryRoutes(fastify: FastifyInstance, options: { dbService: DbService }) {
|
||||
export default async function discoveryRoutes(fastify: FastifyInstance, options: { dbService: DbService; jobService: JobService }) {
|
||||
const { dbService } = options;
|
||||
const { jobService } = options;
|
||||
const discovery = new DiscoveryService(dbService);
|
||||
const images = new ImageEnrichmentService(dbService);
|
||||
|
||||
@@ -33,12 +35,71 @@ export default async function discoveryRoutes(fastify: FastifyInstance, options:
|
||||
return reply.send({ candidates: res.rows });
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/discovery/overview — every recommendation and what became of it.
|
||||
*
|
||||
* One row per candidate, acquired or not, so a stalled candidate is as
|
||||
* visible as a retained track. The play/skip counts come from `evidence`
|
||||
* rather than tracks.play_count because the probation sweep decides on
|
||||
* exactly those two signals: showing anything else would be showing a number
|
||||
* that does not drive the outcome.
|
||||
*/
|
||||
fastify.get('/discovery/overview', async (request, reply) => {
|
||||
const query = request.query as { limit?: string };
|
||||
const limit = Math.min(Math.max(parseInt(query.limit || '200', 10) || 200, 1), 500);
|
||||
|
||||
const rows = await dbService.pgClient.query(
|
||||
`SELECT dc.id, dc.source, dc.status, dc.title, dc.artist_credit, dc.notes,
|
||||
dc.first_seen_at, dc.acquired_at, dc.last_error, dc.acquisition_attempts,
|
||||
t.id AS track_id, t.title AS track_title, t.artist AS track_artist,
|
||||
t.probation_status, t.probation_entered_at,
|
||||
(SELECT COUNT(*)::int FROM evidence e
|
||||
WHERE e.entity_type = 'track' AND e.entity_id = t.id
|
||||
AND e.signal = 'playback_completed') AS completed_plays,
|
||||
(SELECT COUNT(*)::int FROM evidence e
|
||||
WHERE e.entity_type = 'track' AND e.entity_id = t.id
|
||||
AND e.signal = 'skip_quick') AS quick_skips
|
||||
FROM discovery_candidates dc
|
||||
LEFT JOIN tracks t ON t.id = dc.acquired_track_id
|
||||
ORDER BY COALESCE(dc.acquired_at, dc.first_seen_at) DESC
|
||||
LIMIT $1`,
|
||||
[limit]
|
||||
);
|
||||
|
||||
const summary = await dbService.pgClient.query(
|
||||
`SELECT dc.source,
|
||||
COUNT(*)::int AS candidates,
|
||||
COUNT(t.id) FILTER (WHERE t.probation_status = 'probation')::int AS probation,
|
||||
COUNT(t.id) FILTER (WHERE t.probation_status = 'retained')::int AS retained,
|
||||
COUNT(t.id) FILTER (WHERE t.probation_status = 'retired')::int AS retired,
|
||||
COUNT(*) FILTER (WHERE dc.acquired_track_id IS NULL
|
||||
AND dc.status <> 'candidate')::int AS stalled
|
||||
FROM discovery_candidates dc
|
||||
LEFT JOIN tracks t ON t.id = dc.acquired_track_id
|
||||
GROUP BY dc.source
|
||||
ORDER BY dc.source`
|
||||
);
|
||||
|
||||
return reply.send({ rows: rows.rows, summary: summary.rows });
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/discovery/eval — evaluate pending candidates for acquisition
|
||||
*/
|
||||
fastify.post('/discovery/eval', async (request, reply) => {
|
||||
const userId = (request.headers['x-user-id'] as string) || '00000000-0000-0000-0000-000000000000';
|
||||
const results = await discovery.evalCandidates(userId);
|
||||
for (const result of results) {
|
||||
if (!result.shouldAcquire) continue;
|
||||
try {
|
||||
await jobService.enqueueDiscoveryAcquisition(result.candidateId);
|
||||
} catch (err) {
|
||||
const reason = err instanceof Error ? err.message : 'failed to enqueue acquisition';
|
||||
await discovery.markEnqueueFailed(result.candidateId, reason);
|
||||
result.shouldAcquire = false;
|
||||
result.reason = `queue unavailable: ${reason}`;
|
||||
}
|
||||
}
|
||||
return reply.send({ evaluated: results.length, results });
|
||||
});
|
||||
|
||||
|
||||
@@ -6,16 +6,23 @@ export default async function historyRoutes(fastify: FastifyInstance, options: {
|
||||
|
||||
// Record a playback event. completed defaults to false.
|
||||
fastify.post('/history', async (request, reply) => {
|
||||
const { trackId, completed, batchId } = request.body as {
|
||||
const { trackId, completed, batchId, listenedMs } = request.body as {
|
||||
trackId: string;
|
||||
completed?: boolean;
|
||||
batchId?: string;
|
||||
listenedMs?: number;
|
||||
};
|
||||
if (!trackId) {
|
||||
return reply.code(400).send({ error: 'trackId is required' });
|
||||
}
|
||||
// Clamped rather than rejected: a bogus duration must not cost the caller a
|
||||
// play record, and a 24h ceiling keeps one bad client from dominating any
|
||||
// listening-time total.
|
||||
const listened = typeof listenedMs === 'number' && Number.isFinite(listenedMs)
|
||||
? Math.max(0, Math.min(Math.round(listenedMs), 24 * 60 * 60 * 1000))
|
||||
: undefined;
|
||||
const userId = (request.headers['x-user-id'] as string) || '00000000-0000-0000-0000-000000000000';
|
||||
const historyId = await dbService.recordPlay(userId, trackId, completed === true, batchId);
|
||||
const historyId = await dbService.recordPlay(userId, trackId, completed === true, batchId, listened);
|
||||
return reply.send({ historyId });
|
||||
});
|
||||
|
||||
|
||||
@@ -10,12 +10,24 @@ const ALLOWED_HOSTS = new Set([
|
||||
'images.genius.com',
|
||||
'commons.wikimedia.org',
|
||||
'e.snmc.io',
|
||||
// Cover Art Archive 302s to the apex host, which then redirects on to an
|
||||
// ia*.us.archive.org node. The suffix below covers the node, not the apex.
|
||||
'archive.org',
|
||||
]);
|
||||
|
||||
// Wildcard suffixes — any subdomain of these is allowed.
|
||||
//
|
||||
// The CDNs below are where enrichment actually stores artwork: of 572 albums
|
||||
// with a cover, 126 sit on mzstatic (iTunes), 115 on dzcdn (Deezer) and 26 on
|
||||
// discogs, and every coverartarchive.org URL 302s to an ia*.us.archive.org
|
||||
// node. Without these the proxy answered 403 for every cover in the library.
|
||||
const ALLOWED_SUFFIXES = [
|
||||
'.coverartarchive.org',
|
||||
'.musicbrainz.org',
|
||||
'.archive.org',
|
||||
'.mzstatic.com',
|
||||
'.dzcdn.net',
|
||||
'.discogs.com',
|
||||
];
|
||||
|
||||
function isAllowed(hostname: string): boolean {
|
||||
|
||||
@@ -16,6 +16,13 @@ export default async function libraryRoutes(fastify: FastifyInstance, options: {
|
||||
return tracks;
|
||||
});
|
||||
|
||||
// Seeds for the Vibe start screen. Static path, so it is matched ahead of
|
||||
// /tracks/:trackId.
|
||||
fastify.get('/tracks/seeds', async (request) => {
|
||||
const query = request.query as any;
|
||||
return await dbService.getSeedTracks(query.limit ? parseInt(query.limit) : undefined);
|
||||
});
|
||||
|
||||
fastify.get('/artists', async (request) => {
|
||||
const query = request.query as any;
|
||||
return await dbService.getArtists({
|
||||
@@ -59,6 +66,10 @@ export default async function libraryRoutes(fastify: FastifyInstance, options: {
|
||||
return album;
|
||||
});
|
||||
|
||||
fastify.get('/library/stats', async () => {
|
||||
return await dbService.getLibraryStats();
|
||||
});
|
||||
|
||||
// Genres
|
||||
fastify.get('/genres', async () => {
|
||||
return await dbService.getGenres();
|
||||
|
||||
@@ -0,0 +1,235 @@
|
||||
import { FastifyInstance, FastifyRequest } from 'fastify';
|
||||
import {
|
||||
NotSessionOwnerError,
|
||||
PlaybackCommand,
|
||||
PlaybackCommandType,
|
||||
PlaybackStatePatch,
|
||||
PlaybackSyncService,
|
||||
} from '../services/playback-sync.service.js';
|
||||
|
||||
const DEFAULT_USER_ID = '00000000-0000-0000-0000-000000000000';
|
||||
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
const COMMAND_TYPES: PlaybackCommandType[] = ['play', 'pause', 'next', 'prev', 'seek', 'play_track'];
|
||||
/** Well inside the 90s staleness window, and enough to keep proxies from closing an idle stream. */
|
||||
const HEARTBEAT_MS = 25_000;
|
||||
|
||||
type Body = Record<string, unknown>;
|
||||
|
||||
function isObject(value: unknown): value is Body {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function userIdFrom(request: FastifyRequest): string {
|
||||
const header = request.headers['x-user-id'];
|
||||
return typeof header === 'string' && UUID_RE.test(header) ? header : DEFAULT_USER_ID;
|
||||
}
|
||||
|
||||
function parsePatch(body: unknown): PlaybackStatePatch | { error: string } {
|
||||
const input = isObject(body) ? body : {};
|
||||
const patch: PlaybackStatePatch = {};
|
||||
if (input.trackId !== undefined) {
|
||||
if (input.trackId !== null && !(typeof input.trackId === 'string' && UUID_RE.test(input.trackId))) {
|
||||
return { error: 'trackId must be a UUID or null' };
|
||||
}
|
||||
patch.trackId = input.trackId as string | null;
|
||||
}
|
||||
if (input.vibeSessionId !== undefined) {
|
||||
if (input.vibeSessionId !== null && !(typeof input.vibeSessionId === 'string' && UUID_RE.test(input.vibeSessionId))) {
|
||||
return { error: 'vibeSessionId must be a UUID or null' };
|
||||
}
|
||||
patch.vibeSessionId = input.vibeSessionId as string | null;
|
||||
}
|
||||
if (input.queue !== undefined) {
|
||||
if (!Array.isArray(input.queue)) return { error: 'queue must be an array' };
|
||||
patch.queue = input.queue;
|
||||
}
|
||||
if (input.queueIndex !== undefined) {
|
||||
if (typeof input.queueIndex !== 'number' || !Number.isInteger(input.queueIndex)) {
|
||||
return { error: 'queueIndex must be an integer' };
|
||||
}
|
||||
patch.queueIndex = input.queueIndex;
|
||||
}
|
||||
if (input.position !== undefined) {
|
||||
if (typeof input.position !== 'number' || !Number.isFinite(input.position) || input.position < 0) {
|
||||
return { error: 'position must be a non-negative number of seconds' };
|
||||
}
|
||||
patch.position = input.position;
|
||||
}
|
||||
if (input.isPlaying !== undefined) {
|
||||
if (typeof input.isPlaying !== 'boolean') return { error: 'isPlaying must be a boolean' };
|
||||
patch.isPlaying = input.isPlaying;
|
||||
}
|
||||
return patch;
|
||||
}
|
||||
|
||||
function parseCommand(body: unknown): PlaybackCommand | { error: string } {
|
||||
const input = isObject(body) ? body : {};
|
||||
const type = input.type;
|
||||
if (typeof type !== 'string' || !COMMAND_TYPES.includes(type as PlaybackCommandType)) {
|
||||
return { error: `type must be one of ${COMMAND_TYPES.join(', ')}` };
|
||||
}
|
||||
const command: PlaybackCommand = { type: type as PlaybackCommandType };
|
||||
if (type === 'seek') {
|
||||
if (typeof input.position !== 'number' || !Number.isFinite(input.position) || input.position < 0) {
|
||||
return { error: 'seek requires a non-negative position in seconds' };
|
||||
}
|
||||
command.position = input.position;
|
||||
}
|
||||
if (type === 'play_track') {
|
||||
if (typeof input.trackId !== 'string' || !UUID_RE.test(input.trackId)) {
|
||||
return { error: 'play_track requires a trackId' };
|
||||
}
|
||||
command.trackId = input.trackId;
|
||||
}
|
||||
return command;
|
||||
}
|
||||
|
||||
export default async function playbackRoutes(
|
||||
fastify: FastifyInstance,
|
||||
options: { playbackSync: PlaybackSyncService }
|
||||
) {
|
||||
const { playbackSync } = options;
|
||||
|
||||
fastify.post('/playback/devices', async (request, reply) => {
|
||||
const input = isObject(request.body) ? request.body : {};
|
||||
const name = typeof input.name === 'string' ? input.name : '';
|
||||
const deviceId = typeof input.deviceId === 'string' && UUID_RE.test(input.deviceId) ? input.deviceId : null;
|
||||
const device = await playbackSync.registerDevice(userIdFrom(request), name, deviceId);
|
||||
return reply.code(200).send(device);
|
||||
});
|
||||
|
||||
fastify.get('/playback/devices', async (request, reply) => {
|
||||
return reply.code(200).send({ devices: await playbackSync.listDevices(userIdFrom(request)) });
|
||||
});
|
||||
|
||||
fastify.get('/playback/state', async (request, reply) => {
|
||||
const userId = userIdFrom(request);
|
||||
const [state, devices] = await Promise.all([
|
||||
playbackSync.getState(userId),
|
||||
playbackSync.listDevices(userId),
|
||||
]);
|
||||
return reply.code(200).send({ state, devices });
|
||||
});
|
||||
|
||||
fastify.post('/playback/state', async (request, reply) => {
|
||||
const input = isObject(request.body) ? request.body : {};
|
||||
const deviceId = input.deviceId;
|
||||
if (typeof deviceId !== 'string' || !UUID_RE.test(deviceId)) {
|
||||
return reply.code(400).send({ error: 'deviceId must be a UUID' });
|
||||
}
|
||||
const patch = parsePatch(input);
|
||||
if ('error' in patch) return reply.code(400).send({ error: patch.error });
|
||||
try {
|
||||
const state = await playbackSync.reportState(userIdFrom(request), deviceId, patch);
|
||||
return reply.code(200).send({ state });
|
||||
} catch (err) {
|
||||
if (err instanceof NotSessionOwnerError) {
|
||||
// 409, not 403: the device is allowed here, it is simply no longer the
|
||||
// one holding the audio, and its own state report is the stale thing.
|
||||
return reply.code(409).send({ error: err.message });
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
});
|
||||
|
||||
fastify.post('/playback/command', async (request, reply) => {
|
||||
const command = parseCommand(request.body);
|
||||
if ('error' in command) return reply.code(400).send({ error: command.error });
|
||||
const result = await playbackSync.sendCommand(userIdFrom(request), command);
|
||||
if (!result.deliveredTo) return reply.code(409).send({ error: 'no device is holding playback' });
|
||||
return reply.code(202).send(result);
|
||||
});
|
||||
|
||||
fastify.post('/playback/transfer', async (request, reply) => {
|
||||
const input = isObject(request.body) ? request.body : {};
|
||||
const deviceId = input.deviceId;
|
||||
if (typeof deviceId !== 'string' || !UUID_RE.test(deviceId)) {
|
||||
return reply.code(400).send({ error: 'deviceId must be a UUID' });
|
||||
}
|
||||
try {
|
||||
return reply.code(200).send({ state: await playbackSync.transfer(userIdFrom(request), deviceId) });
|
||||
} catch {
|
||||
return reply.code(404).send({ error: 'unknown device' });
|
||||
}
|
||||
});
|
||||
|
||||
fastify.post('/playback/release', async (request, reply) => {
|
||||
const input = isObject(request.body) ? request.body : {};
|
||||
const deviceId = input.deviceId;
|
||||
if (typeof deviceId !== 'string' || !UUID_RE.test(deviceId)) {
|
||||
return reply.code(400).send({ error: 'deviceId must be a UUID' });
|
||||
}
|
||||
await playbackSync.releaseIfOwner(userIdFrom(request), deviceId);
|
||||
return reply.code(204).send();
|
||||
});
|
||||
|
||||
/**
|
||||
* The push channel. Every device holds one of these open: it receives the
|
||||
* session snapshot on connect, every later change, and the commands aimed at
|
||||
* it. The periodic comment line doubles as the device's liveness heartbeat,
|
||||
* so an open stream is what "this device is online" means.
|
||||
*/
|
||||
fastify.get('/playback/stream', async (request, reply) => {
|
||||
const userId = userIdFrom(request);
|
||||
const query = request.query as { deviceId?: string };
|
||||
const deviceId = typeof query.deviceId === 'string' && UUID_RE.test(query.deviceId) ? query.deviceId : null;
|
||||
if (!deviceId) return reply.code(400).send({ error: 'deviceId must be a UUID' });
|
||||
|
||||
// The response is written straight to the socket and lives for as long as
|
||||
// the tab does. Without this Fastify still believes it owes a reply and
|
||||
// waits on a handler that resolves with nothing.
|
||||
reply.hijack();
|
||||
const releaseStream = playbackSync.claimStream(userId, deviceId);
|
||||
|
||||
reply.raw.writeHead(200, {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache, no-transform',
|
||||
Connection: 'keep-alive',
|
||||
// nginx buffers event streams into uselessness without this.
|
||||
'X-Accel-Buffering': 'no',
|
||||
});
|
||||
|
||||
const write = (payload: unknown) => {
|
||||
reply.raw.write(`data: ${JSON.stringify(payload)}\n\n`);
|
||||
};
|
||||
|
||||
const unsubscribe = playbackSync.subscribe(userId, (event) => {
|
||||
if (event.type === 'command' && event.deviceId !== deviceId) return;
|
||||
write(event);
|
||||
});
|
||||
|
||||
// The reply is hijacked, so a throw from here reaches no error handler that
|
||||
// could answer it. Close the stream instead and let the client reopen.
|
||||
try {
|
||||
const [state, devices] = await Promise.all([
|
||||
playbackSync.getState(userId),
|
||||
playbackSync.listDevices(userId),
|
||||
]);
|
||||
write({ type: 'state', state, devices });
|
||||
} catch (err) {
|
||||
request.log.error(err);
|
||||
unsubscribe();
|
||||
releaseStream();
|
||||
reply.raw.end();
|
||||
return;
|
||||
}
|
||||
|
||||
const heartbeat = setInterval(() => {
|
||||
reply.raw.write(': ping\n\n');
|
||||
playbackSync.touchDevice(userId, deviceId).catch(() => {});
|
||||
}, HEARTBEAT_MS);
|
||||
|
||||
request.raw.on('close', () => {
|
||||
clearInterval(heartbeat);
|
||||
unsubscribe();
|
||||
releaseStream();
|
||||
// Ownership deliberately survives a closed stream. A phone changing cell,
|
||||
// locking its screen or dozing drops this connection for a few seconds
|
||||
// while its audio keeps playing; releasing here published an unowned
|
||||
// session, which the phone then read as "something else took over" and
|
||||
// paused itself. A device that is really gone loses the session two other
|
||||
// ways: `pagehide` releases it outright, and the stale-device sweep frees
|
||||
// an owner whose heartbeat has been silent past DEVICE_STALE_MS.
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { DbService } from '../services/db.service.js';
|
||||
|
||||
const SETTING_KEYS = [
|
||||
'enrich_metadata',
|
||||
'enrich_artist_images',
|
||||
'enrich_cover_art',
|
||||
'enrich_genres',
|
||||
'enrich_lyrics',
|
||||
|
||||
@@ -9,14 +9,36 @@ interface ActivePlan {
|
||||
sessionId: string;
|
||||
plan: Candidate[];
|
||||
seedTrackId: string | null;
|
||||
/** Tracks handed to the player during this Redis-backed session. */
|
||||
servedTrackIds?: string[];
|
||||
/** Explicit feedback targets (skip, dislike, completion, promotion). */
|
||||
excludedTrackIds?: string[];
|
||||
}
|
||||
|
||||
const PLAN_TTL_SEC = 2 * 3600;
|
||||
// Retain enough history for long listening sessions without allowing an
|
||||
// unbounded Redis value if a client leaves a session running for days.
|
||||
const MAX_SESSION_EXCLUSIONS = 1000;
|
||||
|
||||
function planKey(userId: string): string {
|
||||
return `v2:plan:${userId}`;
|
||||
}
|
||||
|
||||
function appendUniqueId(ids: string[] | undefined, id: string): string[] {
|
||||
const next = ids ? [...ids] : [];
|
||||
if (!next.includes(id)) next.push(id);
|
||||
return next.length > MAX_SESSION_EXCLUSIONS
|
||||
? next.slice(next.length - MAX_SESSION_EXCLUSIONS)
|
||||
: next;
|
||||
}
|
||||
|
||||
function sessionExclusions(active: ActivePlan): string[] {
|
||||
return [...new Set([
|
||||
...(active.servedTrackIds ?? []),
|
||||
...(active.excludedTrackIds ?? []),
|
||||
])];
|
||||
}
|
||||
|
||||
export default async function v2Routes(fastify: FastifyInstance, options: { dbService: DbService; sessionDirector: SessionDirector }) {
|
||||
const { dbService, sessionDirector: director } = options;
|
||||
|
||||
@@ -47,6 +69,7 @@ export default async function v2Routes(fastify: FastifyInstance, options: { dbSe
|
||||
// span multiple keys/services atomically, swap this for a WATCH/MULTI transaction
|
||||
// or move the plan into a single Lua script instead of an app-level lock.
|
||||
const RELEASE_LOCK_LUA = `if redis.call("get", KEYS[1]) == ARGV[1] then return redis.call("del", KEYS[1]) else return 0 end`;
|
||||
const RENEW_LOCK_LUA = `if redis.call("get", KEYS[1]) == ARGV[1] then return redis.call("pexpire", KEYS[1], ARGV[2]) else return 0 end`;
|
||||
|
||||
async function withPlanLock<T>(userId: string, fn: () => Promise<T>): Promise<T> {
|
||||
const lockKey = `v2:planlock:${userId}`;
|
||||
@@ -54,16 +77,20 @@ export default async function v2Routes(fastify: FastifyInstance, options: { dbSe
|
||||
const deadline = Date.now() + 5000;
|
||||
let acquired = false;
|
||||
while (Date.now() < deadline) {
|
||||
const res = await redisClient.set(lockKey, token, { NX: true, PX: 3000 });
|
||||
const res = await redisClient.set(lockKey, token, { NX: true, PX: 5000 });
|
||||
if (res) { acquired = true; break; }
|
||||
await new Promise((r) => setTimeout(r, 20 + Math.random() * 30));
|
||||
}
|
||||
if (!acquired) {
|
||||
throw new Error('Timed out waiting for active-plan lock');
|
||||
}
|
||||
const renewal = setInterval(() => {
|
||||
void redisClient.eval(RENEW_LOCK_LUA, { keys: [lockKey], arguments: [token, '5000'] });
|
||||
}, 1500);
|
||||
try {
|
||||
return await fn();
|
||||
} finally {
|
||||
clearInterval(renewal);
|
||||
await redisClient.eval(RELEASE_LOCK_LUA, { keys: [lockKey], arguments: [token] });
|
||||
}
|
||||
}
|
||||
@@ -78,9 +105,18 @@ export default async function v2Routes(fastify: FastifyInstance, options: { dbSe
|
||||
const { seedTrackId } = request.body as { seedTrackId?: string };
|
||||
|
||||
const sessionId = await dbService.createSessionState(userId, undefined, { energy: 0.5, novelty_hunger: 0.3 });
|
||||
const plan = await director.buildPlan(userId, sessionId, seedTrackId);
|
||||
const initialExclusions = seedTrackId ? [seedTrackId] : [];
|
||||
const plan = await director.buildPlan(userId, sessionId, seedTrackId, {
|
||||
excludedTrackIds: initialExclusions,
|
||||
});
|
||||
|
||||
await setActivePlan(userId, { sessionId, plan, seedTrackId: seedTrackId ?? null });
|
||||
await setActivePlan(userId, {
|
||||
sessionId,
|
||||
plan,
|
||||
seedTrackId: seedTrackId ?? null,
|
||||
servedTrackIds: [],
|
||||
excludedTrackIds: initialExclusions,
|
||||
});
|
||||
return reply.send({ sessionId, plan: plan.slice(0, 10) });
|
||||
});
|
||||
|
||||
@@ -90,34 +126,62 @@ export default async function v2Routes(fastify: FastifyInstance, options: { dbSe
|
||||
*/
|
||||
fastify.get('/v2/vibe/next', async (request, reply) => {
|
||||
const userId = (request.headers['x-user-id'] as string) || '00000000-0000-0000-0000-000000000000';
|
||||
const { sessionId } = request.query as { sessionId?: string };
|
||||
|
||||
const result = await withPlanLock(userId, async () => {
|
||||
const active = await getActivePlan(userId);
|
||||
|
||||
if (!active || active.plan.length === 0) {
|
||||
return null;
|
||||
if (!active) {
|
||||
return { kind: 'missing' as const };
|
||||
}
|
||||
if (!sessionId || active.sessionId !== sessionId) {
|
||||
return { kind: 'replaced' as const };
|
||||
}
|
||||
if (active.plan.length === 0) {
|
||||
return { kind: 'exhausted' as const };
|
||||
}
|
||||
|
||||
const next = active.plan.shift()!;
|
||||
active.servedTrackIds = appendUniqueId(active.servedTrackIds, next.trackId);
|
||||
// Enrich with track details
|
||||
const track = await dbService.getTrackById(next.trackId);
|
||||
|
||||
// Replan if running low
|
||||
if (active.plan.length < 5) {
|
||||
const refill = await director.replan(userId, active.sessionId, active.plan, [next.trackId], active.seedTrackId ?? undefined);
|
||||
const refill = await director.replan(
|
||||
userId,
|
||||
active.sessionId,
|
||||
active.plan,
|
||||
[next.trackId],
|
||||
active.seedTrackId ?? undefined,
|
||||
{ excludedTrackIds: sessionExclusions(active) }
|
||||
);
|
||||
active.plan = refill;
|
||||
}
|
||||
|
||||
await setActivePlan(userId, active);
|
||||
|
||||
return { track, explanation: next.explanation, planRemaining: active.plan.length };
|
||||
return { kind: 'track' as const, track, explanation: next.explanation, planRemaining: active.plan.length };
|
||||
});
|
||||
|
||||
if (!result) {
|
||||
if (result.kind === 'missing') {
|
||||
return reply.code(404).send({ error: 'No active plan. POST /api/v2/vibe/start first.' });
|
||||
}
|
||||
if (result.kind === 'replaced') {
|
||||
return reply.code(409).send({ error: 'This Vibe session was replaced by a newer session.', code: 'VIBE_SESSION_REPLACED' });
|
||||
}
|
||||
if (result.kind === 'exhausted') {
|
||||
return reply.code(409).send({
|
||||
error: 'Vibe plan exhausted: no eligible unserved tracks remain for this session.',
|
||||
code: 'VIBE_PLAN_EXHAUSTED',
|
||||
});
|
||||
}
|
||||
|
||||
return reply.send(result);
|
||||
return reply.send({
|
||||
track: result.track,
|
||||
explanation: result.explanation,
|
||||
planRemaining: result.planRemaining,
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -126,11 +190,14 @@ export default async function v2Routes(fastify: FastifyInstance, options: { dbSe
|
||||
*/
|
||||
fastify.post('/v2/vibe/feedback', async (request, reply) => {
|
||||
const userId = (request.headers['x-user-id'] as string) || '00000000-0000-0000-0000-000000000000';
|
||||
const { trackId, action } = request.body as { trackId: string; action: string };
|
||||
const { trackId, action, sessionId } = request.body as { trackId: string; action: string; sessionId?: string };
|
||||
|
||||
if (!trackId || !action) {
|
||||
return reply.code(400).send({ error: 'trackId and action are required' });
|
||||
}
|
||||
if (!['completed', 'skipped', 'promoted', 'disliked'].includes(action)) {
|
||||
return reply.code(400).send({ error: 'Unsupported Vibe feedback action' });
|
||||
}
|
||||
|
||||
// Route to existing handlers for evidence wiring
|
||||
if (action === 'completed') {
|
||||
@@ -148,8 +215,19 @@ export default async function v2Routes(fastify: FastifyInstance, options: { dbSe
|
||||
const planRemaining = await withPlanLock(userId, async () => {
|
||||
const active = await getActivePlan(userId);
|
||||
if (!active) return 0;
|
||||
const playedTrackIds = [trackId];
|
||||
const refill = await director.replan(userId, active.sessionId, active.plan, playedTrackIds, active.seedTrackId ?? undefined);
|
||||
if (!sessionId || active.sessionId !== sessionId) return 0;
|
||||
// Feedback may race /next or arrive after a client-side prefetch. In all
|
||||
// cases its track becomes ineligible for the rest of this session.
|
||||
active.excludedTrackIds = appendUniqueId(active.excludedTrackIds, trackId);
|
||||
const sessionTrackIds = sessionExclusions(active);
|
||||
const refill = await director.replan(
|
||||
userId,
|
||||
active.sessionId,
|
||||
active.plan,
|
||||
[trackId],
|
||||
active.seedTrackId ?? undefined,
|
||||
{ excludedTrackIds: sessionTrackIds }
|
||||
);
|
||||
active.plan = refill;
|
||||
await setActivePlan(userId, active);
|
||||
return active.plan.length;
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
import Fastify from 'fastify';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import vibeSessionsRoutes, { VibeIdentityResolver } from './vibe-sessions.routes.js';
|
||||
import { VibeSessionLifecycleError } from '../services/vibe-session-coordinator.service.js';
|
||||
|
||||
const SESSION_ID = '11111111-1111-4111-8111-111111111111';
|
||||
const TRACK_ID = '22222222-2222-4222-8222-222222222222';
|
||||
const USER_ID = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa';
|
||||
|
||||
function response() {
|
||||
return {
|
||||
sessionId: SESSION_ID, planVersion: 1, now: null, preview: [], state: {},
|
||||
replanned: false, replanReason: null,
|
||||
session: { id: SESSION_ID, status: 'active' },
|
||||
};
|
||||
}
|
||||
|
||||
async function appWithCoordinator(identityResolver?: VibeIdentityResolver) {
|
||||
const coordinator = {
|
||||
start: vi.fn().mockResolvedValue(response()),
|
||||
getPlan: vi.fn().mockResolvedValue(response()),
|
||||
appendEvent: vi.fn().mockResolvedValue({ ...response(), event: { id: 'event-1' }, idempotent: false }),
|
||||
end: vi.fn().mockResolvedValue(response()),
|
||||
serveNext: vi.fn().mockResolvedValue(response()),
|
||||
advancePastUnplayable: vi.fn().mockResolvedValue(response()),
|
||||
} as any;
|
||||
const app = Fastify();
|
||||
await app.register(vibeSessionsRoutes, { coordinator, ...(identityResolver ? { identityResolver } : {}) });
|
||||
await app.ready();
|
||||
return { app, coordinator };
|
||||
}
|
||||
|
||||
describe('durable Vibe session routes', () => {
|
||||
it('uses the caller identity so concurrent listeners receive separate sessions', async () => {
|
||||
const { app, coordinator } = await appWithCoordinator();
|
||||
const result = await app.inject({ method: 'POST', url: '/v2/vibe/sessions', headers: { 'x-user-id': USER_ID }, payload: {} });
|
||||
|
||||
expect(result.statusCode).toBe(201);
|
||||
expect(coordinator.start).toHaveBeenCalledWith(USER_ID, expect.any(Object));
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('uses the existing application default when no user header is provided', async () => {
|
||||
const { app, coordinator } = await appWithCoordinator();
|
||||
const result = await app.inject({
|
||||
method: 'POST', url: '/v2/vibe/sessions', payload: {},
|
||||
});
|
||||
expect(result.statusCode).toBe(201);
|
||||
expect(coordinator.start).toHaveBeenCalledWith('00000000-0000-0000-0000-000000000000', expect.any(Object));
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('creates a session and validates event payloads before touching the coordinator', async () => {
|
||||
const { app, coordinator } = await appWithCoordinator(() => USER_ID);
|
||||
const created = await app.inject({
|
||||
method: 'POST', url: '/v2/vibe/sessions', headers: { 'x-user-id': 'spoofed' },
|
||||
payload: { seedTrackId: TRACK_ID },
|
||||
});
|
||||
const invalidEvent = await app.inject({
|
||||
method: 'POST', url: `/v2/vibe/sessions/${SESSION_ID}/events`, headers: { 'x-user-id': 'spoofed' },
|
||||
payload: { type: 'definitely-not-an-event' },
|
||||
});
|
||||
|
||||
expect(created.statusCode).toBe(201);
|
||||
expect(coordinator.start).toHaveBeenCalledWith(USER_ID, expect.objectContaining({ seedTrackId: TRACK_ID }));
|
||||
expect(invalidEvent.statusCode).toBe(400);
|
||||
expect(coordinator.appendEvent).not.toHaveBeenCalled();
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('accepts bounded local calendar context and rejects malformed context', async () => {
|
||||
const { app, coordinator } = await appWithCoordinator();
|
||||
const valid = await app.inject({
|
||||
method: 'POST', url: '/v2/vibe/sessions',
|
||||
payload: { context: { localHour: 22, weekday: 5, month: 8, timeZone: 'Europe/Samara' } },
|
||||
});
|
||||
const invalid = await app.inject({
|
||||
method: 'POST', url: '/v2/vibe/sessions',
|
||||
payload: { context: { localHour: 24, weekday: 5, month: 8 } },
|
||||
});
|
||||
|
||||
expect(valid.statusCode).toBe(201);
|
||||
expect(coordinator.start).toHaveBeenCalledWith(
|
||||
'00000000-0000-0000-0000-000000000000',
|
||||
expect.objectContaining({ context: { localHour: 22, weekday: 5, month: 8, timeZone: 'Europe/Samara' } }),
|
||||
);
|
||||
expect(invalid.statusCode).toBe(400);
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('rejects server-only event types from the client event ledger', async () => {
|
||||
const { app, coordinator } = await appWithCoordinator();
|
||||
const result = await app.inject({
|
||||
method: 'POST', url: `/v2/vibe/sessions/${SESSION_ID}/events`,
|
||||
payload: {
|
||||
type: 'track_served',
|
||||
trackId: TRACK_ID,
|
||||
payload: { planVersionId: '33333333-3333-4333-8333-333333333333', ordinal: 0 },
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.statusCode).toBe(400);
|
||||
expect(result.json()).toEqual({ error: 'type must be a supported client Vibe event type' });
|
||||
expect(coordinator.appendEvent).not.toHaveBeenCalled();
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('returns a lifecycle conflict when an initial plan race ends or replaces the session', async () => {
|
||||
const { app, coordinator } = await appWithCoordinator();
|
||||
coordinator.start.mockRejectedValueOnce(
|
||||
new VibeSessionLifecycleError('Cannot publish a plan for ended Vibe session'),
|
||||
);
|
||||
|
||||
const result = await app.inject({ method: 'POST', url: '/v2/vibe/sessions', payload: {} });
|
||||
|
||||
expect(result.statusCode).toBe(409);
|
||||
expect(result.json()).toEqual({
|
||||
error: 'Cannot publish a plan for ended Vibe session',
|
||||
code: 'VIBE_SESSION_NOT_ACTIVE',
|
||||
});
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('passes a requested plan revision and validated idempotent event through to the coordinator', async () => {
|
||||
const { app, coordinator } = await appWithCoordinator(() => USER_ID);
|
||||
const plan = await app.inject({
|
||||
method: 'GET', url: `/v2/vibe/sessions/${SESSION_ID}/plans?version=2`, headers: { 'x-user-id': 'spoofed' },
|
||||
});
|
||||
const event = await app.inject({
|
||||
method: 'POST', url: `/v2/vibe/sessions/${SESSION_ID}/events`, headers: { 'x-user-id': 'spoofed' },
|
||||
payload: { eventId: '33333333-3333-4333-8333-333333333333', type: 'completed', trackId: TRACK_ID, positionMs: 5_000 },
|
||||
});
|
||||
|
||||
expect(plan.statusCode).toBe(200);
|
||||
expect(coordinator.getPlan).toHaveBeenCalledWith(USER_ID, SESSION_ID, 2);
|
||||
expect(event.statusCode).toBe(200);
|
||||
expect(coordinator.appendEvent).toHaveBeenCalledWith(USER_ID, SESSION_ID, expect.objectContaining({ type: 'completed', positionMs: 5_000 }));
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('validates occurredAt and exposes owned resume and advance operations', async () => {
|
||||
const { app, coordinator } = await appWithCoordinator(() => USER_ID);
|
||||
const resume = await app.inject({
|
||||
method: 'POST', url: '/v2/vibe/sessions', payload: { resumeSessionId: SESSION_ID },
|
||||
});
|
||||
const badTime = await app.inject({
|
||||
method: 'POST', url: `/v2/vibe/sessions/${SESSION_ID}/events`,
|
||||
payload: { type: 'completed', occurredAt: 'not-a-date' },
|
||||
});
|
||||
const next = await app.inject({ method: 'POST', url: `/v2/vibe/sessions/${SESSION_ID}/advance` });
|
||||
expect(resume.statusCode).toBe(201);
|
||||
expect(coordinator.start).toHaveBeenCalledWith(USER_ID, expect.objectContaining({ resumeSessionId: SESSION_ID }));
|
||||
expect(badTime.statusCode).toBe(400);
|
||||
expect(next.statusCode).toBe(200);
|
||||
expect(coordinator.serveNext).toHaveBeenCalledWith(USER_ID, SESSION_ID);
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('passes a version-aware advance request through and rejects an invalid expected version', async () => {
|
||||
const { app, coordinator } = await appWithCoordinator(() => USER_ID);
|
||||
const valid = await app.inject({
|
||||
method: 'POST', url: `/v2/vibe/sessions/${SESSION_ID}/advance`, payload: { expectedPlanVersion: 2 },
|
||||
});
|
||||
const invalid = await app.inject({
|
||||
method: 'POST', url: `/v2/vibe/sessions/${SESSION_ID}/advance`, payload: { expectedPlanVersion: 0 },
|
||||
});
|
||||
|
||||
expect(valid.statusCode).toBe(200);
|
||||
expect(coordinator.serveNext).toHaveBeenCalledWith(USER_ID, SESSION_ID, 2);
|
||||
expect(invalid.statusCode).toBe(400);
|
||||
expect(coordinator.serveNext).toHaveBeenCalledTimes(1);
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('uses the explicit versioned advancement protocol for a served unplayable item', async () => {
|
||||
const { app, coordinator } = await appWithCoordinator(() => USER_ID);
|
||||
const result = await app.inject({
|
||||
method: 'POST', url: `/v2/vibe/sessions/${SESSION_ID}/advance`,
|
||||
payload: {
|
||||
expectedPlanVersion: 2,
|
||||
unplayable: {
|
||||
eventId: '33333333-3333-4333-8333-333333333333',
|
||||
planVersionId: '44444444-4444-4444-8444-444444444444',
|
||||
ordinal: 0,
|
||||
trackId: TRACK_ID,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.statusCode).toBe(200);
|
||||
expect(coordinator.advancePastUnplayable).toHaveBeenCalledWith(USER_ID, SESSION_ID, {
|
||||
expectedPlanVersion: 2,
|
||||
eventId: '33333333-3333-4333-8333-333333333333',
|
||||
planVersionId: '44444444-4444-4444-8444-444444444444',
|
||||
ordinal: 0,
|
||||
trackId: TRACK_ID,
|
||||
});
|
||||
expect(coordinator.serveNext).not.toHaveBeenCalled();
|
||||
await app.close();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,197 @@
|
||||
import { FastifyInstance, FastifyRequest } from 'fastify';
|
||||
import {
|
||||
VIBE_EVENT_TYPES,
|
||||
VibeSessionCoordinator,
|
||||
VibeSessionLifecycleError,
|
||||
VibeSessionNotFoundError,
|
||||
VibePlanNotFoundError,
|
||||
} from '../services/vibe-session-coordinator.service.js';
|
||||
import { VibeCalendarContext } from '../services/generators.service.js';
|
||||
|
||||
const DEFAULT_USER_ID = '00000000-0000-0000-0000-000000000000';
|
||||
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
||||
const ISO_TIMESTAMP_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?(?:Z|[+-]\d{2}:\d{2})$/i;
|
||||
const CLIENT_VIBE_EVENT_TYPES = VIBE_EVENT_TYPES.filter((type) => ![
|
||||
'session_started', 'session_resumed', 'session_ended', 'plan_published', 'track_served',
|
||||
].includes(type));
|
||||
|
||||
type Reply = { code: (statusCode: number) => { send: (payload: unknown) => unknown } };
|
||||
type Body = Record<string, unknown>;
|
||||
|
||||
/** This mirrors the rest of the application until authentication owns identity. */
|
||||
export type VibeIdentityResolver = (request: FastifyRequest) => string | null;
|
||||
|
||||
function isObject(value: unknown): value is Body {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function validUuid(value: unknown): value is string {
|
||||
return value === DEFAULT_USER_ID || (typeof value === 'string' && UUID_RE.test(value));
|
||||
}
|
||||
|
||||
function requestUser(request: FastifyRequest, resolveIdentity?: VibeIdentityResolver): string | null {
|
||||
const resolved = resolveIdentity?.(request);
|
||||
if (resolved !== undefined) return validUuid(resolved) ? resolved : null;
|
||||
const header = request.headers['x-user-id'];
|
||||
const userId = typeof header === 'string' && header ? header : DEFAULT_USER_ID;
|
||||
return validUuid(userId) ? userId : null;
|
||||
}
|
||||
|
||||
function validOccurredAt(value: unknown): value is string {
|
||||
return typeof value === 'string' && ISO_TIMESTAMP_RE.test(value) && !Number.isNaN(Date.parse(value));
|
||||
}
|
||||
|
||||
function parseCalendarContext(value: unknown): VibeCalendarContext | undefined | null {
|
||||
if (value === undefined) return undefined;
|
||||
if (!isObject(value)) return null;
|
||||
const { localHour, weekday, month, timeZone } = value;
|
||||
if (typeof localHour !== 'number' || !Number.isInteger(localHour) || localHour < 0 || localHour > 23) return null;
|
||||
if (typeof weekday !== 'number' || !Number.isInteger(weekday) || weekday < 0 || weekday > 6) return null;
|
||||
if (typeof month !== 'number' || !Number.isInteger(month) || month < 1 || month > 12) return null;
|
||||
if (timeZone !== undefined && (typeof timeZone !== 'string' || timeZone.length > 64)) return null;
|
||||
return { localHour, weekday, month, ...(typeof timeZone === 'string' ? { timeZone } : {}) };
|
||||
}
|
||||
|
||||
function validationError(reply: Reply, message: string) {
|
||||
return reply.code(400).send({ error: message });
|
||||
}
|
||||
|
||||
function sessionIdFrom(request: FastifyRequest, reply: Reply): string | null {
|
||||
const { sessionId } = request.params as { sessionId: string };
|
||||
return validUuid(sessionId) ? sessionId : (validationError(reply, 'sessionId must be a UUID'), null);
|
||||
}
|
||||
|
||||
function parseStart(body: unknown):
|
||||
| { seedTrackId?: string; resumeSessionId?: string; context?: VibeCalendarContext }
|
||||
| { error: string } {
|
||||
const input = isObject(body) ? body : {};
|
||||
if (input.resumeSessionId !== undefined && !validUuid(input.resumeSessionId)) return { error: 'resumeSessionId must be a UUID' };
|
||||
if (input.seedTrackId !== undefined && !validUuid(input.seedTrackId)) return { error: 'seedTrackId must be a UUID' };
|
||||
if (input.resumeSessionId !== undefined && input.seedTrackId !== undefined) return { error: 'resumeSessionId cannot be combined with seedTrackId' };
|
||||
const context = parseCalendarContext(input.context);
|
||||
if (context === null) return { error: 'context must contain valid localHour, weekday, month, and optional timeZone' };
|
||||
return {
|
||||
seedTrackId: input.seedTrackId as string | undefined,
|
||||
resumeSessionId: input.resumeSessionId as string | undefined,
|
||||
...(context ? { context } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function parseEvent(body: unknown):
|
||||
| { type: typeof CLIENT_VIBE_EVENT_TYPES[number]; eventId?: string; trackId?: string; occurredAt?: Date; positionMs?: number; durationMs?: number; payload?: Body }
|
||||
| { error: string } {
|
||||
if (!isObject(body)) return { error: 'event body must be an object' };
|
||||
if (!CLIENT_VIBE_EVENT_TYPES.includes(body.type as typeof CLIENT_VIBE_EVENT_TYPES[number])) return { error: 'type must be a supported client Vibe event type' };
|
||||
if (body.eventId !== undefined && !validUuid(body.eventId)) return { error: 'eventId must be a UUID' };
|
||||
if (body.trackId !== undefined && !validUuid(body.trackId)) return { error: 'trackId must be a UUID' };
|
||||
if (body.occurredAt !== undefined && !validOccurredAt(body.occurredAt)) return { error: 'occurredAt must be an ISO-8601 timestamp' };
|
||||
if (body.positionMs !== undefined && (typeof body.positionMs !== 'number' || !Number.isInteger(body.positionMs) || body.positionMs < 0)) return { error: 'positionMs must be a non-negative integer' };
|
||||
if (body.durationMs !== undefined && (typeof body.durationMs !== 'number' || !Number.isInteger(body.durationMs) || body.durationMs < 0)) return { error: 'durationMs must be a non-negative integer' };
|
||||
if (body.payload !== undefined && !isObject(body.payload)) return { error: 'payload must be an object' };
|
||||
return {
|
||||
type: body.type as typeof CLIENT_VIBE_EVENT_TYPES[number],
|
||||
eventId: body.eventId as string | undefined,
|
||||
trackId: body.trackId as string | undefined,
|
||||
occurredAt: body.occurredAt === undefined ? undefined : new Date(body.occurredAt as string),
|
||||
positionMs: body.positionMs as number | undefined,
|
||||
durationMs: body.durationMs as number | undefined,
|
||||
payload: body.payload as Body | undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function parseAdvance(body: unknown):
|
||||
| { expectedPlanVersion?: number; unplayable?: { eventId: string; planVersionId: string; ordinal: number; trackId: string } }
|
||||
| { error: string } {
|
||||
const input = isObject(body) ? body : {};
|
||||
const version = input.expectedPlanVersion;
|
||||
if (version !== undefined && (typeof version !== 'number' || !Number.isInteger(version) || version < 1)) return { error: 'expectedPlanVersion must be a positive integer' };
|
||||
if (input.unplayable === undefined) return { expectedPlanVersion: version as number | undefined };
|
||||
if (!isObject(input.unplayable)) return { error: 'unplayable must be an object' };
|
||||
const unplayable = input.unplayable;
|
||||
if (version === undefined) return { error: 'expectedPlanVersion is required when advancing an unplayable item' };
|
||||
if (!validUuid(unplayable.eventId) || !validUuid(unplayable.planVersionId) || !validUuid(unplayable.trackId) || typeof unplayable.ordinal !== 'number' || !Number.isInteger(unplayable.ordinal) || unplayable.ordinal < 0) {
|
||||
return { error: 'unplayable requires UUID eventId, planVersionId, trackId and a non-negative integer ordinal' };
|
||||
}
|
||||
return { expectedPlanVersion: version as number, unplayable: unplayable as { eventId: string; planVersionId: string; ordinal: number; trackId: string } };
|
||||
}
|
||||
|
||||
export default async function vibeSessionsRoutes(
|
||||
fastify: FastifyInstance,
|
||||
options: { coordinator: VibeSessionCoordinator; identityResolver?: VibeIdentityResolver },
|
||||
) {
|
||||
const { coordinator, identityResolver } = options;
|
||||
const userFor = (request: FastifyRequest, reply: Reply) => {
|
||||
const userId = requestUser(request, identityResolver);
|
||||
return userId ? userId : (validationError(reply, 'x-user-id must be a UUID'), null);
|
||||
};
|
||||
|
||||
fastify.post('/v2/vibe/sessions', async (request, reply) => {
|
||||
const userId = userFor(request, reply);
|
||||
const input = parseStart(request.body);
|
||||
if (!userId || 'error' in input) return 'error' in input ? validationError(reply, input.error) : undefined;
|
||||
try {
|
||||
return reply.code(201).send(await coordinator.start(userId, input));
|
||||
} catch (error) {
|
||||
return sendCoordinatorError(reply, error);
|
||||
}
|
||||
});
|
||||
|
||||
fastify.get('/v2/vibe/sessions/:sessionId/plans', async (request, reply) => {
|
||||
const userId = userFor(request, reply);
|
||||
const sessionId = sessionIdFrom(request, reply);
|
||||
const { version } = request.query as { version?: string };
|
||||
const parsedVersion = version === undefined ? undefined : Number(version);
|
||||
if (!userId || !sessionId) return;
|
||||
if (version !== undefined && (!Number.isInteger(parsedVersion) || parsedVersion! < 1)) return validationError(reply, 'version must be a positive integer');
|
||||
try {
|
||||
return reply.send(await coordinator.getPlan(userId, sessionId, parsedVersion));
|
||||
} catch (error) {
|
||||
return sendCoordinatorError(reply, error);
|
||||
}
|
||||
});
|
||||
|
||||
fastify.post('/v2/vibe/sessions/:sessionId/events', async (request, reply) => {
|
||||
const userId = userFor(request, reply);
|
||||
const sessionId = sessionIdFrom(request, reply);
|
||||
const input = parseEvent(request.body);
|
||||
if (!userId || !sessionId || 'error' in input) return 'error' in input ? validationError(reply, input.error) : undefined;
|
||||
try {
|
||||
return reply.send(await coordinator.appendEvent(userId, sessionId, input));
|
||||
} catch (error) {
|
||||
return sendCoordinatorError(reply, error);
|
||||
}
|
||||
});
|
||||
|
||||
fastify.post('/v2/vibe/sessions/:sessionId/end', async (request, reply) => {
|
||||
const userId = userFor(request, reply);
|
||||
const sessionId = sessionIdFrom(request, reply);
|
||||
if (!userId || !sessionId) return;
|
||||
try {
|
||||
return reply.send(await coordinator.end(userId, sessionId));
|
||||
} catch (error) {
|
||||
return sendCoordinatorError(reply, error);
|
||||
}
|
||||
});
|
||||
|
||||
fastify.post('/v2/vibe/sessions/:sessionId/advance', async (request, reply) => {
|
||||
const userId = userFor(request, reply);
|
||||
const sessionId = sessionIdFrom(request, reply);
|
||||
const input = parseAdvance(request.body);
|
||||
if (!userId || !sessionId || 'error' in input) return 'error' in input ? validationError(reply, input.error) : undefined;
|
||||
try {
|
||||
return reply.send(input.unplayable
|
||||
? await coordinator.advancePastUnplayable(userId, sessionId, { expectedPlanVersion: input.expectedPlanVersion!, ...input.unplayable })
|
||||
: input.expectedPlanVersion === undefined
|
||||
? await coordinator.serveNext(userId, sessionId)
|
||||
: await coordinator.serveNext(userId, sessionId, input.expectedPlanVersion));
|
||||
} catch (error) {
|
||||
return sendCoordinatorError(reply, error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function sendCoordinatorError(reply: Reply, error: unknown) {
|
||||
if (error instanceof VibeSessionNotFoundError || error instanceof VibePlanNotFoundError) return reply.code(404).send({ error: error.message });
|
||||
if (error instanceof VibeSessionLifecycleError) return reply.code(409).send({ error: error.message, code: 'VIBE_SESSION_NOT_ACTIVE' });
|
||||
throw error;
|
||||
}
|
||||
@@ -7,7 +7,631 @@ function makeService(): { service: DbService; mockQuery: ReturnType<typeof vi.fn
|
||||
return { service, mockQuery };
|
||||
}
|
||||
|
||||
function makeTransactionalService(): { service: DbService; poolQuery: ReturnType<typeof vi.fn>; clientQuery: ReturnType<typeof vi.fn> } {
|
||||
const poolQuery = vi.fn();
|
||||
const clientQuery = vi.fn();
|
||||
const service = new DbService({
|
||||
query: poolQuery,
|
||||
connect: vi.fn().mockResolvedValue({ query: clientQuery, release: vi.fn() }),
|
||||
} as any);
|
||||
return { service, poolQuery, clientQuery };
|
||||
}
|
||||
|
||||
describe('DbService v2 methods', () => {
|
||||
describe('durable Vibe sessions', () => {
|
||||
it('projects unfamiliar feedback into exploration exactly once behind its own marker', async () => {
|
||||
const { service, clientQuery } = makeTransactionalService();
|
||||
const event = {
|
||||
id: 'event-1', session_id: 'session-1', user_id: 'user-1', track_id: 'track-1',
|
||||
type: 'completed', occurred_at: new Date(), client_event_id: null,
|
||||
position_ms: null, duration_ms: null, payload: {},
|
||||
} as any;
|
||||
clientQuery
|
||||
.mockResolvedValueOnce({ rows: [] }) // BEGIN
|
||||
.mockResolvedValueOnce({ rows: [] }) // profile upsert/backfill
|
||||
.mockResolvedValueOnce({ rows: [{ event_id: event.id }] }) // session feedback marker
|
||||
.mockResolvedValueOnce({ rows: [{ context: {} }] }) // no calendar context on legacy session
|
||||
.mockResolvedValueOnce({ rows: [{ familiar: false }] }) // pre-event familiarity
|
||||
.mockResolvedValueOnce({ rows: [{ id: 'evidence-1' }] }) // evidence
|
||||
.mockResolvedValueOnce({ rowCount: 1 }) // discovery belief
|
||||
.mockResolvedValueOnce({ rows: [] }) // no artist/genre targets
|
||||
.mockResolvedValueOnce({ rows: [] }) // no audio targets
|
||||
.mockResolvedValueOnce({ rows: [{
|
||||
exploration_coefficient: 0.36, discovery_radius: 0.434,
|
||||
goals: { type: 'familiar', target: 1, progress: 1 },
|
||||
}] })
|
||||
.mockResolvedValueOnce({ rows: [] }) // session_state projection
|
||||
.mockResolvedValueOnce({ rows: [] }) // COMMIT
|
||||
.mockResolvedValueOnce({ rows: [] }) // BEGIN retry
|
||||
.mockResolvedValueOnce({ rows: [] }) // profile upsert retry
|
||||
.mockResolvedValueOnce({ rows: [] }) // marker conflict
|
||||
.mockResolvedValueOnce({ rows: [] }); // COMMIT retry
|
||||
|
||||
await service.projectVibeSessionFeedback(event);
|
||||
await service.projectVibeSessionFeedback(event);
|
||||
|
||||
expect(clientQuery.mock.calls[1][0]).toContain('INSERT INTO vibe_session_profiles');
|
||||
expect(clientQuery.mock.calls[2][0]).toContain('vibe_session_feedback_projections');
|
||||
expect(clientQuery.mock.calls[3][0]).toContain('SELECT context FROM vibe_sessions');
|
||||
expect(clientQuery.mock.calls[4][0]).toContain('EXISTS (SELECT 1 FROM play_history');
|
||||
expect(clientQuery.mock.calls[5][0]).toContain('INSERT INTO evidence');
|
||||
expect(clientQuery.mock.calls[9][0]).toContain('exploration_coefficient');
|
||||
expect(clientQuery.mock.calls[9][0]).toContain('ELSE goals END');
|
||||
expect(clientQuery.mock.calls[9][0]).toContain('ELSE $3::real END');
|
||||
expect(clientQuery.mock.calls[10][1][4]).toBe(JSON.stringify({ type: 'familiar', target: 1, progress: 1 }));
|
||||
expect(clientQuery.mock.calls.filter(([sql]) => String(sql).includes('INSERT INTO evidence'))).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('creates, reads, and ends sessions scoped to their user', async () => {
|
||||
const { service, poolQuery, clientQuery } = makeTransactionalService();
|
||||
const session = {
|
||||
id: 'session-1', user_id: 'user-1', status: 'active', seed_track_id: null,
|
||||
context: { activity: 'focus' }, policy_version: 'v2.1',
|
||||
};
|
||||
clientQuery
|
||||
.mockResolvedValueOnce({ rows: [] }) // BEGIN
|
||||
.mockResolvedValueOnce({ rows: [] }) // user advisory lock
|
||||
.mockResolvedValueOnce({ rows: [] }) // active-session lock
|
||||
.mockResolvedValueOnce({ rows: [session] }) // insert
|
||||
.mockResolvedValueOnce({ rows: [] }); // COMMIT
|
||||
poolQuery
|
||||
.mockResolvedValueOnce({ rows: [session] })
|
||||
.mockResolvedValueOnce({ rows: [{ ...session, status: 'ended' }] });
|
||||
|
||||
await expect(service.createVibeSession({ userId: 'user-1', policyVersion: 'v2.1' })).resolves.toEqual(session);
|
||||
await expect(service.getVibeSession('session-1', 'user-1')).resolves.toEqual(session);
|
||||
await expect(service.endVibeSession('session-1', 'user-1')).resolves.toMatchObject({ status: 'ended' });
|
||||
|
||||
expect(clientQuery.mock.calls[1][0]).toContain('pg_advisory_xact_lock');
|
||||
expect(clientQuery.mock.calls[2][0]).toContain("status = 'active' FOR UPDATE");
|
||||
expect(clientQuery.mock.calls[3][0]).toContain('INSERT INTO vibe_sessions');
|
||||
expect(clientQuery.mock.calls[3][1]).toEqual(expect.arrayContaining([
|
||||
'user-1', null, expect.any(String), 'v2.1',
|
||||
JSON.stringify({ type: 'discovery', target: 1, progress: 0 }), 0.3, 0.38,
|
||||
]));
|
||||
expect(JSON.parse(clientQuery.mock.calls[3][1][2])).toEqual({});
|
||||
expect(poolQuery.mock.calls[0][0]).toContain('id = $1 AND user_id = $2');
|
||||
expect(poolQuery.mock.calls[1][0]).toContain('COALESCE(ended_at, NOW())');
|
||||
expect(poolQuery.mock.calls[1][0]).toContain('CASE WHEN ended_at IS NULL THEN $3 ELSE status END');
|
||||
});
|
||||
|
||||
it('starts sessions without inventing client context', async () => {
|
||||
const { service, clientQuery } = makeTransactionalService();
|
||||
const session = { id: 'session-1', user_id: 'user-1', status: 'active' };
|
||||
clientQuery
|
||||
.mockResolvedValueOnce({ rows: [] }) // BEGIN
|
||||
.mockResolvedValueOnce({ rows: [] }) // user advisory lock
|
||||
.mockResolvedValueOnce({ rows: [] }) // active-session lock
|
||||
.mockResolvedValueOnce({ rows: [session] }) // insert
|
||||
.mockResolvedValueOnce({ rows: [] }); // COMMIT
|
||||
|
||||
await service.createVibeSession({ userId: 'user-1', policyVersion: 'v2.1' });
|
||||
const insertParameters = clientQuery.mock.calls[3][1];
|
||||
expect(insertParameters.slice(0, 2)).toEqual(['user-1', null]);
|
||||
expect(JSON.parse(insertParameters[2])).toEqual({});
|
||||
expect(insertParameters.slice(3)).toEqual([
|
||||
'v2.1', JSON.stringify({ type: 'discovery', target: 1, progress: 0 }), 0.3, 0.38,
|
||||
]);
|
||||
});
|
||||
|
||||
it('replaces an owned active session and writes its terminal event before starting another', async () => {
|
||||
const { service, clientQuery } = makeTransactionalService();
|
||||
const replacement = { id: 'session-2', user_id: 'user-1', status: 'active' };
|
||||
clientQuery
|
||||
.mockResolvedValueOnce({ rows: [] }) // BEGIN
|
||||
.mockResolvedValueOnce({ rows: [] }) // user advisory lock
|
||||
.mockResolvedValueOnce({ rows: [{ id: 'session-1' }] }) // active lock
|
||||
.mockResolvedValueOnce({ rows: [{ id: 'session-1' }] }) // replace
|
||||
.mockResolvedValueOnce({ rows: [] }) // terminal event
|
||||
.mockResolvedValueOnce({ rows: [replacement] }) // new session
|
||||
.mockResolvedValueOnce({ rows: [] }); // COMMIT
|
||||
|
||||
await expect(service.createVibeSession({ userId: 'user-1', policyVersion: 'v2.1' }))
|
||||
.resolves.toEqual(replacement);
|
||||
expect(clientQuery.mock.calls[3][0]).toContain("status = 'replaced'");
|
||||
expect(clientQuery.mock.calls[4][0]).toContain("'session_ended'");
|
||||
expect(clientQuery.mock.calls[5][0]).toContain('INSERT INTO vibe_sessions');
|
||||
});
|
||||
|
||||
it('records retry-safe events and reports whether the event was inserted', async () => {
|
||||
const { service, clientQuery } = makeTransactionalService();
|
||||
const event = {
|
||||
id: 'event-1', client_event_id: 'client-event-1', session_id: 'session-1',
|
||||
user_id: 'user-1', track_id: 'track-1', type: 'skipped', occurred_at: new Date(),
|
||||
position_ms: 1_500, duration_ms: 10_000, payload: { reason: 'next' },
|
||||
};
|
||||
clientQuery
|
||||
.mockResolvedValueOnce({ rows: [] }) // BEGIN
|
||||
.mockResolvedValueOnce({ rows: [{ id: 'session-1', status: 'active' }] }) // lock session
|
||||
.mockResolvedValueOnce({ rows: [event] }) // existing retry
|
||||
.mockResolvedValueOnce({ rows: [] }); // COMMIT
|
||||
|
||||
const result = await service.recordVibeEvent({
|
||||
sessionId: 'session-1', userId: 'user-1', clientEventId: 'client-event-1',
|
||||
trackId: 'track-1', type: 'skipped', positionMs: 1_500, durationMs: 10_000,
|
||||
payload: { reason: 'next' },
|
||||
});
|
||||
|
||||
expect(result.inserted).toBe(false);
|
||||
expect(result.event.id).toBe('event-1');
|
||||
const [sql, values] = clientQuery.mock.calls[1];
|
||||
expect(sql).toContain('FOR UPDATE');
|
||||
expect(values).toEqual([
|
||||
'session-1', 'user-1',
|
||||
]);
|
||||
expect(clientQuery.mock.calls).toHaveLength(5);
|
||||
expect(clientQuery.mock.calls[3][0]).toContain('vibe_event_projections');
|
||||
expect(clientQuery.mock.calls.map(([query]) => query)).not.toContain(
|
||||
expect.stringContaining('UPDATE vibe_sessions')
|
||||
);
|
||||
});
|
||||
|
||||
it('records a non-material event without mutating session context', async () => {
|
||||
const { service, clientQuery } = makeTransactionalService();
|
||||
const event = {
|
||||
id: 'event-1', client_event_id: null, session_id: 'session-1', user_id: 'user-1', track_id: null,
|
||||
type: 'progress', occurred_at: new Date(), position_ms: null, duration_ms: null,
|
||||
payload: { source: 'player' },
|
||||
};
|
||||
clientQuery
|
||||
.mockResolvedValueOnce({ rows: [] }) // BEGIN
|
||||
.mockResolvedValueOnce({ rows: [{ id: 'session-1', status: 'active' }] }) // lock
|
||||
.mockResolvedValueOnce({ rows: [event] }) // insert
|
||||
.mockResolvedValueOnce({ rows: [] }) // last event timestamp
|
||||
.mockResolvedValueOnce({ rows: [] }); // COMMIT
|
||||
|
||||
await service.recordVibeEvent({
|
||||
sessionId: 'session-1', userId: 'user-1', type: 'progress', payload: { source: 'player' },
|
||||
});
|
||||
|
||||
const values = clientQuery.mock.calls[2][1];
|
||||
const storedPayload = JSON.parse(values[8]);
|
||||
expect(storedPayload).toEqual({ source: 'player' });
|
||||
expect(clientQuery.mock.calls.map(([sql]) => String(sql))).not.toContain(
|
||||
expect.stringContaining('SET context = $3::jsonb'),
|
||||
);
|
||||
});
|
||||
|
||||
it('does not apply a retry body to an existing event', async () => {
|
||||
const { service, clientQuery } = makeTransactionalService();
|
||||
const canonicalEvent = {
|
||||
id: 'event-1', client_event_id: 'client-event-1', session_id: 'session-1', user_id: 'user-1', track_id: null,
|
||||
type: 'progress', occurred_at: new Date(), position_ms: null, duration_ms: null,
|
||||
payload: { source: 'player' },
|
||||
};
|
||||
clientQuery
|
||||
.mockResolvedValueOnce({ rows: [] }) // BEGIN
|
||||
.mockResolvedValueOnce({ rows: [{ id: 'session-1', status: 'active' }] }) // lock
|
||||
.mockResolvedValueOnce({ rows: [canonicalEvent] }) // canonical retry event
|
||||
.mockResolvedValueOnce({ rows: [] }); // COMMIT
|
||||
|
||||
const result = await service.recordVibeEvent({
|
||||
sessionId: 'session-1', userId: 'user-1', clientEventId: 'client-event-1', type: 'progress',
|
||||
payload: { source: 'retry' },
|
||||
});
|
||||
|
||||
expect(result).toEqual({ event: canonicalEvent, inserted: false });
|
||||
expect(clientQuery.mock.calls.map(([sql]) => String(sql))).not.toContain(
|
||||
expect.stringContaining('SET context = $3::jsonb'),
|
||||
);
|
||||
});
|
||||
|
||||
it('projects material feedback once with the durable event transaction', async () => {
|
||||
const { service, clientQuery } = makeTransactionalService();
|
||||
const event = {
|
||||
id: 'event-1', client_event_id: 'client-event-1', session_id: 'session-1',
|
||||
user_id: 'user-1', track_id: 'track-1', type: 'completed', occurred_at: new Date(),
|
||||
position_ms: null, duration_ms: null, payload: {},
|
||||
};
|
||||
const evidence = vi.spyOn(service as any, 'recordTrackEvidence').mockResolvedValue('evidence-1');
|
||||
clientQuery
|
||||
.mockResolvedValueOnce({ rows: [] }) // BEGIN
|
||||
.mockResolvedValueOnce({ rows: [{ id: 'session-1', status: 'active' }] })
|
||||
.mockResolvedValueOnce({ rows: [] }) // no existing idempotency key
|
||||
.mockResolvedValueOnce({ rows: [event] }) // event insert
|
||||
.mockResolvedValueOnce({ rows: [{ event_id: 'event-1' }] }) // projection marker
|
||||
.mockResolvedValueOnce({ rows: [] }) // play history
|
||||
.mockResolvedValueOnce({ rows: [] }) // track counter
|
||||
.mockResolvedValueOnce({ rows: [] }) // session timestamp
|
||||
.mockResolvedValueOnce({ rows: [] }); // COMMIT
|
||||
|
||||
await expect(service.recordVibeEvent({
|
||||
sessionId: 'session-1', userId: 'user-1', clientEventId: 'client-event-1',
|
||||
type: 'completed', trackId: 'track-1',
|
||||
})).resolves.toMatchObject({ inserted: true, event: { id: 'event-1' } });
|
||||
|
||||
expect(clientQuery.mock.calls.map(([sql]) => sql)).toEqual(expect.arrayContaining([
|
||||
expect.stringContaining('vibe_event_projections'),
|
||||
expect.stringContaining('INSERT INTO play_history'),
|
||||
]));
|
||||
expect(evidence).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('rejects an event when no owned session is returned', async () => {
|
||||
const { service, clientQuery } = makeTransactionalService();
|
||||
clientQuery
|
||||
.mockResolvedValueOnce({ rows: [] }) // BEGIN
|
||||
.mockResolvedValueOnce({ rows: [] }) // session lookup
|
||||
.mockResolvedValueOnce({ rows: [] }); // ROLLBACK
|
||||
await expect(service.recordVibeEvent({
|
||||
sessionId: 'session-1', userId: 'other-user', type: 'completed',
|
||||
})).rejects.toThrow('not found or is not owned');
|
||||
});
|
||||
|
||||
it('rejects new events for terminal sessions but returns an existing idempotent retry', async () => {
|
||||
const { service, clientQuery } = makeTransactionalService();
|
||||
const event = {
|
||||
id: 'event-1', client_event_id: 'client-event-1', session_id: 'session-1',
|
||||
user_id: 'user-1', track_id: null, type: 'completed', occurred_at: new Date(),
|
||||
position_ms: null, duration_ms: null, payload: {},
|
||||
};
|
||||
clientQuery
|
||||
.mockResolvedValueOnce({ rows: [] }) // BEGIN: idempotent retry
|
||||
.mockResolvedValueOnce({ rows: [{ id: 'session-1', status: 'ended' }] })
|
||||
.mockResolvedValueOnce({ rows: [event] })
|
||||
.mockResolvedValueOnce({ rows: [] }) // COMMIT
|
||||
.mockResolvedValueOnce({ rows: [] }) // BEGIN: new event
|
||||
.mockResolvedValueOnce({ rows: [{ id: 'session-1', status: 'ended' }] })
|
||||
.mockResolvedValueOnce({ rows: [] }) // no matching retry
|
||||
.mockResolvedValueOnce({ rows: [] }); // ROLLBACK
|
||||
|
||||
await expect(service.recordVibeEvent({
|
||||
sessionId: 'session-1', userId: 'user-1', clientEventId: 'client-event-1', type: 'completed',
|
||||
})).resolves.toEqual({ event, inserted: false });
|
||||
await expect(service.recordVibeEvent({
|
||||
sessionId: 'session-1', userId: 'user-1', clientEventId: 'new-event-1', type: 'completed',
|
||||
})).rejects.toThrow('Cannot record a new event for ended Vibe session');
|
||||
|
||||
expect(clientQuery.mock.calls.map(([query]) => query)).not.toContain(
|
||||
expect.stringContaining('INSERT INTO vibe_events')
|
||||
);
|
||||
});
|
||||
|
||||
it('locks the terminal transition with its event and makes terminal retries no-ops', async () => {
|
||||
const { service, clientQuery } = makeTransactionalService();
|
||||
const active = { id: 'session-1', user_id: 'user-1', status: 'active' };
|
||||
const ended = { ...active, status: 'ended', ended_at: new Date() };
|
||||
clientQuery
|
||||
.mockResolvedValueOnce({ rows: [] }) // BEGIN
|
||||
.mockResolvedValueOnce({ rows: [active] }) // lock
|
||||
.mockResolvedValueOnce({ rows: [] }) // terminal event
|
||||
.mockResolvedValueOnce({ rows: [ended] }) // status transition
|
||||
.mockResolvedValueOnce({ rows: [] }); // COMMIT
|
||||
|
||||
await expect(service.endVibeSessionWithEvent('session-1', 'user-1'))
|
||||
.resolves.toEqual({ session: ended, ended: true });
|
||||
expect(clientQuery.mock.calls[1][0]).toContain('FOR UPDATE');
|
||||
expect(clientQuery.mock.calls[2][0]).toContain("'session_ended'");
|
||||
expect(clientQuery.mock.calls[3][0]).toContain("status = 'ended'");
|
||||
});
|
||||
|
||||
it('resumes an owned session once and records session_resumed in the same lock', async () => {
|
||||
const { service, clientQuery } = makeTransactionalService();
|
||||
const active = { id: 'session-1', user_id: 'user-1', status: 'active' };
|
||||
clientQuery
|
||||
.mockResolvedValueOnce({ rows: [] }) // BEGIN
|
||||
.mockResolvedValueOnce({ rows: [{ ...active, status: 'paused' }] }) // lock
|
||||
.mockResolvedValueOnce({ rows: [] }) // no old resume event
|
||||
.mockResolvedValueOnce({ rows: [active] }) // activate
|
||||
.mockResolvedValueOnce({ rows: [] }) // ledger event
|
||||
.mockResolvedValueOnce({ rows: [] }); // COMMIT
|
||||
|
||||
await expect(service.resumeVibeSession('session-1', 'user-1'))
|
||||
.resolves.toEqual({ session: active, resumed: true });
|
||||
expect(clientQuery.mock.calls[1][0]).toContain('FOR UPDATE');
|
||||
expect(clientQuery.mock.calls[4][0]).toContain("'session_resumed'");
|
||||
});
|
||||
});
|
||||
|
||||
describe('durable Vibe plans', () => {
|
||||
it('publishes a revision and its plan_published event in one transaction', async () => {
|
||||
const { service, clientQuery } = makeTransactionalService();
|
||||
const published = {
|
||||
id: 'plan-1', session_id: 'session-1', version: 1, reason: 'session_started',
|
||||
state_snapshot: {}, objective_snapshot: {}, created_at: new Date(),
|
||||
};
|
||||
clientQuery
|
||||
.mockResolvedValueOnce({ rows: [] }) // BEGIN
|
||||
.mockResolvedValueOnce({ rows: [{ id: 'session-1', status: 'active' }] }) // lock
|
||||
.mockResolvedValueOnce({ rows: [published] }) // header
|
||||
.mockResolvedValueOnce({ rows: [] }) // item
|
||||
.mockResolvedValueOnce({ rows: [] }) // plan_published
|
||||
.mockResolvedValueOnce({ rows: [] }) // timestamp
|
||||
.mockResolvedValueOnce({ rows: [] }); // COMMIT
|
||||
|
||||
await expect(service.publishVibePlan({
|
||||
sessionId: 'session-1', userId: 'user-1', version: 1, reason: 'session_started',
|
||||
stateSnapshot: {}, objectiveSnapshot: {},
|
||||
items: [{ ordinal: 0, track_id: 'track-1', slot_role: 'next', candidate_source: 'comfort', score: 1, score_breakdown: {}, explanation: [], committed: false }],
|
||||
})).resolves.toMatchObject({ version: 1, items: [{ track_id: 'track-1' }] });
|
||||
|
||||
expect(clientQuery.mock.calls[1][0]).toContain('FOR UPDATE');
|
||||
expect(clientQuery.mock.calls[4][0]).toContain("'plan_published'");
|
||||
expect(clientQuery.mock.calls.at(-1)?.[0]).toBe('COMMIT');
|
||||
});
|
||||
|
||||
it('rolls back the plan header and items if writing plan_published fails', async () => {
|
||||
const { service, clientQuery } = makeTransactionalService();
|
||||
const published = {
|
||||
id: 'plan-1', session_id: 'session-1', version: 1, reason: 'session_started',
|
||||
state_snapshot: {}, objective_snapshot: {}, created_at: new Date(),
|
||||
};
|
||||
clientQuery
|
||||
.mockResolvedValueOnce({ rows: [] }) // BEGIN
|
||||
.mockResolvedValueOnce({ rows: [{ id: 'session-1', status: 'active' }] })
|
||||
.mockResolvedValueOnce({ rows: [published] }) // header
|
||||
.mockResolvedValueOnce({ rows: [] }) // item
|
||||
.mockRejectedValueOnce(new Error('ledger write failed'))
|
||||
.mockResolvedValueOnce({ rows: [] }); // ROLLBACK
|
||||
|
||||
await expect(service.publishVibePlan({
|
||||
sessionId: 'session-1', userId: 'user-1', version: 1, reason: 'session_started',
|
||||
stateSnapshot: {}, objectiveSnapshot: {},
|
||||
items: [{ ordinal: 0, track_id: 'track-1', slot_role: 'next', candidate_source: 'comfort', score: 1, score_breakdown: {}, explanation: [], committed: false }],
|
||||
})).rejects.toThrow('ledger write failed');
|
||||
expect(clientQuery.mock.calls.at(-1)?.[0]).toBe('ROLLBACK');
|
||||
expect(clientQuery.mock.calls.map(([sql]) => sql)).not.toContain('COMMIT');
|
||||
});
|
||||
|
||||
it('refuses a delayed initial publication after a concurrent start replaced its session', async () => {
|
||||
const { service, clientQuery } = makeTransactionalService();
|
||||
clientQuery
|
||||
.mockResolvedValueOnce({ rows: [] }) // BEGIN
|
||||
.mockResolvedValueOnce({ rows: [{ id: 'session-1', status: 'replaced' }] })
|
||||
.mockResolvedValueOnce({ rows: [] }); // ROLLBACK
|
||||
|
||||
await expect(service.publishVibePlan({
|
||||
sessionId: 'session-1', userId: 'user-1', version: 1, reason: 'session_started',
|
||||
stateSnapshot: {}, objectiveSnapshot: {}, items: [],
|
||||
})).rejects.toThrow('Cannot publish a plan for replaced Vibe session');
|
||||
expect(clientQuery.mock.calls.map(([sql]) => sql)).not.toContain(expect.stringContaining('INSERT INTO vibe_plan_versions'));
|
||||
});
|
||||
|
||||
it('reads every durable session track as a replacement-plan exclusion', async () => {
|
||||
const { service, mockQuery } = makeService();
|
||||
mockQuery.mockResolvedValueOnce({ rows: [{ track_id: 'served' }, { track_id: 'skipped' }, { track_id: 'disliked' }] });
|
||||
await expect(service.getVibeSessionTrackIds('session-1', 'user-1'))
|
||||
.resolves.toEqual(['served', 'skipped', 'disliked']);
|
||||
expect(mockQuery.mock.calls[0][0]).toContain('SELECT DISTINCT e.track_id');
|
||||
expect(mockQuery.mock.calls[0][0]).toContain('e.track_id IS NOT NULL');
|
||||
});
|
||||
|
||||
it('writes a header and all items in one transaction', async () => {
|
||||
const { service, clientQuery } = makeTransactionalService();
|
||||
clientQuery
|
||||
.mockResolvedValueOnce({ rows: [] }) // BEGIN
|
||||
.mockResolvedValueOnce({ rows: [{
|
||||
id: 'plan-1', session_id: 'session-1', version: 1, reason: 'session_started',
|
||||
state_snapshot: { energy: 0.5 }, objective_snapshot: { freshness: 0.4 }, created_at: new Date(),
|
||||
}] })
|
||||
.mockResolvedValueOnce({ rows: [] }) // item
|
||||
.mockResolvedValueOnce({ rows: [] }); // COMMIT
|
||||
|
||||
const plan = await service.persistVibePlan({
|
||||
sessionId: 'session-1', userId: 'user-1', version: 1, reason: 'session_started',
|
||||
stateSnapshot: { energy: 0.5 }, objectiveSnapshot: { freshness: 0.4 },
|
||||
items: [{
|
||||
ordinal: 0, track_id: 'track-1', slot_role: 'anchor', candidate_source: 'comfort',
|
||||
score: 0.91, score_breakdown: { affinity: 0.8 }, explanation: [{ because: 'favourite' }], committed: true,
|
||||
}],
|
||||
});
|
||||
|
||||
expect(plan.items[0].plan_version_id).toBe('plan-1');
|
||||
expect(clientQuery.mock.calls[1][0]).toContain('INSERT INTO vibe_plan_versions');
|
||||
expect(clientQuery.mock.calls[2][0]).toContain('INSERT INTO vibe_plan_items');
|
||||
expect(clientQuery.mock.calls[3][0]).toBe('COMMIT');
|
||||
});
|
||||
|
||||
it('serves and commits one next item under the session lock', async () => {
|
||||
const { service, clientQuery } = makeTransactionalService();
|
||||
const item = {
|
||||
plan_version_id: 'plan-1', ordinal: 0, track_id: 'track-1', slot_role: 'next',
|
||||
candidate_source: 'comfort', score: 0.9, score_breakdown: {}, explanation: [], committed: true,
|
||||
};
|
||||
clientQuery
|
||||
.mockResolvedValueOnce({ rows: [] }) // BEGIN
|
||||
.mockResolvedValueOnce({ rows: [{ status: 'active' }] }) // session lock
|
||||
.mockResolvedValueOnce({ rows: [{ id: 'plan-1', version: 1 }] }) // latest plan
|
||||
.mockResolvedValueOnce({ rows: [item] }) // commit item
|
||||
.mockResolvedValueOnce({ rows: [] }) // track_served event
|
||||
.mockResolvedValueOnce({ rows: [] }) // timestamp
|
||||
.mockResolvedValueOnce({ rows: [] }); // COMMIT
|
||||
|
||||
await expect(service.serveNextVibePlanItem('session-1', 'user-1')).resolves.toEqual({ item, stale: false });
|
||||
expect(clientQuery.mock.calls[1][0]).toContain('FOR UPDATE');
|
||||
expect(clientQuery.mock.calls[3][0]).toContain('SET committed = true');
|
||||
expect(clientQuery.mock.calls[3][1]).toEqual(['plan-1']);
|
||||
expect(clientQuery.mock.calls[4][0]).toContain("'track_served'");
|
||||
});
|
||||
|
||||
it('returns a newer preview signal without committing when the expected plan is stale', async () => {
|
||||
const { service, clientQuery } = makeTransactionalService();
|
||||
clientQuery
|
||||
.mockResolvedValueOnce({ rows: [] }) // BEGIN
|
||||
.mockResolvedValueOnce({ rows: [{ status: 'active' }] }) // session lock
|
||||
.mockResolvedValueOnce({ rows: [{ id: 'plan-2', version: 2 }] }) // latest
|
||||
.mockResolvedValueOnce({ rows: [] }); // COMMIT
|
||||
|
||||
await expect(service.serveNextVibePlanItem('session-1', 'user-1', 1))
|
||||
.resolves.toEqual({ item: null, stale: true });
|
||||
expect(clientQuery.mock.calls).toHaveLength(4);
|
||||
expect(clientQuery.mock.calls.map(([sql]) => sql)).not.toContain(expect.stringContaining('SET committed = true'));
|
||||
});
|
||||
|
||||
it('returns the original item when a version-aware next request is retried', async () => {
|
||||
const { service, clientQuery } = makeTransactionalService();
|
||||
const item = {
|
||||
plan_version_id: 'plan-1', ordinal: 0, track_id: 'track-1', slot_role: 'next',
|
||||
candidate_source: 'comfort', score: 0.9, score_breakdown: {}, explanation: [], committed: true,
|
||||
};
|
||||
clientQuery
|
||||
.mockResolvedValueOnce({ rows: [] }) // BEGIN
|
||||
.mockResolvedValueOnce({ rows: [{ status: 'active' }] }) // session lock
|
||||
.mockResolvedValueOnce({ rows: [{ id: 'plan-1', version: 1 }] }) // latest
|
||||
.mockResolvedValueOnce({ rows: [item] }) // prior served item
|
||||
.mockResolvedValueOnce({ rows: [] }); // COMMIT
|
||||
|
||||
await expect(service.serveNextVibePlanItem('session-1', 'user-1', 1))
|
||||
.resolves.toEqual({ item, stale: false });
|
||||
expect(clientQuery.mock.calls.map(([sql]) => sql)).not.toContain(expect.stringContaining('SET committed = true'));
|
||||
});
|
||||
|
||||
it('advances a served unplayable item with a separate idempotent event and commits one replacement', async () => {
|
||||
const { service, clientQuery } = makeTransactionalService();
|
||||
const replacement = {
|
||||
plan_version_id: 'plan-1', ordinal: 1, track_id: 'track-2', slot_role: null,
|
||||
candidate_source: 'discovery', score: 0.8, score_breakdown: {}, explanation: [], committed: true,
|
||||
};
|
||||
clientQuery
|
||||
.mockResolvedValueOnce({ rows: [] }) // BEGIN
|
||||
.mockResolvedValueOnce({ rows: [{ status: 'active' }] }) // session lock
|
||||
.mockResolvedValueOnce({ rows: [{ id: 'plan-1', version: 1 }] }) // latest plan
|
||||
.mockResolvedValueOnce({ rows: [] }) // no prior playback_error event
|
||||
.mockResolvedValueOnce({ rows: [{ track_id: 'track-1', ordinal: 0 }] }) // current served cursor
|
||||
.mockResolvedValueOnce({ rows: [{ id: 'error-1' }] }) // playback_error event
|
||||
.mockResolvedValueOnce({ rows: [replacement] }) // commit replacement
|
||||
.mockResolvedValueOnce({ rows: [] }) // replacement track_served event
|
||||
.mockResolvedValueOnce({ rows: [] }) // playback_error result payload
|
||||
.mockResolvedValueOnce({ rows: [] }) // session timestamp
|
||||
.mockResolvedValueOnce({ rows: [] }); // COMMIT
|
||||
|
||||
await expect(service.advancePastUnplayableVibePlanItem('session-1', 'user-1', {
|
||||
expectedPlanVersion: 1,
|
||||
planVersionId: 'plan-1',
|
||||
ordinal: 0,
|
||||
trackId: 'track-1',
|
||||
eventId: 'event-1',
|
||||
})).resolves.toEqual({ item: replacement, stale: false });
|
||||
|
||||
expect(clientQuery.mock.calls[5][0]).toContain("'playback_error'");
|
||||
expect(clientQuery.mock.calls[6][0]).toContain('SET committed = true');
|
||||
expect(clientQuery.mock.calls[7][0]).toContain("'track_served'");
|
||||
expect(clientQuery.mock.calls[8][0]).toContain('UPDATE vibe_events SET payload');
|
||||
});
|
||||
|
||||
it('refuses an old served cursor when events share a timestamp by ordering the immutable plan ordinal', async () => {
|
||||
const { service, clientQuery } = makeTransactionalService();
|
||||
clientQuery
|
||||
.mockResolvedValueOnce({ rows: [] }) // BEGIN
|
||||
.mockResolvedValueOnce({ rows: [{ status: 'active' }] }) // session lock
|
||||
.mockResolvedValueOnce({ rows: [{ id: 'plan-1', version: 1 }] }) // latest plan
|
||||
.mockResolvedValueOnce({ rows: [] }) // no prior playback_error event
|
||||
// The lower-ordinal event can have a lexically greater UUID at the
|
||||
// same occurred_at. The cursor must still be the highest immutable
|
||||
// plan ordinal, never whichever UUID sorts last.
|
||||
.mockResolvedValueOnce({ rows: [{ track_id: 'track-2', ordinal: 1 }] }) // current served cursor
|
||||
.mockResolvedValueOnce({ rows: [] }); // ROLLBACK
|
||||
|
||||
await expect(service.advancePastUnplayableVibePlanItem('session-1', 'user-1', {
|
||||
expectedPlanVersion: 1,
|
||||
planVersionId: 'plan-1',
|
||||
ordinal: 0,
|
||||
trackId: 'track-1',
|
||||
eventId: 'event-1',
|
||||
})).rejects.toThrow('not the current served cursor');
|
||||
|
||||
expect(clientQuery.mock.calls[4][0]).toContain('JOIN vibe_plan_items');
|
||||
expect(clientQuery.mock.calls[4][0]).toContain('ORDER BY i.ordinal DESC');
|
||||
expect(clientQuery.mock.calls[4][0]).not.toContain('id DESC');
|
||||
expect(clientQuery.mock.calls.map(([sql]) => sql)).not.toContain(expect.stringContaining('SET committed = true'));
|
||||
});
|
||||
|
||||
it('retries an unplayable advancement with the same event id without consuming another item', async () => {
|
||||
const { service, clientQuery } = makeTransactionalService();
|
||||
const replacement = {
|
||||
plan_version_id: 'plan-1', ordinal: 1, track_id: 'track-2', slot_role: null,
|
||||
candidate_source: 'discovery', score: 0.8, score_breakdown: {}, explanation: [], committed: true,
|
||||
};
|
||||
clientQuery
|
||||
.mockResolvedValueOnce({ rows: [] }) // BEGIN
|
||||
.mockResolvedValueOnce({ rows: [{ status: 'active' }] }) // session lock
|
||||
.mockResolvedValueOnce({ rows: [{ id: 'plan-1', version: 1 }] }) // latest plan
|
||||
.mockResolvedValueOnce({ rows: [{ type: 'playback_error', payload: {
|
||||
planVersionId: 'plan-1', ordinal: 0, trackId: 'track-1',
|
||||
advancedTo: { planVersionId: 'plan-1', ordinal: 1 },
|
||||
} }] }) // prior explicit advancement
|
||||
.mockResolvedValueOnce({ rows: [replacement] }) // canonical replacement
|
||||
.mockResolvedValueOnce({ rows: [] }); // COMMIT
|
||||
|
||||
await expect(service.advancePastUnplayableVibePlanItem('session-1', 'user-1', {
|
||||
expectedPlanVersion: 1,
|
||||
planVersionId: 'plan-1',
|
||||
ordinal: 0,
|
||||
trackId: 'track-1',
|
||||
eventId: 'event-1',
|
||||
})).resolves.toEqual({ item: replacement, stale: false });
|
||||
|
||||
expect(clientQuery.mock.calls.map(([sql]) => sql)).not.toContain(expect.stringContaining('SET committed = true'));
|
||||
expect(clientQuery.mock.calls).toHaveLength(6);
|
||||
});
|
||||
|
||||
it('reads the latest revision and reconstructs ordered plan items', async () => {
|
||||
const { service, mockQuery } = makeService();
|
||||
mockQuery.mockResolvedValue({ rows: [{
|
||||
id: 'plan-2', session_id: 'session-1', version: 2, reason: 'feedback',
|
||||
state_snapshot: { energy: 0.7 }, objective_snapshot: { freshness: 0.5 }, created_at: new Date(),
|
||||
item_plan_version_id: 'plan-2', ordinal: 0, track_id: 'track-2', slot_role: 'next',
|
||||
candidate_source: 'adjacent', score: 0.8, score_breakdown: { transition: 0.7 },
|
||||
explanation: [{ because: 'similar artist' }], committed: true,
|
||||
}, {
|
||||
id: 'plan-2', session_id: 'session-1', version: 2, reason: 'feedback',
|
||||
state_snapshot: { energy: 0.7 }, objective_snapshot: { freshness: 0.5 }, created_at: new Date(),
|
||||
item_plan_version_id: 'plan-2', ordinal: 1, track_id: 'track-3', slot_role: null,
|
||||
candidate_source: 'discovery', score: 0.6, score_breakdown: { novelty: 0.5 },
|
||||
explanation: [], committed: false,
|
||||
}] });
|
||||
|
||||
const plan = await service.getVibePlan('session-1', 'user-1');
|
||||
|
||||
expect(plan?.version).toBe(2);
|
||||
expect(plan?.items.map((item) => item.track_id)).toEqual(['track-2', 'track-3']);
|
||||
expect(mockQuery.mock.calls[0][0]).toContain('SELECT MAX(version) FROM vibe_plan_versions');
|
||||
expect(mockQuery.mock.calls[0][1]).toEqual(['session-1', 'user-1', null]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createAlbum', () => {
|
||||
it('persists the canonical release date instead of discarding it', async () => {
|
||||
const { service, mockQuery } = makeService();
|
||||
mockQuery.mockResolvedValue({ rows: [{ id: 'album-1' }] });
|
||||
|
||||
await service.createAlbum({
|
||||
id: 'album-1', artist_id: 'artist-1', title: 'Album',
|
||||
year: 2026, release_date: '2026-07-15', artwork_id: null,
|
||||
});
|
||||
|
||||
const [sql, params] = mockQuery.mock.calls[0];
|
||||
expect(sql).toContain('release_date');
|
||||
expect(params[3]).toBe('2026-07-15');
|
||||
});
|
||||
});
|
||||
|
||||
describe('recordPlay durability', () => {
|
||||
it('copies the track identity onto the row and records what was heard', async () => {
|
||||
const { service, mockQuery } = makeService();
|
||||
mockQuery.mockResolvedValue({ rows: [{ id: 'history-1' }] });
|
||||
|
||||
await service.recordPlay('user-1', 'track-1', false, undefined, 91_000);
|
||||
|
||||
const [sql, params] = mockQuery.mock.calls[0];
|
||||
// A LEFT JOIN, not a plain VALUES: the insert must still happen when the
|
||||
// track id resolves to nothing.
|
||||
expect(sql).toContain('LEFT JOIN tracks');
|
||||
expect(sql).toContain('t.title');
|
||||
expect(sql).toContain('t.artist');
|
||||
expect(params).toEqual(['user-1', 'track-1', null, false, 91_000]);
|
||||
});
|
||||
|
||||
it('leaves listened_ms null when the caller has no duration to report', async () => {
|
||||
const { service, mockQuery } = makeService();
|
||||
mockQuery.mockResolvedValue({ rows: [{ id: 'history-1' }] });
|
||||
|
||||
await service.recordPlay('user-1', 'track-1', false);
|
||||
|
||||
expect(mockQuery.mock.calls[0][1][4]).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('upsertClaim', () => {
|
||||
it('calls INSERT ... ON CONFLICT with correct parameters', async () => {
|
||||
const { service, mockQuery } = makeService();
|
||||
@@ -168,6 +792,75 @@ describe('DbService v2 methods', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('track evidence propagation', () => {
|
||||
it('projects a favourite onto artist, genre, and present audio dimensions', async () => {
|
||||
const { service, mockQuery } = makeService();
|
||||
let evidenceNumber = 0;
|
||||
mockQuery.mockImplementation((sql: string) => {
|
||||
if (sql.includes('INSERT INTO evidence')) return Promise.resolve({ rows: [{ id: `ev-${++evidenceNumber}` }] });
|
||||
if (sql.includes('WITH artist_ids')) {
|
||||
return Promise.resolve({ rows: [
|
||||
{ entity_type: 'artist', entity_id: 'artist-1' },
|
||||
{ entity_type: 'genre', entity_id: 'genre-1' },
|
||||
] });
|
||||
}
|
||||
if (sql.includes('SELECT energy, bpm, valence')) {
|
||||
return Promise.resolve({ rows: [{ energy: 0.81, bpm: 128, valence: 0.22 }] });
|
||||
}
|
||||
return Promise.resolve({ rowCount: 1, rows: [] });
|
||||
});
|
||||
|
||||
await service.recordTrackEvidence({
|
||||
user_id: 'user-1', track_id: 'track-1', signal: 'add_to_favorites',
|
||||
profile: 'longterm', weight: 0.60,
|
||||
});
|
||||
|
||||
const evidenceWrites = mockQuery.mock.calls
|
||||
.filter(([sql]) => (sql as string).includes('INSERT INTO evidence'))
|
||||
.map(([, params]) => params as unknown[]);
|
||||
expect(evidenceWrites.map(params => params[1])).toEqual([
|
||||
'track', 'artist', 'genre', 'audio', 'audio', 'audio',
|
||||
]);
|
||||
|
||||
const beliefWrites = mockQuery.mock.calls
|
||||
.filter(([sql]) => (sql as string).includes('INSERT INTO listener_beliefs'))
|
||||
.map(([, params]) => params as unknown[]);
|
||||
const artistBelief = beliefWrites.find(params => params[2] === 'artist');
|
||||
const genreBelief = beliefWrites.find(params => params[2] === 'genre');
|
||||
expect(artistBelief?.[3]).toBe('artist-1');
|
||||
expect(artistBelief?.[5]).toBeCloseTo(0.54); // one favourite is a usable comfort signal
|
||||
expect(genreBelief?.[5]).toBeCloseTo(0.27);
|
||||
expect(beliefWrites.filter(params => params[2] === 'audio')).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('rebuilds shared beliefs from local completed plays and feedback without appending evidence', async () => {
|
||||
const clientQuery = vi.fn((sql: string) => {
|
||||
if (sql === 'BEGIN' || sql === 'COMMIT' || sql === 'ROLLBACK') return Promise.resolve({ rows: [] });
|
||||
if (sql.includes('DELETE FROM listener_beliefs')) return Promise.resolve({ rowCount: 0, rows: [] });
|
||||
if (sql.includes('FROM play_history ph')) {
|
||||
return Promise.resolve({ rows: [{
|
||||
track_id: 'track-1', signal: 'playback_completed', profile: 'longterm', weight: 0.10,
|
||||
}] });
|
||||
}
|
||||
if (sql.includes('WITH artist_ids')) return Promise.resolve({ rows: [{ entity_type: 'artist', entity_id: 'artist-1' }] });
|
||||
if (sql.includes('SELECT energy, bpm, valence')) return Promise.resolve({ rows: [] });
|
||||
return Promise.resolve({ rowCount: 1, rows: [] });
|
||||
});
|
||||
const connect = vi.fn().mockResolvedValue({ query: clientQuery, release: vi.fn() });
|
||||
const service = new DbService({ query: vi.fn(), connect } as any);
|
||||
|
||||
const result = await service.rebuildDerivedListenerBeliefs('user-1');
|
||||
|
||||
expect(result).toEqual({ interactions: 1, beliefs: 1 });
|
||||
expect(clientQuery.mock.calls.some(([sql]) => (sql as string).includes('FROM feedback f'))).toBe(true);
|
||||
expect(clientQuery.mock.calls.some(([sql]) => (sql as string).includes('INSERT INTO evidence'))).toBe(false);
|
||||
const beliefCall = (clientQuery.mock.calls as unknown as Array<[string, unknown[]]>).
|
||||
find(([sql]) => sql.includes('INSERT INTO listener_beliefs'));
|
||||
const beliefParams = beliefCall?.[1] ?? [];
|
||||
expect(beliefParams.slice(1, 5)).toEqual(['longterm', 'artist', 'artist-1', 'affinity']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getFusedTrackArtists', () => {
|
||||
it('reads from claim_fusion view', async () => {
|
||||
const { service, mockQuery } = makeService();
|
||||
|
||||
+1351
-37
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,47 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { DiscoveryService } from './discovery.service.js';
|
||||
import type { DbService } from './db.service.js';
|
||||
|
||||
function makeDb() {
|
||||
const query = vi.fn();
|
||||
return {
|
||||
pgClient: { query },
|
||||
getListenerBeliefs: vi.fn(),
|
||||
upsertClaim: vi.fn().mockResolvedValue('claim-id'),
|
||||
} as unknown as DbService;
|
||||
}
|
||||
|
||||
describe('DiscoveryService System E provenance', () => {
|
||||
it('writes graph-walk claims against discovery_candidate using registered graph provenance', async () => {
|
||||
const db = makeDb();
|
||||
(db.getListenerBeliefs as any).mockResolvedValue([
|
||||
{ entity_id: '11111111-1111-1111-1111-111111111111', value: 0.8 },
|
||||
]);
|
||||
(db.pgClient.query as any)
|
||||
.mockResolvedValueOnce({ rows: [{ candidate_artist_id: '22222222-2222-2222-2222-222222222222' }] })
|
||||
.mockResolvedValueOnce({ rows: [{ id: '33333333-3333-3333-3333-333333333333' }] });
|
||||
|
||||
await new DiscoveryService(db).walkGraphForDiscovery('00000000-0000-0000-0000-000000000000');
|
||||
|
||||
expect(db.upsertClaim).toHaveBeenCalledWith(expect.objectContaining({
|
||||
subject_type: 'discovery_candidate',
|
||||
source: 'graph_exploration',
|
||||
}));
|
||||
});
|
||||
|
||||
it('does not queue artist-only graph candidates without a vetted acquisition URL', async () => {
|
||||
const db = makeDb();
|
||||
(db.pgClient.query as any)
|
||||
.mockResolvedValueOnce({ rows: [{ id: 'candidate-1', notes: { discovery_source: 'graph_exploration' } }] })
|
||||
.mockResolvedValueOnce({ rows: [{ cnt: 0 }] })
|
||||
.mockResolvedValueOnce({ rows: [{ object_id: 'artist-1', fused_value: 0.7 }] })
|
||||
.mockResolvedValueOnce({ rows: [] });
|
||||
|
||||
const result = await new DiscoveryService(db).evalCandidates('00000000-0000-0000-0000-000000000000');
|
||||
|
||||
expect(result).toEqual([{
|
||||
candidateId: 'candidate-1', shouldAcquire: false, reason: 'awaiting resolved acquisition source',
|
||||
}]);
|
||||
expect((db.pgClient.query as any).mock.calls.at(-1)[0]).toContain("status = 'awaiting_resolution'");
|
||||
});
|
||||
});
|
||||
@@ -83,12 +83,17 @@ export class DiscoveryService {
|
||||
const dcId = dcRes.rows[0].id;
|
||||
const relevance = Math.min(belief.value, 0.8);
|
||||
|
||||
// A candidate is not a track. Claims intentionally support generic
|
||||
// entity types, so keeping this distinction prevents graph reads from
|
||||
// treating a random candidate UUID as a library track UUID.
|
||||
await this.db.upsertClaim({
|
||||
subject_type: 'track',
|
||||
subject_type: 'discovery_candidate',
|
||||
subject_id: dcId,
|
||||
predicate: 'discovery_candidate',
|
||||
object_type: 'artist',
|
||||
object_id: row.candidate_artist_id,
|
||||
// Registered in source_trust by the System E migration. Previously
|
||||
// this unregistered value violated claims.source's foreign key.
|
||||
source: 'graph_exploration',
|
||||
confidence: relevance,
|
||||
raw: {
|
||||
@@ -134,7 +139,7 @@ export class DiscoveryService {
|
||||
const claimRes = await this.db.pgClient.query<{ object_id: string; fused_value: number }>(
|
||||
`SELECT object_id, fused_value
|
||||
FROM claim_fusion
|
||||
WHERE subject_type = 'track' AND subject_id = $1::uuid
|
||||
WHERE subject_type = 'discovery_candidate' AND subject_id = $1::uuid
|
||||
AND predicate = 'discovery_candidate'
|
||||
LIMIT 1`,
|
||||
[row.id]
|
||||
@@ -143,6 +148,27 @@ export class DiscoveryService {
|
||||
const relevance = claimRes.rows[0]?.fused_value ?? 0;
|
||||
const candidateArtistId = claimRes.rows[0]?.object_id;
|
||||
|
||||
// A candidate must name what to fetch: either a vetted HTTPS URL, or a
|
||||
// track-level search phrase the worker resolves against its allow-listed
|
||||
// hosts. An artist-only candidate (what a bare graph walk produces) names
|
||||
// neither, and parks here rather than downloading something arbitrary
|
||||
// under that artist's name.
|
||||
const notes = typeof row.notes === 'string' ? safeJson(row.notes) : row.notes;
|
||||
const acquisition = (notes as { acquisition?: { url?: unknown; query?: unknown } } | null)?.acquisition;
|
||||
const hasUrl = typeof acquisition?.url === 'string' && acquisition.url.trim() !== '';
|
||||
const hasQuery = typeof acquisition?.query === 'string' && acquisition.query.trim() !== '';
|
||||
if (!hasUrl && !hasQuery) {
|
||||
await this.db.pgClient.query(
|
||||
`UPDATE discovery_candidates
|
||||
SET status = 'awaiting_resolution', last_eval_at = NOW(),
|
||||
last_error = 'no acquisition url or search query attached'
|
||||
WHERE id = $1`,
|
||||
[row.id]
|
||||
);
|
||||
results.push({ candidateId: row.id, shouldAcquire: false, reason: 'awaiting resolved acquisition source' });
|
||||
continue;
|
||||
}
|
||||
|
||||
const noveltyBeliefs = await this.db.getListenerBeliefs({
|
||||
userId,
|
||||
profile: 'discovery',
|
||||
@@ -160,6 +186,7 @@ export class DiscoveryService {
|
||||
FROM discovery_candidates dc
|
||||
JOIN claims c ON c.subject_id = dc.id
|
||||
WHERE dc.status = 'acquiring'
|
||||
AND c.subject_type = 'discovery_candidate'
|
||||
AND c.predicate = 'discovery_candidate'
|
||||
AND c.object_id = $1::uuid`,
|
||||
[candidateArtistId]
|
||||
@@ -200,6 +227,16 @@ export class DiscoveryService {
|
||||
return results;
|
||||
}
|
||||
|
||||
/** Undo the state transition when Redis rejected an acquisition enqueue. */
|
||||
async markEnqueueFailed(candidateId: string, reason: string): Promise<void> {
|
||||
await this.db.pgClient.query(
|
||||
`UPDATE discovery_candidates
|
||||
SET status = 'candidate', last_eval_at = NOW(), last_error = $2
|
||||
WHERE id = $1 AND status = 'acquiring'`,
|
||||
[candidateId, reason.slice(0, 2000)]
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// E.4 — Probation lifecycle
|
||||
// ---------------------------------------------------------------
|
||||
@@ -226,13 +263,18 @@ export class DiscoveryService {
|
||||
|
||||
if (completedPlays >= 3) {
|
||||
await this.db.pgClient.query(
|
||||
`UPDATE tracks SET probation_status = 'retained' WHERE id = $1`,
|
||||
// Retained recommendations become normal library candidates. Their
|
||||
// source_type remains RECOMMENDATION for provenance, while state=LIBRARY
|
||||
// is the explicit promotion gate consumed by the existing Vibe queries.
|
||||
`UPDATE tracks
|
||||
SET probation_status = 'retained', state = 'LIBRARY'
|
||||
WHERE id = $1`,
|
||||
[trackId]
|
||||
);
|
||||
|
||||
const claimRes = await this.db.pgClient.query<{ source: string }>(
|
||||
`SELECT source FROM claims
|
||||
WHERE subject_type = 'track' AND subject_id = $1 AND predicate = 'discovery_candidate'
|
||||
WHERE subject_type = 'track' AND subject_id = $1 AND predicate = 'acquired_from'
|
||||
LIMIT 1`,
|
||||
[trackId]
|
||||
);
|
||||
@@ -252,7 +294,9 @@ export class DiscoveryService {
|
||||
|
||||
if (completedPlays === 0 && skips >= 3 && daysSinceProbation > 7) {
|
||||
await this.db.pgClient.query(
|
||||
`UPDATE tracks SET probation_status = 'retired' WHERE id = $1`,
|
||||
// Retirement is reversible and never unlinks a file. The existing
|
||||
// cleanup hard-delete workflow remains separately gated.
|
||||
`UPDATE tracks SET probation_status = 'retired', state = 'HIDDEN' WHERE id = $1`,
|
||||
[trackId]
|
||||
);
|
||||
return 'retired';
|
||||
@@ -286,7 +330,7 @@ export class DiscoveryService {
|
||||
`SELECT c.source, COUNT(*)::int AS cnt
|
||||
FROM claims c
|
||||
JOIN tracks t ON t.id = c.subject_id
|
||||
WHERE c.predicate = 'discovery_candidate'
|
||||
WHERE c.predicate = 'acquired_from'
|
||||
AND t.probation_status = 'retained'
|
||||
GROUP BY c.source
|
||||
ORDER BY cnt DESC`
|
||||
@@ -295,3 +339,11 @@ export class DiscoveryService {
|
||||
console.log('[MetaLearning] Discovery source retention counts:', JSON.stringify(res.rows));
|
||||
}
|
||||
}
|
||||
|
||||
function safeJson(value: string): unknown {
|
||||
try {
|
||||
return JSON.parse(value);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,11 +15,46 @@ export interface ClaimEdge {
|
||||
fusedValue: number;
|
||||
}
|
||||
|
||||
export interface VibeCalendarContext {
|
||||
localHour: number;
|
||||
weekday: number;
|
||||
month: number;
|
||||
timeZone?: string;
|
||||
}
|
||||
|
||||
/** A bounded key for short-lived, calendar-specific preference beliefs. */
|
||||
export function calendarContextKey(context: VibeCalendarContext): string {
|
||||
const daypart = context.localHour < 6 ? 'night'
|
||||
: context.localHour < 12 ? 'morning'
|
||||
: context.localHour < 18 ? 'day'
|
||||
: 'evening';
|
||||
const dayType = context.weekday === 0 || context.weekday === 6 ? 'weekend' : 'weekday';
|
||||
const season = context.month === 12 || context.month <= 2 ? 'winter'
|
||||
: context.month <= 5 ? 'spring'
|
||||
: context.month <= 8 ? 'summer'
|
||||
: 'autumn';
|
||||
return `calendar:${daypart}:${dayType}:${season}`;
|
||||
}
|
||||
|
||||
export interface Candidate {
|
||||
trackId: string;
|
||||
generatorId: string;
|
||||
explanation: ClaimEdge[];
|
||||
relevance: number;
|
||||
/**
|
||||
* Filled by the session director after sequence planning. Generators remain
|
||||
* deliberately unaware of slots and objectives, while the durable plan can
|
||||
* retain why this particular candidate won its position.
|
||||
*/
|
||||
plan?: {
|
||||
slotRole: string;
|
||||
score: number;
|
||||
scoreBreakdown: Record<string, unknown>;
|
||||
explanation: Record<string, unknown>;
|
||||
/** Revision-level policy and constraint evidence, copied into the durable
|
||||
* objective snapshot by the coordinator. */
|
||||
objective?: Record<string, unknown>;
|
||||
};
|
||||
}
|
||||
|
||||
export interface GeneratorContext {
|
||||
@@ -33,8 +68,12 @@ export interface GeneratorContext {
|
||||
energy: number;
|
||||
lastArtistIds: string[];
|
||||
lastGenreIds: string[];
|
||||
context: string | null;
|
||||
context: VibeCalendarContext | null;
|
||||
noveltyHunger: number;
|
||||
/** Session-local exploration controls; they never overwrite durable taste. */
|
||||
explorationCoefficient?: number;
|
||||
discoveryRadius?: number;
|
||||
sessionGoal?: { type: string; target: number; progress: number };
|
||||
sessionAgeMin: number;
|
||||
};
|
||||
}
|
||||
@@ -42,6 +81,9 @@ export interface GeneratorContext {
|
||||
export type Generator = (db: DbService, ctx: GeneratorContext) => Promise<Candidate[]>;
|
||||
|
||||
const OBJECTIVE_USER = '00000000-0000-0000-0000-000000000000';
|
||||
const FALLBACK_CANDIDATE_LIMIT = 60;
|
||||
/** How deep into a comfort artist's catalogue the two proposed tracks are drawn from. */
|
||||
const COMFORT_ARTIST_POOL = 10;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 1. COMFORT — Top artists by longterm affinity > 0.5
|
||||
@@ -57,22 +99,31 @@ async function comfortGenerator(db: DbService, ctx: GeneratorContext): Promise<C
|
||||
const artistValueMap = new Map(topArtists.map(b => [b.entity_id, b.value]));
|
||||
const artistIds = topArtists.map(b => b.entity_id);
|
||||
|
||||
// Two tracks per artist, drawn at random from that artist's best known
|
||||
// COMFORT_ARTIST_POOL. Taking the top two by fused value instead proposed the
|
||||
// same handful of tracks in every session for as long as the listener's top
|
||||
// artists held still, which is most of why a Vibe felt like it never moved.
|
||||
const res = await db.pgClient.query(
|
||||
`SELECT sub.id, sub.artist_id
|
||||
`SELECT sampled.id, sampled.artist_id
|
||||
FROM (
|
||||
SELECT t.id, cf.object_id AS artist_id,
|
||||
ROW_NUMBER() OVER (PARTITION BY cf.object_id ORDER BY cf.fused_value DESC) AS rn
|
||||
FROM tracks t
|
||||
JOIN claim_fusion cf ON cf.subject_type = 'track' AND cf.subject_id = t.id
|
||||
AND cf.predicate IN ('credited_main_on', 'featured_on')
|
||||
AND cf.object_type = 'artist'
|
||||
AND cf.object_id = ANY($1::uuid[])
|
||||
AND (cf.user_id = $2 OR cf.user_id = $3)
|
||||
WHERE t.state = 'LIBRARY'
|
||||
AND NOT (t.id = ANY($4::uuid[]))
|
||||
) sub
|
||||
WHERE sub.rn <= 2
|
||||
ORDER BY sub.artist_id, sub.rn`,
|
||||
SELECT ranked.id, ranked.artist_id,
|
||||
ROW_NUMBER() OVER (PARTITION BY ranked.artist_id ORDER BY RANDOM()) AS pick
|
||||
FROM (
|
||||
SELECT t.id, cf.object_id AS artist_id,
|
||||
ROW_NUMBER() OVER (PARTITION BY cf.object_id ORDER BY cf.fused_value DESC) AS rn
|
||||
FROM tracks t
|
||||
JOIN claim_fusion cf ON cf.subject_type = 'track' AND cf.subject_id = t.id
|
||||
AND cf.predicate IN ('credited_main_on', 'featured_on')
|
||||
AND cf.object_type = 'artist'
|
||||
AND cf.object_id = ANY($1::uuid[])
|
||||
AND (cf.user_id = $2 OR cf.user_id = $3)
|
||||
WHERE (t.state = 'LIBRARY' OR (t.state = 'RECOMMENDED' AND t.probation_status = 'probation'))
|
||||
AND NOT (t.id = ANY($4::uuid[]))
|
||||
) ranked
|
||||
WHERE ranked.rn <= ${COMFORT_ARTIST_POOL}
|
||||
) sampled
|
||||
WHERE sampled.pick <= 2
|
||||
ORDER BY sampled.artist_id, sampled.pick`,
|
||||
[artistIds, OBJECTIVE_USER, ctx.userId, ctx.recentExclusions]
|
||||
);
|
||||
|
||||
@@ -141,7 +192,7 @@ async function adjacentGenerator(db: DbService, ctx: GeneratorContext): Promise<
|
||||
AND cf.object_type = 'artist'
|
||||
AND cf.object_id = ANY($1::uuid[])
|
||||
AND (cf.user_id = $2 OR cf.user_id = $3)
|
||||
WHERE t.state = 'LIBRARY'
|
||||
WHERE (t.state = 'LIBRARY' OR (t.state = 'RECOMMENDED' AND t.probation_status = 'probation'))
|
||||
AND NOT (t.id = ANY($4::uuid[]))
|
||||
) sub
|
||||
ORDER BY RANDOM()
|
||||
@@ -209,7 +260,7 @@ async function discoveryGenerator(db: DbService, ctx: GeneratorContext): Promise
|
||||
AND cf.predicate IN ('credited_main_on', 'featured_on')
|
||||
AND cf.object_type = 'artist'
|
||||
AND cf.object_id = ANY($1::uuid[])
|
||||
WHERE t.state = 'LIBRARY'
|
||||
WHERE (t.state = 'LIBRARY' OR (t.state = 'RECOMMENDED' AND t.probation_status = 'probation'))
|
||||
AND NOT (t.id = ANY($2::uuid[]))
|
||||
ORDER BY t.id, cf.fused_value DESC NULLS LAST
|
||||
) sub
|
||||
@@ -273,7 +324,7 @@ async function deepDiveGenerator(db: DbService, ctx: GeneratorContext): Promise<
|
||||
ROW_NUMBER() OVER (PARTITION BY t.album_id ORDER BY t.title ASC) AS rn
|
||||
FROM tracks t
|
||||
WHERE t.album_id = ANY($1::uuid[])
|
||||
AND t.state = 'LIBRARY'
|
||||
AND (t.state = 'LIBRARY' OR (t.state = 'RECOMMENDED' AND t.probation_status = 'probation'))
|
||||
AND NOT (t.id = ANY($2::uuid[]))
|
||||
) sub
|
||||
WHERE sub.rn <= 5
|
||||
@@ -333,7 +384,7 @@ async function revivalGenerator(db: DbService, ctx: GeneratorContext): Promise<C
|
||||
AND cf.object_type = 'artist'
|
||||
AND cf.object_id = ANY($1::uuid[])
|
||||
AND (cf.user_id = $2 OR cf.user_id = $3)
|
||||
WHERE t.state = 'LIBRARY'
|
||||
WHERE (t.state = 'LIBRARY' OR (t.state = 'RECOMMENDED' AND t.probation_status = 'probation'))
|
||||
AND NOT (t.id = ANY($4::uuid[]))
|
||||
ORDER BY t.id, cf.fused_value DESC NULLS LAST
|
||||
) sub
|
||||
@@ -385,7 +436,7 @@ async function noveltyGenerator(db: DbService, ctx: GeneratorContext): Promise<C
|
||||
AND cf_track.object_id = cf_edge.object_id
|
||||
WHERE t.release_date IS NOT NULL
|
||||
AND t.release_date >= NOW() - INTERVAL '60 days'
|
||||
AND t.state = 'LIBRARY'
|
||||
AND (t.state = 'LIBRARY' OR (t.state = 'RECOMMENDED' AND t.probation_status = 'probation'))
|
||||
AND NOT (t.id = ANY($1::uuid[]))
|
||||
) sub
|
||||
ORDER BY release_date DESC
|
||||
@@ -435,7 +486,7 @@ async function experimentalGenerator(db: DbService, ctx: GeneratorContext): Prom
|
||||
FROM tracks t
|
||||
JOIN track_genre tg ON tg.track_id = t.id
|
||||
JOIN unfamiliar_genres ug ON ug.id = tg.genre_id
|
||||
WHERE t.state = 'LIBRARY'
|
||||
WHERE (t.state = 'LIBRARY' OR (t.state = 'RECOMMENDED' AND t.probation_status = 'probation'))
|
||||
AND NOT (t.id = ANY($2::uuid[]))
|
||||
LIMIT 30
|
||||
)
|
||||
@@ -468,6 +519,8 @@ async function experimentalGenerator(db: DbService, ctx: GeneratorContext): Prom
|
||||
async function contextualGenerator(db: DbService, ctx: GeneratorContext): Promise<Candidate[]> {
|
||||
if (!ctx.state.context) return [];
|
||||
|
||||
const contextKey = calendarContextKey(ctx.state.context);
|
||||
|
||||
const contextualBeliefs = await db.getListenerBeliefs({
|
||||
userId: ctx.userId,
|
||||
profile: 'contextual',
|
||||
@@ -476,7 +529,9 @@ async function contextualGenerator(db: DbService, ctx: GeneratorContext): Promis
|
||||
order: 'DESC',
|
||||
});
|
||||
|
||||
const targetBeliefs = contextualBeliefs.filter(b => b.entity_type === 'artist' && b.value > 0.2);
|
||||
const targetBeliefs = contextualBeliefs.filter(b =>
|
||||
b.entity_type === 'artist' && b.dimension === contextKey && b.value > 0.2,
|
||||
);
|
||||
const targetArtistIds = targetBeliefs.map(b => b.entity_id);
|
||||
const targetValueMap = new Map(targetBeliefs.map(b => [b.entity_id, b.value]));
|
||||
|
||||
@@ -491,7 +546,7 @@ async function contextualGenerator(db: DbService, ctx: GeneratorContext): Promis
|
||||
AND cf.object_type = 'artist'
|
||||
AND cf.object_id = ANY($1::uuid[])
|
||||
AND (cf.user_id = $2 OR cf.user_id = $3)
|
||||
WHERE t.state = 'LIBRARY'
|
||||
WHERE (t.state = 'LIBRARY' OR (t.state = 'RECOMMENDED' AND t.probation_status = 'probation'))
|
||||
AND NOT (t.id = ANY($4::uuid[]))
|
||||
ORDER BY t.id, cf.fused_value DESC NULLS LAST
|
||||
) sub
|
||||
@@ -518,6 +573,37 @@ async function contextualGenerator(db: DbService, ctx: GeneratorContext): Promis
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 9. LIBRARY FALLBACK — keeps a Vibe usable when the metadata graph is sparse
|
||||
// ---------------------------------------------------------------------------
|
||||
async function libraryFallbackGenerator(db: DbService, ctx: GeneratorContext): Promise<Candidate[]> {
|
||||
const res = await db.pgClient.query(
|
||||
// Probation belongs here too. This is the one generator that does not walk
|
||||
// the graph, so it is the only way an acquired recommendation nothing has
|
||||
// enriched yet can ever be offered — which is why Found tracks were sitting
|
||||
// on disk unplayed.
|
||||
`SELECT t.id
|
||||
FROM tracks t
|
||||
WHERE (t.state = 'LIBRARY' OR (t.state = 'RECOMMENDED' AND t.probation_status = 'probation'))
|
||||
AND NOT (t.id = ANY($1::uuid[]))
|
||||
ORDER BY RANDOM()
|
||||
LIMIT $2`,
|
||||
[ctx.recentExclusions, FALLBACK_CANDIDATE_LIMIT],
|
||||
);
|
||||
|
||||
return (res.rows as { id: string }[]).map(row => ({
|
||||
trackId: row.id,
|
||||
generatorId: 'library-fallback',
|
||||
relevance: 0.05,
|
||||
explanation: [{
|
||||
subjectType: 'track', subjectId: row.id,
|
||||
predicate: 'library_fallback',
|
||||
objectType: 'track', objectId: row.id,
|
||||
fusedValue: 0.05,
|
||||
}],
|
||||
}));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// All generators, ordered by priority (comfort first, experimental last)
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -530,4 +616,5 @@ export const ALL_GENERATORS: Generator[] = [
|
||||
noveltyGenerator,
|
||||
contextualGenerator,
|
||||
experimentalGenerator,
|
||||
libraryFallbackGenerator,
|
||||
];
|
||||
|
||||
@@ -53,6 +53,34 @@ describe('generators', () => {
|
||||
expect(results[0].explanation.length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it('accepts the fresh artist affinity shape produced by a promoted track', async () => {
|
||||
const db = makeMockDb();
|
||||
(db.pgClient.query as any).mockResolvedValue({ rows: [{ id: 'track-1', artist_id: 'artist-1' }] });
|
||||
// recordTrackEvidence(add_to_favorites) projects 0.60 * 0.90 = 0.54
|
||||
// onto a fresh longterm artist affinity belief.
|
||||
const ctx = makeCtx({ beliefs: [
|
||||
{ entity_type: 'artist', entity_id: 'artist-1', value: 0.54, confidence: 0.05, profile: 'longterm', dimension: 'affinity' } as any,
|
||||
] });
|
||||
const results = await generatorByName.comfort(db, ctx);
|
||||
expect(results).toHaveLength(1);
|
||||
expect(results[0].generatorId).toBe('comfort');
|
||||
});
|
||||
|
||||
it('samples two tracks at random from each artist rather than fixing on their top two', async () => {
|
||||
const db = makeMockDb();
|
||||
(db.pgClient.query as any).mockResolvedValue({ rows: [] });
|
||||
const ctx = makeCtx({ beliefs: [
|
||||
{ entity_type: 'artist', entity_id: 'artist-1', value: 0.8, profile: 'longterm', dimension: 'affinity' } as any,
|
||||
] });
|
||||
|
||||
await generatorByName.comfort(db, ctx);
|
||||
|
||||
const [sql] = (db.pgClient.query as any).mock.calls[0];
|
||||
expect(sql).toContain('ORDER BY RANDOM()');
|
||||
expect(sql).toContain('ranked.rn <= 10');
|
||||
expect(sql).toContain('sampled.pick <= 2');
|
||||
});
|
||||
|
||||
it('returns empty when no high-affinity artists', async () => {
|
||||
const db = makeMockDb();
|
||||
const ctx = makeCtx({ beliefs: [ { entity_type: 'artist', entity_id: 'a1', value: 0.3, profile: 'longterm', dimension: 'affinity' } as any ] });
|
||||
@@ -128,16 +156,43 @@ describe('generators', () => {
|
||||
});
|
||||
});
|
||||
describe('novelty', () => {
|
||||
it('returns recent tracks', async () => {
|
||||
it('returns a candidate when canonical release-date coverage makes a recent track eligible', async () => {
|
||||
const db = makeMockDb();
|
||||
(db.pgClient.query as any).mockResolvedValue({ rows: [{ id: 't1' }] });
|
||||
const ctx = makeCtx({
|
||||
beliefs: [ { entity_type: 'artist', entity_id: 'trusted-1', value: 0.5, profile: 'longterm', dimension: 'affinity' } as any ],
|
||||
});
|
||||
const results = await generatorByName.novelty(db, ctx);
|
||||
if (results.length > 0) {
|
||||
expect(results[0].generatorId).toBe('novelty');
|
||||
}
|
||||
expect(results).toHaveLength(1);
|
||||
expect(results[0]).toMatchObject({ trackId: 't1', generatorId: 'novelty' });
|
||||
});
|
||||
|
||||
it('queries only dated library tracks from the current novelty window', async () => {
|
||||
const db = makeMockDb();
|
||||
(db.pgClient.query as any).mockResolvedValue({ rows: [] });
|
||||
const ctx = makeCtx({
|
||||
beliefs: [ { entity_type: 'artist', entity_id: 'trusted-1', value: 0.5, profile: 'longterm', dimension: 'affinity' } as any ],
|
||||
});
|
||||
|
||||
await generatorByName.novelty(db, ctx);
|
||||
|
||||
const [sql] = (db.pgClient.query as any).mock.calls[0];
|
||||
expect(sql).toContain('t.release_date IS NOT NULL');
|
||||
expect(sql).toContain("t.release_date >= NOW() - INTERVAL '60 days'");
|
||||
expect(sql).toContain("t.state = 'LIBRARY'");
|
||||
});
|
||||
});
|
||||
|
||||
describe('library fallback', () => {
|
||||
it('offers probation recommendations, which no graph generator can reach yet', async () => {
|
||||
const db = makeMockDb();
|
||||
(db.pgClient.query as any).mockResolvedValue({ rows: [{ id: 'probation-1' }] });
|
||||
|
||||
const results = await ALL_GENERATORS[8](db, makeCtx());
|
||||
|
||||
const [sql] = (db.pgClient.query as any).mock.calls[0];
|
||||
expect(sql).toContain("t.state = 'RECOMMENDED' AND t.probation_status = 'probation'");
|
||||
expect(results[0]).toMatchObject({ trackId: 'probation-1', generatorId: 'library-fallback' });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -155,13 +210,19 @@ describe('generators', () => {
|
||||
|
||||
describe('contextual', () => {
|
||||
it('returns tracks matching context when set', async () => {
|
||||
const db = makeMockDb();
|
||||
const db = makeMockDb({
|
||||
getListenerBeliefs: vi.fn().mockResolvedValue([
|
||||
{ entity_type: 'artist', entity_id: 'artist-1', value: 0.7, profile: 'contextual', dimension: 'calendar:day:weekday:summer' },
|
||||
{ entity_type: 'artist', entity_id: 'artist-2', value: 0.9, profile: 'contextual', dimension: 'calendar:night:weekend:summer' },
|
||||
]),
|
||||
});
|
||||
(db.pgClient.query as any).mockResolvedValueOnce({ rows: [{ id: 't1' }] });
|
||||
const ctx = makeCtx({
|
||||
state: { energy: 0.5, lastArtistIds: [], lastGenreIds: [], context: 'coding', noveltyHunger: 0.3, sessionAgeMin: 10 },
|
||||
state: { energy: 0.5, lastArtistIds: [], lastGenreIds: [], context: { localHour: 12, weekday: 1, month: 6 }, noveltyHunger: 0.3, sessionAgeMin: 10 },
|
||||
});
|
||||
const results = await generatorByName.contextual(db, ctx);
|
||||
expect(results).toBeDefined();
|
||||
expect(results).toHaveLength(1);
|
||||
expect((db.pgClient.query as any).mock.calls[0][1][0]).toEqual(['artist-1']);
|
||||
});
|
||||
|
||||
it('returns empty when no context set', async () => {
|
||||
@@ -172,8 +233,8 @@ describe('generators', () => {
|
||||
});
|
||||
|
||||
describe('ALL_GENERATORS', () => {
|
||||
it('contains 8 generators', () => {
|
||||
expect(ALL_GENERATORS).toHaveLength(8);
|
||||
it('contains 9 generators', () => {
|
||||
expect(ALL_GENERATORS).toHaveLength(9);
|
||||
ALL_GENERATORS.forEach(g => expect(typeof g).toBe('function'));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
import { Queue, Job } from 'bullmq';
|
||||
import { MetadataRefreshJob, AudioAnalysisJob, CleanupJob, LibraryScanJob, ReindexTracksJob, ReprocessArtistsJob } from '../types/job.types.js';
|
||||
import { MetadataRefreshJob, AudioAnalysisJob, CleanupJob, LibraryScanJob, ReindexTracksJob, ReprocessArtistsJob, AcquisitionJob } from '../types/job.types.js';
|
||||
|
||||
const AUDIO_ANALYSIS_JOB_OPTIONS = {
|
||||
attempts: 3,
|
||||
backoff: { type: 'exponential', delay: 5_000 },
|
||||
removeOnComplete: { age: 7 * 24 * 60 * 60, count: 10_000 },
|
||||
removeOnFail: { age: 30 * 24 * 60 * 60, count: 10_000 },
|
||||
} as const;
|
||||
|
||||
export interface JobServiceConfig {
|
||||
redisUrl: string;
|
||||
@@ -26,6 +33,37 @@ export interface JobHistoryEntry {
|
||||
returnvalue?: unknown;
|
||||
}
|
||||
|
||||
export interface ReenrichmentQueuePlan {
|
||||
metadataTrackIds: string[];
|
||||
artistIds: string[];
|
||||
albumIds: string[];
|
||||
}
|
||||
|
||||
export interface EnqueueResult {
|
||||
requested: number;
|
||||
enqueued: number;
|
||||
alreadyQueued: number;
|
||||
failed: Array<{ id: string; error: string }>;
|
||||
}
|
||||
|
||||
export interface ReenrichmentQueueResult {
|
||||
metadata: EnqueueResult;
|
||||
artistImages: EnqueueResult;
|
||||
albumCovers: EnqueueResult;
|
||||
}
|
||||
|
||||
export interface EnrichmentQueueDiagnostics {
|
||||
pending: Record<string, number>;
|
||||
recentFailures: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
data: Record<string, unknown>;
|
||||
failedReason?: string;
|
||||
timestamp: number;
|
||||
finishedOn?: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
export class JobService {
|
||||
private queue: Queue;
|
||||
|
||||
@@ -42,9 +80,12 @@ export class JobService {
|
||||
await this.queue.add('metadata_refresh', payload);
|
||||
}
|
||||
|
||||
async enqueueAudioAnalysis(trackId: string, features: string[]) {
|
||||
const payload: AudioAnalysisJob = { trackId, features };
|
||||
await this.queue.add('audio_analysis', payload);
|
||||
async enqueueAudioAnalysis(trackId: string) {
|
||||
const payload: AudioAnalysisJob = { trackId };
|
||||
await this.queue.add('audio_analysis', payload, {
|
||||
jobId: `audio-${trackId}`,
|
||||
...AUDIO_ANALYSIS_JOB_OPTIONS,
|
||||
});
|
||||
}
|
||||
|
||||
async enqueueCleanup(reason: 'expired' | 'manual', targetFiles: string[]) {
|
||||
@@ -67,30 +108,123 @@ export class JobService {
|
||||
await this.queue.add('reprocess_artists', payload);
|
||||
}
|
||||
|
||||
/**
|
||||
* Enqueue metadata_refresh jobs for a batch of track IDs. Used by the
|
||||
* /admin/reenrich-tracks endpoint to re-canonicalize metadata (artist names,
|
||||
* album titles, MBIDs, cover art) without re-scanning files from disk.
|
||||
*
|
||||
* Each job is deduped by `jobId: meta-<trackId>` so re-running the endpoint
|
||||
* doesn't stack duplicate jobs. Old completed/failed jobs with the same ID
|
||||
* are removed first so re-enrichment actually works (BullMQ otherwise treats
|
||||
* existing jobIds as duplicates and silently skips them).
|
||||
*/
|
||||
async enqueueMetadataRefreshBatch(trackIds: string[]): Promise<number> {
|
||||
let enqueued = 0;
|
||||
for (const trackId of trackIds) {
|
||||
const jobId = `meta-${trackId}`;
|
||||
await this.queue.remove(jobId).catch(() => {});
|
||||
const payload: MetadataRefreshJob = { trackId, refreshType: 'full' };
|
||||
await this.queue.add('metadata_refresh', payload, {
|
||||
jobId,
|
||||
removeOnComplete: { age: 86400, count: 10000 },
|
||||
removeOnFail: { age: 86400, count: 10000 },
|
||||
});
|
||||
enqueued++;
|
||||
async enqueueDiscoveryAcquisition(candidateId: string): Promise<void> {
|
||||
const payload: AcquisitionJob = { candidateId };
|
||||
const jobId = `acquire-${candidateId}`;
|
||||
// BullMQ returns a retained completed/failed job for the same id without
|
||||
// executing it. Remove terminal attempts before retrying so a candidate
|
||||
// cannot be stranded in `acquiring` for the retention window.
|
||||
const existing = await this.queue.getJob(jobId);
|
||||
if (existing) {
|
||||
const state = await existing.getState();
|
||||
if (state === 'completed' || state === 'failed') await existing.remove();
|
||||
}
|
||||
return enqueued;
|
||||
await this.queue.add('acquire_discovery_candidate', payload, {
|
||||
// A candidate may have one active attempt. Failed/completed jobs expire
|
||||
// so an operator can explicitly re-evaluate it later.
|
||||
jobId,
|
||||
removeOnComplete: { age: 86400, count: 1000 },
|
||||
removeOnFail: { age: 86400, count: 1000 },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Queue a bounded re-enrichment plan. Metadata, artist images and album
|
||||
* covers deliberately have separate jobs: metadata resolution can complete
|
||||
* even when artwork providers are unavailable, and artwork is deduplicated
|
||||
* at its natural artist/album boundary.
|
||||
*
|
||||
* Existing active/waiting jobs are preserved and reported as alreadyQueued.
|
||||
* Completed/failed jobs are removed before retrying, so a request never
|
||||
* claims a retry was enqueued when BullMQ retained an old job id.
|
||||
*/
|
||||
async enqueueReenrichment(plan: ReenrichmentQueuePlan): Promise<ReenrichmentQueueResult> {
|
||||
return {
|
||||
metadata: await this.enqueueMany(
|
||||
[...new Set(plan.metadataTrackIds)],
|
||||
'metadata_refresh',
|
||||
(trackId) => ({ trackId, refreshType: 'full' }),
|
||||
(trackId) => `meta-${trackId}`,
|
||||
),
|
||||
artistImages: await this.enqueueMany(
|
||||
[...new Set(plan.artistIds)],
|
||||
'artist_image',
|
||||
(artistId) => ({ artistId }),
|
||||
(artistId) => `artist-image-${artistId}`,
|
||||
),
|
||||
albumCovers: await this.enqueueMany(
|
||||
[...new Set(plan.albumIds)],
|
||||
'album_cover',
|
||||
(albumId) => ({ albumId }),
|
||||
(albumId) => `album-cover-${albumId}`,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
private async enqueueMany(
|
||||
ids: string[],
|
||||
name: string,
|
||||
payloadFor: (id: string) => Record<string, unknown>,
|
||||
jobIdFor: (id: string) => string,
|
||||
): Promise<EnqueueResult> {
|
||||
const result: EnqueueResult = { requested: ids.length, enqueued: 0, alreadyQueued: 0, failed: [] };
|
||||
const keep = {
|
||||
removeOnComplete: { age: 86400, count: 10000 },
|
||||
removeOnFail: { age: 86400, count: 10000 },
|
||||
} as const;
|
||||
|
||||
for (const id of ids) {
|
||||
const jobId = jobIdFor(id);
|
||||
try {
|
||||
const existing = await this.queue.getJob(jobId);
|
||||
if (existing) {
|
||||
const state = await existing.getState();
|
||||
if (state === 'active' || state === 'waiting' || state === 'delayed' || state === 'prioritized') {
|
||||
result.alreadyQueued++;
|
||||
continue;
|
||||
}
|
||||
await existing.remove();
|
||||
}
|
||||
await this.queue.add(name, payloadFor(id), { jobId, ...keep });
|
||||
result.enqueued++;
|
||||
} catch (err) {
|
||||
result.failed.push({ id, error: err instanceof Error ? err.message : String(err) });
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async getEnrichmentQueueDiagnostics(limit = 25): Promise<EnrichmentQueueDiagnostics> {
|
||||
const relevant = new Set(['metadata_refresh', 'artist_image', 'album_cover']);
|
||||
const [waiting, active, delayed, failed] = await Promise.all([
|
||||
this.queue.getJobs(['waiting'], 0, 1000),
|
||||
this.queue.getJobs(['active'], 0, 1000),
|
||||
this.queue.getJobs(['delayed'], 0, 1000),
|
||||
this.queue.getJobs(['failed'], 0, Math.max(limit * 4, 100)),
|
||||
]);
|
||||
const pending: Record<string, number> = {
|
||||
metadata_refresh: 0,
|
||||
artist_image: 0,
|
||||
album_cover: 0,
|
||||
};
|
||||
for (const job of [...waiting, ...active, ...delayed]) {
|
||||
if (relevant.has(job.name)) pending[job.name]++;
|
||||
}
|
||||
|
||||
const recentFailures = failed
|
||||
.filter((job) => relevant.has(job.name))
|
||||
.sort((a, b) => (b.finishedOn ?? b.timestamp) - (a.finishedOn ?? a.timestamp))
|
||||
.slice(0, limit)
|
||||
.map((job) => ({
|
||||
id: String(job.id),
|
||||
name: job.name,
|
||||
data: job.data as Record<string, unknown>,
|
||||
failedReason: job.failedReason,
|
||||
timestamp: job.timestamp,
|
||||
finishedOn: job.finishedOn,
|
||||
}));
|
||||
|
||||
return { pending, recentFailures };
|
||||
}
|
||||
|
||||
async getQueueStats(): Promise<QueueStats> {
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { Pool } from 'pg';
|
||||
import {
|
||||
NotSessionOwnerError,
|
||||
PlaybackEvent,
|
||||
PlaybackSyncService,
|
||||
} from './playback-sync.service.js';
|
||||
|
||||
const USER_ID = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa';
|
||||
const DESKTOP = '11111111-1111-4111-8111-111111111111';
|
||||
const PHONE = '22222222-2222-4222-8222-222222222222';
|
||||
const SESSION = '33333333-3333-4333-8333-333333333333';
|
||||
|
||||
/**
|
||||
* A pool stubbed down to the one row this service reasons about. The queries
|
||||
* themselves are plain SQL against a single table, so what is worth testing is
|
||||
* the ownership and fan-out logic sitting on top of them.
|
||||
*/
|
||||
function poolWith(state: {
|
||||
deviceId?: string | null;
|
||||
trackId?: string | null;
|
||||
vibeSessionId?: string | null;
|
||||
isPlaying?: boolean;
|
||||
positionMs?: number;
|
||||
}) {
|
||||
const row = {
|
||||
device_id: state.deviceId ?? null,
|
||||
track_id: state.trackId ?? null,
|
||||
vibe_session_id: state.vibeSessionId ?? null,
|
||||
queue: [],
|
||||
queue_index: -1,
|
||||
position_ms: state.positionMs ?? 0,
|
||||
is_playing: state.isPlaying ?? false,
|
||||
version: '4',
|
||||
updated_at: new Date('2026-08-08T12:00:00Z'),
|
||||
};
|
||||
const queries: string[] = [];
|
||||
const pool = {
|
||||
async query(rawSql: string, params?: unknown[]) {
|
||||
queries.push(rawSql);
|
||||
// The service formats its SQL in columns, so match on collapsed text.
|
||||
const sql = rawSql.replace(/\s+/g, ' ').trim();
|
||||
if (sql.includes('FROM playback_devices') && sql.includes('ORDER BY')) {
|
||||
return { rows: [{ id: DESKTOP, name: 'Linux · Firefox', last_seen_at: row.updated_at, online: true }], rowCount: 1 };
|
||||
}
|
||||
if (sql.startsWith('UPDATE playback_devices SET name')) {
|
||||
return { rows: [{ id: params?.[0], name: params?.[2], last_seen_at: row.updated_at }], rowCount: 1 };
|
||||
}
|
||||
if (sql.startsWith('INSERT INTO playback_devices')) {
|
||||
return { rows: [{ id: PHONE, name: params?.[1], last_seen_at: row.updated_at }], rowCount: 1 };
|
||||
}
|
||||
if (sql.startsWith('SELECT device_id FROM playback_state')) {
|
||||
return { rows: [{ device_id: row.device_id }], rowCount: 1 };
|
||||
}
|
||||
if (sql.includes('SELECT id FROM playback_devices')) {
|
||||
return { rows: [{ id: params?.[0] }], rowCount: 1 };
|
||||
}
|
||||
if (sql.includes('UPDATE playback_state') && sql.includes('device_id = $2')) {
|
||||
row.device_id = String(params?.[1]);
|
||||
return { rows: [row], rowCount: 1 };
|
||||
}
|
||||
if (sql.includes('UPDATE playback_state')) {
|
||||
return { rows: [row], rowCount: 1 };
|
||||
}
|
||||
return { rows: [row], rowCount: 1 };
|
||||
},
|
||||
} as unknown as Pool;
|
||||
return { pool, queries, row };
|
||||
}
|
||||
|
||||
describe('cross-device playback', () => {
|
||||
it('refuses a state report from a device that no longer holds the audio', async () => {
|
||||
const { pool } = poolWith({ deviceId: DESKTOP });
|
||||
const service = new PlaybackSyncService(pool);
|
||||
|
||||
await expect(service.reportState(USER_ID, PHONE, { isPlaying: true }))
|
||||
.rejects.toBeInstanceOf(NotSessionOwnerError);
|
||||
});
|
||||
|
||||
it('accepts the first device to report on an unowned session', async () => {
|
||||
const { pool } = poolWith({ deviceId: null });
|
||||
const service = new PlaybackSyncService(pool);
|
||||
|
||||
const state = await service.reportState(USER_ID, PHONE, { isPlaying: true });
|
||||
expect(state.deviceId).toBe(PHONE);
|
||||
});
|
||||
|
||||
it('publishes the Vibe session the owning device is driving', async () => {
|
||||
// Vibe control follows the audio, and this is how the next owner hears about
|
||||
// the session it is taking over.
|
||||
const { pool } = poolWith({ deviceId: DESKTOP, vibeSessionId: SESSION });
|
||||
const service = new PlaybackSyncService(pool);
|
||||
|
||||
expect((await service.getState(USER_ID)).vibeSessionId).toBe(SESSION);
|
||||
});
|
||||
|
||||
it('keeps a device its stored id across a reload', async () => {
|
||||
const { pool } = poolWith({ deviceId: DESKTOP });
|
||||
const service = new PlaybackSyncService(pool);
|
||||
|
||||
const device = await service.registerDevice(USER_ID, 'Linux · Firefox', DESKTOP);
|
||||
expect(device.id).toBe(DESKTOP);
|
||||
});
|
||||
|
||||
it('gives a second tab a device of its own', async () => {
|
||||
// Both tabs of a browser ask with the same stored id, and one id shared by
|
||||
// two pages is one device that runs every command twice.
|
||||
const { pool } = poolWith({ deviceId: DESKTOP });
|
||||
const service = new PlaybackSyncService(pool);
|
||||
service.claimStream(USER_ID, DESKTOP);
|
||||
|
||||
const device = await service.registerDevice(USER_ID, 'Linux · Firefox', DESKTOP);
|
||||
expect(device.id).toBe(PHONE);
|
||||
});
|
||||
|
||||
it('delivers a command to the owning device only', async () => {
|
||||
const { pool } = poolWith({ deviceId: DESKTOP });
|
||||
const service = new PlaybackSyncService(pool);
|
||||
const seen: PlaybackEvent[] = [];
|
||||
service.subscribe(USER_ID, (event) => seen.push(event));
|
||||
service.claimStream(USER_ID, DESKTOP);
|
||||
|
||||
const result = await service.sendCommand(USER_ID, { type: 'pause' });
|
||||
|
||||
expect(result.deliveredTo).toBe(DESKTOP);
|
||||
expect(seen).toContainEqual({ type: 'command', deviceId: DESKTOP, command: { type: 'pause' } });
|
||||
});
|
||||
|
||||
it('reports no delivery when the owning device has no stream to receive on', async () => {
|
||||
// Ownership outlives a closed stream, so a killed tab still holds the
|
||||
// session. Answering 202 there told the presser a lie.
|
||||
const { pool } = poolWith({ deviceId: DESKTOP });
|
||||
const service = new PlaybackSyncService(pool);
|
||||
|
||||
expect(await service.sendCommand(USER_ID, { type: 'pause' })).toEqual({ deliveredTo: null });
|
||||
});
|
||||
|
||||
it('frees a device id once its stream closes, and not before', async () => {
|
||||
const { pool } = poolWith({ deviceId: DESKTOP });
|
||||
const service = new PlaybackSyncService(pool);
|
||||
|
||||
const release = service.claimStream(USER_ID, DESKTOP);
|
||||
const alsoOpen = service.claimStream(USER_ID, DESKTOP);
|
||||
release();
|
||||
expect(service.hasLiveStream(USER_ID, DESKTOP)).toBe(true);
|
||||
alsoOpen();
|
||||
expect(service.hasLiveStream(USER_ID, DESKTOP)).toBe(false);
|
||||
// A close arriving twice must not free an id a later stream is holding.
|
||||
release();
|
||||
expect(service.hasLiveStream(USER_ID, DESKTOP)).toBe(false);
|
||||
});
|
||||
|
||||
it('reports no delivery when nothing holds the audio', async () => {
|
||||
const { pool } = poolWith({ deviceId: null });
|
||||
const service = new PlaybackSyncService(pool);
|
||||
|
||||
expect(await service.sendCommand(USER_ID, { type: 'play' })).toEqual({ deliveredTo: null });
|
||||
});
|
||||
|
||||
it('hands the session to another device without losing the position', async () => {
|
||||
const { pool } = poolWith({ deviceId: DESKTOP, trackId: 'track-1', positionMs: 45_000, isPlaying: true });
|
||||
const service = new PlaybackSyncService(pool);
|
||||
const seen: PlaybackEvent[] = [];
|
||||
service.subscribe(USER_ID, (event) => seen.push(event));
|
||||
|
||||
const state = await service.transfer(USER_ID, PHONE);
|
||||
|
||||
expect(state.deviceId).toBe(PHONE);
|
||||
expect(state.position).toBe(45);
|
||||
expect(state.isPlaying).toBe(true);
|
||||
expect(seen.some((event) => event.type === 'state')).toBe(true);
|
||||
});
|
||||
|
||||
it('frees ownership when the owning device goes away', async () => {
|
||||
const { pool, queries } = poolWith({ deviceId: DESKTOP });
|
||||
const service = new PlaybackSyncService(pool);
|
||||
|
||||
await service.releaseIfOwner(USER_ID, DESKTOP);
|
||||
|
||||
expect(queries.some((sql) => sql.includes('SET device_id = NULL'))).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,378 @@
|
||||
import { EventEmitter } from 'node:events';
|
||||
import type { Pool } from 'pg';
|
||||
|
||||
/**
|
||||
* Cross-device playback: one authoritative session per listener, any number of
|
||||
* devices watching it, exactly one of them holding the audio.
|
||||
*
|
||||
* Two channels do the work. `state` carries the snapshot every device renders,
|
||||
* so a phone that just opened shows what the desktop is playing. `command`
|
||||
* carries an instruction aimed at the owning device only, because the audio
|
||||
* element lives there and nowhere else. Handoff is a state change like any
|
||||
* other: the new owner starts at the position the old one reported, and the old
|
||||
* one stops when it sees it no longer owns the session.
|
||||
*
|
||||
* ponytail: the fan-out is an in-process EventEmitter, which is correct for the
|
||||
* single backend container this deploys as. A second instance would need Redis
|
||||
* pub/sub here — the rest of the design already assumes nothing else.
|
||||
*/
|
||||
|
||||
/** A device is offline once it stops sending its stream heartbeat. */
|
||||
export const DEVICE_STALE_MS = 90_000;
|
||||
/** Queue snapshots are capped: this is a handoff payload, not a playlist store. */
|
||||
export const MAX_SYNCED_QUEUE = 100;
|
||||
|
||||
export type PlaybackCommandType = 'play' | 'pause' | 'next' | 'prev' | 'seek' | 'play_track';
|
||||
|
||||
export interface PlaybackCommand {
|
||||
type: PlaybackCommandType;
|
||||
/** Seconds into the current track. Only meaningful for `seek`. */
|
||||
position?: number;
|
||||
/** Only meaningful for `play_track`. */
|
||||
trackId?: string;
|
||||
}
|
||||
|
||||
export interface PlaybackDevice {
|
||||
id: string;
|
||||
name: string;
|
||||
lastSeenAt: string;
|
||||
online: boolean;
|
||||
isOwner: boolean;
|
||||
}
|
||||
|
||||
export interface PlaybackSnapshot {
|
||||
deviceId: string | null;
|
||||
trackId: string | null;
|
||||
/**
|
||||
* The durable Vibe session the owning device is playing, when it is playing
|
||||
* one. Whichever device holds the audio drives the session, so this is how the
|
||||
* next owner learns there is one to adopt.
|
||||
*/
|
||||
vibeSessionId: string | null;
|
||||
queue: unknown[];
|
||||
queueIndex: number;
|
||||
position: number;
|
||||
isPlaying: boolean;
|
||||
version: number;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface PlaybackStatePatch {
|
||||
trackId?: string | null;
|
||||
vibeSessionId?: string | null;
|
||||
queue?: unknown[];
|
||||
queueIndex?: number;
|
||||
position?: number;
|
||||
isPlaying?: boolean;
|
||||
}
|
||||
|
||||
export type PlaybackEvent =
|
||||
| { type: 'state'; state: PlaybackSnapshot; devices: PlaybackDevice[] }
|
||||
| { type: 'command'; deviceId: string; command: PlaybackCommand };
|
||||
|
||||
export class NotSessionOwnerError extends Error {
|
||||
constructor() {
|
||||
super('device does not own playback');
|
||||
this.name = 'NotSessionOwnerError';
|
||||
}
|
||||
}
|
||||
|
||||
type StateRow = {
|
||||
device_id: string | null;
|
||||
track_id: string | null;
|
||||
vibe_session_id: string | null;
|
||||
queue: unknown[];
|
||||
queue_index: number;
|
||||
position_ms: number;
|
||||
is_playing: boolean;
|
||||
version: string;
|
||||
updated_at: Date;
|
||||
};
|
||||
|
||||
function toSnapshot(row: StateRow): PlaybackSnapshot {
|
||||
return {
|
||||
deviceId: row.device_id,
|
||||
trackId: row.track_id,
|
||||
vibeSessionId: row.vibe_session_id ?? null,
|
||||
queue: Array.isArray(row.queue) ? row.queue : [],
|
||||
queueIndex: row.queue_index,
|
||||
position: row.position_ms / 1000,
|
||||
isPlaying: row.is_playing,
|
||||
version: Number(row.version),
|
||||
updatedAt: row.updated_at.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
function clampPositionMs(position: number | undefined, fallback: number): number {
|
||||
if (typeof position !== 'number' || !Number.isFinite(position) || position < 0) return fallback;
|
||||
return Math.min(Math.round(position * 1000), 24 * 60 * 60 * 1000);
|
||||
}
|
||||
|
||||
export class PlaybackSyncService {
|
||||
private readonly emitter = new EventEmitter();
|
||||
/**
|
||||
* Open streams per device, keyed `userId:deviceId`. A device only exists as
|
||||
* far as this session is concerned while it is holding one: that is what
|
||||
* decides whether a command can be delivered, and whether a browser asking to
|
||||
* reuse a stored device id would be colliding with a tab that already has it.
|
||||
* Counted rather than a flag, so a reconnect racing its own close cannot leave
|
||||
* a device permanently marked busy.
|
||||
*/
|
||||
private readonly streams = new Map<string, number>();
|
||||
|
||||
constructor(private readonly pgPool: Pool) {
|
||||
// One session with many idle tabs is the normal case, and Node warns at ten
|
||||
// listeners on the assumption they are a leak. They are not.
|
||||
this.emitter.setMaxListeners(0);
|
||||
}
|
||||
|
||||
subscribe(userId: string, listener: (event: PlaybackEvent) => void): () => void {
|
||||
this.emitter.on(userId, listener);
|
||||
return () => this.emitter.off(userId, listener);
|
||||
}
|
||||
|
||||
/** Mark a device's stream open until the returned function is called. */
|
||||
claimStream(userId: string, deviceId: string): () => void {
|
||||
const key = `${userId}:${deviceId}`;
|
||||
this.streams.set(key, (this.streams.get(key) ?? 0) + 1);
|
||||
let released = false;
|
||||
return () => {
|
||||
if (released) return;
|
||||
released = true;
|
||||
const open = (this.streams.get(key) ?? 1) - 1;
|
||||
if (open > 0) this.streams.set(key, open);
|
||||
else this.streams.delete(key);
|
||||
};
|
||||
}
|
||||
|
||||
hasLiveStream(userId: string, deviceId: string): boolean {
|
||||
return (this.streams.get(`${userId}:${deviceId}`) ?? 0) > 0;
|
||||
}
|
||||
|
||||
private async publish(userId: string): Promise<void> {
|
||||
const [state, devices] = await Promise.all([this.getState(userId), this.listDevices(userId)]);
|
||||
this.emitter.emit(userId, { type: 'state', state, devices } satisfies PlaybackEvent);
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a device, or refresh one the browser already knows about. The
|
||||
* caller supplies the id it stored locally so a reload keeps its identity and
|
||||
* the device list does not grow one row per page load.
|
||||
*
|
||||
* A second tab of the same browser asks with the same stored id, and two tabs
|
||||
* sharing one device id are one device that runs every command twice and plays
|
||||
* two copies of the audio. An id whose stream is already open therefore does
|
||||
* not get reused: the caller is given a device of its own instead.
|
||||
*/
|
||||
async registerDevice(userId: string, name: string, deviceId?: string | null): Promise<PlaybackDevice> {
|
||||
const cleanName = (name || 'Unknown device').trim().slice(0, 120) || 'Unknown device';
|
||||
if (deviceId && !this.hasLiveStream(userId, deviceId)) {
|
||||
const updated = await this.pgPool.query<{ id: string; name: string; last_seen_at: Date }>(
|
||||
`UPDATE playback_devices SET name = $3, last_seen_at = NOW()
|
||||
WHERE id = $1 AND user_id = $2
|
||||
RETURNING id, name, last_seen_at`,
|
||||
[deviceId, userId, cleanName]
|
||||
);
|
||||
if (updated.rows[0]) {
|
||||
const owner = await this.ownerId(userId);
|
||||
await this.publish(userId);
|
||||
return {
|
||||
id: updated.rows[0].id,
|
||||
name: updated.rows[0].name,
|
||||
lastSeenAt: updated.rows[0].last_seen_at.toISOString(),
|
||||
online: true,
|
||||
isOwner: owner === updated.rows[0].id,
|
||||
};
|
||||
}
|
||||
}
|
||||
const inserted = await this.pgPool.query<{ id: string; name: string; last_seen_at: Date }>(
|
||||
`INSERT INTO playback_devices (user_id, name) VALUES ($1, $2)
|
||||
RETURNING id, name, last_seen_at`,
|
||||
[userId, cleanName]
|
||||
);
|
||||
const row = inserted.rows[0];
|
||||
await this.publish(userId);
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
lastSeenAt: row.last_seen_at.toISOString(),
|
||||
online: true,
|
||||
isOwner: false,
|
||||
};
|
||||
}
|
||||
|
||||
async touchDevice(userId: string, deviceId: string): Promise<void> {
|
||||
await this.pgPool.query(
|
||||
`UPDATE playback_devices SET last_seen_at = NOW() WHERE id = $1 AND user_id = $2`,
|
||||
[deviceId, userId]
|
||||
);
|
||||
}
|
||||
|
||||
async listDevices(userId: string): Promise<PlaybackDevice[]> {
|
||||
const owner = await this.ownerId(userId);
|
||||
const res = await this.pgPool.query<{ id: string; name: string; last_seen_at: Date; online: boolean }>(
|
||||
`SELECT id, name, last_seen_at,
|
||||
last_seen_at > NOW() - ($2::int * INTERVAL '1 millisecond') AS online
|
||||
FROM playback_devices
|
||||
WHERE user_id = $1
|
||||
ORDER BY last_seen_at DESC`,
|
||||
[userId, DEVICE_STALE_MS]
|
||||
);
|
||||
return res.rows.map((row) => ({
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
lastSeenAt: row.last_seen_at.toISOString(),
|
||||
online: row.online,
|
||||
isOwner: row.id === owner,
|
||||
}));
|
||||
}
|
||||
|
||||
private async ownerId(userId: string): Promise<string | null> {
|
||||
const res = await this.pgPool.query<{ device_id: string | null }>(
|
||||
'SELECT device_id FROM playback_state WHERE user_id = $1', [userId]
|
||||
);
|
||||
return res.rows[0]?.device_id ?? null;
|
||||
}
|
||||
|
||||
async getState(userId: string): Promise<PlaybackSnapshot> {
|
||||
const res = await this.pgPool.query<StateRow>(
|
||||
`INSERT INTO playback_state (user_id) VALUES ($1)
|
||||
ON CONFLICT (user_id) DO UPDATE SET user_id = EXCLUDED.user_id
|
||||
RETURNING device_id, track_id, vibe_session_id, queue, queue_index, position_ms, is_playing, version, updated_at`,
|
||||
[userId]
|
||||
);
|
||||
return toSnapshot(res.rows[0]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Record what the owning device is doing. A device that does not own the
|
||||
* session is rejected rather than ignored, so a stale tab resuming from sleep
|
||||
* learns it lost the audio instead of silently fighting the current owner.
|
||||
*/
|
||||
async reportState(userId: string, deviceId: string, patch: PlaybackStatePatch): Promise<PlaybackSnapshot> {
|
||||
const current = await this.getState(userId);
|
||||
if (current.deviceId !== null && current.deviceId !== deviceId) throw new NotSessionOwnerError();
|
||||
|
||||
const queue = patch.queue === undefined
|
||||
? undefined
|
||||
: patch.queue.slice(0, MAX_SYNCED_QUEUE);
|
||||
|
||||
// A track deleted between the device reading it and reporting it would
|
||||
// otherwise fail the foreign key and take the whole report down with it.
|
||||
// The session is worth more than the pointer: keep the rest, drop the id.
|
||||
const write = async (trackId: string | null) => this.pgPool.query<StateRow>(
|
||||
`UPDATE playback_state
|
||||
SET device_id = $2,
|
||||
track_id = COALESCE($3, CASE WHEN $4 THEN NULL ELSE track_id END),
|
||||
queue = COALESCE($5::jsonb, queue),
|
||||
queue_index = COALESCE($6, queue_index),
|
||||
position_ms = COALESCE($7, position_ms),
|
||||
is_playing = COALESCE($8, is_playing),
|
||||
-- Only the device driving the Vibe reports one, and it reports the
|
||||
-- absence of one just as explicitly: ending a Vibe and playing an
|
||||
-- album has to clear this, or the next owner would adopt a session
|
||||
-- nothing is playing any more.
|
||||
vibe_session_id = COALESCE($9, CASE WHEN $10 THEN NULL ELSE vibe_session_id END),
|
||||
version = version + 1,
|
||||
updated_at = NOW()
|
||||
WHERE user_id = $1
|
||||
RETURNING device_id, track_id, vibe_session_id, queue, queue_index, position_ms, is_playing, version, updated_at`,
|
||||
[
|
||||
userId,
|
||||
deviceId,
|
||||
trackId,
|
||||
patch.trackId === null,
|
||||
queue === undefined ? null : JSON.stringify(queue),
|
||||
patch.queueIndex ?? null,
|
||||
patch.position === undefined ? null : clampPositionMs(patch.position, 0),
|
||||
patch.isPlaying ?? null,
|
||||
patch.vibeSessionId ?? null,
|
||||
patch.vibeSessionId === null,
|
||||
]
|
||||
);
|
||||
|
||||
let res: Awaited<ReturnType<typeof write>>;
|
||||
try {
|
||||
res = await write(patch.trackId ?? null);
|
||||
} catch (err) {
|
||||
if ((err as { code?: string }).code !== '23503') throw err;
|
||||
res = await write(null);
|
||||
}
|
||||
await this.touchDevice(userId, deviceId);
|
||||
await this.publish(userId);
|
||||
return toSnapshot(res.rows[0]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Aim a command at whichever device holds the audio. Any device may send one,
|
||||
* including the owner itself — that is what makes a phone a remote for the
|
||||
* desktop without either side knowing which is which.
|
||||
*
|
||||
* Ownership outlives a closed stream, so the owner is not necessarily
|
||||
* listening: a tab that was killed keeps the session until the sweep frees it.
|
||||
* Emitting into that gap answered the presser with a success it did not get,
|
||||
* so a command is only accepted while the owner has a stream to receive it on.
|
||||
*/
|
||||
async sendCommand(userId: string, command: PlaybackCommand): Promise<{ deliveredTo: string | null }> {
|
||||
const owner = await this.ownerId(userId);
|
||||
if (!owner || !this.hasLiveStream(userId, owner)) return { deliveredTo: null };
|
||||
this.emitter.emit(userId, { type: 'command', deviceId: owner, command } satisfies PlaybackEvent);
|
||||
return { deliveredTo: owner };
|
||||
}
|
||||
|
||||
/**
|
||||
* Move the audio to `deviceId`. The snapshot is untouched apart from the
|
||||
* owner, so the new device resumes the same track at the same position, and
|
||||
* the previous owner stops as soon as the state event reaches it.
|
||||
*/
|
||||
async transfer(userId: string, deviceId: string): Promise<PlaybackSnapshot> {
|
||||
await this.getState(userId);
|
||||
const device = await this.pgPool.query(
|
||||
'SELECT id FROM playback_devices WHERE id = $1 AND user_id = $2', [deviceId, userId]
|
||||
);
|
||||
if (device.rows.length === 0) throw new Error('unknown device');
|
||||
|
||||
const res = await this.pgPool.query<StateRow>(
|
||||
`UPDATE playback_state
|
||||
SET device_id = $2, version = version + 1, updated_at = NOW()
|
||||
WHERE user_id = $1
|
||||
RETURNING device_id, track_id, vibe_session_id, queue, queue_index, position_ms, is_playing, version, updated_at`,
|
||||
[userId, deviceId]
|
||||
);
|
||||
await this.publish(userId);
|
||||
return toSnapshot(res.rows[0]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Release ownership when the owning device goes away, leaving the snapshot
|
||||
* intact so another device can pick the session up where it stopped.
|
||||
*/
|
||||
async releaseIfOwner(userId: string, deviceId: string): Promise<void> {
|
||||
const res = await this.pgPool.query(
|
||||
`UPDATE playback_state SET device_id = NULL, is_playing = FALSE,
|
||||
version = version + 1, updated_at = NOW()
|
||||
WHERE user_id = $1 AND device_id = $2`,
|
||||
[userId, deviceId]
|
||||
);
|
||||
if (res.rowCount) await this.publish(userId);
|
||||
}
|
||||
|
||||
/** Drop devices that have not been seen for a day, and free a stale owner. */
|
||||
async reapStaleDevices(): Promise<number> {
|
||||
const stale = await this.pgPool.query<{ user_id: string }>(
|
||||
`UPDATE playback_state SET device_id = NULL, is_playing = FALSE,
|
||||
version = version + 1, updated_at = NOW()
|
||||
WHERE device_id IN (
|
||||
SELECT id FROM playback_devices
|
||||
WHERE last_seen_at < NOW() - ($1::int * INTERVAL '1 millisecond')
|
||||
)
|
||||
RETURNING user_id`,
|
||||
[DEVICE_STALE_MS]
|
||||
);
|
||||
const removed = await this.pgPool.query(
|
||||
`DELETE FROM playback_devices WHERE last_seen_at < NOW() - INTERVAL '1 day'`
|
||||
);
|
||||
for (const row of stale.rows) await this.publish(row.user_id);
|
||||
return removed.rowCount ?? 0;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,7 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { SessionDirector } from './session-director.service.js';
|
||||
import { mergeUniquePlan, scoreArcTransition, selectConstrainedSequence, SessionDirector, sessionSimilarityPenalty } from './session-director.service.js';
|
||||
import { DbService } from './db.service.js';
|
||||
import { ALL_GENERATORS } from './generators.service.js';
|
||||
|
||||
function makeMockDb(overrides: Record<string, any> = {}): DbService {
|
||||
const mockQuery = vi.fn();
|
||||
@@ -15,6 +16,76 @@ function makeMockDb(overrides: Record<string, any> = {}): DbService {
|
||||
}
|
||||
|
||||
describe('SessionDirector', () => {
|
||||
const candidate = (trackId: string) => ({
|
||||
trackId,
|
||||
generatorId: 'test',
|
||||
relevance: 1,
|
||||
explanation: [],
|
||||
});
|
||||
|
||||
describe('session queue exclusions', () => {
|
||||
it('keeps more than 100 unique selections without cycling', () => {
|
||||
const source = Array.from({ length: 125 }, (_, index) => candidate(`track-${index}`));
|
||||
const plan = mergeUniquePlan([], source, [], 125);
|
||||
|
||||
expect(plan).toHaveLength(125);
|
||||
expect(new Set(plan.map(item => item.trackId)).size).toBe(125);
|
||||
});
|
||||
|
||||
it('drops rapid skips and duplicate-only refill candidates', async () => {
|
||||
const director = new SessionDirector(makeMockDb());
|
||||
const buildPlan = vi.spyOn(director, 'buildPlan').mockResolvedValue([
|
||||
candidate('skipped'),
|
||||
candidate('already-queued'),
|
||||
candidate('fresh'),
|
||||
candidate('fresh'),
|
||||
]);
|
||||
|
||||
const plan = await director.replan(
|
||||
'user-1',
|
||||
'session-1',
|
||||
[candidate('skipped'), candidate('already-queued'), candidate('already-queued')],
|
||||
['skipped'],
|
||||
undefined,
|
||||
{ excludedTrackIds: ['older-skip', 'skipped'] }
|
||||
);
|
||||
|
||||
expect(plan.map(item => item.trackId)).toEqual(['already-queued', 'fresh']);
|
||||
expect(buildPlan).toHaveBeenCalledWith(
|
||||
'user-1',
|
||||
'session-1',
|
||||
undefined,
|
||||
expect.objectContaining({
|
||||
excludedTrackIds: expect.any(Set),
|
||||
})
|
||||
);
|
||||
const refillExclusions = (buildPlan.mock.calls[0][3] as any).excludedTrackIds as Set<string>;
|
||||
expect(refillExclusions).toEqual(new Set(['older-skip', 'skipped', 'already-queued']));
|
||||
expect((buildPlan.mock.calls[0][3] as any).retainedPlan.map((item: any) => item.trackId))
|
||||
.toEqual(['already-queued']);
|
||||
});
|
||||
|
||||
it('does not append anything when a refill contains only queued or excluded tracks', async () => {
|
||||
const director = new SessionDirector(makeMockDb());
|
||||
vi.spyOn(director, 'buildPlan').mockResolvedValue([
|
||||
candidate('queued'),
|
||||
candidate('skipped'),
|
||||
candidate('queued'),
|
||||
]);
|
||||
|
||||
const plan = await director.replan(
|
||||
'user-1',
|
||||
'session-1',
|
||||
[candidate('queued')],
|
||||
['skipped'],
|
||||
undefined,
|
||||
{ excludedTrackIds: ['skipped'] }
|
||||
);
|
||||
|
||||
expect(plan.map(item => item.trackId)).toEqual(['queued']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('pickArc', () => {
|
||||
const director = new SessionDirector(makeMockDb());
|
||||
|
||||
@@ -51,9 +122,261 @@ describe('SessionDirector', () => {
|
||||
|
||||
it('has valid role names', () => {
|
||||
const slots = director.getArcSlots('comfort', 20);
|
||||
const validRoles = ['known', 'adjacent', 'favorite', 'similar', 'new', 'medium', 'high', 'peak', 'cooldown', 'soft', 'ambient', 'acoustic', 'slow'];
|
||||
const validRoles = ['known', 'adjacent', 'favorite', 'similar', 'new', 'medium', 'high', 'peak', 'cooldown', 'soft', 'ambient', 'acoustic', 'slow', 'surprise'];
|
||||
slots.forEach(s => expect(validRoles).toContain(s.role));
|
||||
});
|
||||
|
||||
it('creates measurable targets, callbacks, and one bounded surprise in the first arc cycle', () => {
|
||||
const slots = director.getArcSlots('comfort', 20);
|
||||
expect(slots.every(slot => slot.targets && Object.keys(slot.targets).length > 0)).toBe(true);
|
||||
expect(slots.filter(slot => slot.surprise)).toHaveLength(1);
|
||||
const anchor = slots.find(slot => slot.callback?.phase === 'anchor');
|
||||
const callback = slots.find(slot => slot.callback?.phase === 'return');
|
||||
expect(anchor?.callback?.id).toBe(callback?.callback?.id);
|
||||
expect(anchor?.callback?.minSeparation).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('durable surprise delivery accounting', () => {
|
||||
it('counts only exact, served surprise plan-item exposures', async () => {
|
||||
const sessionId = '00000000-0000-4000-8000-000000000001';
|
||||
const userId = '00000000-0000-4000-8000-000000000002';
|
||||
const otherSessionId = '00000000-0000-4000-8000-000000000003';
|
||||
const revisionOneId = '00000000-0000-4000-8000-000000000011';
|
||||
const revisionTwoId = '00000000-0000-4000-8000-000000000012';
|
||||
const otherRevisionId = '00000000-0000-4000-8000-000000000013';
|
||||
const now = new Date();
|
||||
const recentAt = new Date(now.getTime() - 5 * 60 * 1000);
|
||||
const expiredAt = new Date(now.getTime() - 61 * 60 * 1000);
|
||||
const retainedTrackId = '00000000-0000-4000-8000-000000000021';
|
||||
const servedTrackId = '00000000-0000-4000-8000-000000000022';
|
||||
const oldTrackId = '00000000-0000-4000-8000-000000000023';
|
||||
|
||||
// This mirrors the three tables involved in the query. The unserved
|
||||
// retained row exists in both immutable revisions, but has no ledger
|
||||
// event and therefore must not consume a surprise budget.
|
||||
const versions = [
|
||||
{ id: revisionOneId, sessionId },
|
||||
{ id: revisionTwoId, sessionId },
|
||||
{ id: otherRevisionId, sessionId: otherSessionId },
|
||||
];
|
||||
const items = [
|
||||
{ planVersionId: revisionOneId, ordinal: 6, trackId: retainedTrackId, slotRole: 'surprise' },
|
||||
{ planVersionId: revisionTwoId, ordinal: 6, trackId: retainedTrackId, slotRole: 'surprise' },
|
||||
{ planVersionId: revisionTwoId, ordinal: 7, trackId: servedTrackId, slotRole: 'surprise' },
|
||||
{ planVersionId: revisionTwoId, ordinal: 8, trackId: oldTrackId, slotRole: 'surprise' },
|
||||
{ planVersionId: revisionTwoId, ordinal: 9, trackId: '00000000-0000-4000-8000-000000000024', slotRole: 'favorite' },
|
||||
{ planVersionId: otherRevisionId, ordinal: 7, trackId: servedTrackId, slotRole: 'surprise' },
|
||||
];
|
||||
const events = [
|
||||
// The exact revision-two association counts once.
|
||||
{ id: '00000000-0000-4000-8000-000000000031', sessionId, userId, type: 'track_served', trackId: servedTrackId, payload: { planVersionId: revisionTwoId, ordinal: 7 }, occurredAt: recentAt },
|
||||
// A valid historical exposure remains in the session total but falls
|
||||
// out of the rolling 60-minute counter.
|
||||
{ id: '00000000-0000-4000-8000-000000000032', sessionId, userId, type: 'track_served', trackId: oldTrackId, payload: { planVersionId: revisionTwoId, ordinal: 8 }, occurredAt: expiredAt },
|
||||
{ id: '00000000-0000-4000-8000-000000000033', sessionId, userId, type: 'track_served', trackId: servedTrackId, payload: { planVersionId: revisionOneId, ordinal: 7 }, occurredAt: recentAt }, // wrong ordinal
|
||||
{ id: '00000000-0000-4000-8000-000000000034', sessionId, userId, type: 'track_served', trackId: retainedTrackId, payload: { planVersionId: revisionTwoId, ordinal: 7 }, occurredAt: recentAt }, // wrong track
|
||||
{ id: '00000000-0000-4000-8000-000000000035', sessionId, userId, type: 'track_served', trackId: servedTrackId, payload: { planVersionId: '00000000-0000-4000-8000-000000000014', ordinal: 7 }, occurredAt: recentAt }, // wrong version
|
||||
{ id: '00000000-0000-4000-8000-000000000036', sessionId: otherSessionId, userId, type: 'track_served', trackId: servedTrackId, payload: { planVersionId: otherRevisionId, ordinal: 7 }, occurredAt: recentAt }, // wrong session
|
||||
{ id: '00000000-0000-4000-8000-000000000037', sessionId, userId, type: 'track_finished', trackId: servedTrackId, payload: { planVersionId: revisionTwoId, ordinal: 7 }, occurredAt: recentAt },
|
||||
// Legacy/corrupt payloads must neither cast-fail nor claim an actual
|
||||
// surprise exposure when somebody writes directly to the event ledger.
|
||||
{ id: '00000000-0000-4000-8000-000000000038', sessionId, userId, type: 'track_served', trackId: servedTrackId, payload: { planVersionId: 'not-a-uuid', ordinal: 7 }, occurredAt: recentAt },
|
||||
{ id: '00000000-0000-4000-8000-000000000039', sessionId, userId, type: 'track_served', trackId: servedTrackId, payload: { planVersionId: revisionTwoId, ordinal: 'not-an-integer' }, occurredAt: recentAt },
|
||||
{ id: '00000000-0000-4000-8000-000000000040', sessionId, userId, type: 'track_served', trackId: servedTrackId, payload: { planVersionId: revisionTwoId, ordinal: '999999999999999999999999999999999999' }, occurredAt: recentAt },
|
||||
];
|
||||
const exposureIds: string[] = [];
|
||||
const db = makeMockDb();
|
||||
(db.pgClient.query as any).mockImplementation((sql: string, params: unknown[]) => {
|
||||
// Faithfully evaluate the query's joins against the in-memory rows;
|
||||
// do not treat merely planned items as delivered exposure.
|
||||
expect(params).toEqual([sessionId, userId]);
|
||||
const matching = events.filter(event => {
|
||||
const item = items.find(candidate => candidate.planVersionId === event.payload.planVersionId
|
||||
&& candidate.ordinal === event.payload.ordinal
|
||||
&& candidate.trackId === event.trackId);
|
||||
const version = item && versions.find(candidate => candidate.id === item.planVersionId);
|
||||
return event.sessionId === sessionId
|
||||
&& event.userId === userId
|
||||
&& event.type === 'track_served'
|
||||
&& item?.slotRole === 'surprise'
|
||||
&& version?.sessionId === event.sessionId;
|
||||
});
|
||||
exposureIds.push(...new Set(matching.map(event => event.id)));
|
||||
return Promise.resolve({
|
||||
rows: [{
|
||||
session_count: new Set(matching.map(event => event.id)).size,
|
||||
hour_count: new Set(matching.filter(event => event.occurredAt > new Date(Date.now() - 60 * 60 * 1000)).map(event => event.id)).size,
|
||||
}],
|
||||
});
|
||||
});
|
||||
|
||||
const usage = await (new SessionDirector(db) as any).getSessionSurpriseUsage(sessionId, userId);
|
||||
|
||||
expect(usage).toEqual({ session: 2, hour: 1 });
|
||||
expect(exposureIds).toEqual([
|
||||
'00000000-0000-4000-8000-000000000031',
|
||||
'00000000-0000-4000-8000-000000000032',
|
||||
]);
|
||||
const [sql, params] = (db.pgClient.query as any).mock.calls[0] as [string, unknown[]];
|
||||
expect(params).toEqual([sessionId, userId]);
|
||||
expect(sql).toContain('SELECT DISTINCT e.id, e.occurred_at');
|
||||
expect(sql).toContain("e.payload->>'planVersionId' ~*");
|
||||
expect(sql).toContain("THEN (e.payload->>'planVersionId')::uuid");
|
||||
expect(sql).toContain("e.payload->>'ordinal') ~ '^(0|[1-9][0-9]{0,8})$'");
|
||||
expect(sql).toContain("THEN (e.payload->>'ordinal')::integer");
|
||||
expect(sql).toContain('AND item.track_id = e.track_id');
|
||||
expect(sql).toContain('AND version.session_id = e.session_id');
|
||||
expect(sql).toContain("e.type = 'track_served'");
|
||||
expect(sql).toContain("item.slot_role = 'surprise'");
|
||||
expect(sql).toContain('WHERE e.session_id = $1');
|
||||
expect(sql).toContain('AND e.user_id = $2');
|
||||
expect(sql).toContain("occurred_at > NOW() - INTERVAL '1 hour'");
|
||||
});
|
||||
});
|
||||
|
||||
describe('transition-aware sequence scoring', () => {
|
||||
it('prefers a smooth, on-arc candidate and treats missing analysis as lower confidence', () => {
|
||||
const slot = {
|
||||
position: 0,
|
||||
role: 'high',
|
||||
targets: { energy: { min: 0.7, max: 0.9, maxDelta: 0.2 }, tempo: { min: 120, max: 160, maxDelta: 25 } },
|
||||
};
|
||||
const previous = { energy: 0.72, bpm: 132 };
|
||||
const smooth = scoreArcTransition(candidate('smooth'), { energy: 0.78, bpm: 140 }, previous, slot);
|
||||
const abrupt = scoreArcTransition(candidate('abrupt'), { energy: 0.15, bpm: 72 }, previous, slot);
|
||||
const unknown = scoreArcTransition(candidate('unknown'), {}, previous, slot);
|
||||
|
||||
expect(smooth.score).toBeGreaterThan(abrupt.score);
|
||||
expect(unknown.confidence).toBeLessThan(smooth.confidence);
|
||||
expect(unknown.score).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('selects a callback inside its separation window while preserving hard caps', () => {
|
||||
const candidates = ['anchor', 'bridge-a', 'bridge-b', 'return', 'other'].map(id => ({ ...candidate(id), generatorId: 'comfort' }));
|
||||
const metadata = new Map([
|
||||
['anchor', { artistId: 'theme', albumId: 'a1', favorite: true, energy: 0.5 }],
|
||||
['bridge-a', { artistId: 'a2', albumId: 'a2', energy: 0.5 }],
|
||||
['bridge-b', { artistId: 'a3', albumId: 'a3', energy: 0.5 }],
|
||||
['return', { artistId: 'theme', albumId: 'a4', favorite: true, energy: 0.5 }],
|
||||
['other', { artistId: 'a4', albumId: 'a5', favorite: false, energy: 0.5 }],
|
||||
]);
|
||||
const token = { id: 'theme', theme: 'artist' as const, minSeparation: 2, maxSeparation: 4 };
|
||||
const result = selectConstrainedSequence({
|
||||
candidates,
|
||||
metadata,
|
||||
budgets: [],
|
||||
roleToGeneratorIds: () => ['comfort'],
|
||||
slots: [
|
||||
{ position: 0, role: 'known', targets: {}, callback: { ...token, phase: 'anchor' as const } },
|
||||
{ position: 1, role: 'known', targets: {} },
|
||||
{ position: 2, role: 'known', targets: {} },
|
||||
{ position: 3, role: 'favorite', targets: {}, callback: { ...token, phase: 'return' as const } },
|
||||
],
|
||||
});
|
||||
expect(result.plan.map(item => item.trackId)).toEqual(['anchor', 'bridge-a', 'bridge-b', 'return']);
|
||||
expect(result.plan[3].plan?.scoreBreakdown.callback).toBe(1);
|
||||
});
|
||||
|
||||
it('plans an energetic rise through peak and cooldown when measured candidates exist', () => {
|
||||
const db = makeMockDb();
|
||||
const director = new SessionDirector(db);
|
||||
const slots = director.getArcSlots('energetic', 8);
|
||||
const entries = [
|
||||
['medium-1', 'comfort', 0.55, 110], ['medium-2', 'comfort', 0.62, 125],
|
||||
['high-1', 'discovery', 0.72, 135], ['high-2', 'discovery', 0.8, 150],
|
||||
['peak', 'deep-dive', 0.9, 165], ['surprise', 'discovery', 0.85, 155],
|
||||
['cooldown-1', 'comfort', 0.62, 130], ['cooldown-2', 'comfort', 0.5, 110],
|
||||
] as const;
|
||||
const candidates = entries.map(([trackId, generatorId]) => ({ ...candidate(trackId), generatorId }));
|
||||
const metadata: Map<string, any> = new Map(entries.map(([trackId, generatorId, energy, bpm], index) => [trackId, {
|
||||
artistId: `artist-${index}`, albumId: `album-${index}`, energy, bpm,
|
||||
valence: 0.6, acousticness: trackId.startsWith('cooldown') ? 0.3 : 0.1,
|
||||
favorite: String(trackId).startsWith('medium') || String(trackId).startsWith('cooldown'),
|
||||
newArtist: generatorId === 'discovery',
|
||||
}]));
|
||||
const result = selectConstrainedSequence({
|
||||
candidates, slots, metadata, budgets: [],
|
||||
roleToGeneratorIds: role => (director as any).roleToGeneratorIds(role),
|
||||
});
|
||||
|
||||
expect(result.plan.map(item => item.trackId)).toEqual(entries.map(([trackId]) => trackId));
|
||||
expect(result.plan.map(item => metadata.get(item.trackId)?.energy))
|
||||
.toEqual([0.55, 0.62, 0.72, 0.8, 0.9, 0.85, 0.62, 0.5]);
|
||||
expect(result.relaxations).toEqual([]);
|
||||
});
|
||||
|
||||
it('keeps the familiar-new-familiar discovery callback intact', () => {
|
||||
const db = makeMockDb();
|
||||
const director = new SessionDirector(db);
|
||||
const candidates = [
|
||||
{ ...candidate('favorite-anchor'), generatorId: 'deep-dive' },
|
||||
{ ...candidate('adjacent'), generatorId: 'adjacent' },
|
||||
{ ...candidate('new'), generatorId: 'discovery' },
|
||||
{ ...candidate('favorite-return'), generatorId: 'deep-dive' },
|
||||
];
|
||||
const metadata = new Map([
|
||||
['favorite-anchor', { artistId: 'theme', albumId: 'a1', favorite: true, energy: 0.5 }],
|
||||
['adjacent', { artistId: 'bridge', albumId: 'a2', energy: 0.55 }],
|
||||
['new', { artistId: 'new', albumId: 'a3', newArtist: true, energy: 0.6 }],
|
||||
['favorite-return', { artistId: 'theme', albumId: 'a4', favorite: true, energy: 0.55 }],
|
||||
]);
|
||||
const result = selectConstrainedSequence({
|
||||
candidates, slots: director.getArcSlots('discovery', 4), metadata, budgets: [],
|
||||
roleToGeneratorIds: role => (director as any).roleToGeneratorIds(role),
|
||||
});
|
||||
|
||||
expect(result.plan.map(item => item.trackId))
|
||||
.toEqual(['favorite-anchor', 'adjacent', 'new', 'favorite-return']);
|
||||
expect(result.plan[3].plan?.explanation.callback).toMatchObject({ phase: 'return', matchScore: 1 });
|
||||
});
|
||||
|
||||
it('downgrades a surprise deterministically when no preferred recovery anchor is feasible', () => {
|
||||
const db = makeMockDb();
|
||||
const director = new SessionDirector(db);
|
||||
const candidates = [
|
||||
{ ...candidate('favorite-anchor'), generatorId: 'deep-dive' },
|
||||
{ ...candidate('adjacent'), generatorId: 'adjacent' },
|
||||
{ ...candidate('unrecoverable-surprise'), generatorId: 'discovery' },
|
||||
// This may fill the downgraded favourite slot only through the
|
||||
// explicitly persisted arc-source relaxation; it cannot reserve a
|
||||
// recovery for the surprise because it is not a favourite source.
|
||||
{ ...candidate('fallback'), generatorId: 'contextual' },
|
||||
];
|
||||
const metadata = new Map(candidates.map((item, index) => [item.trackId, {
|
||||
artistId: `artist-${index}`, albumId: `album-${index}`,
|
||||
favorite: item.trackId === 'favorite-anchor', newArtist: item.trackId === 'unrecoverable-surprise', energy: 0.5,
|
||||
}]));
|
||||
const result = selectConstrainedSequence({
|
||||
candidates, slots: director.getArcSlots('discovery', 3), metadata, budgets: [],
|
||||
roleToGeneratorIds: role => (director as any).roleToGeneratorIds(role),
|
||||
});
|
||||
|
||||
expect(result.plan.map(item => item.trackId)).toEqual(['favorite-anchor', 'adjacent', 'fallback']);
|
||||
expect(result.plan[2].plan?.slotRole).toBe('favorite');
|
||||
expect(result.relaxations).toContainEqual(expect.objectContaining({ constraint: 'surprise_recovery' }));
|
||||
});
|
||||
|
||||
it('falls back safely with sparse audio analysis and records arc precision', () => {
|
||||
const db = makeMockDb();
|
||||
const director = new SessionDirector(db);
|
||||
const result = selectConstrainedSequence({
|
||||
candidates: [
|
||||
{ ...candidate('unknown-analysis'), generatorId: 'comfort' },
|
||||
{ ...candidate('wrong-energy'), generatorId: 'comfort' },
|
||||
],
|
||||
slots: director.getArcSlots('energetic', 1),
|
||||
metadata: new Map([
|
||||
['unknown-analysis', { artistId: 'a1', albumId: 'x1' }],
|
||||
['wrong-energy', { artistId: 'a2', albumId: 'x2', energy: 0.1, bpm: 70 }],
|
||||
]),
|
||||
budgets: [], roleToGeneratorIds: role => (director as any).roleToGeneratorIds(role),
|
||||
});
|
||||
|
||||
expect(result.plan.map(item => item.trackId)).toEqual(['unknown-analysis']);
|
||||
expect(result.relaxations).toContainEqual(expect.objectContaining({ constraint: 'arc_precision' }));
|
||||
expect(result.plan[0].plan?.explanation.arcPrecision).toMatchObject({ measuredFit: false });
|
||||
});
|
||||
});
|
||||
|
||||
describe('computeEntropy', () => {
|
||||
@@ -91,7 +414,7 @@ describe('SessionDirector', () => {
|
||||
{ trackId: 't1', generatorId: 'a', relevance: 0.9, explanation: [{ subjectType: 'artist', subjectId: 'a1', predicate: 'credited_main_on', objectType: 'track', objectId: 't1', fusedValue: 1 }] },
|
||||
{ trackId: 't2', generatorId: 'b', relevance: 0.3, explanation: [{ subjectType: 'artist', subjectId: 'a2', predicate: 'credited_main_on', objectType: 'track', objectId: 't2', fusedValue: 1 }] },
|
||||
];
|
||||
const fatigue = { artist: new Map(), genre: new Map(), track: new Map(), language: new Map(), vocal: 0 };
|
||||
const fatigue = { artist: new Map(), album: new Map(), genre: new Map(), track: new Map(), language: new Map(), vocal: 0 };
|
||||
const budgets = [{ dimension: 'artist', budgetShare: 0.2, horizonMin: 30, spent: 0 }];
|
||||
const state = { energy: 0.5, noveltyHunger: 0.3, sessionAgeMin: 10, lastArtistIds: [], lastGenreIds: [], context: null };
|
||||
|
||||
@@ -99,6 +422,478 @@ describe('SessionDirector', () => {
|
||||
const ranked = await director.rankCandidates(candidates, fatigue, budgets, state, repetitionState);
|
||||
expect(ranked[0].relevance).toBeGreaterThanOrEqual(ranked[ranked.length - 1].relevance);
|
||||
});
|
||||
|
||||
it('lifts a never-played track above a familiar one of similar relevance', async () => {
|
||||
const db = makeMockDb();
|
||||
(db.pgClient.query as any).mockResolvedValue({
|
||||
rows: [
|
||||
{ track_id: 'heard', artist_id: 'a1', unheard: false, favorite: false },
|
||||
{ track_id: 'unheard', artist_id: 'a2', unheard: true, favorite: false },
|
||||
],
|
||||
});
|
||||
const director = new SessionDirector(db);
|
||||
|
||||
const candidates = [
|
||||
{ trackId: 'heard', generatorId: 'comfort', relevance: 0.6, explanation: [] },
|
||||
{ trackId: 'unheard', generatorId: 'library-fallback', relevance: 0.4, explanation: [] },
|
||||
] as any;
|
||||
const fatigue = { artist: new Map(), album: new Map(), genre: new Map(), track: new Map(), language: new Map(), vocal: 0 };
|
||||
const state = { energy: 0.5, noveltyHunger: 0.3, sessionAgeMin: 10, lastArtistIds: [], lastGenreIds: [], context: null };
|
||||
const repetitionState = { recentTrackIds: new Set<string>(), recentArtistIds: new Set<string>() };
|
||||
|
||||
const ranked = await director.rankCandidates(candidates, fatigue, [], state, repetitionState);
|
||||
|
||||
expect(ranked[0].trackId).toBe('unheard');
|
||||
});
|
||||
});
|
||||
|
||||
describe('unheard floor', () => {
|
||||
const director = new SessionDirector(makeMockDb());
|
||||
const meta = (unheard: boolean) => ({ unheard });
|
||||
|
||||
it('substitutes unheard candidates for the lowest-priority heard entries', () => {
|
||||
const plan = ['p0', 'p1', 'p2', 'p3'].map(candidate);
|
||||
const pool = [...plan, ...['u1', 'u2'].map(candidate)];
|
||||
const metadata = new Map([
|
||||
...plan.map(item => [item.trackId, meta(false)] as const),
|
||||
['u1', meta(true)] as const,
|
||||
['u2', meta(true)] as const,
|
||||
]);
|
||||
|
||||
const filled = (director as any).enforceUnheardFloor(plan, pool, metadata, []);
|
||||
|
||||
// One in four of a four-item plan, taken from the tail.
|
||||
expect(filled.map((item: any) => item.trackId)).toEqual(['p0', 'p1', 'p2', 'u1']);
|
||||
});
|
||||
|
||||
it('never displaces the track about to play', () => {
|
||||
const plan = [candidate('p0')];
|
||||
const pool = [candidate('p0'), candidate('u1')];
|
||||
const metadata = new Map([['p0', meta(false)] as const, ['u1', meta(true)] as const]);
|
||||
|
||||
const filled = (director as any).enforceUnheardFloor(plan, pool, metadata, []);
|
||||
|
||||
expect(filled.map((item: any) => item.trackId)).toEqual(['p0']);
|
||||
});
|
||||
|
||||
it('leaves a plan that already meets the floor alone', () => {
|
||||
const plan = [candidate('u1'), candidate('p1'), candidate('p2'), candidate('p3')];
|
||||
const pool = [...plan, candidate('u2')];
|
||||
const metadata = new Map([
|
||||
['u1', meta(true)] as const,
|
||||
['p1', meta(false)] as const,
|
||||
['p2', meta(false)] as const,
|
||||
['p3', meta(false)] as const,
|
||||
['u2', meta(true)] as const,
|
||||
]);
|
||||
|
||||
const filled = (director as any).enforceUnheardFloor(plan, pool, metadata, []);
|
||||
|
||||
expect(filled.map((item: any) => item.trackId)).toEqual(['u1', 'p1', 'p2', 'p3']);
|
||||
});
|
||||
|
||||
it('counts the retained tail towards the floor', () => {
|
||||
const plan = [candidate('p0'), candidate('p1')];
|
||||
const retained = [candidate('u0'), candidate('r1')];
|
||||
const pool = [...plan, candidate('u1')];
|
||||
const metadata = new Map([
|
||||
['p0', meta(false)] as const,
|
||||
['p1', meta(false)] as const,
|
||||
['u0', meta(true)] as const,
|
||||
['r1', meta(false)] as const,
|
||||
['u1', meta(true)] as const,
|
||||
]);
|
||||
|
||||
const filled = (director as any).enforceUnheardFloor(plan, pool, metadata, retained);
|
||||
|
||||
expect(filled.map((item: any) => item.trackId)).toEqual(['p0', 'p1']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('recent session fingerprint penalty', () => {
|
||||
it('softly penalizes a repeated session shape without excluding it', () => {
|
||||
const repeated = sessionSimilarityPenalty(
|
||||
{ artistId: 'artist-1', genreId: 'genre-1' },
|
||||
'comfort',
|
||||
[{ artists: ['artist-1'], genres: ['genre-1'], sources: ['comfort'] }],
|
||||
);
|
||||
const newShape = sessionSimilarityPenalty(
|
||||
{ artistId: 'artist-2', genreId: 'genre-2' },
|
||||
'discovery',
|
||||
[{ artists: ['artist-1'], genres: ['genre-1'], sources: ['comfort'] }],
|
||||
);
|
||||
expect(repeated).toBeGreaterThan(0);
|
||||
expect(repeated).toBeLessThanOrEqual(0.18);
|
||||
expect(newShape).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('sequence constraints', () => {
|
||||
const slots = Array.from({ length: 10 }, (_, position) => ({ position, role: 'known' }));
|
||||
const budgets = [
|
||||
{ dimension: 'artist', budgetShare: 0.2, horizonMin: 30, spent: 0 },
|
||||
{ dimension: 'genre', budgetShare: 0.4, horizonMin: 30, spent: 0 },
|
||||
{ dimension: 'language', budgetShare: 0.6, horizonMin: 30, spent: 0 },
|
||||
{ dimension: 'instrumental', budgetShare: 0.1, horizonMin: 30, spent: 0 },
|
||||
{ dimension: 'new_artist', budgetShare: 0.15, horizonMin: 60, spent: 0 },
|
||||
{ dimension: 'favorite', budgetShare: 0.25, horizonMin: 60, spent: 0 },
|
||||
];
|
||||
const roleToGeneratorIds = () => ['comfort'];
|
||||
|
||||
it('projects budgets while enforcing artist and album caps across the sequence', () => {
|
||||
const candidates = Array.from({ length: 15 }, (_, i) => ({ ...candidate(`t${i}`), generatorId: 'comfort' }));
|
||||
const metadata = new Map(candidates.map((item, i) => [item.trackId, {
|
||||
artistId: i < 5 ? 'overplayed-artist' : `artist-${i}`,
|
||||
albumId: i < 4 ? 'overplayed-album' : `album-${i}`,
|
||||
genreId: i < 6 ? 'genre-a' : 'genre-b',
|
||||
language: i < 7 ? 'ja' : 'en',
|
||||
instrumental: i === 7,
|
||||
newArtist: i === 8 || i === 9,
|
||||
favorite: i === 10 || i === 11 || i === 12,
|
||||
}]));
|
||||
|
||||
const result = selectConstrainedSequence({ candidates, slots, metadata, budgets, roleToGeneratorIds });
|
||||
expect(result.plan).toHaveLength(10);
|
||||
const ids = result.plan.map(item => item.trackId);
|
||||
expect(ids.filter(id => metadata.get(id)?.artistId === 'overplayed-artist')).toHaveLength(2);
|
||||
expect(ids.filter(id => metadata.get(id)?.albumId === 'overplayed-album').length).toBeLessThanOrEqual(3);
|
||||
expect(ids.filter(id => metadata.get(id)?.instrumental)).toHaveLength(1);
|
||||
expect(ids.filter(id => metadata.get(id)?.newArtist)).toHaveLength(2);
|
||||
expect(ids.filter(id => metadata.get(id)?.favorite)).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('corrects the detected dimension directly before relaxing it', () => {
|
||||
const candidates = ['ja-1', 'ja-2', 'en-1', 'en-2'].map(trackId => ({ ...candidate(trackId), generatorId: 'comfort' }));
|
||||
const metadata = new Map([
|
||||
['ja-1', { artistId: 'a1', albumId: 'x1', language: 'ja' }],
|
||||
['ja-2', { artistId: 'a2', albumId: 'x2', language: 'ja' }],
|
||||
['en-1', { artistId: 'a3', albumId: 'x3', language: 'en' }],
|
||||
['en-2', { artistId: 'a4', albumId: 'x4', language: 'en' }],
|
||||
]);
|
||||
const result = selectConstrainedSequence({
|
||||
candidates,
|
||||
slots: slots.slice(0, 2),
|
||||
metadata,
|
||||
budgets: [],
|
||||
roleToGeneratorIds,
|
||||
loopDimension: 'language',
|
||||
loopedValue: 'ja',
|
||||
});
|
||||
expect(result.plan.map(item => item.trackId)).toEqual(['en-1', 'en-2']);
|
||||
expect(result.relaxations).toEqual([]);
|
||||
});
|
||||
|
||||
it('records a structured soft relaxation without violating hard album caps', () => {
|
||||
const candidates = Array.from({ length: 5 }, (_, i) => ({ ...candidate(`t${i}`), generatorId: 'comfort' }));
|
||||
const metadata = new Map(candidates.map((item, i) => [item.trackId, {
|
||||
artistId: `artist-${i}`,
|
||||
albumId: i < 4 ? 'single-album' : `album-${i}`,
|
||||
language: 'ja',
|
||||
}]));
|
||||
const result = selectConstrainedSequence({
|
||||
candidates, slots: slots.slice(0, 5), metadata, budgets: [], roleToGeneratorIds,
|
||||
loopDimension: 'language', loopedValue: 'ja',
|
||||
});
|
||||
expect(result.plan.filter(item => metadata.get(item.trackId)?.albumId === 'single-album')).toHaveLength(3);
|
||||
expect(result.relaxations).toContainEqual(expect.objectContaining({ stage: 'soft_budget' }));
|
||||
});
|
||||
|
||||
it('counts the retained queue tail against hard artist caps before selecting replacements', () => {
|
||||
const retained = [candidate('queued-a1'), candidate('queued-a2')];
|
||||
const candidates = [candidate('same-artist'), candidate('other-artist')].map(item => ({ ...item, generatorId: 'comfort' }));
|
||||
const metadata = new Map([
|
||||
['queued-a1', { artistId: 'artist-a', albumId: 'queued-album-1' }],
|
||||
['queued-a2', { artistId: 'artist-a', albumId: 'queued-album-2' }],
|
||||
['same-artist', { artistId: 'artist-a', albumId: 'replacement-album' }],
|
||||
['other-artist', { artistId: 'artist-b', albumId: 'replacement-album-2' }],
|
||||
]);
|
||||
|
||||
const result = selectConstrainedSequence({
|
||||
candidates, slots: slots.slice(0, 1), metadata, budgets: [], roleToGeneratorIds, retainedPlan: retained,
|
||||
});
|
||||
expect(result.plan.map(item => item.trackId)).toEqual(['other-artist']);
|
||||
});
|
||||
|
||||
it('enforces the three-track album limit across the rolling 40-play history', () => {
|
||||
const candidates = [candidate('same-album'), candidate('new-album')].map(item => ({ ...item, generatorId: 'comfort' }));
|
||||
const metadata = new Map([
|
||||
['same-album', { artistId: 'a4', albumId: 'album-a' }],
|
||||
['new-album', { artistId: 'a5', albumId: 'album-b' }],
|
||||
]);
|
||||
const albumHistory = Array.from({ length: 40 }, (_, index) => ({
|
||||
artistId: `history-${index}`,
|
||||
albumId: index < 3 ? 'album-a' : `history-album-${index}`,
|
||||
}));
|
||||
|
||||
const result = selectConstrainedSequence({
|
||||
candidates, slots: slots.slice(0, 1), metadata, budgets: [], roleToGeneratorIds, albumHistory,
|
||||
});
|
||||
expect(result.plan.map(item => item.trackId)).toEqual(['new-album']);
|
||||
});
|
||||
|
||||
it('projects budgets over historical counts and the planned horizon with track-consistent denominators', () => {
|
||||
const candidates = [candidate('ja'), candidate('en')].map(item => ({ ...item, generatorId: 'comfort' }));
|
||||
const metadata = new Map([
|
||||
['ja', { artistId: 'a1', albumId: 'x1', genreId: 'j-pop' }],
|
||||
['en', { artistId: 'a2', albumId: 'x2', genreId: 'rock' }],
|
||||
]);
|
||||
const historicalValues = new Map([['j-pop', 4], ['rock', 1]]);
|
||||
const result = selectConstrainedSequence({
|
||||
candidates,
|
||||
slots: slots.slice(0, 1),
|
||||
metadata,
|
||||
budgets: [{ dimension: 'genre', budgetShare: 0.6, horizonMin: 30, spent: 0.8, historicalTotal: 5, historicalValues }],
|
||||
roleToGeneratorIds,
|
||||
});
|
||||
// 4 / 5 becomes 4 / 6 if rock is selected; a fifth j-pop track would
|
||||
// exceed the 60% cap. The selector must use history + proposal, not only
|
||||
// the one-track replacement queue.
|
||||
expect(result.plan.map(item => item.trackId)).toEqual(['en']);
|
||||
});
|
||||
|
||||
it('does not treat unknown instrumentation as a vocal/instrumental budget credit', () => {
|
||||
const candidates = [candidate('unknown'), candidate('instrumental')].map(item => ({ ...item, generatorId: 'comfort' }));
|
||||
const metadata = new Map([
|
||||
['unknown', { artistId: 'a1', albumId: 'x1' }],
|
||||
['instrumental', { artistId: 'a2', albumId: 'x2', instrumental: true }],
|
||||
]);
|
||||
const result = selectConstrainedSequence({
|
||||
candidates,
|
||||
slots: slots.slice(0, 1),
|
||||
metadata,
|
||||
budgets: [{ dimension: 'instrumental', budgetShare: 1, horizonMin: 30, spent: 0, historicalTotal: 0, historicalValues: new Map() }],
|
||||
roleToGeneratorIds,
|
||||
});
|
||||
expect(result.plan.map(item => item.trackId)).toEqual(['instrumental']);
|
||||
});
|
||||
|
||||
it('excludes candidates matching any detected producer or label, not only their first claim', () => {
|
||||
const candidates = [candidate('producer-match'), candidate('label-match'), candidate('safe')].map(item => ({ ...item, generatorId: 'comfort' }));
|
||||
const metadata = new Map([
|
||||
['producer-match', { artistId: 'a1', albumId: 'x1', producerIds: ['other', 'producer-loop'] }],
|
||||
['label-match', { artistId: 'a2', albumId: 'x2', labelIds: ['other', 'label-loop'] }],
|
||||
['safe', { artistId: 'a3', albumId: 'x3', producerIds: ['safe-producer'], labelIds: ['safe-label'] }],
|
||||
]);
|
||||
const producerResult = selectConstrainedSequence({
|
||||
candidates, slots: slots.slice(0, 1), metadata, budgets: [], roleToGeneratorIds,
|
||||
loopDimension: 'producer', loopedValues: ['producer-loop'],
|
||||
});
|
||||
const labelResult = selectConstrainedSequence({
|
||||
candidates, slots: slots.slice(0, 1), metadata, budgets: [], roleToGeneratorIds,
|
||||
loopDimension: 'label', loopedValues: ['label-loop'],
|
||||
});
|
||||
expect(producerResult.plan.map(item => item.trackId)).not.toContain('producer-match');
|
||||
expect(labelResult.plan.map(item => item.trackId)).not.toContain('label-match');
|
||||
});
|
||||
|
||||
it('keeps stable candidate order while reusing a role preference pool', () => {
|
||||
const candidates = [
|
||||
{ ...candidate('comfort-first'), generatorId: 'comfort' },
|
||||
{ ...candidate('adjacent-first'), generatorId: 'adjacent' },
|
||||
{ ...candidate('comfort-second'), generatorId: 'comfort' },
|
||||
{ ...candidate('adjacent-second'), generatorId: 'adjacent' },
|
||||
];
|
||||
const metadata = new Map(candidates.map((item, index) => [item.trackId, {
|
||||
artistId: `artist-${index}`, albumId: `album-${index}`,
|
||||
}]));
|
||||
const result = selectConstrainedSequence({
|
||||
candidates,
|
||||
slots: Array.from({ length: 4 }, (_, position) => ({ position, role: position % 2 ? 'adjacent' : 'known' })),
|
||||
metadata,
|
||||
budgets: [],
|
||||
roleToGeneratorIds: role => role === 'adjacent' ? ['adjacent'] : ['comfort'],
|
||||
});
|
||||
expect(result.plan.map(item => item.trackId)).toEqual([
|
||||
'comfort-first', 'adjacent-first', 'comfort-second', 'adjacent-second',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('anti-loop signals', () => {
|
||||
const variedRecentPlays = Array.from({ length: 4 }, (_, index) => ({
|
||||
trackId: `00000000-0000-0000-0000-00000000000${index + 1}`,
|
||||
artistId: `artist-${index}`,
|
||||
genreId: `genre-${index}`,
|
||||
language: `lang-${index}`,
|
||||
bpm: 80 + index * 30,
|
||||
energy: index / 3,
|
||||
vocal: null,
|
||||
decade: 1980 + index * 10,
|
||||
valence: index % 2,
|
||||
albumId: `album-${index}`,
|
||||
producerIds: [],
|
||||
labelIds: [],
|
||||
}));
|
||||
|
||||
it('returns fused producer lineage from resolved main artists', async () => {
|
||||
const db = makeMockDb();
|
||||
(db.pgClient.query as any).mockResolvedValue({ rows: [{ lineage_id: 'producer-a' }, { lineage_id: 'producer-b' }] });
|
||||
const director = new SessionDirector(db);
|
||||
const signal = await director.detectAntiLoop({} as any, {} as any, [], variedRecentPlays);
|
||||
expect(signal).toEqual({ dimension: 'producer', values: ['producer-a', 'producer-b'] });
|
||||
const sql = (db.pgClient.query as any).mock.calls[0][0] as string;
|
||||
expect(sql).toContain('claim_fusion cf');
|
||||
expect(sql).toContain("cf.subject_type = 'artist'");
|
||||
expect(sql).toContain('cf.object_id = recent.artist_id');
|
||||
expect(sql).toContain('ORDER BY ta.confidence DESC, ta.artist_id');
|
||||
});
|
||||
|
||||
it('returns label identities after a producer check finds no loop', async () => {
|
||||
const db = makeMockDb();
|
||||
(db.pgClient.query as any)
|
||||
.mockResolvedValueOnce({ rows: [] })
|
||||
.mockResolvedValueOnce({ rows: [{ lineage_id: 'label-a' }] });
|
||||
const director = new SessionDirector(db);
|
||||
const signal = await director.detectAntiLoop({} as any, {} as any, [], variedRecentPlays);
|
||||
expect(signal).toEqual({ dimension: 'label', values: ['label-a'] });
|
||||
const sql = (db.pgClient.query as any).mock.calls[1][0] as string;
|
||||
expect(sql).toContain("cf.predicate = 'same_label_as'");
|
||||
expect(sql).toContain('cf.subject_id = recent.artist_id OR cf.object_id = recent.artist_id');
|
||||
});
|
||||
});
|
||||
|
||||
describe('planner metadata and integration boundaries', () => {
|
||||
it('counts every completed play in a budget horizon while only classifying known values', async () => {
|
||||
const db = makeMockDb();
|
||||
(db.pgClient.query as any).mockResolvedValue({
|
||||
rows: [{ value: 'rock', cnt: 3 }, { value: null, cnt: 2 }],
|
||||
});
|
||||
const director = new SessionDirector(db);
|
||||
const usage = await (director as any).loadBudgetUsage('user-1', 'genre', 30);
|
||||
expect(usage).toMatchObject({ total: 5, spent: 0.6 });
|
||||
expect(usage.values).toEqual(new Map([['rock', 3]]));
|
||||
const sql = (db.pgClient.query as any).mock.calls[0][0] as string;
|
||||
expect(sql).toContain('WITH completed_plays AS');
|
||||
expect(sql).not.toContain('WHERE value IS NOT NULL');
|
||||
});
|
||||
|
||||
it('loads producer and label lineage from fused relationships of the resolved main artist', async () => {
|
||||
const db = makeMockDb();
|
||||
(db.pgClient.query as any).mockResolvedValue({
|
||||
rows: [{
|
||||
track_id: 'track-1', artist_id: 'artist-1', album_id: 'album-1', genre_id: null,
|
||||
language: null, instrumentalness: null, favorite: false, new_artist: false,
|
||||
energy: null, bpm: null, valence: null, release_date: null,
|
||||
producer_ids: ['producer-from-object', 'producer-from-subject'],
|
||||
label_ids: ['label-from-object', 'label-from-subject'],
|
||||
}],
|
||||
});
|
||||
const director = new SessionDirector(db);
|
||||
const metadata = await (director as any).loadConstraintMetadata('user-1', ['track-1']);
|
||||
expect(metadata.get('track-1')).toMatchObject({
|
||||
artistId: 'artist-1',
|
||||
producerIds: ['producer-from-object', 'producer-from-subject'],
|
||||
labelIds: ['label-from-object', 'label-from-subject'],
|
||||
});
|
||||
const sql = (db.pgClient.query as any).mock.calls[0][0] as string;
|
||||
expect(sql).toContain('FROM claim_fusion cf');
|
||||
expect(sql).toContain("cf.predicate = 'produced'");
|
||||
expect(sql).toContain("cf.predicate = 'same_label_as'");
|
||||
expect(sql).toContain('cf.subject_id = artist.artist_id OR cf.object_id = artist.artist_id');
|
||||
expect(sql).toContain('ORDER BY ta.confidence DESC, ta.artist_id');
|
||||
});
|
||||
|
||||
it('carries the retained tail and all 40 album-history plays through replan into constraint selection', async () => {
|
||||
const db = makeMockDb({
|
||||
getVibeSessionTrackIds: vi.fn().mockResolvedValue([]),
|
||||
getListenerBeliefs: vi.fn().mockResolvedValue([]),
|
||||
});
|
||||
const director = new SessionDirector(db);
|
||||
const history = Array.from({ length: 40 }, (_, index) => ({
|
||||
track_id: `history-${index}`, album_id: index < 3 ? 'history-album' : `old-album-${index}`,
|
||||
artist_id: `history-artist-${index}`, genre_id: null, bpm: null, energy: null,
|
||||
valence: null, instrumentalness: null, language: null, release_date: null,
|
||||
producer_ids: [], label_ids: [],
|
||||
}));
|
||||
(db.pgClient.query as any).mockImplementation((sql: string, params: unknown[] = []) => {
|
||||
if (sql.includes('FROM play_history ph') && sql.includes('LIMIT $2')) return Promise.resolve({ rows: history });
|
||||
if (sql.includes('WHERE t.id = ANY($2::uuid[])')) {
|
||||
const ids = params[1] as string[];
|
||||
return Promise.resolve({ rows: ids.map(trackId => ({
|
||||
track_id: trackId,
|
||||
artist_id: `artist-${trackId}`,
|
||||
album_id: trackId === 'history-album-candidate' ? 'history-album' : `album-${trackId}`,
|
||||
genre_id: null, language: null, instrumentalness: null, favorite: false,
|
||||
new_artist: false, energy: null, bpm: null, valence: null, release_date: null,
|
||||
producer_ids: trackId === 'producer-loop-candidate' ? ['producer-loop'] : [],
|
||||
label_ids: [],
|
||||
})) });
|
||||
}
|
||||
return Promise.resolve({ rows: [] });
|
||||
});
|
||||
vi.spyOn(director, 'buildState').mockResolvedValue({
|
||||
energy: 0.5, noveltyHunger: 0.3, sessionAgeMin: 0, lastArtistIds: [], lastGenreIds: [], context: null,
|
||||
});
|
||||
vi.spyOn(director, 'computeFatigue').mockResolvedValue({
|
||||
artist: new Map(), album: new Map(), genre: new Map(), language: new Map(), track: new Map(), vocal: 0.5,
|
||||
});
|
||||
vi.spyOn(director, 'getBudgets').mockResolvedValue([]);
|
||||
vi.spyOn(director, 'buildRepetitionState').mockResolvedValue({ recentTrackIds: new Set(), recentArtistIds: new Set() });
|
||||
vi.spyOn(director, 'rankCandidates').mockImplementation(async candidates => candidates);
|
||||
vi.spyOn(director, 'detectAntiLoop').mockResolvedValue({ dimension: 'producer', values: ['producer-loop'] });
|
||||
vi.spyOn(director as any, 'loadArtistMap').mockResolvedValue(new Map());
|
||||
|
||||
const originalGenerators = [...ALL_GENERATORS];
|
||||
ALL_GENERATORS.splice(0, ALL_GENERATORS.length, async () => [
|
||||
{ ...candidate('producer-loop-candidate'), generatorId: 'comfort' },
|
||||
{ ...candidate('history-album-candidate'), generatorId: 'comfort' },
|
||||
...Array.from({ length: 5 }, (_, index) => ({ ...candidate(`safe-candidate-comfort-${index}`), generatorId: 'comfort' })),
|
||||
...Array.from({ length: 4 }, (_, index) => ({ ...candidate(`safe-candidate-adjacent-${index}`), generatorId: 'adjacent' })),
|
||||
...Array.from({ length: 2 }, (_, index) => ({ ...candidate(`safe-candidate-favorite-${index}`), generatorId: 'deep-dive' })),
|
||||
]);
|
||||
let captured: any;
|
||||
vi.spyOn(director as any, 'constrainedSequence').mockImplementation((params: any) => {
|
||||
captured = params;
|
||||
return selectConstrainedSequence(params);
|
||||
});
|
||||
try {
|
||||
const retained = Array.from({ length: 9 }, (_, index) => ({ ...candidate(`queued-${index}`), generatorId: 'comfort' }));
|
||||
const plan = await director.replan('user-1', 'session-1', retained, []);
|
||||
expect(captured.retainedPlan.map((item: { trackId: string }) => item.trackId)).toEqual(retained.map(item => item.trackId));
|
||||
expect(captured.albumHistory).toHaveLength(40);
|
||||
expect(captured.loopDimension).toBe('producer');
|
||||
expect(captured.metadata.get('producer-loop-candidate').producerIds).toEqual(['producer-loop']);
|
||||
expect(plan.map(item => item.trackId)).not.toContain('history-album-candidate');
|
||||
expect(plan.map(item => item.trackId)).toContain('safe-candidate-comfort-0');
|
||||
} finally {
|
||||
ALL_GENERATORS.splice(0, ALL_GENERATORS.length, ...originalGenerators);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('heard cooldown', () => {
|
||||
it('hands generators every track heard inside the window as a hard exclusion', async () => {
|
||||
const db = makeMockDb();
|
||||
(db.pgClient.query as any).mockImplementation((sql: string) => {
|
||||
if (sql.includes('heard_rows')) return Promise.resolve({ rows: [{ track_id: 'heard-last-week' }] });
|
||||
return Promise.resolve({ rows: [] });
|
||||
});
|
||||
const director = new SessionDirector(db);
|
||||
vi.spyOn(director, 'buildState').mockResolvedValue({
|
||||
energy: 0.5, noveltyHunger: 0.3, sessionAgeMin: 0, lastArtistIds: [], lastGenreIds: [], context: null,
|
||||
});
|
||||
vi.spyOn(director, 'computeFatigue').mockResolvedValue({
|
||||
artist: new Map(), album: new Map(), genre: new Map(), language: new Map(), track: new Map(), vocal: 0.5,
|
||||
});
|
||||
vi.spyOn(director, 'getBudgets').mockResolvedValue([]);
|
||||
vi.spyOn(director, 'buildRepetitionState').mockResolvedValue({ recentTrackIds: new Set(), recentArtistIds: new Set() });
|
||||
vi.spyOn(director as any, 'loadArtistMap').mockResolvedValue(new Map());
|
||||
(db as any).getVibeSessionTrackIds = vi.fn().mockResolvedValue([]);
|
||||
|
||||
const originalGenerators = [...ALL_GENERATORS];
|
||||
let seenExclusions: string[] = [];
|
||||
ALL_GENERATORS.splice(0, ALL_GENERATORS.length, async (_db, ctx) => {
|
||||
seenExclusions = [...ctx.recentExclusions];
|
||||
return [{ ...candidate('heard-last-week'), generatorId: 'comfort' }];
|
||||
});
|
||||
try {
|
||||
const plan = await director.buildPlan('user-1', 'session-1');
|
||||
expect(seenExclusions).toContain('heard-last-week');
|
||||
expect(plan.map(item => item.trackId)).not.toContain('heard-last-week');
|
||||
} finally {
|
||||
ALL_GENERATORS.splice(0, ALL_GENERATORS.length, ...originalGenerators);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildState', () => {
|
||||
|
||||
@@ -0,0 +1,319 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { DbService } from './db.service.js';
|
||||
import {
|
||||
DEFAULT_VIBE_POLICY_VERSION,
|
||||
VibeSessionCoordinator,
|
||||
VibeSessionLifecycleError,
|
||||
VibePlanNotFoundError,
|
||||
} from './vibe-session-coordinator.service.js';
|
||||
|
||||
const SESSION_ID = '11111111-1111-4111-8111-111111111111';
|
||||
const TRACK_ID = '22222222-2222-4222-8222-222222222222';
|
||||
const EVENT_ID = '33333333-3333-4333-8333-333333333333';
|
||||
|
||||
function session(status: 'active' | 'ended' = 'active') {
|
||||
return {
|
||||
id: SESSION_ID, user_id: 'user-1', status, seed_track_id: null,
|
||||
context: {}, policy_version: DEFAULT_VIBE_POLICY_VERSION,
|
||||
started_at: new Date('2026-01-01T00:00:00.000Z'),
|
||||
last_event_at: new Date('2026-01-01T00:00:00.000Z'), ended_at: status === 'ended' ? new Date() : null,
|
||||
} as any;
|
||||
}
|
||||
|
||||
function plan() {
|
||||
return {
|
||||
id: 'plan-1', session_id: SESSION_ID, version: 1, reason: 'session_started',
|
||||
state_snapshot: { energy: 0.5 }, objective_snapshot: {}, created_at: new Date(),
|
||||
items: [{
|
||||
plan_version_id: 'plan-1', ordinal: 0, track_id: TRACK_ID, slot_role: 'next',
|
||||
candidate_source: 'comfort', score: 0.8, score_breakdown: { relevance: 0.8 },
|
||||
explanation: [], committed: false,
|
||||
}],
|
||||
} as any;
|
||||
}
|
||||
|
||||
function setup() {
|
||||
const db = {
|
||||
createVibeSession: vi.fn().mockResolvedValue(session()),
|
||||
createSessionState: vi.fn().mockResolvedValue(SESSION_ID),
|
||||
recordVibeEvent: vi.fn().mockResolvedValue({ event: { id: 'event-1' }, inserted: true }),
|
||||
persistVibePlan: vi.fn().mockResolvedValue(plan()),
|
||||
publishVibePlan: vi.fn().mockImplementation((input: { version?: number; reason: string }) => Promise.resolve({
|
||||
...plan(), version: input.version ?? 2, reason: input.reason,
|
||||
})),
|
||||
getVibeSession: vi.fn().mockResolvedValue(session()),
|
||||
getVibePlan: vi.fn().mockResolvedValue(plan()),
|
||||
endVibeSession: vi.fn().mockResolvedValue(session('ended')),
|
||||
endVibeSessionWithEvent: vi.fn().mockResolvedValue({ session: session('ended'), ended: true }),
|
||||
resumeVibeSession: vi.fn().mockResolvedValue({ session: session(), resumed: true }),
|
||||
serveNextVibePlanItem: vi.fn().mockResolvedValue({ item: plan().items[0], stale: false }),
|
||||
advancePastUnplayableVibePlanItem: vi.fn().mockResolvedValue({ item: plan().items[0], stale: false }),
|
||||
persistNextVibePlan: vi.fn().mockResolvedValue({ ...plan(), version: 2, reason: 'feedback:completed' }),
|
||||
getVibePlanForFeedbackEvent: vi.fn().mockResolvedValue(null),
|
||||
dislikeTrack: vi.fn().mockResolvedValue(undefined),
|
||||
} as unknown as DbService;
|
||||
const director = {
|
||||
buildPlan: vi.fn().mockResolvedValue([{ trackId: TRACK_ID, generatorId: 'comfort', relevance: 0.8, explanation: [] }]),
|
||||
buildState: vi.fn().mockResolvedValue({ energy: 0.5, noveltyHunger: 0.3 }),
|
||||
replan: vi.fn().mockResolvedValue([{ trackId: TRACK_ID, generatorId: 'comfort', relevance: 0.8, explanation: [] }]),
|
||||
} as any;
|
||||
return { db, director, coordinator: new VibeSessionCoordinator(db, director) };
|
||||
}
|
||||
|
||||
describe('VibeSessionCoordinator', () => {
|
||||
it('creates an authoritative session, shadow state, initial plan revision, and ledger events', async () => {
|
||||
const { db, director, coordinator } = setup();
|
||||
|
||||
const response = await coordinator.start('user-1', {});
|
||||
|
||||
expect(response).toMatchObject({ sessionId: SESSION_ID, planVersion: 1, now: { track_id: TRACK_ID } });
|
||||
expect(db.createVibeSession).toHaveBeenCalledWith(expect.objectContaining({
|
||||
userId: 'user-1', policyVersion: DEFAULT_VIBE_POLICY_VERSION,
|
||||
}));
|
||||
expect(db.createSessionState).toHaveBeenCalledWith('user-1', undefined, expect.any(Object), SESSION_ID);
|
||||
expect(director.buildPlan).toHaveBeenCalledWith('user-1', SESSION_ID, undefined);
|
||||
expect(db.publishVibePlan).toHaveBeenCalledWith(expect.objectContaining({
|
||||
sessionId: SESSION_ID, version: 1, reason: 'session_started',
|
||||
items: [expect.objectContaining({ track_id: TRACK_ID, committed: false })],
|
||||
}));
|
||||
expect((db.recordVibeEvent as any).mock.calls.map(([input]: any[]) => input.type))
|
||||
.toEqual(['session_started']);
|
||||
});
|
||||
|
||||
it('persists a director-selected arc role and explainable sequence score', async () => {
|
||||
const { db, director, coordinator } = setup();
|
||||
(director.buildPlan as any).mockResolvedValueOnce([{
|
||||
trackId: TRACK_ID, generatorId: 'discovery', relevance: 0.7, explanation: [{ predicate: 'near' }],
|
||||
plan: {
|
||||
slotRole: 'surprise', score: 0.82,
|
||||
scoreBreakdown: { relevance: 0.7, transition: 0.9, arcTarget: 0.85 },
|
||||
explanation: { arcRole: 'surprise', surprise: { recoveryRole: 'favorite' } },
|
||||
},
|
||||
}]);
|
||||
|
||||
await coordinator.start('user-1', {});
|
||||
|
||||
expect(db.publishVibePlan).toHaveBeenCalledWith(expect.objectContaining({
|
||||
items: [expect.objectContaining({
|
||||
slot_role: 'surprise', score: 0.82,
|
||||
score_breakdown: expect.objectContaining({ transition: 0.9 }),
|
||||
explanation: expect.objectContaining({
|
||||
paths: [{ predicate: 'near' }],
|
||||
planner: expect.objectContaining({ arcRole: 'surprise' }),
|
||||
}),
|
||||
})],
|
||||
}));
|
||||
});
|
||||
|
||||
it('returns the canonical replacement on an idempotent material-event retry without replanning', async () => {
|
||||
const { db, coordinator } = setup();
|
||||
(db.recordVibeEvent as any).mockResolvedValueOnce({
|
||||
event: { id: 'event-1', client_event_id: EVENT_ID, type: 'skipped' }, inserted: false,
|
||||
});
|
||||
(db.getVibePlanForFeedbackEvent as any).mockResolvedValueOnce({ ...plan(), version: 2 });
|
||||
|
||||
const response = await coordinator.appendEvent('user-1', SESSION_ID, {
|
||||
eventId: EVENT_ID, type: 'skipped', trackId: TRACK_ID,
|
||||
});
|
||||
|
||||
expect(response).toMatchObject({ idempotent: true, planVersion: 2, replanned: false, replanReason: null });
|
||||
expect(db.recordVibeEvent).toHaveBeenCalledWith(expect.objectContaining({ clientEventId: EVENT_ID }));
|
||||
expect(db.persistNextVibePlan).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('records a durable dislike so the track is gone from later sessions too', async () => {
|
||||
const { db, coordinator } = setup();
|
||||
|
||||
await coordinator.appendEvent('user-1', SESSION_ID, {
|
||||
eventId: EVENT_ID, type: 'disliked', trackId: TRACK_ID,
|
||||
});
|
||||
|
||||
expect(db.dislikeTrack).toHaveBeenCalledWith('user-1', TRACK_ID);
|
||||
});
|
||||
|
||||
it('does not dislike a track twice when its event is delivered again', async () => {
|
||||
const { db, coordinator } = setup();
|
||||
(db.recordVibeEvent as any).mockResolvedValueOnce({
|
||||
event: { id: 'event-1', client_event_id: EVENT_ID, type: 'disliked' }, inserted: false,
|
||||
});
|
||||
|
||||
await coordinator.appendEvent('user-1', SESSION_ID, {
|
||||
eventId: EVENT_ID, type: 'disliked', trackId: TRACK_ID,
|
||||
});
|
||||
|
||||
expect(db.dislikeTrack).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('leaves the library alone for feedback that is not a dislike', async () => {
|
||||
const { db, coordinator } = setup();
|
||||
|
||||
await coordinator.appendEvent('user-1', SESSION_ID, { type: 'skipped', trackId: TRACK_ID });
|
||||
|
||||
expect(db.dislikeTrack).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('recovers a material feedback replan when its first persistence attempt failed', async () => {
|
||||
const { db, coordinator } = setup();
|
||||
(db.publishVibePlan as any).mockRejectedValueOnce(new Error('temporary database failure'));
|
||||
await expect(coordinator.appendEvent('user-1', SESSION_ID, {
|
||||
eventId: EVENT_ID, type: 'completed', trackId: TRACK_ID,
|
||||
})).rejects.toThrow('temporary database failure');
|
||||
|
||||
(db.recordVibeEvent as any).mockResolvedValueOnce({
|
||||
event: { id: 'event-1', client_event_id: EVENT_ID, type: 'completed' }, inserted: false,
|
||||
});
|
||||
const recovered = await coordinator.appendEvent('user-1', SESSION_ID, {
|
||||
eventId: EVENT_ID, type: 'completed', trackId: TRACK_ID,
|
||||
});
|
||||
|
||||
expect(db.publishVibePlan).toHaveBeenCalledTimes(2);
|
||||
expect(recovered).toMatchObject({ idempotent: true, replanned: true, planVersion: 2 });
|
||||
});
|
||||
|
||||
it('persists a replacement revision for material feedback and returns its preview', async () => {
|
||||
const { db, director, coordinator } = setup();
|
||||
const response = await coordinator.appendEvent('user-1', SESSION_ID, { type: 'completed', trackId: TRACK_ID });
|
||||
|
||||
expect(director.replan).toHaveBeenCalledWith(
|
||||
'user-1', SESSION_ID, expect.any(Array), [TRACK_ID], TRACK_ID,
|
||||
{ excludedTrackIds: new Set() },
|
||||
);
|
||||
expect(db.publishVibePlan).toHaveBeenCalledWith(expect.objectContaining({
|
||||
sessionId: SESSION_ID, reason: 'feedback:completed', items: [expect.objectContaining({ committed: false })],
|
||||
}));
|
||||
expect(response).toMatchObject({ replanned: true, replanReason: 'feedback:completed', planVersion: 2 });
|
||||
});
|
||||
|
||||
it('creates a neutral durable profile until listening behaviour provides evidence', async () => {
|
||||
const { db, coordinator } = setup();
|
||||
|
||||
await coordinator.start('user-1', {});
|
||||
|
||||
expect(db.createVibeSession).toHaveBeenCalledWith(expect.objectContaining({
|
||||
profile: expect.objectContaining({
|
||||
goals: { type: 'discovery', target: 1, progress: 0 },
|
||||
explorationCoefficient: 0.3,
|
||||
discoveryRadius: 0.38,
|
||||
}),
|
||||
}));
|
||||
});
|
||||
|
||||
it('passes the durable unserved callback tail into the retention-aware replan', async () => {
|
||||
const { db, director, coordinator } = setup();
|
||||
(db.getVibePlan as any).mockResolvedValue({
|
||||
...plan(),
|
||||
items: [{
|
||||
...plan().items[0],
|
||||
slot_role: 'favorite',
|
||||
explanation: {
|
||||
paths: [],
|
||||
planner: { arcRole: 'favorite', callback: { id: 'theme:0', phase: 'return' } },
|
||||
},
|
||||
}],
|
||||
});
|
||||
|
||||
await coordinator.appendEvent('user-1', SESSION_ID, { type: 'completed', trackId: 'other-track' });
|
||||
|
||||
expect(director.replan).toHaveBeenCalledWith(
|
||||
'user-1', SESSION_ID,
|
||||
[expect.objectContaining({
|
||||
trackId: TRACK_ID,
|
||||
plan: expect.objectContaining({ slotRole: 'favorite', explanation: expect.objectContaining({ arcRole: 'favorite' }) }),
|
||||
})],
|
||||
['other-track'], 'other-track', { excludedTrackIds: new Set() },
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps the durable seed excluded when feedback supplies a different local replan anchor', async () => {
|
||||
const { db, director, coordinator } = setup();
|
||||
const seedTrackId = '44444444-4444-4444-8444-444444444444';
|
||||
(db.getVibeSession as any).mockResolvedValue({ ...session(), seed_track_id: seedTrackId });
|
||||
|
||||
await coordinator.appendEvent('user-1', SESSION_ID, { type: 'completed', trackId: TRACK_ID });
|
||||
|
||||
expect(director.replan).toHaveBeenCalledWith(
|
||||
'user-1',
|
||||
SESSION_ID,
|
||||
expect.any(Array),
|
||||
[TRACK_ID],
|
||||
TRACK_ID,
|
||||
{ excludedTrackIds: new Set([seedTrackId]) },
|
||||
);
|
||||
});
|
||||
|
||||
it('resumes only the caller-owned session and serves a plan item through the durable API', async () => {
|
||||
const { db, coordinator } = setup();
|
||||
const resumed = await coordinator.start('user-1', { resumeSessionId: SESSION_ID });
|
||||
const served = await coordinator.serveNext('user-1', SESSION_ID);
|
||||
|
||||
expect(db.resumeVibeSession).toHaveBeenCalledWith(SESSION_ID, 'user-1');
|
||||
expect(resumed.sessionId).toBe(SESSION_ID);
|
||||
expect(db.serveNextVibePlanItem).toHaveBeenCalledWith(SESSION_ID, 'user-1');
|
||||
expect(served.now).toMatchObject({ track_id: TRACK_ID });
|
||||
});
|
||||
|
||||
it('returns a lifecycle conflict when the ledger rejects a new terminal-session event', async () => {
|
||||
const { db, coordinator } = setup();
|
||||
(db.recordVibeEvent as any).mockRejectedValueOnce(new Error('Cannot record a new event for ended Vibe session'));
|
||||
|
||||
await expect(coordinator.appendEvent('user-1', SESSION_ID, { type: 'completed' }))
|
||||
.rejects.toBeInstanceOf(VibeSessionLifecycleError);
|
||||
});
|
||||
|
||||
it('distinguishes a missing requested revision from an empty latest plan', async () => {
|
||||
const { db, coordinator } = setup();
|
||||
(db.getVibePlan as any).mockResolvedValueOnce(null);
|
||||
await expect(coordinator.getPlan('user-1', SESSION_ID, 99)).rejects.toBeInstanceOf(VibePlanNotFoundError);
|
||||
});
|
||||
|
||||
it('returns 404-worthy failure when a session has no latest plan', async () => {
|
||||
const { db, coordinator } = setup();
|
||||
(db.getVibePlan as any).mockResolvedValueOnce(null);
|
||||
await expect(coordinator.getPlan('user-1', SESSION_ID)).rejects.toBeInstanceOf(VibePlanNotFoundError);
|
||||
});
|
||||
|
||||
it('does not commit a stale version-aware next request and returns the current preview', async () => {
|
||||
const { db, coordinator } = setup();
|
||||
(db.serveNextVibePlanItem as any).mockResolvedValueOnce({ item: null, stale: true });
|
||||
const result = await coordinator.serveNext('user-1', SESSION_ID, 1);
|
||||
|
||||
expect(db.serveNextVibePlanItem).toHaveBeenCalledWith(SESSION_ID, 'user-1', 1);
|
||||
expect(result).toMatchObject({ planVersion: 1, now: { track_id: TRACK_ID } });
|
||||
});
|
||||
|
||||
it('advances past an unplayable served item using a distinct idempotent event protocol', async () => {
|
||||
const { db, coordinator } = setup();
|
||||
const planVersionId = '44444444-4444-4444-8444-444444444444';
|
||||
const eventId = '55555555-5555-4555-8555-555555555555';
|
||||
|
||||
const result = await coordinator.advancePastUnplayable('user-1', SESSION_ID, {
|
||||
expectedPlanVersion: 1,
|
||||
planVersionId,
|
||||
ordinal: 0,
|
||||
trackId: TRACK_ID,
|
||||
eventId,
|
||||
});
|
||||
|
||||
expect(db.advancePastUnplayableVibePlanItem).toHaveBeenCalledWith(SESSION_ID, 'user-1', {
|
||||
expectedPlanVersion: 1, planVersionId, ordinal: 0, trackId: TRACK_ID, eventId,
|
||||
});
|
||||
expect(result.now).toMatchObject({ track_id: TRACK_ID });
|
||||
});
|
||||
|
||||
it('ends an active session once and preserves its latest persisted plan', async () => {
|
||||
const { db, coordinator } = setup();
|
||||
const response = await coordinator.end('user-1', SESSION_ID);
|
||||
|
||||
expect(db.endVibeSessionWithEvent).toHaveBeenCalledWith(SESSION_ID, 'user-1');
|
||||
expect(response.session.status).toBe('ended');
|
||||
expect(response.planVersion).toBe(1);
|
||||
});
|
||||
|
||||
it('does not publish an initial plan when another start replaced the session while planning', async () => {
|
||||
const { db, coordinator } = setup();
|
||||
(db.publishVibePlan as any).mockRejectedValueOnce(new Error('Cannot publish a plan for replaced Vibe session'));
|
||||
|
||||
await expect(coordinator.start('user-1', {})).rejects.toBeInstanceOf(VibeSessionLifecycleError);
|
||||
expect(db.publishVibePlan).toHaveBeenCalledWith(expect.objectContaining({ version: 1 }));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,418 @@
|
||||
import { DbService, VibeEvent, VibePlan, VibeSession } from './db.service.js';
|
||||
import { SessionDirector } from './session-director.service.js';
|
||||
import { Candidate, VibeCalendarContext } from './generators.service.js';
|
||||
|
||||
/**
|
||||
* This is deliberately a narrow bridge between the durable Vibe ledger and
|
||||
* the current deterministic director. It writes authoritative revisions and
|
||||
* lets the playback client replace only its unserved preview after feedback.
|
||||
*/
|
||||
export const DEFAULT_VIBE_POLICY_VERSION = 'vibe-v2-initial';
|
||||
|
||||
export const VIBE_EVENT_TYPES = [
|
||||
'session_started', 'session_resumed', 'session_ended',
|
||||
'plan_published', 'track_served', 'playback_started', 'progress', 'completed',
|
||||
'skipped', 'disliked', 'kept', 'favourite_added', 'queue_removed',
|
||||
'manual_search', 'album_opened', 'artist_opened', 'playlist_added',
|
||||
'track_replayed', 'volume_changed', 'playback_error',
|
||||
] as const;
|
||||
|
||||
export type VibeEventType = (typeof VIBE_EVENT_TYPES)[number];
|
||||
|
||||
export interface StartVibeSessionInput {
|
||||
seedTrackId?: string;
|
||||
resumeSessionId?: string;
|
||||
context?: VibeCalendarContext;
|
||||
}
|
||||
|
||||
export interface AppendVibeEventInput {
|
||||
eventId?: string;
|
||||
type: VibeEventType;
|
||||
trackId?: string;
|
||||
occurredAt?: Date;
|
||||
positionMs?: number;
|
||||
durationMs?: number;
|
||||
payload?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* An explicit advancement protocol for a plan item that was durably served
|
||||
* but cannot be played locally. `eventId` is the idempotency key for this
|
||||
* state transition; it is intentionally separate from a retry of /next.
|
||||
*/
|
||||
export interface AdvanceUnplayableVibeItemInput {
|
||||
expectedPlanVersion: number;
|
||||
planVersionId: string;
|
||||
ordinal: number;
|
||||
trackId: string;
|
||||
eventId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* How much of the plan a client is shown and holds ready. The plan itself is
|
||||
* PLAN_SIZE long and stays that way; this is only the served window. Every
|
||||
* preview item costs the client one track fetch on every advance, and a replan
|
||||
* discards whatever is still unplayed, so a long window buys little beyond a
|
||||
* longer Up next list.
|
||||
*/
|
||||
const PREVIEW_SIZE = 3;
|
||||
|
||||
export class VibeSessionNotFoundError extends Error {}
|
||||
export class VibeSessionLifecycleError extends Error {}
|
||||
export class VibePlanNotFoundError extends Error {}
|
||||
|
||||
export interface VibeSessionResponse {
|
||||
session: VibeSession;
|
||||
sessionId: string;
|
||||
planVersion: number | null;
|
||||
now: VibePlan['items'][number] | null;
|
||||
preview: VibePlan['items'];
|
||||
state: Record<string, unknown>;
|
||||
replanned: boolean;
|
||||
replanReason: string | null;
|
||||
}
|
||||
|
||||
export class VibeSessionCoordinator {
|
||||
constructor(
|
||||
private readonly db: DbService,
|
||||
private readonly director: Pick<SessionDirector, 'buildPlan' | 'buildState' | 'replan'>,
|
||||
) {}
|
||||
|
||||
async start(userId: string, input: StartVibeSessionInput): Promise<VibeSessionResponse> {
|
||||
if (input.resumeSessionId) return this.resume(userId, input.resumeSessionId);
|
||||
// Calendar context is a weak boot prior, never a substitute for listening
|
||||
// feedback or long-term preference. It is intentionally coarse and is
|
||||
// persisted with the session so a resume remains coherent.
|
||||
const calendar = input.context;
|
||||
const contextualEnergy = calendar && calendar.localHour < 6 ? 0.32
|
||||
: calendar && calendar.localHour >= 18 && calendar.localHour < 23 ? 0.58
|
||||
: 0.5;
|
||||
const initialState = {
|
||||
energy: contextualEnergy,
|
||||
noveltyHunger: 0.3,
|
||||
explorationCoefficient: 0.3,
|
||||
discoveryRadius: 0.38,
|
||||
sessionGoal: { type: 'discovery' as const, target: 1, progress: 0 },
|
||||
};
|
||||
const session = await this.db.createVibeSession({
|
||||
userId,
|
||||
policyVersion: DEFAULT_VIBE_POLICY_VERSION,
|
||||
seedTrackId: input.seedTrackId ?? null,
|
||||
context: calendar ? { ...calendar } : undefined,
|
||||
profile: {
|
||||
goals: initialState.sessionGoal,
|
||||
explorationCoefficient: initialState.explorationCoefficient,
|
||||
discoveryRadius: initialState.discoveryRadius,
|
||||
},
|
||||
});
|
||||
|
||||
// session_state is a derived cache used by the current director. Give it
|
||||
// the durable ID so director state cannot accidentally bleed into another
|
||||
// session while the durable tables remain the source of truth.
|
||||
await this.db.createSessionState(
|
||||
userId,
|
||||
calendar ? JSON.stringify(calendar) : undefined,
|
||||
{
|
||||
energy: initialState.energy,
|
||||
noveltyHunger: initialState.noveltyHunger,
|
||||
explorationCoefficient: initialState.explorationCoefficient,
|
||||
discoveryRadius: initialState.discoveryRadius,
|
||||
sessionGoal: initialState.sessionGoal,
|
||||
},
|
||||
session.id,
|
||||
);
|
||||
await this.db.recordVibeEvent({
|
||||
sessionId: session.id,
|
||||
userId,
|
||||
type: 'session_started',
|
||||
payload: { policyVersion: DEFAULT_VIBE_POLICY_VERSION, ...(calendar ? { calendar } : {}) },
|
||||
});
|
||||
|
||||
const candidates = await this.director.buildPlan(userId, session.id, input.seedTrackId);
|
||||
const state = await this.director.buildState(userId, session.id);
|
||||
let plan: VibePlan;
|
||||
try {
|
||||
plan = await this.db.publishVibePlan({
|
||||
sessionId: session.id,
|
||||
userId,
|
||||
version: 1,
|
||||
reason: 'session_started',
|
||||
stateSnapshot: state,
|
||||
objectiveSnapshot: {
|
||||
policyVersion: DEFAULT_VIBE_POLICY_VERSION,
|
||||
horizonTracks: candidates.length,
|
||||
...(candidates[0]?.plan?.objective ?? {}),
|
||||
},
|
||||
items: candidates.map((candidate, ordinal) => ({
|
||||
ordinal,
|
||||
track_id: candidate.trackId,
|
||||
slot_role: candidate.plan?.slotRole ?? (ordinal === 0 ? 'next' : null),
|
||||
candidate_source: candidate.generatorId,
|
||||
score: candidate.plan?.score ?? candidate.relevance,
|
||||
score_breakdown: candidate.plan?.scoreBreakdown ?? { relevance: candidate.relevance },
|
||||
explanation: candidate.plan
|
||||
? { paths: candidate.explanation, planner: candidate.plan.explanation }
|
||||
: candidate.explanation,
|
||||
committed: false,
|
||||
})),
|
||||
});
|
||||
} catch (error) {
|
||||
throw this.mapLifecycleError(error);
|
||||
}
|
||||
|
||||
return this.toResponse(session, plan, state);
|
||||
}
|
||||
|
||||
async getPlan(userId: string, sessionId: string, version?: number): Promise<VibeSessionResponse> {
|
||||
const session = await this.requireSession(userId, sessionId);
|
||||
const plan = await this.db.getVibePlan(sessionId, userId, version);
|
||||
if (!plan) throw new VibePlanNotFoundError(
|
||||
version === undefined ? 'Vibe session does not have a published plan' : 'Vibe plan revision was not found',
|
||||
);
|
||||
return this.toResponse(session, plan, plan?.state_snapshot ?? {});
|
||||
}
|
||||
|
||||
async serveNext(userId: string, sessionId: string, expectedPlanVersion?: number): Promise<VibeSessionResponse> {
|
||||
try {
|
||||
const served = expectedPlanVersion === undefined
|
||||
? await this.db.serveNextVibePlanItem(sessionId, userId)
|
||||
: await this.db.serveNextVibePlanItem(sessionId, userId, expectedPlanVersion);
|
||||
const response = await this.getPlan(userId, sessionId);
|
||||
// A plan may be replaced between the client's preview and this request.
|
||||
// In that case the database does not commit anything and this is the
|
||||
// current, revisable preview the client must reconcile to.
|
||||
if (served.stale) return response;
|
||||
return { ...response, now: served.item, preview: response.preview };
|
||||
} catch (error) {
|
||||
throw this.mapLifecycleError(error);
|
||||
}
|
||||
}
|
||||
|
||||
async advancePastUnplayable(
|
||||
userId: string,
|
||||
sessionId: string,
|
||||
input: AdvanceUnplayableVibeItemInput,
|
||||
): Promise<VibeSessionResponse> {
|
||||
try {
|
||||
const served = await this.db.advancePastUnplayableVibePlanItem(sessionId, userId, input);
|
||||
const response = await this.getPlan(userId, sessionId);
|
||||
if (served.stale) return response;
|
||||
return { ...response, now: served.item, preview: response.preview };
|
||||
} catch (error) {
|
||||
throw this.mapLifecycleError(error);
|
||||
}
|
||||
}
|
||||
|
||||
async appendEvent(
|
||||
userId: string,
|
||||
sessionId: string,
|
||||
input: AppendVibeEventInput,
|
||||
): Promise<VibeSessionResponse & { event: VibeEvent; idempotent: boolean }> {
|
||||
try {
|
||||
// Do this before the ledger write, rather than after it, because Vibe
|
||||
// events are immutable. The DB repeats this boundary for non-HTTP
|
||||
// callers; keeping it here also makes coordinator callers see exactly
|
||||
// what will be persisted.
|
||||
const result = await this.db.recordVibeEvent({
|
||||
sessionId,
|
||||
userId,
|
||||
clientEventId: input.eventId,
|
||||
type: input.type,
|
||||
trackId: input.trackId,
|
||||
occurredAt: input.occurredAt,
|
||||
positionMs: input.positionMs,
|
||||
durationMs: input.durationMs,
|
||||
payload: input.payload,
|
||||
});
|
||||
// The ledger write is authoritative; this idempotent projection updates
|
||||
// exploration only after the exact event exists. Keep the compatibility
|
||||
// guard for old coordinator test doubles during the migration.
|
||||
const projectSessionFeedback = (this.db as Partial<DbService>).projectVibeSessionFeedback;
|
||||
if (projectSessionFeedback) await projectSessionFeedback.call(this.db, result.event);
|
||||
// A dislike in a Vibe is the same verdict as a dislike anywhere else. The
|
||||
// ledger alone only excludes the track from this one session, which is
|
||||
// why a disliked track kept coming back the next evening. Run it once per
|
||||
// distinct event so a retried delivery cannot log a second feedback row.
|
||||
if (input.type === 'disliked' && input.trackId && result.inserted) {
|
||||
const dislikeTrack = (this.db as Partial<DbService>).dislikeTrack;
|
||||
if (dislikeTrack) await dislikeTrack.call(this.db, userId, input.trackId);
|
||||
}
|
||||
if (!isMaterialFeedback(input.type)) {
|
||||
const response = await this.getPlan(userId, sessionId);
|
||||
return { ...response, event: result.event, idempotent: !result.inserted };
|
||||
}
|
||||
|
||||
// A material event is durable before its computed replacement can be
|
||||
// written. If planning/persistence failed after that event committed, a
|
||||
// retry must finish the missing replacement instead of permanently
|
||||
// returning an obsolete preview. Once a replacement exists, a duplicate
|
||||
// retry returns that canonical revision without doing work again.
|
||||
if (!result.inserted) {
|
||||
const existingReplacement = await this.db.getVibePlanForFeedbackEvent(sessionId, userId, result.event.id);
|
||||
if (existingReplacement) {
|
||||
const session = await this.requireSession(userId, sessionId);
|
||||
return {
|
||||
...this.toResponse(session, existingReplacement, existingReplacement.state_snapshot),
|
||||
event: result.event,
|
||||
idempotent: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const session = await this.requireSession(userId, sessionId);
|
||||
const state = await this.director.buildState(userId, sessionId);
|
||||
// A feedback target is useful as the local replan anchor, but it must
|
||||
// never displace the durable seed from the exclusion boundary. Unlike
|
||||
// feedback tracks, the seed is not necessarily present in the event
|
||||
// ledger, so carry it explicitly into every replacement request.
|
||||
const seedTrackId = session.seed_track_id ?? undefined;
|
||||
// Revisions retain the durable, unserved queue tail rather than building
|
||||
// an unrelated plan after every signal. Besides reducing churn, this
|
||||
// preserves a valid callback/recovery pair that has already been shown
|
||||
// to the client while allowing the director to refill under the same
|
||||
// hard caps and current feedback state.
|
||||
const current = await this.db.getVibePlan(sessionId, userId);
|
||||
const retained = current ? this.unservedCandidates(current) : [];
|
||||
const excludedTrackIds = new Set<string>([
|
||||
...(seedTrackId ? [seedTrackId] : []),
|
||||
]);
|
||||
const candidates = await this.director.replan(
|
||||
userId,
|
||||
sessionId,
|
||||
retained,
|
||||
input.trackId ? [input.trackId] : [],
|
||||
input.trackId ?? seedTrackId,
|
||||
{ excludedTrackIds },
|
||||
);
|
||||
const reason = `feedback:${input.type}`;
|
||||
const plan = await this.db.publishVibePlan({
|
||||
sessionId,
|
||||
userId,
|
||||
reason,
|
||||
stateSnapshot: state,
|
||||
objectiveSnapshot: {
|
||||
policyVersion: session.policy_version,
|
||||
feedbackEventId: result.event.id,
|
||||
feedbackType: input.type,
|
||||
horizonTracks: candidates.length,
|
||||
...(candidates[0]?.plan?.objective ?? {}),
|
||||
},
|
||||
items: candidates.map((candidate, ordinal) => ({
|
||||
ordinal,
|
||||
track_id: candidate.trackId,
|
||||
slot_role: candidate.plan?.slotRole ?? (ordinal === 0 ? 'next' : null),
|
||||
candidate_source: candidate.generatorId,
|
||||
score: candidate.plan?.score ?? candidate.relevance,
|
||||
score_breakdown: candidate.plan?.scoreBreakdown ?? { relevance: candidate.relevance },
|
||||
explanation: candidate.plan
|
||||
? { paths: candidate.explanation, planner: candidate.plan.explanation }
|
||||
: candidate.explanation,
|
||||
committed: false,
|
||||
})),
|
||||
});
|
||||
return {
|
||||
...this.toResponse(session, plan, state),
|
||||
event: result.event,
|
||||
idempotent: !result.inserted,
|
||||
replanned: true,
|
||||
replanReason: reason,
|
||||
};
|
||||
} catch (error) {
|
||||
throw this.mapLifecycleError(error);
|
||||
}
|
||||
}
|
||||
|
||||
async end(userId: string, sessionId: string): Promise<VibeSessionResponse> {
|
||||
let ended: VibeSession;
|
||||
try {
|
||||
ended = (await this.db.endVibeSessionWithEvent(sessionId, userId)).session;
|
||||
} catch (error) {
|
||||
throw this.mapLifecycleError(error);
|
||||
}
|
||||
const plan = await this.db.getVibePlan(sessionId, userId);
|
||||
return this.toResponse(ended, plan, plan?.state_snapshot ?? {});
|
||||
}
|
||||
|
||||
private async resume(userId: string, sessionId: string): Promise<VibeSessionResponse> {
|
||||
try {
|
||||
const resumed = await this.db.resumeVibeSession(sessionId, userId);
|
||||
const plan = await this.db.getVibePlan(sessionId, userId);
|
||||
return this.toResponse(resumed.session, plan, plan?.state_snapshot ?? {});
|
||||
} catch (error) {
|
||||
throw this.mapLifecycleError(error);
|
||||
}
|
||||
}
|
||||
|
||||
private async requireSession(userId: string, sessionId: string): Promise<VibeSession> {
|
||||
const session = await this.db.getVibeSession(sessionId, userId);
|
||||
if (!session) throw new VibeSessionNotFoundError('Vibe session was not found');
|
||||
return session;
|
||||
}
|
||||
|
||||
private toResponse(
|
||||
session: VibeSession,
|
||||
plan: VibePlan | null,
|
||||
state: Record<string, unknown>,
|
||||
): VibeSessionResponse {
|
||||
// A revision is immutable, but clients need a live future: already served
|
||||
// rows stay in the ledger and are excluded from the replacement preview.
|
||||
const preview = plan?.items.filter((item) => !item.committed).slice(0, PREVIEW_SIZE) ?? [];
|
||||
return {
|
||||
session,
|
||||
sessionId: session.id,
|
||||
planVersion: plan?.version ?? null,
|
||||
now: preview[0] ?? null,
|
||||
preview,
|
||||
state,
|
||||
replanned: false,
|
||||
replanReason: null,
|
||||
};
|
||||
}
|
||||
|
||||
/** Reconstruct the planner envelope from the durable revision. Older
|
||||
* revisions stored only paths, so they remain valid retention inputs. */
|
||||
private unservedCandidates(plan: VibePlan): Candidate[] {
|
||||
return plan.items
|
||||
.filter(item => !item.committed)
|
||||
.map(item => {
|
||||
const stored = item.explanation;
|
||||
const hasPlanner = !!stored && !Array.isArray(stored) && typeof stored === 'object'
|
||||
&& 'planner' in stored;
|
||||
const object = hasPlanner ? stored as { paths?: Candidate['explanation']; planner?: Record<string, unknown> } : undefined;
|
||||
const planner = object?.planner;
|
||||
return {
|
||||
trackId: item.track_id,
|
||||
generatorId: item.candidate_source,
|
||||
relevance: item.score,
|
||||
explanation: object?.paths ?? (Array.isArray(stored) ? stored : []),
|
||||
plan: planner ? {
|
||||
slotRole: item.slot_role ?? 'retained',
|
||||
score: item.score,
|
||||
scoreBreakdown: item.score_breakdown,
|
||||
explanation: planner,
|
||||
objective: {
|
||||
policy: planner.policy,
|
||||
constraints: planner.constraints,
|
||||
relaxations: planner.relaxations,
|
||||
},
|
||||
} : undefined,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
private mapLifecycleError(error: unknown): Error {
|
||||
if (error instanceof Error && (error.message.includes('Cannot record a new event for') || error.message.includes('Cannot resume ') || error.message.includes('Cannot publish a plan for'))) {
|
||||
return new VibeSessionLifecycleError(error.message);
|
||||
}
|
||||
if (error instanceof Error && error.message.includes('not found or is not owned')) {
|
||||
return new VibeSessionNotFoundError('Vibe session was not found');
|
||||
}
|
||||
return error instanceof Error ? error : new Error(String(error));
|
||||
}
|
||||
}
|
||||
|
||||
const MATERIAL_FEEDBACK_EVENTS = new Set<VibeEventType>(['skipped', 'disliked', 'completed', 'kept']);
|
||||
|
||||
function isMaterialFeedback(type: VibeEventType): boolean {
|
||||
return MATERIAL_FEEDBACK_EVENTS.has(type);
|
||||
}
|
||||
@@ -5,7 +5,6 @@ export interface MetadataRefreshJob {
|
||||
|
||||
export interface AudioAnalysisJob {
|
||||
trackId: string;
|
||||
features: string[];
|
||||
}
|
||||
|
||||
export interface CleanupJob {
|
||||
@@ -24,4 +23,12 @@ export interface ReprocessArtistsJob {
|
||||
offset?: number;
|
||||
}
|
||||
|
||||
export type JobPayload = MetadataRefreshJob | AudioAnalysisJob | CleanupJob | LibraryScanJob | ReindexTracksJob | ReprocessArtistsJob;
|
||||
/**
|
||||
* Explicit, opt-in System E hand-off. The worker looks the candidate up again
|
||||
* from Postgres; no URL or shell arguments travel through Redis.
|
||||
*/
|
||||
export interface AcquisitionJob {
|
||||
candidateId: string;
|
||||
}
|
||||
|
||||
export type JobPayload = MetadataRefreshJob | AudioAnalysisJob | CleanupJob | LibraryScanJob | ReindexTracksJob | ReprocessArtistsJob | AcquisitionJob;
|
||||
|
||||
+19
-1
@@ -57,7 +57,12 @@ services:
|
||||
- backend
|
||||
|
||||
worker:
|
||||
build: ./workers
|
||||
build:
|
||||
context: ./workers
|
||||
# Enabled deliberately: the auto-seed discovery loop cannot acquire a
|
||||
# candidate without the downloader present.
|
||||
args:
|
||||
INSTALL_YTDLP: "true"
|
||||
restart: unless-stopped
|
||||
network_mode: host
|
||||
environment:
|
||||
@@ -69,6 +74,19 @@ services:
|
||||
DISCOGS_TOKEN: ${DISCOGS_TOKEN}
|
||||
SOCKS_PROXY_URL: ${SOCKS_PROXY_URL}
|
||||
MUSIC_DIR: /music
|
||||
# System E acquisition, enabled. The allow-list is the only thing deciding
|
||||
# where audio may come from, and it binds search-resolved URLs too.
|
||||
# `www.youtube.com` is what yt-dlp reports as webpage_url for a ytsearch
|
||||
# hit. Narrow this list to shrink the blast radius; empty it to stop
|
||||
# acquisition without a rebuild.
|
||||
MUZICK_ACQUISITION_ENABLED: "true"
|
||||
MUZICK_ACQUISITION_YTDLP_PATH: /usr/bin/yt-dlp
|
||||
MUZICK_ACQUISITION_ALLOWED_HOSTS: www.youtube.com
|
||||
MUZICK_ACQUISITION_DIR: .recommendations
|
||||
#
|
||||
# Candidate generation crons (defaults shown, both daily).
|
||||
# NEW_RELEASE_CRON: "30 5 * * *"
|
||||
# RECOMMENDATION_CRON: "30 6 * * *"
|
||||
# Hard-deletion gates for the dislike lifecycle (invariant §C). All three
|
||||
# default to the safe value inside cleanup.service.ts; they are listed here
|
||||
# as documentation and are intentionally left unset.
|
||||
|
||||
+7
-1
@@ -2,7 +2,13 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<!-- viewport-fit=cover lets the app paint under the Android gesture bar; the
|
||||
transport pads itself back out with env(safe-area-inset-bottom). -->
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||
<meta name="theme-color" content="#14110D" />
|
||||
<meta name="mobile-web-app-capable" content="yes" />
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32.png" />
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
||||
<title>Muzick</title>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -1,12 +1,60 @@
|
||||
server {
|
||||
listen 80;
|
||||
|
||||
# The nginx image ships with gzip off, so the bundle went out raw: 555KB of
|
||||
# JS where gzip sends 167KB. Only text formats are listed — images, fonts
|
||||
# (woff2) and audio are already compressed and would only burn CPU.
|
||||
gzip on;
|
||||
gzip_vary on;
|
||||
gzip_min_length 1024;
|
||||
# The public URL sits behind another proxy, which adds a Via header. With
|
||||
# the default gzip_proxied off, nginx refuses to compress those responses.
|
||||
gzip_proxied any;
|
||||
# That same proxy speaks HTTP/1.0 upstream (nginx proxy_pass defaults to it),
|
||||
# and gzip_http_version defaults to 1.1 — so without this nothing here is
|
||||
# ever compressed, however the client asks.
|
||||
gzip_http_version 1.0;
|
||||
gzip_comp_level 6;
|
||||
gzip_types
|
||||
application/javascript
|
||||
application/json
|
||||
application/manifest+json
|
||||
image/svg+xml
|
||||
text/css
|
||||
text/plain;
|
||||
|
||||
location / {
|
||||
root /usr/share/nginx/html;
|
||||
index index.html index.htm;
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# A cached service worker or shell would pin the app to an old build, since
|
||||
# both are the files that point at every hashed asset. Assets themselves are
|
||||
# content-hashed by Vite, so they can be held forever.
|
||||
# nginx's stock mime.types has no .webmanifest entry, so it would otherwise
|
||||
# go out as application/octet-stream.
|
||||
location = /manifest.webmanifest {
|
||||
root /usr/share/nginx/html;
|
||||
default_type application/manifest+json;
|
||||
add_header Cache-Control "no-cache, must-revalidate";
|
||||
}
|
||||
|
||||
location = /sw.js {
|
||||
root /usr/share/nginx/html;
|
||||
add_header Cache-Control "no-cache, must-revalidate";
|
||||
}
|
||||
|
||||
location = /index.html {
|
||||
root /usr/share/nginx/html;
|
||||
add_header Cache-Control "no-cache, must-revalidate";
|
||||
}
|
||||
|
||||
location /assets/ {
|
||||
root /usr/share/nginx/html;
|
||||
add_header Cache-Control "public, max-age=31536000, immutable";
|
||||
}
|
||||
|
||||
# Admin endpoints need the admin key, not the regular API key. This prefix
|
||||
# location is longer than "/api", and nginx picks the longest matching
|
||||
# prefix location, so it wins for /api/admin/* while /api handles the rest.
|
||||
@@ -20,6 +68,20 @@ server {
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
}
|
||||
|
||||
# The cross-device event stream is long-lived and must arrive unbuffered:
|
||||
# with proxy buffering on, nginx holds each event until it has a chunk worth
|
||||
# forwarding, which is exactly the latency this channel exists to avoid.
|
||||
location /api/playback/stream {
|
||||
proxy_pass http://backend:3000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header Authorization "Bearer ${MUZICK_API_KEY}";
|
||||
proxy_set_header Connection '';
|
||||
proxy_buffering off;
|
||||
proxy_cache off;
|
||||
proxy_read_timeout 1h;
|
||||
}
|
||||
|
||||
location /api {
|
||||
proxy_pass http://backend:3000;
|
||||
proxy_http_version 1.1;
|
||||
|
||||
Generated
+5378
-42
File diff suppressed because it is too large
Load Diff
@@ -6,6 +6,8 @@
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
@@ -21,13 +23,19 @@
|
||||
"zustand": "^5.0.14"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@testing-library/jest-dom": "^6.6.3",
|
||||
"@testing-library/react": "^16.1.0",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/react": "^18.3.31",
|
||||
"@types/react-dom": "^18.3.7",
|
||||
"@vitejs/plugin-react": "^4.2.0",
|
||||
"autoprefixer": "^10.5.0",
|
||||
"jsdom": "^25.0.1",
|
||||
"postcss": "^8.5.15",
|
||||
"tailwindcss": "^3.4.19",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^5.2.0"
|
||||
"vite": "^5.2.0",
|
||||
"vite-plugin-pwa": "^1.3.0",
|
||||
"vitest": "^2.1.9"
|
||||
}
|
||||
}
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 3.8 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 890 B |
Binary file not shown.
|
After Width: | Height: | Size: 3.5 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 10 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 8.7 KiB |
@@ -9,16 +9,18 @@ import { LyricsOverlay } from './LyricsOverlay';
|
||||
import { Toaster } from './Toaster';
|
||||
import { CommandPalette } from './CommandPalette';
|
||||
import { KeyboardListener, useKeyboard } from '../hooks/useKeyboard';
|
||||
import { Inspector, type InspectorMode } from './Inspector';
|
||||
import { useInstallPrompt } from '../hooks/useInstallPrompt';
|
||||
import { PlaybackSyncProvider } from './PlaybackSyncProvider';
|
||||
|
||||
export default function AppShell() {
|
||||
const [queueOpen, setQueueOpen] = useState(false);
|
||||
const [navigationOpen, setNavigationOpen] = useState(false);
|
||||
const [lyricsOpen, setLyricsOpen] = useState(false);
|
||||
const [paletteOpen, setPaletteOpen] = useState(false);
|
||||
const [inspector, setInspector] = useState<{ mode: InspectorMode; id: string } | null>(null);
|
||||
|
||||
const togglePalette = useCallback(() => setPaletteOpen((p) => !p), []);
|
||||
const closeInspector = useCallback(() => setInspector(null), []);
|
||||
|
||||
useInstallPrompt();
|
||||
|
||||
// Ctrl+K — command palette (uses `code` so it works on any keyboard layout)
|
||||
useKeyboard({
|
||||
@@ -39,24 +41,29 @@ export default function AppShell() {
|
||||
handler: () => window.history.forward(),
|
||||
});
|
||||
|
||||
// Esc — closes inspector, palette, etc.
|
||||
useKeyboard({
|
||||
code: 'Escape',
|
||||
handler: () => {
|
||||
if (inspector) closeInspector();
|
||||
if (navigationOpen) setNavigationOpen(false);
|
||||
else if (queueOpen) setQueueOpen(false);
|
||||
else if (lyricsOpen) setLyricsOpen(false);
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-screen bg-bg0 text-text overflow-hidden">
|
||||
<PlaybackSyncProvider>
|
||||
<div className="flex h-screen h-[100dvh] flex-col overflow-hidden bg-bg0 text-text">
|
||||
<KeyboardListener />
|
||||
<TopBar onToggleCommandPalette={togglePalette} />
|
||||
<TopBar
|
||||
onToggleCommandPalette={togglePalette}
|
||||
onToggleNavigation={() => setNavigationOpen((open) => !open)}
|
||||
navigationOpen={navigationOpen}
|
||||
/>
|
||||
<div className="relative flex flex-1 overflow-hidden">
|
||||
<NavRail />
|
||||
<main className="flex-1 overflow-y-auto p-4 pb-8">
|
||||
<NavRail open={navigationOpen} onClose={() => setNavigationOpen(false)} />
|
||||
<main className="min-w-0 flex-1 overflow-y-auto p-3 pb-6 sm:p-4 sm:pb-8">
|
||||
<Outlet />
|
||||
</main>
|
||||
{inspector && <Inspector mode={inspector.mode} id={inspector.id} onClose={closeInspector} />}
|
||||
{queueOpen && <NowPlayingPanel onClose={() => setQueueOpen(false)} />}
|
||||
{lyricsOpen && <LyricsOverlay onClose={() => setLyricsOpen(false)} />}
|
||||
</div>
|
||||
@@ -70,5 +77,6 @@ export default function AppShell() {
|
||||
<Toaster />
|
||||
<CommandPalette open={paletteOpen} onClose={() => setPaletteOpen(false)} />
|
||||
</div>
|
||||
</PlaybackSyncProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -24,13 +24,9 @@ function deduplicateArtists(artists: TrackArtist[]): TrackArtist[] {
|
||||
map.set(a.id, a);
|
||||
}
|
||||
}
|
||||
// Preserve original order, skipping duplicates.
|
||||
const seen = new Set<string>();
|
||||
return artists.filter((a) => {
|
||||
if (seen.has(a.id)) return false;
|
||||
seen.add(a.id);
|
||||
return true;
|
||||
});
|
||||
// Keep the position of the selected credit and omit every duplicate. This
|
||||
// makes the documented main-over-featured preference real.
|
||||
return artists.filter((a) => map.get(a.id) === a);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -9,6 +9,8 @@ interface ArtworkProps {
|
||||
rounded?: 'sm' | 'md' | 'lg' | 'xl' | 'full';
|
||||
/** Skip native lazy-loading — set true for above-the-fold artwork (e.g. PlaybackBar). */
|
||||
eager?: boolean;
|
||||
/** Drop the note glyph. For callers that draw their own icon on top (TrackRow's play overlay). */
|
||||
glyph?: boolean;
|
||||
}
|
||||
|
||||
const API_BASE = import.meta.env.VITE_API_URL || '/api';
|
||||
@@ -25,13 +27,13 @@ function proxySrc(src: string): string {
|
||||
return src;
|
||||
}
|
||||
|
||||
export function Artwork({ seed, src, className = '', rounded = 'md', eager = false }: ArtworkProps) {
|
||||
export function Artwork({ seed, src, className = '', rounded = 'md', eager = false, glyph = true }: ArtworkProps) {
|
||||
const hue = hueFromString(seed);
|
||||
// Symmetric top sheen over a diagonal base — the highlight is centered
|
||||
// horizontally so it reads as even behind the centered note glyph.
|
||||
const gradient =
|
||||
`radial-gradient(110% 90% at 50% 0%, hsl(${hue},55%,30%) 0%, transparent 60%), ` +
|
||||
`linear-gradient(160deg, hsl(${hue},48%,23%), hsl(${(hue + 55) % 360},40%,11%))`;
|
||||
`radial-gradient(110% 90% at 50% 0%, hsl(${hue},42%,26%) 0%, transparent 60%), ` +
|
||||
`linear-gradient(160deg, hsl(${hue},36%,19%), hsl(${hue - 6},30%,10%))`;
|
||||
const r = { sm: 'rounded-sm', md: 'rounded-md', lg: 'rounded-lg', xl: 'rounded-xl', full: 'rounded-full' }[rounded];
|
||||
|
||||
// If an image source is supplied we render it lazily over the gradient
|
||||
@@ -45,7 +47,7 @@ export function Artwork({ seed, src, className = '', rounded = 'md', eager = fal
|
||||
const proxied = proxySrc(src);
|
||||
return (
|
||||
<div className={`relative overflow-hidden ${r} ${className}`} style={{ background: gradient }}>
|
||||
{!loaded && <Music className="absolute inset-0 m-auto h-2/5 w-2/5 text-on-accent/20" strokeWidth={1.5} aria-hidden />}
|
||||
{!loaded && glyph && <Music className="absolute inset-0 m-auto h-2/5 w-2/5 text-on-accent/20" strokeWidth={1.5} aria-hidden />}
|
||||
<img
|
||||
src={proxied}
|
||||
alt={seed}
|
||||
@@ -60,7 +62,7 @@ export function Artwork({ seed, src, className = '', rounded = 'md', eager = fal
|
||||
}
|
||||
return (
|
||||
<div className={`relative flex items-center justify-center overflow-hidden ${r} ${className}`} style={{ background: gradient }}>
|
||||
<Music className="h-2/5 w-2/5 text-on-accent/20" strokeWidth={1.5} aria-hidden />
|
||||
{glyph && <Music className="h-2/5 w-2/5 text-on-accent/20" strokeWidth={1.5} aria-hidden />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
import { act, render, waitFor } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { Track } from '../types';
|
||||
import { usePlaybackStore } from '../store/usePlaybackStore';
|
||||
import { useVibeStore } from '../store/useVibeStore';
|
||||
|
||||
const { advancePastUnplayableVibeTrack, reportVibeEvent } = vi.hoisted(() => ({
|
||||
advancePastUnplayableVibeTrack: vi.fn().mockResolvedValue(undefined),
|
||||
reportVibeEvent: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock('../services/vibeSession', () => ({ advancePastUnplayableVibeTrack, reportVibeEvent }));
|
||||
|
||||
import { AudioEngine } from './AudioEngine';
|
||||
|
||||
const track = (id: string): Track => ({
|
||||
id, path: `/music/${id}.mp3`, hash: id, title: id, artist: 'Artist', album_id: 'album',
|
||||
duration: 180, state: 'LIBRARY', source_type: 'MANUAL', play_count: 0, skip_count: 0, dislike_count: 0,
|
||||
});
|
||||
|
||||
describe('AudioEngine', () => {
|
||||
beforeEach(() => {
|
||||
advancePastUnplayableVibeTrack.mockResolvedValue(undefined);
|
||||
reportVibeEvent.mockResolvedValue(undefined);
|
||||
vi.spyOn(HTMLMediaElement.prototype, 'load').mockImplementation(() => undefined);
|
||||
vi.spyOn(HTMLMediaElement.prototype, 'play').mockResolvedValue(undefined);
|
||||
vi.spyOn(HTMLMediaElement.prototype, 'pause').mockImplementation(() => undefined);
|
||||
useVibeStore.getState().reset();
|
||||
useVibeStore.getState().setActiveSession({ sessionId: 'session-a', seedTrackId: 'song' });
|
||||
usePlaybackStore.setState({
|
||||
currentTrack: track('song'), queue: [track('song')], currentIndex: 0, isPlaying: false,
|
||||
queueOwner: 'vibe', vibeAdvanceHandler: () => undefined, audioElsewhere: false,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
/** jsdom media elements report no duration and never really play. */
|
||||
const fakeMedia = (audio: HTMLAudioElement, currentTime: number, duration = 180) => {
|
||||
Object.defineProperty(audio, 'duration', { value: duration, configurable: true });
|
||||
Object.defineProperty(audio, 'currentTime', { value: currentTime, writable: true, configurable: true });
|
||||
Object.defineProperty(audio, 'paused', { value: false, configurable: true });
|
||||
Object.defineProperty(audio, 'ended', { value: false, configurable: true });
|
||||
};
|
||||
|
||||
it('loads no stream while another device holds the audio, and loads one when it comes back', () => {
|
||||
usePlaybackStore.setState({
|
||||
audioElsewhere: true, isPlaying: false, prefetchNext: true,
|
||||
queue: [track('song'), track('next')], currentIndex: 0,
|
||||
});
|
||||
const { container } = render(<AudioEngine />);
|
||||
const [active, idle] = Array.from(container.querySelectorAll('audio'));
|
||||
|
||||
expect(active.getAttribute('src')).toBeNull();
|
||||
expect(idle.getAttribute('src')).toBeNull();
|
||||
// Mirroring is not playback: this device reports nothing to the session.
|
||||
expect(reportVibeEvent).not.toHaveBeenCalled();
|
||||
|
||||
act(() => usePlaybackStore.getState().setAudioElsewhere(false));
|
||||
|
||||
expect(active.src).toContain('/tracks/song/stream');
|
||||
});
|
||||
|
||||
it('buffers the next queued track into the idle element before the current one ends', () => {
|
||||
usePlaybackStore.setState({
|
||||
queue: [track('song'), track('next')], currentIndex: 0, isPlaying: true,
|
||||
queueOwner: 'ordinary', vibeAdvanceHandler: null, prefetchNext: true, crossfadeMs: 0,
|
||||
});
|
||||
const { container } = render(<AudioEngine />);
|
||||
const [active, idle] = Array.from(container.querySelectorAll('audio'));
|
||||
|
||||
fakeMedia(active, 170);
|
||||
active.dispatchEvent(new Event('timeupdate'));
|
||||
|
||||
expect(idle.src).toContain('/tracks/next/stream');
|
||||
expect(active.src).toContain('/tracks/song/stream');
|
||||
});
|
||||
|
||||
it('starts buffering the next track early in the current one, not only near its end', () => {
|
||||
usePlaybackStore.setState({
|
||||
queue: [track('song'), track('next')], currentIndex: 0, isPlaying: true,
|
||||
queueOwner: 'ordinary', vibeAdvanceHandler: null, prefetchNext: true, crossfadeMs: 0,
|
||||
});
|
||||
const { container } = render(<AudioEngine />);
|
||||
const [active, idle] = Array.from(container.querySelectorAll('audio'));
|
||||
|
||||
fakeMedia(active, 20);
|
||||
active.dispatchEvent(new Event('timeupdate'));
|
||||
|
||||
expect(idle.src).toContain('/tracks/next/stream');
|
||||
});
|
||||
|
||||
it('re-points the buffered element when a replan changes what plays next', () => {
|
||||
usePlaybackStore.setState({
|
||||
queue: [track('song'), track('next')], currentIndex: 0, isPlaying: true,
|
||||
queueOwner: 'ordinary', vibeAdvanceHandler: null, prefetchNext: true, crossfadeMs: 0,
|
||||
});
|
||||
const { container } = render(<AudioEngine />);
|
||||
const [active, idle] = Array.from(container.querySelectorAll('audio'));
|
||||
|
||||
fakeMedia(active, 20);
|
||||
active.dispatchEvent(new Event('timeupdate'));
|
||||
expect(idle.src).toContain('/tracks/next/stream');
|
||||
|
||||
usePlaybackStore.setState({ queue: [track('song'), track('other')] });
|
||||
fakeMedia(active, 30);
|
||||
active.dispatchEvent(new Event('timeupdate'));
|
||||
|
||||
expect(idle.src).toContain('/tracks/other/stream');
|
||||
});
|
||||
|
||||
it('hands over to the next track inside the crossfade window instead of waiting for ended', () => {
|
||||
usePlaybackStore.setState({
|
||||
queue: [track('song'), track('next')], currentIndex: 0, isPlaying: true,
|
||||
queueOwner: 'ordinary', vibeAdvanceHandler: null, prefetchNext: true, crossfadeMs: 500,
|
||||
});
|
||||
const { container } = render(<AudioEngine />);
|
||||
const [active, idle] = Array.from(container.querySelectorAll('audio'));
|
||||
|
||||
fakeMedia(active, 179.8);
|
||||
active.dispatchEvent(new Event('timeupdate'));
|
||||
|
||||
expect(usePlaybackStore.getState().currentTrack?.id).toBe('next');
|
||||
expect(idle.src).toContain('/tracks/next/stream');
|
||||
});
|
||||
|
||||
it('joins gaplessly with crossfade off: resolves early, starts the next track when the tail ends', () => {
|
||||
usePlaybackStore.setState({
|
||||
queue: [track('song'), track('next')], currentIndex: 0, isPlaying: true,
|
||||
queueOwner: 'ordinary', vibeAdvanceHandler: null, prefetchNext: true, crossfadeMs: 0,
|
||||
});
|
||||
const { container } = render(<AudioEngine />);
|
||||
const [active, idle] = Array.from(container.querySelectorAll('audio'));
|
||||
const idlePlay = vi.spyOn(idle, 'play').mockResolvedValue(undefined);
|
||||
|
||||
fakeMedia(active, 179);
|
||||
active.dispatchEvent(new Event('timeupdate'));
|
||||
|
||||
// The store moved on so Vibe can resolve, but the tail keeps the audio.
|
||||
expect(usePlaybackStore.getState().currentTrack?.id).toBe('next');
|
||||
expect(idle.src).toContain('/tracks/next/stream');
|
||||
expect(idlePlay).not.toHaveBeenCalled();
|
||||
|
||||
Object.defineProperty(active, 'ended', { value: true, configurable: true });
|
||||
active.dispatchEvent(new Event('ended'));
|
||||
|
||||
expect(idlePlay).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not resume the finished element while the next track is still being resolved', () => {
|
||||
usePlaybackStore.setState({
|
||||
queue: [track('song')], currentIndex: 0, isPlaying: true,
|
||||
queueOwner: 'vibe', vibeAdvanceHandler: () => undefined, crossfadeMs: 0,
|
||||
});
|
||||
const { container } = render(<AudioEngine />);
|
||||
const [active] = Array.from(container.querySelectorAll('audio'));
|
||||
const play = vi.spyOn(active, 'play').mockResolvedValue(undefined);
|
||||
|
||||
Object.defineProperty(active, 'ended', { value: true, configurable: true });
|
||||
Object.defineProperty(active, 'paused', { value: true, configurable: true });
|
||||
active.dispatchEvent(new Event('ended'));
|
||||
// A queue/plan write during the Vibe handshake must not restart the tail.
|
||||
usePlaybackStore.getState().setQueue([track('song')]);
|
||||
|
||||
expect(play).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('uses the durable unplayable advancement when a Vibe stream errors after metadata resolved', async () => {
|
||||
const { container } = render(<AudioEngine />);
|
||||
const audio = container.querySelector('audio')!;
|
||||
|
||||
audio.dispatchEvent(new Event('error'));
|
||||
audio.dispatchEvent(new Event('error'));
|
||||
|
||||
await waitFor(() => expect(advancePastUnplayableVibeTrack).toHaveBeenCalledWith('song'));
|
||||
expect(advancePastUnplayableVibeTrack).toHaveBeenCalledTimes(1);
|
||||
expect(reportVibeEvent).not.toHaveBeenCalledWith('skipped', 'song');
|
||||
});
|
||||
});
|
||||
@@ -1,18 +1,11 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
import { usePlaybackStore } from '../store/usePlaybackStore';
|
||||
import { useVibeStore } from '../store/useVibeStore';
|
||||
import { trackService } from '../services/trackService';
|
||||
import { vibeService } from '../services/vibeService';
|
||||
import { advancePastUnplayableVibeTrack, reportVibeEvent } from '../services/vibeSession';
|
||||
import { PREFETCH_LEAD_SECONDS, PREFETCH_START_SECONDS } from '../lib/playbackPrefs';
|
||||
import type { Track } from '../types';
|
||||
|
||||
// Track ids whose next natural feedback transition should be skipped because
|
||||
// the caller (e.g. Vibe.tsx's dislike button) already recorded feedback for
|
||||
// them explicitly. Consumed once, then cleared.
|
||||
const suppressedFeedbackIds = new Set<string>();
|
||||
export function suppressAutoFeedback(trackId: string): void {
|
||||
suppressedFeedbackIds.add(trackId);
|
||||
}
|
||||
|
||||
// Threshold (seconds) above which a store position change is treated as a user
|
||||
// scrub and applied to the audio element. Keeps the timeupdate -> setPosition ->
|
||||
// effect loop from fighting itself.
|
||||
@@ -25,6 +18,18 @@ const COMPLETION_THRESHOLD = 0.95;
|
||||
// Relative seek increment (seconds) for MediaSession seekforward/seekbackward.
|
||||
const SEEK_INCREMENT = 10;
|
||||
|
||||
// Fade granularity. Fine enough to be inaudible, coarse enough to be cheap.
|
||||
const FADE_TICK_MS = 40;
|
||||
|
||||
// With crossfade off there is no overlap to hide Vibe's replan round-trip, so
|
||||
// the handover still starts this early — the next element just waits, silent,
|
||||
// until the current one actually ends.
|
||||
const HANDOFF_LEAD_MS = 1200;
|
||||
|
||||
// If the outgoing element never reports `ended` (a stalled or broken stream),
|
||||
// start the waiting track anyway this long after its lead began.
|
||||
const JOIN_TIMEOUT_MS = HANDOFF_LEAD_MS + 2000;
|
||||
|
||||
/** Build the artwork URLs for MediaSession metadata (OS media controls). */
|
||||
function buildArtwork(track: Track): MediaImage[] {
|
||||
const sizes = [96, 128, 192, 256, 384, 512];
|
||||
@@ -37,26 +42,219 @@ function buildArtwork(track: Track): MediaImage[] {
|
||||
return sizes.map((s) => ({ src: url, sizes: `${s}x${s}`, type: 'image/jpeg' }));
|
||||
}
|
||||
|
||||
// Headless audio engine: one shared <audio> element driven by the playback store.
|
||||
// State -> DOM via store subscriptions; DOM -> state via media events.
|
||||
export const AudioEngine = () => {
|
||||
const audioRef = useRef<HTMLAudioElement>(null);
|
||||
/**
|
||||
* The id of the track ordinary queue navigation will play next, or null when it
|
||||
* cannot be known ahead of time (shuffle) or there is nothing after this one.
|
||||
* Vibe's next track comes from the server, so this is only a prediction there —
|
||||
* used to warm the stream, never to decide what actually plays.
|
||||
*/
|
||||
function predictNextTrackId(): string | null {
|
||||
const { queue, currentTrack, currentIndex, shuffle, repeat } = usePlaybackStore.getState();
|
||||
if (shuffle || repeat === 'one' || queue.length === 0) return null;
|
||||
const idx =
|
||||
currentIndex >= 0 && queue[currentIndex]?.id === currentTrack?.id
|
||||
? currentIndex
|
||||
: currentTrack
|
||||
? queue.findIndex((t) => t.id === currentTrack.id)
|
||||
: -1;
|
||||
if (idx < 0) return null;
|
||||
if (idx + 1 < queue.length) return queue[idx + 1].id;
|
||||
if (repeat === 'all') return queue[0].id;
|
||||
return null;
|
||||
}
|
||||
|
||||
// Track which id is currently loaded into the element, and whether it ended
|
||||
// naturally (so we record COMPLETED, not skip, on the resulting track change).
|
||||
// Headless audio engine: two <audio> elements driven by the playback store, so
|
||||
// the next track can buffer (and fade in) while the current one is still
|
||||
// playing. State -> DOM via store subscriptions; DOM -> state via media events.
|
||||
export const AudioEngine = () => {
|
||||
const audioARef = useRef<HTMLAudioElement>(null);
|
||||
const audioBRef = useRef<HTMLAudioElement>(null);
|
||||
|
||||
// Index of the element the store's currentTrack is playing on; the other one
|
||||
// is idle, prefetching, or fading out after being retired.
|
||||
const activeIdxRef = useRef(0);
|
||||
// Per-element fade multiplier applied on top of the store's master volume.
|
||||
const gainRef = useRef([1, 1]);
|
||||
const fadeTimerRef = useRef<Array<ReturnType<typeof setInterval> | null>>([null, null]);
|
||||
// The idle element holds this track id, loaded but never yet played.
|
||||
const preparedRef = useRef<{ id: string; idx: number } | null>(null);
|
||||
// Set when the handover window triggered the track change before `ended`, so
|
||||
// the ending element's own event does not advance a second time.
|
||||
const earlyAdvanceRef = useRef(false);
|
||||
// Cancels a pending gapless join (next track loaded, waiting for the tail).
|
||||
const cancelJoinRef = useRef<(() => void) | null>(null);
|
||||
|
||||
// Track which id is currently loaded into the active element, and whether it
|
||||
// ended naturally (so we record COMPLETED, not skip, on the track change).
|
||||
const loadedIdRef = useRef<string | null>(null);
|
||||
const endedNaturallyRef = useRef(false);
|
||||
// Track whether the current track has crossed the completion threshold.
|
||||
const crossedThresholdRef = useRef(false);
|
||||
const lastProgressSecondRef = useRef(-1);
|
||||
const streamErrorTrackIdRef = useRef<string | null>(null);
|
||||
|
||||
const elements = useCallback((): HTMLAudioElement[] => {
|
||||
const a = audioARef.current;
|
||||
const b = audioBRef.current;
|
||||
return a && b ? [a, b] : [];
|
||||
}, []);
|
||||
|
||||
const applyGain = useCallback((idx: number) => {
|
||||
const els = elements();
|
||||
if (!els[idx]) return;
|
||||
const master = usePlaybackStore.getState().volume;
|
||||
els[idx].volume = Math.min(1, Math.max(0, master * gainRef.current[idx]));
|
||||
}, [elements]);
|
||||
|
||||
const stopFade = useCallback((idx: number) => {
|
||||
const timer = fadeTimerRef.current[idx];
|
||||
if (timer !== null) {
|
||||
clearInterval(timer);
|
||||
fadeTimerRef.current[idx] = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const setGain = useCallback((idx: number, gain: number) => {
|
||||
stopFade(idx);
|
||||
gainRef.current[idx] = Math.min(1, Math.max(0, gain));
|
||||
applyGain(idx);
|
||||
}, [applyGain, stopFade]);
|
||||
|
||||
/** Ramp one element's gain, then run `onDone`. Instant when ms <= 0. */
|
||||
const fadeTo = useCallback((idx: number, target: number, ms: number, onDone?: () => void) => {
|
||||
stopFade(idx);
|
||||
if (ms <= 0) {
|
||||
setGain(idx, target);
|
||||
onDone?.();
|
||||
return;
|
||||
}
|
||||
const from = gainRef.current[idx];
|
||||
const steps = Math.max(1, Math.round(ms / FADE_TICK_MS));
|
||||
let step = 0;
|
||||
fadeTimerRef.current[idx] = setInterval(() => {
|
||||
step += 1;
|
||||
gainRef.current[idx] = from + ((target - from) * step) / steps;
|
||||
applyGain(idx);
|
||||
if (step >= steps) {
|
||||
stopFade(idx);
|
||||
setGain(idx, target);
|
||||
onDone?.();
|
||||
}
|
||||
}, FADE_TICK_MS);
|
||||
}, [applyGain, setGain, stopFade]);
|
||||
|
||||
/** Take an element out of service: silence it and drop its stream. */
|
||||
const retire = useCallback((idx: number) => {
|
||||
const els = elements();
|
||||
const el = els[idx];
|
||||
if (!el) return;
|
||||
stopFade(idx);
|
||||
el.pause();
|
||||
el.removeAttribute('src');
|
||||
el.load();
|
||||
setGain(idx, 1);
|
||||
}, [elements, setGain, stopFade]);
|
||||
|
||||
const cancelJoin = useCallback(() => {
|
||||
const cancel = cancelJoinRef.current;
|
||||
cancelJoinRef.current = null;
|
||||
cancel?.();
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Crossfade-off handover: the next track is already loaded and the store has
|
||||
* already moved on, but the outgoing element still has its tail to play.
|
||||
* Start the new one the moment that tail ends, so the join has no gap and no
|
||||
* overlap.
|
||||
*/
|
||||
const scheduleJoin = useCallback((oldIdx: number, nextIdx: number) => {
|
||||
const els = elements();
|
||||
if (els.length === 0) return;
|
||||
const oldEl = els[oldIdx];
|
||||
let settled = false;
|
||||
|
||||
const cleanup = () => {
|
||||
clearTimeout(timer);
|
||||
oldEl.removeEventListener('ended', join);
|
||||
oldEl.removeEventListener('error', join);
|
||||
};
|
||||
|
||||
function join() {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
cleanup();
|
||||
cancelJoinRef.current = null;
|
||||
retire(oldIdx);
|
||||
// A newer track change may already own the pipeline.
|
||||
if (activeIdxRef.current !== nextIdx) return;
|
||||
if (!usePlaybackStore.getState().isPlaying) return;
|
||||
setGain(nextIdx, 1);
|
||||
void els[nextIdx].play().catch(() => {});
|
||||
}
|
||||
|
||||
const timer = setTimeout(join, JOIN_TIMEOUT_MS);
|
||||
oldEl.addEventListener('ended', join);
|
||||
oldEl.addEventListener('error', join);
|
||||
cancelJoinRef.current = () => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
cleanup();
|
||||
retire(oldIdx);
|
||||
};
|
||||
}, [elements, retire, setGain]);
|
||||
|
||||
// --- DOM -> store: media events -----------------------------------------
|
||||
useEffect(() => {
|
||||
const audio = audioRef.current;
|
||||
if (!audio) return;
|
||||
const els = elements();
|
||||
if (els.length === 0) return;
|
||||
|
||||
const store = usePlaybackStore.getState;
|
||||
|
||||
const onTimeUpdate = () => {
|
||||
/** Warm the predicted next track into the idle element. */
|
||||
const prefetch = () => {
|
||||
const playback = store();
|
||||
if (!playback.prefetchNext || playback.audioElsewhere) return;
|
||||
const nextId = predictNextTrackId();
|
||||
if (!nextId || nextId === loadedIdRef.current) return;
|
||||
const idleIdx = 1 - activeIdxRef.current;
|
||||
// Buffering starts early enough that a Vibe replan can change the answer
|
||||
// underneath us. Re-point the idle element rather than arriving at the
|
||||
// handover with the wrong track warmed; leave a tail still playing alone.
|
||||
const prepared = preparedRef.current;
|
||||
if (prepared && (prepared.id === nextId || prepared.idx !== idleIdx)) return;
|
||||
const idle = els[idleIdx];
|
||||
if (prepared && !idle.paused) return;
|
||||
setGain(idleIdx, 1);
|
||||
idle.preload = 'auto';
|
||||
idle.src = trackService.getStreamUrl(nextId);
|
||||
idle.load();
|
||||
preparedRef.current = { id: nextId, idx: idleIdx };
|
||||
};
|
||||
|
||||
/**
|
||||
* Hand over to the next track before `ended`, so Vibe's feedback/replan
|
||||
* round-trip happens while the tail of this one is still playing. With a
|
||||
* crossfade the tail fades under the new track; without one the new track
|
||||
* waits, silent, for the tail to finish.
|
||||
*/
|
||||
const maybeAdvanceEarly = (audio: HTMLAudioElement) => {
|
||||
const playback = store();
|
||||
const leadMs = playback.crossfadeMs > 0 ? playback.crossfadeMs : HANDOFF_LEAD_MS;
|
||||
if (earlyAdvanceRef.current || !playback.isPlaying) return;
|
||||
if (!Number.isFinite(audio.duration) || audio.duration <= 0) return;
|
||||
if (audio.duration * 1000 <= leadMs * 2) return;
|
||||
if (audio.duration - audio.currentTime > leadMs / 1000) return;
|
||||
const vibeOwned = playback.queueOwner === 'vibe' && !!playback.vibeAdvanceHandler;
|
||||
// Ordinary playback must have somewhere to go; otherwise let the element
|
||||
// finish on its own so end-of-queue still stops cleanly.
|
||||
if (!vibeOwned && !predictNextTrackId()) return;
|
||||
earlyAdvanceRef.current = true;
|
||||
endedNaturallyRef.current = true;
|
||||
store().nextWithReason('completed');
|
||||
};
|
||||
|
||||
const onTimeUpdate = (idx: number, audio: HTMLAudioElement) => {
|
||||
if (idx !== activeIdxRef.current) return;
|
||||
store().setPosition(audio.currentTime);
|
||||
// Mark as effectively completed if we cross the threshold.
|
||||
if (
|
||||
@@ -66,137 +264,270 @@ export const AudioEngine = () => {
|
||||
) {
|
||||
crossedThresholdRef.current = true;
|
||||
}
|
||||
const vibe = useVibeStore.getState();
|
||||
const track = store().currentTrack;
|
||||
const elapsed = Math.floor(audio.currentTime);
|
||||
if (vibe.activeSessionId && store().queueOwner === 'vibe' && track && elapsed > 0 && elapsed % 30 === 0 && elapsed !== lastProgressSecondRef.current) {
|
||||
lastProgressSecondRef.current = elapsed;
|
||||
void reportVibeEvent('progress', track.id, Math.round(audio.currentTime * 1000), Math.round((audio.duration || 0) * 1000)).catch(() => undefined);
|
||||
}
|
||||
if (
|
||||
audio.currentTime >= PREFETCH_START_SECONDS ||
|
||||
(Number.isFinite(audio.duration) && audio.duration - audio.currentTime <= PREFETCH_LEAD_SECONDS)
|
||||
) {
|
||||
prefetch();
|
||||
}
|
||||
maybeAdvanceEarly(audio);
|
||||
};
|
||||
const onLoadedMetadata = () => {
|
||||
const onLoadedMetadata = (idx: number, audio: HTMLAudioElement) => {
|
||||
// The idle element's metadata says nothing about what is playing.
|
||||
if (idx !== activeIdxRef.current) return;
|
||||
if (Number.isFinite(audio.duration)) store().setDuration(audio.duration);
|
||||
};
|
||||
const onPlay = () => {
|
||||
const onPlay = (idx: number) => {
|
||||
if (idx !== activeIdxRef.current) return;
|
||||
if (!store().isPlaying) store().play();
|
||||
};
|
||||
const onPause = () => {
|
||||
const onPause = (idx: number, audio: HTMLAudioElement) => {
|
||||
if (idx !== activeIdxRef.current) return;
|
||||
// Ignore the pause that fires as part of ending a track.
|
||||
if (audio.ended) return;
|
||||
if (store().isPlaying) store().pause();
|
||||
};
|
||||
const onEnded = () => {
|
||||
const onEnded = (idx: number, audio: HTMLAudioElement) => {
|
||||
// A retired element reaching its end is expected during a crossfade.
|
||||
if (idx !== activeIdxRef.current) return;
|
||||
// The crossfade window already handed over; do not advance twice.
|
||||
if (earlyAdvanceRef.current) {
|
||||
audio.pause();
|
||||
return;
|
||||
}
|
||||
// Just flag it — applyTrack (below) is the single place that sends
|
||||
// feedback, on the resulting track-change, so completion is recorded
|
||||
// exactly once per track.
|
||||
endedNaturallyRef.current = true;
|
||||
store().next();
|
||||
// Vibe's durable feedback/replan handshake can take longer than the
|
||||
// browser's end transition. Explicitly pause the ended element so it
|
||||
// cannot auto-resume from its final buffered samples while that work is
|
||||
// in flight. applyTrack will start the next source when ready.
|
||||
audio.pause();
|
||||
store().nextWithReason('completed');
|
||||
};
|
||||
const onError = (idx: number) => {
|
||||
if (idx !== activeIdxRef.current) return;
|
||||
const playback = store();
|
||||
const track = playback.currentTrack;
|
||||
const vibe = useVibeStore.getState();
|
||||
// Metadata can be available while the stream itself is no longer
|
||||
// readable. Vibe must advance that exact durable cursor, not fall back
|
||||
// to ordinary queue navigation or feedback-driven replanning.
|
||||
if (!track || !vibe.activeSessionId || playback.queueOwner !== 'vibe' || streamErrorTrackIdRef.current === track.id) return;
|
||||
streamErrorTrackIdRef.current = track.id;
|
||||
void advancePastUnplayableVibeTrack(track.id)
|
||||
.catch(() => undefined)
|
||||
.finally(() => { streamErrorTrackIdRef.current = null; });
|
||||
};
|
||||
|
||||
audio.addEventListener('timeupdate', onTimeUpdate);
|
||||
audio.addEventListener('loadedmetadata', onLoadedMetadata);
|
||||
audio.addEventListener('play', onPlay);
|
||||
audio.addEventListener('pause', onPause);
|
||||
audio.addEventListener('ended', onEnded);
|
||||
const teardown = els.map((audio, idx) => {
|
||||
const listeners: Array<[string, () => void]> = [
|
||||
['timeupdate', () => onTimeUpdate(idx, audio)],
|
||||
['loadedmetadata', () => onLoadedMetadata(idx, audio)],
|
||||
['play', () => onPlay(idx)],
|
||||
['pause', () => onPause(idx, audio)],
|
||||
['ended', () => onEnded(idx, audio)],
|
||||
['error', () => onError(idx)],
|
||||
];
|
||||
for (const [event, handler] of listeners) audio.addEventListener(event, handler);
|
||||
return () => {
|
||||
for (const [event, handler] of listeners) audio.removeEventListener(event, handler);
|
||||
};
|
||||
});
|
||||
|
||||
return () => {
|
||||
audio.removeEventListener('timeupdate', onTimeUpdate);
|
||||
audio.removeEventListener('loadedmetadata', onLoadedMetadata);
|
||||
audio.removeEventListener('play', onPlay);
|
||||
audio.removeEventListener('pause', onPause);
|
||||
audio.removeEventListener('ended', onEnded);
|
||||
};
|
||||
}, []);
|
||||
return () => { for (const off of teardown) off(); };
|
||||
}, [elements, setGain]);
|
||||
|
||||
// --- store -> DOM: react to currentTrack changes ------------------------
|
||||
useEffect(() => {
|
||||
const audio = audioRef.current;
|
||||
if (!audio) return;
|
||||
const els = elements();
|
||||
if (els.length === 0) return;
|
||||
|
||||
const applyTrack = (id: string | null) => {
|
||||
// Another device is playing and this one is only showing what it plays.
|
||||
// Pointing an element at the stream here would download the track — the
|
||||
// whole of it, once an earlier prefetch left that element on preload=auto
|
||||
// — for audio that is never heard. Drop the sources and follow along.
|
||||
if (usePlaybackStore.getState().audioElsewhere) {
|
||||
cancelJoin();
|
||||
for (const idx of [0, 1]) retire(idx);
|
||||
loadedIdRef.current = null;
|
||||
preparedRef.current = null;
|
||||
endedNaturallyRef.current = false;
|
||||
crossedThresholdRef.current = false;
|
||||
earlyAdvanceRef.current = false;
|
||||
return;
|
||||
}
|
||||
if (id === loadedIdRef.current) return;
|
||||
// A newer track change supersedes any tail still waiting to hand over.
|
||||
cancelJoin();
|
||||
|
||||
// The previously loaded track is changing. If it didn't end naturally and
|
||||
// hadn't crossed the completion threshold, record a skip (best-effort).
|
||||
// If it crossed the threshold OR ended naturally, record as completed.
|
||||
// The durable Vibe controller owns normal next/ended navigation. It
|
||||
// records the outcome, receives a new plan revision, then calls the raw
|
||||
// advance method. Do not emit a second event here after that transition.
|
||||
const prevId = loadedIdRef.current;
|
||||
const completed = endedNaturallyRef.current || crossedThresholdRef.current;
|
||||
// Only vibe sessions want this feedback — plain library browsing
|
||||
// shouldn't write skip/completed evidence for tracks merely sampled.
|
||||
const inVibeSession = !!useVibeStore.getState().activeSessionId;
|
||||
if (prevId && inVibeSession) {
|
||||
if (suppressedFeedbackIds.delete(prevId)) {
|
||||
// Caller already recorded explicit feedback (e.g. dislike) for
|
||||
// this track — don't also record the implicit transition.
|
||||
} else {
|
||||
try {
|
||||
void vibeService.feedback(prevId, completed ? 'completed' : 'skipped').catch(() => {});
|
||||
} catch {
|
||||
/* best-effort */
|
||||
}
|
||||
}
|
||||
const playback = usePlaybackStore.getState();
|
||||
const inVibePlayback = !!useVibeStore.getState().activeSessionId && playback.queueOwner === 'vibe';
|
||||
if (prevId && inVibePlayback && !playback.vibeAdvanceHandler) {
|
||||
void reportVibeEvent(completed ? 'completed' : 'skipped', prevId).catch(() => undefined);
|
||||
}
|
||||
const wasEarlyAdvance = earlyAdvanceRef.current;
|
||||
endedNaturallyRef.current = false;
|
||||
crossedThresholdRef.current = false;
|
||||
earlyAdvanceRef.current = false;
|
||||
lastProgressSecondRef.current = -1;
|
||||
loadedIdRef.current = id;
|
||||
|
||||
const oldIdx = activeIdxRef.current;
|
||||
const oldEl = els[oldIdx];
|
||||
|
||||
if (!id) {
|
||||
audio.removeAttribute('src');
|
||||
audio.load();
|
||||
retire(oldIdx);
|
||||
return;
|
||||
}
|
||||
|
||||
audio.src = trackService.getStreamUrl(id);
|
||||
audio.load();
|
||||
// Reuse the prefetched element when it holds exactly this track; that
|
||||
// stream is already buffered, so playback starts without a fetch. With
|
||||
// nothing playing yet there is no tail to preserve, so load in place.
|
||||
const prepared = preparedRef.current;
|
||||
const reusePrefetched = prepared?.id === id && prepared.idx !== oldIdx;
|
||||
const nextIdx = reusePrefetched ? prepared!.idx : prevId ? 1 - oldIdx : oldIdx;
|
||||
const nextEl = els[nextIdx];
|
||||
preparedRef.current = null;
|
||||
|
||||
if (!reusePrefetched) {
|
||||
nextEl.src = trackService.getStreamUrl(id);
|
||||
nextEl.load();
|
||||
} else if (nextEl.currentTime > 0) {
|
||||
nextEl.currentTime = 0;
|
||||
}
|
||||
activeIdxRef.current = nextIdx;
|
||||
|
||||
// Only an early handover leaves a tail to deal with; an element that
|
||||
// already ended has nothing left to play.
|
||||
const crossfadeMs = playback.crossfadeMs;
|
||||
const hasTail = wasEarlyAdvance && nextIdx !== oldIdx && !oldEl.paused && !oldEl.ended;
|
||||
const fadeMs = hasTail ? crossfadeMs : 0;
|
||||
// Tail with no crossfade: hold the new track until the tail is done.
|
||||
const deferStart = hasTail && crossfadeMs <= 0;
|
||||
if (nextIdx !== oldIdx) {
|
||||
if (fadeMs > 0) fadeTo(oldIdx, 0, fadeMs, () => retire(oldIdx));
|
||||
else if (deferStart) scheduleJoin(oldIdx, nextIdx);
|
||||
else retire(oldIdx);
|
||||
}
|
||||
|
||||
if (inVibePlayback) void reportVibeEvent('playback_started', id).catch(() => undefined);
|
||||
|
||||
setGain(nextIdx, fadeMs > 0 ? 0 : 1);
|
||||
if (deferStart) return;
|
||||
if (usePlaybackStore.getState().isPlaying) {
|
||||
void audio.play().catch(() => {});
|
||||
void nextEl.play().catch(() => {});
|
||||
if (fadeMs > 0) fadeTo(nextIdx, 1, fadeMs);
|
||||
} else if (fadeMs > 0) {
|
||||
setGain(nextIdx, 1);
|
||||
}
|
||||
};
|
||||
|
||||
// Apply the current value immediately, then subscribe to future changes.
|
||||
let lastElsewhere = usePlaybackStore.getState().audioElsewhere;
|
||||
applyTrack(usePlaybackStore.getState().currentTrack?.id ?? null);
|
||||
const unsub = usePlaybackStore.subscribe((state) => {
|
||||
if (state.audioElsewhere !== lastElsewhere) {
|
||||
lastElsewhere = state.audioElsewhere;
|
||||
// The audio just came back to this device. Nothing is loaded, and the
|
||||
// track id has not changed, so say so and let applyTrack load it.
|
||||
if (!lastElsewhere) loadedIdRef.current = null;
|
||||
}
|
||||
applyTrack(state.currentTrack?.id ?? null);
|
||||
});
|
||||
return unsub;
|
||||
}, []);
|
||||
}, [cancelJoin, elements, fadeTo, retire, scheduleJoin, setGain]);
|
||||
|
||||
// --- store -> DOM: isPlaying ------------------------------------------------
|
||||
useEffect(() => {
|
||||
const audio = audioRef.current;
|
||||
if (!audio) return;
|
||||
const els = elements();
|
||||
if (els.length === 0) return;
|
||||
|
||||
const apply = (isPlaying: boolean) => {
|
||||
const audio = els[activeIdxRef.current];
|
||||
// Nothing is loaded while another device holds the audio.
|
||||
if (usePlaybackStore.getState().audioElsewhere) return;
|
||||
if (isPlaying) {
|
||||
// Never resume a finished element. Between `ended` and the next source
|
||||
// being loaded, isPlaying is still true, and resuming here replays the
|
||||
// final buffered milliseconds of the track that just finished.
|
||||
if (audio.ended) return;
|
||||
if (audio.paused) void audio.play().catch(() => {});
|
||||
} else {
|
||||
if (!audio.paused) audio.pause();
|
||||
// Pausing ends the handover: the tail is dropped and the waiting track
|
||||
// becomes the one that resumes.
|
||||
cancelJoin();
|
||||
for (const el of els) if (!el.paused) el.pause();
|
||||
}
|
||||
};
|
||||
|
||||
apply(usePlaybackStore.getState().isPlaying);
|
||||
return usePlaybackStore.subscribe((state) => apply(state.isPlaying));
|
||||
}, []);
|
||||
// Gate on an actual change: the subscription is unselected, so it fires on
|
||||
// every store write, including the queue/plan writes that happen while
|
||||
// Vibe's advance handshake is in flight.
|
||||
let lastIsPlaying = usePlaybackStore.getState().isPlaying;
|
||||
apply(lastIsPlaying);
|
||||
return usePlaybackStore.subscribe((state) => {
|
||||
if (state.isPlaying === lastIsPlaying) return;
|
||||
lastIsPlaying = state.isPlaying;
|
||||
apply(lastIsPlaying);
|
||||
});
|
||||
}, [cancelJoin, elements]);
|
||||
|
||||
// --- store -> DOM: volume --------------------------------------------------
|
||||
useEffect(() => {
|
||||
const audio = audioRef.current;
|
||||
if (!audio) return;
|
||||
const els = elements();
|
||||
if (els.length === 0) return;
|
||||
|
||||
const apply = (volume: number) => {
|
||||
audio.volume = Math.min(1, Math.max(0, volume));
|
||||
};
|
||||
// Master volume is scaled by each element's fade gain.
|
||||
const apply = () => els.forEach((_, idx) => applyGain(idx));
|
||||
|
||||
apply(usePlaybackStore.getState().volume);
|
||||
return usePlaybackStore.subscribe((state) => apply(state.volume));
|
||||
}, []);
|
||||
let lastVolume = usePlaybackStore.getState().volume;
|
||||
apply();
|
||||
return usePlaybackStore.subscribe((state) => {
|
||||
if (state.volume === lastVolume) return;
|
||||
lastVolume = state.volume;
|
||||
apply();
|
||||
});
|
||||
}, [applyGain, elements]);
|
||||
|
||||
// --- store -> DOM: external seeks (user scrubbing) -------------------------
|
||||
useEffect(() => {
|
||||
const audio = audioRef.current;
|
||||
if (!audio) return;
|
||||
const els = elements();
|
||||
if (els.length === 0) return;
|
||||
|
||||
const apply = (position: number) => {
|
||||
const audio = els[activeIdxRef.current];
|
||||
// A mirrored position belongs to another device's playhead, and there is
|
||||
// no source loaded here to move anyway.
|
||||
if (usePlaybackStore.getState().audioElsewhere) return;
|
||||
if (Math.abs(audio.currentTime - position) > SEEK_THRESHOLD) {
|
||||
audio.currentTime = position;
|
||||
}
|
||||
};
|
||||
|
||||
return usePlaybackStore.subscribe((state) => apply(state.position));
|
||||
}, []);
|
||||
// Only real position changes are seeks; other store writes must not move
|
||||
// the playhead of a track that is mid-transition.
|
||||
let lastPosition = usePlaybackStore.getState().position;
|
||||
return usePlaybackStore.subscribe((state) => {
|
||||
if (state.position === lastPosition) return;
|
||||
lastPosition = state.position;
|
||||
apply(lastPosition);
|
||||
});
|
||||
}, [elements]);
|
||||
|
||||
// --- MediaSession: hardware media keys + OS media controls ------------------
|
||||
//
|
||||
@@ -209,6 +540,7 @@ export const AudioEngine = () => {
|
||||
if (!('mediaSession' in navigator)) return;
|
||||
|
||||
const store = usePlaybackStore.getState;
|
||||
const active = () => elements()[activeIdxRef.current] ?? null;
|
||||
|
||||
const handlers: Partial<Record<MediaSessionAction, (details: MediaSessionActionDetails) => void>> = {
|
||||
play: () => store().play(),
|
||||
@@ -216,19 +548,19 @@ export const AudioEngine = () => {
|
||||
previoustrack: () => store().prev(),
|
||||
nexttrack: () => store().next(),
|
||||
seekbackward: (details) => {
|
||||
const audio = audioRef.current;
|
||||
const audio = active();
|
||||
if (!audio) return;
|
||||
const delta = details.seekOffset ?? SEEK_INCREMENT;
|
||||
audio.currentTime = Math.max(0, audio.currentTime - delta);
|
||||
},
|
||||
seekforward: (details) => {
|
||||
const audio = audioRef.current;
|
||||
const audio = active();
|
||||
if (!audio) return;
|
||||
const delta = details.seekOffset ?? SEEK_INCREMENT;
|
||||
audio.currentTime = Math.min(audio.duration || 0, audio.currentTime + delta);
|
||||
},
|
||||
seekto: (details) => {
|
||||
const audio = audioRef.current;
|
||||
const audio = active();
|
||||
if (!audio || details.seekTime == null) return;
|
||||
audio.currentTime = details.seekTime;
|
||||
},
|
||||
@@ -262,7 +594,7 @@ export const AudioEngine = () => {
|
||||
}
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
}, [elements]);
|
||||
|
||||
// --- MediaSession: publish metadata + playback state ------------------------
|
||||
useEffect(() => {
|
||||
@@ -289,5 +621,51 @@ export const AudioEngine = () => {
|
||||
});
|
||||
}, []);
|
||||
|
||||
return <audio ref={audioRef} hidden />;
|
||||
// --- MediaSession: publish position -----------------------------------------
|
||||
//
|
||||
// Without a position state the Android notification renders a scrubber that is
|
||||
// stuck at zero. setPositionState throws if position exceeds duration, which
|
||||
// happens transiently at a track handover, so both are clamped.
|
||||
useEffect(() => {
|
||||
if (!('mediaSession' in navigator) || !navigator.mediaSession.setPositionState) return;
|
||||
|
||||
const publish = (position: number, duration: number) => {
|
||||
if (!Number.isFinite(duration) || duration <= 0) return;
|
||||
try {
|
||||
navigator.mediaSession.setPositionState({
|
||||
duration,
|
||||
position: Math.min(Math.max(position, 0), duration),
|
||||
playbackRate: 1,
|
||||
});
|
||||
} catch {
|
||||
// Stale position against a just-changed duration — the next tick fixes it.
|
||||
}
|
||||
};
|
||||
|
||||
let lastPosition = -1;
|
||||
let lastDuration = -1;
|
||||
|
||||
return usePlaybackStore.subscribe((state) => {
|
||||
// timeupdate fires ~4x a second; only republish on a whole-second change.
|
||||
const second = Math.floor(state.position);
|
||||
if (second === lastPosition && state.duration === lastDuration) return;
|
||||
lastPosition = second;
|
||||
lastDuration = state.duration;
|
||||
publish(state.position, state.duration);
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Drop in-flight fades and any pending handover when the engine unmounts.
|
||||
useEffect(() => () => {
|
||||
for (const timer of fadeTimerRef.current) if (timer !== null) clearInterval(timer);
|
||||
cancelJoinRef.current?.();
|
||||
cancelJoinRef.current = null;
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<audio ref={audioARef} hidden />
|
||||
<audio ref={audioBRef} hidden />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -32,7 +32,7 @@ const NAV_COMMANDS: CommandItem[] = [
|
||||
{ id: 'nav-artists', label: 'Artists', icon: Users, action: () => {}, keywords: ['artists', 'bands'] },
|
||||
{ id: 'nav-genres', label: 'Genres', icon: Tag, action: () => {}, keywords: ['genres', 'tags', 'categories'] },
|
||||
{ id: 'nav-vibe', label: 'Vibe', description: 'Endless recommendations', icon: Zap, action: () => {}, keywords: ['vibe', 'recommendations', 'radio'] },
|
||||
{ id: 'nav-discover', label: 'Discover', description: 'Browse by genre', icon: Compass, action: () => {}, keywords: ['discover', 'explore'] },
|
||||
{ id: 'nav-recommendations', label: 'Found', description: 'Discovered tracks and their fate', icon: Compass, action: () => {}, keywords: ['found', 'discovery', 'recommendations', 'probation', 'new releases'] },
|
||||
{ id: 'nav-quarantine', label: 'Quarantine', icon: ShieldAlert, action: () => {}, keywords: ['quarantine', 'disliked', 'trash'] },
|
||||
{ id: 'nav-jobs', label: 'Jobs', description: 'Background tasks', icon: Terminal, action: () => {}, keywords: ['jobs', 'tasks', 'queue'] },
|
||||
{ id: 'nav-settings', label: 'Settings', icon: Settings, action: () => {}, keywords: ['settings', 'preferences', 'config'] },
|
||||
@@ -63,7 +63,7 @@ export function CommandPalette({ open, onClose }: CommandPaletteProps) {
|
||||
'nav-artists': '/artists',
|
||||
'nav-genres': '/genres',
|
||||
'nav-vibe': '/vibe',
|
||||
'nav-discover': '/discover',
|
||||
'nav-recommendations': '/recommendations',
|
||||
'nav-quarantine': '/quarantine',
|
||||
'nav-jobs': '/jobs',
|
||||
'nav-settings': '/settings',
|
||||
@@ -140,7 +140,9 @@ export function CommandPalette({ open, onClose }: CommandPaletteProps) {
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{/* Dialog */}
|
||||
<div className="fixed left-1/2 top-[15vh] z-50 w-full max-w-lg -translate-x-1/2 animate-rise">
|
||||
{/* Centred with margins, not a translate: animate-rise sets its own
|
||||
transform and would drop a -translate-x-1/2 on the same element. */}
|
||||
<div className="fixed inset-x-4 top-[15vh] z-50 mx-auto max-w-lg animate-rise">
|
||||
<div className="overflow-hidden rounded-lg border border-border bg-bg1 shadow-2xl shadow-black/60">
|
||||
{/* Search input */}
|
||||
<div className="flex items-center gap-3 border-b border-border px-4 py-3">
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Laptop, MonitorSpeaker, Smartphone } from 'lucide-react';
|
||||
import { usePlaybackSyncContext } from './PlaybackSyncProvider';
|
||||
|
||||
/**
|
||||
* Device menu. Picking a device moves the audio there: the chosen browser
|
||||
* resumes the same track at the same position, and the one that had it stops.
|
||||
*/
|
||||
export function DevicePicker() {
|
||||
const { devices, deviceId, isOwner, hasRemoteOwner, transferTo } = usePlaybackSyncContext();
|
||||
const [open, setOpen] = useState(false);
|
||||
const wrapper = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onPointerDown = (event: MouseEvent) => {
|
||||
if (!wrapper.current?.contains(event.target as Node)) setOpen(false);
|
||||
};
|
||||
document.addEventListener('mousedown', onPointerDown);
|
||||
return () => document.removeEventListener('mousedown', onPointerDown);
|
||||
}, [open]);
|
||||
|
||||
const online = devices.filter((device) => device.online || device.id === deviceId);
|
||||
// Nothing to switch between, so the control would only take up room.
|
||||
if (online.length < 2 && !hasRemoteOwner) return null;
|
||||
|
||||
const owner = devices.find((device) => device.isOwner) ?? null;
|
||||
|
||||
return (
|
||||
<div className="relative" ref={wrapper}>
|
||||
<button
|
||||
onClick={() => setOpen((value) => !value)}
|
||||
className={`rounded-md p-2 transition-colors ${
|
||||
hasRemoteOwner ? 'bg-accent/20 text-accent' : 'text-muted hover:bg-surface0 hover:text-text'
|
||||
}`}
|
||||
aria-label="Playback device"
|
||||
aria-expanded={open}
|
||||
title={hasRemoteOwner && owner ? `Playing on ${owner.name}` : 'Playing on this device'}
|
||||
>
|
||||
<MonitorSpeaker size={18} />
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div
|
||||
role="menu"
|
||||
className="absolute bottom-full right-0 z-50 mb-2 w-60 overflow-hidden rounded-lg border border-border bg-bg1 shadow-2xl shadow-black/60"
|
||||
>
|
||||
<div className="border-b border-border px-3 py-2 text-xs font-semibold uppercase tracking-wide text-muted">
|
||||
Play on
|
||||
</div>
|
||||
{online.map((device) => {
|
||||
const isThis = device.id === deviceId;
|
||||
return (
|
||||
<button
|
||||
key={device.id}
|
||||
role="menuitem"
|
||||
onClick={() => {
|
||||
setOpen(false);
|
||||
if (!device.isOwner) void transferTo(device.id);
|
||||
}}
|
||||
className={`flex w-full items-center gap-3 px-3 py-2.5 text-left text-sm transition-colors hover:bg-surface0 ${
|
||||
device.isOwner ? 'text-accent' : 'text-text'
|
||||
}`}
|
||||
>
|
||||
{/Android|iPhone|iPad/i.test(device.name) ? <Smartphone size={16} /> : <Laptop size={16} />}
|
||||
<span className="min-w-0 flex-1 truncate">
|
||||
{device.name}
|
||||
{isThis && <span className="text-muted"> · this device</span>}
|
||||
</span>
|
||||
{device.isOwner && <span className="text-xs text-accent">playing</span>}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{!isOwner && !hasRemoteOwner && (
|
||||
<div className="border-t border-border px-3 py-2 text-xs text-muted">
|
||||
Press play to take over the session.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,169 +0,0 @@
|
||||
import { X, Play } from 'lucide-react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Link } from '@tanstack/react-router';
|
||||
import type { AlbumWithTracks, ArtistWithAlbums } from '../types';
|
||||
import { albumService } from '../services/albumService';
|
||||
import { artistService } from '../services/artistService';
|
||||
import { Artwork } from './Artwork';
|
||||
import { Button } from './ethos/Button';
|
||||
import { TrackRow } from './TrackRow';
|
||||
import { usePlaybackStore } from '../store/usePlaybackStore';
|
||||
|
||||
type InspectorMode = 'album' | 'artist' | 'track';
|
||||
|
||||
interface InspectorProps {
|
||||
mode: InspectorMode;
|
||||
id: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
function AlbumInspector({ id, onClose }: { id: string; onClose: () => void }) {
|
||||
const { setQueue, playTrack } = usePlaybackStore();
|
||||
const { data, isLoading } = useQuery<AlbumWithTracks>({
|
||||
queryKey: ['album', id],
|
||||
queryFn: () => albumService.getAlbum(id),
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="p-4 space-y-3">
|
||||
<div className="skeleton h-40 w-full rounded" />
|
||||
<div className="skeleton h-4 w-2/3" />
|
||||
<div className="skeleton h-3 w-1/3" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (!data) return null;
|
||||
|
||||
const tracks = data.tracks ?? [];
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-4 py-2.5 border-b border-border">
|
||||
<span className="text-xs font-semibold uppercase tracking-wider text-muted">Album</span>
|
||||
<button onClick={onClose} className="text-muted hover:text-text p-0.5 rounded">
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="overflow-y-auto flex-1">
|
||||
{/* Artwork + meta */}
|
||||
<div className="p-4 space-y-3">
|
||||
<div className="w-full aspect-square rounded-md overflow-hidden">
|
||||
<Artwork seed={data.title} src={data.artwork_id} className="w-full h-full" rounded="md" eager />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold text-text truncate">{data.title}</h2>
|
||||
<p className="text-xs text-secondary">{data.artist_name || 'Unknown artist'}{data.year ? ` · ${data.year}` : ''}</p>
|
||||
<p className="text-xs text-muted mt-0.5">{tracks.length} tracks</p>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
icon={<Play size={14} fill="currentColor" />}
|
||||
onClick={() => { if (tracks.length) { setQueue(tracks); playTrack(tracks[0]); } }}
|
||||
disabled={!tracks.length}
|
||||
className="w-full"
|
||||
>
|
||||
Play album
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Track list */}
|
||||
<div className="border-t border-border">
|
||||
<div className="px-4 py-2 text-[10px] font-semibold uppercase tracking-wider text-muted">Tracks</div>
|
||||
<div className="space-y-0.5 px-2 pb-3">
|
||||
{tracks.map((t, i) => (
|
||||
<TrackRow key={t.id} track={t} queue={tracks} index={i} showActions={false} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ArtistInspector({ id, onClose }: { id: string; onClose: () => void }) {
|
||||
const { data, isLoading } = useQuery<ArtistWithAlbums>({
|
||||
queryKey: ['artist', id],
|
||||
queryFn: () => artistService.getArtist(id),
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="p-4 space-y-3">
|
||||
<div className="skeleton h-32 w-32 rounded-full mx-auto" />
|
||||
<div className="skeleton h-4 w-1/2 mx-auto" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (!data) return null;
|
||||
|
||||
const albums = data.albums ?? [];
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="flex items-center justify-between px-4 py-2.5 border-b border-border">
|
||||
<span className="text-xs font-semibold uppercase tracking-wider text-muted">Artist</span>
|
||||
<button onClick={onClose} className="text-muted hover:text-text p-0.5 rounded">
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="overflow-y-auto flex-1">
|
||||
<div className="p-4 space-y-3 text-center">
|
||||
<div className="w-24 h-24 rounded-full overflow-hidden mx-auto ring-2 ring-border">
|
||||
<Artwork seed={data.name} src={data.image_path} className="w-full h-full" rounded="full" eager />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold text-text">{data.name}</h2>
|
||||
<p className="text-xs text-muted">{albums.length} albums</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{albums.length > 0 && (
|
||||
<div className="border-t border-border">
|
||||
<div className="px-4 py-2 text-[10px] font-semibold uppercase tracking-wider text-muted">Albums</div>
|
||||
<div className="grid grid-cols-3 gap-2 p-2">
|
||||
{albums.map((album) => (
|
||||
<Link
|
||||
key={album.id}
|
||||
to="/albums/$albumId"
|
||||
params={{ albumId: album.id }}
|
||||
className="flex flex-col gap-1 rounded-md p-1.5 hover:bg-surface0 transition-colors"
|
||||
>
|
||||
<div className="aspect-square rounded-sm overflow-hidden">
|
||||
<Artwork seed={album.title} src={album.artwork_id} className="w-full h-full" />
|
||||
</div>
|
||||
<span className="text-xs text-text truncate">{album.title}</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export type { InspectorMode };
|
||||
|
||||
/**
|
||||
* Inspector panel — right-side detail view for albums, artists, and tracks.
|
||||
* Replaces full-page navigations with a slide-in panel per Ethos conventions.
|
||||
*/
|
||||
export function Inspector({ mode, id, onClose }: InspectorProps) {
|
||||
return (
|
||||
<aside className="w-80 flex flex-col border-l border-border bg-bg1 overflow-hidden shrink-0 animate-slide-in">
|
||||
{mode === 'album' && <AlbumInspector id={id} onClose={onClose} />}
|
||||
{mode === 'artist' && <ArtistInspector id={id} onClose={onClose} />}
|
||||
{mode === 'track' && (
|
||||
<div className="p-4 text-sm text-muted text-center py-10">
|
||||
Track inspector coming soon
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +1,11 @@
|
||||
import { Link } from '@tanstack/react-router';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
import {
|
||||
Home, Music, Disc3, Users, Tag, Compass,
|
||||
Terminal, ShieldAlert,
|
||||
Zap,
|
||||
Settings, Sparkles,
|
||||
Settings, Sparkles, X,
|
||||
} from 'lucide-react';
|
||||
|
||||
interface NavItem {
|
||||
@@ -34,7 +35,7 @@ const NAV_GROUPS: NavGroup[] = [
|
||||
label: 'AI',
|
||||
items: [
|
||||
{ to: '/vibe', icon: Zap, label: 'Vibe' },
|
||||
{ to: '/discover', icon: Compass, label: 'Discover' },
|
||||
{ to: '/recommendations', icon: Compass, label: 'Found' },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -59,15 +60,86 @@ const active =
|
||||
'bg-accent/10 text-accent font-medium ' +
|
||||
"before:content-[''] before:absolute before:left-0 before:top-1 before:bottom-1 before:w-0.5 before:rounded-full before:bg-accent";
|
||||
|
||||
export function NavRail() {
|
||||
interface NavRailProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function NavRail({ open, onClose }: NavRailProps) {
|
||||
const [desktop, setDesktop] = useState(false);
|
||||
const drawerRef = useRef<HTMLElement>(null);
|
||||
const closeRef = useRef<HTMLButtonElement>(null);
|
||||
useEffect(() => {
|
||||
const query = window.matchMedia('(min-width: 1024px)');
|
||||
const update = () => setDesktop(query.matches);
|
||||
update();
|
||||
query.addEventListener('change', update);
|
||||
return () => query.removeEventListener('change', update);
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
if (open && !desktop) closeRef.current?.focus();
|
||||
}, [open, desktop]);
|
||||
useEffect(() => {
|
||||
if (!open || desktop || !drawerRef.current) return;
|
||||
const previous = document.activeElement as HTMLElement | null;
|
||||
const trap = (event: KeyboardEvent) => {
|
||||
if (event.key !== 'Tab' || !drawerRef.current) return;
|
||||
const focusable = [...drawerRef.current.querySelectorAll<HTMLElement>(
|
||||
'a[href], button:not([disabled]), input:not([disabled]), [tabindex]:not([tabindex="-1"])'
|
||||
)].filter((element) => !element.hasAttribute('hidden'));
|
||||
if (focusable.length === 0) return;
|
||||
const first = focusable[0];
|
||||
const last = focusable[focusable.length - 1];
|
||||
if (event.shiftKey && document.activeElement === first) {
|
||||
event.preventDefault(); last.focus();
|
||||
} else if (!event.shiftKey && document.activeElement === last) {
|
||||
event.preventDefault(); first.focus();
|
||||
}
|
||||
};
|
||||
document.addEventListener('keydown', trap);
|
||||
return () => {
|
||||
document.removeEventListener('keydown', trap);
|
||||
previous?.focus();
|
||||
};
|
||||
}, [open, desktop]);
|
||||
|
||||
// Do not leave an off-screen mobile drawer in the tab order. The desktop
|
||||
// rail remains mounted independently of the drawer state.
|
||||
if (!desktop && !open) return null;
|
||||
return (
|
||||
<aside className="w-48 flex flex-col bg-bg1 border-r border-border shrink-0 overflow-y-auto">
|
||||
<>
|
||||
{open && (
|
||||
<button
|
||||
type="button"
|
||||
className="absolute inset-0 z-30 bg-black/60 lg:hidden"
|
||||
aria-label="Close navigation"
|
||||
onClick={onClose}
|
||||
/>
|
||||
)}
|
||||
<aside
|
||||
ref={drawerRef}
|
||||
aria-label="Main navigation"
|
||||
aria-modal={!desktop || undefined}
|
||||
role={desktop ? undefined : 'dialog'}
|
||||
className={`absolute inset-y-0 left-0 z-40 flex w-72 max-w-[85vw] flex-col overflow-y-auto border-r border-border bg-bg1 shadow-2xl transition-transform duration-200 lg:relative lg:z-auto lg:w-48 lg:max-w-none lg:translate-x-0 lg:shadow-none ${
|
||||
open ? 'translate-x-0' : '-translate-x-full'
|
||||
}`}
|
||||
>
|
||||
{/* App branding */}
|
||||
<div className="flex items-center gap-2 px-3 py-2.5 border-b border-border">
|
||||
<span className="flex h-6 w-6 items-center justify-center rounded-md bg-accent text-on-accent">
|
||||
<Sparkles size={14} />
|
||||
</span>
|
||||
<span className="text-sm font-semibold text-text tracking-tight">muzick</span>
|
||||
<button
|
||||
ref={closeRef}
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="ml-auto rounded-md p-2 text-muted hover:bg-surface0 hover:text-text lg:hidden"
|
||||
aria-label="Close navigation"
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Navigation */}
|
||||
@@ -85,6 +157,7 @@ export function NavRail() {
|
||||
activeOptions={{ exact: exact ?? false }}
|
||||
activeProps={{ className: `${base} ${active}` }}
|
||||
inactiveProps={{ className: `${base} ${inactive}` }}
|
||||
onClick={onClose}
|
||||
>
|
||||
<Icon size={15} className="flex-none transition-transform group-hover:scale-110" />
|
||||
<span className="truncate">{label}</span>
|
||||
@@ -100,6 +173,7 @@ export function NavRail() {
|
||||
<div className="px-3 py-2 text-[10px] text-disabled border-t border-border">
|
||||
muzick · v0.1
|
||||
</div>
|
||||
</aside>
|
||||
</aside>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { usePlaybackStore } from '../store/usePlaybackStore';
|
||||
import { NowPlayingPanel } from './NowPlayingPanel';
|
||||
|
||||
describe('NowPlayingPanel', () => {
|
||||
it('announces itself as a modal and keeps keyboard focus inside', async () => {
|
||||
usePlaybackStore.setState({ currentTrack: null, queue: [], currentIndex: -1, isPlaying: false });
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<QueryClientProvider client={new QueryClient({ defaultOptions: { queries: { retry: false } } })}>
|
||||
<NowPlayingPanel onClose={() => undefined} />
|
||||
</QueryClientProvider>
|
||||
);
|
||||
|
||||
const dialog = screen.getByRole('dialog', { name: 'Now playing queue' });
|
||||
const close = screen.getByRole('button', { name: 'Close now playing' });
|
||||
expect(dialog).toHaveAttribute('aria-modal', 'true');
|
||||
expect(close).toHaveFocus();
|
||||
await user.tab({ shift: true });
|
||||
expect(screen.getByRole('button', { name: 'Next' })).toHaveFocus();
|
||||
await user.tab();
|
||||
expect(close).toHaveFocus();
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
import { X, Play, Pause, SkipBack, SkipForward, Disc3 } from 'lucide-react';
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Link } from '@tanstack/react-router';
|
||||
import { usePlaybackStore } from '../store/usePlaybackStore';
|
||||
@@ -6,9 +7,36 @@ import { Artwork } from './Artwork';
|
||||
import { ArtistLinks } from './ArtistLinks';
|
||||
import { TrackRow, formatDuration } from './TrackRow';
|
||||
import { albumService } from '../services/albumService';
|
||||
import { useTransport } from '../hooks/useTransport';
|
||||
|
||||
export function NowPlayingPanel({ onClose }: { onClose: () => void }) {
|
||||
const { currentTrack, queue, isPlaying, position, duration, play, pause, next, prev, setPosition } = usePlaybackStore();
|
||||
const panelRef = useRef<HTMLElement>(null);
|
||||
const closeRef = useRef<HTMLButtonElement>(null);
|
||||
useEffect(() => { closeRef.current?.focus(); }, []);
|
||||
useEffect(() => {
|
||||
const previous = document.activeElement as HTMLElement | null;
|
||||
const trap = (event: KeyboardEvent) => {
|
||||
if (event.key !== 'Tab' || !panelRef.current) return;
|
||||
const focusable = [...panelRef.current.querySelectorAll<HTMLElement>(
|
||||
'a[href], button:not([disabled]), input:not([disabled]), [tabindex]:not([tabindex="-1"])'
|
||||
)].filter((element) => !element.hasAttribute('hidden'));
|
||||
if (focusable.length === 0) return;
|
||||
const first = focusable[0];
|
||||
const last = focusable[focusable.length - 1];
|
||||
if (event.shiftKey && document.activeElement === first) {
|
||||
event.preventDefault(); last.focus();
|
||||
} else if (!event.shiftKey && document.activeElement === last) {
|
||||
event.preventDefault(); first.focus();
|
||||
}
|
||||
};
|
||||
document.addEventListener('keydown', trap);
|
||||
return () => {
|
||||
document.removeEventListener('keydown', trap);
|
||||
previous?.focus();
|
||||
};
|
||||
}, []);
|
||||
const { currentTrack, queue, position, duration } = usePlaybackStore();
|
||||
const transport = useTransport();
|
||||
|
||||
const currentIdx = currentTrack ? queue.findIndex((t) => t.id === currentTrack.id) : -1;
|
||||
const upNext = currentIdx >= 0 ? queue.slice(currentIdx + 1) : queue;
|
||||
@@ -22,25 +50,29 @@ export function NowPlayingPanel({ onClose }: { onClose: () => void }) {
|
||||
const artwork = albumQ.data?.artwork_id ?? currentTrack?.artwork_id ?? null;
|
||||
|
||||
return (
|
||||
<aside className="w-96 flex flex-col border-l border-border/70 bg-bg1/80 backdrop-blur-sm overflow-hidden shrink-0">
|
||||
<aside ref={panelRef} role="dialog" aria-modal="true" aria-label="Now playing queue" className="absolute inset-0 z-30 flex w-full flex-col overflow-hidden border-l border-border/70 bg-bg1 backdrop-blur-sm animate-slide-in sm:left-auto sm:w-96 lg:relative lg:z-auto lg:bg-bg1/80">
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-border/70">
|
||||
<span className="text-sm font-semibold text-text">Now Playing</span>
|
||||
<button onClick={onClose} className="text-muted hover:text-text p-1 rounded">
|
||||
<button ref={closeRef} onClick={onClose} aria-label="Close now playing" className="rounded-md p-2 text-muted hover:text-text hover:bg-surface1">
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="p-4 space-y-4">
|
||||
{/* The artwork below is capped against viewport height, not just width.
|
||||
At full width on a phone the square alone is taller than the space
|
||||
between the top bar and the transport, which pushed Up Next — the
|
||||
reason the panel opens — entirely off the screen. */}
|
||||
<div className="p-3 space-y-3 sm:p-4 sm:space-y-4">
|
||||
{currentTrack?.album_id ? (
|
||||
<Link to="/albums/$albumId" params={{ albumId: currentTrack.album_id }}
|
||||
className="group block aspect-square rounded-xl overflow-hidden relative shadow-lg shadow-black/40" title="Go to album">
|
||||
className="group mx-auto block aspect-square w-full max-w-[min(100%,34vh)] rounded-xl overflow-hidden relative shadow-lg shadow-black/40" title="Go to album">
|
||||
<Artwork seed={`${currentTrack.title} ${currentTrack.artist}`} src={artwork} className="w-full h-full transition-transform group-hover:scale-105" rounded="xl" />
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-black/0 group-hover:bg-black/40 transition-colors">
|
||||
<Disc3 size={28} className="text-on-accent opacity-0 group-hover:opacity-100 transition-opacity" />
|
||||
</div>
|
||||
</Link>
|
||||
) : (
|
||||
<div className="aspect-square rounded-xl overflow-hidden shadow-lg shadow-black/40">
|
||||
<div className="mx-auto aspect-square w-full max-w-[min(100%,34vh)] rounded-xl overflow-hidden shadow-lg shadow-black/40">
|
||||
<Artwork seed={currentTrack ? `${currentTrack.title} ${currentTrack.artist}` : 'empty'} src={artwork} className="w-full h-full" rounded="xl" />
|
||||
</div>
|
||||
)}
|
||||
@@ -60,11 +92,11 @@ export function NowPlayingPanel({ onClose }: { onClose: () => void }) {
|
||||
<div className="text-center text-sm text-muted italic">No track playing</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-1">
|
||||
<div className="track-list">
|
||||
<input
|
||||
type="range" min={0} max={Math.max(duration, 0.1)} step={0.1}
|
||||
value={Math.min(position, duration || 0)}
|
||||
onChange={(e) => setPosition(Number(e.target.value))}
|
||||
onChange={(e) => transport.seek(Number(e.target.value))}
|
||||
disabled={!currentTrack || duration <= 0}
|
||||
className="w-full cursor-pointer"
|
||||
/>
|
||||
@@ -74,16 +106,17 @@ export function NowPlayingPanel({ onClose }: { onClose: () => void }) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-center gap-6">
|
||||
<button onClick={prev} className="text-muted hover:text-text"><SkipBack size={20} /></button>
|
||||
<div className="flex items-center justify-center gap-4 sm:gap-6">
|
||||
<button onClick={transport.prev} aria-label="Previous" className="rounded-md p-2 text-muted hover:text-text hover:bg-surface0"><SkipBack size={20} /></button>
|
||||
<button
|
||||
onClick={() => isPlaying ? pause() : play()}
|
||||
onClick={transport.toggle}
|
||||
aria-label={transport.playing ? 'Pause' : 'Play'}
|
||||
disabled={!currentTrack}
|
||||
className="transport-btn"
|
||||
>
|
||||
{isPlaying ? <Pause size={18} fill="currentColor" /> : <Play size={18} fill="currentColor" />}
|
||||
{transport.playing ? <Pause size={18} fill="currentColor" /> : <Play size={18} fill="currentColor" />}
|
||||
</button>
|
||||
<button onClick={next} className="text-muted hover:text-text"><SkipForward size={20} /></button>
|
||||
<button onClick={transport.next} aria-label="Next" className="rounded-md p-2 text-muted hover:text-text hover:bg-surface0"><SkipForward size={20} /></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -4,15 +4,18 @@ type ContainerWidth = 'sm' | 'md' | 'lg' | 'full';
|
||||
|
||||
interface PageContainerProps {
|
||||
children: ReactNode;
|
||||
/** Controls max-width. sm → max-w-2xl, md → max-w-3xl (default), lg → max-w-5xl, full → no constraint. */
|
||||
/** Controls max-width. sm → reading width (prose, forms), md → default, lg → widest grid, full → no constraint. */
|
||||
width?: ContainerWidth;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
// Measured on a 1440px viewport: the old md (768px) left ~480px of the main
|
||||
// area empty and pinned every grid to 4–5 columns. These track the content,
|
||||
// not an arbitrary prose measure — only `sm` stays narrow, for forms and prose.
|
||||
const WIDTH_CLASSES: Record<ContainerWidth, string> = {
|
||||
sm: 'max-w-2xl',
|
||||
md: 'max-w-3xl',
|
||||
lg: 'max-w-5xl',
|
||||
sm: 'max-w-3xl',
|
||||
md: 'max-w-[1240px]',
|
||||
lg: 'max-w-[1400px]',
|
||||
full: '',
|
||||
};
|
||||
|
||||
@@ -24,7 +27,7 @@ const WIDTH_CLASSES: Record<ContainerWidth, string> = {
|
||||
*/
|
||||
export function PageContainer({ children, width = 'md', className = '' }: PageContainerProps) {
|
||||
return (
|
||||
<div className={`mx-auto space-y-6 ${WIDTH_CLASSES[width]} ${className}`}>
|
||||
<div className={`mx-auto space-y-4 ${WIDTH_CLASSES[width]} ${className}`}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,35 +1,35 @@
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
|
||||
interface PageHeaderProps {
|
||||
icon?: LucideIcon;
|
||||
title: string;
|
||||
/** Human-written line under the title (sans). Keep it short or leave it out. */
|
||||
subtitle?: string;
|
||||
/** Machine values — counts, sizes, durations. Rendered mono, per Ethos law 1. */
|
||||
meta?: string;
|
||||
/** Optional right-aligned actions (buttons, toggles, etc.). */
|
||||
actions?: React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Consistent page heading: a gradient title with an optional accent icon chip,
|
||||
* subtitle, and right-aligned action slot. Used across the library pages so
|
||||
* every screen opens the same way.
|
||||
* The one page heading for every screen (Ethos law 3 — shared shell). Editorial
|
||||
* sans title, an optional human subtitle, and a mono `meta` line for whatever
|
||||
* the machine knows: counts, page position, queue depth.
|
||||
*
|
||||
* Deliberately has no icon chip and no gradient fill. The nav rail and the
|
||||
* breadcrumb already name the page; a glowing accent tile on every screen made
|
||||
* the accent read as decoration rather than signal.
|
||||
*/
|
||||
export function PageHeader({ icon: Icon, title, subtitle, actions }: PageHeaderProps) {
|
||||
export function PageHeader({ title, subtitle, meta, actions }: PageHeaderProps) {
|
||||
return (
|
||||
<div className="flex items-end justify-between gap-4 animate-rise">
|
||||
<div className="flex items-center gap-4 min-w-0">
|
||||
{Icon && (
|
||||
<div className="flex h-12 w-12 flex-none items-center justify-center rounded-2xl bg-accent/15 text-accent ring-1 ring-accent/25 shadow-lg shadow-accent/10">
|
||||
<Icon size={24} />
|
||||
</div>
|
||||
<div className="flex flex-wrap items-end justify-between gap-3 border-b border-border pb-3 animate-rise">
|
||||
<div className="min-w-0">
|
||||
<h1 className="truncate text-2xl font-semibold tracking-tight text-text sm:text-3xl">
|
||||
{title}
|
||||
</h1>
|
||||
{subtitle && <p className="mt-1 text-sm text-secondary">{subtitle}</p>}
|
||||
{meta && (
|
||||
<p className="mt-1 truncate font-mono text-xs tabular-nums text-muted">{meta}</p>
|
||||
)}
|
||||
<div className="min-w-0">
|
||||
<h1 className="text-gradient truncate text-3xl font-extrabold tracking-tight sm:text-4xl">
|
||||
{title}
|
||||
</h1>
|
||||
{subtitle && <p className="mt-1 truncate text-sm text-muted">{subtitle}</p>}
|
||||
</div>
|
||||
</div>
|
||||
{actions && <div className="flex flex-none items-center gap-2">{actions}</div>}
|
||||
{actions && <div className="flex flex-none flex-wrap items-center gap-2">{actions}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ interface PanelHeaderProps {
|
||||
className?: string;
|
||||
/**
|
||||
* Title styling intent:
|
||||
* - `'panel'` (default) — text-xs uppercase muted (Inspector, LyricsOverlay)
|
||||
* - `'panel'` (default) — text-xs uppercase muted (side panels, LyricsOverlay)
|
||||
* - `'heading'` — text-sm semibold text-text (NowPlayingPanel)
|
||||
*/
|
||||
intent?: 'panel' | 'heading';
|
||||
@@ -19,7 +19,7 @@ const TITLE_CLASSES = {
|
||||
|
||||
/**
|
||||
* Overlay/panel header bar — title label with an optional close button.
|
||||
* Standardizes the pattern that was hand-rolled in Inspector (×2),
|
||||
* Standardizes the pattern used by side panels,
|
||||
* NowPlayingPanel, LyricsOverlay, CommandPalette, and more.
|
||||
*/
|
||||
export function PanelHeader({ title, onClose, className = '', intent = 'panel' }: PanelHeaderProps) {
|
||||
|
||||
@@ -5,6 +5,8 @@ import { useDislikeTrack } from '../hooks/useDislikeTrack';
|
||||
import { Artwork } from './Artwork';
|
||||
import { ArtistLinks } from './ArtistLinks';
|
||||
import { formatDuration } from './TrackRow';
|
||||
import { DevicePicker } from './DevicePicker';
|
||||
import { useTransport } from '../hooks/useTransport';
|
||||
|
||||
interface PlaybackBarProps {
|
||||
queueOpen: boolean;
|
||||
@@ -14,8 +16,9 @@ interface PlaybackBarProps {
|
||||
}
|
||||
|
||||
export function PlaybackBar({ queueOpen, lyricsOpen, onToggleQueue, onToggleLyrics }: PlaybackBarProps) {
|
||||
const { currentTrack, isPlaying, position, duration, volume, shuffle, repeat, play, pause, next, prev, setPosition, setVolume, toggleShuffle, cycleRepeat } = usePlaybackStore();
|
||||
const { currentTrack, position, duration, volume, shuffle, repeat, setVolume, toggleShuffle, cycleRepeat } = usePlaybackStore();
|
||||
const dislikeTrack = useDislikeTrack();
|
||||
const transport = useTransport();
|
||||
|
||||
const handleDislike = () => {
|
||||
if (!currentTrack) return;
|
||||
@@ -23,9 +26,10 @@ export function PlaybackBar({ queueOpen, lyricsOpen, onToggleQueue, onToggleLyri
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="glass h-20 border-t border-border/70 px-4 flex items-center gap-4 shrink-0 z-20">
|
||||
<div className="glass safe-x safe-b border-t border-border/70 pt-2 shrink-0 z-20 [--safe-b-base:16px] [--safe-x-base:24px] sm:h-20 sm:pt-0 sm:[--safe-b-base:0px] sm:[--safe-x-base:32px]">
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1.5 sm:flex-nowrap sm:gap-4">
|
||||
{/* Track info */}
|
||||
<div className="flex items-center gap-3 w-64 min-w-0 shrink-0">
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2.5 sm:w-64 sm:flex-none sm:gap-3">
|
||||
{currentTrack ? (
|
||||
<>
|
||||
{currentTrack.album_id ? (
|
||||
@@ -52,78 +56,84 @@ export function PlaybackBar({ queueOpen, lyricsOpen, onToggleQueue, onToggleLyri
|
||||
<button
|
||||
onClick={handleDislike}
|
||||
title="Dislike (sends to quarantine)"
|
||||
className="flex-none rounded-md p-1.5 text-muted hover:bg-surface1 hover:text-red-400 transition-colors"
|
||||
className="flex-none rounded-md p-2 text-muted hover:bg-surface1 hover:text-red-400 transition-colors"
|
||||
aria-label="Dislike — move to quarantine"
|
||||
>
|
||||
<ThumbsDown size={16} />
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<div className="text-sm text-muted italic">Nothing playing</div>
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="h-12 w-12 flex-none rounded-lg border border-border bg-bg2" aria-hidden />
|
||||
{/* Reached only before anything has ever played in this browser —
|
||||
a returning tab restores its last track instead. */}
|
||||
<div className="text-sm text-muted">Pick a track to start</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Controls + scrubber */}
|
||||
<div className="flex-1 flex flex-col items-center gap-1">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="order-3 flex basis-full flex-col items-center gap-1 sm:order-none sm:flex-1 sm:basis-auto">
|
||||
<div className="flex items-center gap-2 sm:gap-3">
|
||||
<button
|
||||
onClick={toggleShuffle}
|
||||
className={`p-1.5 rounded-md transition-colors ${shuffle ? 'text-accent' : 'text-muted hover:text-text'}`}
|
||||
className={`rounded-md p-2 transition-colors ${shuffle ? 'text-accent' : 'text-muted hover:text-text'}`}
|
||||
aria-label={shuffle ? 'Disable shuffle' : 'Enable shuffle'}
|
||||
title={shuffle ? 'Shuffle on' : 'Shuffle off'}
|
||||
>
|
||||
<Shuffle size={18} />
|
||||
</button>
|
||||
<button onClick={prev} className="text-muted hover:text-text" aria-label="Previous">
|
||||
<button onClick={transport.prev} className="rounded-md p-2 text-muted hover:text-text hover:bg-surface0" aria-label="Previous">
|
||||
<SkipBack size={20} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => isPlaying ? pause() : play()}
|
||||
onClick={transport.toggle}
|
||||
disabled={!currentTrack}
|
||||
className="transport-btn"
|
||||
aria-label={isPlaying ? 'Pause' : 'Play'}
|
||||
aria-label={transport.playing ? 'Pause' : 'Play'}
|
||||
>
|
||||
{isPlaying ? <Pause size={18} fill="currentColor" /> : <Play size={18} fill="currentColor" />}
|
||||
{transport.playing ? <Pause size={18} fill="currentColor" /> : <Play size={18} fill="currentColor" />}
|
||||
</button>
|
||||
<button onClick={next} className="text-muted hover:text-text" aria-label="Next">
|
||||
<button onClick={transport.next} className="rounded-md p-2 text-muted hover:text-text hover:bg-surface0" aria-label="Next">
|
||||
<SkipForward size={20} />
|
||||
</button>
|
||||
<button
|
||||
onClick={cycleRepeat}
|
||||
className={`p-1.5 rounded-md transition-colors ${repeat !== 'none' ? 'text-accent' : 'text-muted hover:text-text'}`}
|
||||
className={`rounded-md p-2 transition-colors ${repeat !== 'none' ? 'text-accent' : 'text-muted hover:text-text'}`}
|
||||
aria-label={`Repeat: ${repeat}`}
|
||||
title={repeat === 'none' ? 'Repeat off' : repeat === 'all' ? 'Repeat all' : 'Repeat one'}
|
||||
>
|
||||
{repeat === 'one' ? <Repeat1 size={18} /> : <Repeat size={18} />}
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex w-full max-w-lg items-center gap-2">
|
||||
<span className="text-xs text-muted w-9 text-right tabular-nums">{formatDuration(position)}</span>
|
||||
<div className="flex w-full max-w-xl items-center gap-2">
|
||||
<span className="w-10 flex-none text-right font-mono text-xs tabular-nums text-machine">{formatDuration(position)}</span>
|
||||
<input
|
||||
type="range" min={0} max={Math.max(duration, 0.1)} step={0.1}
|
||||
value={Math.min(position, duration || 0)}
|
||||
onChange={(e) => setPosition(Number(e.target.value))}
|
||||
onChange={(e) => transport.seek(Number(e.target.value))}
|
||||
disabled={!currentTrack || duration <= 0}
|
||||
className="flex-1 h-1 cursor-pointer"
|
||||
aria-label="Seek"
|
||||
/>
|
||||
<span className="text-xs text-muted w-9 tabular-nums">{formatDuration(duration)}</span>
|
||||
<span className="w-10 flex-none font-mono text-xs tabular-nums text-machine">{formatDuration(duration)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Volume + panel toggle */}
|
||||
<div className="flex items-center gap-3 w-48 justify-end shrink-0">
|
||||
<Volume2 size={18} className="text-muted flex-none" />
|
||||
<div className="order-2 flex items-center gap-1 justify-end shrink-0 sm:order-none sm:w-48 sm:gap-3">
|
||||
<Volume2 size={18} className="hidden text-muted flex-none sm:block" />
|
||||
<input
|
||||
type="range" min={0} max={1} step={0.01} value={volume}
|
||||
onChange={(e) => setVolume(Number(e.target.value))}
|
||||
className="w-20 h-1 cursor-pointer"
|
||||
className="hidden w-20 h-1 cursor-pointer sm:block"
|
||||
aria-label="Volume"
|
||||
/>
|
||||
<DevicePicker />
|
||||
<button
|
||||
onClick={onToggleLyrics}
|
||||
disabled={!currentTrack}
|
||||
className={`p-2 rounded-md transition-colors disabled:opacity-30 ${lyricsOpen ? 'bg-accent/20 text-accent' : 'text-muted hover:text-text'}`}
|
||||
className={`rounded-md p-2 transition-colors disabled:opacity-30 ${lyricsOpen ? 'bg-accent/20 text-accent' : 'text-muted hover:text-text hover:bg-surface0'}`}
|
||||
aria-label="Toggle lyrics"
|
||||
title="Lyrics"
|
||||
>
|
||||
@@ -131,13 +141,14 @@ export function PlaybackBar({ queueOpen, lyricsOpen, onToggleQueue, onToggleLyri
|
||||
</button>
|
||||
<button
|
||||
onClick={onToggleQueue}
|
||||
className={`p-2 rounded-md transition-colors ${queueOpen ? 'bg-accent/20 text-accent' : 'text-muted hover:text-text'}`}
|
||||
className={`rounded-md p-2 transition-colors ${queueOpen ? 'bg-accent/20 text-accent' : 'text-muted hover:text-text hover:bg-surface0'}`}
|
||||
aria-label="Toggle queue panel"
|
||||
title="Up Next"
|
||||
>
|
||||
<ListMusic size={18} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { createContext, useContext, type ReactNode } from 'react';
|
||||
import { usePlaybackSync, type PlaybackSyncApi } from '../hooks/usePlaybackSync';
|
||||
|
||||
/**
|
||||
* One sync session per app, shared by everything that draws a transport
|
||||
* control. Mounting the hook twice would open two streams and register the same
|
||||
* browser as two devices.
|
||||
*/
|
||||
export const PlaybackSyncContext = createContext<PlaybackSyncApi | null>(null);
|
||||
|
||||
export function PlaybackSyncProvider({ children }: { children: ReactNode }) {
|
||||
const sync = usePlaybackSync();
|
||||
return <PlaybackSyncContext.Provider value={sync}>{children}</PlaybackSyncContext.Provider>;
|
||||
}
|
||||
|
||||
export function usePlaybackSyncContext(): PlaybackSyncApi {
|
||||
const value = useContext(PlaybackSyncContext);
|
||||
if (!value) throw new Error('usePlaybackSyncContext must be used inside PlaybackSyncProvider');
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* The same session, or nothing when the caller sits outside the provider — a
|
||||
* test rendering one control, for instance. Callers that only need to know
|
||||
* whether the audio is elsewhere use this and treat absence as "play here".
|
||||
*/
|
||||
export function useOptionalPlaybackSync(): PlaybackSyncApi | null {
|
||||
return useContext(PlaybackSyncContext);
|
||||
}
|
||||
@@ -1,11 +1,13 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Search, X, Command, ChevronRight, Wifi, WifiOff } from 'lucide-react';
|
||||
import { Search, X, Command, ChevronRight, Wifi, WifiOff, Menu } from 'lucide-react';
|
||||
import { useNavigate, useRouterState } from '@tanstack/react-router';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { fetchHealthStatus } from '../services/healthService';
|
||||
|
||||
interface TopBarProps {
|
||||
onToggleCommandPalette: () => void;
|
||||
onToggleNavigation: () => void;
|
||||
navigationOpen: boolean;
|
||||
}
|
||||
|
||||
/** Page title map for breadcrumbs */
|
||||
@@ -15,8 +17,8 @@ const PAGE_TITLES: Record<string, string> = {
|
||||
'/albums': 'Albums',
|
||||
'/artists': 'Artists',
|
||||
'/genres': 'Genres',
|
||||
'/recommendations': 'Found',
|
||||
'/vibe': 'Vibe',
|
||||
'/discover': 'Discover',
|
||||
'/search': 'Search',
|
||||
'/settings': 'Settings',
|
||||
'/quarantine': 'Quarantine',
|
||||
@@ -86,7 +88,7 @@ function ConnectionStatus() {
|
||||
);
|
||||
}
|
||||
|
||||
export function TopBar({ onToggleCommandPalette }: TopBarProps) {
|
||||
export function TopBar({ onToggleCommandPalette, onToggleNavigation, navigationOpen }: TopBarProps) {
|
||||
const navigate = useNavigate();
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const { pathname, urlQuery } = useRouterState({
|
||||
@@ -136,14 +138,23 @@ export function TopBar({ onToggleCommandPalette }: TopBarProps) {
|
||||
};
|
||||
|
||||
return (
|
||||
<header className="glass h-12 border-b border-border flex items-center px-3 gap-3 shrink-0 z-20">
|
||||
<header className="glass h-12 border-b border-border flex items-center px-2.5 gap-2 sm:px-3 sm:gap-3 shrink-0 z-20">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggleNavigation}
|
||||
className="rounded-md p-2 text-muted hover:bg-surface0 hover:text-text lg:hidden"
|
||||
aria-label={navigationOpen ? 'Close navigation' : 'Open navigation'}
|
||||
aria-expanded={navigationOpen}
|
||||
>
|
||||
{navigationOpen ? <X size={18} /> : <Menu size={18} />}
|
||||
</button>
|
||||
{/* Breadcrumbs */}
|
||||
<div className="flex items-center min-w-0 flex-none max-w-[200px]">
|
||||
<div className="hidden sm:flex items-center min-w-0 flex-none max-w-[200px]">
|
||||
<Breadcrumbs pathname={pathname} />
|
||||
</div>
|
||||
|
||||
{/* Universal search */}
|
||||
<form onSubmit={handleSubmit} className="flex-1 max-w-md">
|
||||
<form onSubmit={handleSubmit} className="min-w-0 flex-1 max-w-md">
|
||||
<div className="relative group">
|
||||
<Search size={14} className="absolute left-2.5 top-1/2 -translate-y-1/2 text-muted pointer-events-none transition-colors group-focus-within:text-accent" />
|
||||
<input
|
||||
@@ -152,7 +163,10 @@ export function TopBar({ onToggleCommandPalette }: TopBarProps) {
|
||||
value={q}
|
||||
onChange={(e) => setQ(e.target.value)}
|
||||
placeholder="Search…"
|
||||
className="w-full bg-surface0/70 border border-border rounded-md pl-8 pr-8 py-1.5 text-xs text-text placeholder:text-muted outline-none focus:border-accent focus:bg-surface0 transition-all"
|
||||
/* bg-bg2, not bg-surface0/70: Tailwind cannot alpha-modify these
|
||||
var() colors, so that class emitted nothing and the field fell
|
||||
back to the UA's white — a white pill in a warm dark room. */
|
||||
className="w-full bg-bg2 border border-border rounded-md pl-8 pr-8 py-1.5 text-xs text-text placeholder:text-muted outline-none focus:border-accent focus:bg-surface1 transition-colors"
|
||||
/>
|
||||
{q ? (
|
||||
<button
|
||||
@@ -164,7 +178,7 @@ export function TopBar({ onToggleCommandPalette }: TopBarProps) {
|
||||
<X size={12} />
|
||||
</button>
|
||||
) : (
|
||||
<kbd className="absolute right-2 top-1/2 -translate-y-1/2 hidden sm:flex items-center rounded border border-border bg-bg2 px-1 py-0.5 text-[10px] font-medium text-muted pointer-events-none">
|
||||
<kbd className="absolute right-2 top-1/2 -translate-y-1/2 hidden sm:flex items-center rounded border border-border bg-surface1 px-1 py-0.5 text-[10px] font-medium text-muted pointer-events-none">
|
||||
/
|
||||
</kbd>
|
||||
)}
|
||||
@@ -172,7 +186,7 @@ export function TopBar({ onToggleCommandPalette }: TopBarProps) {
|
||||
</form>
|
||||
|
||||
{/* Right section */}
|
||||
<div className="flex items-center gap-2 flex-none">
|
||||
<div className="flex items-center gap-1.5 sm:gap-2 flex-none">
|
||||
{/* Connection status */}
|
||||
<ConnectionStatus />
|
||||
|
||||
@@ -184,7 +198,7 @@ export function TopBar({ onToggleCommandPalette }: TopBarProps) {
|
||||
>
|
||||
<Command size={12} />
|
||||
<span className="hidden sm:inline">Commands</span>
|
||||
<kbd className="rounded border border-border bg-bg2 px-1 text-[10px] text-muted">
|
||||
<kbd className="hidden md:inline rounded border border-border bg-bg2 px-1 text-[10px] text-muted">
|
||||
Ctrl+K
|
||||
</kbd>
|
||||
</button>
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { Play, Pause, ThumbsDown, Disc3, Sparkles } from 'lucide-react';
|
||||
import { Play, Pause, ThumbsDown, Sparkles } from 'lucide-react';
|
||||
import { Link, useRouter } from '@tanstack/react-router';
|
||||
import type { Track } from '../types';
|
||||
import { usePlaybackStore } from '../store/usePlaybackStore';
|
||||
import { useDislikeTrack } from '../hooks/useDislikeTrack';
|
||||
import { vibeService } from '../services/vibeService';
|
||||
import { startVibeSession } from '../services/vibeSession';
|
||||
import { Artwork } from './Artwork';
|
||||
import { ArtistLinks } from './ArtistLinks';
|
||||
|
||||
@@ -24,9 +24,13 @@ interface TrackRowProps {
|
||||
variant?: TrackRowVariant;
|
||||
/** Show a "Vibe by track" button that starts a vibe session seeded from this track. */
|
||||
showVibe?: boolean;
|
||||
/** Override ordinary queue playback, for contextual actions such as Vibe seed rows. */
|
||||
onSelect?: (track: Track) => void;
|
||||
/** Display-only rows keep their surrounding playback controller authoritative. */
|
||||
playable?: boolean;
|
||||
}
|
||||
|
||||
export function TrackRow({ track, queue, showActions = true, variant = 'default', showVibe = false }: TrackRowProps) {
|
||||
export function TrackRow({ track, queue, showActions = true, variant = 'default', showVibe = false, onSelect, playable = true }: TrackRowProps) {
|
||||
const { setQueue, playTrack, play, pause, currentTrack, isPlaying } = usePlaybackStore();
|
||||
const dislikeTrack = useDislikeTrack();
|
||||
const router = useRouter();
|
||||
@@ -34,6 +38,11 @@ export function TrackRow({ track, queue, showActions = true, variant = 'default'
|
||||
const compact = variant === 'compact';
|
||||
|
||||
const handlePlay = () => {
|
||||
if (!playable) return;
|
||||
if (onSelect) {
|
||||
onSelect(track);
|
||||
return;
|
||||
}
|
||||
if (isCurrent) { isPlaying ? pause() : play(); return; }
|
||||
// Queue the whole list and start at this track, so Previous can walk back
|
||||
// into the tracks before it.
|
||||
@@ -41,6 +50,12 @@ export function TrackRow({ track, queue, showActions = true, variant = 'default'
|
||||
playTrack(track);
|
||||
};
|
||||
|
||||
const playLabel = !playable
|
||||
? `${track.title || 'Track'} is queued by Vibe`
|
||||
: isCurrent && isPlaying
|
||||
? `Pause ${track.title || 'track'}`
|
||||
: `Play ${track.title || 'track'}`;
|
||||
|
||||
const handleDislike = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
dislikeTrack(track.id);
|
||||
@@ -48,8 +63,8 @@ export function TrackRow({ track, queue, showActions = true, variant = 'default'
|
||||
|
||||
const handleVibe = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
// Start a vibe session then navigate to the vibe page.
|
||||
vibeService.start(track.id).then(() => {
|
||||
// Start and hydrate the V2 plan before showing the Vibe page.
|
||||
startVibeSession(track).then(() => {
|
||||
router.navigate({ to: '/vibe' });
|
||||
}).catch(() => {
|
||||
// Session failed — still navigate so the user can try manually.
|
||||
@@ -59,32 +74,64 @@ export function TrackRow({ track, queue, showActions = true, variant = 'default'
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={handlePlay}
|
||||
className={`group flex w-full cursor-pointer items-center gap-3 rounded-lg border transition-colors ${
|
||||
compact ? 'p-2' : 'p-2.5'
|
||||
// With the title linking to its album, a seed row would otherwise only be
|
||||
// selectable by its 36px artwork tile. The whole row takes the click.
|
||||
onClick={onSelect ? handlePlay : undefined}
|
||||
role={onSelect ? 'button' : undefined}
|
||||
className={`group relative flex w-full items-center gap-2.5 rounded-md px-1.5 transition-colors ${onSelect ? 'cursor-pointer' : ''} ${
|
||||
compact ? 'h-11' : 'h-[52px]'
|
||||
} ${
|
||||
isCurrent
|
||||
? 'border-accent/60 bg-accent/10'
|
||||
: 'border-border/70 bg-surface0/50 hover:border-accent/30 hover:bg-surface1'
|
||||
? "bg-accent/10 before:absolute before:left-0 before:top-1.5 before:bottom-1.5 before:w-0.5 before:rounded-full before:bg-accent before:content-['']"
|
||||
// `.track-row` carries the hover light (see index.css) — a gradient that
|
||||
// falls off to the right instead of a flat slab. Display-only rows still
|
||||
// light up; what they skip is the play-icon ramp below.
|
||||
: 'track-row'
|
||||
}`}
|
||||
>
|
||||
{/* Artwork + play overlay */}
|
||||
<div className={`relative flex flex-none items-center justify-center rounded overflow-hidden ${
|
||||
compact ? 'h-9 w-9' : 'h-10 w-10'
|
||||
<button
|
||||
type="button"
|
||||
onClick={handlePlay}
|
||||
disabled={!playable}
|
||||
aria-label={playLabel}
|
||||
className={`relative flex flex-none items-center justify-center rounded overflow-hidden focus-visible:z-30 disabled:cursor-default disabled:opacity-70 ${
|
||||
// 32px, written as an arbitrary value on purpose: the spacing scale is
|
||||
// remapped, so `h-8` is 64px and overflowed this 44px row — that overflow
|
||||
// was the "stacked" look, not a design choice.
|
||||
compact ? 'h-[32px] w-[32px]' : 'h-9 w-9'
|
||||
}`}>
|
||||
<Artwork seed={`${track.title} ${track.artist}`} src={track.artwork_id} className="absolute inset-0 w-full h-full" />
|
||||
{/* glyph={false}: the play/pause icon below is the only glyph this tile gets. */}
|
||||
<Artwork seed={`${track.title} ${track.artist}`} src={track.artwork_id} glyph={false} className="absolute inset-0 w-full h-full" />
|
||||
{isCurrent && isPlaying ? (
|
||||
<Pause size={compact ? 14 : 18} className="absolute z-20 text-text opacity-100" />
|
||||
) : (
|
||||
<Play size={compact ? 14 : 18} className="absolute z-20 text-text opacity-0 group-hover:opacity-100" />
|
||||
<Play
|
||||
size={compact ? 14 : 18}
|
||||
className={`absolute z-20 text-text opacity-70 transition-opacity ${playable ? 'group-hover:opacity-100' : ''}`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* Title + artist */}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className={`truncate font-medium ${isCurrent ? 'text-accent' : 'text-text'} ${compact ? 'text-sm' : 'text-sm'}`}>
|
||||
{track.title || 'Untitled'}
|
||||
</div>
|
||||
{/* The title navigates to the album, matching the artist links beside it.
|
||||
Playback lives on the artwork tile; a title that played was the surprise. */}
|
||||
{track.album_id ? (
|
||||
<Link
|
||||
to="/albums/$albumId"
|
||||
params={{ albumId: track.album_id }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
title={track.title || 'Untitled'}
|
||||
className={`block max-w-full truncate rounded text-left text-sm font-medium hover:underline ${isCurrent ? 'text-accent' : 'text-text'}`}
|
||||
>
|
||||
{track.title || 'Untitled'}
|
||||
</Link>
|
||||
) : (
|
||||
<span className={`block max-w-full truncate text-sm font-medium ${isCurrent ? 'text-accent' : 'text-text'}`}>
|
||||
{track.title || 'Untitled'}
|
||||
</span>
|
||||
)}
|
||||
<ArtistLinks
|
||||
artists={track.artists}
|
||||
fallback={track.artist}
|
||||
@@ -95,24 +142,14 @@ export function TrackRow({ track, queue, showActions = true, variant = 'default'
|
||||
|
||||
{/* Actions (vibe → album link → dislike) */}
|
||||
{showActions && !compact && (
|
||||
<div className="flex flex-none items-center gap-1 opacity-0 transition-opacity group-hover:opacity-100">
|
||||
<div className="track-row-actions flex flex-none items-center gap-1">
|
||||
{showVibe && (
|
||||
<button onClick={handleVibe} title="Vibe by track" className="rounded p-1.5 text-muted hover:bg-surface1 hover:text-accent">
|
||||
<button type="button" onClick={handleVibe} aria-label="Start a Vibe from this track" title="Vibe by track" className="rounded p-1.5 text-muted hover:bg-surface1 hover:text-accent">
|
||||
<Sparkles size={16} />
|
||||
</button>
|
||||
)}
|
||||
{track.album_id && (
|
||||
<Link
|
||||
to="/albums/$albumId"
|
||||
params={{ albumId: track.album_id }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
title="Go to album"
|
||||
className="rounded p-1.5 text-muted hover:bg-surface1 hover:text-text"
|
||||
>
|
||||
<Disc3 size={16} />
|
||||
</Link>
|
||||
)}
|
||||
<button onClick={handleDislike} title="Dislike" className="rounded p-1.5 text-muted hover:bg-surface1 hover:text-red-400">
|
||||
{/* The album disc button is gone — the title itself is the album link now. */}
|
||||
<button type="button" onClick={handleDislike} aria-label={`Dislike ${track.title || 'track'}`} title="Dislike" className="rounded p-1.5 text-muted hover:bg-surface1 hover:text-red-400">
|
||||
<ThumbsDown size={16} />
|
||||
</button>
|
||||
</div>
|
||||
@@ -120,7 +157,7 @@ export function TrackRow({ track, queue, showActions = true, variant = 'default'
|
||||
|
||||
{/* Duration (hidden in compact) */}
|
||||
{!compact && (
|
||||
<div className="flex-none text-xs tabular-nums text-muted">{formatDuration(track.duration)}</div>
|
||||
<div className="flex-none pr-1 font-mono text-xs tabular-nums text-machine">{formatDuration(track.duration)}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { VibeAura } from './VibeAura';
|
||||
|
||||
function stubMatchMedia(matches: boolean) {
|
||||
vi.stubGlobal('matchMedia', (query: string) => ({
|
||||
matches,
|
||||
media: query,
|
||||
addEventListener: () => {},
|
||||
removeEventListener: () => {},
|
||||
}));
|
||||
}
|
||||
|
||||
describe('VibeAura', () => {
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
it('leaves the displacement filter off a narrow viewport', () => {
|
||||
stubMatchMedia(false);
|
||||
const { container } = render(<VibeAura profile={{ energy: 0.8 }} ambient />);
|
||||
|
||||
expect(container.querySelector('filter#vibe-plasma')).toBeNull();
|
||||
expect(container.querySelector('.vibe-aura-stack-plasma')).toBeNull();
|
||||
// The layers themselves stay: a phone gets the churn, not the noise pass.
|
||||
expect(container.querySelectorAll('.vibe-aura-layer')).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('mounts the filter and names it on the stack once there is room for it', () => {
|
||||
stubMatchMedia(true);
|
||||
const { container } = render(<VibeAura profile={{ energy: 0.8 }} ambient />);
|
||||
|
||||
expect(container.querySelector('filter#vibe-plasma')).not.toBeNull();
|
||||
expect(container.querySelector('.vibe-aura-stack-plasma')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('turns the live recommendation profile into an accessible ambient state', () => {
|
||||
render(<VibeAura profile={{ energy: 0.82, noveltyHunger: 0.78 }} />);
|
||||
|
||||
expect(screen.getByRole('img', { name: 'Current Vibe: charged energy and adventurous discovery' })).toBeInTheDocument();
|
||||
expect(screen.getByText('Your Vibe is charged')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,127 @@
|
||||
import type { CSSProperties } from 'react';
|
||||
import { useMediaQuery } from '../hooks/useMediaQuery';
|
||||
|
||||
export interface VibeProfile {
|
||||
energy?: number;
|
||||
noveltyHunger?: number;
|
||||
explorationCoefficient?: number;
|
||||
discoveryRadius?: number;
|
||||
sessionGoal?: { type?: string; progress?: number; target?: number };
|
||||
}
|
||||
|
||||
const clamp = (value: number | undefined, fallback: number) =>
|
||||
Math.min(1, Math.max(0, typeof value === 'number' ? value : fallback));
|
||||
|
||||
function describeProfile(energy: number, novelty: number) {
|
||||
const energyLabel = energy < 0.36 ? 'settled' : energy > 0.68 ? 'charged' : 'flowing';
|
||||
const discoveryLabel = novelty < 0.36 ? 'familiar' : novelty > 0.64 ? 'adventurous' : 'balanced';
|
||||
return { energyLabel, discoveryLabel };
|
||||
}
|
||||
|
||||
/** An ambient representation of the live recommendation profile. */
|
||||
export function VibeAura({ profile, ambient = false }: { profile: VibeProfile; ambient?: boolean }) {
|
||||
// The displacement pass is desktop-only; see the comment in the ambient
|
||||
// branch below. Same breakpoint the layout already uses for this element.
|
||||
const plasma = useMediaQuery('(min-width: 640px)');
|
||||
const energy = clamp(profile.energy, 0.5);
|
||||
const novelty = clamp(profile.noveltyHunger ?? profile.explorationCoefficient, 0.3);
|
||||
const { energyLabel, discoveryLabel } = describeProfile(energy, novelty);
|
||||
// Hue is discovery alone: ember red when the Vibe stays familiar, gold when it
|
||||
// reaches. Energy is deliberately absent — it already drives four motion
|
||||
// channels below, and mixing it in here cancelled half the novelty swing.
|
||||
// Warm range only; the old 205 base put a teal-blue glow in a warm brown room.
|
||||
const hue = Math.round(15 + novelty * 40);
|
||||
const style = {
|
||||
'--vibe-hue': String(hue),
|
||||
'--vibe-pulse': `${(4.8 - energy * 2.3).toFixed(2)}s`,
|
||||
'--vibe-orbit': `${(11 - energy * 4).toFixed(2)}s`,
|
||||
'--vibe-scale': String((0.9 + energy * 0.16).toFixed(2)),
|
||||
'--vibe-core-opacity': ambient ? '0.38' : '1',
|
||||
} as CSSProperties;
|
||||
|
||||
const aura = (
|
||||
<div className="grid h-16 w-16 place-items-center rounded-full">
|
||||
<div className="vibe-aura-halo" />
|
||||
<div className="vibe-aura-orbit vibe-aura-orbit-one" />
|
||||
<div className="vibe-aura-orbit vibe-aura-orbit-two" />
|
||||
<div className="vibe-aura-core" style={{ opacity: 'var(--vibe-core-opacity, 1)' }}>
|
||||
<span className="vibe-aura-spark vibe-aura-spark-one" />
|
||||
<span className="vibe-aura-spark vibe-aura-spark-two" />
|
||||
<span className="vibe-aura-heart" />
|
||||
</div>
|
||||
{!ambient && (
|
||||
<div className="pointer-events-none absolute right-0 top-[calc(100%+0.4rem)] z-10 w-44 rounded-md border border-border bg-surface1 px-3 py-2 text-xs leading-relaxed text-muted opacity-0 shadow-xl transition-opacity group-hover:opacity-100 group-focus:opacity-100">
|
||||
<span className="block font-medium text-text">Your Vibe is {energyLabel}</span>
|
||||
<span>Leaning {discoveryLabel}; it shifts as you listen.</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
if (ambient) {
|
||||
// Displacing clean gradients by animated fractal noise is what separates a
|
||||
// plasma from a blurred blob. baseFrequency is animated in SMIL rather than
|
||||
// CSS because no CSS property reaches inside an SVG filter primitive.
|
||||
//
|
||||
// That pass is also why the Vibe page crawled on a phone. An SVG filter is
|
||||
// rasterised on the CPU, and animating baseFrequency regenerates the whole
|
||||
// turbulence field every frame — for a 420x380 element, under a blur, over
|
||||
// three counter-rotating layers. Below the breakpoint the filter is not
|
||||
// mounted at all and the same layers churn behind a plain blur, which is
|
||||
// the difference between a heavy effect and a scrolling page.
|
||||
return (
|
||||
<div
|
||||
className="vibe-aura-blob pointer-events-none absolute z-0 h-[380px] w-[420px] -translate-x-1/2 -translate-y-1/2 opacity-85 mix-blend-screen sm:h-[560px] sm:w-[660px] sm:opacity-95"
|
||||
style={style}
|
||||
role="img"
|
||||
aria-label={`Current Vibe: ${energyLabel} energy and ${discoveryLabel} discovery`}
|
||||
>
|
||||
{plasma && (
|
||||
<svg aria-hidden className="absolute h-0 w-0">
|
||||
<filter id="vibe-plasma" x="-30%" y="-30%" width="160%" height="160%">
|
||||
<feTurbulence
|
||||
type="fractalNoise"
|
||||
baseFrequency="0.009 0.014"
|
||||
numOctaves={3}
|
||||
seed={7}
|
||||
result="noise"
|
||||
>
|
||||
<animate
|
||||
attributeName="baseFrequency"
|
||||
dur={`${(18 - energy * 8).toFixed(1)}s`}
|
||||
values="0.009 0.014; 0.021 0.007; 0.009 0.014"
|
||||
repeatCount="indefinite"
|
||||
/>
|
||||
</feTurbulence>
|
||||
<feDisplacementMap
|
||||
in="SourceGraphic"
|
||||
in2="noise"
|
||||
scale={String(Math.round(46 + energy * 70))}
|
||||
xChannelSelector="R"
|
||||
yChannelSelector="G"
|
||||
/>
|
||||
</filter>
|
||||
</svg>
|
||||
)}
|
||||
<div className={plasma ? 'vibe-aura-stack vibe-aura-stack-plasma' : 'vibe-aura-stack'}>
|
||||
<div className="vibe-aura-layer vibe-aura-rays-layer" />
|
||||
<div className="vibe-aura-layer vibe-aura-swirl-layer" />
|
||||
<div className="vibe-aura-layer vibe-aura-core-layer" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="group relative grid h-16 w-16 shrink-0 place-items-center rounded-full focus:outline-none"
|
||||
style={style}
|
||||
role="img"
|
||||
tabIndex={0}
|
||||
aria-label={`Current Vibe: ${energyLabel} energy and ${discoveryLabel} discovery`}
|
||||
title={`Current Vibe: ${energyLabel} energy, ${discoveryLabel} discovery`}
|
||||
>
|
||||
{aura}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import {
|
||||
RouterProvider,
|
||||
createMemoryHistory,
|
||||
createRootRoute,
|
||||
createRouter,
|
||||
} from '@tanstack/react-router';
|
||||
import type { ReactNode } from 'react';
|
||||
import type { Track } from '../types';
|
||||
import { usePlaybackStore } from '../store/usePlaybackStore';
|
||||
import { VibeTimeline } from './VibeTimeline';
|
||||
|
||||
const track = (id: string): Track => ({
|
||||
id, path: `/music/${id}.mp3`, hash: id, title: id, artist: 'Artist', album_id: 'album',
|
||||
duration: 180, state: 'LIBRARY', source_type: 'MANUAL', play_count: 0, skip_count: 0, dislike_count: 0,
|
||||
});
|
||||
|
||||
/** Track titles link to their album, so rows need a router in scope. */
|
||||
function renderInRouter(ui: ReactNode) {
|
||||
const rootRoute = createRootRoute({ component: () => ui });
|
||||
const router = createRouter({ routeTree: rootRoute, history: createMemoryHistory() });
|
||||
return render(
|
||||
<QueryClientProvider client={new QueryClient({ defaultOptions: { queries: { retry: false } } })}>
|
||||
<RouterProvider router={router as never} />
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
describe('VibeTimeline', () => {
|
||||
beforeEach(() => {
|
||||
const current = track('current');
|
||||
usePlaybackStore.setState({
|
||||
currentTrack: current, queue: [current, track('upcoming')], currentIndex: 0,
|
||||
isPlaying: true, queueOwner: 'vibe', vibeAdvanceHandler: () => undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('renders upcoming plan entries as display-only so they cannot hand queue ownership to ordinary playback', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderInRouter(<VibeTimeline currentTrack={track('current')} upcoming={[track('upcoming')]} />);
|
||||
|
||||
const queued = await screen.findAllByRole('button', { name: 'upcoming is queued by Vibe' });
|
||||
expect(queued).toHaveLength(1);
|
||||
expect(queued[0]).toBeDisabled();
|
||||
await user.click(queued[0]);
|
||||
|
||||
expect(usePlaybackStore.getState()).toMatchObject({
|
||||
queueOwner: 'vibe', currentTrack: track('current'),
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -11,10 +11,10 @@ interface VibeTimelineProps {
|
||||
export function VibeTimeline({ currentTrack, upcoming }: VibeTimelineProps) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2 text-text">
|
||||
<Radio size={18} className="text-accent" />
|
||||
<h2 className="text-lg font-semibold">Incoming recommendations</h2>
|
||||
<span className="text-xs text-muted">({upcoming.length} buffered)</span>
|
||||
<div className="flex items-center gap-2 border-b border-border pb-2">
|
||||
<Radio size={14} className="text-accent" />
|
||||
<h2 className="text-[11px] font-semibold uppercase tracking-[0.1em] text-muted">Up next</h2>
|
||||
<span className="ml-auto font-mono text-xs tabular-nums text-machine">{upcoming.length} buffered</span>
|
||||
</div>
|
||||
|
||||
{currentTrack && (
|
||||
@@ -26,7 +26,9 @@ export function VibeTimeline({ currentTrack, upcoming }: VibeTimelineProps) {
|
||||
showActions={false}
|
||||
variant="compact"
|
||||
/>
|
||||
<Badge color="accent" className="absolute right-2 top-1/2 -translate-y-1/2">Now playing</Badge>
|
||||
{/* Hidden under 640px: the badge sat on top of the title. The accent
|
||||
title and the accent edge already mark the current row. */}
|
||||
<Badge color="accent" className="absolute right-2 top-1/2 hidden -translate-y-1/2 sm:block">Now playing</Badge>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -35,7 +37,7 @@ export function VibeTimeline({ currentTrack, upcoming }: VibeTimelineProps) {
|
||||
No upcoming tracks buffered yet.
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-0.5">
|
||||
<div className="track-list">
|
||||
{upcoming.map((track, i) => (
|
||||
<TrackRow
|
||||
key={`${track.id}-${i}`}
|
||||
@@ -44,6 +46,7 @@ export function VibeTimeline({ currentTrack, upcoming }: VibeTimelineProps) {
|
||||
index={0}
|
||||
showActions={false}
|
||||
variant="compact"
|
||||
playable={false}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -11,13 +11,13 @@ export function Skeleton({ className = '' }: { className?: string }) {
|
||||
/** Rows matching TrackRow height */
|
||||
export function SkeletonRows({ count = 5 }: { count?: number }) {
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<div>
|
||||
{Array.from({ length: count }).map((_, i) => (
|
||||
<div key={i} className="flex items-center gap-3 rounded-md px-3 py-2">
|
||||
<Skeleton className="h-8 w-8 flex-none rounded" />
|
||||
<div key={i} className="flex h-[52px] items-center gap-2.5 rounded-md px-1.5">
|
||||
<Skeleton className="h-9 w-9 flex-none rounded" />
|
||||
<div className="flex-1 space-y-1.5">
|
||||
<Skeleton className="h-3 w-1/3" />
|
||||
<Skeleton className="h-2.5 w-1/4" />
|
||||
<Skeleton className="h-3 w-1/4" />
|
||||
<Skeleton className="h-2.5 w-1/6" />
|
||||
</div>
|
||||
<Skeleton className="h-3 w-8 flex-none" />
|
||||
</div>
|
||||
@@ -29,7 +29,7 @@ export function SkeletonRows({ count = 5 }: { count?: number }) {
|
||||
/** Grid matching album/artist card layout */
|
||||
export function SkeletonGrid({ count = 10 }: { count?: number }) {
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5">
|
||||
<div className="grid grid-cols-3 gap-3 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-6 xl:grid-cols-7">
|
||||
{Array.from({ length: count }).map((_, i) => (
|
||||
<div key={i} className="flex flex-col gap-2 rounded-md border border-border bg-surface0 p-2">
|
||||
<Skeleton className="aspect-square rounded" />
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useToastStore, toast } from '../store/useToastStore';
|
||||
|
||||
/**
|
||||
* Chrome fires `beforeinstallprompt` when the app qualifies for installation.
|
||||
* Not in lib.dom yet, so the shape is declared here.
|
||||
*/
|
||||
interface BeforeInstallPromptEvent extends Event {
|
||||
prompt: () => Promise<void>;
|
||||
userChoice: Promise<{ outcome: 'accepted' | 'dismissed' }>;
|
||||
}
|
||||
|
||||
const SNOOZE_KEY = 'muzick.install-prompt.snoozed-at';
|
||||
const SNOOZE_MS = 30 * 24 * 60 * 60 * 1000;
|
||||
|
||||
// Long enough that the suggestion lands after the app has proved useful, not
|
||||
// while the first page is still painting.
|
||||
const DELAY_MS = 20_000;
|
||||
|
||||
function snoozed(): boolean {
|
||||
try {
|
||||
const at = Number(localStorage.getItem(SNOOZE_KEY));
|
||||
return Number.isFinite(at) && at > 0 && Date.now() - at < SNOOZE_MS;
|
||||
} catch {
|
||||
return false; // Private mode or blocked storage — just ask.
|
||||
}
|
||||
}
|
||||
|
||||
function snooze() {
|
||||
try {
|
||||
localStorage.setItem(SNOOZE_KEY, String(Date.now()));
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Offers "Add to Home screen" as a toast, once the browser says the app is
|
||||
* installable. Declining snoozes the suggestion for a month; installing
|
||||
* silences it for good.
|
||||
*/
|
||||
export function useInstallPrompt() {
|
||||
useEffect(() => {
|
||||
// Already installed — the standalone display mode is the reliable signal.
|
||||
if (window.matchMedia?.('(display-mode: standalone)').matches) return;
|
||||
if (snoozed()) return;
|
||||
|
||||
let deferred: BeforeInstallPromptEvent | null = null;
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
let unsubscribe: (() => void) | undefined;
|
||||
|
||||
const offer = () => {
|
||||
if (!deferred) return;
|
||||
const event = deferred;
|
||||
let accepted = false;
|
||||
|
||||
const id = toast.info('Muzick runs better from your home screen.', {
|
||||
ttl: 0,
|
||||
action: {
|
||||
label: 'Install',
|
||||
onClick: () => {
|
||||
accepted = true;
|
||||
unsubscribe?.();
|
||||
// A prompt can only be shown once per event; drop it either way.
|
||||
deferred = null;
|
||||
void event.prompt().then(() => event.userChoice).then((choice) => {
|
||||
if (choice.outcome === 'dismissed') snooze();
|
||||
}).catch(() => snooze());
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// The toast's own close button calls dismiss() directly, so the only way
|
||||
// to notice a decline is to watch the toast leave the store.
|
||||
unsubscribe = useToastStore.subscribe((state) => {
|
||||
if (accepted) return;
|
||||
if (!state.toasts.some((t) => t.id === id)) {
|
||||
snooze();
|
||||
unsubscribe?.();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const onBeforeInstallPrompt = (e: Event) => {
|
||||
// Suppress Chrome's own mini-infobar so only our toast asks.
|
||||
e.preventDefault();
|
||||
deferred = e as BeforeInstallPromptEvent;
|
||||
timer = setTimeout(offer, DELAY_MS);
|
||||
};
|
||||
|
||||
const onInstalled = () => {
|
||||
deferred = null;
|
||||
if (timer) clearTimeout(timer);
|
||||
snooze();
|
||||
};
|
||||
|
||||
window.addEventListener('beforeinstallprompt', onBeforeInstallPrompt);
|
||||
window.addEventListener('appinstalled', onInstalled);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('beforeinstallprompt', onBeforeInstallPrompt);
|
||||
window.removeEventListener('appinstalled', onInstalled);
|
||||
if (timer) clearTimeout(timer);
|
||||
unsubscribe?.();
|
||||
};
|
||||
}, []);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
/**
|
||||
* Track a CSS media query from React, for the cases where a media query in the
|
||||
* stylesheet is not enough — deciding whether to mount an element at all, not
|
||||
* just how to paint it.
|
||||
*
|
||||
* Returns false where `matchMedia` is missing (server render, jsdom), which
|
||||
* makes the cheaper branch the default everywhere it cannot be measured.
|
||||
*/
|
||||
export function useMediaQuery(query: string): boolean {
|
||||
const [matches, setMatches] = useState(() => {
|
||||
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return false;
|
||||
return window.matchMedia(query).matches;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return;
|
||||
const list = window.matchMedia(query);
|
||||
const update = () => setMatches(list.matches);
|
||||
update();
|
||||
list.addEventListener('change', update);
|
||||
return () => list.removeEventListener('change', update);
|
||||
}, [query]);
|
||||
|
||||
return matches;
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
import { renderHook, act, waitFor } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { usePlaybackStore } from '../store/usePlaybackStore';
|
||||
import { useVibeStore } from '../store/useVibeStore';
|
||||
import { playbackSyncService, type PlaybackSnapshot, type PlaybackSyncEvent } from '../services/playbackSync';
|
||||
import { usePlaybackSync } from './usePlaybackSync';
|
||||
|
||||
const THIS_DEVICE = '11111111-1111-1111-1111-111111111111';
|
||||
const OTHER_DEVICE = '22222222-2222-2222-2222-222222222222';
|
||||
|
||||
let emit: ((event: PlaybackSyncEvent) => void) | null = null;
|
||||
|
||||
const { adoptVibeSession, releaseVibeDriving } = vi.hoisted(() => ({
|
||||
adoptVibeSession: vi.fn(async () => true),
|
||||
releaseVibeDriving: vi.fn(),
|
||||
}));
|
||||
vi.mock('../services/vibeSession', () => ({ adoptVibeSession, releaseVibeDriving }));
|
||||
|
||||
vi.mock('../services/playbackSync', () => ({
|
||||
storedDeviceId: () => null,
|
||||
playbackSyncService: {
|
||||
register: vi.fn(async () => ({
|
||||
id: THIS_DEVICE,
|
||||
name: 'Test device',
|
||||
lastSeenAt: new Date(0).toISOString(),
|
||||
online: true,
|
||||
isOwner: false,
|
||||
})),
|
||||
reportState: vi.fn(async () => undefined),
|
||||
transfer: vi.fn(async () => undefined),
|
||||
release: vi.fn(async () => undefined),
|
||||
sendCommand: vi.fn(async () => undefined),
|
||||
openStream: (_deviceId: string, onEvent: (event: PlaybackSyncEvent) => void) => {
|
||||
emit = onEvent;
|
||||
return () => { emit = null; };
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
function snapshot(over: Partial<PlaybackSnapshot> = {}): PlaybackSnapshot {
|
||||
return {
|
||||
deviceId: THIS_DEVICE,
|
||||
trackId: null,
|
||||
vibeSessionId: null,
|
||||
queue: [],
|
||||
queueIndex: 0,
|
||||
position: 0,
|
||||
isPlaying: true,
|
||||
version: 1,
|
||||
updatedAt: new Date(0).toISOString(),
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
async function mountOwning() {
|
||||
const view = renderHook(() => usePlaybackSync());
|
||||
await waitFor(() => expect(emit).not.toBeNull());
|
||||
// First snapshot: this device takes the session over at 30s.
|
||||
await act(async () => {
|
||||
emit!({ type: 'state', state: snapshot({ position: 30, version: 1 }), devices: [] });
|
||||
});
|
||||
await waitFor(() => expect(view.result.current.isOwner).toBe(true));
|
||||
return view;
|
||||
}
|
||||
|
||||
describe('usePlaybackSync', () => {
|
||||
beforeEach(() => {
|
||||
emit = null;
|
||||
useVibeStore.getState().reset();
|
||||
usePlaybackStore.setState({
|
||||
position: 0, isPlaying: false, queue: [], currentTrack: null, audioElsewhere: false,
|
||||
queueOwner: 'ordinary', vibeAdvanceHandler: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('takes the reported position when it first gains the session', async () => {
|
||||
await mountOwning();
|
||||
expect(usePlaybackStore.getState().position).toBe(30);
|
||||
});
|
||||
|
||||
it('ignores snapshots echoing its own stale position while it owns the audio', async () => {
|
||||
await mountOwning();
|
||||
|
||||
// Playback has moved on locally; the server still holds the last report.
|
||||
act(() => usePlaybackStore.getState().setPosition(48));
|
||||
await act(async () => {
|
||||
emit!({ type: 'state', state: snapshot({ position: 30, version: 2 }), devices: [] });
|
||||
});
|
||||
|
||||
expect(usePlaybackStore.getState().position).toBe(48);
|
||||
});
|
||||
|
||||
it('pauses and follows along once another device takes the session', async () => {
|
||||
const view = await mountOwning();
|
||||
act(() => usePlaybackStore.setState({ isPlaying: true, position: 48 }));
|
||||
|
||||
await act(async () => {
|
||||
emit!({
|
||||
type: 'state',
|
||||
state: snapshot({ deviceId: OTHER_DEVICE, position: 55, version: 3 }),
|
||||
devices: [],
|
||||
});
|
||||
});
|
||||
|
||||
expect(usePlaybackStore.getState().isPlaying).toBe(false);
|
||||
expect(usePlaybackStore.getState().position).toBe(55);
|
||||
expect(view.result.current.hasRemoteOwner).toBe(true);
|
||||
});
|
||||
|
||||
it('reports the remote play state rather than its own while it watches', async () => {
|
||||
const view = await mountOwning();
|
||||
|
||||
await act(async () => {
|
||||
emit!({
|
||||
type: 'state',
|
||||
state: snapshot({ deviceId: OTHER_DEVICE, isPlaying: true, version: 3 }),
|
||||
devices: [],
|
||||
});
|
||||
});
|
||||
|
||||
expect(view.result.current.remotePlaying).toBe(true);
|
||||
expect(usePlaybackStore.getState().isPlaying).toBe(false);
|
||||
expect(usePlaybackStore.getState().audioElsewhere).toBe(true);
|
||||
});
|
||||
|
||||
it('reports the Vibe session it is driving so the next device can take it', async () => {
|
||||
await mountOwning();
|
||||
act(() => {
|
||||
useVibeStore.getState().setActiveSession({ sessionId: 'session-a', seedTrackId: 'track-1' });
|
||||
usePlaybackStore.getState().setVibeQueue([{ id: 'track-1' }] as never);
|
||||
usePlaybackStore.getState().setCurrentTrack({ id: 'track-1' } as never);
|
||||
});
|
||||
|
||||
await waitFor(() => expect(playbackSyncService.reportState).toHaveBeenCalledWith(
|
||||
THIS_DEVICE,
|
||||
expect.objectContaining({ vibeSessionId: 'session-a' }),
|
||||
));
|
||||
});
|
||||
|
||||
it('stops driving the Vibe when the audio moves away, and adopts it back', async () => {
|
||||
const view = await mountOwning();
|
||||
act(() => {
|
||||
usePlaybackStore.getState().setVibeQueue([{ id: 'track-1' }] as never);
|
||||
usePlaybackStore.getState().setCurrentTrack({ id: 'track-1' } as never);
|
||||
usePlaybackStore.getState().setVibeAdvanceHandler(() => undefined);
|
||||
useVibeStore.getState().setActiveSession({ sessionId: 'session-a', seedTrackId: 'track-1' });
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
emit!({
|
||||
type: 'state',
|
||||
state: snapshot({ deviceId: OTHER_DEVICE, vibeSessionId: 'session-a', version: 3 }),
|
||||
devices: [],
|
||||
});
|
||||
});
|
||||
|
||||
// One driver at a time, and it is whichever device holds the audio.
|
||||
expect(releaseVibeDriving).toHaveBeenCalled();
|
||||
expect(adoptVibeSession).not.toHaveBeenCalled();
|
||||
|
||||
await act(async () => {
|
||||
emit!({
|
||||
type: 'state',
|
||||
state: snapshot({ deviceId: THIS_DEVICE, vibeSessionId: 'session-a', version: 4 }),
|
||||
devices: [],
|
||||
});
|
||||
});
|
||||
|
||||
expect(view.result.current.isOwner).toBe(true);
|
||||
expect(usePlaybackStore.getState().audioElsewhere).toBe(false);
|
||||
expect(adoptVibeSession).toHaveBeenCalledWith('session-a');
|
||||
});
|
||||
|
||||
it('lets go of its own Vibe when it takes over playback that is not one', async () => {
|
||||
await mountOwning();
|
||||
act(() => {
|
||||
usePlaybackStore.getState().setVibeQueue([{ id: 'track-1' }] as never);
|
||||
useVibeStore.getState().setActiveSession({ sessionId: 'session-a', seedTrackId: 'track-1' });
|
||||
});
|
||||
|
||||
// The other device played an album, and this one is taking that over.
|
||||
await act(async () => {
|
||||
emit!({
|
||||
type: 'state',
|
||||
state: snapshot({ deviceId: OTHER_DEVICE, vibeSessionId: null, version: 3 }),
|
||||
devices: [],
|
||||
});
|
||||
});
|
||||
await act(async () => {
|
||||
emit!({ type: 'state', state: snapshot({ deviceId: THIS_DEVICE, vibeSessionId: null, version: 4 }), devices: [] });
|
||||
});
|
||||
|
||||
expect(adoptVibeSession).not.toHaveBeenCalled();
|
||||
expect(useVibeStore.getState().activeSessionId).toBeNull();
|
||||
});
|
||||
|
||||
it('keeps playing and reclaims the session when a dropped stream leaves it unowned', async () => {
|
||||
await mountOwning();
|
||||
act(() => usePlaybackStore.setState({
|
||||
isPlaying: true,
|
||||
position: 48,
|
||||
currentTrack: { id: 'track-1' } as never,
|
||||
}));
|
||||
|
||||
// What the server publishes after this device's stream breaks: nobody owns
|
||||
// the audio, and the position is this device's own report from 18s ago.
|
||||
await act(async () => {
|
||||
emit!({ type: 'state', state: snapshot({ deviceId: null, position: 30, version: 4 }), devices: [] });
|
||||
});
|
||||
|
||||
expect(usePlaybackStore.getState().isPlaying).toBe(true);
|
||||
expect(usePlaybackStore.getState().position).toBe(48);
|
||||
expect(playbackSyncService.reportState).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,317 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { usePlaybackStore } from '../store/usePlaybackStore';
|
||||
import { useVibeStore } from '../store/useVibeStore';
|
||||
import {
|
||||
PlaybackCommand,
|
||||
PlaybackDevice,
|
||||
PlaybackSnapshot,
|
||||
playbackSyncService,
|
||||
storedDeviceId,
|
||||
} from '../services/playbackSync';
|
||||
import { trackService } from '../services/trackService';
|
||||
import { adoptVibeSession, releaseVibeDriving } from '../services/vibeSession';
|
||||
import type { Track } from '../types';
|
||||
|
||||
/**
|
||||
* Keeps this browser in step with the listener's other devices.
|
||||
*
|
||||
* Exactly one device holds the audio. That device reports what it is playing;
|
||||
* every other device renders the same thing and, when the listener presses a
|
||||
* control, sends a command instead of playing locally. Picking a device from
|
||||
* the menu moves the audio: the new owner resumes the same track at the
|
||||
* position the old one last reported, and the old one stops when it sees the
|
||||
* session is no longer its.
|
||||
*/
|
||||
|
||||
/** Position drifts constantly; anything faster than this is noise on the wire. */
|
||||
const POSITION_REPORT_MS = 10_000;
|
||||
|
||||
/** Waits between attempts to register this device when the network is down. */
|
||||
const REGISTER_BACKOFF_MS = [1_000, 2_000, 5_000, 10_000, 20_000, 30_000];
|
||||
|
||||
export interface PlaybackSyncApi {
|
||||
deviceId: string | null;
|
||||
devices: PlaybackDevice[];
|
||||
isOwner: boolean;
|
||||
/** True once another device holds the audio, so controls become remote controls. */
|
||||
hasRemoteOwner: boolean;
|
||||
/**
|
||||
* Whether the device holding the audio is playing. The local `isPlaying` says
|
||||
* nothing about it — a watching device is always paused — so controls that
|
||||
* draw a play/pause state read this one while `hasRemoteOwner` is true.
|
||||
*/
|
||||
remotePlaying: boolean;
|
||||
transferTo: (deviceId: string) => Promise<void>;
|
||||
sendCommand: (command: PlaybackCommand) => Promise<void>;
|
||||
}
|
||||
|
||||
export function usePlaybackSync(): PlaybackSyncApi {
|
||||
const [deviceId, setDeviceId] = useState<string | null>(storedDeviceId());
|
||||
const [devices, setDevices] = useState<PlaybackDevice[]>([]);
|
||||
const [ownerId, setOwnerId] = useState<string | null>(null);
|
||||
const [remotePlaying, setRemotePlaying] = useState(false);
|
||||
|
||||
const deviceIdRef = useRef<string | null>(deviceId);
|
||||
const ownerIdRef = useRef<string | null>(null);
|
||||
// Set while a remote snapshot or command is being written into the store, so
|
||||
// the store subscription below does not report those changes straight back.
|
||||
const applyingRemote = useRef(false);
|
||||
const lastVersion = useRef(-1);
|
||||
const lastPositionReport = useRef(0);
|
||||
|
||||
deviceIdRef.current = deviceId;
|
||||
ownerIdRef.current = ownerId;
|
||||
|
||||
const reportNow = useCallback(async () => {
|
||||
const id = deviceIdRef.current;
|
||||
if (!id) return;
|
||||
const store = usePlaybackStore.getState();
|
||||
const vibe = useVibeStore.getState();
|
||||
try {
|
||||
await playbackSyncService.reportState(id, {
|
||||
trackId: store.currentTrack?.id ?? null,
|
||||
// Reported by the device driving the Vibe, and reported as null by a
|
||||
// device playing anything else, so the next owner knows which it is.
|
||||
vibeSessionId: store.queueOwner === 'vibe' ? vibe.activeSessionId : null,
|
||||
queue: store.queue,
|
||||
queueIndex: store.currentIndex,
|
||||
position: store.position,
|
||||
isPlaying: store.isPlaying,
|
||||
});
|
||||
} catch {
|
||||
// 409 means another device owns the session now. Its next state event is
|
||||
// what corrects this one, so there is nothing to do here.
|
||||
}
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Take the queue off the wire without ending a Vibe this device is running.
|
||||
* `setQueue` is an ownership handoff: it drops the advance handler, which is
|
||||
* the thing that asks the server for the next Vibe track. A snapshot from
|
||||
* another device used to go through it, so mirroring the desktop — or taking
|
||||
* the audio back afterwards — turned a live session into a static list of the
|
||||
* hundred tracks that happened to be synced.
|
||||
*/
|
||||
const adoptQueue = useCallback((queue: Track[]) => {
|
||||
if (queue.length === 0) return;
|
||||
const store = usePlaybackStore.getState();
|
||||
const runningVibe = store.queueOwner === 'vibe' && useVibeStore.getState().activeSessionId !== null;
|
||||
if (runningVibe) store.setVibeQueue(queue);
|
||||
else store.setQueue(queue);
|
||||
}, []);
|
||||
|
||||
const applySnapshot = useCallback(async (state: PlaybackSnapshot) => {
|
||||
if (state.version <= lastVersion.current) return;
|
||||
lastVersion.current = state.version;
|
||||
|
||||
const store = usePlaybackStore.getState();
|
||||
const iOwnIt = state.deviceId !== null && state.deviceId === deviceIdRef.current;
|
||||
// Captured before setOwnerId, which only reaches the ref on the next render.
|
||||
const alreadyOwnedIt = ownerIdRef.current !== null && ownerIdRef.current === deviceIdRef.current;
|
||||
setOwnerId(state.deviceId);
|
||||
|
||||
// What the controls draw, and whether the engine should load anything: both
|
||||
// follow from where the audio is, so they are set before any of the writes
|
||||
// below and on every snapshot, including the ones the owner ignores.
|
||||
const elsewhere = state.deviceId !== null && !iOwnIt;
|
||||
setRemotePlaying(elsewhere ? state.isPlaying : false);
|
||||
store.setAudioElsewhere(elsewhere);
|
||||
|
||||
// The server publishes a snapshot for anything that touches the session,
|
||||
// including another device merely registering on page load. For the device
|
||||
// already holding the audio those snapshots carry nothing new: the position
|
||||
// in them is this device's own last report, up to POSITION_REPORT_MS old.
|
||||
// Applying it would drag playback backwards, so the owner ignores them and
|
||||
// stays the authority on its own position.
|
||||
if (iOwnIt && alreadyOwnedIt) return;
|
||||
|
||||
// Nobody holds the session. That is not an instruction to stop: it is what
|
||||
// the server publishes when this device's stream broke for a moment, or
|
||||
// when the sweep freed an owner it thought was gone. A device with audio
|
||||
// loaded claims the session back instead of pausing and rewinding to the
|
||||
// position it last reported, which is how a phone losing signal for ten
|
||||
// seconds used to stop playing.
|
||||
if (state.deviceId === null && store.currentTrack) {
|
||||
if (alreadyOwnedIt || store.isPlaying) void reportNow();
|
||||
return;
|
||||
}
|
||||
|
||||
applyingRemote.current = true;
|
||||
try {
|
||||
if (!iOwnIt) {
|
||||
// Another device holds the audio, and with it the Vibe. Stop driving the
|
||||
// session from here, then show what it plays and make no sound.
|
||||
if (store.isPlaying) store.pause();
|
||||
if (store.vibeAdvanceHandler) releaseVibeDriving();
|
||||
adoptQueue(state.queue);
|
||||
const shown = state.queue[state.queueIndex] ?? store.currentTrack;
|
||||
if (shown && shown.id !== store.currentTrack?.id) store.setCurrentTrack(shown);
|
||||
store.setPosition(state.position);
|
||||
return;
|
||||
}
|
||||
|
||||
// We just took the session over: rebuild the queue, land on the right
|
||||
// track, and resume from where the previous device actually was.
|
||||
adoptQueue(state.queue);
|
||||
let track: Track | null = state.queue[state.queueIndex] ?? null;
|
||||
if (!track && state.trackId && state.trackId !== store.currentTrack?.id) {
|
||||
track = await trackService.getTrack(state.trackId).catch(() => null);
|
||||
}
|
||||
if (track && track.id !== store.currentTrack?.id) store.playTrack(track);
|
||||
store.setPosition(state.position);
|
||||
if (state.isPlaying) store.play();
|
||||
else store.pause();
|
||||
} finally {
|
||||
applyingRemote.current = false;
|
||||
}
|
||||
|
||||
// Only a device that just took the audio reaches this: every other case
|
||||
// returned above. Vibe control travels with the audio, so pick up the
|
||||
// session the previous owner was driving — or let go of the one this device
|
||||
// still holds, because whatever is playing now is not it.
|
||||
if (state.vibeSessionId) {
|
||||
// Adoption rewrites the unplayed queue from the durable plan, and the
|
||||
// others are showing this device's queue, so tell them.
|
||||
if (await adoptVibeSession(state.vibeSessionId).catch(() => false)) void reportNow();
|
||||
} else if (useVibeStore.getState().activeSessionId) {
|
||||
releaseVibeDriving();
|
||||
useVibeStore.getState().reset();
|
||||
}
|
||||
}, [adoptQueue, reportNow]);
|
||||
|
||||
const applyCommand = useCallback(async (command: PlaybackCommand) => {
|
||||
const store = usePlaybackStore.getState();
|
||||
applyingRemote.current = true;
|
||||
try {
|
||||
switch (command.type) {
|
||||
case 'play': store.play(); break;
|
||||
case 'pause': store.pause(); break;
|
||||
case 'next': store.next(); break;
|
||||
case 'prev': store.prev(); break;
|
||||
case 'seek': store.setPosition(command.position); break;
|
||||
case 'play_track': {
|
||||
const queued = store.queue.find((t) => t.id === command.trackId);
|
||||
const track = queued ?? await trackService.getTrack(command.trackId).catch(() => null);
|
||||
if (track) store.playTrack(track);
|
||||
break;
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
applyingRemote.current = false;
|
||||
}
|
||||
// The command changed what this device plays, so the others need to know.
|
||||
void reportNow();
|
||||
}, [reportNow]);
|
||||
|
||||
// Register, then hold the stream open for as long as the app is mounted.
|
||||
useEffect(() => {
|
||||
let closeStream: (() => void) | null = null;
|
||||
let cancelled = false;
|
||||
let retry: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
// Registration is one request, and a phone woken with no network fails it.
|
||||
// Giving up there left that tab with no device id and no stream until the
|
||||
// listener reloaded the page, so keep asking instead.
|
||||
const attempt = async (attemptCount: number) => {
|
||||
const device = await playbackSyncService.register().catch(() => null);
|
||||
if (cancelled) return;
|
||||
if (!device) {
|
||||
const wait = REGISTER_BACKOFF_MS[Math.min(attemptCount, REGISTER_BACKOFF_MS.length - 1)];
|
||||
retry = setTimeout(() => void attempt(attemptCount + 1), wait);
|
||||
return;
|
||||
}
|
||||
setDeviceId(device.id);
|
||||
deviceIdRef.current = device.id;
|
||||
closeStream = playbackSyncService.openStream(device.id, (event) => {
|
||||
if (event.type === 'state') {
|
||||
setDevices(event.devices);
|
||||
void applySnapshot(event.state);
|
||||
} else {
|
||||
void applyCommand(event.command);
|
||||
}
|
||||
});
|
||||
};
|
||||
void attempt(0);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearTimeout(retry);
|
||||
closeStream?.();
|
||||
// Nothing is watching the session any more, so nothing knows where the
|
||||
// audio is. Leaving the flag set would keep the engine silent for good.
|
||||
usePlaybackStore.getState().setAudioElsewhere(false);
|
||||
};
|
||||
}, [applySnapshot, applyCommand]);
|
||||
|
||||
// Local playback is reported upward — but only from the device that owns the
|
||||
// audio, and only when the change did not come from the network in the first
|
||||
// place. Starting playback on an unowned session claims it.
|
||||
useEffect(() => {
|
||||
let previous = usePlaybackStore.getState();
|
||||
return usePlaybackStore.subscribe((state) => {
|
||||
const prev = previous;
|
||||
previous = state;
|
||||
const id = deviceIdRef.current;
|
||||
if (!id || applyingRemote.current) return;
|
||||
|
||||
const trackChanged = state.currentTrack?.id !== prev.currentTrack?.id;
|
||||
const playingChanged = state.isPlaying !== prev.isPlaying;
|
||||
const startedPlaying = state.isPlaying && !prev.isPlaying;
|
||||
|
||||
if (ownerIdRef.current !== id) {
|
||||
// A device that does not own the session only takes it by starting
|
||||
// playback here. Everything else it does stays local.
|
||||
if (startedPlaying || (trackChanged && state.isPlaying)) {
|
||||
void playbackSyncService.transfer(id).then(() => reportNow());
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (trackChanged || playingChanged) {
|
||||
void reportNow();
|
||||
lastPositionReport.current = Date.now();
|
||||
return;
|
||||
}
|
||||
if (Date.now() - lastPositionReport.current >= POSITION_REPORT_MS) {
|
||||
lastPositionReport.current = Date.now();
|
||||
void reportNow();
|
||||
}
|
||||
});
|
||||
}, [reportNow]);
|
||||
|
||||
// Leaving the page hands the session back rather than stranding it on a tab
|
||||
// that is gone. The stream close does this too; this covers the browsers that
|
||||
// keep a closing connection alive long enough to matter.
|
||||
useEffect(() => {
|
||||
const release = () => {
|
||||
const id = deviceIdRef.current;
|
||||
if (id && ownerIdRef.current === id) void playbackSyncService.release(id);
|
||||
};
|
||||
window.addEventListener('pagehide', release);
|
||||
return () => window.removeEventListener('pagehide', release);
|
||||
}, []);
|
||||
|
||||
const transferTo = useCallback(async (target: string) => {
|
||||
// The target resumes from the last position on record, so flush the real
|
||||
// one first — otherwise handing the audio over rewinds it by up to
|
||||
// POSITION_REPORT_MS.
|
||||
if (ownerIdRef.current !== null && ownerIdRef.current === deviceIdRef.current) {
|
||||
await reportNow();
|
||||
}
|
||||
await playbackSyncService.transfer(target);
|
||||
}, [reportNow]);
|
||||
|
||||
const sendCommand = useCallback(async (command: PlaybackCommand) => {
|
||||
await playbackSyncService.sendCommand(command);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
deviceId,
|
||||
devices,
|
||||
isOwner: ownerId !== null && ownerId === deviceId,
|
||||
hasRemoteOwner: ownerId !== null && ownerId !== deviceId,
|
||||
remotePlaying,
|
||||
transferTo,
|
||||
sendCommand,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { renderHook, act } from '@testing-library/react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import type { ReactNode } from 'react';
|
||||
import { PlaybackSyncContext } from '../components/PlaybackSyncProvider';
|
||||
import type { PlaybackSyncApi } from './usePlaybackSync';
|
||||
import type { PlaybackCommand } from '../services/playbackSync';
|
||||
import { usePlaybackStore } from '../store/usePlaybackStore';
|
||||
import { useTransport } from './useTransport';
|
||||
|
||||
function wrapperFor(api: Partial<PlaybackSyncApi>) {
|
||||
const value = {
|
||||
deviceId: 'this-device',
|
||||
devices: [],
|
||||
isOwner: false,
|
||||
hasRemoteOwner: false,
|
||||
remotePlaying: false,
|
||||
transferTo: async () => undefined,
|
||||
sendCommand: async () => undefined,
|
||||
...api,
|
||||
} as PlaybackSyncApi;
|
||||
return ({ children }: { children: ReactNode }) => (
|
||||
<PlaybackSyncContext.Provider value={value}>{children}</PlaybackSyncContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
describe('useTransport', () => {
|
||||
it('plays here when this device holds the audio', () => {
|
||||
usePlaybackStore.setState({ isPlaying: false });
|
||||
const sendCommand = vi.fn();
|
||||
const { result } = renderHook(() => useTransport(), {
|
||||
wrapper: wrapperFor({ isOwner: true, hasRemoteOwner: false, sendCommand }),
|
||||
});
|
||||
|
||||
act(() => result.current.toggle());
|
||||
|
||||
expect(sendCommand).not.toHaveBeenCalled();
|
||||
expect(usePlaybackStore.getState().isPlaying).toBe(true);
|
||||
});
|
||||
|
||||
it('forwards the press when another device holds the audio', () => {
|
||||
// A watching device keeps its own audio paused, so what the button does
|
||||
// follows the remote state and never the local one.
|
||||
usePlaybackStore.setState({ isPlaying: false });
|
||||
const sendCommand = vi.fn(async (_command: PlaybackCommand) => undefined);
|
||||
const { result } = renderHook(() => useTransport(), {
|
||||
wrapper: wrapperFor({ hasRemoteOwner: true, remotePlaying: true, sendCommand }),
|
||||
});
|
||||
|
||||
expect(result.current.playing).toBe(true);
|
||||
|
||||
act(() => {
|
||||
result.current.toggle();
|
||||
result.current.next();
|
||||
result.current.seek(30);
|
||||
});
|
||||
|
||||
expect(sendCommand.mock.calls.map(([command]) => command)).toEqual([
|
||||
{ type: 'pause' },
|
||||
{ type: 'next' },
|
||||
{ type: 'seek', position: 30 },
|
||||
]);
|
||||
// The remote device is the one that stops; this one never started.
|
||||
expect(usePlaybackStore.getState().isPlaying).toBe(false);
|
||||
});
|
||||
|
||||
it('asks a paused remote device to play, whatever this device was doing', () => {
|
||||
usePlaybackStore.setState({ isPlaying: true });
|
||||
const sendCommand = vi.fn(async (_command: PlaybackCommand) => undefined);
|
||||
const { result } = renderHook(() => useTransport(), {
|
||||
wrapper: wrapperFor({ hasRemoteOwner: true, remotePlaying: false, sendCommand }),
|
||||
});
|
||||
|
||||
act(() => result.current.toggle());
|
||||
|
||||
expect(sendCommand).toHaveBeenCalledWith({ type: 'play' });
|
||||
});
|
||||
|
||||
it('plays here when there is no sync session at all', () => {
|
||||
usePlaybackStore.setState({ isPlaying: true });
|
||||
const { result } = renderHook(() => useTransport());
|
||||
|
||||
act(() => result.current.pause());
|
||||
|
||||
expect(usePlaybackStore.getState().isPlaying).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
import { useMemo } from 'react';
|
||||
import { usePlaybackStore } from '../store/usePlaybackStore';
|
||||
import { useOptionalPlaybackSync } from '../components/PlaybackSyncProvider';
|
||||
|
||||
/**
|
||||
* The transport every control set should call.
|
||||
*
|
||||
* While another device holds the audio, a press has to travel to that device
|
||||
* instead of starting a second stream here. Controls that reach into the
|
||||
* playback store directly work on the desktop and silently do nothing useful
|
||||
* from a phone, so there is one place that makes the choice.
|
||||
*/
|
||||
export interface Transport {
|
||||
play: () => void;
|
||||
pause: () => void;
|
||||
toggle: () => void;
|
||||
next: () => void;
|
||||
prev: () => void;
|
||||
seek: (seconds: number) => void;
|
||||
/** True when the presses are being forwarded rather than played here. */
|
||||
remote: boolean;
|
||||
/**
|
||||
* Whether the audio is playing, wherever it is. A watching device holds its
|
||||
* own store paused, so a control that reads `isPlaying` from the store draws a
|
||||
* Play button while the desktop plays — and then sends `play` when the
|
||||
* listener presses it, which is why pausing from a phone never worked. Every
|
||||
* control reads this instead.
|
||||
*/
|
||||
playing: boolean;
|
||||
}
|
||||
|
||||
export function useTransport(): Transport {
|
||||
const sync = useOptionalPlaybackSync();
|
||||
const hasRemoteOwner = sync?.hasRemoteOwner ?? false;
|
||||
const remotePlaying = sync?.remotePlaying ?? false;
|
||||
const sendCommand = sync?.sendCommand;
|
||||
const localPlaying = usePlaybackStore((state) => state.isPlaying);
|
||||
const playing = hasRemoteOwner ? remotePlaying : localPlaying;
|
||||
|
||||
return useMemo(() => {
|
||||
const local = () => usePlaybackStore.getState();
|
||||
const away = hasRemoteOwner && sendCommand !== undefined;
|
||||
// The owning device may hold the session without listening on it — a killed
|
||||
// tab keeps it until the sweep. The backend answers that with a 409, and a
|
||||
// rejected press is not worth an unhandled rejection.
|
||||
const send = (command: Parameters<NonNullable<typeof sendCommand>>[0]) =>
|
||||
void Promise.resolve(sendCommand!(command)).catch(() => undefined);
|
||||
return {
|
||||
play: () => (away ? send({ type: 'play' }) : local().play()),
|
||||
pause: () => (away ? send({ type: 'pause' }) : local().pause()),
|
||||
toggle: () => {
|
||||
if (away) send({ type: playing ? 'pause' : 'play' });
|
||||
else if (playing) local().pause();
|
||||
else local().play();
|
||||
},
|
||||
next: () => (away ? send({ type: 'next' }) : local().next()),
|
||||
prev: () => (away ? send({ type: 'prev' }) : local().prev()),
|
||||
seek: (seconds: number) =>
|
||||
away ? send({ type: 'seek', position: seconds }) : local().setPosition(seconds),
|
||||
remote: away,
|
||||
playing,
|
||||
};
|
||||
}, [hasRemoteOwner, playing, sendCommand]);
|
||||
}
|
||||
@@ -68,6 +68,9 @@
|
||||
--ethos-secondary: #B4AA98;
|
||||
--ethos-muted: #756C5C;
|
||||
--ethos-disabled: #5a5347;
|
||||
/* Mono default (Ethos law 1) — machine values sit a step above `muted` so a
|
||||
count or a duration stays legible without competing with the human label. */
|
||||
--ethos-machine: #9C917D;
|
||||
|
||||
/* muzick fingerprint: honey amber */
|
||||
--ethos-accent: #EDA24E;
|
||||
@@ -100,6 +103,38 @@ body {
|
||||
#root {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
min-height: 100dvh;
|
||||
}
|
||||
|
||||
button,
|
||||
a,
|
||||
input[type='range'] {
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
/* ── Safe area ────────────────────────────────────────────────────────────────
|
||||
Installed on Android the app paints edge to edge (viewport-fit=cover), so the
|
||||
gesture bar and any display cutout sit on top of the layout. These add the
|
||||
inset on top of whatever padding the element already carries. */
|
||||
.safe-b {
|
||||
padding-bottom: calc(var(--safe-b-base, 0px) + env(safe-area-inset-bottom));
|
||||
}
|
||||
|
||||
.safe-x {
|
||||
padding-left: calc(var(--safe-x-base, 0px) + env(safe-area-inset-left));
|
||||
padding-right: calc(var(--safe-x-base, 0px) + env(safe-area-inset-right));
|
||||
}
|
||||
|
||||
@media (pointer: coarse) {
|
||||
input[type='range']::-webkit-slider-thumb {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
margin-top: -7px;
|
||||
}
|
||||
input[type='range']::-moz-range-thumb {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Focus ring: 2px accent, for keyboard users only ──────────────────────── */
|
||||
@@ -164,6 +199,154 @@ html { scroll-behavior: smooth; }
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
/* Vibe's profile aura: a small, old-player-inspired creature whose color and
|
||||
movement come from the active recommendation state. */
|
||||
@keyframes vibe-aura-pulse {
|
||||
0%, 100% { transform: scale(var(--vibe-scale)) rotate(-7deg); filter: brightness(.9); }
|
||||
50% { transform: scale(calc(var(--vibe-scale) * 1.12)) rotate(8deg); filter: brightness(1.28); }
|
||||
}
|
||||
@keyframes vibe-aura-orbit {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
@keyframes vibe-aura-spark {
|
||||
0%, 100% { transform: translateY(0) scale(.75); opacity: .38; }
|
||||
50% { transform: translateY(-5px) scale(1.15); opacity: 1; }
|
||||
}
|
||||
.vibe-aura-halo {
|
||||
position: absolute;
|
||||
inset: 7px;
|
||||
border-radius: 9999px;
|
||||
background: hsl(var(--vibe-hue) 88% 62% / .22);
|
||||
filter: blur(12px);
|
||||
animation: vibe-aura-pulse var(--vibe-pulse) ease-in-out infinite;
|
||||
}
|
||||
.vibe-aura-core {
|
||||
position: relative;
|
||||
width: 30px;
|
||||
height: 36px;
|
||||
border: 1px solid hsl(var(--vibe-hue) 95% 86% / .72);
|
||||
border-radius: 46% 54% 58% 42% / 43% 45% 55% 57%;
|
||||
background: radial-gradient(circle at 36% 28%, hsl(var(--vibe-hue) 100% 93%), hsl(var(--vibe-hue) 88% 61%) 38%, hsl(calc(var(--vibe-hue) + 35) 68% 31%) 100%);
|
||||
box-shadow: inset 3px 2px 8px hsl(0 0% 100% / .34), 0 0 17px hsl(var(--vibe-hue) 92% 59% / .78);
|
||||
animation: vibe-aura-pulse var(--vibe-pulse) ease-in-out infinite;
|
||||
}
|
||||
.vibe-aura-orbit {
|
||||
position: absolute;
|
||||
width: 47px;
|
||||
height: 47px;
|
||||
border: 1px solid hsl(var(--vibe-hue) 90% 76% / .36);
|
||||
border-radius: 47% 53% 43% 57%;
|
||||
animation: vibe-aura-orbit var(--vibe-orbit) linear infinite;
|
||||
}
|
||||
.vibe-aura-orbit-two {
|
||||
width: 56px;
|
||||
height: 37px;
|
||||
border-color: hsl(calc(var(--vibe-hue) + 55) 90% 76% / .24);
|
||||
animation-direction: reverse;
|
||||
animation-duration: calc(var(--vibe-orbit) * 1.45);
|
||||
}
|
||||
.vibe-aura-heart,
|
||||
.vibe-aura-spark {
|
||||
position: absolute;
|
||||
display: block;
|
||||
border-radius: 9999px;
|
||||
background: hsl(0 0% 100% / .9);
|
||||
box-shadow: 0 0 8px hsl(0 0% 100% / .92);
|
||||
}
|
||||
.vibe-aura-heart { width: 7px; height: 7px; left: 11px; top: 15px; }
|
||||
.vibe-aura-spark { width: 4px; height: 4px; animation: vibe-aura-spark 2.1s ease-in-out infinite; }
|
||||
.vibe-aura-spark-one { left: -4px; top: 5px; }
|
||||
.vibe-aura-spark-two { right: -3px; bottom: 7px; animation-delay: -1s; }
|
||||
/* Ambient variant: a plasma in the page background, in the lineage of a media
|
||||
player visualizer. Three layers — a hot core, a cooler counter-rotating
|
||||
companion, and a fan of rays — stacked and pushed through one SVG turbulence
|
||||
displacement pass (see VibeAura.tsx), which is what turns clean gradients
|
||||
into churning flame. Centering lives on the wrapper, never here: these
|
||||
keyframes animate `transform` and would overwrite it. */
|
||||
/* Anchor lives here, not in utility classes: the offsets differ per breakpoint
|
||||
and the -50% centering pair sits on the same element, so keeping both in one
|
||||
place is what stops the two from fighting. Off to the right on purpose — the
|
||||
reading column stays clear and the plasma bleeds past the track rows. */
|
||||
.vibe-aura-blob {
|
||||
left: 62%;
|
||||
top: 52%;
|
||||
}
|
||||
@media (min-width: 640px) {
|
||||
.vibe-aura-blob { left: 80%; top: 56%; }
|
||||
}
|
||||
.vibe-aura-stack {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
/* Blur alone is the cheap variant, and the only one phones get. The
|
||||
displacement pass is added by .vibe-aura-stack-plasma, which VibeAura
|
||||
applies only when it has also mounted the filter it names. */
|
||||
filter: blur(14px);
|
||||
}
|
||||
.vibe-aura-stack-plasma {
|
||||
filter: url(#vibe-plasma) blur(14px);
|
||||
}
|
||||
.vibe-aura-layer {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: 50%;
|
||||
mix-blend-mode: screen;
|
||||
}
|
||||
@keyframes vibe-aura-churn {
|
||||
0% { transform: scale(var(--vibe-scale)) translate3d(-7%, -4%, 0) rotate(0deg); }
|
||||
33% { transform: scale(calc(var(--vibe-scale) * 1.22)) translate3d(6%, -8%, 0) rotate(120deg); }
|
||||
66% { transform: scale(calc(var(--vibe-scale) * 0.9)) translate3d(8%, 7%, 0) rotate(240deg); }
|
||||
100% { transform: scale(var(--vibe-scale)) translate3d(-7%, -4%, 0) rotate(360deg); }
|
||||
}
|
||||
@keyframes vibe-aura-spin {
|
||||
from { transform: rotate(0deg) scale(var(--vibe-scale)); }
|
||||
to { transform: rotate(-360deg) scale(var(--vibe-scale)); }
|
||||
}
|
||||
@keyframes vibe-aura-breathe {
|
||||
from { opacity: .55; transform: scale(.86); }
|
||||
to { opacity: 1; transform: scale(1.12); }
|
||||
}
|
||||
/* Hot core — near-white centre falling to the profile hue. */
|
||||
.vibe-aura-core-layer {
|
||||
background:
|
||||
radial-gradient(closest-side at 47% 44%, hsl(calc(var(--vibe-hue) + 24) 100% 82% / .95), hsl(var(--vibe-hue) 100% 58% / .75) 38%, transparent 70%),
|
||||
radial-gradient(closest-side at 58% 58%, hsl(calc(var(--vibe-hue) - 12) 100% 50% / .8), transparent 66%);
|
||||
animation: vibe-aura-churn var(--vibe-orbit) ease-in-out infinite;
|
||||
}
|
||||
/* Companion — turns the other way so the two shear against each other. */
|
||||
.vibe-aura-swirl-layer {
|
||||
background:
|
||||
radial-gradient(closest-side at 62% 40%, hsl(calc(var(--vibe-hue) - 20) 100% 54% / .7), transparent 68%),
|
||||
radial-gradient(closest-side at 36% 66%, hsl(calc(var(--vibe-hue) - 44) 96% 50% / .58), transparent 72%);
|
||||
animation: vibe-aura-churn calc(var(--vibe-orbit) * 1.6) ease-in-out infinite reverse;
|
||||
}
|
||||
/* Ray fan — the spikes the displacement pass bends into filaments. Masked to a
|
||||
ring so the centre stays a clean hot core. */
|
||||
.vibe-aura-rays-layer {
|
||||
background: repeating-conic-gradient(
|
||||
from 0deg,
|
||||
transparent 0deg 5deg,
|
||||
hsl(calc(var(--vibe-hue) + 8) 100% 66% / .5) 5deg 7.5deg
|
||||
);
|
||||
-webkit-mask-image: radial-gradient(closest-side, transparent 18%, #000 46%, transparent 82%);
|
||||
mask-image: radial-gradient(closest-side, transparent 18%, #000 46%, transparent 82%);
|
||||
animation: vibe-aura-spin calc(var(--vibe-orbit) * 2.4) linear infinite;
|
||||
}
|
||||
/* Pulse at the profile's tempo. This lives on the stack, not on the outer
|
||||
.vibe-aura-blob: that element carries the -translate-x-1/2 -translate-y-1/2
|
||||
centering, and an animation setting `transform` overwrites it, which anchors
|
||||
the plasma by its top-left corner instead of its middle. */
|
||||
.vibe-aura-stack {
|
||||
animation: vibe-aura-breathe var(--vibe-pulse) ease-in-out infinite alternate;
|
||||
}
|
||||
@media (min-width: 640px) {
|
||||
.vibe-aura-stack { filter: blur(20px); }
|
||||
.vibe-aura-stack-plasma { filter: url(#vibe-plasma) blur(20px); }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.vibe-aura-halo, .vibe-aura-core, .vibe-aura-orbit, .vibe-aura-spark,
|
||||
.vibe-aura-stack, .vibe-aura-layer { animation: none; }
|
||||
}
|
||||
|
||||
/* ── Component base classes ────────────────────────────────────────────────── */
|
||||
|
||||
/* Card surface — standard container */
|
||||
@@ -182,6 +365,25 @@ html { scroll-behavior: smooth; }
|
||||
border-color: color-mix(in srgb, var(--ethos-accent) 40%, transparent);
|
||||
}
|
||||
|
||||
/* Track list — one dense, hairline-separated column everywhere a list of tracks
|
||||
appears. Replaces the per-page `space-y-1` around bordered row cards, which
|
||||
cost 90px per track and fit only 7 rows on a 900px screen. */
|
||||
.track-list > * + * {
|
||||
border-top: 1px solid color-mix(in srgb, var(--ethos-border) 60%, transparent);
|
||||
}
|
||||
|
||||
/* Row hover: light falling off to the right, not a filled slab. A flat
|
||||
770px-wide rectangle with a hard right edge is the ugly version, and it also
|
||||
squares off the Vibe timeline's stacked artwork. */
|
||||
.track-row:hover {
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
color-mix(in srgb, var(--ethos-surface1) 85%, transparent) 0%,
|
||||
color-mix(in srgb, var(--ethos-surface1) 40%, transparent) 38%,
|
||||
transparent 78%
|
||||
);
|
||||
}
|
||||
|
||||
/* Artwork frame */
|
||||
.artwork-frame {
|
||||
aspect-ratio: 1 / 1;
|
||||
@@ -217,6 +419,40 @@ html { scroll-behavior: smooth; }
|
||||
}
|
||||
.group:hover .play-overlay-btn { transform: translateY(0); }
|
||||
|
||||
/* Settings library actions use a compact, stateful button rather than an
|
||||
unstyled native control. */
|
||||
.admin-btn {
|
||||
display: inline-flex;
|
||||
min-height: 36px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
border: 1px solid var(--ethos-border-hi);
|
||||
border-radius: 8px;
|
||||
padding: 8px 12px;
|
||||
background: var(--ethos-surface1);
|
||||
color: var(--ethos-text);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
transition: background 150ms ease, border-color 150ms ease, color 150ms ease;
|
||||
}
|
||||
.admin-btn:hover:not(:disabled) {
|
||||
background: var(--ethos-surface2);
|
||||
border-color: color-mix(in srgb, var(--ethos-accent) 50%, transparent);
|
||||
}
|
||||
.admin-btn:disabled { cursor: wait; opacity: 0.65; }
|
||||
.admin-btn--done { border-color: color-mix(in srgb, var(--ethos-green) 60%, transparent); color: var(--ethos-green); }
|
||||
.admin-btn--error { border-color: color-mix(in srgb, var(--ethos-red) 60%, transparent); color: var(--ethos-red); }
|
||||
|
||||
/* Keep row actions available on touch. On pointer-and-hover devices, reveal
|
||||
them when the row is hovered or any of its controls receives focus. */
|
||||
.track-row-actions { opacity: 1; transition: opacity 150ms ease; }
|
||||
@media (hover: hover) and (pointer: fine) {
|
||||
.track-row-actions { opacity: 0; }
|
||||
.group:hover .track-row-actions,
|
||||
.group:focus-within .track-row-actions { opacity: 1; }
|
||||
}
|
||||
|
||||
/* Transport button */
|
||||
.transport-btn {
|
||||
width: 40px;
|
||||
|
||||
@@ -6,11 +6,16 @@
|
||||
* as the single source of truth.
|
||||
*/
|
||||
|
||||
/** Deterministic hash → hue (0..359) from an arbitrary string. */
|
||||
/**
|
||||
* Deterministic hash → hue from an arbitrary string, clamped to the warm band
|
||||
* (amber → rust → deep red, 8°..52°). Free-running 0..359 hues produced blue,
|
||||
* teal and violet placeholder tiles that fight the warm room Ethos asks for;
|
||||
* a 44° window keeps tiles distinguishable without leaving the palette.
|
||||
*/
|
||||
export function hueFromString(s: string): number {
|
||||
let h = 0;
|
||||
for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) | 0;
|
||||
return Math.abs(h) % 360;
|
||||
return 8 + (Math.abs(h) % 45);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* Local playback preferences (this browser only), read synchronously at store
|
||||
* creation so the audio engine never runs a frame with the wrong values.
|
||||
*/
|
||||
|
||||
export const PLAYBACK_PREF_KEYS = {
|
||||
prefetchNext: 'muzick.settings.prefetchNext',
|
||||
crossfadeMs: 'muzick.settings.crossfadeMs',
|
||||
lastTrack: 'muzick.playback.lastTrack',
|
||||
} as const;
|
||||
|
||||
/** Longest fade the UI offers. Also the longest early-advance lead. */
|
||||
export const MAX_CROSSFADE_MS = 3000;
|
||||
|
||||
/** How far before the end of a track the next one starts buffering. */
|
||||
export const PREFETCH_LEAD_SECONDS = 20;
|
||||
|
||||
/**
|
||||
* How far into a track the next one starts buffering, whichever comes first
|
||||
* with PREFETCH_LEAD_SECONDS. A phone that sleeps mid-track gets the whole
|
||||
* remaining song to pull the next one down instead of a 20 second window.
|
||||
*/
|
||||
export const PREFETCH_START_SECONDS = 15;
|
||||
|
||||
export const PLAYBACK_PREF_DEFAULTS = {
|
||||
prefetchNext: true,
|
||||
/** Short by default: enough to hide the Vibe replan round-trip, short enough
|
||||
* that it reads as a join rather than a mix. */
|
||||
crossfadeMs: 400,
|
||||
} as const;
|
||||
|
||||
export function readStoredPrefetchNext(): boolean {
|
||||
try {
|
||||
const stored = localStorage.getItem(PLAYBACK_PREF_KEYS.prefetchNext);
|
||||
if (stored === 'true') return true;
|
||||
if (stored === 'false') return false;
|
||||
} catch { /* ignore */ }
|
||||
return PLAYBACK_PREF_DEFAULTS.prefetchNext;
|
||||
}
|
||||
|
||||
export function readStoredCrossfadeMs(): number {
|
||||
try {
|
||||
const stored = localStorage.getItem(PLAYBACK_PREF_KEYS.crossfadeMs);
|
||||
if (stored !== null) {
|
||||
const parsed = Number(stored);
|
||||
if (Number.isFinite(parsed)) return clampCrossfadeMs(parsed);
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
return PLAYBACK_PREF_DEFAULTS.crossfadeMs;
|
||||
}
|
||||
|
||||
export function clampCrossfadeMs(value: number): number {
|
||||
if (!Number.isFinite(value)) return 0;
|
||||
return Math.min(MAX_CROSSFADE_MS, Math.max(0, Math.round(value)));
|
||||
}
|
||||
|
||||
export function storePrefetchNext(value: boolean): void {
|
||||
try { localStorage.setItem(PLAYBACK_PREF_KEYS.prefetchNext, String(value)); } catch { /* ignore */ }
|
||||
}
|
||||
|
||||
export function storeCrossfadeMs(value: number): void {
|
||||
try { localStorage.setItem(PLAYBACK_PREF_KEYS.crossfadeMs, String(value)); } catch { /* ignore */ }
|
||||
}
|
||||
|
||||
/**
|
||||
* The last track loaded into the player, so a fresh tab shows what was playing
|
||||
* rather than an empty bar. Restored paused — never autoplayed.
|
||||
* ponytail: stores the whole track object. It is one small row and it saves a
|
||||
* fetch on boot; if the shape drifts, the parse simply fails and the bar is empty.
|
||||
*/
|
||||
export function readStoredLastTrack<T>(): T | null {
|
||||
try {
|
||||
const stored = localStorage.getItem(PLAYBACK_PREF_KEYS.lastTrack);
|
||||
return stored ? (JSON.parse(stored) as T) : null;
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
export function storeLastTrack(track: unknown): void {
|
||||
try {
|
||||
if (track) localStorage.setItem(PLAYBACK_PREF_KEYS.lastTrack, JSON.stringify(track));
|
||||
else localStorage.removeItem(PLAYBACK_PREF_KEYS.lastTrack);
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
@@ -46,14 +46,14 @@ export default function AlbumDetail() {
|
||||
return rank(x) - rank(y);
|
||||
});
|
||||
return (
|
||||
<PageContainer className="space-y-8">
|
||||
<PageContainer className="space-y-6">
|
||||
<BackLink to="/albums" label="Albums" />
|
||||
<div className="flex items-end gap-5">
|
||||
<div className="w-40 h-40 flex-none rounded-xl overflow-hidden">
|
||||
<Artwork seed={data.title} src={data.artwork_id} className="w-full h-full" rounded="xl" eager />
|
||||
<div className="flex items-end gap-4 border-b border-border pb-4">
|
||||
<div className="h-28 w-28 flex-none overflow-hidden rounded-lg sm:h-32 sm:w-32">
|
||||
<Artwork seed={data.title} src={data.artwork_id} className="w-full h-full" rounded="lg" eager />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<h1 className="text-4xl font-bold text-text">{data.title}</h1>
|
||||
<div className="min-w-0 space-y-1.5">
|
||||
<h1 className="truncate text-2xl font-semibold tracking-tight text-text sm:text-3xl">{data.title}</h1>
|
||||
{artists.length > 0 && (
|
||||
<ArtistLinks
|
||||
artists={artists}
|
||||
@@ -61,7 +61,7 @@ export default function AlbumDetail() {
|
||||
className="flex flex-wrap items-center gap-x-1 gap-y-0.5 text-sm"
|
||||
/>
|
||||
)}
|
||||
<p className="text-sm text-muted">{data.year ? `${data.year} · ` : ''}{tracks.length} {tracks.length === 1 ? 'track' : 'tracks'}</p>
|
||||
<p className="font-mono text-xs tabular-nums text-machine">{data.year ? `${data.year} · ` : ''}{tracks.length} tracks</p>
|
||||
<Button
|
||||
variant="primary"
|
||||
icon={<Play size={16} fill="currentColor" />}
|
||||
@@ -72,7 +72,7 @@ export default function AlbumDetail() {
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<section className="space-y-1">
|
||||
<section className="track-list">
|
||||
{tracks.length === 0 ? <EmptyState compact icon={<Music size={28} />} title="No tracks in this album" />
|
||||
: tracks.map((t, i) => <TrackRow key={t.id} track={t} queue={tracks} index={i} />)}
|
||||
</section>
|
||||
|
||||
@@ -25,23 +25,27 @@ export default function Albums() {
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<PageHeader icon={Disc3} title="Albums" subtitle={albums.length ? `${albums.length} on this page` : undefined} />
|
||||
<PageHeader
|
||||
title="Albums"
|
||||
meta={albums.length ? `${albums.length} shown · page ${page + 1}` : undefined}
|
||||
/>
|
||||
{isLoading ? <SkeletonGrid count={10} />
|
||||
: isError ? <EmptyState compact icon={<AlertCircle size={28} />} title="Couldn't load albums" subtitle="Something went wrong. Try reloading the page." />
|
||||
: !albums?.length ? <EmptyState compact icon={<Disc3 size={28} />} title="No albums yet" subtitle="Run a library scan in Settings to populate it." />
|
||||
: (
|
||||
<>
|
||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5">
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6">
|
||||
{albums.map((album) => (
|
||||
<Link key={album.id} to="/albums/$albumId" params={{ albumId: album.id }}
|
||||
className="card-surface group">
|
||||
<div className="artwork-frame">
|
||||
<Artwork seed={album.title} src={album.artwork_id} className="w-full h-full transition-transform duration-500 group-hover:scale-105" rounded="xl" />
|
||||
<Artwork seed={album.title} src={album.artwork_id} className="w-full h-full transition-transform duration-500 group-hover:scale-105" rounded="lg" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="truncate text-sm font-semibold text-text">{album.title}</div>
|
||||
<div className="truncate text-xs text-muted mt-0.5">
|
||||
{album.artist_name || 'Unknown artist'}{album.year ? ` · ${album.year}` : ''}
|
||||
<div className="truncate text-sm font-medium text-text" title={album.title}>{album.title}</div>
|
||||
<div className="mt-0.5 flex items-baseline gap-1.5 text-xs">
|
||||
<span className="truncate text-secondary">{album.artist_name || 'Unknown artist'}</span>
|
||||
{album.year && <span className="flex-none font-mono tabular-nums text-machine">{album.year}</span>}
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
@@ -23,28 +23,22 @@ export default function ArtistDetail() {
|
||||
<PageContainer width="lg" className="space-y-8">
|
||||
<BackLink to="/artists" label="Artists" />
|
||||
|
||||
{/* Hero: blurred backdrop of the artist image + avatar + name */}
|
||||
<div className="relative overflow-hidden rounded-3xl border border-border/70 animate-rise">
|
||||
<div className="absolute inset-0">
|
||||
<Artwork seed={data.name} src={data.image_path} className="h-full w-full scale-110 blur-2xl opacity-40" eager />
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-background via-background/70 to-background/30" />
|
||||
{/* ponytail: flat hero — the blurred-backdrop version was the frosted-glass trap */}
|
||||
<div className="animate-rise flex items-end gap-4 border-b border-border pb-4">
|
||||
<div className="h-24 w-24 flex-none overflow-hidden rounded-full sm:h-28 sm:w-28">
|
||||
<Artwork seed={data.name} src={data.image_path} className="h-full w-full" rounded="full" eager />
|
||||
</div>
|
||||
<div className="relative flex items-end gap-5 p-6 sm:p-8">
|
||||
<div className="h-28 w-28 sm:h-36 sm:w-36 flex-none rounded-full overflow-hidden ring-4 ring-background shadow-2xl shadow-black/50">
|
||||
<Artwork seed={data.name} src={data.image_path} className="h-full w-full" rounded="full" eager />
|
||||
</div>
|
||||
<div className="min-w-0 pb-1">
|
||||
<div className="text-xs font-semibold uppercase tracking-wider text-muted">Artist</div>
|
||||
<h1 className="text-gradient text-4xl sm:text-5xl font-extrabold tracking-tight truncate">{data.name}</h1>
|
||||
<p className="mt-1.5 text-sm text-muted">{albums.length} {albums.length === 1 ? 'album' : 'albums'}</p>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="text-[10px] font-semibold uppercase tracking-[0.1em] text-muted">Artist</div>
|
||||
<h1 className="truncate text-2xl font-semibold tracking-tight text-text sm:text-3xl">{data.name}</h1>
|
||||
<p className="mt-1 font-mono text-xs tabular-nums text-machine">{albums.length} albums</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section className="space-y-4">
|
||||
<h2 className="text-xl font-semibold text-text">Albums</h2>
|
||||
<section className="space-y-3">
|
||||
<h2 className="text-[11px] font-semibold uppercase tracking-[0.1em] text-muted">Albums</h2>
|
||||
{albums.length === 0 ? <EmptyState compact icon={<Disc3 size={28} />} title="No albums by this artist" /> : (
|
||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5">
|
||||
<div className="grid grid-cols-3 gap-3 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-6 xl:grid-cols-7">
|
||||
{albums.map((album) => (
|
||||
<Link key={album.id} to="/albums/$albumId" params={{ albumId: album.id }}
|
||||
className="card-surface group">
|
||||
|
||||
@@ -25,20 +25,23 @@ export default function Artists() {
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<PageHeader icon={Users} title="Artists" subtitle="Browse by artist" />
|
||||
<PageHeader
|
||||
title="Artists"
|
||||
meta={artists.length ? `${artists.length} shown · page ${page + 1}` : undefined}
|
||||
/>
|
||||
{isLoading ? <SkeletonGrid count={10} />
|
||||
: isError ? <EmptyState compact icon={<AlertCircle size={28} />} title="Couldn't load artists" subtitle="Something went wrong. Try reloading the page." />
|
||||
: !artists?.length ? <EmptyState compact icon={<Users size={28} />} title="No artists yet" subtitle="Run a library scan in Settings to populate it." />
|
||||
: (
|
||||
<>
|
||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5">
|
||||
<div className="grid grid-cols-3 gap-3 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-6 xl:grid-cols-8">
|
||||
{artists.map((artist) => (
|
||||
<Link key={artist.id} to="/artists/$artistId" params={{ artistId: artist.id }}
|
||||
className="card-surface group items-center p-4">
|
||||
<div className="w-24 h-24 rounded-full overflow-hidden ring-2 ring-transparent group-hover:ring-accent/40 shadow-lg shadow-black/30 transition-all">
|
||||
className="card-surface group items-center p-2">
|
||||
<div className="aspect-square w-full overflow-hidden rounded-full ring-1 ring-border transition-all group-hover:ring-accent/40">
|
||||
<Artwork seed={artist.name} src={artist.image_path} className="w-full h-full transition-transform duration-500 group-hover:scale-105" rounded="full" />
|
||||
</div>
|
||||
<div className="text-sm font-semibold text-text truncate w-full text-center group-hover:text-accent transition-colors">{artist.name}</div>
|
||||
<div className="w-full truncate text-center text-xs font-medium text-text transition-colors group-hover:text-accent" title={artist.name}>{artist.name}</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -1,120 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Disc3, Sparkles } from 'lucide-react';
|
||||
import { genreService } from '../services/genreService';
|
||||
import { vibeService } from '../services/vibeService';
|
||||
import { usePlaybackStore } from '../store/usePlaybackStore';
|
||||
import { useVibeStore } from '../store/useVibeStore';
|
||||
import { TrackRow } from '../components/TrackRow';
|
||||
import { PageContainer } from '../components/PageContainer';
|
||||
import type { Genre, Track } from '../types';
|
||||
|
||||
export default function Discover() {
|
||||
const [selected, setSelected] = useState<Genre | null>(null);
|
||||
const { setQueue, playTrack } = usePlaybackStore();
|
||||
const { setActiveSession, setSeedTrackId, setBuffer } = useVibeStore();
|
||||
|
||||
const genres = useQuery<Genre[]>({
|
||||
queryKey: ['genres'],
|
||||
queryFn: () => genreService.listGenres(),
|
||||
});
|
||||
|
||||
const genreTracks = useQuery<Track[]>({
|
||||
queryKey: ['genre-tracks', selected?.id],
|
||||
queryFn: () => genreService.getGenreTracks(selected!.id),
|
||||
enabled: !!selected,
|
||||
});
|
||||
|
||||
const startGenreVibe = async () => {
|
||||
const tracks = genreTracks.data;
|
||||
if (!tracks || tracks.length === 0) return;
|
||||
const seed = tracks[0];
|
||||
try {
|
||||
const { sessionId } = await vibeService.start(seed.id);
|
||||
setActiveSession({ sessionId, seedTrackId: seed.id });
|
||||
setSeedTrackId(seed.id);
|
||||
setBuffer(tracks);
|
||||
setQueue(tracks);
|
||||
playTrack(tracks[0]);
|
||||
} catch {
|
||||
setQueue(tracks);
|
||||
playTrack(tracks[0]);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<header className="space-y-1">
|
||||
<h1 className="text-3xl font-bold text-text">Discover</h1>
|
||||
<p className="text-muted">Browse by genre, then play tracks or start a vibe.</p>
|
||||
</header>
|
||||
|
||||
{genres.isLoading ? (
|
||||
<p className="text-sm text-muted">Loading genres…</p>
|
||||
) : genres.isError ? (
|
||||
<p className="text-sm text-muted">Couldn't load genres.</p>
|
||||
) : (genres.data ?? []).length === 0 ? (
|
||||
<p className="text-sm text-muted">No genres available yet.</p>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4">
|
||||
{(genres.data ?? []).map((genre) => {
|
||||
const active = selected?.id === genre.id;
|
||||
return (
|
||||
<button
|
||||
key={genre.id}
|
||||
onClick={() => setSelected(genre)}
|
||||
className={`flex flex-col gap-2 rounded-xl border p-4 text-left transition-colors ${
|
||||
active
|
||||
? 'border-accent/60 bg-accent/10'
|
||||
: 'border-border bg-surface0 hover:bg-surface1'
|
||||
}`}
|
||||
>
|
||||
<Disc3 size={22} className={active ? 'text-accent' : 'text-muted'} />
|
||||
<div className="truncate font-semibold text-text">{genre.name}</div>
|
||||
{typeof genre.track_count === 'number' && (
|
||||
<div className="text-xs text-muted">{genre.track_count} tracks</div>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selected && (
|
||||
<section className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-xl font-semibold text-text">{selected.name}</h2>
|
||||
<button
|
||||
onClick={() => void startGenreVibe()}
|
||||
disabled={!genreTracks.data || genreTracks.data.length === 0}
|
||||
className="flex items-center gap-2 rounded-lg border border-accent/60 bg-accent/10 px-3 py-1.5 text-sm font-medium text-accent transition-colors hover:bg-accent/20 disabled:opacity-50"
|
||||
>
|
||||
<Sparkles size={16} />
|
||||
Start a vibe
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{genreTracks.isLoading ? (
|
||||
<p className="text-sm text-muted">Loading tracks…</p>
|
||||
) : genreTracks.isError ? (
|
||||
<p className="text-sm text-muted">Couldn't load tracks for this genre.</p>
|
||||
) : (genreTracks.data ?? []).length === 0 ? (
|
||||
<p className="text-sm text-muted">No tracks found for this genre.</p>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{(genreTracks.data ?? []).map((track, i) => (
|
||||
<TrackRow
|
||||
key={`${track.id}-${i}`}
|
||||
track={track}
|
||||
queue={genreTracks.data ?? []}
|
||||
index={i}
|
||||
showActions={false}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
@@ -7,14 +7,46 @@ import { TrackRow } from '../components/TrackRow';
|
||||
import { BackLink } from '../components/BackLink';
|
||||
import { Button } from '../components/ethos/Button';
|
||||
import { PageContainer } from '../components/PageContainer';
|
||||
import { PageHeader } from '../components/PageHeader';
|
||||
import { Pagination } from '../components/Pagination';
|
||||
import { EmptyState } from '../components/EmptyState';
|
||||
import { SkeletonRows, SkeletonGrid } from '../components/LoadingState';
|
||||
import { hueFromString } from '../lib/color';
|
||||
import type { Genre, Track } from '../types';
|
||||
|
||||
const GENRE_TRACKS_PAGE_SIZE = 50;
|
||||
|
||||
/**
|
||||
* One genre entry. Flat surface, hairline separation, mono count — the previous
|
||||
* version painted every card with a hue derived from its name, which turned the
|
||||
* page into 40 unrelated colour fields and read as decoration, not information.
|
||||
*/
|
||||
function GenreButton({
|
||||
genre,
|
||||
onSelect,
|
||||
primary = false,
|
||||
}: {
|
||||
genre: Genre;
|
||||
onSelect: (g: Genre) => void;
|
||||
primary?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
onClick={() => onSelect(genre)}
|
||||
className={`group flex w-full items-center gap-2.5 bg-bg1 px-3 text-left transition-colors hover:bg-surface0 ${
|
||||
primary ? 'h-12 rounded-t-lg' : 'h-11'
|
||||
}`}
|
||||
>
|
||||
<Tag size={primary ? 16 : 14} className="flex-none text-muted transition-colors group-hover:text-accent" />
|
||||
<span className={`min-w-0 flex-1 truncate ${primary ? 'text-sm font-medium text-text' : 'text-xs text-secondary group-hover:text-text'}`}>
|
||||
{genre.name}
|
||||
</span>
|
||||
<span className="flex-none font-mono text-xs tabular-nums text-machine">
|
||||
{(genre.track_count ?? 0).toLocaleString()}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Genres() {
|
||||
const [selected, setSelected] = useState<Genre | null>(null);
|
||||
const [genrePage, setGenrePage] = useState(0);
|
||||
@@ -36,22 +68,25 @@ export default function Genres() {
|
||||
return (
|
||||
<PageContainer>
|
||||
<BackLink to="/genres" label="Genres" />
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="flex items-center gap-3 text-3xl font-bold text-text"><Tag size={28} className="text-accent" />{selected.name}</h1>
|
||||
<Button
|
||||
variant="primary"
|
||||
icon={<Play size={16} fill="currentColor" />}
|
||||
onClick={() => { if (tracks.length) { setQueue(tracks); playTrack(tracks[0]); } }}
|
||||
disabled={!tracks.length}
|
||||
>
|
||||
Play all
|
||||
</Button>
|
||||
</div>
|
||||
<PageHeader
|
||||
title={selected.name}
|
||||
meta={`${(selected.track_count ?? tracks.length).toLocaleString()} tracks`}
|
||||
actions={
|
||||
<Button
|
||||
variant="primary"
|
||||
icon={<Play size={16} fill="currentColor" />}
|
||||
onClick={() => { if (tracks.length) { setQueue(tracks); playTrack(tracks[0]); } }}
|
||||
disabled={!tracks.length}
|
||||
>
|
||||
Play all
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
{tracksQ.isLoading ? <SkeletonRows count={8} />
|
||||
: tracks.length === 0 ? <EmptyState compact icon={<Music size={28} />} title="No tracks in this genre" />
|
||||
: (
|
||||
<>
|
||||
<div className="space-y-1">{tracks.map((t, i) => <TrackRow key={t.id} track={t} queue={tracks} index={i} />)}</div>
|
||||
<div className="track-list">{tracks.map((t, i) => <TrackRow key={t.id} track={t} queue={tracks} index={i} />)}</div>
|
||||
<Pagination
|
||||
page={genrePage}
|
||||
hasNext={hasNext}
|
||||
@@ -68,60 +103,44 @@ export default function Genres() {
|
||||
const genres = genresQ.data ?? [];
|
||||
const roots = genres.filter((g) => !g.parent_id);
|
||||
const childrenOf = (id: string) => genres.filter((g) => g.parent_id === id);
|
||||
const orphans = genres.filter((g) => g.parent_id && !genres.some((p) => p.id === g.parent_id));
|
||||
const flat = [...roots.filter((g) => childrenOf(g.id).length === 0), ...orphans];
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<h1 className="flex items-center gap-3 text-3xl font-bold text-text"><Tag size={28} className="text-accent" />Genres</h1>
|
||||
<PageHeader
|
||||
title="Genres"
|
||||
meta={genres.length ? `${genres.length} genres · ${roots.length} top level` : undefined}
|
||||
/>
|
||||
{genresQ.isLoading ? <SkeletonGrid count={10} />
|
||||
: genresQ.isError ? <EmptyState compact icon={<AlertCircle size={28} />} title="Couldn't load genres" subtitle="Something went wrong. Try reloading the page." />
|
||||
: !genres.length ? <EmptyState compact icon={<Tag size={28} />} title="No genres yet" subtitle="Genres appear after you scan and enrich your library." />
|
||||
: (
|
||||
<div className="space-y-6">
|
||||
{roots.map((genre) => {
|
||||
const hue = hueFromString(genre.name);
|
||||
<div className="space-y-4">
|
||||
{roots.filter((g) => childrenOf(g.id).length > 0).map((genre) => {
|
||||
const subs = childrenOf(genre.id);
|
||||
return (
|
||||
<div key={genre.id} className="space-y-2">
|
||||
<button onClick={() => setSelected(genre)}
|
||||
className="group flex items-center gap-3 rounded-xl border border-border/70 p-4 w-full text-left transition-colors hover:border-accent/40"
|
||||
style={{ background: `linear-gradient(135deg, hsl(${hue},40%,15%), hsl(${(hue+60)%360},30%,10%))` }}>
|
||||
<Tag size={20} className="text-muted group-hover:text-accent flex-none" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate font-medium text-text">{genre.name}</div>
|
||||
<div className="text-xs text-muted">{genre.track_count ?? 0} tracks</div>
|
||||
</div>
|
||||
</button>
|
||||
<section key={genre.id} className="rounded-lg border border-border bg-surface0/40">
|
||||
<GenreButton genre={genre} onSelect={setSelected} primary />
|
||||
{subs.length > 0 && (
|
||||
<div className="ml-6 grid grid-cols-2 gap-2 sm:grid-cols-3 md:grid-cols-4">
|
||||
{subs.map((sub) => {
|
||||
const subHue = hueFromString(sub.name);
|
||||
return (
|
||||
<button key={sub.id} onClick={() => setSelected(sub)}
|
||||
className="group flex flex-col items-start gap-1 rounded-lg border border-border/70 p-3 text-left transition-colors hover:border-accent/40"
|
||||
style={{ background: `linear-gradient(135deg, hsl(${subHue},30%,12%), hsl(${(subHue+60)%360},25%,8%))` }}>
|
||||
<div className="w-full truncate text-sm font-medium text-text">{sub.name}</div>
|
||||
<div className="text-xs text-muted">{sub.track_count ?? 0} tracks</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
<div className="grid grid-cols-2 gap-px border-t border-border bg-border/40 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5">
|
||||
{subs.map((sub) => (
|
||||
<GenreButton key={sub.id} genre={sub} onSelect={setSelected} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{/* Orphan genres (parent_id set but parent not in list) fall back to flat display */}
|
||||
{genres.filter((g) => g.parent_id && !genres.find((p) => p.id === g.parent_id)).map((genre) => {
|
||||
const hue = hueFromString(genre.name);
|
||||
return (
|
||||
<button key={genre.id} onClick={() => setSelected(genre)}
|
||||
className="group flex flex-col items-start gap-2 rounded-xl border border-border/70 p-4 text-left transition-colors hover:border-accent/40"
|
||||
style={{ background: `linear-gradient(135deg, hsl(${hue},40%,15%), hsl(${(hue+60)%360},30%,10%))` }}>
|
||||
<Tag size={20} className="text-muted group-hover:text-accent" />
|
||||
<div className="w-full truncate font-medium text-text">{genre.name}</div>
|
||||
<div className="text-xs text-muted">{genre.track_count ?? 0} tracks</div>
|
||||
</button>
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
{/* Childless roots + orphans (parent missing from the list) share one grid —
|
||||
a section per genre wastes a full row on a single tag. */}
|
||||
{flat.length > 0 && (
|
||||
<div className="grid grid-cols-2 gap-px overflow-hidden rounded-lg border border-border bg-border/40 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5">
|
||||
{flat.map((genre) => (
|
||||
<GenreButton key={genre.id} genre={genre} onSelect={setSelected} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</PageContainer>
|
||||
|
||||
+32
-31
@@ -1,20 +1,22 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Link } from '@tanstack/react-router';
|
||||
import { Zap, Music, Disc3, Users } from 'lucide-react';
|
||||
import { Zap } from 'lucide-react';
|
||||
import { ShelfRow } from '../components/ShelfRow';
|
||||
import { MediaCard } from '../components/MediaCard';
|
||||
import { PageContainer } from '../components/PageContainer';
|
||||
import { Skeleton } from '../components/LoadingState';
|
||||
import { historyService } from '../services/historyService';
|
||||
import { trackService } from '../services/trackService';
|
||||
import { fetchLibraryStats, type LibraryStats } from '../services/libraryService';
|
||||
import { usePlaybackStore } from '../store/usePlaybackStore';
|
||||
import type { HistoryEntry, Track } from '../types';
|
||||
|
||||
const SHORTCUTS = [
|
||||
{ label: 'Songs', icon: Music, to: '/tracks' as const },
|
||||
{ label: 'Albums', icon: Disc3, to: '/albums' as const },
|
||||
{ label: 'Artists', icon: Users, to: '/artists' as const },
|
||||
];
|
||||
/** `128h 04m` — total library playtime, sized to read at a glance. */
|
||||
function formatTotalTime(seconds: number): string {
|
||||
const hours = Math.floor(seconds / 3600);
|
||||
const minutes = Math.floor((seconds % 3600) / 60);
|
||||
return `${hours.toLocaleString()}h ${minutes.toString().padStart(2, '0')}m`;
|
||||
}
|
||||
|
||||
/** A row of placeholder cards matching MediaCard's shelf width. */
|
||||
function ShelfSkeleton({ count = 6 }: { count?: number }) {
|
||||
@@ -44,6 +46,17 @@ export default function Home() {
|
||||
queryFn: () => trackService.listTracks({ limit: 12, sort_by: 'play_count', order: 'DESC' }),
|
||||
});
|
||||
|
||||
const stats = useQuery<LibraryStats>({
|
||||
queryKey: ['library-stats'],
|
||||
queryFn: fetchLibraryStats,
|
||||
staleTime: 60_000,
|
||||
});
|
||||
|
||||
const counts = stats.data
|
||||
? `${stats.data.tracks.toLocaleString()} tracks · ${stats.data.albums.toLocaleString()} albums · ` +
|
||||
`${stats.data.artists.toLocaleString()} artists · ${formatTotalTime(stats.data.duration)}`
|
||||
: null;
|
||||
|
||||
const playFrom = (list: Track[], index: number) => {
|
||||
setQueue(list.slice(index));
|
||||
playTrack(list[index]);
|
||||
@@ -53,35 +66,23 @@ export default function Home() {
|
||||
|
||||
return (
|
||||
<PageContainer width="lg" className="space-y-8">
|
||||
<div className="animate-rise">
|
||||
<h1 className="text-gradient text-4xl font-extrabold tracking-tight">Good listening</h1>
|
||||
<p className="text-muted mt-1.5">Your music, your way.</p>
|
||||
</div>
|
||||
|
||||
{/* Vibe hero + quick shortcuts */}
|
||||
<section className="grid gap-3 sm:grid-cols-[2fr_3fr]">
|
||||
{/* Vibe hero. The three library shortcuts that used to sit beside it were
|
||||
duplicates of the nav rail two inches to the left — the space now goes
|
||||
to the one action this page is for, plus what the library actually holds. */}
|
||||
<section className="animate-rise flex flex-wrap items-end justify-between gap-4 border-b border-border pb-4">
|
||||
<div className="min-w-0">
|
||||
<h1 className="text-3xl font-semibold tracking-tight text-text sm:text-4xl">Good listening</h1>
|
||||
<p className="mt-1.5 font-mono text-xs tabular-nums text-machine">
|
||||
{counts ?? 'reading library…'}
|
||||
</p>
|
||||
</div>
|
||||
<Link
|
||||
to="/vibe"
|
||||
className="group relative overflow-hidden rounded-2xl border border-accent/30 bg-gradient-to-br from-accent/30 to-accent/5 p-5 flex flex-col justify-between min-h-[7rem] transition-all hover:-translate-y-0.5 hover:shadow-xl hover:shadow-accent/10"
|
||||
className="group flex flex-none items-center gap-2.5 rounded-lg border border-accent/40 bg-accent/10 px-4 py-2.5 text-sm font-medium text-accent transition-colors hover:bg-accent/20"
|
||||
>
|
||||
<Zap size={22} className="text-accent transition-transform group-hover:scale-110" />
|
||||
<div>
|
||||
<div className="text-lg font-bold text-text">Start a Vibe</div>
|
||||
<div className="text-xs text-muted">Endless recommendations from your library.</div>
|
||||
</div>
|
||||
<Zap size={18} className="transition-transform group-hover:scale-110" />
|
||||
Start a Vibe
|
||||
</Link>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
{SHORTCUTS.map(({ label, icon: Icon, to }) => (
|
||||
<Link
|
||||
key={to}
|
||||
to={to}
|
||||
className="group flex flex-col items-center justify-center gap-2 rounded-2xl border border-border/70 bg-surface0/60 p-4 transition-all hover:-translate-y-0.5 hover:border-accent/40 hover:bg-surface1"
|
||||
>
|
||||
<Icon size={22} className="text-muted transition-colors group-hover:text-accent" />
|
||||
<span className="text-sm font-semibold text-text">{label}</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<ShelfRow title="Continue Listening" viewAllTo="/tracks">
|
||||
|
||||
+58
-33
@@ -2,13 +2,9 @@ import { useQuery } from '@tanstack/react-query';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
Activity,
|
||||
Play,
|
||||
Pause,
|
||||
RotateCcw,
|
||||
Terminal,
|
||||
CheckCircle,
|
||||
AlertCircle,
|
||||
Clock,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Copy,
|
||||
@@ -61,16 +57,32 @@ function formatDuration(ms: number): string {
|
||||
|
||||
/* ─────────────────────────────────────────── Sub-components ──────────────────────────────────────── */
|
||||
|
||||
function StatCard({ icon: Icon, label, value, color }: { icon: React.ComponentType<{ className?: string; style?: React.CSSProperties }>; label: string; value: number; color: string }) {
|
||||
/**
|
||||
* One queue counter. Every tile used to carry its own hue and a giant ghost icon,
|
||||
* which made a queue at rest look like an alarm panel. Now the number is mono and
|
||||
* neutral; colour appears only when the value is one that wants attention.
|
||||
*/
|
||||
function StatCard({
|
||||
label,
|
||||
value,
|
||||
tone = 'neutral',
|
||||
}: {
|
||||
label: string;
|
||||
value: number;
|
||||
tone?: 'neutral' | 'active' | 'bad';
|
||||
}) {
|
||||
const live = value > 0;
|
||||
const valueColor = !live
|
||||
? 'text-disabled'
|
||||
: tone === 'bad'
|
||||
? 'text-red'
|
||||
: tone === 'active'
|
||||
? 'text-accent'
|
||||
: 'text-text';
|
||||
return (
|
||||
<div className="bg-surface0 border border-border rounded-lg p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-xs text-muted uppercase tracking-wide">{label}</p>
|
||||
<p className="text-3xl font-bold mt-1" style={{ color }}>{value.toLocaleString()}</p>
|
||||
</div>
|
||||
<Icon className="w-8 h-8 opacity-30" style={{ color }} />
|
||||
</div>
|
||||
<div className="rounded-lg border border-border bg-surface0/50 px-3 py-2.5">
|
||||
<p className="text-[10px] font-semibold uppercase tracking-[0.1em] text-muted">{label}</p>
|
||||
<p className={`mt-1 font-mono text-2xl tabular-nums ${valueColor}`}>{value.toLocaleString()}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -384,29 +396,38 @@ export default function JobsPage() {
|
||||
return (
|
||||
<div className="flex-1 flex flex-col overflow-hidden">
|
||||
{/* ── Header ── */}
|
||||
<div className="flex items-center justify-between p-4 border-b border-border">
|
||||
<div className="flex flex-wrap items-end justify-between gap-3 border-b border-border p-4">
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold">Jobs</h1>
|
||||
<p className="text-sm text-muted">Background task queue monitoring</p>
|
||||
<h1 className="text-2xl font-semibold tracking-tight text-text sm:text-3xl">Jobs</h1>
|
||||
<p className="mt-1 font-mono text-xs tabular-nums text-machine">
|
||||
{stats
|
||||
? `${stats.waiting + stats.active + stats.delayed} queued · ${stats.failed} failed`
|
||||
: 'reading queue…'}
|
||||
{autoRefresh ? ' · refresh 5s' : ' · refresh off'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => { refetchStats(); refetchHistory(); }}
|
||||
className="flex items-center gap-1.5 text-xs text-muted hover:text-text transition-colors"
|
||||
className="flex items-center gap-1.5 rounded-lg border border-border px-3 py-2 text-xs text-secondary transition-colors hover:border-accent/40 hover:text-text"
|
||||
title="Refresh now"
|
||||
>
|
||||
<RefreshCw className="w-3.5 h-3.5" />
|
||||
Refresh
|
||||
</button>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={autoRefresh}
|
||||
onChange={(e) => setAutoRefresh(e.target.checked)}
|
||||
className="w-4 h-4 accent-accent"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setAutoRefresh(!autoRefresh)}
|
||||
aria-pressed={autoRefresh}
|
||||
className={`flex items-center gap-2 rounded-lg border px-3 py-2 text-xs transition-colors ${
|
||||
autoRefresh
|
||||
? 'border-accent/50 bg-accent/10 text-accent'
|
||||
: 'border-border text-secondary hover:text-text'
|
||||
}`}
|
||||
>
|
||||
<span className={`h-1.5 w-1.5 rounded-full ${autoRefresh ? 'bg-accent' : 'bg-disabled'}`} />
|
||||
Auto-refresh
|
||||
</label>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -451,16 +472,20 @@ export default function JobsPage() {
|
||||
|
||||
{/* ── Overview tab ── */}
|
||||
{selectedTab === 'overview' && !stats && !loadError && (
|
||||
<div className="text-center py-16 text-muted text-sm">Loading queue stats…</div>
|
||||
<div className="grid grid-cols-2 gap-4 md:grid-cols-3 lg:grid-cols-6">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<div key={i} className="skeleton h-[70px] rounded-lg" />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{selectedTab === 'overview' && stats && (
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-4">
|
||||
<StatCard icon={Clock} label="Waiting" value={stats.waiting} color="#eab308" />
|
||||
<StatCard icon={Play} label="Active" value={stats.active} color="#a78bfa" />
|
||||
<StatCard icon={CheckCircle} label="Completed" value={stats.completed} color="#22c55e" />
|
||||
<StatCard icon={AlertCircle} label="Failed" value={stats.failed} color="#f87171" />
|
||||
<StatCard icon={RotateCcw} label="Delayed" value={stats.delayed} color="#60a5fa" />
|
||||
<StatCard icon={Pause} label="Paused" value={stats.paused} color="#6b7280" />
|
||||
<StatCard label="Waiting" value={stats.waiting} />
|
||||
<StatCard label="Active" value={stats.active} tone="active" />
|
||||
<StatCard label="Completed" value={stats.completed} />
|
||||
<StatCard label="Failed" value={stats.failed} tone="bad" />
|
||||
<StatCard label="Delayed" value={stats.delayed} />
|
||||
<StatCard label="Paused" value={stats.paused} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { ShieldAlert, RotateCcw, Trash2, Clock, AlertCircle } from 'lucide-react';
|
||||
import { ShieldAlert, RotateCcw, Trash2, AlertCircle } from 'lucide-react';
|
||||
import { quarantineService } from '../services/quarantineService';
|
||||
import { Badge } from '../components/ethos/Badge';
|
||||
import { PageContainer } from '../components/PageContainer';
|
||||
import { PageHeader } from '../components/PageHeader';
|
||||
import { EmptyState } from '../components/EmptyState';
|
||||
import { SkeletonRows } from '../components/LoadingState';
|
||||
import { toast } from '../store/useToastStore';
|
||||
@@ -56,12 +57,11 @@ export default function Quarantine() {
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<div>
|
||||
<h1 className="flex items-center gap-3 text-3xl font-bold text-text">
|
||||
<ShieldAlert size={28} className="text-accent" /> Quarantine
|
||||
</h1>
|
||||
<p className="text-muted mt-1">Disliked tracks pending deletion. Restore before the timer expires.</p>
|
||||
</div>
|
||||
<PageHeader
|
||||
title="Quarantine"
|
||||
subtitle="Disliked tracks pending deletion. Restore before the timer expires."
|
||||
meta={entries.length ? `${entries.length} held` : undefined}
|
||||
/>
|
||||
|
||||
{isLoading ? <SkeletonRows count={3} />
|
||||
: isError ? <EmptyState compact icon={<AlertCircle size={28} />} title="Couldn't load quarantine list" subtitle="Something went wrong. Try reloading the page." />
|
||||
@@ -72,25 +72,23 @@ export default function Quarantine() {
|
||||
subtitle="Disliked tracks will appear here during the grace period before being deleted."
|
||||
/>
|
||||
) : (
|
||||
<ul className="space-y-2">
|
||||
<ul className="track-list rounded-lg border border-border bg-surface0/40 px-1.5">
|
||||
{entries.map((entry) => (
|
||||
<li key={entry.track_id} className="flex items-center gap-3 rounded-lg border border-border bg-surface0 p-3">
|
||||
<li key={entry.track_id} className="flex h-14 items-center gap-2.5">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-sm font-semibold text-text truncate">{entry.track_title}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="truncate text-sm font-medium text-text">{entry.track_title}</span>
|
||||
{stateLabel(entry.state)}
|
||||
</div>
|
||||
<div className="text-xs text-muted">{entry.track_artist}</div>
|
||||
<div className="flex items-center gap-1 mt-1 text-xs text-muted">
|
||||
<Clock size={12} /> {countdown(entry)}
|
||||
</div>
|
||||
<div className="truncate text-xs text-secondary">{entry.track_artist}</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<span className="flex-none font-mono text-xs tabular-nums text-machine">{countdown(entry)}</span>
|
||||
<div className="flex flex-none items-center gap-1.5">
|
||||
<button
|
||||
onClick={() => restore.mutate(entry.track_id)}
|
||||
disabled={restore.isPending}
|
||||
title="Restore to library"
|
||||
className="flex items-center gap-1.5 rounded-lg border border-border px-3 py-1.5 text-sm text-text hover:bg-surface1 disabled:opacity-50 transition-colors"
|
||||
className="flex items-center gap-1.5 rounded-md border border-border px-2 py-1 text-xs text-text transition-colors hover:bg-surface1 disabled:opacity-50"
|
||||
>
|
||||
<RotateCcw size={14} /> Restore
|
||||
</button>
|
||||
@@ -98,7 +96,7 @@ export default function Quarantine() {
|
||||
onClick={() => { if (confirm(`Permanently delete "${entry.track_title}"?`)) hardDelete.mutate(entry.track_id); }}
|
||||
disabled={hardDelete.isPending}
|
||||
title="Delete now"
|
||||
className="flex items-center gap-1.5 rounded-lg border border-red-500/40 px-3 py-1.5 text-sm text-red-400 hover:bg-red-500/10 disabled:opacity-50 transition-colors"
|
||||
className="flex items-center gap-1.5 rounded-md border border-red-500/40 px-2 py-1 text-xs text-red-400 transition-colors hover:bg-red-500/10 disabled:opacity-50"
|
||||
>
|
||||
<Trash2 size={14} /> Delete
|
||||
</button>
|
||||
|
||||
@@ -0,0 +1,239 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { AlertCircle, Compass } from 'lucide-react';
|
||||
import { discoveryService } from '../services/discoveryService';
|
||||
import type { DiscoveryRow, DiscoverySourceSummary } from '../services/discoveryService';
|
||||
import { Badge } from '../components/ethos/Badge';
|
||||
import { PageContainer } from '../components/PageContainer';
|
||||
import { PageHeader } from '../components/PageHeader';
|
||||
import { EmptyState } from '../components/EmptyState';
|
||||
import { SkeletonRows } from '../components/LoadingState';
|
||||
|
||||
// Mirrors the probation sweep in workers/src/index.ts. Shown rather than hidden:
|
||||
// a track on probation is N plays away from staying, and the operator should be
|
||||
// able to see exactly how far. If the sweep's thresholds change, change these.
|
||||
const KEEP_AFTER_PLAYS = 3;
|
||||
const DROP_AFTER_SKIPS = 3;
|
||||
|
||||
type Filter = 'all' | 'probation' | 'retained' | 'retired' | 'stalled';
|
||||
|
||||
const FILTERS: { id: Filter; label: string }[] = [
|
||||
{ id: 'all', label: 'All' },
|
||||
{ id: 'probation', label: 'On probation' },
|
||||
{ id: 'retained', label: 'Kept' },
|
||||
{ id: 'retired', label: 'Removed' },
|
||||
{ id: 'stalled', label: 'Never arrived' },
|
||||
];
|
||||
|
||||
/** A candidate that was evaluated but never became a track. */
|
||||
const isStalled = (row: DiscoveryRow) => !row.track_id && row.status !== 'candidate';
|
||||
|
||||
function matches(row: DiscoveryRow, filter: Filter): boolean {
|
||||
if (filter === 'all') return true;
|
||||
if (filter === 'stalled') return isStalled(row);
|
||||
return row.probation_status === filter;
|
||||
}
|
||||
|
||||
function sourceLabel(source: string): string {
|
||||
if (source === 'new_release') return 'New release';
|
||||
if (source === 'similar_recommendation') return 'Similar';
|
||||
if (source === 'graph_exploration') return 'Graph';
|
||||
return source;
|
||||
}
|
||||
|
||||
function statusBadge(row: DiscoveryRow) {
|
||||
if (row.probation_status === 'retained') return <Badge color="green">Kept</Badge>;
|
||||
if (row.probation_status === 'retired') return <Badge color="neutral">Removed</Badge>;
|
||||
if (row.probation_status === 'probation') return <Badge color="amber" dot>On probation</Badge>;
|
||||
if (isStalled(row)) return <Badge color="red">{row.status.replace(/_/g, ' ')}</Badge>;
|
||||
return <Badge color="neutral">Queued</Badge>;
|
||||
}
|
||||
|
||||
function shortDate(value: string | null): string {
|
||||
if (!value) return '—';
|
||||
return new Date(value).toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
/** Where this candidate came from, in one line, without a second request. */
|
||||
function provenance(row: DiscoveryRow): string {
|
||||
const notes = row.notes ?? {};
|
||||
if (notes.album) return `from ${notes.album}`;
|
||||
if (notes.seed_artist) return `because you play ${notes.seed_artist}`;
|
||||
return sourceLabel(row.source);
|
||||
}
|
||||
|
||||
function displayName(row: DiscoveryRow): { title: string; artist: string } {
|
||||
const credited = row.artist_credit?.[0]?.name ?? '';
|
||||
return {
|
||||
title: row.track_title ?? row.title ?? 'Untitled',
|
||||
artist: row.track_artist ?? credited,
|
||||
};
|
||||
}
|
||||
|
||||
function SummaryStrip({ summary }: { summary: DiscoverySourceSummary[] }) {
|
||||
if (summary.length === 0) return null;
|
||||
return (
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
{summary.map((s) => (
|
||||
<div key={s.source} className="rounded-lg border border-border bg-surface0/40 p-3">
|
||||
<div className="flex items-baseline justify-between gap-2">
|
||||
<span className="truncate text-sm font-medium text-text">{sourceLabel(s.source)}</span>
|
||||
<span className="flex-none font-mono text-xs tabular-nums text-machine">
|
||||
{s.candidates} found
|
||||
</span>
|
||||
</div>
|
||||
<dl className="mt-2 flex flex-wrap gap-x-4 gap-y-1 font-mono text-xs tabular-nums">
|
||||
<div className="flex gap-1.5">
|
||||
<dt className="font-sans text-secondary">kept</dt>
|
||||
<dd className="text-green">{s.retained}</dd>
|
||||
</div>
|
||||
<div className="flex gap-1.5">
|
||||
<dt className="font-sans text-secondary">trying</dt>
|
||||
<dd className="text-amber">{s.probation}</dd>
|
||||
</div>
|
||||
<div className="flex gap-1.5">
|
||||
<dt className="font-sans text-secondary">removed</dt>
|
||||
<dd className="text-machine">{s.retired}</dd>
|
||||
</div>
|
||||
<div className="flex gap-1.5">
|
||||
<dt className="font-sans text-secondary">never arrived</dt>
|
||||
<dd className={s.stalled > 0 ? 'text-red' : 'text-machine'}>{s.stalled}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Recommendations() {
|
||||
const [filter, setFilter] = useState<Filter>('all');
|
||||
const { data, isLoading, isError } = useQuery({
|
||||
queryKey: ['discovery-overview'],
|
||||
queryFn: () => discoveryService.overview(),
|
||||
refetchInterval: 60_000,
|
||||
});
|
||||
|
||||
const rows = data?.rows ?? [];
|
||||
const visible = useMemo(() => rows.filter((row) => matches(row, filter)), [rows, filter]);
|
||||
|
||||
const totals = data?.summary.reduce(
|
||||
(acc, s) => ({
|
||||
candidates: acc.candidates + s.candidates,
|
||||
retained: acc.retained + s.retained,
|
||||
probation: acc.probation + s.probation,
|
||||
}),
|
||||
{ candidates: 0, retained: 0, probation: 0 }
|
||||
);
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<PageHeader
|
||||
title="Recommendations"
|
||||
subtitle="What discovery brought in, and what your skips did with it."
|
||||
meta={
|
||||
totals
|
||||
? `${totals.candidates} found · ${totals.retained} kept · ${totals.probation} on probation`
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
|
||||
{isLoading ? (
|
||||
<SkeletonRows count={5} />
|
||||
) : isError ? (
|
||||
<EmptyState
|
||||
compact
|
||||
icon={<AlertCircle size={28} />}
|
||||
title="Couldn't load recommendations"
|
||||
subtitle="Something went wrong. Try reloading the page."
|
||||
/>
|
||||
) : rows.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={<Compass size={28} />}
|
||||
title="Nothing discovered yet"
|
||||
subtitle="The new-release and similarity scans run daily. Acquisition also has to be enabled on the worker before a candidate can become a track."
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<SummaryStrip summary={data?.summary ?? []} />
|
||||
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{FILTERS.map((f) => {
|
||||
const count = rows.filter((row) => matches(row, f.id)).length;
|
||||
const active = filter === f.id;
|
||||
return (
|
||||
<button
|
||||
key={f.id}
|
||||
onClick={() => setFilter(f.id)}
|
||||
className={`flex items-center gap-1.5 rounded-md border px-2.5 py-1 text-xs transition-colors ${
|
||||
active
|
||||
? 'border-accent/60 bg-accent/10 text-accent'
|
||||
: 'border-border text-secondary hover:bg-surface1'
|
||||
}`}
|
||||
>
|
||||
{f.label}
|
||||
<span className="font-mono tabular-nums text-[11px] text-machine">{count}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{visible.length === 0 ? (
|
||||
<EmptyState compact icon={<Compass size={28} />} title="Nothing in this state" />
|
||||
) : (
|
||||
<ul className="track-list rounded-lg border border-border bg-surface0/40 px-1.5">
|
||||
{visible.map((row) => {
|
||||
const { title, artist } = displayName(row);
|
||||
const plays = row.completed_plays ?? 0;
|
||||
const skips = row.quick_skips ?? 0;
|
||||
// Stacked below 640px: at that width the name, the badge and the
|
||||
// mono readout cannot share a row without the title collapsing
|
||||
// to two characters.
|
||||
return (
|
||||
<li
|
||||
key={row.id}
|
||||
className="flex flex-col gap-1 py-2 sm:min-h-14 sm:flex-row sm:flex-wrap sm:items-center sm:gap-2.5"
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="truncate text-sm font-medium text-text">{title}</span>
|
||||
{statusBadge(row)}
|
||||
</div>
|
||||
<div className="truncate text-xs text-secondary">
|
||||
{artist ? `${artist} · ` : ''}
|
||||
{provenance(row)}
|
||||
</div>
|
||||
{row.last_error && (
|
||||
<div className="mt-0.5 truncate font-mono text-[11px] text-red" title={row.last_error}>
|
||||
{row.last_error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-3 font-mono text-xs tabular-nums text-machine sm:flex-none">
|
||||
{row.probation_status === 'probation' ? (
|
||||
<span title="Completed plays that keep it, quick skips that drop it">
|
||||
{plays}/{KEEP_AFTER_PLAYS} plays · {skips}/{DROP_AFTER_SKIPS} skips
|
||||
</span>
|
||||
) : row.track_id ? (
|
||||
<span title="Completed plays · quick skips">
|
||||
{plays} plays · {skips} skips
|
||||
</span>
|
||||
) : (
|
||||
<span title="Acquisition attempts">
|
||||
{row.acquisition_attempts} attempts
|
||||
</span>
|
||||
)}
|
||||
<span className="text-muted" title="Acquired, or first seen">
|
||||
{shortDate(row.acquired_at ?? row.first_seen_at)}
|
||||
</span>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { Search as SearchIcon, SearchX } from 'lucide-react';
|
||||
import { searchService } from '../services/searchService';
|
||||
import { TrackRow } from '../components/TrackRow';
|
||||
import { PageContainer } from '../components/PageContainer';
|
||||
import { PageHeader } from '../components/PageHeader';
|
||||
import { EmptyState } from '../components/EmptyState';
|
||||
import { LoadingState } from '../components/LoadingState';
|
||||
import type { SearchResponse, Track } from '../types';
|
||||
@@ -26,17 +27,11 @@ export default function Search() {
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<div className="animate-rise">
|
||||
<h1 className="text-gradient text-3xl font-extrabold tracking-tight sm:text-4xl">Search</h1>
|
||||
{query.length > 0 ? (
|
||||
<p className="mt-1.5 text-sm text-muted">
|
||||
{busy ? 'Searching' : `${tracks.length} ${tracks.length === 1 ? 'result' : 'results'}`} for{' '}
|
||||
<span className="font-semibold text-text">“{query}”</span>
|
||||
</p>
|
||||
) : (
|
||||
<p className="mt-1.5 text-sm text-muted">Search your library from the bar above.</p>
|
||||
)}
|
||||
</div>
|
||||
<PageHeader
|
||||
title="Search"
|
||||
subtitle={query.length > 0 ? `“${query}”` : 'Search your library from the bar above.'}
|
||||
meta={query.length > 0 ? (busy ? 'searching…' : `${tracks.length} results`) : undefined}
|
||||
/>
|
||||
|
||||
{query.length === 0 ? (
|
||||
<EmptyState
|
||||
@@ -51,7 +46,7 @@ export default function Search() {
|
||||
) : tracks.length === 0 ? (
|
||||
<EmptyState icon={<SearchX size={28} />} title="No results" subtitle={`Nothing matched “${query}”.`} />
|
||||
) : (
|
||||
<section className="space-y-1 rounded-2xl border border-border/70 bg-surface0/40 p-2 animate-rise">
|
||||
<section className="track-list animate-rise rounded-lg border border-border bg-surface0/30 px-1 py-1">
|
||||
{tracks.map((t, i) => (
|
||||
<TrackRow key={t.id} track={t} queue={tracks} index={i} />
|
||||
))}
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Volume2, Info, Scan, RefreshCw, Globe, Copy, ChevronDown, ChevronRight, Trash2, Users, Sparkles } from 'lucide-react';
|
||||
import { Volume2, Info, Scan, RefreshCw, Globe, Copy, ChevronDown, ChevronRight, Trash2, Users, Sparkles, Radio } from 'lucide-react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { usePlaybackStore } from '../store/usePlaybackStore';
|
||||
import { PageContainer } from '../components/PageContainer';
|
||||
import { PageHeader } from '../components/PageHeader';
|
||||
import api from '../services/api';
|
||||
import { settingsService, type EnrichSettingKey, type EnrichSettings } from '../services/settingsService';
|
||||
import { STORAGE_KEYS, readStoredVolume } from '../lib/theme';
|
||||
import { MAX_CROSSFADE_MS, PREFETCH_LEAD_SECONDS } from '../lib/playbackPrefs';
|
||||
import { toast } from '../store/useToastStore';
|
||||
import type { Track } from '../types';
|
||||
|
||||
@@ -21,15 +23,29 @@ const ENRICH_LABELS: Record<EnrichSettingKey, { label: string; desc: string }> =
|
||||
function EnrichToggles() {
|
||||
const [settings, setSettings] = useState<EnrichSettings | null>(null);
|
||||
const [saving, setSaving] = useState<EnrichSettingKey | null>(null);
|
||||
const [loadState, setLoadState] = useState<'loading' | 'ready' | 'error'>('loading');
|
||||
const [updateError, setUpdateError] = useState<string | null>(null);
|
||||
|
||||
const loadSettings = async () => {
|
||||
setLoadState('loading');
|
||||
setUpdateError(null);
|
||||
try {
|
||||
setSettings(await settingsService.load());
|
||||
setLoadState('ready');
|
||||
} catch {
|
||||
setLoadState('error');
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
settingsService.load().then(setSettings).catch(() => {});
|
||||
void loadSettings();
|
||||
}, []);
|
||||
|
||||
const toggle = async (key: EnrichSettingKey) => {
|
||||
if (!settings || saving) return;
|
||||
const next = !settings[key];
|
||||
setSaving(key);
|
||||
setUpdateError(null);
|
||||
// Optimistic update.
|
||||
setSettings((prev) => prev ? { ...prev, [key]: next } : prev);
|
||||
try {
|
||||
@@ -37,12 +53,28 @@ function EnrichToggles() {
|
||||
} catch {
|
||||
// Revert on failure.
|
||||
setSettings((prev) => prev ? { ...prev, [key]: !next } : prev);
|
||||
setUpdateError(`Could not update ${ENRICH_LABELS[key].label}. Your previous setting was restored.`);
|
||||
} finally {
|
||||
setSaving(null);
|
||||
}
|
||||
};
|
||||
|
||||
if (!settings) return null;
|
||||
if (loadState === 'loading') {
|
||||
return <p className="pt-2 text-xs text-muted" role="status">Loading enrichment settings…</p>;
|
||||
}
|
||||
|
||||
if (loadState === 'error' || !settings) {
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-3 border-t border-border pt-3">
|
||||
<p className="text-xs text-red-400" role="alert">Couldn't load enrichment settings.</p>
|
||||
<button type="button" onClick={() => void loadSettings()} className="rounded-lg border border-border px-3 py-1.5 text-xs text-text hover:bg-surface1">
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const enabledCount = Object.values(settings).filter(Boolean).length;
|
||||
|
||||
return (
|
||||
<div className="space-y-3 pt-2 border-t border-border">
|
||||
@@ -53,18 +85,23 @@ function EnrichToggles() {
|
||||
Which external services to query during library scan. Changes apply to
|
||||
the <strong>next scan</strong>.
|
||||
</p>
|
||||
<p className="text-xs text-muted" role="status" aria-live="polite">
|
||||
{saving ? `Saving ${ENRICH_LABELS[saving].label}…` : `${enabledCount} of ${Object.keys(ENRICH_LABELS).length} enrichment jobs enabled.`}
|
||||
</p>
|
||||
{updateError && <p className="text-xs text-red-400" role="alert">{updateError}</p>}
|
||||
{Object.entries(ENRICH_LABELS).map(([key, { label, desc }]) => {
|
||||
const k = key as EnrichSettingKey;
|
||||
const on = settings[k];
|
||||
return (
|
||||
<button key={k} onClick={() => toggle(k)} disabled={saving === k}
|
||||
className="w-full flex items-center justify-between gap-3 rounded-lg border border-border/70 px-4 py-3 text-left transition-colors hover:bg-surface1 disabled:opacity-50">
|
||||
<button type="button" key={k} onClick={() => void toggle(k)} disabled={saving !== null}
|
||||
role="switch" aria-checked={on}
|
||||
className="w-full flex items-center justify-between gap-3 rounded-lg border border-border px-3 py-2 text-left transition-colors hover:bg-surface1 disabled:opacity-50">
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-medium text-text">{label}</div>
|
||||
<div className="text-xs text-muted truncate">{desc}</div>
|
||||
</div>
|
||||
<div className={`shrink-0 relative w-10 h-5 rounded-full transition-colors ${on ? 'bg-accent' : 'bg-surface2'}`}>
|
||||
<div className={`absolute top-0.5 left-0.5 w-4 h-4 rounded-full shadow-sm transition-transform ${on ? 'bg-on-accent translate-x-5' : 'bg-secondary'}`} />
|
||||
<div className={`relative h-[20px] w-[36px] shrink-0 rounded-full transition-colors ${on ? 'bg-accent' : 'bg-surface2'}`}>
|
||||
<div className={`absolute left-[2px] top-[2px] h-[16px] w-[16px] rounded-full transition-transform ${on ? 'bg-on-accent translate-x-[16px]' : 'bg-secondary'}`} />
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
@@ -110,9 +147,11 @@ function AdminAction({
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClick}
|
||||
disabled={state === 'loading'}
|
||||
className={`admin-btn ${state === 'done' ? 'admin-btn--done' : state === 'error' ? 'admin-btn--error' : ''}`}
|
||||
aria-live="polite"
|
||||
>
|
||||
<Icon size={16} className={state === 'loading' ? 'animate-spin' : ''} />
|
||||
{state === 'loading' ? busyLabel
|
||||
@@ -241,6 +280,10 @@ function DuplicatesSection() {
|
||||
export default function Settings() {
|
||||
const volume = usePlaybackStore((s) => s.volume);
|
||||
const setVolume = usePlaybackStore((s) => s.setVolume);
|
||||
const prefetchNext = usePlaybackStore((s) => s.prefetchNext);
|
||||
const setPrefetchNext = usePlaybackStore((s) => s.setPrefetchNext);
|
||||
const crossfadeMs = usePlaybackStore((s) => s.crossfadeMs);
|
||||
const setCrossfadeMs = usePlaybackStore((s) => s.setCrossfadeMs);
|
||||
|
||||
useEffect(() => {
|
||||
setVolume(readStoredVolume(volume));
|
||||
@@ -253,24 +296,54 @@ export default function Settings() {
|
||||
};
|
||||
|
||||
return (
|
||||
<PageContainer width="sm" className="space-y-8 lg:max-w-3xl">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-text">Settings</h1>
|
||||
<p className="text-muted mt-1">Preferences are stored locally in this browser.</p>
|
||||
</div>
|
||||
<PageContainer width="sm" className="space-y-6 lg:max-w-3xl">
|
||||
<PageHeader title="Settings" subtitle="Preferences are stored locally in this browser." />
|
||||
|
||||
<section className="rounded-xl border border-border/70 bg-surface0 p-5 space-y-4">
|
||||
<h2 className="text-lg font-semibold text-text flex items-center gap-2"><Volume2 size={20} className="text-accent" />Default volume</h2>
|
||||
<section className="rounded-xl border border-border bg-surface0/50 p-3 space-y-3">
|
||||
<h2 className="flex items-center gap-2 text-sm font-semibold text-text"><Volume2 size={15} className="text-accent" />Default volume</h2>
|
||||
<div className="flex items-center gap-4">
|
||||
<input type="range" min={0} max={1} step={0.01} value={volume}
|
||||
onChange={(e) => handleVolume(Number(e.target.value))}
|
||||
className="flex-1" aria-label="Volume" />
|
||||
<span className="w-12 text-right text-sm tabular-nums text-text">{Math.round(volume * 100)}%</span>
|
||||
<span className="w-12 flex-none text-right font-mono text-xs tabular-nums text-machine">{Math.round(volume * 100)}%</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="rounded-xl border border-border/70 bg-surface0 p-5 space-y-4">
|
||||
<h2 className="text-lg font-semibold text-text flex items-center gap-2"><Scan size={20} className="text-accent" />Library</h2>
|
||||
<section className="rounded-xl border border-border bg-surface0/50 p-3 space-y-3">
|
||||
<h2 className="flex items-center gap-2 text-sm font-semibold text-text"><Radio size={15} className="text-accent" />Transitions</h2>
|
||||
|
||||
<button type="button" onClick={() => setPrefetchNext(!prefetchNext)}
|
||||
role="switch" aria-checked={prefetchNext}
|
||||
className="w-full flex items-center justify-between gap-3 rounded-lg border border-border px-3 py-2 text-left transition-colors hover:bg-surface1">
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-medium text-text">Preload next track</div>
|
||||
<div className="text-xs text-muted">Buffers the upcoming track {PREFETCH_LEAD_SECONDS}s before the current one ends</div>
|
||||
</div>
|
||||
<div className={`relative h-[20px] w-[36px] shrink-0 rounded-full transition-colors ${prefetchNext ? 'bg-accent' : 'bg-surface2'}`}>
|
||||
<div className={`absolute left-[2px] top-[2px] h-[16px] w-[16px] rounded-full transition-transform ${prefetchNext ? 'bg-on-accent translate-x-[16px]' : 'bg-secondary'}`} />
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="crossfade" className="text-sm font-medium text-text">Crossfade</label>
|
||||
<div className="flex items-center gap-4">
|
||||
<input id="crossfade" type="range" min={0} max={MAX_CROSSFADE_MS} step={100} value={crossfadeMs}
|
||||
onChange={(e) => setCrossfadeMs(Number(e.target.value))}
|
||||
className="flex-1" />
|
||||
<span className="w-12 flex-none text-right font-mono text-xs tabular-nums text-machine">
|
||||
{crossfadeMs === 0 ? 'Off' : `${(crossfadeMs / 1000).toFixed(1)}s`}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted/70">
|
||||
Overlaps the end of one track with the start of the next. Off still
|
||||
joins tracks without a pause — the next one is resolved and loaded
|
||||
during the last second, then starts the moment this one ends.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="rounded-xl border border-border bg-surface0/50 p-3 space-y-3">
|
||||
<h2 className="flex items-center gap-2 text-sm font-semibold text-text"><Scan size={15} className="text-accent" />Library</h2>
|
||||
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<AdminAction icon={Scan} label="Scan library" busyLabel="Scanning…" doneLabel="Scan enqueued"
|
||||
@@ -296,7 +369,7 @@ export default function Settings() {
|
||||
</div>
|
||||
<p className="text-xs text-muted/70 -mt-1">
|
||||
<strong>Reprocess artists</strong> re-resolves canonical names, MBIDs and
|
||||
images for every artist, and merges duplicates. <strong>Re-enrich metadata</strong>
|
||||
images for every artist, and merges duplicates. <strong>Re-enrich metadata</strong>{' '}
|
||||
re-queries MusicBrainz/Discogs for all tracks (album titles, years, cover
|
||||
art) without re-scanning files. Both run in the background.
|
||||
</p>
|
||||
@@ -305,8 +378,8 @@ export default function Settings() {
|
||||
<DuplicatesSection />
|
||||
</section>
|
||||
|
||||
<section className="rounded-xl border border-border/70 bg-surface0 p-5 space-y-3">
|
||||
<h2 className="text-lg font-semibold text-text flex items-center gap-2"><Info size={20} className="text-accent" />About</h2>
|
||||
<section className="rounded-xl border border-border bg-surface0/50 p-3 space-y-3">
|
||||
<h2 className="flex items-center gap-2 text-sm font-semibold text-text"><Info size={15} className="text-accent" />About</h2>
|
||||
<dl className="space-y-2 text-sm">
|
||||
<div className="flex justify-between"><dt className="text-muted">Application</dt><dd className="text-text font-medium">muzick</dd></div>
|
||||
<div className="flex justify-between"><dt className="text-muted">Version</dt><dd className="text-text font-medium tabular-nums">0.1.0</dd></div>
|
||||
|
||||
@@ -24,11 +24,14 @@ export default function Tracks() {
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<PageHeader icon={Music} title="Songs" subtitle="Everything in your library" />
|
||||
<PageHeader
|
||||
title="Songs"
|
||||
meta={tracks.length ? `${tracks.length} shown · page ${page + 1}` : undefined}
|
||||
/>
|
||||
{isLoading ? <SkeletonRows count={8} />
|
||||
: isError ? <EmptyState compact icon={<AlertCircle size={28} />} title="Couldn't load tracks" subtitle="Something went wrong. Try reloading the page." />
|
||||
: tracks.length === 0 ? <EmptyState compact icon={<Music size={28} />} title={page === 0 ? 'No tracks yet' : 'No more tracks'} subtitle={page === 0 ? 'Run a library scan in Settings to populate it.' : undefined} />
|
||||
: <div className="space-y-1">{tracks.map((t, i) => <TrackRow key={t.id} track={t} queue={tracks} index={i} />)}</div>}
|
||||
: <div className="track-list">{tracks.map((t, i) => <TrackRow key={t.id} track={t} queue={tracks} index={i} />)}</div>}
|
||||
{(tracks.length > 0 || page > 0) && (
|
||||
<Pagination
|
||||
page={page}
|
||||
|
||||
+135
-182
@@ -1,78 +1,66 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useCallback, useMemo, useRef, useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Heart, Loader2, Play, Shuffle, ThumbsDown, Sparkles, Square } from 'lucide-react';
|
||||
import { vibeService, fetchNextBatch } from '../services/vibeService';
|
||||
import { Loader2, Play, Shuffle, ThumbsDown, Square } from 'lucide-react';
|
||||
import { advanceVibe, endVibeSession, startVibeSession, vibeErrorMessage } from '../services/vibeSession';
|
||||
import { trackService } from '../services/trackService';
|
||||
import { usePlaybackStore } from '../store/usePlaybackStore';
|
||||
import { useVibeStore } from '../store/useVibeStore';
|
||||
import { TrackRow } from '../components/TrackRow';
|
||||
import { PageContainer } from '../components/PageContainer';
|
||||
import { PageHeader } from '../components/PageHeader';
|
||||
import type { Track } from '../types';
|
||||
import { VibeTimeline } from '../components/VibeTimeline';
|
||||
import { suppressAutoFeedback } from '../components/AudioEngine';
|
||||
import { toast } from '../store/useToastStore';
|
||||
import { VibeAura } from '../components/VibeAura';
|
||||
|
||||
const INITIAL_BATCH_SIZE = 5;
|
||||
const PREFETCH_THRESHOLD = 3;
|
||||
const PREFETCH_BATCH_SIZE = 3;
|
||||
|
||||
function bestEffort(p: Promise<unknown>): void {
|
||||
void p.catch(() => undefined);
|
||||
}
|
||||
const SEED_LIST_SIZE = 50;
|
||||
|
||||
export default function Vibe() {
|
||||
const { currentTrack, queue, setQueue, playTrack, next: playNext, pause, setCurrentTrack } = usePlaybackStore();
|
||||
const { currentTrack, queue } = usePlaybackStore();
|
||||
const {
|
||||
activeSessionId,
|
||||
buffer,
|
||||
setActiveSession,
|
||||
setSeedTrackId,
|
||||
setCenterTrack,
|
||||
setBuffer,
|
||||
appendBuffer,
|
||||
reset,
|
||||
initialBatchStatus,
|
||||
profile,
|
||||
} = useVibeStore();
|
||||
|
||||
const [starting, setStarting] = useState(false);
|
||||
const [prefetching, setPrefetching] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [empty, setEmpty] = useState(false);
|
||||
const prefetchingRef = useRef(false);
|
||||
const startingRef = useRef(false);
|
||||
|
||||
const { data: libraryTracks = [], isLoading: libraryLoading } = useQuery<Track[]>({
|
||||
// The sample is drawn in the database and is uniform over everything
|
||||
// playable, so Surprise me still reaches the whole library. Reading all 5000
|
||||
// tracks to display fifty of them was megabytes of JSON for a phone to parse
|
||||
// before this page could paint.
|
||||
const { data: seeds, isLoading: libraryLoading } = useQuery({
|
||||
queryKey: ['library-seed'],
|
||||
queryFn: () => trackService.listTracks({ limit: 50, sort_by: 'play_count', order: 'DESC' }),
|
||||
queryFn: () => trackService.listSeedTracks(SEED_LIST_SIZE),
|
||||
enabled: !activeSessionId,
|
||||
});
|
||||
const seedTracks = useMemo(() => seeds?.tracks ?? [], [seeds]);
|
||||
const eligibleCount = seeds?.total ?? 0;
|
||||
|
||||
const startSession = useCallback(
|
||||
async (seed: Track) => {
|
||||
if (startingRef.current) return;
|
||||
startingRef.current = true;
|
||||
setStarting(true);
|
||||
setError(null);
|
||||
setEmpty(false);
|
||||
try {
|
||||
const { sessionId } = await vibeService.start(seed.id);
|
||||
setActiveSession({ sessionId, seedTrackId: seed.id });
|
||||
setSeedTrackId(seed.id);
|
||||
setCenterTrack(seed);
|
||||
|
||||
const chunk = await fetchNextBatch(INITIAL_BATCH_SIZE);
|
||||
if (chunk.length === 0) {
|
||||
setBuffer([]);
|
||||
const result = await startVibeSession(seed);
|
||||
if (result.tracks.length === 0) {
|
||||
setEmpty(true);
|
||||
return;
|
||||
setError('Could not load recommendations for this vibe. Please try another seed.');
|
||||
}
|
||||
setBuffer(chunk);
|
||||
setQueue(chunk);
|
||||
playTrack(chunk[0]);
|
||||
} catch {
|
||||
setError('Could not start a vibe session. Please try again.');
|
||||
reset();
|
||||
} catch (startError) {
|
||||
setError(vibeErrorMessage(startError));
|
||||
} finally {
|
||||
startingRef.current = false;
|
||||
setStarting(false);
|
||||
}
|
||||
},
|
||||
[setActiveSession, setSeedTrackId, setCenterTrack, setBuffer, setQueue, playTrack, reset]
|
||||
[]
|
||||
);
|
||||
|
||||
const startFromCurrent = useCallback(() => {
|
||||
@@ -80,101 +68,55 @@ export default function Vibe() {
|
||||
}, [currentTrack, startSession]);
|
||||
|
||||
const surpriseMe = useCallback(() => {
|
||||
if (libraryTracks.length === 0) return;
|
||||
const seed = libraryTracks[Math.floor(Math.random() * libraryTracks.length)];
|
||||
if (seedTracks.length === 0) return;
|
||||
const seed = seedTracks[Math.floor(Math.random() * seedTracks.length)];
|
||||
void startSession(seed);
|
||||
}, [libraryTracks, startSession]);
|
||||
|
||||
const remaining = currentTrack
|
||||
? queue.length - (queue.findIndex((t) => t.id === currentTrack.id) + 1)
|
||||
: queue.length;
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeSessionId || prefetchingRef.current) return;
|
||||
if (remaining > PREFETCH_THRESHOLD) return;
|
||||
|
||||
prefetchingRef.current = true;
|
||||
setPrefetching(true);
|
||||
fetchNextBatch(PREFETCH_BATCH_SIZE)
|
||||
.then((chunk) => {
|
||||
if (chunk.length > 0) {
|
||||
appendBuffer(chunk);
|
||||
const current = usePlaybackStore.getState().queue;
|
||||
const currentIds = new Set(current.map((t) => t.id));
|
||||
const fresh = chunk.filter((t) => !currentIds.has(t.id));
|
||||
if (fresh.length > 0) setQueue([...current, ...fresh]);
|
||||
}
|
||||
})
|
||||
.catch(() => undefined)
|
||||
.finally(() => {
|
||||
prefetchingRef.current = false;
|
||||
setPrefetching(false);
|
||||
});
|
||||
}, [activeSessionId, remaining, appendBuffer, setQueue]);
|
||||
|
||||
// Trim buffer to prevent unbounded growth — keep only from currentTrack onward.
|
||||
useEffect(() => {
|
||||
if (!activeSessionId || !currentTrack || buffer.length === 0) return;
|
||||
const idx = buffer.findIndex((t) => t.id === currentTrack.id);
|
||||
if (idx > 0) {
|
||||
setBuffer(buffer.slice(idx));
|
||||
}
|
||||
}, [activeSessionId, currentTrack, buffer, setBuffer]);
|
||||
|
||||
const handleKeep = useCallback(() => {
|
||||
if (currentTrack) {
|
||||
bestEffort(vibeService.feedback(currentTrack.id, 'promoted'));
|
||||
toast.success(`Kept "${currentTrack.title}"`);
|
||||
}
|
||||
}, [currentTrack]);
|
||||
}, [seedTracks, startSession]);
|
||||
|
||||
const handleDislike = useCallback(() => {
|
||||
if (currentTrack) {
|
||||
bestEffort(vibeService.feedback(currentTrack.id, 'disliked'));
|
||||
// AudioEngine would otherwise also record a 'skipped' on the track
|
||||
// change caused by playNext() below — suppress that duplicate.
|
||||
suppressAutoFeedback(currentTrack.id);
|
||||
void advanceVibe('disliked').catch((feedbackError) => setError(vibeErrorMessage(feedbackError)));
|
||||
}
|
||||
playNext();
|
||||
}, [currentTrack, playNext]);
|
||||
}, [currentTrack]);
|
||||
|
||||
const handleEnd = useCallback(() => {
|
||||
// V2 plan expires via Redis TTL (2h). No explicit end endpoint.
|
||||
pause();
|
||||
setQueue([]);
|
||||
setCurrentTrack(null);
|
||||
reset();
|
||||
void endVibeSession().catch((endError) => setError(vibeErrorMessage(endError)));
|
||||
setEmpty(false);
|
||||
setError(null);
|
||||
}, [reset, pause, setQueue, setCurrentTrack]);
|
||||
}, []);
|
||||
|
||||
const upcoming = currentTrack
|
||||
? (() => {
|
||||
const idx = buffer.findIndex((t) => t.id === currentTrack.id);
|
||||
return idx >= 0 ? buffer.slice(idx + 1) : buffer;
|
||||
})()
|
||||
: buffer;
|
||||
// The aura shows the profile as light, which is not readable. These are the
|
||||
// same numbers the director steers on (Ethos law 5 — show the machinery).
|
||||
const profileMeta = useMemo(() => {
|
||||
if (initialBatchStatus === 'loading') return 'planning…';
|
||||
const pct = (value: number | undefined, fallback: number) =>
|
||||
`${Math.round(Math.min(1, Math.max(0, value ?? fallback)) * 100)}%`;
|
||||
const parts = [
|
||||
`energy ${pct(profile.energy, 0.5)}`,
|
||||
`discovery ${pct(profile.noveltyHunger ?? profile.explorationCoefficient, 0.3)}`,
|
||||
];
|
||||
const goal = profile.sessionGoal;
|
||||
if (goal?.target) parts.push(`goal ${goal.progress ?? 0}/${goal.target}`);
|
||||
return parts.join(' · ');
|
||||
}, [profile, initialBatchStatus]);
|
||||
|
||||
// Read what is actually queued rather than the plan preview: the seed plays
|
||||
// first and is not a plan item, so the preview alone would misreport what's next.
|
||||
const upcoming = useMemo(() => {
|
||||
const index = queue.findIndex((track) => track.id === currentTrack?.id);
|
||||
return index >= 0 ? queue.slice(index + 1) : buffer;
|
||||
}, [queue, currentTrack, buffer]);
|
||||
|
||||
// ---- Start screen (no active session) ----
|
||||
if (!activeSessionId) {
|
||||
return (
|
||||
<PageContainer width="sm" className="py-8">
|
||||
<header className="space-y-2 text-center">
|
||||
<div className="inline-flex items-center gap-2 rounded-full bg-accent/10 px-4 py-1.5 text-sm text-accent">
|
||||
<Sparkles size={16} />
|
||||
Rolling Vibe
|
||||
</div>
|
||||
<h1 className="text-4xl font-bold text-text">Start a Vibe</h1>
|
||||
<p className="text-muted">
|
||||
An infinite, ever-rolling stream of recommendations seeded from a track you love.
|
||||
</p>
|
||||
<p className="mx-auto max-w-md text-xs text-muted/70">
|
||||
Pick a seed below and playback starts immediately, with a rolling
|
||||
timeline of upcoming tracks. <strong className="text-muted">Keep</strong> what you love,
|
||||
<strong className="text-muted"> Dislike & skip</strong> what you don't — the vibe
|
||||
adapts as you go.
|
||||
</p>
|
||||
</header>
|
||||
<PageContainer>
|
||||
{/* Three stacked paragraphs of explanation used to sit here. A seed, a
|
||||
shuffle and a track list say the same thing by being used. */}
|
||||
<PageHeader
|
||||
title="Start a Vibe"
|
||||
subtitle="Pick a seed. Playback starts at once and the queue re-plans as you keep or skip."
|
||||
meta={eligibleCount ? `${eligibleCount.toLocaleString()} tracks eligible` : undefined}
|
||||
/>
|
||||
|
||||
{error && (
|
||||
<div className="rounded-lg border border-red-500/40 bg-red-500/10 p-3 text-sm text-red-300">
|
||||
@@ -182,52 +124,55 @@ export default function Vibe() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
{currentTrack && (
|
||||
<button
|
||||
onClick={startFromCurrent}
|
||||
disabled={starting}
|
||||
className="flex w-full items-center gap-3 rounded-lg border border-accent/60 bg-accent/10 p-4 text-left transition-colors hover:bg-accent/20 disabled:opacity-60"
|
||||
className="flex h-16 w-full items-center gap-3 rounded-lg border border-accent/50 bg-accent/10 px-3 text-left transition-colors hover:bg-accent/20 disabled:opacity-60"
|
||||
>
|
||||
<Play size={20} className="text-accent" />
|
||||
<div className="flex-1">
|
||||
<div className="font-semibold text-text">Vibe from “{currentTrack.title}”</div>
|
||||
<div className="text-sm text-muted">{currentTrack.artist}</div>
|
||||
<Play size={18} className="flex-none text-accent" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-sm font-medium text-text">Vibe from “{currentTrack.title}”</div>
|
||||
<div className="truncate text-xs text-secondary">{currentTrack.artist}</div>
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={surpriseMe}
|
||||
disabled={starting || libraryLoading || libraryTracks.length === 0}
|
||||
className="flex w-full items-center gap-3 rounded-lg border border-border bg-surface0 p-4 text-left transition-colors hover:bg-surface1 disabled:opacity-60"
|
||||
disabled={starting || libraryLoading || seedTracks.length === 0}
|
||||
className="flex h-16 w-full items-center gap-3 rounded-lg border border-border bg-surface0/60 px-3 text-left transition-colors hover:bg-surface1 disabled:opacity-60"
|
||||
>
|
||||
<Shuffle size={20} className="text-text" />
|
||||
<div className="flex-1">
|
||||
<div className="font-semibold text-text">Surprise me</div>
|
||||
<div className="text-sm text-muted">
|
||||
<Shuffle size={18} className="flex-none text-text" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-medium text-text">Surprise me</div>
|
||||
<div className="truncate text-xs text-secondary">
|
||||
{libraryLoading
|
||||
? 'Loading your library…'
|
||||
: libraryTracks.length === 0
|
||||
? 'No library tracks available to seed a vibe.'
|
||||
: 'Start from a random track in your library.'}
|
||||
? 'reading library…'
|
||||
: seedTracks.length === 0
|
||||
? 'No eligible tracks to seed from.'
|
||||
: 'Random seed from the whole library.'}
|
||||
</div>
|
||||
</div>
|
||||
{starting && <Loader2 size={18} className="animate-spin text-muted" />}
|
||||
{starting && <Loader2 size={18} className="flex-none animate-spin text-muted" />}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{!libraryLoading && libraryTracks.length > 0 && (
|
||||
{!libraryLoading && seedTracks.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<h2 className="text-sm font-semibold text-muted">Or pick a seed track</h2>
|
||||
<ul className="max-h-72 space-y-1 overflow-y-auto">
|
||||
{libraryTracks.map((track, index) => (
|
||||
<h2 className="text-[11px] font-semibold uppercase tracking-[0.1em] text-muted">Or pick a seed track</h2>
|
||||
{/* Two columns of the sampled 50 — one 320px-tall scroller showed 6
|
||||
of them and left the rest behind a scrollbar. */}
|
||||
<ul className="track-list grid rounded-lg border border-border bg-surface0/30 px-1 sm:grid-cols-2 sm:gap-x-4 sm:[&>*:nth-child(2)]:border-t-0">
|
||||
{seedTracks.map((track, index) => (
|
||||
<li key={track.id}>
|
||||
<TrackRow
|
||||
track={track}
|
||||
queue={libraryTracks}
|
||||
queue={seedTracks}
|
||||
index={index}
|
||||
showActions={false}
|
||||
onSelect={startSession}
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
@@ -240,48 +185,56 @@ export default function Vibe() {
|
||||
|
||||
// ---- Active session ----
|
||||
return (
|
||||
<PageContainer width="sm" className="py-6">
|
||||
<header className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Sparkles size={22} className="text-accent" />
|
||||
<h1 className="text-2xl font-bold text-text">Vibing</h1>
|
||||
{prefetching && <Loader2 size={16} className="animate-spin text-muted" />}
|
||||
</div>
|
||||
<button
|
||||
onClick={handleEnd}
|
||||
className="flex items-center gap-2 rounded-lg border border-border px-3 py-1.5 text-sm text-text transition-colors hover:border-red-500/60 hover:text-red-300"
|
||||
>
|
||||
<Square size={14} />
|
||||
End Vibe
|
||||
</button>
|
||||
</header>
|
||||
<PageContainer width="sm" className="relative isolate py-6">
|
||||
<VibeAura profile={profile} ambient />
|
||||
<div className="relative z-10 space-y-6">
|
||||
<PageHeader
|
||||
title="Vibing"
|
||||
// The buffer count belongs to Up next, and the plan revision is a
|
||||
// debugging number. The live profile does belong here.
|
||||
meta={profileMeta}
|
||||
// Both verbs sit in one cluster. Dislike used to live a row down,
|
||||
// which put 200px of empty header between the two things to press.
|
||||
actions={
|
||||
<>
|
||||
{currentTrack && (
|
||||
<button
|
||||
onClick={handleDislike}
|
||||
className="flex items-center gap-2 rounded-lg border border-border bg-surface0/60 px-3 py-2 text-sm text-text transition-colors hover:border-red/60 hover:text-red"
|
||||
>
|
||||
<ThumbsDown size={16} />
|
||||
Dislike & skip
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={handleEnd}
|
||||
className="flex items-center gap-2 rounded-lg border border-border px-3 py-2 text-sm text-text transition-colors hover:border-red/60 hover:text-red"
|
||||
>
|
||||
<Square size={14} />
|
||||
End Vibe
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
{empty && (
|
||||
<div className="rounded-lg border border-border bg-surface0 p-4 text-sm text-muted">
|
||||
No recommendations came back for this seed yet. Try ending and starting a different vibe.
|
||||
</div>
|
||||
)}
|
||||
{(empty || initialBatchStatus === 'exhausted' || initialBatchStatus === 'failed') && (
|
||||
<div className="rounded-lg border border-border bg-surface0 p-4 text-sm text-muted">
|
||||
{initialBatchStatus === 'failed'
|
||||
? 'Could not load recommendations for this Vibe. Try starting a different one.'
|
||||
: 'No recommendations came back for this seed yet. Try ending and starting a different vibe.'}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{currentTrack && (
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={handleKeep}
|
||||
className="flex items-center gap-2 rounded-lg border border-border px-4 py-2 text-sm font-medium text-text transition-colors hover:border-pink-500/60 hover:text-pink-300"
|
||||
>
|
||||
<Heart size={16} />
|
||||
Keep
|
||||
</button>
|
||||
<button
|
||||
onClick={handleDislike}
|
||||
className="flex items-center gap-2 rounded-lg border border-border px-4 py-2 text-sm font-medium text-text transition-colors hover:border-red-500/60 hover:text-red-300"
|
||||
>
|
||||
<ThumbsDown size={16} />
|
||||
Dislike & skip
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
<div className="rounded-lg border border-red-500/40 bg-red-500/10 p-3 text-sm text-red-300">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<VibeTimeline currentTrack={currentTrack} upcoming={upcoming} />
|
||||
{/* Keep is gone: letting a track finish already reports `completed`,
|
||||
which the director weighs the same as an explicit keep. */}
|
||||
<VibeTimeline currentTrack={currentTrack} upcoming={upcoming} />
|
||||
</div>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
+29
-17
@@ -2,22 +2,29 @@ import {
|
||||
createRootRoute,
|
||||
createRoute,
|
||||
createRouter,
|
||||
lazyRouteComponent,
|
||||
} from '@tanstack/react-router';
|
||||
import { z } from 'zod';
|
||||
import AppShell from './components/AppShell';
|
||||
import { LoadingState } from './components/LoadingState';
|
||||
import Home from './pages/Home';
|
||||
import Artists from './pages/Artists';
|
||||
import ArtistDetail from './pages/ArtistDetail';
|
||||
import Albums from './pages/Albums';
|
||||
import AlbumDetail from './pages/AlbumDetail';
|
||||
import Tracks from './pages/Tracks';
|
||||
import Genres from './pages/Genres';
|
||||
import Vibe from './pages/Vibe';
|
||||
import Discover from './pages/Discover';
|
||||
import Search from './pages/Search';
|
||||
import Settings from './pages/Settings';
|
||||
import Quarantine from './pages/Quarantine';
|
||||
import Jobs from './pages/Jobs';
|
||||
|
||||
// Home is imported eagerly: it is the landing route, and deferring it would put
|
||||
// a second round trip in front of the first paint. Every other page is a
|
||||
// separate chunk, fetched when the listener first goes there and then held by
|
||||
// the service worker.
|
||||
const Artists = lazyRouteComponent(() => import('./pages/Artists'));
|
||||
const ArtistDetail = lazyRouteComponent(() => import('./pages/ArtistDetail'));
|
||||
const Albums = lazyRouteComponent(() => import('./pages/Albums'));
|
||||
const AlbumDetail = lazyRouteComponent(() => import('./pages/AlbumDetail'));
|
||||
const Tracks = lazyRouteComponent(() => import('./pages/Tracks'));
|
||||
const Genres = lazyRouteComponent(() => import('./pages/Genres'));
|
||||
const Vibe = lazyRouteComponent(() => import('./pages/Vibe'));
|
||||
const Recommendations = lazyRouteComponent(() => import('./pages/Recommendations'));
|
||||
const Search = lazyRouteComponent(() => import('./pages/Search'));
|
||||
const Settings = lazyRouteComponent(() => import('./pages/Settings'));
|
||||
const Quarantine = lazyRouteComponent(() => import('./pages/Quarantine'));
|
||||
const Jobs = lazyRouteComponent(() => import('./pages/Jobs'));
|
||||
|
||||
// Root route renders the AppShell (NavRail + TopBar + PlaybackBar + NowPlayingPanel)
|
||||
// with an <Outlet/> where the active child route renders.
|
||||
@@ -73,10 +80,10 @@ export const vibeRoute = createRoute({
|
||||
component: Vibe,
|
||||
});
|
||||
|
||||
export const discoverRoute = createRoute({
|
||||
export const recommendationsRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: '/discover',
|
||||
component: Discover,
|
||||
path: '/recommendations',
|
||||
component: Recommendations,
|
||||
});
|
||||
|
||||
export const searchRoute = createRoute({
|
||||
@@ -115,14 +122,19 @@ const routeTree = rootRoute.addChildren([
|
||||
tracksRoute,
|
||||
genresRoute,
|
||||
vibeRoute,
|
||||
discoverRoute,
|
||||
recommendationsRoute,
|
||||
searchRoute,
|
||||
settingsRoute,
|
||||
quarantineRoute,
|
||||
jobsRoute,
|
||||
]);
|
||||
|
||||
export const router = createRouter({ routeTree });
|
||||
export const router = createRouter({
|
||||
routeTree,
|
||||
// Shown while a route's chunk is in flight. Without it the shell holds the
|
||||
// previous page and the tap reads as a dropped input.
|
||||
defaultPendingComponent: () => <LoadingState />,
|
||||
});
|
||||
|
||||
// Type-safety: register the router instance type globally.
|
||||
declare module '@tanstack/react-router' {
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import api from './api';
|
||||
|
||||
/** One candidate and whatever became of it. `track_id` is null until acquired. */
|
||||
export interface DiscoveryRow {
|
||||
id: string;
|
||||
source: string;
|
||||
status: string;
|
||||
title: string | null;
|
||||
artist_credit: { name?: string; artist_id?: string }[] | null;
|
||||
notes: { acquisition?: { query?: string; url?: string }; album?: string; seed_artist?: string; seed_title?: string } | null;
|
||||
first_seen_at: string;
|
||||
acquired_at: string | null;
|
||||
last_error: string | null;
|
||||
acquisition_attempts: number;
|
||||
track_id: string | null;
|
||||
track_title: string | null;
|
||||
track_artist: string | null;
|
||||
probation_status: 'probation' | 'retained' | 'retired' | null;
|
||||
probation_entered_at: string | null;
|
||||
completed_plays: number | null;
|
||||
quick_skips: number | null;
|
||||
}
|
||||
|
||||
export interface DiscoverySourceSummary {
|
||||
source: string;
|
||||
candidates: number;
|
||||
probation: number;
|
||||
retained: number;
|
||||
retired: number;
|
||||
stalled: number;
|
||||
}
|
||||
|
||||
export const discoveryService = {
|
||||
// GET /api/discovery/overview -> every candidate plus per-source totals
|
||||
async overview(): Promise<{ rows: DiscoveryRow[]; summary: DiscoverySourceSummary[] }> {
|
||||
const res = await api.get<{ rows: DiscoveryRow[]; summary: DiscoverySourceSummary[] }>(
|
||||
'/discovery/overview'
|
||||
);
|
||||
return res.data;
|
||||
},
|
||||
};
|
||||
@@ -2,16 +2,18 @@ import api from './api';
|
||||
import type { FeedbackAction, HistoryEntry } from '../types';
|
||||
|
||||
export const historyService = {
|
||||
// POST /api/history { trackId, completed?, batchId? } -> { historyId }
|
||||
// POST /api/history { trackId, completed?, batchId?, listenedMs? } -> { historyId }
|
||||
async recordPlay(
|
||||
trackId: string,
|
||||
completed?: boolean,
|
||||
batchId?: string
|
||||
batchId?: string,
|
||||
listenedMs?: number
|
||||
): Promise<{ historyId: string }> {
|
||||
const res = await api.post<{ historyId: string }>('/history', {
|
||||
trackId,
|
||||
completed,
|
||||
batchId,
|
||||
listenedMs,
|
||||
});
|
||||
return res.data;
|
||||
},
|
||||
|
||||
@@ -1,6 +1,22 @@
|
||||
// Barrel re-export for the library-related services. The previous version of this
|
||||
// file pointed at `/library/*` paths, but the backend registers library routes at
|
||||
// the `/api` root (see backend/src/app.ts). Use the per-entity services instead.
|
||||
import api from './api';
|
||||
|
||||
export interface LibraryStats {
|
||||
tracks: number;
|
||||
albums: number;
|
||||
artists: number;
|
||||
/** Total playtime in seconds. */
|
||||
duration: number;
|
||||
}
|
||||
|
||||
// GET /api/library/stats
|
||||
export async function fetchLibraryStats(): Promise<LibraryStats> {
|
||||
const res = await api.get<LibraryStats>('/library/stats');
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export { trackService } from './trackService';
|
||||
export { artistService } from './artistService';
|
||||
export { albumService } from './albumService';
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
import api from './api';
|
||||
import type { Track } from '../types';
|
||||
|
||||
/**
|
||||
* Client half of cross-device playback. One browser is one device, identified
|
||||
* by a stored id so a reload keeps its place in the device list instead of
|
||||
* adding a new row every time.
|
||||
*/
|
||||
|
||||
const DEVICE_ID_KEY = 'muzick.deviceId';
|
||||
|
||||
/** Waits before reopening a push stream the server closed with an error. */
|
||||
const STREAM_REOPEN_MS = [1_000, 2_000, 5_000, 10_000, 20_000, 30_000];
|
||||
|
||||
export type PlaybackCommand =
|
||||
| { type: 'play' | 'pause' | 'next' | 'prev' }
|
||||
| { type: 'seek'; position: number }
|
||||
| { type: 'play_track'; trackId: string };
|
||||
|
||||
export interface PlaybackDevice {
|
||||
id: string;
|
||||
name: string;
|
||||
lastSeenAt: string;
|
||||
online: boolean;
|
||||
isOwner: boolean;
|
||||
}
|
||||
|
||||
export interface PlaybackSnapshot {
|
||||
deviceId: string | null;
|
||||
trackId: string | null;
|
||||
/** The Vibe session the owning device is driving, if it is driving one. */
|
||||
vibeSessionId: string | null;
|
||||
queue: Track[];
|
||||
queueIndex: number;
|
||||
position: number;
|
||||
isPlaying: boolean;
|
||||
version: number;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export type PlaybackSyncEvent =
|
||||
| { type: 'state'; state: PlaybackSnapshot; devices: PlaybackDevice[] }
|
||||
| { type: 'command'; deviceId: string; command: PlaybackCommand };
|
||||
|
||||
/**
|
||||
* The id this tab registered under, or the one the browser last used.
|
||||
*
|
||||
* Two levels, because a device is really a tab and not a browser: the audio
|
||||
* element, the queue and the command handling all live in one page. Two tabs
|
||||
* sharing a single id are one device that runs every command twice, so each tab
|
||||
* keeps its own id in `sessionStorage` — which survives its reloads and nothing
|
||||
* else. The `localStorage` copy is only a seed for a tab that has no id yet, and
|
||||
* the server refuses to hand it back while another tab's stream is holding it.
|
||||
*/
|
||||
export function storedDeviceId(): string | null {
|
||||
try {
|
||||
return sessionStorage.getItem(DEVICE_ID_KEY) ?? localStorage.getItem(DEVICE_ID_KEY);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function rememberDeviceId(id: string): void {
|
||||
try {
|
||||
sessionStorage.setItem(DEVICE_ID_KEY, id);
|
||||
localStorage.setItem(DEVICE_ID_KEY, id);
|
||||
} catch {
|
||||
// Private browsing: the device still works, it just re-registers next load.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A name the listener can tell apart in a device menu. The user agent is the
|
||||
* only thing a browser will say about its host, so this reads platform and
|
||||
* browser out of it rather than showing the raw string.
|
||||
*/
|
||||
export function describeThisDevice(): string {
|
||||
const ua = navigator.userAgent;
|
||||
const platform = /iPhone|iPad|iPod/.test(ua) ? 'iPhone'
|
||||
: /Android/.test(ua) ? 'Android'
|
||||
: /Macintosh/.test(ua) ? 'Mac'
|
||||
: /Windows/.test(ua) ? 'Windows'
|
||||
: /Linux/.test(ua) ? 'Linux'
|
||||
: 'Device';
|
||||
const browser = /Firefox\//.test(ua) ? 'Firefox'
|
||||
: /Edg\//.test(ua) ? 'Edge'
|
||||
: /Chrome\//.test(ua) ? 'Chrome'
|
||||
: /Safari\//.test(ua) ? 'Safari'
|
||||
: 'Browser';
|
||||
return `${platform} · ${browser}`;
|
||||
}
|
||||
|
||||
export const playbackSyncService = {
|
||||
async register(): Promise<PlaybackDevice> {
|
||||
const res = await api.post<PlaybackDevice>('/playback/devices', {
|
||||
deviceId: storedDeviceId(),
|
||||
name: describeThisDevice(),
|
||||
});
|
||||
rememberDeviceId(res.data.id);
|
||||
return res.data;
|
||||
},
|
||||
|
||||
async getState(): Promise<{ state: PlaybackSnapshot; devices: PlaybackDevice[] }> {
|
||||
const res = await api.get<{ state: PlaybackSnapshot; devices: PlaybackDevice[] }>('/playback/state');
|
||||
return res.data;
|
||||
},
|
||||
|
||||
/** Report what this device is playing. Rejected with 409 once it is not the owner. */
|
||||
async reportState(deviceId: string, patch: {
|
||||
trackId?: string | null;
|
||||
vibeSessionId?: string | null;
|
||||
queue?: Track[];
|
||||
queueIndex?: number;
|
||||
position?: number;
|
||||
isPlaying?: boolean;
|
||||
}): Promise<void> {
|
||||
await api.post('/playback/state', { deviceId, ...patch });
|
||||
},
|
||||
|
||||
async sendCommand(command: PlaybackCommand): Promise<void> {
|
||||
await api.post('/playback/command', command);
|
||||
},
|
||||
|
||||
async transfer(deviceId: string): Promise<void> {
|
||||
await api.post('/playback/transfer', { deviceId });
|
||||
},
|
||||
|
||||
async release(deviceId: string): Promise<void> {
|
||||
await api.post('/playback/release', { deviceId });
|
||||
},
|
||||
|
||||
/**
|
||||
* Open the push channel and keep it open. The server resends the full
|
||||
* snapshot on every connect, so a reconnection needs no resume bookkeeping.
|
||||
*
|
||||
* EventSource retries a dropped connection by itself, but only that case: an
|
||||
* error status from the server — a backend restart, a proxy 502, a captive
|
||||
* portal answering for it — closes the stream for good. A phone that hit one
|
||||
* of those stayed silent until the page was reloaded, so reopen it here, and
|
||||
* check the moment the network or the tab comes back rather than waiting out
|
||||
* a backoff the listener is watching.
|
||||
*/
|
||||
openStream(deviceId: string, onEvent: (event: PlaybackSyncEvent) => void): () => void {
|
||||
const base = (import.meta.env.VITE_API_URL as string | undefined) || '/api';
|
||||
const url = `${base}/playback/stream?deviceId=${encodeURIComponent(deviceId)}`;
|
||||
let source: EventSource | null = null;
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
let attempt = 0;
|
||||
let closed = false;
|
||||
|
||||
const open = () => {
|
||||
if (closed) return;
|
||||
clearTimeout(timer);
|
||||
source = new EventSource(url);
|
||||
source.onopen = () => { attempt = 0; };
|
||||
source.onmessage = (message) => {
|
||||
try {
|
||||
onEvent(JSON.parse(message.data) as PlaybackSyncEvent);
|
||||
} catch {
|
||||
// A malformed frame is not worth tearing the stream down for.
|
||||
}
|
||||
};
|
||||
source.onerror = () => {
|
||||
// CONNECTING means EventSource is already retrying on its own terms.
|
||||
if (closed || source?.readyState !== EventSource.CLOSED) return;
|
||||
source.close();
|
||||
source = null;
|
||||
const wait = STREAM_REOPEN_MS[Math.min(attempt, STREAM_REOPEN_MS.length - 1)];
|
||||
attempt += 1;
|
||||
timer = setTimeout(open, wait);
|
||||
};
|
||||
};
|
||||
|
||||
const reopenIfDead = () => {
|
||||
if (closed || document.visibilityState === 'hidden') return;
|
||||
if (!source || source.readyState === EventSource.CLOSED) open();
|
||||
};
|
||||
|
||||
// React never unmounts on a page leaving, so the stream would stay open
|
||||
// until the server noticed the socket die. Closing it here frees the device
|
||||
// id straight away, which is what lets a reload register as the same device
|
||||
// rather than being told it is a second tab. A page that comes back from the
|
||||
// history cache reopens through `reopenIfDead`.
|
||||
const closeForNow = () => {
|
||||
clearTimeout(timer);
|
||||
source?.close();
|
||||
source = null;
|
||||
};
|
||||
|
||||
open();
|
||||
window.addEventListener('online', reopenIfDead);
|
||||
window.addEventListener('pagehide', closeForNow);
|
||||
document.addEventListener('visibilitychange', reopenIfDead);
|
||||
|
||||
return () => {
|
||||
closed = true;
|
||||
clearTimeout(timer);
|
||||
window.removeEventListener('online', reopenIfDead);
|
||||
window.removeEventListener('pagehide', closeForNow);
|
||||
document.removeEventListener('visibilitychange', reopenIfDead);
|
||||
source?.close();
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -16,6 +16,12 @@ export const trackService = {
|
||||
return res.data;
|
||||
},
|
||||
|
||||
// GET /api/tracks/seeds — a random playable sample and the size of the pool.
|
||||
async listSeedTracks(limit = 50): Promise<{ total: number; tracks: Track[] }> {
|
||||
const res = await api.get<{ total: number; tracks: Track[] }>('/tracks/seeds', { params: { limit } });
|
||||
return res.data;
|
||||
},
|
||||
|
||||
// GET /api/tracks/:id
|
||||
async getTrack(id: string): Promise<Track> {
|
||||
const res = await api.get<Track>(`/tracks/${id}`);
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const { post, get } = vi.hoisted(() => ({ post: vi.fn(), get: vi.fn() }));
|
||||
vi.mock('./api', () => ({ default: { post, get } }));
|
||||
|
||||
import { vibeService } from './vibeService';
|
||||
|
||||
describe('durable vibe service', () => {
|
||||
it('serves the next item with the caller plan version', async () => {
|
||||
post.mockResolvedValue({ data: { sessionId: 'session', planVersion: 3, now: null, preview: [] } });
|
||||
|
||||
await vibeService.next('session', 3);
|
||||
|
||||
expect(post).toHaveBeenCalledWith('/v2/vibe/sessions/session/advance', { expectedPlanVersion: 3 });
|
||||
});
|
||||
|
||||
it('uses an explicit idempotency key to advance a served but unplayable item', async () => {
|
||||
post.mockResolvedValue({ data: { sessionId: 'session', planVersion: 3, now: null, preview: [] } });
|
||||
|
||||
await vibeService.advancePastUnplayable('session', 3, {
|
||||
eventId: 'event', planVersionId: 'plan', ordinal: 4, trackId: 'track',
|
||||
});
|
||||
|
||||
expect(post).toHaveBeenCalledWith('/v2/vibe/sessions/session/advance', {
|
||||
expectedPlanVersion: 3,
|
||||
unplayable: { eventId: 'event', planVersionId: 'plan', ordinal: 4, trackId: 'track' },
|
||||
});
|
||||
});
|
||||
|
||||
it('sends client event ids to the durable event ledger', async () => {
|
||||
post.mockResolvedValue({ data: { sessionId: 'session', planVersion: 2, now: null, preview: [] } });
|
||||
|
||||
await vibeService.event('session', {
|
||||
eventId: 'event', type: 'progress', occurredAt: '2026-01-01T00:00:00.000Z', trackId: 'track', positionMs: 30000,
|
||||
});
|
||||
|
||||
expect(post).toHaveBeenCalledWith('/v2/vibe/sessions/session/events', expect.objectContaining({
|
||||
eventId: 'event', type: 'progress', trackId: 'track', positionMs: 30000,
|
||||
}));
|
||||
});
|
||||
});
|
||||
@@ -1,73 +1,121 @@
|
||||
import api from './api';
|
||||
import type { Track } from '../types';
|
||||
|
||||
// A candidate from the v2 recommendation plan. The plan is stored server-side
|
||||
// in Redis; the frontend only needs trackId + explanation for display.
|
||||
// These types intentionally mirror the durable session API. Tracks are not
|
||||
// embedded in a plan revision: the client resolves ids through the normal
|
||||
// library endpoint so a deleted/hidden track can never become playable merely
|
||||
// because an older plan mentioned it.
|
||||
export interface VibePlanItem {
|
||||
trackId: string;
|
||||
generatorId: string;
|
||||
explanation: unknown[];
|
||||
relevance: number;
|
||||
plan_version_id: string;
|
||||
ordinal: number;
|
||||
track_id: string;
|
||||
slot_role: string | null;
|
||||
candidate_source: string;
|
||||
score: number;
|
||||
score_breakdown: Record<string, unknown>;
|
||||
explanation: unknown;
|
||||
committed: boolean;
|
||||
}
|
||||
|
||||
export interface VibeStartResponse {
|
||||
export interface DurableVibeSessionResponse {
|
||||
sessionId: string;
|
||||
plan: VibePlanItem[];
|
||||
/** The durable session row. Present on start, resume and plan reads. */
|
||||
session?: { id: string; seed_track_id: string | null };
|
||||
planVersion: number | null;
|
||||
now: VibePlanItem | null;
|
||||
preview: VibePlanItem[];
|
||||
state: Record<string, unknown>;
|
||||
replanned: boolean;
|
||||
replanReason: string | null;
|
||||
}
|
||||
|
||||
export interface VibeNextResponse {
|
||||
track: Track;
|
||||
explanation: unknown[] | null;
|
||||
planRemaining: number;
|
||||
export type VibeEventType =
|
||||
| 'playback_started'
|
||||
| 'progress'
|
||||
| 'completed'
|
||||
| 'skipped'
|
||||
| 'disliked'
|
||||
| 'kept';
|
||||
|
||||
export interface VibeEventInput {
|
||||
eventId: string;
|
||||
type: VibeEventType;
|
||||
trackId?: string;
|
||||
occurredAt: string;
|
||||
positionMs?: number;
|
||||
durationMs?: number;
|
||||
payload?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export type VibeFeedbackAction = 'completed' | 'skipped' | 'promoted' | 'disliked';
|
||||
export interface VibeEventResponse extends DurableVibeSessionResponse {
|
||||
event: { id: string; client_event_id: string | null; type: string };
|
||||
idempotent: boolean;
|
||||
}
|
||||
|
||||
/** Coarse local calendar context, used only for short-lived Vibe preferences. */
|
||||
export interface VibeCalendarContext {
|
||||
localHour: number;
|
||||
weekday: number;
|
||||
month: number;
|
||||
timeZone?: string;
|
||||
}
|
||||
|
||||
/** A durable, idempotent advancement past a plan item the player cannot load. */
|
||||
export interface VibeUnplayableItemInput {
|
||||
eventId: string;
|
||||
planVersionId: string;
|
||||
ordinal: number;
|
||||
trackId: string;
|
||||
}
|
||||
|
||||
// V2 recommendation engine. The backend stores the plan in Redis (2h TTL) and
|
||||
// serves tracks one at a time via GET /next. Feedback triggers replanning.
|
||||
export const vibeService = {
|
||||
// POST /api/v2/vibe/start { seedTrackId? } -> { sessionId, plan }
|
||||
async start(seedTrackId?: string): Promise<VibeStartResponse> {
|
||||
const res = await api.post<VibeStartResponse>('/v2/vibe/start', { seedTrackId });
|
||||
async start(seedTrackId?: string, context?: VibeCalendarContext): Promise<DurableVibeSessionResponse> {
|
||||
const res = await api.post<DurableVibeSessionResponse>('/v2/vibe/sessions', { seedTrackId, context });
|
||||
return res.data;
|
||||
},
|
||||
|
||||
// GET /api/v2/vibe/next -> { track, explanation, planRemaining }
|
||||
// Returns one track at a time, shifting the server-side plan.
|
||||
// 404 if no active plan — caller should handle gracefully.
|
||||
async next(): Promise<VibeNextResponse> {
|
||||
const res = await api.get<VibeNextResponse>('/v2/vibe/next');
|
||||
/**
|
||||
* Pick up a session that is already running, on a device that did not start
|
||||
* it. Resuming is how Vibe control follows the audio between devices; it also
|
||||
* revives a session the reaper had paused.
|
||||
*/
|
||||
async resume(sessionId: string): Promise<DurableVibeSessionResponse> {
|
||||
const res = await api.post<DurableVibeSessionResponse>('/v2/vibe/sessions', { resumeSessionId: sessionId });
|
||||
return res.data;
|
||||
},
|
||||
|
||||
// POST /api/v2/vibe/feedback { trackId, action } -> { status, planRemaining }
|
||||
// Action 'promoted' also calls addFavorite; 'disliked' also calls dislikeTrack.
|
||||
// Triggers replan of the remaining plan.
|
||||
async feedback(trackId: string, action: VibeFeedbackAction): Promise<{ status: string; planRemaining: number }> {
|
||||
const res = await api.post<{ status: string; planRemaining: number }>('/v2/vibe/feedback', { trackId, action });
|
||||
async getPlan(sessionId: string, version?: number): Promise<DurableVibeSessionResponse> {
|
||||
const res = await api.get<DurableVibeSessionResponse>(`/v2/vibe/sessions/${sessionId}/plans`, {
|
||||
params: version === undefined ? undefined : { version },
|
||||
});
|
||||
return res.data;
|
||||
},
|
||||
|
||||
// GET /api/v2/vibe/plan -> { sessionId, planRemaining, plan }
|
||||
// Debug endpoint — returns the full remaining plan.
|
||||
async getPlan(): Promise<{ sessionId: string; planRemaining: number; plan: VibePlanItem[] }> {
|
||||
const res = await api.get('/v2/vibe/plan');
|
||||
async next(sessionId: string, expectedPlanVersion: number): Promise<DurableVibeSessionResponse> {
|
||||
const res = await api.post<DurableVibeSessionResponse>(`/v2/vibe/sessions/${sessionId}/advance`, {
|
||||
expectedPlanVersion,
|
||||
});
|
||||
return res.data;
|
||||
},
|
||||
|
||||
async advancePastUnplayable(
|
||||
sessionId: string,
|
||||
expectedPlanVersion: number,
|
||||
unplayable: VibeUnplayableItemInput,
|
||||
): Promise<DurableVibeSessionResponse> {
|
||||
const res = await api.post<DurableVibeSessionResponse>(`/v2/vibe/sessions/${sessionId}/advance`, {
|
||||
expectedPlanVersion,
|
||||
unplayable,
|
||||
});
|
||||
return res.data;
|
||||
},
|
||||
|
||||
async event(sessionId: string, event: VibeEventInput): Promise<VibeEventResponse> {
|
||||
const res = await api.post<VibeEventResponse>(`/v2/vibe/sessions/${sessionId}/events`, event);
|
||||
return res.data;
|
||||
},
|
||||
|
||||
async end(sessionId: string): Promise<DurableVibeSessionResponse> {
|
||||
const res = await api.post<DurableVibeSessionResponse>(`/v2/vibe/sessions/${sessionId}/end`);
|
||||
return res.data;
|
||||
},
|
||||
};
|
||||
|
||||
// Fetch N tracks from the v2 plan sequentially. Each call to /next shifts the
|
||||
// server-side plan, so calls must be sequential (not parallel). Stops early on
|
||||
// 404 (plan exhausted or expired).
|
||||
export async function fetchNextBatch(count: number): Promise<Track[]> {
|
||||
const tracks: Track[] = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
try {
|
||||
const { track } = await vibeService.next();
|
||||
tracks.push(track);
|
||||
} catch {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return tracks;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,438 @@
|
||||
import { AxiosError } from 'axios';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { Track } from '../types';
|
||||
import { usePlaybackStore } from '../store/usePlaybackStore';
|
||||
import { useVibeStore } from '../store/useVibeStore';
|
||||
|
||||
const { start, resume, next, advancePastUnplayable, event, end, getTrack } = vi.hoisted(() => ({
|
||||
start: vi.fn(), resume: vi.fn(), next: vi.fn(), advancePastUnplayable: vi.fn(), event: vi.fn(), end: vi.fn(), getTrack: vi.fn(),
|
||||
}));
|
||||
vi.mock('./vibeService', () => ({ vibeService: { start, resume, next, advancePastUnplayable, event, end } }));
|
||||
vi.mock('./trackService', () => ({ trackService: { getTrack } }));
|
||||
|
||||
import {
|
||||
adoptVibeSession,
|
||||
advancePastUnplayableVibeTrack,
|
||||
advanceVibe,
|
||||
endVibeSession,
|
||||
releaseVibeDriving,
|
||||
reportVibeEvent,
|
||||
startVibeSession,
|
||||
} from './vibeSession';
|
||||
|
||||
const track = (id: string): Track => ({
|
||||
id, path: `/music/${id}.mp3`, hash: id, title: id, artist: 'Artist',
|
||||
album_id: 'album', duration: 180, state: 'LIBRARY', source_type: 'MANUAL',
|
||||
play_count: 0, skip_count: 0, dislike_count: 0,
|
||||
});
|
||||
|
||||
const item = (track_id: string, committed = false, ordinal = 0) => ({
|
||||
plan_version_id: 'plan', ordinal, track_id, slot_role: null, candidate_source: 'test',
|
||||
score: 1, score_breakdown: {}, explanation: [], committed,
|
||||
});
|
||||
|
||||
const response = (planVersion: number, now: ReturnType<typeof item> | null = item('one', true), preview = [item('two')]) => ({
|
||||
sessionId: 'session-a', planVersion, now, preview, state: {}, replanned: false, replanReason: null,
|
||||
});
|
||||
|
||||
describe('durable Vibe session client', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
useVibeStore.getState().reset();
|
||||
usePlaybackStore.setState({
|
||||
currentTrack: null, queue: [], currentIndex: -1, isPlaying: false, vibeAdvanceHandler: null, queueOwner: 'ordinary',
|
||||
});
|
||||
getTrack.mockImplementation((id: string) => Promise.resolve(track(id)));
|
||||
});
|
||||
|
||||
it('starts by version-serving and hydrating the first durable plan item', async () => {
|
||||
start.mockResolvedValue(response(1, item('one'), [item('two')]));
|
||||
next.mockResolvedValue(response(1, item('one', true), [item('two')]));
|
||||
|
||||
await expect(startVibeSession(track('one'))).resolves.toMatchObject({ status: 'complete', tracks: [track('one'), track('two')] });
|
||||
|
||||
expect(start).toHaveBeenCalledWith('one', expect.objectContaining({
|
||||
localHour: expect.any(Number), weekday: expect.any(Number), month: expect.any(Number),
|
||||
}));
|
||||
expect(next).toHaveBeenCalledWith('session-a', 1);
|
||||
expect(useVibeStore.getState()).toMatchObject({ activeSessionId: 'session-a', planVersion: 1, buffer: [track('two')] });
|
||||
expect(usePlaybackStore.getState().currentTrack).toEqual(track('one'));
|
||||
});
|
||||
|
||||
it('drops a second recording of a song already in the queue', async () => {
|
||||
// Same title, different track: a cover or another artist's version. One sitting
|
||||
// should not play the same song twice.
|
||||
getTrack.mockImplementation((id: string) =>
|
||||
Promise.resolve({ ...track(id), title: id === 'cover' ? 'One' : track(id).title })
|
||||
);
|
||||
start.mockResolvedValue(response(1, item('one'), [item('cover'), item('two')]));
|
||||
next.mockResolvedValue(response(1, item('one', true), [item('cover'), item('two')]));
|
||||
|
||||
await startVibeSession({ ...track('one'), title: 'one' });
|
||||
|
||||
expect(usePlaybackStore.getState().queue.map((entry) => entry.id)).toEqual(['one', 'two']);
|
||||
});
|
||||
|
||||
it('replans, version-serves, and removes stale prefetched tracks before advancing', async () => {
|
||||
start.mockResolvedValue(response(1, item('one'), [item('stale')]));
|
||||
next
|
||||
.mockResolvedValueOnce(response(1, item('one', true), [item('stale')]))
|
||||
.mockResolvedValueOnce(response(2, item('two', true), [item('three')]));
|
||||
event.mockResolvedValue({ ...response(2, item('two'), [item('three')]), replanned: true, event: {}, idempotent: false });
|
||||
await startVibeSession(track('one'));
|
||||
|
||||
await advanceVibe('skipped');
|
||||
|
||||
expect(next).toHaveBeenLastCalledWith('session-a', 2);
|
||||
expect(usePlaybackStore.getState().queue.map((entry) => entry.id)).toEqual(['one', 'two', 'three']);
|
||||
expect(usePlaybackStore.getState().currentTrack?.id).toBe('two');
|
||||
expect(useVibeStore.getState().buffer.map((entry) => entry.id)).toEqual(['three']);
|
||||
expect(usePlaybackStore.getState().queue.map((entry) => entry.id)).not.toContain('stale');
|
||||
expect(event).toHaveBeenCalledWith('session-a', expect.objectContaining({ type: 'skipped', trackId: 'one', eventId: expect.any(String) }));
|
||||
});
|
||||
|
||||
it('replaces only the future when a keep event replans', async () => {
|
||||
start.mockResolvedValue(response(1, item('one'), [item('stale')]));
|
||||
next.mockResolvedValue(response(1, item('one', true), [item('stale')]));
|
||||
event.mockResolvedValue({ ...response(2, item('fresh'), [item('fresh'), item('later')]), replanned: true, event: {}, idempotent: false });
|
||||
await startVibeSession(track('one'));
|
||||
|
||||
await reportVibeEvent('kept', 'one');
|
||||
|
||||
expect(usePlaybackStore.getState().queue.map((entry) => entry.id)).toEqual(['one', 'fresh', 'later']);
|
||||
expect(usePlaybackStore.getState().currentTrack?.id).toBe('one');
|
||||
expect(useVibeStore.getState().planVersion).toBe(2);
|
||||
});
|
||||
|
||||
it('reconciles the canonical replacement returned by an idempotent material-event retry', async () => {
|
||||
start.mockResolvedValue(response(1, item('one'), [item('stale')]));
|
||||
next.mockResolvedValue(response(1, item('one', true), [item('stale')]));
|
||||
// The first response was lost after it published revision 2. Retrying the
|
||||
// same client event returns that revision with replanned=false.
|
||||
event.mockResolvedValue({
|
||||
...response(2, item('fresh'), [item('fresh'), item('later')]),
|
||||
replanned: false,
|
||||
event: {},
|
||||
idempotent: true,
|
||||
});
|
||||
await startVibeSession(track('one'));
|
||||
|
||||
await reportVibeEvent('kept', 'one');
|
||||
|
||||
expect(useVibeStore.getState()).toMatchObject({ planVersion: 2, buffer: [track('fresh'), track('later')] });
|
||||
expect(usePlaybackStore.getState().queue.map((entry) => entry.id)).toEqual(['one', 'fresh', 'later']);
|
||||
});
|
||||
|
||||
it('cleans up local playback when the durable session is gone', async () => {
|
||||
start.mockResolvedValue(response(1, item('one'), [item('stale')]));
|
||||
next.mockResolvedValue(response(1, item('one', true), [item('stale')]));
|
||||
event.mockRejectedValue(new AxiosError('gone', undefined, undefined, undefined, {
|
||||
data: {}, status: 404, statusText: 'Not Found', headers: {}, config: {} as never,
|
||||
}));
|
||||
await startVibeSession(track('one'));
|
||||
|
||||
await expect(reportVibeEvent('kept', 'one')).rejects.toThrow('gone');
|
||||
|
||||
expect(useVibeStore.getState().activeSessionId).toBeNull();
|
||||
expect(usePlaybackStore.getState()).toMatchObject({ currentTrack: null, queue: [], isPlaying: false });
|
||||
});
|
||||
|
||||
it('hands ordinary playback back to browse queues without Vibe reporting or next interception', async () => {
|
||||
start.mockResolvedValue(response(1, item('one'), [item('two')]));
|
||||
next.mockResolvedValue(response(1, item('one', true), [item('two')]));
|
||||
await startVibeSession(track('one'));
|
||||
|
||||
const ordinary = track('ordinary');
|
||||
const playback = usePlaybackStore.getState();
|
||||
playback.setQueue([ordinary, track('ordinary-next')]);
|
||||
playback.playTrack(ordinary);
|
||||
playback.nextWithReason('completed');
|
||||
|
||||
expect(usePlaybackStore.getState()).toMatchObject({
|
||||
queueOwner: 'ordinary', currentTrack: track('ordinary-next'), vibeAdvanceHandler: null,
|
||||
});
|
||||
expect(event).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('serializes material events and ignores an older plan revision', async () => {
|
||||
start.mockResolvedValue(response(1, item('one'), [item('old')]));
|
||||
next.mockResolvedValue(response(1, item('one', true), [item('old')]));
|
||||
await startVibeSession(track('one'));
|
||||
|
||||
let resolveFirst!: (value: ReturnType<typeof response> & { event: object; idempotent: boolean }) => void;
|
||||
event.mockImplementationOnce(() => new Promise((resolve) => { resolveFirst = resolve; }));
|
||||
event.mockResolvedValueOnce({ ...response(1, item('stale'), [item('stale')]), replanned: true, event: {}, idempotent: false });
|
||||
|
||||
const first = reportVibeEvent('kept', 'one');
|
||||
const second = reportVibeEvent('completed', 'one');
|
||||
await Promise.resolve();
|
||||
expect(event).toHaveBeenCalledTimes(1);
|
||||
resolveFirst({ ...response(2, item('fresh'), [item('fresh')]), replanned: true, event: {}, idempotent: false });
|
||||
await Promise.all([first, second]);
|
||||
|
||||
expect(event).toHaveBeenCalledTimes(2);
|
||||
expect(useVibeStore.getState()).toMatchObject({ planVersion: 2, buffer: [track('fresh')] });
|
||||
expect(usePlaybackStore.getState().queue.map((entry) => entry.id)).toEqual(['one', 'fresh']);
|
||||
});
|
||||
|
||||
it('retries a failed event with the same idempotency key until it is acknowledged', async () => {
|
||||
start.mockResolvedValue(response(1, item('one'), [item('two')]));
|
||||
next.mockResolvedValue(response(1, item('one', true), [item('two')]));
|
||||
event.mockRejectedValueOnce(new Error('network dropped')).mockResolvedValueOnce({
|
||||
...response(1), event: {}, idempotent: true,
|
||||
});
|
||||
await startVibeSession(track('one'));
|
||||
|
||||
await reportVibeEvent('progress', 'one', 30000, 180000);
|
||||
|
||||
expect(event).toHaveBeenCalledTimes(2);
|
||||
expect(event.mock.calls[0][1].eventId).toBe(event.mock.calls[1][1].eventId);
|
||||
});
|
||||
|
||||
it('skips a hidden plan item and starts from the next playable item', async () => {
|
||||
start.mockResolvedValue(response(1, item('hidden'), [item('good')]));
|
||||
next
|
||||
.mockResolvedValueOnce(response(1, item('hidden', true), [item('good')]));
|
||||
advancePastUnplayable.mockResolvedValueOnce(response(1, item('good', true), [item('later')]));
|
||||
getTrack.mockImplementation((id: string) => id === 'hidden'
|
||||
? Promise.resolve({ ...track(id), state: 'HIDDEN' })
|
||||
: Promise.resolve(track(id)));
|
||||
|
||||
await expect(startVibeSession(track('good'))).resolves.toMatchObject({ status: 'complete', tracks: [track('good'), track('later')] });
|
||||
|
||||
expect(next).toHaveBeenCalledTimes(1);
|
||||
expect(advancePastUnplayable).toHaveBeenCalledWith('session-a', 1, expect.objectContaining({
|
||||
planVersionId: 'plan', ordinal: 0, trackId: 'hidden', eventId: expect.any(String),
|
||||
}));
|
||||
expect(usePlaybackStore.getState().currentTrack?.id).toBe('good');
|
||||
expect(usePlaybackStore.getState().queue.map((entry) => entry.id)).not.toContain('hidden');
|
||||
});
|
||||
|
||||
it('advances consecutive hidden replacements directly without replaying an older served cursor', async () => {
|
||||
start.mockResolvedValue(response(1, item('hidden-one'), [item('hidden-two', false, 1)]));
|
||||
next.mockResolvedValueOnce(response(1, item('hidden-one', true), [item('hidden-two', false, 1)]));
|
||||
advancePastUnplayable
|
||||
.mockResolvedValueOnce(response(1, item('hidden-two', true, 1), [item('good', false, 2)]))
|
||||
.mockResolvedValueOnce(response(1, item('good', true, 2), [item('later', false, 3)]));
|
||||
getTrack.mockImplementation((id: string) => ['hidden-one', 'hidden-two'].includes(id)
|
||||
? Promise.resolve({ ...track(id), state: 'HIDDEN' })
|
||||
: Promise.resolve(track(id)));
|
||||
|
||||
await expect(startVibeSession(track('good'))).resolves.toMatchObject({
|
||||
status: 'complete', tracks: [track('good'), track('later')],
|
||||
});
|
||||
|
||||
expect(next).toHaveBeenCalledTimes(1);
|
||||
expect(advancePastUnplayable).toHaveBeenCalledTimes(2);
|
||||
expect(advancePastUnplayable.mock.calls.map(([, , input]) => [input.ordinal, input.trackId]))
|
||||
.toEqual([[0, 'hidden-one'], [1, 'hidden-two']]);
|
||||
expect(usePlaybackStore.getState().currentTrack?.id).toBe('good');
|
||||
});
|
||||
|
||||
it('retries an unplayable advancement with its original event id after a lost response', async () => {
|
||||
start.mockResolvedValue(response(1, item('hidden'), [item('good')]));
|
||||
next.mockResolvedValueOnce(response(1, item('hidden', true), [item('good')]));
|
||||
advancePastUnplayable
|
||||
.mockRejectedValueOnce(new Error('response dropped'))
|
||||
.mockResolvedValueOnce(response(1, item('good', true), [item('later')]));
|
||||
getTrack.mockImplementation((id: string) => id === 'hidden'
|
||||
? Promise.resolve({ ...track(id), state: 'MISSING' })
|
||||
: Promise.resolve(track(id)));
|
||||
|
||||
await startVibeSession(track('good'));
|
||||
|
||||
expect(advancePastUnplayable).toHaveBeenCalledTimes(2);
|
||||
expect(advancePastUnplayable.mock.calls[0][2].eventId)
|
||||
.toBe(advancePastUnplayable.mock.calls[1][2].eventId);
|
||||
});
|
||||
|
||||
it('advances a stream-error track through its stored durable cursor without ordinary feedback', async () => {
|
||||
start.mockResolvedValue(response(1, item('one'), [item('two', false, 1)]));
|
||||
next.mockResolvedValueOnce(response(1, item('one', true), [item('two', false, 1)]));
|
||||
advancePastUnplayable.mockResolvedValueOnce(response(1, item('two', true, 1), [item('later', false, 2)]));
|
||||
await startVibeSession(track('one'));
|
||||
|
||||
await advancePastUnplayableVibeTrack('one');
|
||||
|
||||
expect(advancePastUnplayable).toHaveBeenCalledWith('session-a', 1, expect.objectContaining({
|
||||
planVersionId: 'plan', ordinal: 0, trackId: 'one', eventId: expect.any(String),
|
||||
}));
|
||||
expect(event).not.toHaveBeenCalledWith('session-a', expect.objectContaining({ type: 'skipped', trackId: 'one' }));
|
||||
expect(usePlaybackStore.getState().currentTrack?.id).toBe('two');
|
||||
expect(useVibeStore.getState().currentPlanItem).toMatchObject({ track_id: 'two', ordinal: 1 });
|
||||
});
|
||||
|
||||
// A locked phone drops its connection for a moment. Neither half of the
|
||||
// advance may treat that as the session's fault: the plan is still valid, the
|
||||
// prefetched future is still worth keeping, and the listener's action must
|
||||
// reach the director exactly once.
|
||||
const offline = () => new AxiosError('network error', 'ERR_NETWORK');
|
||||
|
||||
/** Let the pending backoff hear the network come back, rather than waiting it out. */
|
||||
const comeBackOnline = async () => {
|
||||
await Promise.resolve();
|
||||
window.dispatchEvent(new Event('online'));
|
||||
};
|
||||
|
||||
it('waits out a dropped connection and sends the same event once', async () => {
|
||||
start.mockResolvedValue(response(1, item('one'), [item('two')]));
|
||||
next
|
||||
.mockResolvedValueOnce(response(1, item('one', true), [item('two')]))
|
||||
.mockResolvedValue(response(2, item('two', true), [item('three')]));
|
||||
await startVibeSession(track('one'));
|
||||
|
||||
// Fails, retries immediately, fails again, then waits for the network.
|
||||
event
|
||||
.mockRejectedValueOnce(offline())
|
||||
.mockRejectedValueOnce(offline())
|
||||
.mockResolvedValue({ ...response(2, item('two'), [item('three')]), replanned: true, event: {}, idempotent: false });
|
||||
|
||||
const advancing = advanceVibe('completed');
|
||||
await vi.waitFor(() => expect(event).toHaveBeenCalledTimes(2));
|
||||
await comeBackOnline();
|
||||
await advancing;
|
||||
|
||||
const eventIds = event.mock.calls.map((call) => (call[1] as { eventId: string }).eventId);
|
||||
expect(new Set(eventIds).size).toBe(1);
|
||||
expect(usePlaybackStore.getState().currentTrack?.id).toBe('two');
|
||||
expect(usePlaybackStore.getState().queue.map((entry) => entry.id)).toEqual(['one', 'two', 'three']);
|
||||
});
|
||||
|
||||
it('retries only the serve when the connection drops after the event landed', async () => {
|
||||
start.mockResolvedValue(response(1, item('one'), [item('two')]));
|
||||
next
|
||||
.mockResolvedValueOnce(response(1, item('one', true), [item('two')]))
|
||||
.mockRejectedValueOnce(offline())
|
||||
.mockResolvedValue(response(2, item('two', true), [item('three')]));
|
||||
event.mockResolvedValue({ ...response(2, item('two'), [item('three')]), replanned: true, event: {}, idempotent: false });
|
||||
await startVibeSession(track('one'));
|
||||
|
||||
const advancing = advanceVibe('completed');
|
||||
await vi.waitFor(() => expect(next).toHaveBeenCalledTimes(2));
|
||||
await comeBackOnline();
|
||||
await advancing;
|
||||
|
||||
// The event is the listener's action and must not be replayed by a retry
|
||||
// that only the serve needed.
|
||||
expect(event).toHaveBeenCalledTimes(1);
|
||||
expect(usePlaybackStore.getState().currentTrack?.id).toBe('two');
|
||||
});
|
||||
|
||||
it('keeps the prefetched future when a dropped connection outlives every retry', async () => {
|
||||
start.mockResolvedValue(response(1, item('one'), [item('two')]));
|
||||
next.mockResolvedValue(response(1, item('one', true), [item('two')]));
|
||||
await startVibeSession(track('one'));
|
||||
const future = usePlaybackStore.getState().queue.map((entry) => entry.id);
|
||||
|
||||
event.mockRejectedValue(new AxiosError('bad gateway', undefined, undefined, undefined, {
|
||||
data: {}, status: 502, statusText: 'Bad Gateway', headers: {}, config: {} as never,
|
||||
}));
|
||||
|
||||
// Cut every backoff short so the retries exhaust without real waiting.
|
||||
const exhausting = advanceVibe('completed');
|
||||
const pump = setInterval(() => window.dispatchEvent(new Event('online')), 0);
|
||||
await expect(exhausting).rejects.toThrow('bad gateway');
|
||||
clearInterval(pump);
|
||||
|
||||
expect(useVibeStore.getState().activeSessionId).toBe('session-a');
|
||||
expect(usePlaybackStore.getState().queue.map((entry) => entry.id)).toEqual(future);
|
||||
expect(usePlaybackStore.getState().isPlaying).toBe(false);
|
||||
});
|
||||
|
||||
it('plays the seed first and steps off it without reporting plan feedback', async () => {
|
||||
start.mockResolvedValue(response(1, item('one'), [item('two')]));
|
||||
next.mockResolvedValue(response(1, item('one', true), [item('two')]));
|
||||
|
||||
await startVibeSession(track('seed'));
|
||||
|
||||
expect(usePlaybackStore.getState().currentTrack?.id).toBe('seed');
|
||||
expect(usePlaybackStore.getState().queue.map((entry) => entry.id)).toEqual(['seed', 'one', 'two']);
|
||||
|
||||
await advanceVibe('completed');
|
||||
|
||||
// The seed is not a plan item: no feedback, no second serve, and the plan's
|
||||
// own first item is what plays next.
|
||||
expect(event).not.toHaveBeenCalled();
|
||||
expect(next).toHaveBeenCalledTimes(1);
|
||||
expect(usePlaybackStore.getState().currentTrack?.id).toBe('one');
|
||||
});
|
||||
|
||||
it('adopts a session running on another device and drives it from here', async () => {
|
||||
// What a device that has just been handed the audio starts from: the queue
|
||||
// and playing track came off the playback snapshot, the session did not.
|
||||
usePlaybackStore.setState({
|
||||
currentTrack: track('one'), queue: [track('one')], currentIndex: 0, isPlaying: true,
|
||||
});
|
||||
resume.mockResolvedValue({ ...response(3, null, [item('two'), item('three')]), session: { id: 'session-a', seed_track_id: 'one' } });
|
||||
|
||||
await expect(adoptVibeSession('session-a')).resolves.toBe(true);
|
||||
|
||||
expect(resume).toHaveBeenCalledWith('session-a');
|
||||
expect(useVibeStore.getState()).toMatchObject({
|
||||
activeSessionId: 'session-a', seedTrackId: 'one', planVersion: 3, buffer: [track('two'), track('three')],
|
||||
});
|
||||
// No cursor is recoverable for the track already playing; the next advance sets one.
|
||||
expect(useVibeStore.getState().currentPlanItem).toBeNull();
|
||||
expect(usePlaybackStore.getState().queueOwner).toBe('vibe');
|
||||
expect(usePlaybackStore.getState().vibeAdvanceHandler).not.toBeNull();
|
||||
expect(usePlaybackStore.getState().queue.map((entry) => entry.id)).toEqual(['one', 'two', 'three']);
|
||||
// The track that was playing keeps playing: adoption replaces the future only.
|
||||
expect(usePlaybackStore.getState().currentTrack?.id).toBe('one');
|
||||
});
|
||||
|
||||
it('keeps playing the ordinary queue when the session it was told about is gone', async () => {
|
||||
usePlaybackStore.setState({ currentTrack: track('one'), queue: [track('one')], currentIndex: 0 });
|
||||
resume.mockRejectedValue(new AxiosError('gone', undefined, undefined, undefined, {
|
||||
data: {}, status: 404, statusText: 'Not Found', headers: {}, config: {} as never,
|
||||
}));
|
||||
|
||||
await expect(adoptVibeSession('session-a')).resolves.toBe(false);
|
||||
|
||||
expect(usePlaybackStore.getState()).toMatchObject({ queueOwner: 'ordinary', vibeAdvanceHandler: null });
|
||||
expect(useVibeStore.getState().activeSessionId).toBeNull();
|
||||
});
|
||||
|
||||
it('stops driving a session without ending it when the audio moves away', async () => {
|
||||
start.mockResolvedValue(response(1, item('one'), [item('two')]));
|
||||
next.mockResolvedValue(response(1, item('one', true), [item('two')]));
|
||||
await startVibeSession(track('one'));
|
||||
|
||||
releaseVibeDriving();
|
||||
|
||||
expect(usePlaybackStore.getState().vibeAdvanceHandler).toBeNull();
|
||||
expect(useVibeStore.getState().activeSessionId).toBe('session-a');
|
||||
expect(end).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('reports an unplayable stream as a skip while an adopted session has no cursor', async () => {
|
||||
usePlaybackStore.setState({
|
||||
currentTrack: track('one'), queue: [track('one')], currentIndex: 0,
|
||||
queueOwner: 'vibe', vibeAdvanceHandler: () => undefined,
|
||||
});
|
||||
resume.mockResolvedValue(response(3, null, [item('two')]));
|
||||
await adoptVibeSession('session-a');
|
||||
event.mockResolvedValue(response(4, null, [item('two')]));
|
||||
next.mockResolvedValue(response(4, item('two', true), []));
|
||||
|
||||
await advancePastUnplayableVibeTrack('one');
|
||||
|
||||
expect(advancePastUnplayable).not.toHaveBeenCalled();
|
||||
expect(event).toHaveBeenCalledWith('session-a', expect.objectContaining({ type: 'skipped', trackId: 'one' }));
|
||||
expect(usePlaybackStore.getState().currentTrack?.id).toBe('two');
|
||||
});
|
||||
|
||||
it('ends a Vibe by removing Vibe ownership and clearing the local queue', async () => {
|
||||
start.mockResolvedValue(response(1, item('one'), [item('two')]));
|
||||
next.mockResolvedValue(response(1, item('one', true), [item('two')]));
|
||||
end.mockResolvedValue(response(1));
|
||||
await startVibeSession(track('one'));
|
||||
|
||||
await endVibeSession();
|
||||
|
||||
expect(end).toHaveBeenCalledWith('session-a');
|
||||
expect(useVibeStore.getState().activeSessionId).toBeNull();
|
||||
expect(usePlaybackStore.getState()).toMatchObject({
|
||||
queueOwner: 'ordinary', vibeAdvanceHandler: null, currentTrack: null, queue: [], isPlaying: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,664 @@
|
||||
import axios from 'axios';
|
||||
import type { Track } from '../types';
|
||||
import { usePlaybackStore, type VibeAdvanceReason } from '../store/usePlaybackStore';
|
||||
import { useVibeStore } from '../store/useVibeStore';
|
||||
import {
|
||||
vibeService,
|
||||
type DurableVibeSessionResponse,
|
||||
type VibeCalendarContext,
|
||||
type VibeEventType,
|
||||
type VibePlanItem,
|
||||
} from './vibeService';
|
||||
import { trackService } from './trackService';
|
||||
|
||||
export interface StartedVibeSession {
|
||||
status: 'complete' | 'exhausted' | 'failed';
|
||||
tracks: Track[];
|
||||
}
|
||||
|
||||
let startInFlight: Promise<StartedVibeSession> | null = null;
|
||||
let advanceInFlight: Promise<void> | null = null;
|
||||
let materialTail: Promise<void> = Promise.resolve();
|
||||
// A seeded Vibe plays its seed first — asking for a vibe "from this track" and
|
||||
// getting a different track is the surprise. The seed sits in front of the
|
||||
// durable plan without being part of it, so the first advance must consume it
|
||||
// locally instead of reporting feedback and serving the next item.
|
||||
// ponytail: no 'completed' event is sent for the seed. The listener chose it
|
||||
// explicitly; the director already has that signal from the session's seed id.
|
||||
let seedPendingTrackId: string | null = null;
|
||||
|
||||
interface PendingEvent {
|
||||
sessionId: string;
|
||||
input: Parameters<typeof vibeService.event>[1];
|
||||
retried: boolean;
|
||||
settled: boolean;
|
||||
/** Consecutive failures that were the network's fault rather than the server's. */
|
||||
transientAttempts: number;
|
||||
resolve: (response: DurableVibeSessionResponse) => void;
|
||||
reject: (error: unknown) => void;
|
||||
}
|
||||
|
||||
// A phone with a locked screen drops its radio, changes network, or dozes, and
|
||||
// one request fails. Waiting through it is right: the plan is still valid and
|
||||
// the event id is stable, so the same event is simply sent again. Roughly two
|
||||
// minutes of waiting in total before giving up on the listener's behalf.
|
||||
const TRANSIENT_BACKOFF_MS = [1_000, 2_000, 5_000, 10_000, 20_000, 30_000, 30_000, 30_000];
|
||||
|
||||
// The event ledger deduplicates client_event_id. Keep an event in this ordered
|
||||
// outbox until the server acknowledges it so a transient failure never turns a
|
||||
// retry into a second listener action.
|
||||
const eventOutbox: PendingEvent[] = [];
|
||||
let flushingOutbox = false;
|
||||
|
||||
function serializeMaterial<T>(operation: () => Promise<T>): Promise<T> {
|
||||
const result = materialTail.then(operation, operation);
|
||||
materialTail = result.then(() => undefined, () => undefined);
|
||||
return result;
|
||||
}
|
||||
|
||||
function newEventId(): string {
|
||||
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') return crypto.randomUUID();
|
||||
// UUID v4-shaped fallback for older embedded webviews. The server only uses
|
||||
// this as an idempotency key, not as a source of entropy.
|
||||
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (letter) => {
|
||||
const value = Math.floor(Math.random() * 16);
|
||||
return (letter === 'x' ? value : (value & 0x3) | 0x8).toString(16);
|
||||
});
|
||||
}
|
||||
|
||||
function localCalendarContext(): VibeCalendarContext {
|
||||
const now = new Date();
|
||||
let timeZone: string | undefined;
|
||||
try {
|
||||
timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone || undefined;
|
||||
} catch {
|
||||
// Some embedded players omit Intl time-zone support. The coarse calendar
|
||||
// fields still provide useful, non-identifying context.
|
||||
}
|
||||
return {
|
||||
localHour: now.getHours(),
|
||||
weekday: now.getDay(),
|
||||
month: now.getMonth() + 1,
|
||||
timeZone,
|
||||
};
|
||||
}
|
||||
|
||||
function isPlayable(track: Track): boolean {
|
||||
return !['HIDDEN', 'MISSING', 'DELETED'].includes(track.state);
|
||||
}
|
||||
|
||||
async function hydrateItem(item: VibePlanItem | null): Promise<Track | null> {
|
||||
if (!item) return null;
|
||||
try {
|
||||
const track = await trackService.getTrack(item.track_id);
|
||||
return isPlayable(track) ? track : null;
|
||||
} catch {
|
||||
// A plan can outlive a hidden/deleted file. Never substitute another item
|
||||
// for this ordinal: keeping the remaining order is safer than a mismatch.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function hydratePreview(items: VibePlanItem[]): Promise<Track[]> {
|
||||
const uniqueIds = [...new Set(items.map((item) => item.track_id))];
|
||||
const loaded = await Promise.all(uniqueIds.map(async (id) => {
|
||||
try {
|
||||
const track = await trackService.getTrack(id);
|
||||
return [id, isPlayable(track) ? track : null] as const;
|
||||
} catch {
|
||||
return [id, null] as const;
|
||||
}
|
||||
}));
|
||||
const byId = new Map(loaded.filter((entry): entry is readonly [string, Track] => entry[1] !== null));
|
||||
return items.flatMap((item) => {
|
||||
const track = byId.get(item.track_id);
|
||||
return track ? [track] : [];
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Two recordings of one song — a cover, a remaster, another artist's version —
|
||||
* are distinct track ids but read as a duplicate in one sitting. The title is the
|
||||
* key; remixes and live cuts name themselves in the title, so they survive.
|
||||
* ponytail: title string match, no normalisation beyond case and edges. Add
|
||||
* feat./punctuation stripping only if real duplicates keep getting through.
|
||||
*/
|
||||
const songKey = (track: Track) => (track.title || track.id).trim().toLowerCase();
|
||||
|
||||
function dedupeSongs(tracks: Track[], seen = new Set<string>()): Track[] {
|
||||
return tracks.filter((track) => {
|
||||
const key = songKey(track);
|
||||
if (seen.has(key)) return false;
|
||||
seen.add(key);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
/** Replace only the queue after the currently playing Vibe track. */
|
||||
function replaceUnplayedQueue(preview: Track[]): void {
|
||||
const playback = usePlaybackStore.getState();
|
||||
const current = playback.currentTrack;
|
||||
const queueIndex = current
|
||||
? (playback.currentIndex >= 0 && playback.queue[playback.currentIndex]?.id === current.id
|
||||
? playback.currentIndex
|
||||
: playback.queue.findIndex((track) => track.id === current.id))
|
||||
: -1;
|
||||
const history = queueIndex >= 0
|
||||
? playback.queue.slice(0, queueIndex + 1)
|
||||
: current ? [current] : [];
|
||||
// While the seed plays, the durable cursor's own track sits between it and the
|
||||
// preview. A `preview` list never contains that served item, so keep it — but
|
||||
// only while it is still the cursor, never after it has been retired.
|
||||
const next = playback.queue[queueIndex + 1];
|
||||
const served = queueIndex >= 0
|
||||
&& playback.queue[queueIndex]?.id === seedPendingTrackId
|
||||
&& next
|
||||
&& useVibeStore.getState().currentPlanItem?.track_id === next.id
|
||||
? [next]
|
||||
: [];
|
||||
const future = dedupeSongs(preview, new Set([...history, ...served].map(songKey)));
|
||||
playback.setVibeQueue([...history, ...served, ...future]);
|
||||
}
|
||||
|
||||
function isCurrentVibeOwner(sessionId: string): boolean {
|
||||
return useVibeStore.getState().activeSessionId === sessionId
|
||||
&& usePlaybackStore.getState().queueOwner === 'vibe';
|
||||
}
|
||||
|
||||
async function reconcilePreview(sessionId: string, response: DurableVibeSessionResponse): Promise<Track[]> {
|
||||
if (!isCurrentVibeOwner(sessionId)) return [];
|
||||
const preview = await hydratePreview(response.preview);
|
||||
if (!isCurrentVibeOwner(sessionId) || !useVibeStore.getState().setPlan(response.planVersion, preview)) return [];
|
||||
useVibeStore.getState().setProfile(response.state);
|
||||
replaceUnplayedQueue(preview);
|
||||
return preview;
|
||||
}
|
||||
|
||||
async function serveNextCurrent(sessionId: string, version: number): Promise<DurableVibeSessionResponse> {
|
||||
// A concurrent device or a feedback replan can make a version stale between
|
||||
// the event response and /next. A stale response has an uncommitted `now`;
|
||||
// refresh once with its latest version before admitting a track to playback.
|
||||
let response = await vibeService.next(sessionId, version);
|
||||
if (response.now?.committed) return response;
|
||||
if (!response.planVersion || response.planVersion === version) return response;
|
||||
response = await vibeService.next(sessionId, response.planVersion);
|
||||
return response;
|
||||
}
|
||||
|
||||
async function serveNextPlayable(
|
||||
sessionId: string,
|
||||
version: number,
|
||||
): Promise<{ response: DurableVibeSessionResponse; now: Track; preview: Track[] } | null> {
|
||||
return resolvePlayableResponse(sessionId, await serveNextCurrent(sessionId, version));
|
||||
}
|
||||
|
||||
/**
|
||||
* Serving a version is idempotent, so a dropped connection here costs nothing
|
||||
* but the wait. The feedback event has already been acknowledged by this point,
|
||||
* which is why the retry sits around the serve alone: replaying the whole
|
||||
* advance would send a second event for a track the listener heard once.
|
||||
*/
|
||||
async function serveNextPlayableRetrying(
|
||||
sessionId: string,
|
||||
version: number,
|
||||
): Promise<{ response: DurableVibeSessionResponse; now: Track; preview: Track[] } | null> {
|
||||
for (let attempt = 0; ; attempt++) {
|
||||
try {
|
||||
return await serveNextPlayable(sessionId, version);
|
||||
} catch (error) {
|
||||
if (!isTransientEventError(error) || attempt >= TRANSIENT_BACKOFF_MS.length - 1) throw error;
|
||||
await waitBeforeRetry(attempt);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function advanceResponsePastUnplayable(
|
||||
sessionId: string,
|
||||
response: DurableVibeSessionResponse,
|
||||
): Promise<DurableVibeSessionResponse> {
|
||||
if (!response.now?.committed || !response.planVersion) return response;
|
||||
const unplayable = {
|
||||
eventId: newEventId(),
|
||||
planVersionId: response.now.plan_version_id,
|
||||
ordinal: response.now.ordinal,
|
||||
trackId: response.now.track_id,
|
||||
};
|
||||
try {
|
||||
return await vibeService.advancePastUnplayable(sessionId, response.planVersion, unplayable);
|
||||
} catch (error) {
|
||||
// A response may have been lost after the server committed the advance.
|
||||
// Retry the same event id so it returns the same replacement rather than
|
||||
// consuming another future item.
|
||||
if (isSessionTerminalError(error)) throw error;
|
||||
return vibeService.advancePastUnplayable(sessionId, response.planVersion, unplayable);
|
||||
}
|
||||
}
|
||||
|
||||
async function resolvePlayableResponse(
|
||||
sessionId: string,
|
||||
initialResponse: DurableVibeSessionResponse,
|
||||
): Promise<{ response: DurableVibeSessionResponse; now: Track; preview: Track[] } | null> {
|
||||
let response = initialResponse;
|
||||
// A durable plan can reference a file which has since become hidden. Commit
|
||||
// past such entries but never load one into the player. An unplayable
|
||||
// advancement already returns and commits its replacement, so process that
|
||||
// response directly: asking ordinary /next again would replay the original
|
||||
// served cursor rather than advancing through consecutive hidden entries.
|
||||
for (let attempts = 0; attempts < 20; attempts++) {
|
||||
if (!response.now?.committed || !response.planVersion) {
|
||||
if (!response.planVersion) return null;
|
||||
response = await serveNextCurrent(sessionId, response.planVersion);
|
||||
continue;
|
||||
}
|
||||
let now = await hydrateItem(response.now);
|
||||
let preview = await hydratePreview(response.preview);
|
||||
if (now) return { response, now, preview };
|
||||
const advanced = await advanceResponsePastUnplayable(sessionId, response);
|
||||
if (!advanced.planVersion) return null;
|
||||
// The unplayable transition may itself race a feedback replan. Its stale
|
||||
// response did not advance the old revision, so version-serve the current
|
||||
// revision normally rather than treating a preview item as committed.
|
||||
if (!advanced.now?.committed) {
|
||||
response = await serveNextCurrent(sessionId, advanced.planVersion);
|
||||
continue;
|
||||
}
|
||||
response = advanced;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function deactivateBrokenSession(): void {
|
||||
seedPendingTrackId = null;
|
||||
const playback = usePlaybackStore.getState();
|
||||
playback.setVibeAdvanceHandler(null);
|
||||
useVibeStore.getState().reset();
|
||||
playback.pause();
|
||||
playback.setQueue([]);
|
||||
playback.setCurrentTrack(null);
|
||||
}
|
||||
|
||||
function isSessionTerminalError(error: unknown): boolean {
|
||||
return axios.isAxiosError(error) && [401, 404, 409].includes(error.response?.status ?? 0);
|
||||
}
|
||||
|
||||
export function vibeErrorMessage(error: unknown): string {
|
||||
if (!axios.isAxiosError(error)) return 'Could not refresh this Vibe. Please try again.';
|
||||
switch (error.response?.status) {
|
||||
case 400: return 'Vibe needs a valid user identity.';
|
||||
case 404: return 'This Vibe session is no longer available.';
|
||||
case 409: return 'This Vibe session has already ended or was replaced.';
|
||||
default: return 'Could not refresh this Vibe. Please try again.';
|
||||
}
|
||||
}
|
||||
|
||||
async function sendEvent(
|
||||
type: VibeEventType,
|
||||
trackId?: string,
|
||||
positionMs?: number,
|
||||
durationMs?: number,
|
||||
): Promise<DurableVibeSessionResponse | null> {
|
||||
const sessionId = useVibeStore.getState().activeSessionId;
|
||||
if (!sessionId) return null;
|
||||
const input = {
|
||||
eventId: newEventId(),
|
||||
type,
|
||||
trackId,
|
||||
occurredAt: new Date().toISOString(),
|
||||
positionMs,
|
||||
durationMs,
|
||||
};
|
||||
return new Promise<DurableVibeSessionResponse>((resolve, reject) => {
|
||||
eventOutbox.push({ sessionId, input, retried: false, settled: false, transientAttempts: 0, resolve, reject });
|
||||
void flushEventOutbox();
|
||||
});
|
||||
}
|
||||
|
||||
function retryableEventError(error: unknown): boolean {
|
||||
return !isSessionTerminalError(error);
|
||||
}
|
||||
|
||||
/**
|
||||
* A failure the request never survived to reach an opinion about: no response
|
||||
* at all (offline, timeout, DNS), or a server that is momentarily unable rather
|
||||
* than refusing. These say nothing about the session, so they must not be
|
||||
* allowed to discard a valid plan.
|
||||
*/
|
||||
function isTransientEventError(error: unknown): boolean {
|
||||
if (!axios.isAxiosError(error)) return false;
|
||||
if (!error.response) return true;
|
||||
return error.response.status === 429 || error.response.status >= 500;
|
||||
}
|
||||
|
||||
/** Wait out a backoff, but come back early the moment the network returns. */
|
||||
function waitBeforeRetry(attempt: number): Promise<void> {
|
||||
const delay = TRANSIENT_BACKOFF_MS[Math.min(attempt, TRANSIENT_BACKOFF_MS.length - 1)];
|
||||
return new Promise((resolve) => {
|
||||
let settled = false;
|
||||
const finish = () => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
window.removeEventListener('online', finish);
|
||||
resolve();
|
||||
};
|
||||
const timer = setTimeout(finish, delay);
|
||||
window.addEventListener('online', finish);
|
||||
});
|
||||
}
|
||||
|
||||
async function flushEventOutbox(): Promise<void> {
|
||||
if (flushingOutbox) return;
|
||||
flushingOutbox = true;
|
||||
try {
|
||||
while (eventOutbox.length > 0) {
|
||||
const entry = eventOutbox[0];
|
||||
try {
|
||||
const response = await vibeService.event(entry.sessionId, entry.input);
|
||||
eventOutbox.shift();
|
||||
entry.settled = true;
|
||||
entry.resolve(response);
|
||||
} catch (error) {
|
||||
// Retry once immediately using the exact same client event id. After
|
||||
// that leave it at the head for a later retry, rather than discarding
|
||||
// the idempotency key or allowing newer material events to overtake it.
|
||||
if (!entry.retried && retryableEventError(error)) {
|
||||
entry.retried = true;
|
||||
continue;
|
||||
}
|
||||
// The network failed, not the session. Hold the entry unsettled and
|
||||
// keep trying: rejecting here is what used to end the Vibe whenever a
|
||||
// locked phone lost its connection for a moment.
|
||||
if (isTransientEventError(error) && entry.transientAttempts < TRANSIENT_BACKOFF_MS.length) {
|
||||
const attempt = entry.transientAttempts;
|
||||
entry.transientAttempts += 1;
|
||||
await waitBeforeRetry(attempt);
|
||||
continue;
|
||||
}
|
||||
// A session that is gone/ended can never acknowledge this event. Do
|
||||
// not let an irrecoverable old-session entry block a later session.
|
||||
if (isSessionTerminalError(error)) eventOutbox.shift();
|
||||
if (!entry.settled) {
|
||||
entry.settled = true;
|
||||
entry.reject(error);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
flushingOutbox = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a non-navigation event. Material feedback reconciles the future before
|
||||
* it resolves, so no old prefetch remains after Keep or an implicit update.
|
||||
*/
|
||||
export async function reportVibeEvent(
|
||||
type: VibeEventType,
|
||||
trackId?: string,
|
||||
positionMs?: number,
|
||||
durationMs?: number,
|
||||
): Promise<void> {
|
||||
return serializeMaterial(async () => {
|
||||
const sessionId = useVibeStore.getState().activeSessionId;
|
||||
if (!sessionId || !isCurrentVibeOwner(sessionId)) return;
|
||||
try {
|
||||
const response = await sendEvent(type, trackId, positionMs, durationMs);
|
||||
// Even a duplicate material event can acknowledge a canonical
|
||||
// replacement revision (replanned=false). Reconcile every valid
|
||||
// revision so a response lost after its original replan cannot leave a
|
||||
// stale locally-prefetched future behind.
|
||||
if (response?.planVersion !== null && response?.planVersion !== undefined) {
|
||||
await reconcilePreview(sessionId, response);
|
||||
}
|
||||
} catch (error) {
|
||||
if (isSessionTerminalError(error)) deactivateBrokenSession();
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Advance only after the prior track's durable outcome has produced a new plan. */
|
||||
export function advanceVibe(reason: VibeAdvanceReason): Promise<void> {
|
||||
if (advanceInFlight) return advanceInFlight;
|
||||
advanceInFlight = serializeMaterial(async () => {
|
||||
const vibe = useVibeStore.getState();
|
||||
const current = usePlaybackStore.getState().currentTrack;
|
||||
if (!vibe.activeSessionId || !current || !isCurrentVibeOwner(vibe.activeSessionId)) return;
|
||||
|
||||
// The seed is not a plan item — step off it without touching the cursor.
|
||||
if (seedPendingTrackId && current.id === seedPendingTrackId) {
|
||||
seedPendingTrackId = null;
|
||||
usePlaybackStore.getState().advance();
|
||||
// A dislike still has to reach the director; a completed seed carries no
|
||||
// information the session's seed id does not already hold.
|
||||
if (reason !== 'completed') void sendEvent(reason, current.id).catch(() => undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const feedback = await sendEvent(reason, current.id);
|
||||
if (!feedback?.planVersion) {
|
||||
usePlaybackStore.getState().pause();
|
||||
return;
|
||||
}
|
||||
const served = await serveNextPlayableRetrying(vibe.activeSessionId, feedback.planVersion);
|
||||
if (!served || served.response.sessionId !== vibe.activeSessionId || !isCurrentVibeOwner(vibe.activeSessionId)) {
|
||||
// Never play an uncommitted or unresolvable plan item. The user can
|
||||
// retry from the page after the director publishes another revision.
|
||||
replaceUnplayedQueue([]);
|
||||
usePlaybackStore.getState().pause();
|
||||
return;
|
||||
}
|
||||
if (!useVibeStore.getState().setPlan(served.response.planVersion, served.preview)) return;
|
||||
useVibeStore.getState().setProfile(served.response.state);
|
||||
useVibeStore.getState().setCurrentPlanItem(served.response.now);
|
||||
replaceUnplayedQueue([served.now, ...served.preview]);
|
||||
usePlaybackStore.getState().advance();
|
||||
} catch (error) {
|
||||
// A network failure that outlived every retry leaves the plan valid and
|
||||
// the prefetched future worth keeping, so the listener can carry on from
|
||||
// the page once they are back on a connection.
|
||||
if (isTransientEventError(error)) {
|
||||
usePlaybackStore.getState().pause();
|
||||
throw error;
|
||||
}
|
||||
// Clearing the future is deliberate: carrying on with stale prefetches
|
||||
// after a rejected feedback/replan would violate the plan boundary.
|
||||
replaceUnplayedQueue([]);
|
||||
if (isSessionTerminalError(error)) deactivateBrokenSession();
|
||||
else usePlaybackStore.getState().pause();
|
||||
throw error;
|
||||
}
|
||||
}).finally(() => { advanceInFlight = null; });
|
||||
return advanceInFlight;
|
||||
}
|
||||
|
||||
/**
|
||||
* A stream can fail after its track metadata was successfully hydrated. This
|
||||
* advances the exact durable cursor through the explicit unplayable protocol,
|
||||
* rather than treating it as ordinary feedback and allowing a replan to hide
|
||||
* the failure.
|
||||
*/
|
||||
export function advancePastUnplayableVibeTrack(trackId: string): Promise<void> {
|
||||
if (advanceInFlight) return advanceInFlight;
|
||||
// A session adopted from another device has no cursor until its first advance.
|
||||
// Without one there is no durable item to step past, so report the failure as
|
||||
// an ordinary skip rather than stalling on a track that will not play.
|
||||
const adopted = useVibeStore.getState();
|
||||
if (
|
||||
adopted.activeSessionId && !adopted.currentPlanItem && seedPendingTrackId !== trackId
|
||||
&& usePlaybackStore.getState().currentTrack?.id === trackId
|
||||
) {
|
||||
return advanceVibe('skipped');
|
||||
}
|
||||
advanceInFlight = serializeMaterial(async () => {
|
||||
const vibe = useVibeStore.getState();
|
||||
const playback = usePlaybackStore.getState();
|
||||
const currentItem = vibe.currentPlanItem;
|
||||
if (!vibe.activeSessionId || !isCurrentVibeOwner(vibe.activeSessionId)) return;
|
||||
// The seed has no durable cursor to advance — a seed that will not stream is
|
||||
// simply stepped over, leaving the plan's first item to play next.
|
||||
if (seedPendingTrackId === trackId) {
|
||||
seedPendingTrackId = null;
|
||||
playback.advance();
|
||||
return;
|
||||
}
|
||||
if (!currentItem || currentItem.track_id !== trackId) return;
|
||||
|
||||
try {
|
||||
const advanced = await advanceResponsePastUnplayable(vibe.activeSessionId, {
|
||||
sessionId: vibe.activeSessionId,
|
||||
planVersion: vibe.planVersion,
|
||||
now: currentItem,
|
||||
preview: [],
|
||||
state: {},
|
||||
replanned: false,
|
||||
replanReason: null,
|
||||
});
|
||||
const served = await resolvePlayableResponse(vibe.activeSessionId, advanced);
|
||||
if (!served || served.response.sessionId !== vibe.activeSessionId || !isCurrentVibeOwner(vibe.activeSessionId)) {
|
||||
replaceUnplayedQueue([]);
|
||||
playback.pause();
|
||||
return;
|
||||
}
|
||||
if (!useVibeStore.getState().setPlan(served.response.planVersion, served.preview)) return;
|
||||
useVibeStore.getState().setProfile(served.response.state);
|
||||
useVibeStore.getState().setCurrentPlanItem(served.response.now);
|
||||
replaceUnplayedQueue([served.now, ...served.preview]);
|
||||
playback.advance();
|
||||
} catch (error) {
|
||||
replaceUnplayedQueue([]);
|
||||
if (isSessionTerminalError(error)) deactivateBrokenSession();
|
||||
else playback.pause();
|
||||
throw error;
|
||||
}
|
||||
}).finally(() => { advanceInFlight = null; });
|
||||
return advanceInFlight;
|
||||
}
|
||||
|
||||
function installVibeAdvanceHandler(): void {
|
||||
usePlaybackStore.getState().setVibeAdvanceHandler((reason) => {
|
||||
void advanceVibe(reason).catch(() => undefined);
|
||||
});
|
||||
}
|
||||
|
||||
/** Start, version-serve and hydrate the first durable Vibe track. */
|
||||
export async function startVibeSession(seed: Track): Promise<StartedVibeSession> {
|
||||
if (startInFlight) return startInFlight;
|
||||
startInFlight = serializeMaterial<StartedVibeSession>(async () => {
|
||||
const started = await vibeService.start(seed.id, localCalendarContext());
|
||||
if (!started.planVersion) return { status: 'exhausted', tracks: [] };
|
||||
const served = await serveNextPlayable(started.sessionId, started.planVersion);
|
||||
if (!served) return { status: 'exhausted', tracks: [] };
|
||||
|
||||
const vibe = useVibeStore.getState();
|
||||
// A newly started session has its own revision sequence. Drop the old
|
||||
// local revision before admitting revision 1 from this new session.
|
||||
vibe.reset();
|
||||
vibe.setInitialBatchStatus('loading');
|
||||
vibe.setActiveSession({ sessionId: started.sessionId, seedTrackId: seed.id });
|
||||
vibe.setCenterTrack(seed);
|
||||
vibe.setPlan(served.response.planVersion, served.preview);
|
||||
vibe.setProfile(served.response.state);
|
||||
vibe.setCurrentPlanItem(served.response.now);
|
||||
vibe.setInitialBatchStatus('idle');
|
||||
|
||||
const playback = usePlaybackStore.getState();
|
||||
const seedFirst = isPlayable(seed) && seed.id !== served.now.id;
|
||||
seedPendingTrackId = seedFirst ? seed.id : null;
|
||||
const queue = dedupeSongs(
|
||||
seedFirst ? [seed, served.now, ...served.preview] : [served.now, ...served.preview]
|
||||
);
|
||||
playback.setVibeQueue(queue);
|
||||
playback.playTrack(queue[0]);
|
||||
installVibeAdvanceHandler();
|
||||
return { status: 'complete', tracks: queue };
|
||||
});
|
||||
try {
|
||||
return await startInFlight;
|
||||
} finally {
|
||||
startInFlight = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Take over a session that is already running, on the device that has just been
|
||||
* given the audio.
|
||||
*
|
||||
* A Vibe has two halves: the durable plan on the server, and the controller here
|
||||
* that reports outcomes and asks for the next track. Only one device may hold
|
||||
* the controller, and the right one is whichever holds the audio — otherwise the
|
||||
* device that started the session keeps replanning for a player it cannot hear,
|
||||
* or, as it did before this existed, nobody replans at all and the Vibe quietly
|
||||
* becomes a fixed list of whatever tracks were synced.
|
||||
*
|
||||
* The queue and playing track are already in place from the playback snapshot;
|
||||
* this restores the session around them.
|
||||
*/
|
||||
export async function adoptVibeSession(sessionId: string): Promise<boolean> {
|
||||
return serializeMaterial(async () => {
|
||||
const playback = usePlaybackStore.getState();
|
||||
const alreadyDriving = useVibeStore.getState().activeSessionId === sessionId
|
||||
&& playback.queueOwner === 'vibe'
|
||||
&& playback.vibeAdvanceHandler !== null;
|
||||
if (alreadyDriving) return true;
|
||||
|
||||
let response: DurableVibeSessionResponse;
|
||||
try {
|
||||
response = await vibeService.resume(sessionId);
|
||||
} catch (error) {
|
||||
// Ended, replaced or simply gone: there is nothing to drive, and the
|
||||
// ordinary queue this device received is the honest thing to keep playing.
|
||||
if (isSessionTerminalError(error)) return false;
|
||||
throw error;
|
||||
}
|
||||
if (!response.planVersion) return false;
|
||||
|
||||
const preview = await hydratePreview(response.preview);
|
||||
const vibe = useVibeStore.getState();
|
||||
// A different session, or a stale local revision of this one: drop it before
|
||||
// admitting the revision the server just reported.
|
||||
if (vibe.activeSessionId !== sessionId) vibe.reset();
|
||||
vibe.setActiveSession({ sessionId, seedTrackId: response.session?.seed_track_id ?? null });
|
||||
vibe.setPlan(response.planVersion, preview);
|
||||
vibe.setProfile(response.state);
|
||||
// The cursor for the track already playing was committed in a revision that
|
||||
// a later replan superseded, so it is not in this response and cannot be
|
||||
// reconstructed. The first advance sets it; until then a stream that fails
|
||||
// is reported as an ordinary skip.
|
||||
vibe.setCurrentPlanItem(null);
|
||||
|
||||
// Ownership first: replaceUnplayedQueue and every session guard read it.
|
||||
usePlaybackStore.getState().setVibeQueue(usePlaybackStore.getState().queue);
|
||||
installVibeAdvanceHandler();
|
||||
replaceUnplayedQueue(preview);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Give up driving the session without ending it, because the audio has moved to
|
||||
* another device. The session stays on record here so the local Vibe view still
|
||||
* has something to show, and taking the audio back adopts it again.
|
||||
*/
|
||||
export function releaseVibeDriving(): void {
|
||||
usePlaybackStore.getState().setVibeAdvanceHandler(null);
|
||||
}
|
||||
|
||||
export async function endVibeSession(): Promise<void> {
|
||||
return serializeMaterial(async () => {
|
||||
const sessionId = useVibeStore.getState().activeSessionId;
|
||||
try {
|
||||
if (sessionId) await vibeService.end(sessionId);
|
||||
} finally {
|
||||
seedPendingTrackId = null;
|
||||
const playback = usePlaybackStore.getState();
|
||||
playback.setVibeAdvanceHandler(null);
|
||||
useVibeStore.getState().reset();
|
||||
playback.pause();
|
||||
playback.setQueue([]);
|
||||
playback.setCurrentTrack(null);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1,7 +1,18 @@
|
||||
import { create } from 'zustand';
|
||||
import type { Track } from '../types';
|
||||
import {
|
||||
clampCrossfadeMs,
|
||||
readStoredCrossfadeMs,
|
||||
readStoredLastTrack,
|
||||
readStoredPrefetchNext,
|
||||
storeCrossfadeMs,
|
||||
storeLastTrack,
|
||||
storePrefetchNext,
|
||||
} from '../lib/playbackPrefs';
|
||||
|
||||
export type RepeatMode = 'none' | 'all' | 'one';
|
||||
export type VibeAdvanceReason = 'skipped' | 'completed' | 'disliked';
|
||||
export type PlaybackOwner = 'ordinary' | 'vibe';
|
||||
|
||||
/**
|
||||
* How many already-played tracks to keep behind the cursor. Bounds queue growth
|
||||
@@ -20,19 +31,42 @@ interface PlaybackState {
|
||||
volume: number;
|
||||
shuffle: boolean;
|
||||
repeat: RepeatMode;
|
||||
/** Warm the next track's stream into the idle audio element before this one ends. */
|
||||
prefetchNext: boolean;
|
||||
/** Overlap between tracks, in milliseconds. 0 disables the fade. */
|
||||
crossfadeMs: number;
|
||||
/** Ids already played this shuffle "lap" (repeat-all), to avoid bouncing between the same few tracks. */
|
||||
shufflePlayed: Set<string>;
|
||||
/** Installed only while a durable Vibe session owns the queue. */
|
||||
vibeAdvanceHandler: ((reason: VibeAdvanceReason) => void) | null;
|
||||
/** Vibe must opt in explicitly; ordinary browsing always owns itself. */
|
||||
queueOwner: PlaybackOwner;
|
||||
/**
|
||||
* True while another device holds the audio and this one is only showing what
|
||||
* it plays. The engine loads no stream in that state, so a phone watching the
|
||||
* desktop stops pulling megabytes of audio it will never play.
|
||||
*/
|
||||
audioElsewhere: boolean;
|
||||
|
||||
setQueue: (queue: Track[]) => void;
|
||||
/** Vibe-only queue replacement. Do not use for library browsing. */
|
||||
setVibeQueue: (queue: Track[]) => void;
|
||||
playTrack: (track: Track) => void;
|
||||
play: () => void;
|
||||
pause: () => void;
|
||||
next: () => void;
|
||||
nextWithReason: (reason: VibeAdvanceReason) => void;
|
||||
/** Bypass the Vibe controller after it has prepared the next committed track. */
|
||||
advance: () => void;
|
||||
setVibeAdvanceHandler: (handler: ((reason: VibeAdvanceReason) => void) | null) => void;
|
||||
prev: () => void;
|
||||
setPosition: (position: number) => void;
|
||||
setDuration: (duration: number) => void;
|
||||
setVolume: (volume: number) => void;
|
||||
setPrefetchNext: (prefetchNext: boolean) => void;
|
||||
setCrossfadeMs: (crossfadeMs: number) => void;
|
||||
setCurrentTrack: (track: Track | null) => void;
|
||||
setAudioElsewhere: (audioElsewhere: boolean) => void;
|
||||
toggleShuffle: () => void;
|
||||
cycleRepeat: () => void;
|
||||
}
|
||||
@@ -60,17 +94,26 @@ function advanceTo(queue: Track[], index: number) {
|
||||
};
|
||||
}
|
||||
|
||||
// A fresh tab opens on the last track it played, paused at zero — an empty
|
||||
// player bar told the listener nothing about where they were.
|
||||
const restoredTrack = readStoredLastTrack<Track>();
|
||||
|
||||
export const usePlaybackStore = create<PlaybackState>((set, get) => ({
|
||||
currentTrack: null,
|
||||
queue: [],
|
||||
currentIndex: -1,
|
||||
currentTrack: restoredTrack,
|
||||
queue: restoredTrack ? [restoredTrack] : [],
|
||||
currentIndex: restoredTrack ? 0 : -1,
|
||||
isPlaying: false,
|
||||
position: 0,
|
||||
duration: 0,
|
||||
duration: restoredTrack?.duration ?? 0,
|
||||
volume: 1,
|
||||
shuffle: false,
|
||||
repeat: 'none',
|
||||
prefetchNext: readStoredPrefetchNext(),
|
||||
crossfadeMs: readStoredCrossfadeMs(),
|
||||
shufflePlayed: new Set<string>(),
|
||||
vibeAdvanceHandler: null,
|
||||
queueOwner: 'ordinary',
|
||||
audioElsewhere: false,
|
||||
|
||||
setQueue: (queue) =>
|
||||
set((state) => ({
|
||||
@@ -78,6 +121,18 @@ export const usePlaybackStore = create<PlaybackState>((set, get) => ({
|
||||
// Keep the cursor pointing at whatever is playing, if it is still queued.
|
||||
currentIndex: state.currentTrack ? queue.findIndex((t) => t.id === state.currentTrack!.id) : -1,
|
||||
shufflePlayed: new Set(),
|
||||
// Every ordinary queue operation is an explicit ownership handoff. This
|
||||
// prevents a stale Vibe session from intercepting browser/UI next.
|
||||
queueOwner: 'ordinary',
|
||||
vibeAdvanceHandler: null,
|
||||
})),
|
||||
|
||||
setVibeQueue: (queue) =>
|
||||
set((state) => ({
|
||||
queue,
|
||||
currentIndex: state.currentTrack ? queue.findIndex((t) => t.id === state.currentTrack!.id) : -1,
|
||||
shufflePlayed: new Set(),
|
||||
queueOwner: 'vibe',
|
||||
})),
|
||||
|
||||
playTrack: (track) =>
|
||||
@@ -94,6 +149,19 @@ export const usePlaybackStore = create<PlaybackState>((set, get) => ({
|
||||
pause: () => set({ isPlaying: false }),
|
||||
|
||||
next: () => {
|
||||
get().nextWithReason('skipped');
|
||||
},
|
||||
|
||||
nextWithReason: (reason) => {
|
||||
const { vibeAdvanceHandler: handler, queueOwner } = get();
|
||||
if (queueOwner === 'vibe' && handler) {
|
||||
handler(reason);
|
||||
return;
|
||||
}
|
||||
get().advance();
|
||||
},
|
||||
|
||||
advance: () => {
|
||||
const { queue, currentTrack, shuffle, repeat, shufflePlayed } = get();
|
||||
if (queue.length === 0) {
|
||||
set({ isPlaying: false, position: 0 });
|
||||
@@ -169,6 +237,11 @@ export const usePlaybackStore = create<PlaybackState>((set, get) => ({
|
||||
}
|
||||
},
|
||||
|
||||
setVibeAdvanceHandler: (vibeAdvanceHandler) => set((state) => ({
|
||||
vibeAdvanceHandler,
|
||||
queueOwner: vibeAdvanceHandler ? 'vibe' : state.queueOwner,
|
||||
})),
|
||||
|
||||
prev: () => {
|
||||
const { queue, currentTrack, currentIndex } = get();
|
||||
if (queue.length === 0) return;
|
||||
@@ -193,12 +266,23 @@ export const usePlaybackStore = create<PlaybackState>((set, get) => ({
|
||||
setPosition: (position) => set({ position }),
|
||||
setDuration: (duration) => set({ duration }),
|
||||
setVolume: (volume) => set({ volume }),
|
||||
setPrefetchNext: (prefetchNext) => {
|
||||
storePrefetchNext(prefetchNext);
|
||||
set({ prefetchNext });
|
||||
},
|
||||
setCrossfadeMs: (value) => {
|
||||
const crossfadeMs = clampCrossfadeMs(value);
|
||||
storeCrossfadeMs(crossfadeMs);
|
||||
set({ crossfadeMs });
|
||||
},
|
||||
setCurrentTrack: (currentTrack) =>
|
||||
set((state) => ({
|
||||
currentTrack,
|
||||
currentIndex: currentTrack ? state.queue.findIndex((t) => t.id === currentTrack.id) : -1,
|
||||
})),
|
||||
|
||||
setAudioElsewhere: (audioElsewhere) => set({ audioElsewhere }),
|
||||
|
||||
toggleShuffle: () => set((state) => ({ shuffle: !state.shuffle })),
|
||||
cycleRepeat: () =>
|
||||
set((state) => {
|
||||
@@ -207,3 +291,13 @@ export const usePlaybackStore = create<PlaybackState>((set, get) => ({
|
||||
return { repeat: next };
|
||||
}),
|
||||
}));
|
||||
|
||||
// One subscription instead of a write in playTrack, advance, prev and the
|
||||
// shuffle pick — every path that changes the track goes through here.
|
||||
let lastPersistedTrackId = restoredTrack?.id ?? null;
|
||||
usePlaybackStore.subscribe((state) => {
|
||||
const id = state.currentTrack?.id ?? null;
|
||||
if (id === lastPersistedTrackId) return;
|
||||
lastPersistedTrackId = id;
|
||||
storeLastTrack(state.currentTrack);
|
||||
});
|
||||
|
||||
@@ -1,53 +1,61 @@
|
||||
import { create } from 'zustand';
|
||||
import type { Track, VibeSession } from '../types';
|
||||
import type { VibePlanItem } from '../services/vibeService';
|
||||
import type { VibeProfile } from '../components/VibeAura';
|
||||
|
||||
// V2 recommendation session state. The backend stores the plan in Redis
|
||||
// (keyed by sessionId) and serves tracks one at a time via GET /v2/vibe/next.
|
||||
// We keep a lookahead buffer of upcoming Track[] to feed playback.
|
||||
// The durable plan is authoritative. `buffer` is only its currently
|
||||
// uncommitted, hydrated preview; it may be replaced at any feedback boundary.
|
||||
interface VibeState {
|
||||
activeSessionId: string | null;
|
||||
seedTrackId: string | null;
|
||||
planVersion: number | null;
|
||||
/** Durable cursor for the track currently in Vibe playback. */
|
||||
currentPlanItem: VibePlanItem | null;
|
||||
profile: VibeProfile;
|
||||
centerTrack: Track | null;
|
||||
buffer: Track[]; // lookahead buffer of upcoming recommended tracks
|
||||
buffer: Track[];
|
||||
initialBatchStatus: 'idle' | 'loading' | 'exhausted' | 'failed';
|
||||
|
||||
setActiveSession: (session: VibeSession | null) => void;
|
||||
setSeedTrackId: (seedTrackId: string | null) => void;
|
||||
setCenterTrack: (track: Track | null) => void;
|
||||
setBuffer: (buffer: Track[]) => void;
|
||||
appendBuffer: (tracks: Track[]) => void;
|
||||
shiftBuffer: () => Track | undefined;
|
||||
/** Returns false when a response belongs to an older plan revision. */
|
||||
setPlan: (planVersion: number | null, preview: Track[]) => boolean;
|
||||
setCurrentPlanItem: (item: VibePlanItem | null) => void;
|
||||
setProfile: (profile: VibeProfile) => void;
|
||||
setInitialBatchStatus: (status: VibeState['initialBatchStatus']) => void;
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
const initialState = {
|
||||
activeSessionId: null as string | null,
|
||||
seedTrackId: null as string | null,
|
||||
planVersion: null as number | null,
|
||||
currentPlanItem: null as VibePlanItem | null,
|
||||
profile: {} as VibeProfile,
|
||||
centerTrack: null as Track | null,
|
||||
buffer: [] as Track[],
|
||||
initialBatchStatus: 'idle' as const,
|
||||
};
|
||||
|
||||
export const useVibeStore = create<VibeState>((set, get) => ({
|
||||
...initialState,
|
||||
|
||||
setActiveSession: (session) =>
|
||||
set(
|
||||
session
|
||||
? { activeSessionId: session.sessionId, seedTrackId: session.seedTrackId }
|
||||
: { activeSessionId: null, seedTrackId: null }
|
||||
),
|
||||
|
||||
setActiveSession: (session) => set(
|
||||
session
|
||||
? { activeSessionId: session.sessionId, seedTrackId: session.seedTrackId }
|
||||
: { activeSessionId: null, seedTrackId: null },
|
||||
),
|
||||
setSeedTrackId: (seedTrackId) => set({ seedTrackId }),
|
||||
setCenterTrack: (centerTrack) => set({ centerTrack }),
|
||||
setBuffer: (buffer) => set({ buffer }),
|
||||
appendBuffer: (tracks) => set((state) => ({ buffer: [...state.buffer, ...tracks] })),
|
||||
|
||||
shiftBuffer: () => {
|
||||
const { buffer } = get();
|
||||
if (buffer.length === 0) return undefined;
|
||||
const [head, ...rest] = buffer;
|
||||
set({ buffer: rest });
|
||||
return head;
|
||||
setPlan: (planVersion, buffer) => {
|
||||
const current = get().planVersion;
|
||||
if (planVersion === null || (current !== null && planVersion < current)) return false;
|
||||
set({ planVersion, buffer });
|
||||
return true;
|
||||
},
|
||||
|
||||
setCurrentPlanItem: (currentPlanItem) => set({ currentPlanItem }),
|
||||
setProfile: (profile) => set({ profile }),
|
||||
setInitialBatchStatus: (initialBatchStatus) => set({ initialBatchStatus }),
|
||||
reset: () => set({ ...initialState }),
|
||||
}));
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import '@testing-library/jest-dom/vitest';
|
||||
import { cleanup } from '@testing-library/react';
|
||||
import { afterEach } from 'vitest';
|
||||
|
||||
afterEach(() => cleanup());
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user