28 Commits

Author SHA1 Message Date
kami 57120b872d fix(sync): let a phone pause the desktop, and let a Vibe follow the audio
Typecheck / typecheck (backend) (push) Has been cancelled
Typecheck / typecheck (workers) (push) Has been cancelled
Six bugs in the single transport and in how a browser registers as a device,
then the feature the fourth one was hiding.

A watching device keeps its own audio paused, so every control that read
`isPlaying` from the store drew a Play button while the desktop played — and
sent `play` when it was pressed. The transport now carries one `playing` value:
the remote state while presses are being forwarded, the local one otherwise.

Two tabs of one browser shared a stored device id, which made them one device
that ran every command twice and played two copies of the audio. A device id is
now held by whichever stream has it open: registration refuses to hand back a
busy id, and each tab keeps its own in `sessionStorage`.

A device that was only showing what another one plays still pointed an audio
element at the stream, downloading tracks it would never play. It now loads
nothing while the audio is elsewhere, and reloads the moment it comes back.

Commands were accepted for an owner with no stream to receive them on, so a
killed tab answered a press with a success it never got. Ownership outlives a
closed stream deliberately; delivery does not.

The event stream never called `reply.hijack()`, leaving Fastify waiting on a
handler that resolves with nothing.

And the Vibe: `setQueue` is an ownership handoff, so a snapshot from another
device dropped the advance handler that asks the server for the next track. A
session moved to a phone became a fixed list of the hundred tracks that
happened to be synced. Vibe control now follows the audio — the snapshot
carries the session id, the device losing the audio stops driving, and the one
gaining it resumes the durable session and takes over replanning.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 15:04:03 +04:00
kami a75d36b821 fix(db): repair the two things the scanner quietly undid
Both migrations exist because of scanner behaviour fixed in this branch,
and both are idempotent.

31 tracks the listener had disliked were sitting back in LIBRARY,
restored by successive library scans, and were being served again. They
return to HIDDEN unless the dislike was deliberately reverted.

740 tracks had artist tags but no edge in the claim graph, which put
them out of reach of every Vibe generator but the fallback. Their
credits are written from track_artists, the same shape the scanner now
produces at scan time.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KENqSChfyqWnor6ud2WWH6
2026-08-10 14:00:32 +04:00
kami 5a019bd35c perf(vibe): stop building a plasma and reading the library on a phone
Two things made the Vibe page crawl on mobile.

The ambient aura pushed three counter-rotating layers through an SVG
turbulence and displacement pass whose noise field was animated in SMIL.
SVG filters rasterise on the CPU, so that regenerated the whole field
every frame, under a blur, across a 420x380 element. Below the
breakpoint the filter is no longer mounted and the layers churn behind a
plain blur instead. A phone gets the motion, not the noise pass.

The start screen read all 5,000 tracks to show fifty of them. The sample
is now drawn in the database, which keeps Surprise me uniform over the
whole library while sending only what is on screen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KENqSChfyqWnor6ud2WWH6
2026-08-10 14:00:19 +04:00
kami 0a01085ed0 fix(discovery): give every track a place in the graph, not just the ones MusicBrainz knows
track_artists_v2 is a view over claim_fusion, and only the MusicBrainz
spine wrote credited_main_on claims. The scanner filled track_artists
and left the spine alone, so the tag-derived backfill migration from
July was the only thing that ever put local credits in the graph.

Every track scanned since was therefore invisible to all but one Vibe
generator — 740 of them, including 20 of the 26 acquired
recommendations, all of which had perfectly good artist tags sitting
unused. Obscure music is exactly what MusicBrainz does not know and
exactly what a self-hosted library is full of.

The scanner now writes those credits itself. Source is `tag`, the
lowest trust in the spine, so a later MusicBrainz claim about the same
edge still outranks it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KENqSChfyqWnor6ud2WWH6
2026-08-10 14:00:04 +04:00
kami d28a92803b feat(vibe): offer the four fifths of the library it never reached
Of 5,365 tracks, 692 had ever been played and 1,027 had ever entered a
plan. Four things kept the Vibe circling the same music.

