27 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 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 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 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 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 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 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 9eba247a58 refactor(vibe): simplify durable session flow
Typecheck / typecheck (backend) (push) Has been cancelled
Typecheck / typecheck (workers) (push) Has been cancelled
Typecheck / typecheck (backend) (pull_request) Has been cancelled
Typecheck / typecheck (workers) (pull_request) Has been cancelled
2026-08-03 12:44:08 +04:00
kami 57df1cfe9f feat(vibe): reconcile mutable session previews in playback 2026-08-01 23:47:11 +04:00
kami 4c48d11e9d feat: enhance discovery, vibe sessions, and library enrichment
Typecheck / typecheck (backend) (push) Has been cancelled
Typecheck / typecheck (workers) (push) Has been cancelled
2026-08-01 14:40:48 +04:00
kami 512c3fdb90 chore: refresh frontend package-lock.json
Regenerated by a clean npm install; no dependency ranges in package.json
were changed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 23:58:45 +04:00
kami 543031e48c fix: track a playback currentIndex so prev and repeat-all work
next() did `queue.slice(idx + 1)`, so the current track was always
queue[0]. prev()'s `idx > 0` guard could therefore never pass after an
auto-advance — Previous did nothing, ever — and repeat: 'all' jumped to
queue[0], which is the track that just finished, looping the last track of
an album instead of restarting it.

Replaced with a currentIndex cursor; the queue is no longer trimmed behind
the playhead. The old slice did serve a purpose — bounding Vibe-prefetch
growth — so that is preserved as a MAX_HISTORY = 50 cap that drops the
oldest entries and re-bases the index, rather than dropped outright.
setQueue/playTrack/setCurrentTrack recompute the cursor, next()/prev() fall
back to findIndex if it drifts, and shuffle now picks by index so the
cursor stays valid.

Consumer audit: NowPlayingPanel and Vibe.tsx already derived position via
findIndex and needed no change. TrackRow.handlePlay did
`setQueue(queue.slice(index))`, which re-broke prev at the point of click
even with the store fixed; it now passes the intact queue.

This commit also includes a pre-existing uncommitted fix from the working
tree (not authored by Claude): the end-of-queue auto-resume loop, which
stops playback at the end of the queue instead of restarting. It is correct
and independent of the cursor bug, and is preserved verbatim here.

REVIEW-2026-07-30.md finding 7.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 23:50:08 +04:00
kami 755de34501 fix: inject the admin key so the admin UI stops returning 403
The entire admin surface of the SPA had been dead since auth landed
(5ed8d9e / 3bc9f2d). nginx.conf.template injected only
`Authorization: Bearer ${MUZICK_API_KEY}` for all of /api, docker-compose
passed only MUZICK_API_KEY to the frontend container, and app.ts requires
token === adminKey for /api/admin/*. The two keys differ, and
services/api.ts sets no headers of its own.

All 10 admin call sites were affected: the Jobs page polled 403s every
3s/5s forever and rendered a blank Overview with no error state, and every
Settings library action (Scan, Reindex, Reprocess artists, Re-enrich,
Duplicates merge) silently failed.

Three changes, each necessary:
  - a `location /api/admin/` block injecting the admin key
  - the Dockerfile envsubst list widened to include MUZICK_ADMIN_KEY,
    without which the new variable substitutes to empty and the header
    becomes a bare "Bearer"
  - MUZICK_ADMIN_KEY passed to the frontend service in docker-compose

nginx selects the longest matching prefix regardless of block order;
verified empirically in a throwaway nginx:stable-alpine running the real
envsubst output against a stub that echoes $http_authorization:

    /api/admin/queue-stats       -> Bearer ADMINKEY456
    /api/admin/duplicates/merge  -> Bearer ADMINKEY456
    /api/tracks                  -> Bearer APIKEY123
    /api/health                  -> Bearer APIKEY123

All 10 call sites use /admin/... under the axios /api baseURL and none
request bare /api/admin without a trailing slash.

Also gives the Jobs page an error state: a banner that names a 401/403 as a
missing or wrong admin key, a Retry button, "Loading queue stats..." in
place of a blank Overview, and refetchInterval returning false once the
query has errored so it stops hammering a failing endpoint.

Deletes frontend/nginx.conf — unreferenced by the Dockerfile (confirmed by
grep) and the insecure variant of the template.

Worth noting and not addressed here: the outer LAN-only proxy already
forges credentials for everything reaching /api, so this key split buys no
real security while having cost the whole admin surface. Collapsing to one
key would be simpler.

REVIEW-2026-07-30.md finding 2.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 23:50:08 +04:00
kami c41316ee99 fix vibe engine audit findings: pg.Pool, plan replan, dead exclusions, legacy engine removal
Backend:
- app.ts: switch shared pg.Client to pg.Pool with per-transaction clients (#205)
- v2.routes.ts: replace plan instead of appending on replan, fixing self-duplication (#206)
- session-director: populate recentExclusions, per-candidate ranking, batch repetition checks (#209/#211/#213/#215 + minor)
- db.service.ts: claim-fusion watermark, legacy recommendation_batch engine removed (#216/#219/#232)
- app.ts: drop test enqueue-job endpoint (#234)

Frontend:
- AudioEngine/Vibe/usePlaybackStore: dedupe completed feedback, gate feedback to vibe sessions, End Vibe stops playback, Keep toast, shuffle played-set (#207/#236/#237/#238/#239/#240)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 13:22:06 +04:00
kami 3bc9f2d303 muzick: nginx proxy Bearer auth, restart policies, env subst 2026-07-14 15:51:10 +04:00
kami 737bf19fd1 initial state: muzick music player + recommendation engine 2026-07-14 01:35:52 +04:00