Commit Graph
485 Commits
Author SHA1 Message Date
pastilhasandClaude Opus 4.8 0ef8e9fbd2 web /music: sample-accurate gapless playback (Web Audio)
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>
2026-07-28 12:18:09 +00:00
pastilhasandClaude Opus 4.8 e2c9905885 move all music per-user state into the sidecar; platform = auth + proxy only
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>
2026-07-28 01:05:52 +00:00
pastilhasandClaude Opus 4.8 7a966c780e add per-user named music playlists (server-side infra)
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>
2026-07-28 00:37:29 +00:00
pastilhasandClaude Opus 4.8 e559c6c884 make the localized music reindex recursive + prune stale descendants
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>
2026-07-27 23:12:15 +00:00
pastilhasandClaude Opus 4.8 8980cfe717 music: POST /reindex?full=1 — on-demand full staged rebuild
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>
2026-07-27 22:38:18 +00:00
pastilhasandClaude Opus 4.8 40080f7f1e music indexer: add disc number to each track (from ID3 TPOS)
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>
2026-07-27 21:49:30 +00:00
pastilhasandClaude Opus 4.8 0e711d281c music: index + serve loose folder images (band photos, booklet scans)
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>
2026-07-27 19:25:19 +00:00
pastilhasandClaude Opus 4.8 c99b9c6d77 music: embedded lyrics are canonical (quality-aware precedence)
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>
2026-07-27 17:34:58 +00:00
pastilhasandClaude Opus 4.8 11263ce120 task modal: audio info panel for Get Lyrics (title/artist/length/lyrics)
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>
2026-07-27 17:07:18 +00:00
pastilhasandClaude Opus 4.8 55bf3aecca music: recursive watcher → localized reindex on any change
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>
2026-07-27 16:41:16 +00:00
pastilhasandClaude Opus 4.8 1f21768c4c music: nightly 3am full reindex (staged + atomic swap)
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>
2026-07-27 16:30:42 +00:00
pastilhasandClaude Opus 4.8 b1d1c91968 music: staging-slot cache with atomic symlink swap for full reindex
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>
2026-07-27 16:29:41 +00:00
pastilhasandClaude Opus 4.8 b84f0b0b18 music: /manifest is a pure read — never triggers a (re)build
Reading the manifest (which every app refresh hits) used to call
ensureIndexFresh(), kicking off a debounced rebuild — and after the CACHE_VERSION
bump that meant a plain refresh could launch a full library rebuild. Make reads
side-effect-free: /manifest now just returns the last completed index. Builds are
explicit only (POST /reindex or the SSE stream); pick up disk changes by
reindexing.

Removes the now-unused ensureIndexFresh + lastBuildFinishedAt, and drops
/manifest from the 30-min per-request timeout extension (both hops) since it no
longer blocks on a build.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-27 16:07:39 +00:00
pastilhasandClaude Opus 4.8 48d24544f3 task modal: add multi-line 'text' input type (textarea)
`type: string` renders a single-line <input>; the new `type: text` renders a
resizable multi-line <textarea> (6 rows) — for pasted multi-line values like
lyrics. Checked before options/default so `text` always means free-form text.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-27 15:54:27 +00:00
pastilhasandClaude Opus 4.8 3de22a3166 rescan: refetch inactive item lists too (works from any page)
invalidateQueries only refetches mounted queries, so a rescan pressed anywhere
but the Tasks/Skills/… page just marked those lists stale — the new item wasn't
picked up until you opened that page. Pass refetchType: 'all' so every item
cache refreshes immediately, wherever the button is pressed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-27 15:47:46 +00:00
pastilhasandClaude Opus 4.8 de0642766c music: 30-min per-request idle timeout for reindex/manifest (from-scratch builds)
A from-scratch rebuild holds the triggering request open for many minutes with
no bytes flowing, so both server hops' idle timeouts would drop it. Bun caps the
server-level idleTimeout at 255s, but server.timeout(req, seconds) allows more
per-request:
- sidecar Bun.serve: extend /reindex, /manifest, /reindex/stream to 1800s.
- platform proxy (music router): same, via the Bun server exposed as Hono's env.