Comfort took each top artist's two highest-scoring tracks in a fixed
order, so it proposed the same eighteen candidates in every session for
as long as the listener's top artists held still. It now samples two
from each artist's best ten.

A track the listener has never played now scores as if it carried real
relevance. Without that, library-fallback — the one generator that
samples the whole library evenly — entered at 0.05 and lost every slot.

A quarter of every plan is now reserved for unheard tracks outright,
taken from the tail so the track about to play keeps its shaping.

Hard exclusion reached back forty completed plays, so a track heard last
night was a candidate again tonight. It now covers everything heard in
the last fourteen days, counted from a durable serve or a play and never
from a plan item, since most planned tracks are replaced unheard. A cap
keeps the window from ever holding back more than 40% of the library.

Library-fallback also offers probation recommendations now. It is the
only generator that does not walk the graph, so it is how an acquired
recommendation gets heard before anything has enriched it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KENqSChfyqWnor6ud2WWH6
2026-08-10 13:59:44 +04:00
kami 60085c1d72 fix(playback): stop a moment without signal from pausing the phone
A phone changing cell or locking its screen drops the push stream for a
few seconds while its audio keeps playing. The server released session
ownership the instant that stream closed, and the phone read its own
reconnect snapshot as another device taking over: it paused, and rewound
to whatever position it had last reported. Ownership now survives a
closed stream. A device that is really gone still loses the session, via
pagehide and via the sweep that frees an owner whose heartbeat stopped.

The client no longer treats an unowned session as an instruction to
stop. With audio loaded it claims the session back instead.

Two ways a phone could go quiet until the page was reloaded are also
gone: registration is retried rather than attempted once, and the push
stream reopens after an error status, which EventSource treats as final.
It also checks itself when the network or the tab comes back.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KENqSChfyqWnor6ud2WWH6
2026-08-10 13:59:29 +04:00
kami 5a73a6a6f3 fix(vibe): let a dislike outlive the session that heard it
Disliking a track in a Vibe wrote one row to the session ledger and
nothing else. The ledger only excludes a track from the session it was
recorded in, so the same track came back the next evening, and the one
after that. A dislike in a Vibe is the same verdict as a dislike
anywhere else, so it now takes the same path.

Two more things undid a dislike that did land. The library scan rewrote
every track's state from the file on disk, which restored every HIDDEN
track to LIBRARY on every scan; finding a file again says nothing about
whether the listener wants to hear it. And hiding only matched tracks in
LIBRARY, so a disliked probation recommendation stayed eligible.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KENqSChfyqWnor6ud2WWH6
2026-08-10 13:59:16 +04:00
kami 0749f6ad10 feat(playback): buffer the next track early, look further ahead less
The served Vibe preview was eight items deep, and every one of them cost
a track fetch on each advance while buying nothing but a longer Up next
list. Three is enough to show where the stream is going.

The audio prefetch was the opposite problem: it only began twenty
seconds before the end, so a phone that lost signal in that window
arrived at the handover with nothing buffered. It now starts fifteen
seconds into the current track, which gives the rest of the song to pull
the next one down. Buffering that early means a replan can change the
answer, so the idle element is re-pointed when it does.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-08 23:38:58 +04:00
kami 78f5feea11 fix(vibe): let a session survive a phone losing its connection
A locked screen drops the radio, changes network or dozes, and one
request fails. Both halves of an advance treated that as the session's
fault. The outbox kept the failed event at its head with the comment
that a later retry would pick it up, but nothing ever triggered one, so
it sat there while the caller was rejected. advanceVibe then cleared the
prefetched future and paused, destroying a plan that was still valid.

