Compare commits
28 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 |
@@ -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.
|
||||
|
||||
@@ -18,6 +18,13 @@ 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;
|
||||
@@ -91,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();
|
||||
@@ -166,6 +211,7 @@ export async function buildApp(config: AppConfig) {
|
||||
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.
|
||||
@@ -176,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);
|
||||
}
|
||||
|
||||
@@ -759,4 +759,148 @@ export const MIGRATIONS: Migration[] = [
|
||||
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');
|
||||
`,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -302,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
|
||||
);
|
||||
|
||||
|
||||
@@ -35,6 +35,54 @@ 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
|
||||
*/
|
||||
|
||||
@@ -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.
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -68,6 +68,26 @@ describe('durable Vibe session routes', () => {
|
||||
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({
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
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;
|
||||
@@ -40,6 +41,17 @@ 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 });
|
||||
}
|
||||
@@ -50,13 +62,19 @@ function sessionIdFrom(request: FastifyRequest, reply: Reply): string | null {
|
||||
}
|
||||
|
||||
function parseStart(body: unknown):
|
||||
| { seedTrackId?: string; resumeSessionId?: string }
|
||||
| { 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' };
|
||||
return { seedTrackId: input.seedTrackId as string | undefined, resumeSessionId: input.resumeSessionId as string | undefined };
|
||||
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):
|
||||
|
||||
@@ -30,6 +30,7 @@ describe('DbService v2 methods', () => {
|
||||
.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
|
||||
@@ -51,11 +52,13 @@ describe('DbService v2 methods', () => {
|
||||
|
||||
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('EXISTS (SELECT 1 FROM play_history');
|
||||
expect(clientQuery.mock.calls[4][0]).toContain('INSERT INTO evidence');
|
||||
expect(clientQuery.mock.calls[8][0]).toContain('exploration_coefficient');
|
||||
expect(clientQuery.mock.calls[8][0]).toContain('ELSE goals END');
|
||||
expect(clientQuery.mock.calls[9][1][4]).toBe(JSON.stringify({ type: 'familiar', target: 1, progress: 1 }));
|
||||
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);
|
||||
});
|
||||
|
||||
@@ -437,6 +440,7 @@ describe('DbService v2 methods', () => {
|
||||
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'");
|
||||
});
|
||||
|
||||
@@ -602,6 +606,32 @@ describe('DbService v2 methods', () => {
|
||||
});
|
||||
});
|
||||
|
||||
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();
|
||||
|
||||
@@ -10,6 +10,19 @@ import { SearchService } from './search.service.js';
|
||||
/** Anything with a `.query()` — either the shared Pool or a checked-out client. */
|
||||
type Queryable = Pool | PoolClient;
|
||||
|
||||
function calendarContextKey(context: Record<string, unknown> | null | undefined): string | null {
|
||||
const hour = context?.localHour;
|
||||
const weekday = context?.weekday;
|
||||
const month = context?.month;
|
||||
if (typeof hour !== 'number' || typeof weekday !== 'number' || typeof month !== 'number'
|
||||
|| !Number.isInteger(hour) || !Number.isInteger(weekday) || !Number.isInteger(month)
|
||||
|| hour < 0 || hour > 23 || weekday < 0 || weekday > 6 || month < 1 || month > 12) return null;
|
||||
const daypart = hour < 6 ? 'night' : hour < 12 ? 'morning' : hour < 18 ? 'day' : 'evening';
|
||||
const dayType = weekday === 0 || weekday === 6 ? 'weekend' : 'weekday';
|
||||
const season = month === 12 || month <= 2 ? 'winter' : month <= 5 ? 'spring' : month <= 8 ? 'summer' : 'autumn';
|
||||
return `calendar:${daypart}:${dayType}:${season}`;
|
||||
}
|
||||
|
||||
type VibePlanVersionRow = Omit<VibePlan, 'items'> & {
|
||||
item_plan_version_id: string | null;
|
||||
ordinal: number | null;
|
||||
@@ -56,6 +69,22 @@ import type {
|
||||
import { AUDIO_PREFERENCE_BUCKETS, FEEDBACK_ACTIONS } from '../db/types.js';
|
||||
export * from '../db/types.js';
|
||||
|
||||
/**
|
||||
* Every play_history write goes through this statement.
|
||||
*
|
||||
* The title/artist copy is taken in the same round trip via LEFT JOIN, so a
|
||||
* play stays readable after its track is hard-deleted, and the join never
|
||||
* suppresses the insert when the track id is unknown.
|
||||
*/
|
||||
const PLAY_HISTORY_INSERT = `
|
||||
INSERT INTO play_history (
|
||||
user_id, track_id, batch_id, completed, listened_ms, track_title, track_artist
|
||||
)
|
||||
SELECT $1::uuid, $2::uuid, $3::uuid, $4::boolean, $5::int, t.title, t.artist
|
||||
FROM (SELECT 1) AS always
|
||||
LEFT JOIN tracks t ON t.id = $2::uuid
|
||||
RETURNING id`;
|
||||
|
||||
export class DbService {
|
||||
/** Exposed so route handlers (e.g. settings) can query the database directly. */
|
||||
readonly pgClient: Pool;
|
||||
@@ -182,6 +211,33 @@ export class DbService {
|
||||
return this.attachArtists(res.rows as Track[]);
|
||||
}
|
||||
|
||||
/**
|
||||
* A random handful of playable tracks, with the size of the pool they came
|
||||
* from. The Vibe start screen offers seeds; it used to read the whole library
|
||||
* to pick fifty of them, which on a phone is megabytes of JSON to draw one
|
||||
* list. Sampling in the database keeps "Surprise me" uniform over everything
|
||||
* playable while sending only what is shown.
|
||||
*/
|
||||
async getSeedTracks(limit = 50): Promise<{ total: number; tracks: Track[] }> {
|
||||
const playable = `t.state NOT IN ('HIDDEN', 'DELETED')`;
|
||||
const [sample, total] = await Promise.all([
|
||||
this.pgClient.query(
|
||||
`SELECT t.*, al.artwork_id
|
||||
FROM tracks t
|
||||
LEFT JOIN albums al ON al.id = t.album_id
|
||||
WHERE ${playable}
|
||||
ORDER BY RANDOM()
|
||||
LIMIT $1`,
|
||||
[Math.min(Math.max(1, limit), 200)]
|
||||
),
|
||||
this.pgClient.query(`SELECT COUNT(*)::int AS total FROM tracks t WHERE ${playable}`),
|
||||
]);
|
||||
return {
|
||||
total: total.rows[0]?.total ?? 0,
|
||||
tracks: await this.attachArtists(sample.rows as Track[]),
|
||||
};
|
||||
}
|
||||
|
||||
/** Attach artists array (from track_artists join) to a list of tracks. */
|
||||
private async attachArtists(tracks: Track[]): Promise<Track[]> {
|
||||
if (tracks.length === 0) return tracks;
|
||||
@@ -305,6 +361,21 @@ export class DbService {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Library totals for the Home header. Counts only what library views show,
|
||||
* so the number on screen matches what a user can actually browse.
|
||||
*/
|
||||
async getLibraryStats(): Promise<{ tracks: number; albums: number; artists: number; duration: number }> {
|
||||
const res = await this.pgClient.query(
|
||||
`SELECT
|
||||
(SELECT COUNT(*)::int FROM tracks WHERE state NOT IN ('HIDDEN','DELETED')) AS tracks,
|
||||
(SELECT COUNT(*)::int FROM albums) AS albums,
|
||||
(SELECT COUNT(*)::int FROM artists) AS artists,
|
||||
(SELECT COALESCE(SUM(duration),0)::int FROM tracks WHERE state NOT IN ('HIDDEN','DELETED')) AS duration`
|
||||
);
|
||||
return res.rows[0];
|
||||
}
|
||||
|
||||
async getGenres(): Promise<Genre[]> {
|
||||
const res = await this.pgClient.query(
|
||||
`SELECT g.id, g.name, g.parent_id, COUNT(tg.track_id)::int AS track_count
|
||||
@@ -372,9 +443,14 @@ export class DbService {
|
||||
*/
|
||||
async dislikeTrack(userId: string, trackId: string): Promise<void> {
|
||||
await this.withTransaction(async (client) => {
|
||||
// Phase 1: hide the track in all active views
|
||||
// Phase 1: hide the track in all active views. A probation recommendation
|
||||
// is disliked the same way a library track is, and retires on the spot —
|
||||
// the listener has answered the question probation exists to ask.
|
||||
await client.query(
|
||||
"UPDATE tracks SET state = 'HIDDEN' WHERE id = $1 AND state = 'LIBRARY'",
|
||||
`UPDATE tracks
|
||||
SET state = 'HIDDEN',
|
||||
probation_status = CASE WHEN state = 'RECOMMENDED' THEN 'retired' ELSE probation_status END
|
||||
WHERE id = $1 AND state IN ('LIBRARY', 'RECOMMENDED')`,
|
||||
[trackId]
|
||||
);
|
||||
|
||||
@@ -455,11 +531,17 @@ export class DbService {
|
||||
// (permanentlyDeleteTrack, below) and exactly one unlink site in the whole
|
||||
// system (workers/src/cleanup.service.ts).
|
||||
|
||||
async recordPlay(userId: string, trackId: string, completed: boolean, batchId?: string): Promise<string> {
|
||||
async recordPlay(
|
||||
userId: string,
|
||||
trackId: string,
|
||||
completed: boolean,
|
||||
batchId?: string,
|
||||
listenedMs?: number,
|
||||
): Promise<string> {
|
||||
if (!completed) {
|
||||
const res = await this.pgClient.query(
|
||||
'INSERT INTO play_history (user_id, track_id, batch_id, completed) VALUES ($1, $2, $3, $4) RETURNING id',
|
||||
[userId, trackId, batchId ?? null, false]
|
||||
PLAY_HISTORY_INSERT,
|
||||
[userId, trackId, batchId ?? null, false, listenedMs ?? null]
|
||||
);
|
||||
return res.rows[0].id as string;
|
||||
}
|
||||
@@ -469,8 +551,8 @@ export class DbService {
|
||||
return this.withTransaction(async (client) => {
|
||||
// 1. Record play history
|
||||
const insertRes = await client.query(
|
||||
'INSERT INTO play_history (user_id, track_id, batch_id, completed) VALUES ($1, $2, $3, $4) RETURNING id',
|
||||
[userId, trackId, batchId ?? null, true]
|
||||
PLAY_HISTORY_INSERT,
|
||||
[userId, trackId, batchId ?? null, true, listenedMs ?? null]
|
||||
);
|
||||
const historyId = insertRes.rows[0].id as string;
|
||||
|
||||
@@ -1210,6 +1292,7 @@ export class DbService {
|
||||
signal: string;
|
||||
profile: string;
|
||||
weight: number;
|
||||
dimension?: string;
|
||||
context?: Record<string, unknown>;
|
||||
}, client?: Queryable): Promise<string> {
|
||||
const { track_id: trackId, ...event } = evidence;
|
||||
@@ -1227,6 +1310,7 @@ export class DbService {
|
||||
signal: event.signal,
|
||||
profile: event.profile,
|
||||
weight: event.weight * target.factor,
|
||||
dimension: event.dimension,
|
||||
context: { ...event.context, ...target.context },
|
||||
}, client);
|
||||
}
|
||||
@@ -1243,6 +1327,7 @@ export class DbService {
|
||||
signal: string;
|
||||
profile: string;
|
||||
weight: number;
|
||||
dimension?: string;
|
||||
context?: unknown;
|
||||
}, client?: Queryable): Promise<string> {
|
||||
const res = await (client ?? this.pgClient).query(
|
||||
@@ -1265,7 +1350,7 @@ export class DbService {
|
||||
// which feeds 'novelty_tolerance'. Each new evidence row must also
|
||||
// upsert the matching listener_belief (spec §B.4) — otherwise evidence
|
||||
// accumulates but beliefs never materialise.
|
||||
const dimension = this.beliefDimensionForSignal(evidence.signal);
|
||||
const dimension = evidence.dimension ?? this.beliefDimensionForSignal(evidence.signal);
|
||||
await this.updateListenerBelief({
|
||||
user_id: evidence.user_id,
|
||||
profile: evidence.profile,
|
||||
@@ -1514,6 +1599,7 @@ export class DbService {
|
||||
userId: string;
|
||||
policyVersion: string;
|
||||
seedTrackId?: string | null;
|
||||
context?: Record<string, unknown>;
|
||||
profile?: {
|
||||
goals: Record<string, unknown>;
|
||||
explorationCoefficient: number;
|
||||
@@ -1561,7 +1647,7 @@ export class DbService {
|
||||
[
|
||||
params.userId,
|
||||
params.seedTrackId ?? null,
|
||||
'{}',
|
||||
JSON.stringify(params.context ? { ...params.context } : {}),
|
||||
params.policyVersion,
|
||||
JSON.stringify(params.profile?.goals ?? { type: 'discovery', target: 1, progress: 0 }),
|
||||
params.profile?.explorationCoefficient ?? 0.3,
|
||||
@@ -1635,6 +1721,25 @@ export class DbService {
|
||||
);
|
||||
if (!marker.rows[0]) return;
|
||||
|
||||
const sessionContext = await client.query(
|
||||
'SELECT context FROM vibe_sessions WHERE id = $1 AND user_id = $2',
|
||||
[event.session_id, event.user_id],
|
||||
);
|
||||
const context = (sessionContext.rows[0]?.context ?? {}) as Record<string, unknown>;
|
||||
const contextDimension = calendarContextKey(context);
|
||||
if (contextDimension) {
|
||||
const contextWeight = event.type === 'skipped' ? -0.16 : event.type === 'completed' ? 0.08 : 0.04;
|
||||
await this.recordTrackEvidence({
|
||||
user_id: event.user_id,
|
||||
track_id: trackId,
|
||||
signal: event.type === 'skipped' ? 'skip_quick' : event.type === 'kept' ? 'kept' : 'playback_completed',
|
||||
profile: 'contextual',
|
||||
weight: contextWeight,
|
||||
dimension: contextDimension,
|
||||
context: { vibe_event_id: event.id, session_id: event.session_id, calendar: context },
|
||||
}, client);
|
||||
}
|
||||
|
||||
// This query occurs before a completed event's play_history projection
|
||||
// can be considered. Favourites and prior evidence count as familiarity
|
||||
// too, avoiding a false “new discovery” on a locally known track.
|
||||
@@ -1664,12 +1769,12 @@ export class DbService {
|
||||
|
||||
const profile = await client.query(
|
||||
`UPDATE vibe_session_profiles
|
||||
SET exploration_coefficient = GREATEST(0, LEAST(1, exploration_coefficient + CASE WHEN $4 THEN 0 ELSE $3 END)),
|
||||
discovery_radius = GREATEST(0.15, LEAST(0.9, 0.2 + (GREATEST(0, LEAST(1, exploration_coefficient + CASE WHEN $4 THEN 0 ELSE $3 END)) * 0.65))),
|
||||
SET exploration_coefficient = GREATEST(0, LEAST(1, exploration_coefficient + CASE WHEN $4 THEN 0::real ELSE $3::real END)),
|
||||
discovery_radius = GREATEST(0.15, LEAST(0.9, 0.2 + (GREATEST(0, LEAST(1, exploration_coefficient + CASE WHEN $4 THEN 0::real ELSE $3::real END)) * 0.65))),
|
||||
goals = CASE
|
||||
WHEN goals->>'type' = 'familiar' AND $4 AND $5
|
||||
THEN jsonb_set(goals, '{progress}', to_jsonb(LEAST(COALESCE((goals->>'progress')::int, 0) + 1, COALESCE((goals->>'target')::int, 1))))
|
||||
WHEN goals->>'type' IN ('discovery', 'surprise', 'artist_introduction') AND NOT $4 AND $3 > 0
|
||||
WHEN goals->>'type' IN ('discovery', 'surprise', 'artist_introduction') AND NOT $4 AND $3::real > 0
|
||||
THEN jsonb_set(goals, '{progress}', to_jsonb(LEAST(COALESCE((goals->>'progress')::int, 0) + 1, COALESCE((goals->>'target')::int, 1))))
|
||||
ELSE goals END,
|
||||
updated_at = NOW()
|
||||
@@ -1885,10 +1990,17 @@ export class DbService {
|
||||
const occurredAt = event.occurred_at?.toISOString?.() ?? new Date().toISOString();
|
||||
switch (event.type) {
|
||||
case 'completed':
|
||||
// The Vibe player is the one caller that knows how much was actually
|
||||
// heard. position_ms is where playback reached; duration_ms is the
|
||||
// fallback for a client that only reported the track length.
|
||||
await client.query(
|
||||
`INSERT INTO play_history (user_id, track_id, completed, played_at)
|
||||
VALUES ($1, $2, true, $3::timestamptz)`,
|
||||
[event.user_id, event.track_id, occurredAt],
|
||||
`INSERT INTO play_history (
|
||||
user_id, track_id, completed, played_at, listened_ms, track_title, track_artist
|
||||
)
|
||||
SELECT $1::uuid, $2::uuid, true, $3::timestamptz, $4::int, t.title, t.artist
|
||||
FROM (SELECT 1) AS always
|
||||
LEFT JOIN tracks t ON t.id = $2::uuid`,
|
||||
[event.user_id, event.track_id, occurredAt, event.position_ms ?? event.duration_ms ?? null],
|
||||
);
|
||||
await client.query(
|
||||
`UPDATE tracks
|
||||
@@ -2203,12 +2315,12 @@ export class DbService {
|
||||
const item = await client.query(
|
||||
`WITH next_item AS (
|
||||
SELECT i.plan_version_id, i.ordinal FROM vibe_plan_items i
|
||||
WHERE i.plan_version_id = $2 AND NOT i.committed ORDER BY i.ordinal ASC LIMIT 1 FOR UPDATE
|
||||
WHERE i.plan_version_id = $1 AND NOT i.committed ORDER BY i.ordinal ASC LIMIT 1 FOR UPDATE
|
||||
)
|
||||
UPDATE vibe_plan_items i SET committed = true
|
||||
FROM next_item n WHERE i.plan_version_id = n.plan_version_id AND i.ordinal = n.ordinal
|
||||
RETURNING i.*`,
|
||||
[sessionId, plan.id]
|
||||
[plan.id]
|
||||
);
|
||||
const served = item.rows[0] as VibePlanItem | undefined;
|
||||
if (!served) return { item: null, stale: false };
|
||||
|
||||
@@ -148,17 +148,20 @@ export class DiscoveryService {
|
||||
const relevance = claimRes.rows[0]?.fused_value ?? 0;
|
||||
const candidateArtistId = claimRes.rows[0]?.object_id;
|
||||
|
||||
// Graph walks identify artists, not a legal/downloadable recording. A
|
||||
// resolver (or a human) must attach a vetted HTTPS source before the
|
||||
// worker can acquire anything. Do not guess a search query and download
|
||||
// an arbitrary track under an artist's name.
|
||||
// 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 resolvedUrl = (notes as { acquisition?: { url?: unknown } } | null)?.acquisition?.url;
|
||||
if (typeof resolvedUrl !== 'string' || resolvedUrl.trim() === '') {
|
||||
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 vetted acquisition source attached'
|
||||
last_error = 'no acquisition url or search query attached'
|
||||
WHERE id = $1`,
|
||||
[row.id]
|
||||
);
|
||||
|
||||
@@ -15,6 +15,27 @@ 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;
|
||||
@@ -47,7 +68,7 @@ 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;
|
||||
@@ -60,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
|
||||
@@ -75,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' OR (t.state = 'RECOMMENDED' AND t.probation_status = 'probation'))
|
||||
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]
|
||||
);
|
||||
|
||||
@@ -486,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',
|
||||
@@ -494,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]));
|
||||
|
||||
@@ -536,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)
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -548,4 +616,5 @@ export const ALL_GENERATORS: Generator[] = [
|
||||
noveltyGenerator,
|
||||
contextualGenerator,
|
||||
experimentalGenerator,
|
||||
libraryFallbackGenerator,
|
||||
];
|
||||
|
||||
@@ -66,6 +66,21 @@ describe('generators', () => {
|
||||
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 ] });
|
||||
@@ -168,6 +183,19 @@ describe('generators', () => {
|
||||
});
|
||||
});
|
||||
|
||||
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' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('experimental', () => {
|
||||
it('returns tracks from unfamiliar genres', async () => {
|
||||
const db = makeMockDb();
|
||||
@@ -182,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 () => {
|
||||
@@ -199,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'));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { DbService, ListenerBelief } from './db.service.js';
|
||||
import { Candidate, GeneratorContext, Generator, ALL_GENERATORS } from './generators.service.js';
|
||||
import { Candidate, GeneratorContext, Generator, ALL_GENERATORS, VibeCalendarContext } from './generators.service.js';
|
||||
import { AUDIO_PREFERENCE_BUCKETS } from '../db/types.js';
|
||||
|
||||
export interface FatigueState {
|
||||
@@ -11,6 +11,25 @@ export interface FatigueState {
|
||||
vocal: number;
|
||||
}
|
||||
|
||||
function parseCalendarContext(value: string | null): VibeCalendarContext | null {
|
||||
if (!value) return null;
|
||||
try {
|
||||
const context = JSON.parse(value) as Record<string, unknown>;
|
||||
if (!Number.isInteger(context.localHour) || !Number.isInteger(context.weekday) || !Number.isInteger(context.month)
|
||||
|| (context.localHour as number) < 0 || (context.localHour as number) > 23
|
||||
|| (context.weekday as number) < 0 || (context.weekday as number) > 6
|
||||
|| (context.month as number) < 1 || (context.month as number) > 12) return null;
|
||||
return {
|
||||
localHour: context.localHour as number,
|
||||
weekday: context.weekday as number,
|
||||
month: context.month as number,
|
||||
...(typeof context.timeZone === 'string' ? { timeZone: context.timeZone } : {}),
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export interface RecentPlay {
|
||||
trackId: string;
|
||||
artistId: string | null;
|
||||
@@ -39,6 +58,8 @@ export interface CandidateConstraintMetadata {
|
||||
instrumental?: boolean;
|
||||
favorite?: boolean;
|
||||
newArtist?: boolean;
|
||||
/** No play of this track has ever been recorded for this listener. */
|
||||
unheard?: boolean;
|
||||
energy?: number;
|
||||
bpm?: number;
|
||||
valence?: number;
|
||||
@@ -163,11 +184,35 @@ const W_FATIGUE = 0.4;
|
||||
const W_DIVERSITY = 0.3;
|
||||
const W_ENTROPY = 0.2;
|
||||
const W_REPETITION = 0.5;
|
||||
/** What a never-played track is worth against a well-attested favourite. */
|
||||
const W_UNHEARD = 0.35;
|
||||
const PLAN_SIZE = 20;
|
||||
const MAX_ARTIST_PER_PLAN = 2;
|
||||
const MAX_ALBUM_PER_40_TRACKS = 3;
|
||||
const ALBUM_HORIZON_TRACKS = 40;
|
||||
|
||||
/**
|
||||
* The share of a plan reserved for tracks the listener has never played. The
|
||||
* score bonus alone leaves this to chance; a floor is what makes every Vibe
|
||||
* move somewhere rather than most of them happening to.
|
||||
*/
|
||||
const UNHEARD_PLAN_SHARE = 0.25;
|
||||
|
||||
/**
|
||||
* How long a track stays out of the Vibe after the listener actually hears it.
|
||||
* Hard exclusion used to reach back only 40 completed plays, so a track heard
|
||||
* last night was a candidate again tonight. Counted from a durable `track_served`
|
||||
* event or a play, never from a plan item — most planned tracks are replaced
|
||||
* before anyone hears them, and excluding those would burn the library.
|
||||
*/
|
||||
const HEARD_COOLDOWN_DAYS = 14;
|
||||
|
||||
/**
|
||||
* A cooldown must never starve the pool. Whatever the window says, at most this
|
||||
* share of the eligible library is held back, keeping the newest exclusions.
|
||||
*/
|
||||
const COOLDOWN_LIBRARY_SHARE = 0.4;
|
||||
|
||||
/**
|
||||
* Producer/label relationships live on artist nodes in the fused graph, not
|
||||
* on track nodes. The graph represents both directions of a relationship,
|
||||
@@ -754,7 +799,7 @@ export class SessionDirector {
|
||||
energy: (row.state_vector?.energy as number) ?? 0.5,
|
||||
lastArtistIds: (row.state_vector?.lastArtistIds as string[]) ?? [],
|
||||
lastGenreIds: (row.state_vector?.lastGenreIds as string[]) ?? [],
|
||||
context: row.context,
|
||||
context: parseCalendarContext(row.context),
|
||||
noveltyHunger: (row.state_vector?.noveltyHunger as number) ?? 0.3,
|
||||
explorationCoefficient: (row.state_vector?.explorationCoefficient as number) ?? 0.3,
|
||||
discoveryRadius: (row.state_vector?.discoveryRadius as number) ?? 0.38,
|
||||
@@ -773,7 +818,7 @@ export class SessionDirector {
|
||||
energy: (latest.state_vector?.energy as number) ?? 0.5,
|
||||
lastArtistIds: (latest.state_vector?.lastArtistIds as string[]) ?? [],
|
||||
lastGenreIds: (latest.state_vector?.lastGenreIds as string[]) ?? [],
|
||||
context: latest.context,
|
||||
context: parseCalendarContext(latest.context),
|
||||
noveltyHunger: (latest.state_vector?.noveltyHunger as number) ?? 0.3,
|
||||
explorationCoefficient: (latest.state_vector?.explorationCoefficient as number) ?? 0.3,
|
||||
discoveryRadius: (latest.state_vector?.discoveryRadius as number) ?? 0.38,
|
||||
@@ -1091,6 +1136,9 @@ export class SessionDirector {
|
||||
if (goal.type === 'familiar') return 'comfort';
|
||||
if (goal.type === 'discovery' || goal.type === 'artist_introduction') return 'discovery';
|
||||
}
|
||||
// Calendar time is a gentle tie-breaker: it only selects the quiet arc
|
||||
// when no explicit listening goal has priority over it.
|
||||
if (state.context && state.context.localHour < 6) return 'late-night';
|
||||
if (state.energy < 0.3) return 'late-night';
|
||||
if (state.energy > 0.6 && state.noveltyHunger > 0.5) return 'discovery';
|
||||
if (state.energy > 0.6) return 'energetic';
|
||||
@@ -1494,6 +1542,10 @@ export class SessionDirector {
|
||||
artist.artist_id, genre.genre_id, tl.language,
|
||||
taf.instrumentalness,
|
||||
EXISTS(SELECT 1 FROM favorites f WHERE f.user_id = $1 AND f.track_id = t.id) AS favorite,
|
||||
NOT EXISTS(
|
||||
SELECT 1 FROM play_history heard
|
||||
WHERE heard.user_id = $1 AND heard.track_id = t.id
|
||||
) AS unheard,
|
||||
NOT EXISTS(
|
||||
SELECT 1 FROM play_history ph
|
||||
JOIN track_artists_v2 old_artist ON old_artist.track_id = ph.track_id
|
||||
@@ -1530,6 +1582,7 @@ export class SessionDirector {
|
||||
? (row.instrumentalness as number) >= 0.5 : undefined,
|
||||
favorite: row.favorite === true,
|
||||
newArtist: row.new_artist === true && row.artist_id != null,
|
||||
unheard: row.unheard === true,
|
||||
energy: (row.energy as number | null) ?? undefined,
|
||||
bpm: (row.bpm as number | null) ?? undefined,
|
||||
valence: (row.valence as number | null) ?? undefined,
|
||||
@@ -1605,10 +1658,18 @@ export class SessionDirector {
|
||||
const explorationFit = 1 - Math.abs(novelty - (state.discoveryRadius ?? 0.38));
|
||||
const sessionSimilarity = sessionSimilarityPenalty(item, c.generatorId, recentSessionFingerprints);
|
||||
|
||||
// A track the listener has never heard scores as if it carried real
|
||||
// relevance. Without this the only generator that samples the whole
|
||||
// library, library-fallback, entered at relevance 0.05 and lost every
|
||||
// slot to the same few well-attested favourites — which is how four
|
||||
// fifths of the library stayed unplayed.
|
||||
const unheardBonus = item?.unheard ? 1 : 0;
|
||||
|
||||
let score = W_ENJOY * c.relevance
|
||||
- W_FATIGUE * avgFatigue
|
||||
+ W_DIVERSITY * diversityBonus
|
||||
+ W_ENTROPY * entropyBonus
|
||||
+ W_UNHEARD * unheardBonus
|
||||
+ 0.08 * explorationFit
|
||||
- sessionSimilarity;
|
||||
|
||||
@@ -1705,6 +1766,79 @@ export class SessionDirector {
|
||||
// ---------------------------------------------------------------
|
||||
// D.9 — Plan + replan loop
|
||||
// ---------------------------------------------------------------
|
||||
/**
|
||||
* Tracks the listener actually heard inside the cooldown window, newest
|
||||
* first, capped so the exclusion can never hold back more than
|
||||
* COOLDOWN_LIBRARY_SHARE of what is playable. Both halves matter: a Vibe
|
||||
* serves tracks the listener never finishes, and the listener plays tracks
|
||||
* outside any Vibe, and both count as "I have heard this recently".
|
||||
*/
|
||||
private async getHeardCooldownTrackIds(userId: string): Promise<string[]> {
|
||||
const res = await this.db.pgClient.query(
|
||||
`WITH eligible AS (
|
||||
SELECT COUNT(*)::int AS total FROM tracks
|
||||
WHERE state = 'LIBRARY' OR (state = 'RECOMMENDED' AND probation_status = 'probation')
|
||||
),
|
||||
heard AS (
|
||||
SELECT track_id, MAX(heard_at) AS heard_at FROM (
|
||||
SELECT track_id, played_at AS heard_at
|
||||
FROM play_history
|
||||
WHERE user_id = $1 AND played_at > NOW() - ($2::int * INTERVAL '1 day')
|
||||
UNION ALL
|
||||
SELECT track_id, occurred_at AS heard_at
|
||||
FROM vibe_events
|
||||
WHERE user_id = $1 AND type = 'track_served' AND track_id IS NOT NULL
|
||||
AND occurred_at > NOW() - ($2::int * INTERVAL '1 day')
|
||||
) heard_rows
|
||||
GROUP BY track_id
|
||||
)
|
||||
SELECT ranked.track_id FROM (
|
||||
SELECT heard.track_id,
|
||||
ROW_NUMBER() OVER (ORDER BY heard.heard_at DESC) AS rn,
|
||||
eligible.total
|
||||
FROM heard CROSS JOIN eligible
|
||||
) ranked
|
||||
WHERE ranked.rn <= FLOOR(ranked.total * $3::numeric)`,
|
||||
[userId, HEARD_COOLDOWN_DAYS, COOLDOWN_LIBRARY_SHARE],
|
||||
);
|
||||
return (res.rows as { track_id: string }[]).map(row => row.track_id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Guarantee the plan's share of never-played tracks. The ranker's bonus makes
|
||||
* an unheard track competitive; this makes it certain, by substituting the
|
||||
* best unheard candidates the ranker did not select for the lowest-priority
|
||||
* heard entries. The first entry is never swapped — that is the track about
|
||||
* to play, and the listener asked for it by seeding this Vibe.
|
||||
*/
|
||||
private enforceUnheardFloor(
|
||||
plan: Candidate[],
|
||||
pool: Candidate[],
|
||||
metadata: Map<string, CandidateConstraintMetadata>,
|
||||
retainedPlan: Candidate[],
|
||||
): Candidate[] {
|
||||
const isUnheard = (candidate: Candidate) => metadata.get(candidate.trackId)?.unheard === true;
|
||||
const total = plan.length + retainedPlan.length;
|
||||
if (total === 0) return plan;
|
||||
|
||||
const present = [...plan, ...retainedPlan].filter(isUnheard).length;
|
||||
let missing = Math.ceil(total * UNHEARD_PLAN_SHARE) - present;
|
||||
if (missing <= 0) return plan;
|
||||
|
||||
const planned = new Set([...plan, ...retainedPlan].map(candidate => candidate.trackId));
|
||||
// `pool` arrives in ranked order, so this takes the best unused ones.
|
||||
const spare = pool.filter(candidate => isUnheard(candidate) && !planned.has(candidate.trackId));
|
||||
if (spare.length === 0) return plan;
|
||||
|
||||
const filled = [...plan];
|
||||
for (let index = filled.length - 1; index > 0 && missing > 0 && spare.length > 0; index--) {
|
||||
if (isUnheard(filled[index])) continue;
|
||||
filled[index] = spare.shift()!;
|
||||
missing--;
|
||||
}
|
||||
return filled;
|
||||
}
|
||||
|
||||
async buildPlan(
|
||||
userId: string,
|
||||
sessionId: string,
|
||||
@@ -1795,6 +1929,7 @@ export class SessionDirector {
|
||||
const recentExclusionSet = new Set<string>([
|
||||
...recentPlays.map(p => p.trackId),
|
||||
...durableSessionTrackIds,
|
||||
...(await this.getHeardCooldownTrackIds(userId)),
|
||||
...(options.excludedTrackIds ?? []),
|
||||
]);
|
||||
if (seedTrackId) recentExclusionSet.add(seedTrackId);
|
||||
@@ -1893,7 +2028,8 @@ export class SessionDirector {
|
||||
// objective snapshots. Do not silently hide a degraded sequence.
|
||||
console.warn('Vibe constraint relaxations', { sessionId, relaxations: constrained.relaxations });
|
||||
}
|
||||
return constrained.plan.slice(0, Math.max(0, planSize - retainedPlan.length));
|
||||
const plan = constrained.plan.slice(0, Math.max(0, planSize - retainedPlan.length));
|
||||
return this.enforceUnheardFloor(plan, deduped, metadata, retainedPlan);
|
||||
}
|
||||
|
||||
/** Apply the durable delivery budget to fresh arc slots. Retained entries
|
||||
|
||||
@@ -423,6 +423,91 @@ describe('SessionDirector', () => {
|
||||
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', () => {
|
||||
@@ -776,6 +861,41 @@ describe('SessionDirector', () => {
|
||||
});
|
||||
});
|
||||
|
||||
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', () => {
|
||||
it('returns state with default values when no prior session', async () => {
|
||||
const db = makeMockDb();
|
||||
|
||||
@@ -50,6 +50,7 @@ function setup() {
|
||||
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: [] }]),
|
||||
@@ -120,6 +121,37 @@ describe('VibeSessionCoordinator', () => {
|
||||
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'));
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { DbService, VibeEvent, VibePlan, VibeSession } from './db.service.js';
|
||||
import { SessionDirector } from './session-director.service.js';
|
||||
import { Candidate } from './generators.service.js';
|
||||
import { Candidate, VibeCalendarContext } from './generators.service.js';
|
||||
|
||||
/**
|
||||
* This is deliberately a narrow bridge between the durable Vibe ledger and
|
||||
@@ -22,6 +22,7 @@ export type VibeEventType = (typeof VIBE_EVENT_TYPES)[number];
|
||||
export interface StartVibeSessionInput {
|
||||
seedTrackId?: string;
|
||||
resumeSessionId?: string;
|
||||
context?: VibeCalendarContext;
|
||||
}
|
||||
|
||||
export interface AppendVibeEventInput {
|
||||
@@ -47,6 +48,15 @@ export interface AdvanceUnplayableVibeItemInput {
|
||||
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 {}
|
||||
@@ -70,10 +80,15 @@ export class VibeSessionCoordinator {
|
||||
|
||||
async start(userId: string, input: StartVibeSessionInput): Promise<VibeSessionResponse> {
|
||||
if (input.resumeSessionId) return this.resume(userId, input.resumeSessionId);
|
||||
// Vibe has no reliable device/activity/location signal. Start from neutral
|
||||
// recommendation state and let actual listening behaviour shape the plan.
|
||||
// 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: 0.5,
|
||||
energy: contextualEnergy,
|
||||
noveltyHunger: 0.3,
|
||||
explorationCoefficient: 0.3,
|
||||
discoveryRadius: 0.38,
|
||||
@@ -83,6 +98,7 @@ export class VibeSessionCoordinator {
|
||||
userId,
|
||||
policyVersion: DEFAULT_VIBE_POLICY_VERSION,
|
||||
seedTrackId: input.seedTrackId ?? null,
|
||||
context: calendar ? { ...calendar } : undefined,
|
||||
profile: {
|
||||
goals: initialState.sessionGoal,
|
||||
explorationCoefficient: initialState.explorationCoefficient,
|
||||
@@ -95,7 +111,7 @@ export class VibeSessionCoordinator {
|
||||
// session while the durable tables remain the source of truth.
|
||||
await this.db.createSessionState(
|
||||
userId,
|
||||
undefined,
|
||||
calendar ? JSON.stringify(calendar) : undefined,
|
||||
{
|
||||
energy: initialState.energy,
|
||||
noveltyHunger: initialState.noveltyHunger,
|
||||
@@ -109,7 +125,7 @@ export class VibeSessionCoordinator {
|
||||
sessionId: session.id,
|
||||
userId,
|
||||
type: 'session_started',
|
||||
payload: { policyVersion: DEFAULT_VIBE_POLICY_VERSION },
|
||||
payload: { policyVersion: DEFAULT_VIBE_POLICY_VERSION, ...(calendar ? { calendar } : {}) },
|
||||
});
|
||||
|
||||
const candidates = await this.director.buildPlan(userId, session.id, input.seedTrackId);
|
||||
@@ -213,6 +229,14 @@ export class VibeSessionCoordinator {
|
||||
// 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 };
|
||||
@@ -332,7 +356,7 @@ export class VibeSessionCoordinator {
|
||||
): 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, 8) ?? [];
|
||||
const preview = plan?.items.filter((item) => !item.committed).slice(0, PREVIEW_SIZE) ?? [];
|
||||
return {
|
||||
session,
|
||||
sessionId: session.id,
|
||||
|
||||
+16
-9
@@ -59,9 +59,10 @@ services:
|
||||
worker:
|
||||
build:
|
||||
context: ./workers
|
||||
# Keep the downloader absent unless an operator intentionally opts in.
|
||||
# Enabled deliberately: the auto-seed discovery loop cannot acquire a
|
||||
# candidate without the downloader present.
|
||||
args:
|
||||
INSTALL_YTDLP: "false"
|
||||
INSTALL_YTDLP: "true"
|
||||
restart: unless-stopped
|
||||
network_mode: host
|
||||
environment:
|
||||
@@ -73,13 +74,19 @@ services:
|
||||
DISCOGS_TOKEN: ${DISCOGS_TOKEN}
|
||||
SOCKS_PROXY_URL: ${SOCKS_PROXY_URL}
|
||||
MUSIC_DIR: /music
|
||||
# System E acquisition is intentionally disabled by default. To enable it
|
||||
# an operator must build with INSTALL_YTDLP=true and set all three runtime
|
||||
# gates below; no acquisition happens merely from graph discovery.
|
||||
# MUZICK_ACQUISITION_ENABLED: "true"
|
||||
# MUZICK_ACQUISITION_YTDLP_PATH: /usr/bin/yt-dlp
|
||||
# MUZICK_ACQUISITION_ALLOWED_HOSTS: example.org
|
||||
# MUZICK_ACQUISITION_DIR: .recommendations
|
||||
# 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
+4109
-41
File diff suppressed because it is too large
Load Diff
@@ -35,6 +35,7 @@
|
||||
"tailwindcss": "^3.4.19",
|
||||
"typescript": "^5.9.3",
|
||||
"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,6 +9,8 @@ import { LyricsOverlay } from './LyricsOverlay';
|
||||
import { Toaster } from './Toaster';
|
||||
import { CommandPalette } from './CommandPalette';
|
||||
import { KeyboardListener, useKeyboard } from '../hooks/useKeyboard';
|
||||
import { useInstallPrompt } from '../hooks/useInstallPrompt';
|
||||
import { PlaybackSyncProvider } from './PlaybackSyncProvider';
|
||||
|
||||
export default function AppShell() {
|
||||
const [queueOpen, setQueueOpen] = useState(false);
|
||||
@@ -18,6 +20,8 @@ export default function AppShell() {
|
||||
|
||||
const togglePalette = useCallback(() => setPaletteOpen((p) => !p), []);
|
||||
|
||||
useInstallPrompt();
|
||||
|
||||
// Ctrl+K — command palette (uses `code` so it works on any keyboard layout)
|
||||
useKeyboard({
|
||||
code: 'KeyK',
|
||||
@@ -47,6 +51,7 @@ export default function AppShell() {
|
||||
});
|
||||
|
||||
return (
|
||||
<PlaybackSyncProvider>
|
||||
<div className="flex h-screen h-[100dvh] flex-col overflow-hidden bg-bg0 text-text">
|
||||
<KeyboardListener />
|
||||
<TopBar
|
||||
@@ -72,5 +77,6 @@ export default function AppShell() {
|
||||
<Toaster />
|
||||
<CommandPalette open={paletteOpen} onClose={() => setPaletteOpen(false)} />
|
||||
</div>
|
||||
</PlaybackSyncProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { render, waitFor } from '@testing-library/react';
|
||||
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';
|
||||
@@ -19,18 +19,151 @@ const track = (id: string): Track => ({
|
||||
|
||||
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,
|
||||
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')!;
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
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 { advancePastUnplayableVibeTrack, reportVibeEvent } from '../services/vibeSession';
|
||||
import { PREFETCH_LEAD_SECONDS, PREFETCH_START_SECONDS } from '../lib/playbackPrefs';
|
||||
import type { Track } from '../types';
|
||||
|
||||
// Threshold (seconds) above which a store position change is treated as a user
|
||||
@@ -17,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];
|
||||
@@ -29,13 +42,50 @@ 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.
|
||||
@@ -43,14 +93,168 @@ export const AudioEngine = () => {
|
||||
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 (
|
||||
@@ -67,26 +271,50 @@ export const AudioEngine = () => {
|
||||
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;
|
||||
// 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 = () => {
|
||||
const onError = (idx: number) => {
|
||||
if (idx !== activeIdxRef.current) return;
|
||||
const playback = store();
|
||||
const track = playback.currentTrack;
|
||||
const vibe = useVibeStore.getState();
|
||||
@@ -100,30 +328,47 @@ export const AudioEngine = () => {
|
||||
.finally(() => { streamErrorTrackIdRef.current = null; });
|
||||
};
|
||||
|
||||
audio.addEventListener('timeupdate', onTimeUpdate);
|
||||
audio.addEventListener('loadedmetadata', onLoadedMetadata);
|
||||
audio.addEventListener('play', onPlay);
|
||||
audio.addEventListener('pause', onPause);
|
||||
audio.addEventListener('ended', onEnded);
|
||||
audio.addEventListener('error', onError);
|
||||
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);
|
||||
audio.removeEventListener('error', onError);
|
||||
};
|
||||
}, []);
|
||||
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 durable Vibe controller owns normal next/ended navigation. It
|
||||
// records the outcome, receives a new plan revision, then calls the raw
|
||||
@@ -135,76 +380,154 @@ export const AudioEngine = () => {
|
||||
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 ------------------
|
||||
//
|
||||
@@ -217,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(),
|
||||
@@ -224,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;
|
||||
},
|
||||
@@ -270,7 +594,7 @@ export const AudioEngine = () => {
|
||||
}
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
}, [elements]);
|
||||
|
||||
// --- MediaSession: publish metadata + playback state ------------------------
|
||||
useEffect(() => {
|
||||
@@ -297,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>
|
||||
);
|
||||
}
|
||||
@@ -35,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' },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -7,6 +7,7 @@ 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 panelRef = useRef<HTMLElement>(null);
|
||||
@@ -34,7 +35,8 @@ export function NowPlayingPanel({ onClose }: { onClose: () => void }) {
|
||||
previous?.focus();
|
||||
};
|
||||
}, []);
|
||||
const { currentTrack, queue, isPlaying, position, duration, play, pause, next, prev, setPosition } = usePlaybackStore();
|
||||
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;
|
||||
@@ -56,17 +58,21 @@ export function NowPlayingPanel({ onClose }: { onClose: () => void }) {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 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 mx-auto block aspect-square w-full max-w-sm 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="mx-auto aspect-square w-full max-w-sm 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>
|
||||
)}
|
||||
@@ -86,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"
|
||||
/>
|
||||
@@ -101,15 +107,16 @@ export function NowPlayingPanel({ onClose }: { onClose: () => void }) {
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-center gap-4 sm:gap-6">
|
||||
<button onClick={prev} aria-label="Previous" className="rounded-md p-2 text-muted hover:text-text hover:bg-surface0"><SkipBack size={20} /></button>
|
||||
<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} aria-label="Next" className="rounded-md p-2 text-muted hover:text-text hover:bg-surface0"><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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,7 +26,7 @@ export function PlaybackBar({ queueOpen, lyricsOpen, onToggleQueue, onToggleLyri
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="glass border-t border-border/70 px-3 py-2 shrink-0 z-20 sm:h-20 sm:px-4 sm:py-0">
|
||||
<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 min-w-0 flex-1 items-center gap-2.5 sm:w-64 sm:flex-none sm:gap-3">
|
||||
@@ -60,7 +63,12 @@ export function PlaybackBar({ queueOpen, lyricsOpen, onToggleQueue, onToggleLyri
|
||||
</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>
|
||||
|
||||
@@ -75,18 +83,18 @@ export function PlaybackBar({ queueOpen, lyricsOpen, onToggleQueue, onToggleLyri
|
||||
>
|
||||
<Shuffle size={18} />
|
||||
</button>
|
||||
<button onClick={prev} className="rounded-md p-2 text-muted hover:text-text hover:bg-surface0" 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="rounded-md p-2 text-muted hover:text-text hover:bg-surface0" 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
|
||||
@@ -98,17 +106,17 @@ export function PlaybackBar({ queueOpen, lyricsOpen, onToggleQueue, onToggleLyri
|
||||
{repeat === 'one' ? <Repeat1 size={18} /> : <Repeat size={18} />}
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex w-full max-w-lg items-center gap-1.5 sm: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>
|
||||
|
||||
@@ -121,6 +129,7 @@ export function PlaybackBar({ queueOpen, lyricsOpen, onToggleQueue, onToggleLyri
|
||||
className="hidden w-20 h-1 cursor-pointer sm:block"
|
||||
aria-label="Volume"
|
||||
/>
|
||||
<DevicePicker />
|
||||
<button
|
||||
onClick={onToggleLyrics}
|
||||
disabled={!currentTrack}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -17,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',
|
||||
@@ -163,7 +163,10 @@ export function TopBar({ onToggleCommandPalette, onToggleNavigation, navigationO
|
||||
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
|
||||
@@ -175,7 +178,7 @@ export function TopBar({ onToggleCommandPalette, onToggleNavigation, navigationO
|
||||
<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>
|
||||
)}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
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';
|
||||
@@ -74,12 +74,19 @@ export function TrackRow({ track, queue, showActions = true, variant = 'default'
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`group flex w-full 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 */}
|
||||
@@ -89,27 +96,42 @@ export function TrackRow({ track, queue, showActions = true, variant = 'default'
|
||||
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 ${
|
||||
compact ? 'h-9 w-9' : 'h-10 w-10'
|
||||
// 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-70 transition-opacity group-hover:opacity-100" />
|
||||
<Play
|
||||
size={compact ? 14 : 18}
|
||||
className={`absolute z-20 text-text opacity-70 transition-opacity ${playable ? 'group-hover:opacity-100' : ''}`}
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Title + artist */}
|
||||
<div className="min-w-0 flex-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handlePlay}
|
||||
disabled={!playable}
|
||||
className={`block max-w-full truncate rounded text-left font-medium hover:underline ${isCurrent ? 'text-accent' : 'text-text'} ${compact ? 'text-sm' : 'text-sm'}`}
|
||||
aria-label={playLabel}
|
||||
>
|
||||
{track.title || 'Untitled'}
|
||||
</button>
|
||||
{/* 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}
|
||||
@@ -126,18 +148,7 @@ export function TrackRow({ track, queue, showActions = true, variant = 'default'
|
||||
<Sparkles size={16} />
|
||||
</button>
|
||||
)}
|
||||
{track.album_id && (
|
||||
<Link
|
||||
to="/albums/$albumId"
|
||||
params={{ albumId: track.album_id }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
aria-label="Go to album"
|
||||
title="Go to album"
|
||||
className="rounded p-1.5 text-muted hover:bg-surface1 hover:text-text"
|
||||
>
|
||||
<Disc3 size={16} />
|
||||
</Link>
|
||||
)}
|
||||
{/* 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>
|
||||
@@ -146,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>
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,13 @@ 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';
|
||||
@@ -11,6 +18,17 @@ const track = (id: string): Track => ({
|
||||
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');
|
||||
@@ -22,16 +40,11 @@ describe('VibeTimeline', () => {
|
||||
|
||||
it('renders upcoming plan entries as display-only so they cannot hand queue ownership to ordinary playback', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<QueryClientProvider client={new QueryClient({ defaultOptions: { queries: { retry: false } } })}>
|
||||
<VibeTimeline currentTrack={track('current')} upcoming={[track('upcoming')]} />
|
||||
</QueryClientProvider>
|
||||
);
|
||||
renderInRouter(<VibeTimeline currentTrack={track('current')} upcoming={[track('upcoming')]} />);
|
||||
|
||||
const queued = screen.getAllByRole('button', { name: 'upcoming is queued by Vibe' });
|
||||
expect(queued).toHaveLength(2);
|
||||
const queued = await screen.findAllByRole('button', { name: 'upcoming is queued by Vibe' });
|
||||
expect(queued).toHaveLength(1);
|
||||
expect(queued[0]).toBeDisabled();
|
||||
expect(queued[1]).toBeDisabled();
|
||||
await user.click(queued[0]);
|
||||
|
||||
expect(usePlaybackStore.getState()).toMatchObject({
|
||||
|
||||
@@ -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}`}
|
||||
|
||||
@@ -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;
|
||||
@@ -109,6 +112,19 @@ 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;
|
||||
@@ -183,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 */
|
||||
@@ -201,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;
|
||||
|
||||
@@ -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,125 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Disc3, Sparkles } from 'lucide-react';
|
||||
import { useNavigate } from '@tanstack/react-router';
|
||||
import { genreService } from '../services/genreService';
|
||||
import { startVibeSession } from '../services/vibeSession';
|
||||
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 [startingVibe, setStartingVibe] = useState(false);
|
||||
const [vibeError, setVibeError] = useState<string | null>(null);
|
||||
const navigate = useNavigate();
|
||||
|
||||
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 || startingVibe) return;
|
||||
const seed = tracks[0];
|
||||
setStartingVibe(true);
|
||||
setVibeError(null);
|
||||
try {
|
||||
await startVibeSession(seed);
|
||||
await navigate({ to: '/vibe' });
|
||||
} catch {
|
||||
setVibeError('Could not start a Vibe from this genre. Please try again.');
|
||||
} finally {
|
||||
setStartingVibe(false);
|
||||
}
|
||||
};
|
||||
|
||||
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={startingVibe || !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} />
|
||||
{startingVibe ? 'Starting…' : 'Start a vibe'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{vibeError && (
|
||||
<p className="rounded-lg border border-red-500/40 bg-red-500/10 p-3 text-sm text-red-300">
|
||||
{vibeError}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{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';
|
||||
|
||||
@@ -93,13 +95,13 @@ function EnrichToggles() {
|
||||
return (
|
||||
<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/70 px-4 py-3 text-left transition-colors hover:bg-surface1 disabled:opacity-50">
|
||||
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>
|
||||
);
|
||||
@@ -278,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));
|
||||
@@ -290,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"
|
||||
@@ -333,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>
|
||||
@@ -342,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}
|
||||
|
||||
+114
-115
@@ -1,34 +1,26 @@
|
||||
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 { advanceVibe, endVibeSession, reportVibeEvent, startVibeSession, vibeErrorMessage } from '../services/vibeSession';
|
||||
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 { toast } from '../store/useToastStore';
|
||||
import { VibeAura } from '../components/VibeAura';
|
||||
|
||||
const SEED_LIST_SIZE = 50;
|
||||
|
||||
function sampleTracks(tracks: Track[], count: number): Track[] {
|
||||
const sampled = [...tracks];
|
||||
for (let index = sampled.length - 1; index > 0; index--) {
|
||||
const pick = Math.floor(Math.random() * (index + 1));
|
||||
[sampled[index], sampled[pick]] = [sampled[pick], sampled[index]];
|
||||
}
|
||||
return sampled.slice(0, count);
|
||||
}
|
||||
|
||||
export default function Vibe() {
|
||||
const { currentTrack } = usePlaybackStore();
|
||||
const { currentTrack, queue } = usePlaybackStore();
|
||||
const {
|
||||
activeSessionId,
|
||||
buffer,
|
||||
initialBatchStatus,
|
||||
planVersion,
|
||||
profile,
|
||||
} = useVibeStore();
|
||||
|
||||
const [starting, setStarting] = useState(false);
|
||||
@@ -36,14 +28,17 @@ export default function Vibe() {
|
||||
const [empty, setEmpty] = useState(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'],
|
||||
// Fetch the eligible library once so Surprise me is not restricted to the
|
||||
// most-played 50 tracks. The backend excludes hidden/deleted tracks.
|
||||
queryFn: () => trackService.listTracks({ limit: 5000, sort_by: 'title', order: 'ASC' }),
|
||||
queryFn: () => trackService.listSeedTracks(SEED_LIST_SIZE),
|
||||
enabled: !activeSessionId,
|
||||
});
|
||||
const seedTracks = useMemo(() => sampleTracks(libraryTracks, SEED_LIST_SIZE), [libraryTracks]);
|
||||
const seedTracks = useMemo(() => seeds?.tracks ?? [], [seeds]);
|
||||
const eligibleCount = seeds?.total ?? 0;
|
||||
|
||||
const startSession = useCallback(
|
||||
async (seed: Track) => {
|
||||
@@ -73,17 +68,10 @@ 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 handleKeep = useCallback(() => {
|
||||
if (currentTrack) {
|
||||
void reportVibeEvent('kept', currentTrack.id).catch((feedbackError) => setError(vibeErrorMessage(feedbackError)));
|
||||
toast.success(`Kept "${currentTrack.title}"`);
|
||||
}
|
||||
}, [currentTrack]);
|
||||
}, [seedTracks, startSession]);
|
||||
|
||||
const handleDislike = useCallback(() => {
|
||||
if (currentTrack) {
|
||||
@@ -96,28 +84,39 @@ export default function Vibe() {
|
||||
setEmpty(false);
|
||||
}, []);
|
||||
|
||||
const upcoming = 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">
|
||||
@@ -125,45 +124,47 @@ 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 across ${libraryTracks.length.toLocaleString()} library tracks.`}
|
||||
? '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 && 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">
|
||||
<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
|
||||
@@ -184,58 +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>
|
||||
{initialBatchStatus === 'loading' && <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 || 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>
|
||||
)}
|
||||
{(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>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="rounded-lg border border-red-500/40 bg-red-500/10 p-3 text-sm text-red-300">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
<div className="rounded-lg border border-red-500/40 bg-red-500/10 p-3 text-sm text-red-300">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{planVersion && <p className="text-xs text-muted/70">Plan revision {planVersion}; upcoming tracks may change as you listen.</p>}
|
||||
|
||||
{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>
|
||||
)}
|
||||
|
||||
<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}`);
|
||||
|
||||
@@ -18,6 +18,8 @@ export interface VibePlanItem {
|
||||
|
||||
export interface DurableVibeSessionResponse {
|
||||
sessionId: string;
|
||||
/** 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[];
|
||||
@@ -49,6 +51,14 @@ export interface VibeEventResponse extends DurableVibeSessionResponse {
|
||||
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;
|
||||
@@ -58,8 +68,18 @@ export interface VibeUnplayableItemInput {
|
||||
}
|
||||
|
||||
export const vibeService = {
|
||||
async start(seedTrackId?: string): Promise<DurableVibeSessionResponse> {
|
||||
const res = await api.post<DurableVibeSessionResponse>('/v2/vibe/sessions', { seedTrackId });
|
||||
async start(seedTrackId?: string, context?: VibeCalendarContext): Promise<DurableVibeSessionResponse> {
|
||||
const res = await api.post<DurableVibeSessionResponse>('/v2/vibe/sessions', { seedTrackId, context });
|
||||
return res.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* 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;
|
||||
},
|
||||
|
||||
|
||||
@@ -4,13 +4,21 @@ import type { Track } from '../types';
|
||||
import { usePlaybackStore } from '../store/usePlaybackStore';
|
||||
import { useVibeStore } from '../store/useVibeStore';
|
||||
|
||||
const { start, next, advancePastUnplayable, event, end, getTrack } = vi.hoisted(() => ({
|
||||
start: vi.fn(), next: vi.fn(), advancePastUnplayable: vi.fn(), event: vi.fn(), end: vi.fn(), getTrack: vi.fn(),
|
||||
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, next, advancePastUnplayable, event, end } }));
|
||||
vi.mock('./vibeService', () => ({ vibeService: { start, resume, next, advancePastUnplayable, event, end } }));
|
||||
vi.mock('./trackService', () => ({ trackService: { getTrack } }));
|
||||
|
||||
import { advancePastUnplayableVibeTrack, advanceVibe, endVibeSession, reportVibeEvent, startVibeSession } from './vibeSession';
|
||||
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',
|
||||
@@ -23,7 +31,7 @@ const item = (track_id: string, committed = false, ordinal = 0) => ({
|
||||
score: 1, score_breakdown: {}, explanation: [], committed,
|
||||
});
|
||||
|
||||
const response = (planVersion: number, now = item('one', true), preview = [item('two')]) => ({
|
||||
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,
|
||||
});
|
||||
|
||||
@@ -41,20 +49,37 @@ describe('durable Vibe session client', () => {
|
||||
start.mockResolvedValue(response(1, item('one'), [item('two')]));
|
||||
next.mockResolvedValue(response(1, item('one', true), [item('two')]));
|
||||
|
||||
await expect(startVibeSession(track('seed'))).resolves.toMatchObject({ status: 'complete', tracks: [track('one'), track('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('seed'));
|
||||
await startVibeSession(track('one'));
|
||||
|
||||
await advanceVibe('skipped');
|
||||
|
||||
@@ -70,7 +95,7 @@ describe('durable Vibe session client', () => {
|
||||
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('seed'));
|
||||
await startVibeSession(track('one'));
|
||||
|
||||
await reportVibeEvent('kept', 'one');
|
||||
|
||||
@@ -90,7 +115,7 @@ describe('durable Vibe session client', () => {
|
||||
event: {},
|
||||
idempotent: true,
|
||||
});
|
||||
await startVibeSession(track('seed'));
|
||||
await startVibeSession(track('one'));
|
||||
|
||||
await reportVibeEvent('kept', 'one');
|
||||
|
||||
@@ -104,7 +129,7 @@ describe('durable Vibe session client', () => {
|
||||
event.mockRejectedValue(new AxiosError('gone', undefined, undefined, undefined, {
|
||||
data: {}, status: 404, statusText: 'Not Found', headers: {}, config: {} as never,
|
||||
}));
|
||||
await startVibeSession(track('seed'));
|
||||
await startVibeSession(track('one'));
|
||||
|
||||
await expect(reportVibeEvent('kept', 'one')).rejects.toThrow('gone');
|
||||
|
||||
@@ -115,7 +140,7 @@ describe('durable Vibe session client', () => {
|
||||
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('seed'));
|
||||
await startVibeSession(track('one'));
|
||||
|
||||
const ordinary = track('ordinary');
|
||||
const playback = usePlaybackStore.getState();
|
||||
@@ -132,7 +157,7 @@ describe('durable Vibe session client', () => {
|
||||
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('seed'));
|
||||
await startVibeSession(track('one'));
|
||||
|
||||
let resolveFirst!: (value: ReturnType<typeof response> & { event: object; idempotent: boolean }) => void;
|
||||
event.mockImplementationOnce(() => new Promise((resolve) => { resolveFirst = resolve; }));
|
||||
@@ -156,7 +181,7 @@ describe('durable Vibe session client', () => {
|
||||
event.mockRejectedValueOnce(new Error('network dropped')).mockResolvedValueOnce({
|
||||
...response(1), event: {}, idempotent: true,
|
||||
});
|
||||
await startVibeSession(track('seed'));
|
||||
await startVibeSession(track('one'));
|
||||
|
||||
await reportVibeEvent('progress', 'one', 30000, 180000);
|
||||
|
||||
@@ -173,7 +198,7 @@ describe('durable Vibe session client', () => {
|
||||
? Promise.resolve({ ...track(id), state: 'HIDDEN' })
|
||||
: Promise.resolve(track(id)));
|
||||
|
||||
await expect(startVibeSession(track('seed'))).resolves.toMatchObject({ status: 'complete', tracks: [track('good'), track('later')] });
|
||||
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({
|
||||
@@ -193,7 +218,7 @@ describe('durable Vibe session client', () => {
|
||||
? Promise.resolve({ ...track(id), state: 'HIDDEN' })
|
||||
: Promise.resolve(track(id)));
|
||||
|
||||
await expect(startVibeSession(track('seed'))).resolves.toMatchObject({
|
||||
await expect(startVibeSession(track('good'))).resolves.toMatchObject({
|
||||
status: 'complete', tracks: [track('good'), track('later')],
|
||||
});
|
||||
|
||||
@@ -214,7 +239,7 @@ describe('durable Vibe session client', () => {
|
||||
? Promise.resolve({ ...track(id), state: 'MISSING' })
|
||||
: Promise.resolve(track(id)));
|
||||
|
||||
await startVibeSession(track('seed'));
|
||||
await startVibeSession(track('good'));
|
||||
|
||||
expect(advancePastUnplayable).toHaveBeenCalledTimes(2);
|
||||
expect(advancePastUnplayable.mock.calls[0][2].eventId)
|
||||
@@ -225,7 +250,7 @@ describe('durable Vibe session client', () => {
|
||||
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('seed'));
|
||||
await startVibeSession(track('one'));
|
||||
|
||||
await advancePastUnplayableVibeTrack('one');
|
||||
|
||||
@@ -237,11 +262,170 @@ describe('durable Vibe session client', () => {
|
||||
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('seed'));
|
||||
await startVibeSession(track('one'));
|
||||
|
||||
await endVibeSession();
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useVibeStore } from '../store/useVibeStore';
|
||||
import {
|
||||
vibeService,
|
||||
type DurableVibeSessionResponse,
|
||||
type VibeCalendarContext,
|
||||
type VibeEventType,
|
||||
type VibePlanItem,
|
||||
} from './vibeService';
|
||||
@@ -18,16 +19,31 @@ export interface StartedVibeSession {
|
||||
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.
|
||||
@@ -50,6 +66,23 @@ function newEventId(): string {
|
||||
});
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
@@ -83,6 +116,24 @@ async function hydratePreview(items: VibePlanItem[]): Promise<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();
|
||||
@@ -95,9 +146,18 @@ function replaceUnplayedQueue(preview: Track[]): void {
|
||||
const history = queueIndex >= 0
|
||||
? playback.queue.slice(0, queueIndex + 1)
|
||||
: current ? [current] : [];
|
||||
const seen = new Set(history.map((track) => track.id));
|
||||
const future = preview.filter((track) => !seen.has(track.id));
|
||||
playback.setVibeQueue([...history, ...future]);
|
||||
// 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 {
|
||||
@@ -109,6 +169,7 @@ async function reconcilePreview(sessionId: string, response: DurableVibeSessionR
|
||||
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;
|
||||
}
|
||||
@@ -131,6 +192,26 @@ async function serveNextPlayable(
|
||||
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,
|
||||
@@ -187,6 +268,7 @@ async function resolvePlayableResponse(
|
||||
}
|
||||
|
||||
function deactivateBrokenSession(): void {
|
||||
seedPendingTrackId = null;
|
||||
const playback = usePlaybackStore.getState();
|
||||
playback.setVibeAdvanceHandler(null);
|
||||
useVibeStore.getState().reset();
|
||||
@@ -226,7 +308,7 @@ async function sendEvent(
|
||||
durationMs,
|
||||
};
|
||||
return new Promise<DurableVibeSessionResponse>((resolve, reject) => {
|
||||
eventOutbox.push({ sessionId, input, retried: false, settled: false, resolve, reject });
|
||||
eventOutbox.push({ sessionId, input, retried: false, settled: false, transientAttempts: 0, resolve, reject });
|
||||
void flushEventOutbox();
|
||||
});
|
||||
}
|
||||
@@ -235,6 +317,35 @@ 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;
|
||||
@@ -254,6 +365,15 @@ async function flushEventOutbox(): Promise<void> {
|
||||
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();
|
||||
@@ -306,13 +426,23 @@ export function advanceVibe(reason: VibeAdvanceReason): Promise<void> {
|
||||
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 serveNextPlayable(vibe.activeSessionId, feedback.planVersion);
|
||||
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.
|
||||
@@ -321,10 +451,18 @@ export function advanceVibe(reason: VibeAdvanceReason): Promise<void> {
|
||||
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([]);
|
||||
@@ -344,11 +482,29 @@ export function advanceVibe(reason: VibeAdvanceReason): Promise<void> {
|
||||
*/
|
||||
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 || !currentItem || currentItem.track_id !== trackId || !isCurrentVibeOwner(vibe.activeSessionId)) return;
|
||||
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, {
|
||||
@@ -367,6 +523,7 @@ export function advancePastUnplayableVibeTrack(trackId: string): Promise<void> {
|
||||
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();
|
||||
@@ -390,7 +547,7 @@ function installVibeAdvanceHandler(): void {
|
||||
export async function startVibeSession(seed: Track): Promise<StartedVibeSession> {
|
||||
if (startInFlight) return startInFlight;
|
||||
startInFlight = serializeMaterial<StartedVibeSession>(async () => {
|
||||
const started = await vibeService.start(seed.id);
|
||||
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: [] };
|
||||
@@ -403,14 +560,20 @@ export async function startVibeSession(seed: Track): Promise<StartedVibeSession>
|
||||
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();
|
||||
playback.setVibeQueue([served.now, ...served.preview]);
|
||||
playback.playTrack(served.now);
|
||||
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: [served.now, ...served.preview] };
|
||||
return { status: 'complete', tracks: queue };
|
||||
});
|
||||
try {
|
||||
return await startInFlight;
|
||||
@@ -419,12 +582,77 @@ export async function startVibeSession(seed: Track): Promise<StartedVibeSession>
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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();
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
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';
|
||||
@@ -22,12 +31,22 @@ 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. */
|
||||
@@ -44,7 +63,10 @@ interface PlaybackState {
|
||||
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;
|
||||
}
|
||||
@@ -72,19 +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) => ({
|
||||
@@ -237,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) => {
|
||||
@@ -251,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,6 +1,7 @@
|
||||
import { create } from 'zustand';
|
||||
import type { Track, VibeSession } from '../types';
|
||||
import type { VibePlanItem } from '../services/vibeService';
|
||||
import type { VibeProfile } from '../components/VibeAura';
|
||||
|
||||
// The durable plan is authoritative. `buffer` is only its currently
|
||||
// uncommitted, hydrated preview; it may be replaced at any feedback boundary.
|
||||
@@ -10,6 +11,7 @@ interface VibeState {
|
||||
planVersion: number | null;
|
||||
/** Durable cursor for the track currently in Vibe playback. */
|
||||
currentPlanItem: VibePlanItem | null;
|
||||
profile: VibeProfile;
|
||||
centerTrack: Track | null;
|
||||
buffer: Track[];
|
||||
initialBatchStatus: 'idle' | 'loading' | 'exhausted' | 'failed';
|
||||
@@ -20,6 +22,7 @@ interface VibeState {
|
||||
/** 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;
|
||||
}
|
||||
@@ -29,6 +32,7 @@ const initialState = {
|
||||
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,
|
||||
@@ -51,6 +55,7 @@ export const useVibeStore = create<VibeState>((set, get) => ({
|
||||
return true;
|
||||
},
|
||||
setCurrentPlanItem: (currentPlanItem) => set({ currentPlanItem }),
|
||||
setProfile: (profile) => set({ profile }),
|
||||
setInitialBatchStatus: (initialBatchStatus) => set({ initialBatchStatus }),
|
||||
reset: () => set({ ...initialState }),
|
||||
}));
|
||||
|
||||
@@ -28,6 +28,7 @@ export default {
|
||||
secondary: 'var(--ethos-secondary)',
|
||||
muted: 'var(--ethos-muted)',
|
||||
disabled: 'var(--ethos-disabled)',
|
||||
machine: 'var(--ethos-machine)',
|
||||
accent: 'var(--ethos-accent)',
|
||||
'accent-h': 'var(--ethos-accent-hover)',
|
||||
green: 'var(--ethos-green)',
|
||||
|
||||
+71
-2
@@ -1,8 +1,65 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import { VitePWA } from 'vite-plugin-pwa'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
plugins: [
|
||||
react(),
|
||||
VitePWA({
|
||||
registerType: 'autoUpdate',
|
||||
includeAssets: ['favicon-32.png', 'apple-touch-icon.png'],
|
||||
manifest: {
|
||||
name: 'Muzick',
|
||||
short_name: 'Muzick',
|
||||
description: 'Self-hosted music player and recommendation engine',
|
||||
start_url: '/',
|
||||
scope: '/',
|
||||
display: 'standalone',
|
||||
orientation: 'portrait',
|
||||
background_color: '#14110D',
|
||||
theme_color: '#14110D',
|
||||
icons: [
|
||||
{ src: '/icon-192.png', sizes: '192x192', type: 'image/png' },
|
||||
{ src: '/icon-512.png', sizes: '512x512', type: 'image/png' },
|
||||
{
|
||||
src: '/icon-maskable-512.png',
|
||||
sizes: '512x512',
|
||||
type: 'image/png',
|
||||
purpose: 'maskable',
|
||||
},
|
||||
],
|
||||
},
|
||||
workbox: {
|
||||
globPatterns: ['**/*.{js,css,html,woff2}'],
|
||||
|
||||
// The SPA fallback must never swallow the API: /api/playback/stream is a
|
||||
// long-lived SSE channel and the track endpoints stream audio. Both look
|
||||
// like navigations to Workbox unless they are denied here.
|
||||
navigateFallback: '/index.html',
|
||||
navigateFallbackDenylist: [/^\/api\//],
|
||||
|
||||
// A waiting service worker would otherwise activate and reload the page
|
||||
// mid-song. Letting it wait means updates land on the next cold start,
|
||||
// which is the right trade for a player that runs in the background.
|
||||
skipWaiting: false,
|
||||
clientsClaim: false,
|
||||
|
||||
runtimeCaching: [
|
||||
{
|
||||
// Artwork only. Audio is deliberately absent: range requests and
|
||||
// multi-megabyte bodies do not belong in the shell cache.
|
||||
urlPattern: ({ request }) => request.destination === 'image',
|
||||
handler: 'CacheFirst',
|
||||
options: {
|
||||
cacheName: 'muzick-artwork',
|
||||
expiration: { maxEntries: 300, maxAgeSeconds: 60 * 60 * 24 * 30 },
|
||||
cacheableResponse: { statuses: [0, 200] },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
],
|
||||
test: {
|
||||
environment: 'jsdom',
|
||||
setupFiles: './src/test/setup.ts',
|
||||
@@ -10,11 +67,23 @@ export default defineConfig({
|
||||
},
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': 'http://localhost:3000',
|
||||
// ponytail: the deployed nginx injects the Authorization header, so dev can
|
||||
// point at it (DEV_API_TARGET=http://localhost:5174) instead of a bare backend.
|
||||
'/api': process.env.DEV_API_TARGET || 'http://localhost:3000',
|
||||
}
|
||||
},
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
emptyOutDir: true,
|
||||
rollupOptions: {
|
||||
output: {
|
||||
// The framework changes on an npm upgrade, the app changes every
|
||||
// deploy. Splitting them means a redeploy re-downloads app code only,
|
||||
// which matters here because the service worker precaches the lot.
|
||||
manualChunks: {
|
||||
vendor: ['react', 'react-dom', '@tanstack/react-router', '@tanstack/react-query'],
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
+15
-2
@@ -2,8 +2,21 @@ FROM node:20-slim
|
||||
# yt-dlp is intentionally opt-in. Enabling its image build alone does not make
|
||||
# acquisition live: the worker additionally requires explicit runtime gates.
|
||||
ARG INSTALL_YTDLP=false
|
||||
RUN apt-get update && apt-get install -y ffmpeg \
|
||||
&& if [ "$INSTALL_YTDLP" = "true" ]; then apt-get install -y yt-dlp; fi \
|
||||
# Debian's yt-dlp package lags years behind and fails on current YouTube, so
|
||||
# take the self-contained upstream binary instead. It bundles its own Python.
|
||||
# ponytail: tracks latest; pin the tag here if a release ever breaks the loop.
|
||||
RUN apt-get update && apt-get install -y ffmpeg curl unzip \
|
||||
&& if [ "$INSTALL_YTDLP" = "true" ]; then \
|
||||
curl -fsSL https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp_linux \
|
||||
-o /usr/bin/yt-dlp \
|
||||
&& chmod 755 /usr/bin/yt-dlp \
|
||||
&& /usr/bin/yt-dlp --version \
|
||||
# YouTube guards some formats with an obfuscated JS challenge that
|
||||
# yt-dlp must execute. Deno is the only runtime it enables by default,
|
||||
# and without one those videos fail extraction.
|
||||
&& curl -fsSL https://deno.land/install.sh | DENO_INSTALL=/usr/local sh -s -- --yes \
|
||||
&& deno --version; \
|
||||
fi \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
WORKDIR /app
|
||||
COPY package*.json ./
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { parseAcquisitionSpec, validateAcquisitionUrl } from './acquisition.service.js';
|
||||
|
||||
const HOSTS = new Set(['www.youtube.com']);
|
||||
|
||||
test('accepts an allow-listed HTTPS url', () => {
|
||||
const spec = parseAcquisitionSpec(
|
||||
{ acquisition: { url: 'https://www.youtube.com/watch?v=abc', expectedTitle: 'T', expectedArtist: 'A' } },
|
||||
HOSTS
|
||||
);
|
||||
assert.equal(spec.url, 'https://www.youtube.com/watch?v=abc');
|
||||
assert.equal(spec.query, undefined);
|
||||
assert.equal(spec.expectedArtist, 'A');
|
||||
});
|
||||
|
||||
test('accepts a search query and strips newlines that could forge print output', () => {
|
||||
const spec = parseAcquisitionSpec(
|
||||
{ acquisition: { query: 'Boards of Canada\nRoygbiv', expectedTitle: 'Roygbiv' } },
|
||||
HOSTS
|
||||
);
|
||||
assert.equal(spec.query, 'Boards of Canada Roygbiv');
|
||||
assert.equal(spec.url, undefined);
|
||||
});
|
||||
|
||||
test('rejects a candidate naming neither a url nor a query', () => {
|
||||
assert.throws(() => parseAcquisitionSpec({ acquisition: { expectedTitle: 'T' } }, HOSTS), /url or a query/);
|
||||
assert.throws(() => parseAcquisitionSpec({ acquisition: { query: ' ' } }, HOSTS), /url or a query/);
|
||||
assert.throws(() => parseAcquisitionSpec({}, HOSTS), /no resolved acquisition source/);
|
||||
});
|
||||
|
||||
test('search-resolved urls face the same gates as supplied ones', () => {
|
||||
assert.throws(() => validateAcquisitionUrl('https://evil.example/x', HOSTS), /not allow-listed/);
|
||||
assert.throws(() => validateAcquisitionUrl('http://www.youtube.com/x', HOSTS), /must use HTTPS/);
|
||||
assert.throws(() => validateAcquisitionUrl('https://u:p@www.youtube.com/x', HOSTS), /must not contain credentials/);
|
||||
assert.equal(
|
||||
validateAcquisitionUrl('https://WWW.YouTube.com/watch?v=1', HOSTS),
|
||||
'https://www.youtube.com/watch?v=1'
|
||||
);
|
||||
});
|
||||
@@ -13,7 +13,10 @@ type CandidateRow = {
|
||||
};
|
||||
|
||||
type AcquisitionSpec = {
|
||||
url: string;
|
||||
/** A vetted, allow-listed HTTPS URL. Mutually exclusive with `query`. */
|
||||
url?: string;
|
||||
/** A search phrase to resolve into such a URL at download time. */
|
||||
query?: string;
|
||||
expectedTitle?: string;
|
||||
expectedArtist?: string;
|
||||
};
|
||||
@@ -72,18 +75,17 @@ function matchesExpected(actual: string, expected: string | undefined): boolean
|
||||
return left === right || left.includes(right) || right.includes(left);
|
||||
}
|
||||
|
||||
/** Parse only a deliberately supplied HTTPS source URL; never accept argv/query strings. */
|
||||
export function parseAcquisitionSpec(notes: unknown, allowedHosts: Set<string>): AcquisitionSpec {
|
||||
const notesValue = typeof notes === 'string' ? JSON.parse(notes) : notes;
|
||||
const candidate = (notesValue as { acquisition?: unknown } | null)?.acquisition;
|
||||
if (!candidate || typeof candidate !== 'object') {
|
||||
throw new Error('candidate has no resolved acquisition source');
|
||||
}
|
||||
const { url, expectedTitle, expectedArtist } = candidate as Record<string, unknown>;
|
||||
if (typeof url !== 'string') throw new Error('acquisition source URL is required');
|
||||
/**
|
||||
* Every URL that reaches the downloader passes through here: HTTPS only, no
|
||||
* embedded credentials, host on the allow-list. Applied to operator-supplied
|
||||
* URLs and to search-resolved ones alike, so a search cannot widen the hosts a
|
||||
* download may come from.
|
||||
*/
|
||||
export function validateAcquisitionUrl(raw: unknown, allowedHosts: Set<string>): string {
|
||||
if (typeof raw !== 'string') throw new Error('acquisition source URL is required');
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(url);
|
||||
parsed = new URL(raw);
|
||||
} catch {
|
||||
throw new Error('acquisition source URL is invalid');
|
||||
}
|
||||
@@ -92,11 +94,41 @@ export function parseAcquisitionSpec(notes: unknown, allowedHosts: Set<string>):
|
||||
if (!allowedHosts.has(parsed.hostname.toLowerCase())) {
|
||||
throw new Error(`acquisition host is not allow-listed: ${parsed.hostname}`);
|
||||
}
|
||||
return {
|
||||
url: parsed.toString(),
|
||||
return parsed.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse either a vetted HTTPS URL or a search phrase to resolve into one.
|
||||
*
|
||||
* The search form is what automated discovery emits: a graph walk or a
|
||||
* similarity lookup names an artist and a title, never a downloadable file. A
|
||||
* resolved search result is trusted no further than an operator-supplied URL —
|
||||
* same host allow-list, and `matchesExpected` still rejects the download if the
|
||||
* file's tags disagree with the candidate.
|
||||
*/
|
||||
export function parseAcquisitionSpec(notes: unknown, allowedHosts: Set<string>): AcquisitionSpec {
|
||||
const notesValue = typeof notes === 'string' ? JSON.parse(notes) : notes;
|
||||
const candidate = (notesValue as { acquisition?: unknown } | null)?.acquisition;
|
||||
if (!candidate || typeof candidate !== 'object') {
|
||||
throw new Error('candidate has no resolved acquisition source');
|
||||
}
|
||||
const { url, query, expectedTitle, expectedArtist } = candidate as Record<string, unknown>;
|
||||
const expected = {
|
||||
expectedTitle: typeof expectedTitle === 'string' ? expectedTitle.slice(0, 500) : undefined,
|
||||
expectedArtist: typeof expectedArtist === 'string' ? expectedArtist.slice(0, 500) : undefined,
|
||||
};
|
||||
|
||||
if (url !== undefined) {
|
||||
return { url: validateAcquisitionUrl(url, allowedHosts), ...expected };
|
||||
}
|
||||
if (typeof query === 'string' && query.trim() !== '') {
|
||||
// Newlines would let a crafted candidate forge extra --print output lines
|
||||
// when the resolver parses stdout.
|
||||
const cleaned = query.replace(/[\r\n]+/g, ' ').trim().slice(0, 300);
|
||||
if (cleaned === '') throw new Error('acquisition search query is empty');
|
||||
return { query: cleaned, ...expected };
|
||||
}
|
||||
throw new Error('acquisition source needs either a url or a query');
|
||||
}
|
||||
|
||||
async function runDownloader(executable: string, args: string[], timeoutMs: number): Promise<string> {
|
||||
@@ -153,6 +185,24 @@ export class AcquisitionService {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Turn a search phrase into one allow-listed URL, without downloading.
|
||||
*
|
||||
* ponytail: first search result only. Ranking alternatives needs a quality
|
||||
* signal this system does not have, and the tag check downstream already
|
||||
* rejects a wrong hit. Revisit if mismatches become common.
|
||||
*/
|
||||
private async resolveQueryToUrl(query: string): Promise<string> {
|
||||
const stdout = await runDownloader(this.config.ytDlpPath, [
|
||||
'--no-playlist', '--no-progress', '--skip-download',
|
||||
'--print', 'webpage_url',
|
||||
'--', `ytsearch1:${query}`,
|
||||
], this.config.timeoutMs);
|
||||
const lines = stdout.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
|
||||
if (lines.length !== 1) throw new Error('search did not resolve to exactly one result');
|
||||
return validateAcquisitionUrl(lines[0], this.config.allowedHosts);
|
||||
}
|
||||
|
||||
async acquire(candidateId: string): Promise<AcquisitionResult> {
|
||||
const rowResult = await this.pgPool.query<CandidateRow>(
|
||||
`SELECT id, source, notes, status FROM discovery_candidates WHERE id = $1`, [candidateId]
|
||||
@@ -212,13 +262,19 @@ export class AcquisitionService {
|
||||
// Arguments are fixed by us. The sole untrusted value is the validated URL
|
||||
// and spawn() is invoked with shell:false, so no command interpolation is
|
||||
// possible. One URL / one item is deliberate: playlists are out of scope.
|
||||
const sourceUrl = spec.url ?? await this.resolveQueryToUrl(spec.query as string);
|
||||
|
||||
const outputTemplate = path.join(candidateDir, '%(id)s.%(ext)s');
|
||||
const stdout = await runDownloader(this.config.ytDlpPath, [
|
||||
'--no-playlist', '--no-progress', '--restrict-filenames',
|
||||
'--extract-audio', '--audio-format', 'mp3', '--audio-quality', '5',
|
||||
// Without this the mp3 carries no tags at all, the scanner falls back to
|
||||
// "<video id>.mp3" / "Unknown Artist", and the tag check below rejects
|
||||
// every download.
|
||||
'--embed-metadata',
|
||||
'--output', outputTemplate,
|
||||
'--print', 'after_move:filepath',
|
||||
'--', spec.url,
|
||||
'--', sourceUrl,
|
||||
], this.config.timeoutMs);
|
||||
const reportedPaths = stdout.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
|
||||
if (reportedPaths.length !== 1) throw new Error('downloader did not report exactly one output file');
|
||||
@@ -235,6 +291,11 @@ export class AcquisitionService {
|
||||
await this.setStatus(candidate.id, 'scanning', null);
|
||||
const scan = await this.scanner.scanDirectory(candidateDir, {
|
||||
sourceType: 'RECOMMENDATION', probationStatus: 'probation', candidateId: candidate.id,
|
||||
// A source that embeds no tags leaves nothing to verify against. The
|
||||
// candidate's own vetted names are then both the display names and
|
||||
// what the check below compares, so the download is accepted and the
|
||||
// track stays on probation, where listening decides its fate.
|
||||
fallbackTitle: spec.expectedTitle, fallbackArtist: spec.expectedArtist,
|
||||
});
|
||||
if (scan.trackIds.length !== 1) {
|
||||
throw new Error(`scanner created ${scan.trackIds.length} tracks; expected exactly one`);
|
||||
@@ -270,7 +331,14 @@ export class AcquisitionService {
|
||||
ON CONFLICT (subject_type, subject_id, predicate, object_type, object_id, source, user_id)
|
||||
DO UPDATE SET confidence = EXCLUDED.confidence,
|
||||
last_reinforced_at = NOW(), raw = EXCLUDED.raw`,
|
||||
[trackId, candidate.id, candidate.source, JSON.stringify({ expectedTitle: spec.expectedTitle, expectedArtist: spec.expectedArtist })]
|
||||
[trackId, candidate.id, candidate.source, JSON.stringify({
|
||||
expectedTitle: spec.expectedTitle,
|
||||
expectedArtist: spec.expectedArtist,
|
||||
// Which URL a search actually landed on is the only way to audit a
|
||||
// bad acquisition after the fact.
|
||||
sourceUrl,
|
||||
resolvedFromQuery: spec.query ?? null,
|
||||
})]
|
||||
);
|
||||
await client.query('COMMIT');
|
||||
} catch (err) {
|
||||
|
||||
@@ -0,0 +1,288 @@
|
||||
// External discovery sources: where recommendation candidates come from.
|
||||
//
|
||||
// Two deliberately separate strategies, one per source_trust key:
|
||||
//
|
||||
// new_release — an artist you actually play released something new.
|
||||
// Seeded from local play counts, resolved via Deezer.
|
||||
// similar_recommendation — Last.fm's "sounds like" neighbours of the tracks
|
||||
// you play most. Weaker prior, wider reach.
|
||||
//
|
||||
// Both write track-level rows into discovery_candidates carrying a search spec
|
||||
// (artist + title) rather than a URL: the acquisition worker resolves that to a
|
||||
// concrete allow-listed URL at download time. Nothing here touches the
|
||||
// filesystem or the player; a candidate is only ever an intent to try a track.
|
||||
|
||||
import type { Pool } from 'pg';
|
||||
import { DeezerClient, LastFmClient } from './integrations/index.js';
|
||||
|
||||
/** A track we might want to try, before any acquisition source is resolved. */
|
||||
interface CandidateSeed {
|
||||
source: 'new_release' | 'similar_recommendation';
|
||||
/** Stable dedup key within the source. */
|
||||
externalId: string;
|
||||
title: string;
|
||||
artist: string;
|
||||
/** Local artist this candidate hangs off: the releasing or the seed artist. */
|
||||
relatedArtistId: string;
|
||||
/** 0..1 prior on this candidate being worth a probation slot. */
|
||||
relevance: number;
|
||||
raw: Record<string, unknown>;
|
||||
}
|
||||
|
||||
interface TopArtist {
|
||||
id: string;
|
||||
name: string;
|
||||
plays: number;
|
||||
}
|
||||
|
||||
interface TopTrack {
|
||||
title: string;
|
||||
artist: string;
|
||||
artistId: string;
|
||||
plays: number;
|
||||
}
|
||||
|
||||
export interface DiscoverySourcesResult {
|
||||
considered: number;
|
||||
inserted: number;
|
||||
}
|
||||
|
||||
const NEW_RELEASE_WINDOW_DAYS = 120;
|
||||
|
||||
export class DiscoverySourcesService {
|
||||
constructor(
|
||||
private readonly pgPool: Pool,
|
||||
private readonly deezer = new DeezerClient(),
|
||||
private readonly lastfm = new LastFmClient(),
|
||||
) {}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Shared plumbing
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
/** Artists ranked by how much of their local catalog actually gets played. */
|
||||
private async topArtists(limit: number): Promise<TopArtist[]> {
|
||||
const res = await this.pgPool.query<{ id: string; name: string; plays: string }>(
|
||||
`SELECT a.id, COALESCE(a.canonical_name, a.name) AS name, SUM(t.play_count)::text AS plays
|
||||
FROM tracks t
|
||||
JOIN albums al ON al.id = t.album_id
|
||||
JOIN artists a ON a.id = al.artist_id
|
||||
WHERE t.state = 'LIBRARY' AND t.deleted_at IS NULL AND t.play_count > 0
|
||||
GROUP BY a.id, COALESCE(a.canonical_name, a.name)
|
||||
ORDER BY SUM(t.play_count) DESC
|
||||
LIMIT $1`,
|
||||
[limit]
|
||||
);
|
||||
return res.rows.map((r) => ({ id: r.id, name: r.name, plays: Number(r.plays) }));
|
||||
}
|
||||
|
||||
/** Most-played individual tracks — the seeds similarity lookups start from. */
|
||||
private async topTracks(limit: number): Promise<TopTrack[]> {
|
||||
const res = await this.pgPool.query<{ title: string; artist: string; artist_id: string; play_count: number }>(
|
||||
`SELECT t.title, t.artist, al.artist_id, t.play_count
|
||||
FROM tracks t
|
||||
JOIN albums al ON al.id = t.album_id
|
||||
WHERE t.state = 'LIBRARY' AND t.deleted_at IS NULL
|
||||
AND t.play_count > 0 AND al.artist_id IS NOT NULL
|
||||
ORDER BY t.play_count DESC
|
||||
LIMIT $1`,
|
||||
[limit]
|
||||
);
|
||||
return res.rows.map((r) => ({
|
||||
title: r.title,
|
||||
artist: r.artist,
|
||||
artistId: r.artist_id,
|
||||
plays: r.play_count,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* True when this artist+title is already a local track, in any state.
|
||||
*
|
||||
* Any state is deliberate: a retired or quarantined track must not be
|
||||
* re-acquired, otherwise every sweep re-downloads what the listener already
|
||||
* skipped away.
|
||||
*/
|
||||
private async alreadyKnown(artist: string, title: string): Promise<boolean> {
|
||||
const res = await this.pgPool.query<{ exists: boolean }>(
|
||||
`SELECT EXISTS (
|
||||
SELECT 1 FROM tracks
|
||||
WHERE lower(artist) = lower($1) AND lower(title) = lower($2)
|
||||
) AS exists`,
|
||||
[artist, title]
|
||||
);
|
||||
return res.rows[0]?.exists ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert a candidate plus the relevance claim `evalCandidates` reads.
|
||||
*
|
||||
* The claim's object is the local artist the candidate came from. For a new
|
||||
* release that is the releasing artist; for a similarity hit it is the seed.
|
||||
* Either way it is what the existing per-artist diversity cap should count.
|
||||
*/
|
||||
private async insertCandidate(seed: CandidateSeed): Promise<boolean> {
|
||||
// The (source, external_id) unique key does not catch the same song reaching
|
||||
// us twice under different ids — a Deezer single and the album that carries
|
||||
// it, or a Last.fm hit for something a new release already proposed. Name
|
||||
// equality does, in any candidate state, for the same reason alreadyKnown
|
||||
// ignores track state.
|
||||
const duplicate = await this.pgPool.query<{ exists: boolean }>(
|
||||
`SELECT EXISTS (
|
||||
SELECT 1 FROM discovery_candidates
|
||||
WHERE lower(title) = lower($1)
|
||||
AND lower(artist_credit->0->>'name') = lower($2)
|
||||
) AS exists`,
|
||||
[seed.title, seed.artist]
|
||||
);
|
||||
if (duplicate.rows[0]?.exists) return false;
|
||||
|
||||
const notes = {
|
||||
discovery_source: seed.source,
|
||||
...seed.raw,
|
||||
// The acquisition worker turns this into a concrete URL. No URL is
|
||||
// recorded here because nothing has vetted one yet.
|
||||
acquisition: {
|
||||
query: `${seed.artist} ${seed.title}`,
|
||||
expectedTitle: seed.title,
|
||||
expectedArtist: seed.artist,
|
||||
},
|
||||
};
|
||||
|
||||
const dcRes = await this.pgPool.query<{ id: string }>(
|
||||
`INSERT INTO discovery_candidates (source, external_id, title, artist_credit, notes)
|
||||
VALUES ($1, $2, $3, $4::jsonb, $5::jsonb)
|
||||
ON CONFLICT (source, external_id) DO NOTHING
|
||||
RETURNING id`,
|
||||
[
|
||||
seed.source,
|
||||
seed.externalId,
|
||||
seed.title,
|
||||
JSON.stringify([{ name: seed.artist, artist_id: seed.relatedArtistId }]),
|
||||
JSON.stringify(notes),
|
||||
]
|
||||
);
|
||||
if (dcRes.rows.length === 0) return false;
|
||||
|
||||
await this.pgPool.query(
|
||||
`INSERT INTO claims (
|
||||
subject_type, subject_id, predicate, object_type, object_id,
|
||||
source, confidence, raw
|
||||
) VALUES ('discovery_candidate', $1::uuid, 'discovery_candidate', 'artist', $2::uuid,
|
||||
$3, $4, $5::jsonb)
|
||||
ON CONFLICT (subject_type, subject_id, predicate, object_type, object_id, source, user_id)
|
||||
DO UPDATE SET confidence = EXCLUDED.confidence, last_reinforced_at = NOW()`,
|
||||
[
|
||||
dcRes.rows[0].id,
|
||||
seed.relatedArtistId,
|
||||
seed.source,
|
||||
Math.max(0, Math.min(1, seed.relevance)),
|
||||
JSON.stringify({ discovery_source: seed.source, ...seed.raw }),
|
||||
]
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// new_release
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* For the `artistLimit` most-played local artists, find releases from the
|
||||
* last NEW_RELEASE_WINDOW_DAYS whose tracks we do not own.
|
||||
*
|
||||
* ponytail: `tracksPerAlbum` tracks per new album, not the whole thing. A
|
||||
* probation slot per track is the expensive resource, and one representative
|
||||
* track is enough to learn whether the release lands. Raise it if retention
|
||||
* on this source turns out high.
|
||||
*/
|
||||
async discoverNewReleases(artistLimit = 15, tracksPerAlbum = 2): Promise<DiscoverySourcesResult> {
|
||||
const since = new Date(Date.now() - NEW_RELEASE_WINDOW_DAYS * 86_400_000)
|
||||
.toISOString()
|
||||
.slice(0, 10);
|
||||
let considered = 0;
|
||||
let inserted = 0;
|
||||
|
||||
for (const artist of await this.topArtists(artistLimit)) {
|
||||
const albums = await this.deezer.getArtistAlbumsSince(artist.name, since);
|
||||
for (const album of albums) {
|
||||
const titles = await this.deezer.getAlbumTracks(album.id);
|
||||
let takenFromAlbum = 0;
|
||||
for (const title of titles) {
|
||||
if (takenFromAlbum >= tracksPerAlbum) break;
|
||||
considered++;
|
||||
if (await this.alreadyKnown(artist.name, title)) continue;
|
||||
const ok = await this.insertCandidate({
|
||||
source: 'new_release',
|
||||
externalId: `deezer:${album.id}:${title.toLowerCase()}`,
|
||||
title,
|
||||
artist: artist.name,
|
||||
relatedArtistId: artist.id,
|
||||
// A new release by someone already in heavy rotation is the
|
||||
// strongest prior this system has short of an explicit request.
|
||||
relevance: 0.75,
|
||||
raw: {
|
||||
album: album.title,
|
||||
release_date: album.releaseDate,
|
||||
record_type: album.recordType,
|
||||
seed_artist_plays: artist.plays,
|
||||
},
|
||||
});
|
||||
if (ok) {
|
||||
inserted++;
|
||||
takenFromAlbum++;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { considered, inserted };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// similar_recommendation
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Ask Last.fm what sounds like the tracks played most, and keep the hits by
|
||||
* artists other than the seed's — a different track by the same artist is a
|
||||
* library gap, not a discovery.
|
||||
*/
|
||||
async discoverRecommendations(seedLimit = 10, perSeed = 5): Promise<DiscoverySourcesResult> {
|
||||
let considered = 0;
|
||||
let inserted = 0;
|
||||
|
||||
for (const seed of await this.topTracks(seedLimit)) {
|
||||
const similar = await this.lastfm.getSimilarTracks(seed.artist, seed.title, perSeed * 4);
|
||||
let takenFromSeed = 0;
|
||||
for (const hit of similar) {
|
||||
if (takenFromSeed >= perSeed) break;
|
||||
considered++;
|
||||
if (hit.artist.toLowerCase() === seed.artist.toLowerCase()) continue;
|
||||
if (await this.alreadyKnown(hit.artist, hit.name)) continue;
|
||||
const ok = await this.insertCandidate({
|
||||
source: 'similar_recommendation',
|
||||
externalId: `lastfm:${hit.artist.toLowerCase()}:${hit.name.toLowerCase()}`,
|
||||
title: hit.name,
|
||||
artist: hit.artist,
|
||||
relatedArtistId: seed.artistId,
|
||||
// Last.fm's own match score, floored so a weak-but-present match
|
||||
// still clears the acquisition gate's relevance > 0.3.
|
||||
relevance: Math.max(0.35, Math.min(0.7, hit.match)),
|
||||
raw: {
|
||||
seed_artist: seed.artist,
|
||||
seed_title: seed.title,
|
||||
seed_plays: seed.plays,
|
||||
lastfm_match: hit.match,
|
||||
},
|
||||
});
|
||||
if (ok) {
|
||||
inserted++;
|
||||
takenFromSeed++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { considered, inserted };
|
||||
}
|
||||
}
|
||||
@@ -519,6 +519,20 @@ export class EnrichmentService {
|
||||
console.warn('[Enrich] Discogs image failed:', (err as Error).message);
|
||||
}
|
||||
|
||||
// 6. Deezer (by name, no auth). The five sources above cover almost nothing
|
||||
// in this library: Fanart needs a key the worker does not have, TheAudioDB
|
||||
// and Discogs 404 on most names, Wikidata needs an MBID that 638 of 734
|
||||
// artists lack, and Last.fm stopped serving real artist photos.
|
||||
try {
|
||||
const deezerImage = await this.deezer.searchArtistImage(artistName);
|
||||
if (deezerImage) {
|
||||
await this.updateArtistImage(artistId, deezerImage);
|
||||
return deezerImage;
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('[Enrich] Deezer image failed:', (err as Error).message);
|
||||
}
|
||||
|
||||
// NOTE: Wikimedia Commons "by name" was previously the last fallback, but a
|
||||
// blind File-namespace text search ("<name> artist") routinely returns the
|
||||
// wrong image entirely (e.g. an unrelated person who shares the name). It has
|
||||
|
||||
+54
-2
@@ -1,6 +1,6 @@
|
||||
import { Worker, Job } from 'bullmq';
|
||||
import { connection, QUEUE_NAME, queue } from './queue.js';
|
||||
import { MetadataRefreshJob, AudioAnalysisJob, AudioAnalysisSweepJob, LibraryScanJob, ArtistSimilarityJob, ArtistImageJob, AlbumCoverJob, ReprocessArtistsJob, AcquisitionJob } from './types.js';
|
||||
import { MetadataRefreshJob, AudioAnalysisJob, AudioAnalysisSweepJob, LibraryScanJob, ArtistSimilarityJob, ArtistImageJob, AlbumCoverJob, ReprocessArtistsJob, AcquisitionJob, DiscoverySourceJob } from './types.js';
|
||||
import { Pool } from 'pg';
|
||||
import { ScannerService } from './scanner.service.js';
|
||||
import { IntegrityService } from './integrity.service.js';
|
||||
@@ -9,6 +9,7 @@ import { AudioFeaturesService } from './audio-features.service.js';
|
||||
import { CleanupSweepService } from './cleanup.service.js';
|
||||
import { reprocessArtists } from './reprocess-artists.service.js';
|
||||
import { AcquisitionService } from './acquisition.service.js';
|
||||
import { DiscoverySourcesService } from './discovery-sources.service.js';
|
||||
import { AUDIO_ANALYSIS_JOB_OPTIONS, AUDIO_ANALYSIS_VERSION, audioAnalysisJobId } from './audio-analysis.js';
|
||||
|
||||
// Cron for the periodic integrity sweep (default: daily at 03:00). Configurable
|
||||
@@ -22,6 +23,12 @@ const CLEANUP_SWEEP_CRON = process.env.CLEANUP_SWEEP_CRON || '0 */6 * * *';
|
||||
// 24h must transition to RESOLVED so returning users start fresh sessions.
|
||||
const VIBE_REAP_CRON = process.env.VIBE_REAP_CRON || '0 * * * *';
|
||||
const PROBATION_SWEEP_CRON = process.env.PROBATION_SWEEP_CRON || '15 * * * *';
|
||||
// Candidate generation runs daily, not hourly: both sources are seeded from
|
||||
// play counts, which barely move in an hour, and every extra run is external
|
||||
// API traffic that returns the same rows. New releases are checked at a
|
||||
// different hour from similarity so the two never contend for the proxy.
|
||||
const NEW_RELEASE_CRON = process.env.NEW_RELEASE_CRON || '30 5 * * *';
|
||||
const RECOMMENDATION_CRON = process.env.RECOMMENDATION_CRON || '30 6 * * *';
|
||||
// A small daily backfill is intentionally bounded. It refreshes stale v1
|
||||
// measurements over time without turning a worker restart into a library-wide
|
||||
// ffmpeg/Essentia batch.
|
||||
@@ -74,6 +81,7 @@ async function initWorker() {
|
||||
|
||||
const scannerService = new ScannerService(pgPool, queue);
|
||||
const acquisitionService = new AcquisitionService(pgPool, scannerService);
|
||||
const discoverySources = new DiscoverySourcesService(pgPool);
|
||||
const enrichmentService = new EnrichmentService(pgPool);
|
||||
const audioFeaturesService = new AudioFeaturesService(pgPool);
|
||||
await audioFeaturesService.ensureSchema();
|
||||
@@ -293,6 +301,18 @@ async function initWorker() {
|
||||
if (result.status === 'failed') throw new Error(result.reason);
|
||||
return result;
|
||||
}
|
||||
case 'discover_new_releases': {
|
||||
const payload = job.data as DiscoverySourceJob;
|
||||
const result = await discoverySources.discoverNewReleases(payload.seeds, payload.perSeed);
|
||||
console.log(`[Discovery] New releases considered=${result.considered} inserted=${result.inserted}`);
|
||||
return result;
|
||||
}
|
||||
case 'discover_recommendations': {
|
||||
const payload = job.data as DiscoverySourceJob;
|
||||
const result = await discoverySources.discoverRecommendations(payload.seeds, payload.perSeed);
|
||||
console.log(`[Discovery] Recommendations considered=${result.considered} inserted=${result.inserted}`);
|
||||
return result;
|
||||
}
|
||||
case 'probation_sweep': {
|
||||
// Keep probation moving without exposing an operator-only HTTP endpoint
|
||||
// as the sole lifecycle driver. These conditions mirror DiscoveryService.
|
||||
@@ -311,8 +331,26 @@ async function initWorker() {
|
||||
AND (SELECT COUNT(*) FROM evidence e WHERE e.entity_type = 'track'
|
||||
AND e.entity_id = t.id AND e.signal = 'skip_quick') >= 3`
|
||||
);
|
||||
const result = { retained: retained.rowCount ?? 0, retired: retired.rowCount ?? 0 };
|
||||
// Per-source outcomes, so which strategy is worth its bandwidth is a
|
||||
// fact rather than a hunch. Logged every sweep because the interesting
|
||||
// number is the trend, and the counts are a single grouped scan.
|
||||
const bySource = await pgPool.query<{ source: string; probation: string; retained: string; retired: string }>(
|
||||
`SELECT dc.source,
|
||||
COUNT(*) FILTER (WHERE t.probation_status = 'probation')::text AS probation,
|
||||
COUNT(*) FILTER (WHERE t.probation_status = 'retained')::text AS retained,
|
||||
COUNT(*) FILTER (WHERE t.probation_status = 'retired')::text AS retired
|
||||
FROM discovery_candidates dc
|
||||
JOIN tracks t ON t.id = dc.acquired_track_id
|
||||
GROUP BY dc.source
|
||||
ORDER BY dc.source`
|
||||
);
|
||||
const result = {
|
||||
retained: retained.rowCount ?? 0,
|
||||
retired: retired.rowCount ?? 0,
|
||||
bySource: bySource.rows,
|
||||
};
|
||||
console.log(`[Probation] Sweep retained=${result.retained} retired=${result.retired}`);
|
||||
console.log(`[Probation] Lifetime by source: ${JSON.stringify(result.bySource)}`);
|
||||
return result;
|
||||
}
|
||||
default:
|
||||
@@ -363,6 +401,20 @@ async function initWorker() {
|
||||
);
|
||||
console.log(`[Probation] Sweep scheduled with cron: ${PROBATION_SWEEP_CRON}`);
|
||||
|
||||
await queue.upsertJobScheduler(
|
||||
'discover-new-releases',
|
||||
{ pattern: NEW_RELEASE_CRON },
|
||||
{ name: 'discover_new_releases', data: { reason: 'scheduled' } satisfies DiscoverySourceJob }
|
||||
);
|
||||
console.log(`[Discovery] New-release scan scheduled with cron: ${NEW_RELEASE_CRON}`);
|
||||
|
||||
await queue.upsertJobScheduler(
|
||||
'discover-recommendations',
|
||||
{ pattern: RECOMMENDATION_CRON },
|
||||
{ name: 'discover_recommendations', data: { reason: 'scheduled' } satisfies DiscoverySourceJob }
|
||||
);
|
||||
console.log(`[Discovery] Recommendation scan scheduled with cron: ${RECOMMENDATION_CRON}`);
|
||||
|
||||
await queue.upsertJobScheduler(
|
||||
'audio-analysis-sweep',
|
||||
{ pattern: AUDIO_ANALYSIS_SWEEP_CRON },
|
||||
|
||||
@@ -22,6 +22,16 @@ export interface DeezerAlbum {
|
||||
coverBig: string;
|
||||
}
|
||||
|
||||
/** An album in an artist's discography (only the fields we read). */
|
||||
export interface DeezerAlbumRelease {
|
||||
id: number;
|
||||
title: string;
|
||||
/** ISO date, YYYY-MM-DD. */
|
||||
releaseDate: string;
|
||||
/** 'album' | 'single' | 'ep' | 'compilation' as reported by Deezer. */
|
||||
recordType: string;
|
||||
}
|
||||
|
||||
interface DeezerSearchResult {
|
||||
artist?: { name?: string };
|
||||
title?: string;
|
||||
@@ -33,6 +43,10 @@ interface DeezerSearchResponse {
|
||||
data?: DeezerSearchResult[];
|
||||
}
|
||||
|
||||
/** Lowercase and strip diacritics, so a query for "Bjork" matches "Björk". */
|
||||
const foldName = (name: string) =>
|
||||
name.trim().toLowerCase().normalize('NFD').replace(/\p{Diacritic}/gu, '');
|
||||
|
||||
export class DeezerClient {
|
||||
private readonly baseUrl = 'https://api.deezer.com';
|
||||
private readonly userAgent: string;
|
||||
@@ -81,4 +95,100 @@ export class DeezerClient {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Artist photo (1000×1000 `picture_xl`) by name, or null.
|
||||
*
|
||||
* ponytail: exact case-insensitive name match only. Deezer's artist search is
|
||||
* fuzzy and happily returns a tribute band for a near miss, so a wrong photo
|
||||
* is worse than the placeholder. Loosen it only if real artists get skipped.
|
||||
*/
|
||||
async searchArtistImage(artist: string): Promise<string | null> {
|
||||
if (artist.trim() === '') return null;
|
||||
const qs = new URLSearchParams({ q: artist });
|
||||
try {
|
||||
const data = await requestJson<{ data?: { name?: string; picture_xl?: string; picture_big?: string }[] }>(
|
||||
`${this.baseUrl}/search/artist?${qs.toString()}`,
|
||||
{ userAgent: this.userAgent, minIntervalMs: this.minIntervalMs }
|
||||
);
|
||||
const want = foldName(artist);
|
||||
for (const a of data.data ?? []) {
|
||||
if (foldName(a.name ?? '') !== want) continue;
|
||||
// Deezer serves a blank grey tile for artists it has no photo for: the
|
||||
// path is either empty or the md5 of the empty string. Both are useless,
|
||||
// and the same name can appear twice with only the second one real.
|
||||
const url = a.picture_xl ?? a.picture_big ?? '';
|
||||
if (url && !url.includes('d41d8cd98f00b204e9800998ecf8427e') && !url.includes('/artist//')) return url;
|
||||
}
|
||||
return null;
|
||||
} catch (err) {
|
||||
console.warn('[Deezer] searchArtistImage failed:', (err as Error).message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Resolve an artist name to a Deezer artist id via exact fold-matched search. */
|
||||
private async resolveArtistId(artist: string): Promise<number | null> {
|
||||
if (artist.trim() === '') return null;
|
||||
const qs = new URLSearchParams({ q: artist });
|
||||
try {
|
||||
const data = await requestJson<{ data?: { id?: number; name?: string }[] }>(
|
||||
`${this.baseUrl}/search/artist?${qs.toString()}`,
|
||||
{ userAgent: this.userAgent, minIntervalMs: this.minIntervalMs }
|
||||
);
|
||||
const want = foldName(artist);
|
||||
for (const a of data.data ?? []) {
|
||||
if (foldName(a.name ?? '') === want && typeof a.id === 'number') return a.id;
|
||||
}
|
||||
return null;
|
||||
} catch (err) {
|
||||
console.warn('[Deezer] resolveArtistId failed:', (err as Error).message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Albums by `artist` released on/after `sinceIso` (YYYY-MM-DD), newest first.
|
||||
*
|
||||
* ponytail: reads only the first page (25 albums). Deezer returns albums
|
||||
* newest-first, so a release-date cutoff never needs page two unless an
|
||||
* artist dropped 25 albums inside the window. Paginate if that ever happens.
|
||||
*/
|
||||
async getArtistAlbumsSince(artist: string, sinceIso: string): Promise<DeezerAlbumRelease[]> {
|
||||
const artistId = await this.resolveArtistId(artist);
|
||||
if (artistId === null) return [];
|
||||
try {
|
||||
const data = await requestJson<{
|
||||
data?: { id?: number; title?: string; release_date?: string; record_type?: string }[];
|
||||
}>(`${this.baseUrl}/artist/${artistId}/albums?limit=25`, {
|
||||
userAgent: this.userAgent,
|
||||
minIntervalMs: this.minIntervalMs,
|
||||
});
|
||||
return (data.data ?? [])
|
||||
.filter((a) => typeof a.id === 'number' && a.title && a.release_date && a.release_date >= sinceIso)
|
||||
.map((a) => ({
|
||||
id: a.id as number,
|
||||
title: a.title as string,
|
||||
releaseDate: a.release_date as string,
|
||||
recordType: a.record_type ?? 'album',
|
||||
}));
|
||||
} catch (err) {
|
||||
console.warn('[Deezer] getArtistAlbumsSince failed:', (err as Error).message);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/** Track titles on a Deezer album, in tracklist order. */
|
||||
async getAlbumTracks(albumId: number): Promise<string[]> {
|
||||
try {
|
||||
const data = await requestJson<{ data?: { title?: string }[] }>(
|
||||
`${this.baseUrl}/album/${albumId}/tracks?limit=50`,
|
||||
{ userAgent: this.userAgent, minIntervalMs: this.minIntervalMs }
|
||||
);
|
||||
return (data.data ?? []).map((t) => t.title ?? '').filter((t) => t !== '');
|
||||
} catch (err) {
|
||||
console.warn('[Deezer] getAlbumTracks failed:', (err as Error).message);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,7 +35,7 @@ export { ITunesClient, upscaleITunesArtwork } from './itunes.client.js';
|
||||
export type { ITunesAlbum } from './itunes.client.js';
|
||||
|
||||
export { DeezerClient } from './deezer.client.js';
|
||||
export type { DeezerAlbum } from './deezer.client.js';
|
||||
export type { DeezerAlbum, DeezerAlbumRelease } from './deezer.client.js';
|
||||
|
||||
export { WikimediaClient } from './wikimedia.client.js';
|
||||
|
||||
|
||||
@@ -18,6 +18,13 @@ export interface ScanContext {
|
||||
sourceType?: 'MANUAL' | 'RECOMMENDATION';
|
||||
probationStatus?: 'probation' | 'retained' | 'retired';
|
||||
candidateId?: string;
|
||||
/**
|
||||
* Names to use when the file carries no title/artist tags. An acquired
|
||||
* download often has none, and the filename ("VTKqlmCpTmQ.mp3") plus
|
||||
* "Unknown Artist" is worse than the vetted candidate's own names.
|
||||
*/
|
||||
fallbackTitle?: string;
|
||||
fallbackArtist?: string;
|
||||
}
|
||||
|
||||
export interface ScanResult {
|
||||
@@ -31,7 +38,7 @@ export interface ScanResult {
|
||||
* in the title ("Song (feat. X)") are folded in either way. First artist is the
|
||||
* main artist; the rest are featured. See ./utils/artist-names for the rules.
|
||||
*/
|
||||
function parseArtistsFromMetadata(common: any): { main: string; featured: string[] } {
|
||||
function parseArtistsFromMetadata(common: any, fallbackArtist = 'Unknown Artist'): { main: string; featured: string[] } {
|
||||
const rawTitle = common.title || '';
|
||||
|
||||
// Structured array present: still split each entry (tags sometimes put a whole
|
||||
@@ -52,7 +59,7 @@ function parseArtistsFromMetadata(common: any): { main: string; featured: string
|
||||
}
|
||||
}
|
||||
|
||||
return parseArtists(common.artist || 'Unknown Artist', rawTitle);
|
||||
return parseArtists(common.artist || fallbackArtist, rawTitle);
|
||||
}
|
||||
|
||||
function hashFile(filePath: string): Promise<string> {
|
||||
@@ -136,8 +143,9 @@ export class ScannerService {
|
||||
const { common, format } = metadata;
|
||||
|
||||
// 1. Ensure Artist(s) exist.
|
||||
const trackTitle = common.title || path.basename(filePath);
|
||||
const { main: mainArtistRaw, featured: featuredArtistNames } = parseArtistsFromMetadata(common);
|
||||
const trackTitle = common.title || context.fallbackTitle || path.basename(filePath);
|
||||
const { main: mainArtistRaw, featured: featuredArtistNames } =
|
||||
parseArtistsFromMetadata(common, context.fallbackArtist || 'Unknown Artist');
|
||||
|
||||
const { id: artistId, name: resolvedArtist } = await this.resolveOrCreateArtist(mainArtistRaw);
|
||||
|
||||
@@ -205,8 +213,15 @@ export class ScannerService {
|
||||
mtime = EXTRACT(EPOCH FROM NOW()),
|
||||
-- Existing recommendation rows stay recommendations during every
|
||||
-- ordinary scan. This is load-bearing for probation and Vibe.
|
||||
-- A dislike is a listener verdict on a file that is still on disk,
|
||||
-- so finding that file again says nothing new: HIDDEN and DELETED
|
||||
-- survive a rescan. Without this every scan restored every disliked
|
||||
-- track to LIBRARY and the Vibe served it again.
|
||||
state = CASE WHEN tracks.source_type = 'RECOMMENDATION'
|
||||
THEN tracks.state ELSE EXCLUDED.state END,
|
||||
THEN tracks.state
|
||||
WHEN tracks.state IN ('HIDDEN', 'DELETED')
|
||||
THEN tracks.state
|
||||
ELSE EXCLUDED.state END,
|
||||
source_type = CASE WHEN tracks.source_type = 'RECOMMENDATION'
|
||||
THEN tracks.source_type ELSE $8::track_source_type END,
|
||||
probation_status = CASE WHEN tracks.source_type = 'RECOMMENDATION'
|
||||
@@ -237,6 +252,14 @@ export class ScannerService {
|
||||
);
|
||||
}
|
||||
|
||||
// 5. Put the same credit into the claim graph. Every Vibe generator but
|
||||
// the fallback reaches tracks through claim_fusion, and only MusicBrainz
|
||||
// was writing those edges — so a track MusicBrainz has never heard of was
|
||||
// invisible to the recommender no matter how well tagged the file was.
|
||||
// `tag` is the lowest-trust source in the spine, so a later MusicBrainz
|
||||
// claim about the same edge still outranks this one.
|
||||
await this.writeTagCredits(trackId, String(artistId), featuredIds.map(String));
|
||||
|
||||
console.log(`[Scanner] Successfully processed: ${trackTitle}`);
|
||||
|
||||
// Trigger external-API enrichment for this track + artist + album.
|
||||
@@ -300,6 +323,34 @@ export class ScannerService {
|
||||
return { id: String(inserted.rows[0].id), name: String(inserted.rows[0].name) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Record the file's own artist credits as claims. Confidence is 1.0 because
|
||||
* the tag says this without ambiguity; how much that is worth is decided by
|
||||
* the trust attached to the `tag` source, not here.
|
||||
*/
|
||||
private async writeTagCredits(trackId: string, artistId: string, featuredIds: string[]): Promise<void> {
|
||||
const credits: Array<[string, string]> = [
|
||||
[artistId, 'credited_main_on'],
|
||||
...featuredIds.map((id): [string, string] => [id, 'featured_on']),
|
||||
];
|
||||
for (const [objectId, predicate] of credits) {
|
||||
try {
|
||||
await this.pgClient.query(
|
||||
`INSERT INTO claims (
|
||||
subject_type, subject_id, predicate, object_type, object_id, source, confidence
|
||||
) VALUES ('track', $1::uuid, $2, 'artist', $3::uuid, 'tag', 1.0)
|
||||
ON CONFLICT (subject_type, subject_id, predicate, object_type, object_id, source, user_id)
|
||||
DO UPDATE SET last_reinforced_at = NOW()`,
|
||||
[trackId, predicate, objectId]
|
||||
);
|
||||
} catch (err) {
|
||||
// A missing edge costs this track its place in the graph; it must not
|
||||
// cost the whole scan the file.
|
||||
console.error(`[Scanner] Failed to write ${predicate} claim for track ${trackId}:`, err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async enqueueEnrichment(trackId: string, artistId: string, albumId: string) {
|
||||
const keep = {
|
||||
removeOnComplete: { age: 86400, count: 5000 },
|
||||
|
||||
+14
-1
@@ -58,6 +58,18 @@ export interface AcquisitionJob {
|
||||
candidateId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* External candidate generation. Two job names share this payload because the
|
||||
* knobs are the same shape; the strategies themselves stay separate.
|
||||
*/
|
||||
export interface DiscoverySourceJob {
|
||||
reason?: string;
|
||||
/** new_release: artists to check. similar_recommendation: seed tracks. */
|
||||
seeds?: number;
|
||||
/** Candidates to keep per album (new_release) or per seed track (similar). */
|
||||
perSeed?: number;
|
||||
}
|
||||
|
||||
export type JobPayload =
|
||||
| MetadataRefreshJob
|
||||
| ArtistSimilarityJob
|
||||
@@ -69,4 +81,5 @@ export type JobPayload =
|
||||
| LibraryScanJob
|
||||
| IntegritySweepJob
|
||||
| ReprocessArtistsJob
|
||||
| AcquisitionJob;
|
||||
| AcquisitionJob
|
||||
| DiscoverySourceJob;
|
||||
|
||||
Reference in New Issue
Block a user