Baseline idleTimeouts unchanged (sidecar 255, main server 60). The build always
completed in the background regardless; this keeps the request itself alive.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-27 15:23:27 +00:00
pastilhasandClaude Opus 4.8 43135865e2 music indexer: 3s progress heartbeat log during a build
A long full rebuild logged only "resync started" then nothing until "done".
Add a console heartbeat (throttled to 3s, piggybacked on emitProgress) showing
folders/built/skipped/tracks/videos/posters/lyrics counts + the current path, so
progress is visible in the pm2 logs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-27 15:15:35 +00:00
pastilhasandClaude Opus 4.8 219f93a831 music sidecar: raise Bun.serve idleTimeout (blocking reindex outran 10s default)
The one-time v1→v2 full rebuild (and long range/SSE reads) take far longer than
Bun.serve's default 10s request idle-timeout, which dropped the triggering
request mid-flight ("request timed out after 10 seconds"). Set idleTimeout: 255
(Bun's max). A build that still outruns it completes in the background regardless
— a closed socket doesn't cancel the in-flight build promise.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-27 15:08:26 +00:00
pastilhasandClaude Opus 4.8 f408e4dd6f music indexer: pick video poster via ffmpeg thumbnail filter (dodge black frames)
The fixed ~10% frame grab could land on a black/near-black frame for clips that
fade in from black (or on a title card). Keep the ~10% seek to skip intros, but
select the frame with `thumbnail=n=300` — ffmpeg picks the most representative
frame from the batch, which avoids uniform/black frames. Verified on a
fade-from-black video: luma ~122 (vs 0 at t=0), and it dodges the fade even when
seeking from the start.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-27 15:05:14 +00:00
pastilhasandClaude Opus 4.8 4d3c17b2df music indexer: cache-format version → full rebuild for posters/lyrics migration
Videos/tracks indexed before posters+lyrics existed were skipped by the per-album
`v` check (unchanged v → skip), so their posters/ and lyrics/ never generated —
a plain reindex couldn't fix it.

Add CACHE_VERSION (now 2). The `v` skip is only trusted when the on-disk
manifest is already at the current format; an older version forces a one-time
FULL rebuild that regenerates every album (incl. the new posters/lyrics), then
writes version:2 so subsequent builds skip normally. Verified: old-cache rebuild
regenerates the poster, next build skips (no loop).

Deploy = restart officer-music, then one reindex (a full rebuild, slower than an
incremental — one time only).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-27 14:59:08 +00:00
pastilhasandClaude Opus 4.8 b3a4da4b97 music indexer: index track lyrics (.lrc/.txt sidecars + embedded)
Each track's lyrics are resolved and cached at cache/<rel>/lyrics/<file>.<lrc|txt>,
with the format recorded as `lyrics: 'lrc'|'txt'` on the meta.tracks entry.

Precedence: external "<base>.lrc" > external "<base>.txt" > embedded tag
(lyrics / lyrics-<lang> / unsyncedlyrics — ffprobe now reads all format tags).
Content that contains [mm:ss] lines is stored as lrc even from a .txt/embedded
source. Only track-matching sidecars affect the version signature (a stray
notes.txt is ignored). Lyrics dir is wiped+regenerated per rebuild; new
`lyricsIndexed` counter.

Served by GET /api/music/lyrics?path=<rel>&file=<track> (text/plain +
X-Lyrics-Format header, ETag=<v>, 304, 404 when none).

Verified end-to-end: external .lrc wins over embedded; embedded → plain txt;
unmatched .txt ignored. MUSIC_API.md documents the field + endpoint.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-27 14:51:58 +00:00
pastilhasandClaude Opus 4.8 10d7b6a425 music indexer: generate video poster thumbnails
Each indexed video gets a compressed poster (a frame grab ~10% in, capped at
30s, scaled ≤600px q5 like covers), written to cache/<rel>/posters/<file>.jpg
and recorded as `poster` on the meta.videos entry. The posters dir is wiped and
regenerated on each rebuild so orphans (removed videos) don't linger. New
`postersSaved` status counter.

Served by a new sidecar route GET /api/music/poster?path=<rel>&file=<video>
(image/jpeg, ETag=<v>, 304, 404 when none) — path-safe via basename.

Verified end-to-end on a real .mp4: video-only album → manifest {tracks:0,
videos:1}, meta.poster set, 14 KB poster on disk. MUSIC_API.md documents the
poster field + endpoint + postersSaved.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-27 14:38:15 +00:00
pastilhasandClaude Opus 4.8 d5b3dbb473 music indexer: index videos (concerts/clips) in artist/album dirs
Videos (all phone-compatible .mp4, plus common containers) that live in an
artist or album folder are now indexed alongside audio:

- Move 'mp4' out of AUDIO_EXT into a new VIDEO_EXT (mp4/m4v/mkv/mov/webm/avi) —
  it was wrongly treated as an audio track before.
- ffprobeVideo captures file/title/durationSec/width/height per video.
- meta.json gains an optional `videos: IndexVideo[]`; a folder with only videos
  now still gets a meta.json. Manifest entries gain optional `videos: N`.
- Video files join the album version signature (changes bump `v` for resync).
- New `videosIndexed` status counter + resync-log line.

Location is inherent in the folder rel (always an artist/album dir), so no
extra location field is needed. MUSIC_API.md documents the videos field +
manifest count. No poster/thumbnail generation yet (folder cover is reused).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-27 14:16:45 +00:00
pastilhasandClaude Opus 4.8 d317bfc983 dock: lift the nav-dock hide threshold when the music dock is present
The nav dock rides MUSIC_DOCK_HEIGHT higher while the music dock is up, but the
hide threshold was still measured from the bottom — so hovering the raised dock
read as past HIDE_THRESHOLD and it slid away under the cursor. Lift hideAt (and
the magnify gate) by MUSIC_DOCK_HEIGHT; the reveal trigger stays at the edge.
Also read musicDockPresent via a ref so the once-registered mousemove handler
reacts to music starting/stopping.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-27 13:56:51 +00:00
pastilhasandClaude Opus 4.8 ee6c7b0fdd music (web): Favorites view (inspired by the app's FavoritesScreen)
A heart button in the left panel header opens a Favorites view in the right
panel (coordinated via a new music:favorites panel channel). Grouped
Artists / Albums / Tracks, each row: cover thumb (indexed cover, icon fallback)
+ title/subtitle + a heart to un-favorite. Click an album/artist to navigate
the library there; click a track to play it in album context. Navigating
anywhere closes the view (cwd-change effect). Empty state prompts to heart
something.

useMusicFavorites now also returns the grouped `favorites`; shared gains the
channel + parseAlbumName.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-27 13:40:33 +00:00
pastilhasandClaude Opus 4.8 f887fb4679 music (web): cursor-pointer on dock controls (Tailwind v4 no longer defaults it)
Add cursor-pointer to the player dock's buttons — track-info, prev/next (with
disabled:cursor-default), play/pause, mute, and close — and to MusicHeart (so
every heart across the player/tracklists gets it). The volume slider and SeekBar
already had it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-27 12:58:59 +00:00
pastilhasandClaude Opus 4.8 3f2efbf852 music (web): click the dock track info to open its album in /music
The bottom player dock's cover + title/artist is now a button — clicking it sets
the music:cwd channel to the playing album and navigates to /music, landing on
that album's tracklist (current track highlighted). Uses the global panel
channel + react-router navigate.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-27 12:55:53 +00:00
pastilhasandClaude Opus 4.8 5b17121de8 music (web): browser filter + reindex; richer album tracklist
Left panel (MusicBrowser):
- Fuzzy filter input (case-insensitive subsequence match) over the current
  library/folder list; resets on navigation, with a clear button + "No matches".
- Reindex button (spins while running) → POST /music/reindex, then refreshes the
  manifest and current listing.

Right panel album view (MusicDetail):
- Current track clearly highlighted: primary tint background + a Volume2 marker
  replacing the track number + medium weight.
- Every track shows its artist (falling back to album artist) under the title,
  and its duration on the right. Web Track type gains albumArtist + durationSec;
  fmtDuration/fuzzyMatch added to shared.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-27 12:52:34 +00:00
pastilhasandClaude Opus 4.8 3924155bfb music (web): larger, spaced browser rows with cover thumbs; hide dotfiles
Left panel (MusicBrowser) polish + a shared fix:
- Larger row text (text-base; "Music" heading text-lg).
- More space between rows (gap-1.5 + py-2).
- Leading thumbnail is the folder's indexed cover (its folder.jpg/cover.jpg,
  server-compressed) with a Folder/Library icon fallback when there's none or
  the image fails.