Transient failures now hold the outbox entry unsettled and resend the
same event id on a backoff, so no duplicate feedback reaches the
director. The retry around the serve sits on the serve alone: serving a
version is idempotent, while replaying the whole advance would report a
second outcome for a track heard once. Each wait ends early when the
browser says the network is back, which is the moment that matters when
a screen unlocks. A drop that outlives every retry leaves the future
intact to carry on from.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-08 23:28:50 +04:00
kami 93c737ee49 fix(playback): stop the device holding the audio rewinding itself
The owner reports its position every ten seconds, and the server
publishes a snapshot to every device on anything that touches the
session. The owner was writing those snapshots back into its own store,
so each one dragged playback back to its last report. Reloading another
tab did it too, since registering a device publishes a snapshot: the
phone jumped back to whatever position was on record, which right after
a track change is zero, and the song started over.

A device that already owns the session now ignores incoming snapshots
and stays the authority on its own position. Snapshots still apply when
ownership moves to or away from this device, which is what that branch
was written for. Moving audio between devices also flushes the real
position first, rather than handing over a ten-second-old one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-08 23:28:33 +04:00
kami dea08f9c47 feat(mobile): install Muzick to the home screen, and get there quickly
Adds the manifest, icons and service worker that make the app
installable, and offers it as a toast once Chrome says it qualifies.
Declining snoozes the offer for a month; installing ends it.

A waiting service worker never activates on its own. Reloading the page
under a listener to swap in a new build would cut the song they are in
the middle of, so updates land on the next cold start instead. Audio is
kept out of the cache entirely: range requests and multi-megabyte bodies
do not belong in a shell cache. Artwork is cached, and the SPA
navigation fallback denies /api so it cannot swallow the event stream.

Installed on Android the app paints edge to edge, so the transport pads
itself past the gesture bar. MediaSession gains setPositionState, which
is what gives the notification shade a seek bar that moves.

Three things kept the bundle from ever being compressed, each hiding the
next: the nginx image ships with gzip off, gzip_proxied defaults to off
and skips anything carrying a Via header, and gzip_http_version defaults
to 1.1 while the host proxy speaks 1.0. With those fixed and the pages
split per route, the first load goes from 555KB to 60KB of app code plus
a vendor chunk that survives redeploys.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-08 23:28:19 +04:00
kami a88ae62db1 docs: name the compose command that actually works here
The v1 `docker-compose` binary on the deploy host fails with
KeyError: 'ContainerConfig' when it recreates a container, so every
instruction pointed at a command that cannot finish a deploy.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-08 23:27:59 +04:00
kami 85ca9cf543 fix(ui): one transport for every control, and a queue panel that fits
The queue panel drove the playback store directly, so its buttons played
locally while another device held the audio. Both control sets now go
through one transport that forwards a press when the audio is elsewhere.

The panel's artwork is capped against viewport height too: at full width
on a phone the square alone pushed Up Next off the screen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-08 20:56:30 +04:00
kami 4ead344aec feat(playback): move a session between devices
One device holds the audio; the rest watch the same session over an
event stream and act as remotes. Picking a device hands the audio over
at the position the previous one reported, and that device stops.

Also centre the command palette with margins instead of a translate:
animate-rise sets its own transform and dropped the offset on mobile.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-08 19:12:26 +04:00
kami bfe22745bc feat(discovery): acquire recommendations that keep their names
Acquisition ran yt-dlp without --embed-metadata, so every download
arrived untagged. The scanner then stored the video id as the title and
"Unknown Artist" as the artist, the vetted-candidate tag check rejected
the mismatch, and all 18 acquired tracks were hidden and retired.

- Pass --embed-metadata so downloads carry real tags.
- Let a scan take fallback title/artist from the candidate, for sources
  that still ship untagged files.
- Install Deno alongside yt-dlp: YouTube guards some formats with a JS
  challenge yt-dlp must execute, and no other runtime is enabled.
- Dedupe candidates by artist and title. The (source, external_id) key
  misses the same song reaching us under two Deezer release ids.

Also carries the in-flight discovery work this builds on: the
Recommendations page replacing Discover, the discovery source service,
and the acquisition spec tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-08 18:43:58 +04:00
kami d371bd97f3 fix(images): allow the CDNs artwork actually lives on, add Deezer artist photos
Typecheck / typecheck (backend) (push) Has been cancelled
Typecheck / typecheck (workers) (push) Has been cancelled
The image proxy allowlist covered none of the hosts enrichment writes, so every
album cover answered 403: 305 covers sit on coverartarchive.org (which 302s to
archive.org and on to an ia*.us.archive.org node), 126 on mzstatic, 115 on
dzcdn, 26 on discogs.

