The page backed WorkspaceLayout with local useState, so onLayoutChange
(fired on every resize) only updated ephemeral state — pane sizes reset
to the default split on reload. Back it with useDashboardState under
screens/jobs, like the other workspace routes, with a structural guard
that falls back to the default when a persisted layout's panel ids no
longer match PANEL_COMPONENTS.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Every verb ended in an unconditional res.json(), which throws
"Unexpected end of JSON input" on 201/204 responses that carry no body —
so a request that actually succeeded still surfaced as an error (e.g. a
sent chat message toasting a failure). Read text first and only parse
when non-empty, otherwise resolve undefined. Non-empty JSON is unchanged.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- render dashboard/uploads/rooms/chat/users/system from the section channel
- nav + view both default to the dashboard section
- register the view panel's zoom header; read the persisted zoom via useDashboardState
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- dashboard: connection health, live download/upload tallies, recent searches
- uploads: mirror of downloads (what peers pull from us), per-row cancel/clear
- rooms: join chat rooms, transcript + composer, available-room datalist
- chat: private 1:1 conversations with unread badges and self-aligned bubbles
- users: look up a peer's presence/profile and browse their shared folders
- system: server connection state with connect/disconnect and version info
- view header: +/- zoom controls persisted per panel
- search view: restyle history to the shared dark (zinc) theme
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- downloads: split into active/completed with a bulk toolbar (retry errored,
cancel all, remove completed) mirroring slskd's downloads page
- downloads: queued rows hover to show place-in-queue and click to force a refresh
- search results: add a per-folder "Download folder (N)" button alongside the
per-user download-all
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- add types for transfers, private conversations, users, rooms, and server state
- add formatClock / folderLabel / formatDuration helpers and the groupResponses shaper
- move the per-panel zoom key into the persisted screens/ namespace
- extract shared dark-theme Card / SubCard / RowList primitives used by the panels
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The in-flow music dock reserves its height whenever a track is loaded (current),
even paused — so after a queue finished (onEndOfQueue set playing=false but kept
the queue) the dock lingered, shifting the layout up with a faint idle bar. On
natural end-of-queue, close() the queue so the dock releases its space. The saved
now-playing snapshot is untouched, so reload still resumes.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- split into nav (vertical section menu) + view panels via soulseek:section channel
- search view: history list + input, opens results subpanel for a past search
- search results load stored responses (no re-run), session-cached, ranked
- remove client from effect/callback deps (useClient is a fresh object per render)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two panels (search + transfers) via WorkspaceView, coordinating over the
soulseek:refresh channel. Adds the page-title rule and documents the
route conventions in CLAUDE.md.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add officer-slskd, a singleton sidecar that reverse-proxies to a
self-hosted slskd (Soulseek) instance and reports its loopback port to
the API on connect. All slskd knowledge (URL + API key) lives in the
sidecar; the platform is a thin auth+forward proxy for /api/slskd/* and
holds no slskd credentials. Mirrors the officer-vault pattern.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Squash the incremental 0000–0004 migrations into one baseline generated from the
schema, which drizzle-kit confirms reproduces the current DB (26/26 tables, incl.
the hand-applied music_now_playing (user_id,device) PK and vault_tokens.client_id).
The Drizzle schema is the source of truth; this repo uses db:push, so a single
baseline is all that's needed to recreate the structure from scratch.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Reverts the video-download UI from the ephemeral side panel back to the modal
(VideoDownloadDialog) that predated it — the "download video" button opens the
modal again and downloads inline via /download-video, exactly as before. The
six wiring files had no non-download changes since the modal→panel conversion,
so they're restored verbatim from that commit's parent; the panel file is
removed. The inline ReClip routes it uses are untouched.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The crypto-shape question is answered (classic + v2 fields both present); drop
the diagnostic that logged the response field names.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Key music_now_playing on (user_id, device) instead of user_id alone so the
browser ('web') and phone ('' default) each keep their own resume snapshot
instead of sharing one row. Sidecar /now-playing threads device (?device=,
default '') through get/set/clearNowPlaying; the web client tags its calls
?device=web. Phone unchanged → '' bucket, inherits the existing row.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Vaultwarden (2025.12.0/f21a3ada) returns both classic (Key/PrivateKey/Kdf*) and
v2 crypto (UserDecryptionOptions.MasterPasswordUnlock, AccountKeys) synthesized
from the classic stored fields. session/login now returns the whole native
connect/token response minus the transport tokens (under `connectToken`),
alongside the spec's protectedUserKey/privateKey/kdf aliases, so the SDK gets
whatever unlock path it uses. Temp diagnostic logs the response field NAMES
(values redacted) to empirically confirm on a real login.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- token-store: shared "give me a valid Vaultwarden access token" (proactive
refresh) used by both the HTTP proxy and the WS; router refactored onto it.
- notifications WS: validates the platform session in `open` (deferred, owner
only), injects the stored Vaultwarden token into the upstream, and buffers
client frames during the async setup so the SignalR handshake isn't dropped.
The device connects with its platform JWT (?access_token=), never a vault one.
- lifecycle: logout drops the vault token set (keeps the protector); distress
(/auth/revoke) and panic wipe both token set and protector, forcing a
one-time master-password re-setup.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Implements the platform half of VAULT_AUTH_SPEC.md. /api/vault is now gated on
an owner platform session (userMiddleware, no bodyParser → streaming preserved)
and origin-scoped as before; the device holds no Vaultwarden token.
- POST /session/login {email, authHash, kdf, device*} → broker calls Vaultwarden
/identity/connect/token via the sidecar, stores the encrypted token set tied to
the owner, and returns {protectedUserKey, privateKey, kdf} (ciphertext to us).
- GET/PUT /unlock-key → store/release the Officer-app protector key (owner only).
- Catch-all proxy swaps the incoming platform JWT for the stored Vaultwarden
access token, proactively refreshes near expiry, and retries once on a 401 for
replayable requests. Bodies are never parsed.
client_id column added to vault_tokens (needed to refresh). Broker error text is
read across Vaultwarden's message/errorModel/error fields.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Foundation for the platform-brokered vault auth (VAULT_AUTH_SPEC.md). Two
owner-keyed tables: vault_tokens (the brokered Vaultwarden access/refresh set)
and vault_unlock_keys (the Officer-app protector key). All secret columns are
AES-256-GCM encrypted via a VAULT_STORE_KEY-derived key (crypto.ts, lazy-loaded
so the platform still boots without it); queries encrypt/decrypt transparently.
Migration SQL is applied via db:push/psql (schema is source of truth); the
generated files are left out to avoid the shared-journal coupling.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Scope OFFICER_VAULT_ORIGIN to ['/api/auth', '/api/vault'] instead of
'/api/vault' only. The OffVault app authenticates to the platform first
(/api/auth/signin) and only then reaches the vault proxy — same shape as the
music app's ['/api/auth', '/api/music']. Without /api/auth the signin was
rejected in originScopeMiddleware with "Origin not permitted".
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Transparent pass-through fronting a self-hosted Vaultwarden so the OffVault
(Bitwarden-SDK) app reaches it through the platform's per-app origin gate. True
out-of-process sidecar (officer-vault): it owns all Vaultwarden knowledge (URL,
paths, notifications WebSocket) on a random loopback port and registers via the
sidecar connector; the platform is a thin origin-gated forwarder that knows only
the sidecar's port. Never decrypts/parses/rewrites/logs bodies.
- sidecar/vault: HTTP + notifications-WS proxy to VAULTWARDEN_URL, /_health
- api/vault: sidecar-port discovery + thin forwarder + WS pipe + origin gate
- origin: OFFICER_VAULT_ORIGIN allow-listed, scoped to /api/vault
- mounted top-level (not protected) so the Bitwarden bearer token isn't 401'd
- protocol: vault:server event; ecosystem: officer-vault app
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
move video/audio downloads off the dedicated ReClip download lane onto the
generic script-job path:
- delete execute-download.ts, reclip-client.ts and the POST /jobs/download
endpoint; drop the 'download' mode from the pipeline_jobs enum (legacy rows
tolerated)
- execute-script.ts: strip the @@officer:progress@@ sentinel from the log,
emit progress events, and isolate viewer/log writes (safeEmit/safeLog) so a
broadcast or log throw can't wedge the stdout pump
- pipeline-job-manager.ts: persist latest progress; guard sendToViewer sends
- ScriptJobDetail: render the two progress bars; DownloadJobDetail kept for
legacy history rows
- TaskRunnerModal: ScriptRunner descends into the triggered directory
- VideoDownloadPanel: rewire startJob to the script-job path
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A failed download has no media file, so its description was lost — no way to tell
which video was missed. Now a failure writes a `<title>.txt` (or `<videoId>.txt`
when untitled) holding the URL + description, so every missed item leaves a
recoverable reference. Always written (even with an empty description — the URL is
the reference); skipped only on a deliberate Stop, not a genuine error.
Verified: successful item → "<media base>.txt" (description); failed item →
"<title>.txt" (url + description); spaces preserved in both.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The metadata phase already fetches ReClip's description (ReClip now forwards it in
/api/info). Carry it into the download phase and write it to a text file with the
same base name as the media — "Song Name.mp3" → "Song Name.txt".
reclipDownloadOne gains an onFilename callback that fires the moment the final
filename is known (before the file transfers), so the executor writes the sidecar
in parallel with the download stream, and the exact name guarantees they pair up.
Empty descriptions write nothing; the write is best-effort (never fails a download).
Verified: correct base name + .txt, exact content, and no sidecar for an empty
description.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Verified against live ReClip: POST /api/download with title:"" → a hash filename
(b5d04adc86.mp3); with title:"Me at the zoo" → "Me at the zoo.mp3". So ReClip
names the file from the title WE send (falling back to a hash) — it does not
self-name. The title is mandatory, which means a metadata pass is required.
Back to two phases:
1. metadata — fetch each item's /api/info (title + validity), keep survivors, skip
errors.
2. download — download each survivor passing its title, so files land with real
names; skip download errors.
Keeps the exact-urls[] input (Mix playlists can't drift) and the one-request-per-
item download. Progress is two counters again (Titles + Download); UI shows two
bars. ~2 requests/item is inherent to needing the title (per the user's call:
correctness over speed).
Verified two-phase filtering + title passthrough + skip-on-error with a mock.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A YouTube Mix/radio playlist (list=RD…) returns a different set of items on every
/api/playlist call (observed 779 / 1485 / 529 for the same URL). The job used to
re-expand the playlist server-side, so it would download a different list than the
count shown on the decision screen.
The panel now passes the already-expanded `urls[]` into the job, and the executor
uses them verbatim (falling back to expanding `url` only when no list is given).
The job downloads exactly what you decided on. Endpoint takes `urls[]` (stored as
inputs.urls) or `url`.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The job's two phases were a misread — the "count" phase is the client-side
playlist expansion (for the inline-vs-job decision, already done in the panel).
The job itself is just one download request per item.
Dropped the in-job metadata pass entirely:
- reclip-client: reclipDownloadOne no longer prefetches /api/info for a title —
ReClip names the file from the video title itself, so it's a single request
per item.
- execute-download: one phase — expand the playlist, then /api/download each url,
skip failures. Progress is a single { done, failed, total, current } counter
(no meta/dl split); ~2× faster and downloads start right after expansion.
- UI (DownloadJobDetail + panel JobView): one "Downloaded" bar instead of two.
Verified: every item is attempted directly (no /api/info gate), skip-on-error
counts correct.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Front half of the download-job feature.
Panel (VideoDownloadPanel): after Fetch expands a playlist and the count is known,
a decision screen — "Found N items" → pick Audio/Video + subfolder → "Download all
as a job" (POST /jobs/download), or "fetch inline to pick individually" (the
existing card grid). A single video still goes straight to the inline card. The
job phase shows live two-phase progress (polled from the job) + a "View in Jobs"
link; it notes the job runs server-side so closing the panel is fine, and it
refreshes the browser as each file lands.
/jobs (DownloadJobDetail + JobsPage dispatch): a `download` job renders a compact
two-phase readout — Metadata and Download bars (processed/total, found/skipped and
saved/failed) + the current item — polled from the job's progress, with a Stop.
Executor tweak: phase-1 meta.done now counts kept (not processed) so both phases
read the same `(done+failed)/total`.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Turns the downloader into a server-side job on the existing jobs spine (Postgres
persistence, live WS viewers + replay, abort, /jobs UI) — but with its own
executor and its own lane, since it's deterministic scripting, not an agent, and
a multi-hour playlist mustn't block agentic jobs.
- reclip-client.ts (new, shared): reclipInfo / reclipPlaylist / reclipDownloadOne
(single download → streams the file to a dir, abort-aware). Extracted so both
the file-browser endpoints and the job executor use one client.
- execute-download.ts (new): the two-phase executor —
phase 1 metadata (expand playlist, fetch each info, keep survivors, skip
errors), phase 2 download (each survivor in the chosen format; skip download
errors). Emits a compact `download:progress` snapshot (counters, not per-item
events — playlists are thousands of items). Throws on abort / fatal.
- job manager: `download` mode dispatch → executeDownload; persists
download:progress; adds execution LANES (download vs default) so the two run
independently and each serializes on its own; promoteNext fills both lanes.
- POST /api/tasks/jobs/download { url, format, dir, root?, label? } — enqueues a
download job (own lane, no capability task needed; traversal-guarded target).
- schema: `download` added to the mode enum (drizzle text-enum — no DB migration);
getPendingJobs() query for lane filling.
Verified the executor with a mocked ReClip client: two-phase filtering, skip-on-
error counts, and abort-throws all correct.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Reworks the download model around a format choice (video = ReClip video, audio =
audioOnly) instead of a global "extract audio" toggle:
- Input phase is just URL + Fetch (dropped the audio checkbox).
- Each card tracks video + audio download state independently and shows a button
per format (Video only when ReClip reports video formats — audio-only sources
get just Audio).
- Playlist top actions: "All video" (when any item has video), "All audio", and
"Select".
- Select mode: each card shows Video/Audio checkboxes (pick one, both, or none per
item); a "Start download (N)" button runs the chosen set and shows a fake
progress bar (N/M count, no real byte progress). Bulk "All video/audio" reuse
the same progress bar.
- Single item = large card with the two format buttons, no bulk row.
VideoInfo gains `formats` so the client can tell video-capable from audio-only.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Uses the resizable panel + container queries instead of a cramped vertical list:
- Single item → one large card (big 16:9 thumbnail, title, uploader·duration,
full-width download button).
- Playlist → a responsive grid (2 cols, 3 at @520px, 4 at @760px of panel width)
of compact cells, each with a position number badge and an overlay download/
status chip.
Loading skeletons, error, and per-cell download states (download → downloading →
saving → saved / retry) carry over.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The music dock was position:fixed, overlaying the bottom of the content, so the
nav dock needed a pile of hacks to dodge it: a hand-measured MUSIC_DOCK_HEIGHT
(72) constant, a presence flag, a translateY lift, and a matching hover-threshold
lift (via a ref) so it wouldn't slide away under the cursor. Fragile the moment
the music dock's height changed.
Now the dock is an in-flow bottom bar that reserves its own height:
- DashboardLayout is a flex column: the content region (flex-1, min-h-0) shrinks
when the music dock takes its space; MusicPlayerHost renders an in-flow bar
(shrink-0) instead of a fixed overlay.
- The nav Dock is absolute within the content region and measures its reveal/hide
boundary from that region's bottom edge (a boundaryRef) — so it always sits just
above whatever's at the bottom, music dock or not, with zero knowledge of it.
- Deleted MUSIC_DOCK_HEIGHT, musicDockPresent, the lift, and the transform hack.
Bonus: content at the very bottom is no longer hidden under the fixed dock.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The dock's X only reset local player state; the server's now-playing snapshot
survived, so a reload restored the dock. Closing now also DELETEs /music/now-playing
before clearing the queue.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two problems behind "deleted/changed the folder image but it still shows" (on
both web and app):
1. Client caching. Cover/meta/etc. were served with an ETag(=v) but NO
Cache-Control, so browsers served them straight from the heuristic cache at
the same URL — a changed cover kept showing the old image. And the platform
proxy never forwarded If-None-Match, so the ETag revalidation couldn't work
anyway. Now the sidecar sends `Cache-Control: no-cache` on every version-
stamped artifact (cover/meta/poster/lyrics/image) + the manifest, and the
proxy forwards If-None-Match → the client revalidates every time and gets a
cheap 304 when unchanged, a fresh 200 when v changed.
2. Removed covers lingered. On rebuild the indexer only (over)wrote cover.jpg
when a source cover existed — a deleted or now-undecodable source left the
old cover.jpg in the cache (still served, still cover:true). Now it clears
cover.jpg first and regenerates only if there's a valid source.
Verified live against a booted sidecar: cover carries no-cache + ETag, a
matching If-None-Match → 304, and deleting the source folder.jpg drops the
cached cover (404, meta.cover cleared, manifest cover:false).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Moves the video-download UI from a modal into the same ephemeral side-panel slot
the file viewer uses (double-click a video), per request. It's driven by the
`download` search param (the target folder) + `downloadRoot`, exactly like the
existing view/play/chat ephemeral panels.
- layouts: singleDownloadLayout (files-download panel).
- VideoDownloadPanel (new, apps/FileBrowser): self-contained — reads the target
folder/root from the params, does its own useFilesAPI, prefetch + per-entry /
download-all flow (unchanged from the dialog), and bumps the shared
`files:refresh-signal` so the browser re-lists when a file lands. Header shows
a "Saving to <folder>" hint.
- useFileViewerPanels: register files-download (param → layout → panel + close),
add download/downloadRoot to the on-refresh cleanup keys.
- useFileBrowserApp: replace the showVideoDownload modal state with
openVideoDownload(), which sets the download param for the current folder+root.
- Toolbar + FileViewContainer trigger openVideoDownload(); drop the modal mount
and delete VideoDownloadDialog.tsx.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Reworks the download-video dialog into a prefetch-then-download flow, mirroring
ReClip's own web UI. Still a pure proxy to ReClip (its yt-dlp); no downloader
logic moves to the platform.
Server (thin ReClip proxies alongside /download-video):
- POST /file-browser/video-info { url } → ReClip /api/info → { title, thumbnail, duration, uploader }
- POST /file-browser/video-playlist { url } → ReClip /api/playlist → { urls }
Both return { error } inline (200) so the client can render failures per-card.
UI (VideoDownloadDialog, now self-contained; useFileBrowserApp exposes `files`
and drops the old single-shot state/handler):
- Paste a URL → Fetch. A playlist URL (list=) expands via /video-playlist, then
each entry's /video-info is prefetched sequentially (ReClip does yt-dlp per
video), rendering a card (thumbnail, title, uploader, duration) that fills in
progressively.
- Per-entry Download, plus Download All when there's more than one; per-card
status (downloading → saving → saved / retry-on-error) via the existing
background job + poll.
- Playlists get an optional "subfolder you name" field (ReClip's /api/playlist
carries no playlist title); blank = current folder.
- Quality is always best (matches the mobile Share flow — no picker); the
audio-only toggle applies to the whole batch.
Verified ReClip's contract live: /api/info returns the metadata fields, and
/api/playlist returns { urls } (17 entries in ~1.2s).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The MusicBrowser and MusicDetail panels each kept their OWN local `manifest`
(and folder-listing) state, fetched in their own effects. The reindex (↻) button
lives in MusicBrowser and only refreshed its own copy — MusicDetail (the right
panel showing the tracklist/grid) never heard about it, so newly-indexed content
only appeared after navigating (which re-ran its effects).
Add a shared `music:resync` panel channel: when a reindex completes, MusicBrowser
bumps it to a fresh nonce, and both panels re-run their manifest/libraries/folder
fetches. In MusicDetail the fresh manifest object identity also re-triggers the
[cwd, manifest] listing effect, so the open album's meta/tracklist and any folder
grid refresh in place — no navigation required. Drops the browser's now-redundant
hand-refresh of its own state.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The AudioContext autoplay-unlock listener (pointerdown → ctx.resume()) was
persistent, so every click anywhere resumed the context — including after a
deliberate pause (pause = ctx.suspend()). Result: pause, click back on the page,
and the track resumed from its position while React's `playing` stayed false, so
the button showed "paused" and it took two clicks to actually stop it.
The gesture only needs to unlock the context ONCE; sticky activation lets
engine.play() resume it thereafter. Make the listener { once: true } so it can't
fight an intentional pause.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The engine is created once (mount effect), so its onIndex/onEndOfQueue callbacks
captured the first-render syncIndex/setPlaying. useGlobal's setData reads the
`data` from the render that created the setter when given a functional updater —
that's the INITIAL empty state. So when a track ended and the engine called
syncIndex(i) → setState(s => ({...s, index:i})), `s` was {queue:[], index:0}:
the queue got wiped, `current` went undefined, playback stopped and the dock
vanished. Manual track selection was unaffected because playQueue passes a plain
object (no stale `data` read) — which is why it seemed to work.
Route the two engine-invoked setters through refs kept current each render, so a
natural advance mirrors into the live state instead of the stale initial one.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The player used a single <audio> element and advanced by swapping .src, which
re-fetches + re-buffers the next file — an audible gap between tracks. For a
continuous DJ mix (silence already trimmed at the edges) that's the whole
problem. Replace the <audio> element with a Web Audio engine that decodes each
track to an AudioBuffer and schedules the NEXT track's source to start() at the
exact AudioContext time the current track ends → sample-accurate, zero gap on
auto-advance.
- gapless-engine.ts (new): AudioContext + gain, LRU-capped decoded-buffer cache,
fetch-whole-file → decodeAudioData, boundary scheduling, seek/skip/play-pause
(pause = ctx.suspend so the clock + scheduled next freeze together), a
generation counter to invalidate stale onended/async, and a gesture unlock for
autoplay policy. Callbacks: onIndex/onTime/onEndOfQueue/onLoadingChange.
- MusicPlayerHost.tsx: drives the engine instead of an <audio> element. React
keeps the queue/index (useMusicPlayer); user actions (new album, jump, prev/
next) command the engine, and the engine's own natural advance mirrors back
via syncIndex WITHOUT restarting playback (that's what keeps the seam gapless).
Preserves restore/persist/heartbeat/album-nav/volume/heart; adds a decode
spinner on the play button (startup/skip has fetch+decode latency by nature).
- useMusicPlayer.ts: syncIndex() — set index without touching `playing`.
Trade-off (chosen deliberately over near-gapless preloading): true gapless
needs the whole next file decoded to PCM ahead of time (~200MB per 10-min
track), so the buffer cache is capped at 3. Verified the scheduler state
machine with a mocked AudioContext: next track scheduled at the current's exact
end sample, advance/promote/seek/skip/end-of-queue all correct.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Favorites / now-playing / playlists were being served by the platform router
straight from Postgres, which violated the intended split (officer = auth +
proxy; officer-music = the whole /api/music/* contract). Move them into the
sidecar so it owns ALL music endpoints — library AND user state.
- sidecar (index.ts): serves /favorites, /now-playing, /playlists[/:id[/items]]
backed by Postgres (the same officerdb queries other sidecars already use).
The authenticated user id arrives in X-Officer-User; the sidecar is loopback-
only, so it trusts the header (401 if absent). HTTP-contract comment updated.
- platform (router.ts): reduced to a pure auth+proxy catch-all — it now injects
X-Officer-User from the authenticated ctx user and forwards the request body
(favorites/now-playing/playlist writes carry JSON) in addition to Range/query.
No schema change — the tables are unchanged, only WHERE they're served moves.
Verified live: booted the real sidecar against the live DB and exercised the
endpoints with the X-Officer-User header — 401-without-header, favorites round-
trip, and full playlist CRUD with ownership scoping all pass.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Mirrors the existing per-user music state (favorites / now-playing): Postgres
tables + query layer + REST endpoints on the music router, all scoped to the
caller's user id and served directly by the platform (not proxied to the
user-stateless sidecar). Item `key`s are opaque track homePaths, same contract
as favorites — the server never interprets them.
- schema: music_playlists (name unique per user) + music_playlist_items
(0-based position, dupes allowed, cascade delete).
- queries: get/create/rename/delete playlists; add (append) / set (replace,
covers reorder+remove) items; every mutation ownership-checked; item ops in a
transaction that also bumps the playlist updatedAt.
- router (/api/music, before the catch-all proxy): GET/POST /playlists,
GET/PATCH/DELETE /playlists/:id, POST/PUT /playlists/:id/items. 409 on
name collision, 404 on a playlist that isn't the caller's.
- migration 0002 (also backfills music_favorites/now_playing into the snapshot,
which were originally applied via a direct db:push). Applied to the DB.
Verified end-to-end against the live DB: create, dupes, ordering, append,
replace/reorder, ownership scoping, rename, counts, delete — all pass.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The watcher's localized reindex was non-recursive and only ever touched the
exact folders it was handed, so renaming/moving a *container* dir (an artist
folder, or a whole subtree) left the manifest inconsistent: the renamed-in
album children were never indexed, and the old path's album keys lingered
forever (only the nightly full reindex healed it).
reindexFolder now takes { recursive } and, for any folder, also prunes any
manifest descendant whose top-level child dir has vanished from disk. The
watcher enqueues the containing folder SHALLOW (rebuild-this-album + prune a
renamed/removed-away child) and the event path itself RECURSIVE (index a
new/renamed-in container's album children). A recursive reindex of a plain
file or leaf album stays a cheap no-op / single rebuild, and a shallow reindex
of a big container (e.g. Albums) is just a readdir + key scan — no deep walk.
Verified in an isolated temp library: a case-only artist rename prunes the old
keys and indexes the 3 renamed albums while leaving unrelated albums alone; a
leaf file edit rebuilds just that album; deleting an album folder prunes it.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The default POST /reindex is incremental (skips unchanged albums by version
stamp), so it can't backfill a meta-format change like the new track `disc`
field. Add ?full=1 to run reindexFull() instead — a from-scratch rebuild into a
fresh slot, atomically swapped in (safe, never disrupts the live index). Same
30-min per-request timeout applies. The nightly 3am run still does this
automatically; this is the on-demand trigger.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
ffprobe surfaces ID3 TPOS as the `disc` tag ("n" or "n/total"); parse the leading
number → IndexTrack.disc?: number in meta.json (undefined when absent/unparseable),
alongside `track`. Verified on Pink Floyd - The Wall (Disc 1 → 1, Disc 2 → 2).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Analog of the videos feature — exposes per-folder images (excluding the album
cover files) under /api/music so the app can show an "Images" section.
Indexer: IMAGE_EXT + isImage; collect loose images (minus COVER_FILES); add them
to the version signature (img: parts, so v changes when one is added/removed —
no re-sync hack needed, source files); write meta.images: [{ file }] and a
manifest images count. Folders with only images now index too.
Serving: GET /image?path=<rel>&file=<img> streams the ORIGINAL image bytes from
the library folder (image/*, ETag=<v>, 304), basename + prefix-guarded against
traversal. Documented in the contract comment.
Verified: meta.images lists loose images with the cover excluded, manifest count
correct, /image path resolution + traversal guard. App side (music-api, Images
section) is the app's to add. No CACHE_VERSION bump — the sig change reindexes
exactly the folders that have images.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Rework resolveTrackLyrics so a track's OWN embedded lyrics win, and SYNCED always
beats plain:
synced-embedded > synced-sidecar(.lrc) > plain-embedded > plain-sidecar(.txt)
So an mp3 whose synced lyrics are embedded (as LRC text in the USLT/`lyrics` tag)
becomes the canonical source over leftover .lrc/.txt sidecars — but a track with
only a *plain* embed still serves a synced .lrc until it's re-embedded (no silent
downgrade). Binary SYLT frames aren't readable here, so sync must be stored as
LRC text in the text lyrics tag (verified it round-trips + is detected as lrc).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Display-only panel at the top of the Get Lyrics run-task form (single file only):
- Backend: GET /file-browser/audio-meta?path= — ffprobe format tags + duration,
plus a second probe for embedded lyrics (USLT/SYLT/lyrics* keys, case-insensitive).
Returns { title, artist, duration, hasLyrics }; tolerant of missing tags/probe
failures.
- Client: files.audioMeta(path) + AudioMeta type in useFilesAPI.
- TaskRunnerModal: prefetch audioMeta for get-lyrics single-file runs (bypasses
the hasTrackPickers early-return, error-tolerant), and render AudioMetaPanel
above TaskInputForm — title/artist + a muted length + Lyrics: Yes/No chip,
filename fallback. Directories + other tasks unaffected (no panel, no probing).
Verified ffprobe logic on a real embedded-lyrics file. tsgo clean; formatted.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Extract walk()'s per-folder logic into a reusable buildFolder(), so a single
album/artist can be (re)indexed on its own. Add:
- reindexFolder(rel): rebuild just one folder's live cache entry + patch the
manifest (prune if the folder vanished). Its mtime-based signature means any
change — add / re-tag / delete — is picked up, and irrelevant touches no-op.
- withIndexLock: serialize ALL index mutations (full / incremental / localized)
so a localized reindex can never race the full reindex's atomic swap.
- watcher.ts: fs.watch(~/Music, { recursive }) → log every change → debounce 3s →
reindexFolder the affected folder(s). Verified: Bun's recursive watch fires
through the ~/Music symlink and on new-dir creation (so a new album's contents
are read by reindexFolder); inotify max_user_watches (~483k) >> folder count.
Started at boot, stopped on shutdown. The nightly full reindex backstops any
change that lands after a new folder's debounce.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Self-scheduling timer in the sidecar (fresh setTimeout each night, so it always
fires at 3am local regardless of drift) runs reindexFull() — builds into a fresh
slot and swaps atomically only on success, never disrupting the live index.
Started at boot, cleared on shutdown. Logs the next scheduled time + each run.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Foundation for a safe from-scratch reindex. The live cache path is now a SYMLINK
to a slot dir; readers + incremental writes follow it.
- ensureCacheSetup() (run at sidecar boot): makes `cache` a symlink to a slot,
migrating an existing real cache dir once (a fast rename, not a copy).
- runBuild(outRoot, prev): the build now writes to a given root and returns the
manifest without touching live serving state.
- reindexNow(): incremental, in-place live build (manual + localized updates).
- reindexFull(): builds a complete index into a FRESH slot without touching the
live one, then activateSlot() swaps the symlink atomically (rename-over) ONLY
on success — a failed rebuild leaves the live index untouched; old slots pruned.
walk() takes the output root. Verified end-to-end: fresh setup, full build +
swap + prune, incremental-through-symlink, and the one-time real-dir migration
(content preserved). Restart officer-music to apply (triggers the migration).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>