- Hide hidden files/folders (dotfiles like .claude) in the listing — applied to
  MusicBrowser and the MusicDetail libraries/grid so neither panel shows them.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-27 12:38:09 +00:00
pastilhasandClaude Opus 4.8 0995aaa8af music (web): resume currently-playing on reload/return
The web player kept no durable state, so a reload (and a return to /music,
whose cwd resets) lost your place. Wire it to the platform's per-user
/api/music/now-playing (same endpoints the app uses):

- MusicPlayerHost persists a snapshot (track + position) on play/pause + track
  change + a 10s heartbeat (position read via ref so the heartbeat stays live).
- On first load with an empty queue it restores that snapshot: rebuilds the
  album queue (sortTracks), loads it PAUSED (browsers block autoplay on reload),
  and seeks to the saved position once metadata is in. A restore guard stops the
  load from clobbering the saved position with 0.
- MusicDetail auto-opens the currently-playing album once on mount (when it has
  no location yet), so /music lands on the track — without yanking you back
  after you navigate away.

Adds loadQueue() (paused) to useMusicPlayer + a NowPlaying type. tsgo clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-27 12:24:47 +00:00
pastilhasandClaude Opus 4.8 759dcb8b5b music (web): artist heart on non-discography artist pages too
The artist heart only rendered in the discography view (disco && !album), which
needs a _discography.md — so artists without one (plain grid of album folders)
had no heart. Add an artist header + heart to the grid view when on an artist
folder (crumbs>=2), matching the app's inArtist rule. favKey = artist music-rel.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-27 12:17:17 +00:00
pastilhasandClaude Opus 4.8 5af3119096 music (web): Favorites hearts in the /music player
Wires the web player to the platform's per-user /api/music/favorites (same
endpoints the app uses). New useMusicFavorites hook (react-query, shared
optimistic cache) + a reusable MusicHeart toggle, placed at:
- album header (album) + each track row (track, reveals on row hover, filled
  favorites stay shown)