Artist images were empty for a different reason — no working source. 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 photos. Deezer needs no auth and its host is already allowlisted.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 00:23:03 +04:00
kami 93619824d8 feat(ui): rework every page for comfort, and make a seeded Vibe play its seed
Typecheck / typecheck (backend) (push) Has been cancelled
Typecheck / typecheck (workers) (push) Has been cancelled
A pass over the whole app against the Ethos laws, then a focused pass on
Vibe with the operator reviewing each change.

Across the app:
- The player bar restores the last track it played, paused at zero, so a
  fresh tab opens on where the listener was instead of "nothing playing".
- Track titles link to their album, matching the artist links beside them.
  Playback stays on the artwork tile; a title that played was the surprise.
- The search field is bg-bg2. Tailwind cannot alpha-modify these var()
  colors, so bg-surface0/70 emitted no rule at all and the input fell back
  to the UA's white.
- Row hover is light falling off to the right, not a flat slab.
- The artwork placeholder can drop its note glyph, so TrackRow no longer
  layers a play icon on top of one.

Vibe:
- A seeded Vibe plays its seed first. The seed sits in front of the durable
  plan without being part of it, so the first advance consumes it locally
  and reports no plan feedback.
- The queue drops a second recording of a song it already holds — same
  title, different track id, which id-based dedup let through.
- Up next is read from the queue rather than the plan preview, since the
  seed is not a plan item.
- The header carries the live profile (energy, discovery, goal) and both
  verbs. Keep is gone: letting a track finish already reports `completed`,
  which the director weighs the same.
- The aura is one warm diffuse blob in the page background, warm-hued only
  and quieter on mobile.
- Compact artwork is 32px. It was h-8 w-8, which this remapped spacing
  scale renders as 64px inside a 44px row, and that overflow was the
  "stacked" look.

Verified by render at 1440x900 and 390x844, no horizontal overflow at
either. 26 frontend and 122 backend tests pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 23:53:46 +04:00
kami a7d126787f feat(vibe): weigh local calendar context in session planning
The director had no idea what hour or season a session started in, so a
22:00 weeknight and a Sunday morning drew from the same pool. The client
now sends localHour, weekday, month and an optional timeZone; the route
validates and bounds all four, the coordinator threads them through, and
the generators use them as scoring signals.

Also exposes GET /library/stats, which the Home page counts read.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 23:53:22 +04:00
kami 8f33744f8c fix(playback): remove the stutter and gap between tracks
The isPlaying subscriber is unselected. Every store write during Vibe's
feedback/replan handshake called play() on the element that had just ended.
That replayed its final buffered milliseconds until the next source loaded.
Gate that subscriber and the seek subscriber on an actual value change, and
never resume a finished element.

Then close the gap the handshake leaves behind. The engine now drives two
<audio> elements. The next track buffers into the idle one 20s early. The
handover starts before `ended`, so the round-trip happens under the outgoing
tail. With a crossfade, that tail fades out under the new track. With crossfade
off, the new track waits in silence and starts the moment the tail ends.