- album cards in the artist/grid views (album)
- artist discography header (artist)
- the now-playing player bar (current track)

Track key = homePath "Music/<rel>/<file>"; album/artist keys = music-rel.
No dedicated Favorites browsing view yet (hearts only), matching the app.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-27 11:58:02 +00:00
pastilhasandClaude Opus 4.8 6fc34363c8 music (web): order album tracks by track number, not readdir order
meta.json is written in ffprobe/readdir order (arbitrary), and the web player
rendered/queued it as-is — so albums like Andrew Bird's "The Mysterious
Production of Eggs" showed scrambled (11, 7, 5, 14, 1, …). The web Track type
didn't even carry the `track` tag.

Add `track` to the Track type + a shared sortTracks(): by track NUMBER (parsed
from the "n/total" tag), falling back to tag title only for tracks without a
number. Applied to the rendered tracklist (setAlbum) and both play queues.
Verified it reorders that album to 1→14.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-27 11:50:34 +00:00
pastilhasandClaude Opus 4.8 21cb3489a7 music: per-user Favorites + Currently-playing (platform/Postgres)
User-level state for the music app, served by the platform from Postgres (not
the sidecar, which is stateless about users) under the same /api/music prefix
so the music-app account gate permits it:

- music_favorites (userId, kind, key) — kind ∈ track|album|artist, opaque path
  key the server never interprets; unique per (user,kind,key), newest-first.
  GET /favorites (grouped), POST /favorites (idempotent), DELETE /favorites.
- music_now_playing (one row/user) — current track + position snapshot for
  resume-across-launch/device, with a light title/artist/album cache so the
  resume card renders before the library index syncs.
  GET/PUT/DELETE /now-playing (upsert).

Routes registered before the catch-all proxy. Tables created via direct DDL;
query layer smoke-tested against the live DB. MUSIC_API.md documents the
contract for the app. App-side wiring (heart toggles, Favorites view, player
persistence) follows next.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-27 11:28:28 +00:00
pastilhasandClaude Opus 4.8 54fa21dd46 workspace: panel-level responsiveness via CSS container queries
Panels are now container contexts (@container → container-type: inline-size)
on both the normal and transparent PanelSlot render paths, so panel content
can respond to the *panel's* width instead of only the viewport's. Non-
breaking: existing screens keep their viewport (lg:/md:) variants untouched.

First adopter: the bTop system-monitor grid reflows on panel drag —
grid-cols-1 → 2 at @min-[600px] → 3 at @min-[960px] (panel width), keeping
cards ≥300px so they no longer squeeze on a narrow panel.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-27 11:16:17 +00:00
pastilhasandClaude Opus 4.8 de8f70562b docs: SYSTEM_MONITOR_API.md contract for the app + drop dead Pm2Logs.tsx
Full /api/system-monitor/* contract (stats snapshot incl. cpu/mem/disks/temp/
gpu/net/power, pm2, docker, and the two SSE log streams) with response shapes,
auth (Bearer or ?token=), owner-only note, and the net/power rate caveats — so
the app can implement the same views. Also removes the orphaned Pm2Logs.tsx
(superseded by LogStream) that a prior commit left tracked.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-27 11:03:10 +00:00
pastilhasandClaude Opus 4.8 f9752d2868 system-monitor: GPU, Network, and Power cards in bTop
Three more same-level grid cards (bTop now 7 cards; Temperature stays under
Memory via natural 3-col flow):
- GPU: gpu_busy_percent + VRAM used/total from /sys/class/drm (instant).
- Network: ↓/↑ throughput (bytes/sec) from /proc/net/dev deltas between /stats
  calls, aggregate + top interfaces.
- Power: CPU package watts via RAPL energy delta + GPU watts (amdgpu hwmon).

Note: RAPL energy_uj is root-only by default (Spectre-era lockdown), so CPU
package power shows "—" unless made readable (a udev rule); GPU watts work.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-27 10:59:27 +00:00
pastilhasandClaude Opus 4.8 75a978d2e2 system-monitor: CPU temperature in bTop (Temperature card under Memory)
- Backend: readTemps() scans /sys/class/hwmon for all temp sensors and picks the
  CPU one (k10temp/coretemp/zenpower Tctl/Tdie/Package); added to /stats.temp.
- BtopView: a Temperature card at lg:col-start-2 (under Memory, second row) —
  big CPU °C (color-graded), its sensor label, and the other sensors (GPU, NVMe,
  wifi…) listed beneath.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-27 10:51:54 +00:00
pastilhasandClaude Opus 4.8 41ebd73c2d system-monitor: docker grid view + name/image filter
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-27 10:45:36 +00:00
pastilhasandClaude Opus 4.8 f3108f6d83 system-monitor: black log pane + live docker container logs
- Shared LogStream component (solid-black <pre> terminal pane) replaces Pm2Logs;
  pm2 and docker both drill into it.
- GET /api/system-monitor/docker/logs?id=<container>&lines=<n> — SSE of
  `docker logs -f` (combined stdout+stderr); id charset-validated + spawn arg.
- DockerView: container cards are now clickable → live logs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-27 10:42:02 +00:00
pastilhasandClaude Opus 4.8 67e6646bca system-monitor: live pm2 log streaming — click a process name to tail its logs
- GET /api/system-monitor/pm2/logs?id=<pm_id>&lines=<n> — SSE that spawns
  `pm2 logs <id> --raw` (combined out+err, follows live) and streams each line.
  id validated numeric + passed as a spawn arg (no shell); killed on disconnect.
- Pm2View: clicking a process name drills into Pm2Logs (EventSource tail with a
  live pulse + back button); autoscrolls, capped at ~1200 lines.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-27 10:32:53 +00:00
pastilhasandClaude Opus 4.8 7f91c4f9bf system-monitor: sortable columns in the pm2 view (click headers; asc/desc)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-27 10:25:37 +00:00
pastilhasandClaude Opus 4.8 1b7a5feeea system-monitor: show pm2 id (#) column in the pm2 processes view
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-27 10:24:20 +00:00
pastilhasandClaude Opus 4.8 059929aa59 system-monitor: Workspace/Panel layout with a scope list (bTop / pm2 / dockers)
/system-monitor is now a WorkspaceView (like /music): a left ScopeList panel
selects the scope over the 'monitor:scope' channel, the right MonitorMain panel
renders it. Three scopes:
- bTop  — the existing system snapshot (CPU/mem/disks/top processes)
- pm2 processes — new GET /api/system-monitor/pm2 (pm2 jlist → name/status/cpu/
  mem/restarts/uptime table)
- dockers — new GET /api/system-monitor/docker (docker ps → container cards with
  state/status/image/ports)
Both new endpoints degrade gracefully to an error field. Persisted as
screens/system-monitor; owner-only.

Needs a restart (backend routes + rebundle) + hard-refresh to appear.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-27 10:17:15 +00:00
pastilhasandClaude Opus 4.8 621f93b884 chat: don't idle-GC a session while background tasks are still running
Phase 1 gap surfaced by a long overnight job: armIdle fired 30 min after the
last turn regardless of in-flight background work, so a silent run_in_background
job outliving the timeout got its persistent SDK session aborted — killing the
harness that delivers its task_notification (and any detached watcher's hook).

Fix = task-lifecycle heartbeat: track task:started → task:notification per
session; suppress/re-arm the idle timer while any task is pending. task:started
also clears a pending idle timer. So long run_in_background jobs keep their own
session alive and their completion is delivered; idle-GC resumes only once all
tasks finish and the session is truly idle. Pairs with the Activity path-tail
(35973a5) for the pure-setsid case.

Not deployed (no restart — long job still running); lands with the Activity
batch on next restart.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-27 09:34:59 +00:00
pastilhasandClaude Opus 4.8 35973a5505 activity: follow the agent's background work live + chat-event retention
"Activity" (placeholder name — jobs/tasks were taken) = watch background tasks
scroll in parallel with chat. Built DB-free; NOT restarted — deploy + test in
the morning.

- NDJSON progress contract (activity/progress.ts): capabilities append
  {job,cap,phase,status,pct,detail,ts,...} lines; tolerant parser treats any
  JSON object with phase/status as structured progress, else a raw log line.
- Backend (activity/router.ts, owner-only, path-guarded):
  - GET /api/activity/tasks — registry by scanning /tmp/claude-*/<cwd>/tasks/
    *.output (harness run_in_background) + announced detached jobs.
  - POST /api/activity/announce {name,path} — register a detached (setsid) job's
    log so it's followable too (the setsid case is on the critical path, since
    the warm worker now makes plain run_in_background the default for heavy jobs).
  - GET /api/activity/stream?task=<id>|path=<abs> — SSE tail (poll + offset),
    emitting {kind:'line'|'progress'} with NDJSON parsed.