Both are configurable under Settings -> Transitions and persist to
localStorage. Preload is on and crossfade is 400ms by default.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 21:18:28 +04:00
kami ce16bb94f8 fix(vibe): let ambient profile aura cross track rows
Typecheck / typecheck (backend) (push) Has been cancelled
Typecheck / typecheck (workers) (push) Has been cancelled
2026-08-03 14:38:27 +04:00
kami c0a3eeee4b style(vibe): enlarge interactive profile aura
Typecheck / typecheck (backend) (push) Has been cancelled
Typecheck / typecheck (workers) (push) Has been cancelled
2026-08-03 14:33:48 +04:00
kami 68bf299296 style(vibe): center the ambient profile aura
Typecheck / typecheck (backend) (push) Has been cancelled
Typecheck / typecheck (workers) (push) Has been cancelled
2026-08-03 14:30:53 +04:00
kami 03746a3899 refactor(vibe): make profile aura ambient
Typecheck / typecheck (backend) (push) Has been cancelled
Typecheck / typecheck (workers) (push) Has been cancelled
2026-08-03 14:27:28 +04:00
kami 092a43b981 feat(vibe): visualize the listening profile
Typecheck / typecheck (backend) (push) Has been cancelled
Typecheck / typecheck (workers) (push) Has been cancelled
2026-08-03 14:22:58 +04:00
kami ec141b32f4 feat(vibe): explain recommendation signals
Typecheck / typecheck (backend) (push) Has been cancelled
Typecheck / typecheck (workers) (push) Has been cancelled
2026-08-03 14:16:26 +04:00
kami 1f44af5893 fix(vibe): keep playback advancing with full plans
Typecheck / typecheck (backend) (push) Has been cancelled
Typecheck / typecheck (workers) (push) Has been cancelled
2026-08-03 14:12:22 +04:00
kami 6b40cf7a9c fix(vibe): bind next plan item query correctly
Typecheck / typecheck (backend) (push) Has been cancelled
Typecheck / typecheck (workers) (push) Has been cancelled
2026-08-03 14:06:45 +04:00
kami dfb8ed6f28 Merge pull request 'Feat/vibe v2 session director' (#4) from feat/vibe-v2-session-director into master
Typecheck / typecheck (backend) (push) Has been cancelled
Typecheck / typecheck (workers) (push) Has been cancelled
Reviewed-on: #4
2026-08-03 11:23:09 +02:00
97 changed files with 10237 additions and 814 deletions
+3 -1
View File
@@ -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.
+48
View File
@@ -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);
}
+144
View File
@@ -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');
`,
},
];
+9 -1
View File
@@ -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
);
+48
View File
@@ -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
*/
+9 -2
View File
@@ -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 });
});
+12
View File
@@ -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 {
+11
View File
@@ -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();
+235
View File
@@ -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({
+20 -2
View File
@@ -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):
+35 -5
View File
@@ -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();
+129 -17
View File
@@ -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 };
+10 -7
View File
@@ -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]
);
+85 -16
View File
@@ -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,
];
+39 -5
View File
@@ -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
View File
@@ -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
View File
@@ -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>
+62
View File
@@ -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;
+4109 -41
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -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

+6
View File
@@ -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>
);
}
+7 -5
View File
@@ -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>
);
}
+135 -2
View File
@@ -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')!;
+431 -61
View File
@@ -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 />
</>
);
};
+5 -3
View File
@@ -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">
+83
View File
@@ -0,0 +1,83 @@
import { useEffect, useRef, useState } from 'react';
import { Laptop, MonitorSpeaker, Smartphone } from 'lucide-react';
import { usePlaybackSyncContext } from './PlaybackSyncProvider';
/**
* Device menu. Picking a device moves the audio there: the chosen browser
* resumes the same track at the same position, and the one that had it stops.
*/
export function DevicePicker() {
const { devices, deviceId, isOwner, hasRemoteOwner, transferTo } = usePlaybackSyncContext();
const [open, setOpen] = useState(false);
const wrapper = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) return;
const onPointerDown = (event: MouseEvent) => {
if (!wrapper.current?.contains(event.target as Node)) setOpen(false);
};
document.addEventListener('mousedown', onPointerDown);
return () => document.removeEventListener('mousedown', onPointerDown);
}, [open]);
const online = devices.filter((device) => device.online || device.id === deviceId);
// Nothing to switch between, so the control would only take up room.
if (online.length < 2 && !hasRemoteOwner) return null;
const owner = devices.find((device) => device.isOwner) ?? null;
return (
<div className="relative" ref={wrapper}>
<button
onClick={() => setOpen((value) => !value)}
className={`rounded-md p-2 transition-colors ${
hasRemoteOwner ? 'bg-accent/20 text-accent' : 'text-muted hover:bg-surface0 hover:text-text'
}`}
aria-label="Playback device"
aria-expanded={open}
title={hasRemoteOwner && owner ? `Playing on ${owner.name}` : 'Playing on this device'}
>
<MonitorSpeaker size={18} />
</button>
{open && (
<div
role="menu"
className="absolute bottom-full right-0 z-50 mb-2 w-60 overflow-hidden rounded-lg border border-border bg-bg1 shadow-2xl shadow-black/60"
>
<div className="border-b border-border px-3 py-2 text-xs font-semibold uppercase tracking-wide text-muted">
Play on
</div>
{online.map((device) => {
const isThis = device.id === deviceId;
return (
<button
key={device.id}
role="menuitem"
onClick={() => {
setOpen(false);
if (!device.isOwner) void transferTo(device.id);
}}
className={`flex w-full items-center gap-3 px-3 py-2.5 text-left text-sm transition-colors hover:bg-surface0 ${
device.isOwner ? 'text-accent' : 'text-text'
}`}
>
{/Android|iPhone|iPad/i.test(device.name) ? <Smartphone size={16} /> : <Laptop size={16} />}
<span className="min-w-0 flex-1 truncate">
{device.name}
{isThis && <span className="text-muted"> · this device</span>}
</span>
{device.isOwner && <span className="text-xs text-accent">playing</span>}
</button>
);
})}
{!isOwner && !hasRemoteOwner && (
<div className="border-t border-border px-3 py-2 text-xs text-muted">
Press play to take over the session.
</div>
)}
</div>
)}
</div>
);
}
+1 -1
View File
@@ -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' },
],
},
{
+16 -9
View File
@@ -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>
+8 -5
View File
@@ -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 45 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>
);
+20 -20
View File
@@ -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>
);
}
+21 -12
View File
@@ -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);
}
+6 -3
View File
@@ -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>
)}
+41 -30
View File
@@ -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>
);
+41
View File
@@ -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();
});
});
+127
View File
@@ -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>
);
}
+21 -8
View File
@@ -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({
+8 -6
View File
@@ -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}`}
+6 -6
View File
@@ -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" />
+107
View File
@@ -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?.();
};
}, []);
}
+27
View File
@@ -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;
}
+215
View File
@@ -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();
});
});
+317
View File
@@ -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,
};
}
+86
View File
@@ -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);
});
});
+64
View File
@@ -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]);
}
+183
View File
@@ -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;
+7 -2
View File
@@ -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);
}
/**
+83
View File
@@ -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 */ }
}
+8 -8
View File
@@ -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>
+10 -6
View File
@@ -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>
+11 -17
View File
@@ -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">
+8 -5
View File
@@ -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>
-125
View File
@@ -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>
);
}
+72 -53
View File
@@ -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
View File
@@ -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
View File
@@ -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>
)}
+16 -18
View File
@@ -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>
+239
View File
@@ -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>
);
}
+7 -12
View File
@@ -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} />
))}
+53 -17
View File
@@ -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>
+5 -2
View File
@@ -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
View File
@@ -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 &amp; skip</strong> what you don&apos;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 &ldquo;{currentTrack.title}&rdquo;</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 &ldquo;{currentTrack.title}&rdquo;</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 &amp; 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
View File
@@ -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' {
+41
View File
@@ -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;
},
};
+4 -2
View File
@@ -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;
},
+16
View File
@@ -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';
+204
View File
@@ -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();
};
},
};
+6
View File
@@ -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}`);
+22 -2
View File
@@ -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;
},
+202 -18
View File
@@ -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();
+238 -10
View File
@@ -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();
+54 -4
View File
@@ -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);
});
+5
View File
@@ -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 }),
}));
+1
View File
@@ -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
View File
@@ -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
View File
@@ -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 ./
+40
View File
@@ -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'
);
});
+83 -15
View File
@@ -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) {
+288
View File
@@ -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 };
}
}
+14
View File
@@ -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
View File
@@ -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 },
+110
View File
@@ -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 [];
}
}
}
+1 -1
View File
@@ -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';
+56 -5
View File
@@ -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
View File
@@ -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;