- Frontend /activity screen + Radio nav item: task list (active dot) → live tail
  with a phase/pct progress header + raw log, following the /system-monitor pattern.
- Retention: startChatEventRetention() prunes chat_session_events >7d every 6h
  (wired in bootstrap) so the durable queue stays bounded.

Verified headlessly (no restart): parseTailLine classification, and the scan
finds 53 real task output files. Endpoints + UI untested until deploy.

Deferred (see handoff): cross-device sync + OpenCode parity (both touch the
now-stable chat path — won't ship un-restart-tested); task:progress into chat.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-27 00:56:10 +00:00
pastilhasandClaude Opus 4.8 6b3eb247a3 chat: durable Postgres event queue + cursor replay on reconnect (Phase 2+3)
Completes the turn/session decoupling so nothing is lost across disconnects.

Phase 2 (durability):
- New chat_session_events table (global monotonic id = cursor) + queries
  appendChatEvent / getChatEventsSince / pruneChatEventsOlderThan.
- Every durable outbound ServerMessage now goes through emitToSession: appended
  to the queue (even while the client is disconnected) and delivered live with
  its seq. Streaming deltas stay ephemeral (live-only, never persisted).

Phase 3 (resilient transport):
- New 'resume-cursor' client message → handleResumeCursor re-binds the socket to
  the (still-live) session (cancels idle-GC via attachWs) and replays every event
  since the client's cursor.
- useChatWebSocket already auto-reconnects; added an onOpen hook. useChat tracks
  the max seq and, on every (re)connect with an established session, sends
  resume-cursor — so a dropped connection self-heals with no manual navigate
  away/back, and background task notifications that landed while offline replay.

Verified end-to-end: disconnect after a turn's result but before a background
task finishes, reconnect with the cursor → the missed task:notification is
replayed from Postgres, no duplicates.

Note: the DB is managed via drizzle push/direct DDL (no __drizzle_migrations
table), so 0001 was applied directly; the generated migration is committed for
the record.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-27 00:12:46 +00:00
pastilhasandClaude Opus 4.8 449f28b1e5 chat: persistent Agent SDK session per chat — decouple worker from turn (Phase 1)
Root fix for orphaned background tasks: the platform drove Claude Code as a
one-shot `claude -p` per turn (stdin ignored, process exits at turn end), so
run_in_background/Monitor work — and its task_notification — had no live harness
to return to. Now each chat session runs ONE long-lived Agent SDK query() with
streaming input; turns are user messages pushed onto it, and the session stays
warm between turns.

- claude-manager: persistent `query({ prompt: AsyncIterable, options })` per
  sessionKey (bypassPermissions, --resume, mcp via extraArgs, CLAUDECODE stripped).
  Single consumer loop maps every SDK message → ChatEvent, incl. post-turn
  task_started / task_notification. interrupt() = stop-turn; abort() = kill-session;
  30-min idle GC.
- stream-parser: processMessage() (object-level, reused by the SDK loop) + task
  message handling. ChatEvent/ServerMessage gain task:started / task:notification.
- API: the sidecar event subscription is now SESSION-scoped (no longer unsubscribes
  on 'result'), so background events after turn-end still reach the client. First
  turn opens the session; later turns push onto it. handleStop → interrupt (keeps
  session warm); disconnect/deleteSession → kill.
- protocol/sidecar-registry/user-instance: claude:interrupt command + interruptClaude.
- client: render task:started / task:notification in the transcript.

Verified end-to-end through the real chat WS: a run_in_background task's completion
arrives ~6s AFTER the turn's result; multi-turn on one warm session works.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-27 00:00:11 +00:00
pastilhasandClaude Opus 4.8 b9d539c1bd chat: "Disconnect session" — end the whole session from the UI (first stab)
Adds a per-session teardown, distinct from the existing turn-only "stop":
- New WS 'disconnect' message → handleDisconnect → sessionManager.deleteSession,
  which fires _claudeKill (kills any in-flight Claude/OpenCode turn) + _sidecarUnsub,
  clears the idle timer, and drops the in-memory session. WS stays open so a new
  prompt starts fresh. Server acks with 'disconnected'.
- useChat: disconnectSession() + a 'disconnected' handler (commit partial stream,
  settle to idle).
- UI: an Unplug button in the chat DetailBar (shown while connected).

Scope note: targets the CURRENTLY-OPEN session (correct in-memory sessionKey).
Disconnecting an arbitrary *listed* session isn't wired yet — session-list rows are
keyed by the on-disk transcript uuid, which isn't the live sessionKey, so that needs
a reverse lookup + a REST endpoint. NOT yet deployed (needs a server restart).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 23:22:53 +00:00
pastilhasandClaude Opus 4.8 bff11bc86d music: fresh-on-refresh reindex, resync logs, and stop the junk-cover rebuild loop
- POST /reindex now runs the build to completion before responding (via a
  coalescing reindexNow), and GET /manifest ensures a fresh (debounced 3s)
  index first — so on-disk changes show up on a plain app refresh, not only
  via the explicit reindex sheet.
- Log each resync in the officer-music sidecar (start + one-line summary,
  or a failure line).
- Fix albums whose cover file isn't a decodable image (junk .jpg): the
  manifest cover flag and the skip check now reflect whether a cover was
  actually cached, so they settle to cover:false instead of rebuilding every
  run (and the app no longer 404s fetching a cover that was never there).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 23:13:17 +00:00