music becomes a plugin, and the player stays behind

The whole of music moves to plugins/music/: the sidecar (index, indexer,
stream-audio, nightly-reindex), the four Postgres tables and their queries,
the /music workspace panels, MUSIC_API.md and the reindex CLI. The platform
keeps no music routes, no music capability entry, no music screen and no
music schema.

Three things stayed, each on purpose.

cliamp and the widget were out of scope by the owner's decision. The plugin's
sidecar still serves the two cliamp sockets, so it imports cliamp-ws.ts and
pulse-audio.ts from @@/sidecar/music/ — the files stay where they were.

The player did not move, and that was the open judgement call. Deciding it
took one fact: the dashboard widget imports useMusicPlayer and PlayerTrack
from officerdev, and the platform cannot import from a plugin. So the player
STATE stays whatever is decided about the UI around it, and two copies would
mean two audio engines. Given that, the engine and the bar stayed with the
state rather than being split from the thing they drive. Moving them would
also have needed a shell slot rendering a plugin-provided component on every
route — the one escape hatch this system deleted on purpose. MusicPlayerHost
gates on can('music'), which is now the plugin's permission, so the seam
switches itself off with the plugin.

api/music/router.ts stays too: api/cliamp/relay.ts imports getMusicServerWsUrl
from it. The plugin's api/router.ts re-exports that proxy rather than building
a second one — two subscribers to the one-shot music:server port announcement
would work today and 503 on the first reconnect where only one was listening.

Two bugs found on the way, neither visible from reading.

The app-store catalogue still listed music. Availability is derived from
sidecar_installs and a PLUGIN never gets a row there, so `music` would have
been permanently unavailable — which puts /music into deniedRoutes and blanks
the screen on a server where the plugin was installed and healthy. Exactly
the headscale bug documented six lines above it in the same file, and it would
have fired on the first install. Entry removed.

[test] root was "./src", so moving lyrics.test.ts into plugins/ stopped it
running and said nothing — the count fell by nine and the suite still read
green. Root is now the repo. Positional filters cannot fix this: `bun test
plugins` matches under root and finds src/servers/plugins/ instead.

registry.test.ts tested the `personal` mechanism THROUGH the music capability.
Re-anchored on a fixture rather than on another entry, because borrowing a
feature only moves the problem to the next extraction — and three of those
four tests had been passing for the wrong reason since music's api was
commented out on 2026-08-13, when everything started resolving to "refused
because nothing is claimed". The cliamp sockets being claimed by nothing is
now pinned by a test instead of being rediscovered.

music's `personal` paths ride across on readOnlyWrites, the one field a
manifest has. isRequestAllowedAtLevel concatenates the two lists, so a read
grant permits exactly the four paths it permitted yesterday, and no field was
added to the manifest to design a per-user model that is not this work.

bunx tsgo clean. 772 tests, 762 pass, 7 fail — all seven pre-existing and
unrelated (cliamp, pty, and five capability tests that other switched-off
plugins break). Baseline was 757/10; the three that went green are the ones
re-anchored above.

Not yet verified on the live server — that is next.
This commit is contained in:
2026-08-15 01:46:43 +00:00
parent 18c4ebd0b4
commit de3340398c
44 changed files with 418 additions and 192 deletions
-1
View File
@@ -60,7 +60,6 @@ export function App() {
<Route path="/files" element={<Dashboard.FilesScreen />} />
<Route path="/calendar" element={<Dashboard.CalendarScreen />} />
<Route path="/contacts" element={<Dashboard.ContactsScreen />} />
<Route path="/music" element={<Dashboard.MusicScreen />} />
<Route path="/soulseek" element={<Dashboard.SoulseekScreen />} />
<Route path="/soulseek/:section" element={<Dashboard.SoulseekScreen />} />
<Route path="/photos" element={<Dashboard.PhotosScreen />} />
@@ -1,23 +0,0 @@
import type { LayoutNode } from 'officerdev';
import { WorkspaceView } from 'officerdev';
import { useDashboardState } from 'state/useDashboardState';
import { defaultLayout } from './defaultLayout';
// /music uses the Workspace/Panel system (like /chat): two vertical panels — the library browser
// (music-browser) and the content/detail (music-detail). They do not coordinate with each other; both
// read `?path=` off the URL. No route pair and no guard: the bare /music is the library root, a real
// state, and an unknown path gets an empty listing rather than a rewritten address.
export const MusicScreen = () => {
const workspace = useDashboardState<LayoutNode>('screens/music', defaultLayout);
return (
<div className="h-full w-full pt-2">
<WorkspaceView
workspace={workspace}
locked
appTypes={{ allowed: ['music-browser', 'music-detail'], fallback: 'music-detail' }}
/>
</div>
);
};
@@ -1,11 +0,0 @@
import type { LayoutNode } from 'officerdev';
export const defaultLayout: LayoutNode = {
type: 'group',
id: 'music-root',
direction: 'horizontal',
children: [
{ node: { type: 'panel', id: 'music-browser', appType: 'music-browser' }, size: 26 },
{ node: { type: 'panel', id: 'music-detail', appType: 'music-detail' }, size: 74 },
],
};
@@ -1 +0,0 @@
export * from './MusicScreen';
@@ -13,7 +13,6 @@ export * from './Tasks';
export * from './Files';
export * from './Calendar';
export * from './Contacts';
export * from './Music';
export * from './Soulseek';
export * from './Photos';
export * from './Jellyfin';
@@ -21,7 +21,6 @@ const RULES: TitleRule[] = [
{ match: (p) => p.startsWith('/files'), title: 'Files' },
{ match: (p) => p.startsWith('/calendar'), title: 'Calendar' },
{ match: (p) => p.startsWith('/contacts'), title: 'Contacts' },
{ match: (p) => p.startsWith('/music'), title: 'Music' },
{ match: (p) => p.startsWith('/app-store'), title: 'App store' },
{ match: (p) => p.startsWith('/plugins'), title: 'Plugins' },
{ match: (p) => p.startsWith('/photos'), title: 'Photos' },
-1
View File
@@ -46,7 +46,6 @@ export * from './dav';
export * from './email';
export * from './invoiceshelf';
export * from './jellyfin';
export * from './music';
export * from './notify';
export * from './photos';
export * from './soulseek';
@@ -1,17 +0,0 @@
export {
getMusicFavorites,
addMusicFavorite,
removeMusicFavorite,
getNowPlaying,
setNowPlaying,
clearNowPlaying,
getPlaylists,
getPlaylist,
createPlaylist,
renamePlaylist,
deletePlaylist,
addPlaylistItems,
setPlaylistItems,
} from './queries';
export type { FavoriteKind, GroupedFavorites, NowPlaying, NowPlayingInput, PlaylistSummary, Playlist } from './queries';
@@ -1,233 +0,0 @@
import { eq, and, desc, asc, sql } from 'drizzle-orm';
import { db } from '../db';
import { musicFavorites, musicNowPlaying, musicPlaylists, musicPlaylistItems } from './schema';
export type FavoriteKind = 'track' | 'album' | 'artist';
export type GroupedFavorites = { tracks: string[]; albums: string[]; artists: string[] };
/** All of a user's favorites, grouped by kind, newest first within each group. */
export async function getMusicFavorites(userId: number): Promise<GroupedFavorites> {
const rows = await db
.select({ kind: musicFavorites.kind, key: musicFavorites.key })
.from(musicFavorites)
.where(eq(musicFavorites.userId, userId))
.orderBy(desc(musicFavorites.createdAt));
const grouped: GroupedFavorites = { tracks: [], albums: [], artists: [] };
for (const r of rows) {
if (r.kind === 'track') grouped.tracks.push(r.key);
else if (r.kind === 'album') grouped.albums.push(r.key);
else if (r.kind === 'artist') grouped.artists.push(r.key);
}
return grouped;
}
/** Add a favorite (idempotent — a repeat add is a no-op via the unique constraint). */
export async function addMusicFavorite(userId: number, kind: FavoriteKind, key: string): Promise<void> {
await db.insert(musicFavorites).values({ userId, kind, key }).onConflictDoNothing();
}
/** Remove a favorite (no-op if it wasn't set). */
export async function removeMusicFavorite(userId: number, kind: FavoriteKind, key: string): Promise<void> {
await db
.delete(musicFavorites)
.where(and(eq(musicFavorites.userId, userId), eq(musicFavorites.kind, kind), eq(musicFavorites.key, key)));
}
export type NowPlaying = {
homePath: string;
dir: string;
title: string;
artist: string;
album: string;
durationSec: number;
positionSec: number;
updatedAt: Date;
};
export type NowPlayingInput = {
homePath: string;
dir?: string;
title?: string;
artist?: string;
album?: string;
durationSec?: number;
positionSec?: number;
};
/** This (user, device)'s last "currently playing" snapshot, or null if none. */
export async function getNowPlaying(userId: number, device: string): Promise<NowPlaying | null> {
const [row] = await db
.select({
homePath: musicNowPlaying.homePath,
dir: musicNowPlaying.dir,
title: musicNowPlaying.title,
artist: musicNowPlaying.artist,
album: musicNowPlaying.album,
durationSec: musicNowPlaying.durationSec,
positionSec: musicNowPlaying.positionSec,
updatedAt: musicNowPlaying.updatedAt,
})
.from(musicNowPlaying)
.where(and(eq(musicNowPlaying.userId, userId), eq(musicNowPlaying.device, device)));
return row ?? null;
}
/** Upsert this (user, device)'s "currently playing" snapshot (one row per user+device). */
export async function setNowPlaying(userId: number, device: string, np: NowPlayingInput): Promise<void> {
const values = {
userId,
device,
homePath: np.homePath,
dir: np.dir ?? '',
title: np.title ?? '',
artist: np.artist ?? '',
album: np.album ?? '',
durationSec: np.durationSec ?? 0,
positionSec: np.positionSec ?? 0,
updatedAt: new Date(),
};
await db
.insert(musicNowPlaying)
.values(values)
.onConflictDoUpdate({
target: [musicNowPlaying.userId, musicNowPlaying.device],
set: {
homePath: values.homePath,
dir: values.dir,
title: values.title,
artist: values.artist,
album: values.album,
durationSec: values.durationSec,
positionSec: values.positionSec,
updatedAt: values.updatedAt,
},
});
}
/** Clear this (user, device)'s "currently playing" (on close/stop). */
export async function clearNowPlaying(userId: number, device: string): Promise<void> {
await db.delete(musicNowPlaying).where(and(eq(musicNowPlaying.userId, userId), eq(musicNowPlaying.device, device)));
}
// ── Named playlists ────────────────────────────────────────────────────────────────────────────────
export type PlaylistSummary = { id: number; name: string; count: number; createdAt: Date; updatedAt: Date };
export type Playlist = { id: number; name: string; items: string[]; createdAt: Date; updatedAt: Date };
/** All of a user's playlists with item counts, most-recently-updated first. */
export async function getPlaylists(userId: number): Promise<PlaylistSummary[]> {
return db
.select({
id: musicPlaylists.id,
name: musicPlaylists.name,
count: sql<number>`count(${musicPlaylistItems.id})::int`,
createdAt: musicPlaylists.createdAt,
updatedAt: musicPlaylists.updatedAt,
})
.from(musicPlaylists)
.leftJoin(musicPlaylistItems, eq(musicPlaylistItems.playlistId, musicPlaylists.id))
.where(eq(musicPlaylists.userId, userId))
.groupBy(musicPlaylists.id)
.orderBy(desc(musicPlaylists.updatedAt));
}
/** One playlist with its ordered item keys, or null if it doesn't exist or isn't this user's. */
export async function getPlaylist(userId: number, id: number): Promise<Playlist | null> {
const [header] = await db
.select({
id: musicPlaylists.id,
name: musicPlaylists.name,
createdAt: musicPlaylists.createdAt,
updatedAt: musicPlaylists.updatedAt,
})
.from(musicPlaylists)
.where(and(eq(musicPlaylists.id, id), eq(musicPlaylists.userId, userId)));
if (!header) return null;
const rows = await db
.select({ key: musicPlaylistItems.key })
.from(musicPlaylistItems)
.where(eq(musicPlaylistItems.playlistId, id))
.orderBy(asc(musicPlaylistItems.position));
return { ...header, items: rows.map((r) => r.key) };
}
/** Create a named playlist. Returns the new row, or null if the name is already taken for this user. */
export async function createPlaylist(userId: number, name: string): Promise<PlaylistSummary | null> {
const [row] = await db
.insert(musicPlaylists)
.values({ userId, name })
.onConflictDoNothing({ target: [musicPlaylists.userId, musicPlaylists.name] })
.returning({
id: musicPlaylists.id,
name: musicPlaylists.name,
createdAt: musicPlaylists.createdAt,
updatedAt: musicPlaylists.updatedAt,
});
return row ? { ...row, count: 0 } : null;
}
/** Rename a playlist. Returns false if it isn't this user's, or the new name collides. */
export async function renamePlaylist(userId: number, id: number, name: string): Promise<boolean> {
const existing = await db
.select({ id: musicPlaylists.id })
.from(musicPlaylists)
.where(and(eq(musicPlaylists.userId, userId), eq(musicPlaylists.name, name)));
if (existing.some((r) => r.id !== id)) return false; // name taken by a different playlist
const updated = await db
.update(musicPlaylists)
.set({ name, updatedAt: new Date() })
.where(and(eq(musicPlaylists.id, id), eq(musicPlaylists.userId, userId)))
.returning({ id: musicPlaylists.id });
return updated.length > 0;
}
/** Delete a playlist (items cascade). Returns false if it wasn't this user's. */
export async function deletePlaylist(userId: number, id: number): Promise<boolean> {
const deleted = await db
.delete(musicPlaylists)
.where(and(eq(musicPlaylists.id, id), eq(musicPlaylists.userId, userId)))
.returning({ id: musicPlaylists.id });
return deleted.length > 0;
}
/** Verify a playlist belongs to the user; returns its id or null. */
async function ownedPlaylist(userId: number, id: number): Promise<number | null> {
const [row] = await db
.select({ id: musicPlaylists.id })
.from(musicPlaylists)
.where(and(eq(musicPlaylists.id, id), eq(musicPlaylists.userId, userId)));
return row?.id ?? null;
}
/** Append keys to the end of a playlist, preserving order. Returns the new count, or null if not the user's. */
export async function addPlaylistItems(userId: number, id: number, keys: string[]): Promise<number | null> {
if ((await ownedPlaylist(userId, id)) === null) return null;
return db.transaction(async (tx) => {
const [{ next } = { next: 0 }] = await tx
.select({ next: sql<number>`coalesce(max(${musicPlaylistItems.position}) + 1, 0)::int` })
.from(musicPlaylistItems)
.where(eq(musicPlaylistItems.playlistId, id));
if (keys.length) {
await tx.insert(musicPlaylistItems).values(keys.map((key, i) => ({ playlistId: id, key, position: next + i })));
}
await tx.update(musicPlaylists).set({ updatedAt: new Date() }).where(eq(musicPlaylists.id, id));
const [{ total } = { total: 0 }] = await tx
.select({ total: sql<number>`count(*)::int` })
.from(musicPlaylistItems)
.where(eq(musicPlaylistItems.playlistId, id));
return total;
});
}
/** Replace a playlist's entire ordered item list (covers reorder + remove). Returns the new count, or null. */
export async function setPlaylistItems(userId: number, id: number, keys: string[]): Promise<number | null> {
if ((await ownedPlaylist(userId, id)) === null) return null;
return db.transaction(async (tx) => {
await tx.delete(musicPlaylistItems).where(eq(musicPlaylistItems.playlistId, id));
if (keys.length) {
await tx.insert(musicPlaylistItems).values(keys.map((key, i) => ({ playlistId: id, key, position: i })));
}
await tx.update(musicPlaylists).set({ updatedAt: new Date() }).where(eq(musicPlaylists.id, id));
return keys.length;
});
}
@@ -1,80 +0,0 @@
import { pgTable, serial, integer, text, real, timestamp, index, primaryKey, uniqueIndex } from 'drizzle-orm/pg-core';
import { users } from '../auth/schema';
// Per-user music favorites. `key` is an opaque path the app supplies and the server never interprets:
// track → homePath "Music/<rel>/<file>" (also the /stream path + RNTP queue id)
// album → music-rel "Albums/AC-DC/[1980] Back in Black"
// artist → music-rel "Albums/AC-DC"
export const musicFavorites = pgTable(
'music_favorites',
{
id: serial('id').primaryKey(),
userId: integer('user_id')
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
kind: text('kind').notNull(), // 'track' | 'album' | 'artist'
key: text('key').notNull(),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
},
(t) => [
uniqueIndex('uq_music_favorites_user_kind_key').on(t.userId, t.kind, t.key),
index('idx_music_favorites_user_kind').on(t.userId, t.kind),
],
);
// Per-user named playlists (header) + their ordered items (below). Like favorites, an item `key` is the
// opaque track homePath "Music/<rel>/<file>" the app supplies — the server never interprets it. A playlist
// name is unique per user; items are position-ordered and MAY repeat (a track can appear twice).
export const musicPlaylists = pgTable(
'music_playlists',
{
id: serial('id').primaryKey(),
userId: integer('user_id')
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
name: text('name').notNull(),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
},
(t) => [uniqueIndex('uq_music_playlists_user_name').on(t.userId, t.name)],
);
// Ordered track entries of a playlist. `position` is 0-based; deletes cascade from the playlist.
export const musicPlaylistItems = pgTable(
'music_playlist_items',
{
id: serial('id').primaryKey(),
playlistId: integer('playlist_id')
.notNull()
.references(() => musicPlaylists.id, { onDelete: 'cascade' }),
key: text('key').notNull(), // track homePath "Music/<rel>/<file>"
position: integer('position').notNull(),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
},
(t) => [index('idx_music_playlist_items_playlist').on(t.playlistId, t.position)],
);
// Per-(user, device) "currently playing" for resume-across-launch: the current track + playback
// position, plus a light metadata snapshot so the resume card renders before the library index has
// synced on a fresh device. `dir` is the folder to rebuild the album queue from ('' for a cross-album
// queue). `device` is an opaque client tag ('' = default/phone, 'web' = the browser) so each client
// keeps its OWN resume state instead of stomping a shared one. One row per (user, device) — upserted;
// the app writes it throttled while playing and on pause/track-change/close.
export const musicNowPlaying = pgTable(
'music_now_playing',
{
userId: integer('user_id')
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
device: text('device').notNull().default(''),
homePath: text('home_path').notNull(),
dir: text('dir').notNull().default(''),
title: text('title').notNull().default(''),
artist: text('artist').notNull().default(''),
album: text('album').notNull().default(''),
durationSec: real('duration_sec').notNull().default(0),
positionSec: real('position_sec').notNull().default(0),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
},
(t) => [primaryKey({ name: 'pk_music_now_playing', columns: [t.userId, t.device] })],
);
-1
View File
@@ -44,7 +44,6 @@ export * from './service-connections/schema'; // service_connections
// ── Plugins — uncomment when the plugin is installed ─────────────────────────────────────────────
// export * from './email/schema'; // email_accounts officer-email
// export * from './music/schema'; // music_favorites, _playlists, _playlist_items, _now_playing
// export * from './notify/schema'; // push_devices officer-notify
// export * from './dav/schema'; // dav_app_passwords officer-caldav
// export * from './photos/schema'; // photos_config officer-photos
+16 -11
View File
@@ -386,17 +386,22 @@ export const CATALOGUE: CatalogueEntry[] = [
// form. Duplicating it here would be a second place to maintain the same credentials.
configFields: [],
},
{
id: 'music',
ui: { name: 'Music', icon: 'Music', color: '#22c55e', rootRoute: '/music', routes: ['/music'] },
process: 'officer-music',
label: 'Music',
summary: 'Index and play the library on this machine',
members: 'none',
modes: ['config'],
capability: 'music',
configFields: [{ key: 'library', label: 'Music folder', type: 'text', required: true, placeholder: '~/Music' }],
},
// Music was here until 2026-08-15, when it became `plugins/music/`. Removing it was not tidying — it
// was the headscale bug above, exactly, and it would have fired on the first install.
//
// `capabilityAvailability` reads `sidecar_installs`, and a PLUGIN never gets a row there: its install
// state lives in `plugin_installs`. So `music` would have been permanently `unavailable`, which puts
// `/music` into `deniedRoutes` — the screen blank and the dock tile withheld on a server where the
// plugin was installed, enabled and healthy. The same shape as headscale, found by reading that note
// rather than by hitting it again.
//
// The tile now comes from `pluginDockManifests()`, and the library folder is not configured at all:
// the sidecar reads `~/Music`. That `configFields` entry wrote a `service_connections` row nothing
// ever read.
//
// `[open]` This is the "two dock sources" seam. The store keeps its own catalogue while the plugin
// system builds tiles from manifests, and the self endpoint concatenates both. One when the store is
// rebuilt on the plugin system.
{
id: 'wallet',
ui: { name: 'Wallet', icon: 'Bitcoin', color: '#f7931a', rootRoute: '/wallet', routes: ['/wallet'] },
+55 -15
View File
@@ -1,4 +1,5 @@
import { describe, expect, test } from 'bun:test';
import type { Capability } from './registry';
import {
CAPABILITIES,
CAPABILITY_BY_KEY,
@@ -57,8 +58,8 @@ describe('totality', () => {
test('refuses a claim on a socket that is not served', () => {
const s = surface();
s.wsProviders = REAL_WS.filter((p) => p !== 'cliamp');
expect(() => assertCapabilityTotality(s)).toThrow(/claims websocket 'cliamp', which is not served/);
s.wsProviders = REAL_WS.filter((p) => p !== 'terminal');
expect(() => assertCapabilityTotality(s)).toThrow(/claims websocket 'terminal', which is not served/);
});
test('no two capabilities claim the same prefix', () => {
@@ -74,7 +75,7 @@ describe('totality', () => {
test('signin is exempt and gitea is not', () => {
expect(isExemptApiPath('/api/auth/signin')).toBe(true);
expect(isExemptApiPath('/api/gitea')).toBe(false);
expect(isExemptApiPath('/api/music/albums')).toBe(false);
expect(isExemptApiPath('/api/dashboards/4')).toBe(false);
});
});
@@ -85,8 +86,9 @@ describe('path → capability', () => {
});
test('does not match a prefix that is merely a string prefix', () => {
// '/api/musicbrainz' must not resolve to the 'music' capability. A naive startsWith would.
expect(capabilityForApiPath('/api/musicbrainz')).toBeNull();
// '/api/username' must not resolve to the 'account' capability, which claims '/user'. A naive
// startsWith would. (This was '/api/musicbrainz' against 'music' until music became a plugin.)
expect(capabilityForApiPath('/api/username')).toBeNull();
});
test('longest prefix wins, so /dav and /caldav do not fight', () => {
@@ -99,34 +101,72 @@ describe('path → capability', () => {
});
test('sockets resolve to their capability', () => {
expect(capabilityForWsProvider('cliamp')?.key).toBe('music');
expect(capabilityForWsProvider('terminal')?.key).toBe('terminal');
expect(capabilityForWsProvider('chat')?.key).toBe('chat');
expect(capabilityForWsProvider('nope')).toBeNull();
});
// `cliamp` and `cliamp-audio` are SERVED in server.tsx's route table and claimed by nothing, because
// music's `ws` list was commented out on 2026-08-13 and the capability itself left with the plugin on
// 2026-08-15. They upgrade into handlers that are commented out too, so nothing is reachable — but the
// boot check cannot see the drift, since it reads `Object.keys(handlers)` rather than the route table.
//
// Pinned here so the hole is a documented fact with a test on it rather than something to rediscover.
// Closing it is the totality work in plugins/EXTRACTING-A-PLUGIN.md, not this file's.
test('the cliamp sockets are claimed by nothing — known drift, see server.tsx', () => {
expect(capabilityForWsProvider('cliamp')).toBeNull();
expect(capabilityForWsProvider('cliamp-audio')).toBeNull();
});
});
describe('levels', () => {
const music = CAPABILITY_BY_KEY.get('music')!;
// A FIXTURE, not a registry entry.
//
// These tests ran against the real `music` capability until 2026-08-15, when music left with
// `plugins/music/`. Re-anchoring them on whichever entry happens to have a `personal` list today only
// moves the problem to the next extraction — and it had already half-broken before that, because the
// moment music's `api` was commented out (2026-08-13) every path below stopped matching and three of
// these four tests passed for the wrong reason: everything is refused when nothing is claimed.
//
// `isRequestAllowedAtLevel` is a pure function of a Capability. Handing it one states what is actually
// under test — the RULE — rather than borrowing a feature that can leave.
const fixture: Capability = {
key: 'fixture',
label: 'Fixture',
description: 'Not in the registry — a shape to exercise the level rules against',
kind: 'app',
api: ['/fixture'],
personal: ['/favorites', '/now-playing'],
};
test('write permits anything within the capability', () => {
expect(isRequestAllowedAtLevel(music, 'write', 'DELETE', '/api/music/track/9')).toBe(true);
expect(isRequestAllowedAtLevel(fixture, 'write', 'DELETE', '/api/fixture/track/9')).toBe(true);
});
test('read permits safe methods', () => {
expect(isRequestAllowedAtLevel(music, 'read', 'GET', '/api/music/albums')).toBe(true);
expect(isRequestAllowedAtLevel(music, 'read', 'HEAD', '/api/music/albums')).toBe(true);
expect(isRequestAllowedAtLevel(fixture, 'read', 'GET', '/api/fixture/albums')).toBe(true);
expect(isRequestAllowedAtLevel(fixture, 'read', 'HEAD', '/api/fixture/albums')).toBe(true);
});
test('read permits mutations only under personal sub-paths', () => {
expect(isRequestAllowedAtLevel(music, 'read', 'POST', '/api/music/favorites/7')).toBe(true);
expect(isRequestAllowedAtLevel(music, 'read', 'PUT', '/api/music/now-playing')).toBe(true);
expect(isRequestAllowedAtLevel(music, 'read', 'POST', '/api/music/scan')).toBe(false);
expect(isRequestAllowedAtLevel(music, 'read', 'DELETE', '/api/music/track/9')).toBe(false);
expect(isRequestAllowedAtLevel(fixture, 'read', 'POST', '/api/fixture/favorites/7')).toBe(true);
expect(isRequestAllowedAtLevel(fixture, 'read', 'PUT', '/api/fixture/now-playing')).toBe(true);
expect(isRequestAllowedAtLevel(fixture, 'read', 'POST', '/api/fixture/scan')).toBe(false);
expect(isRequestAllowedAtLevel(fixture, 'read', 'DELETE', '/api/fixture/track/9')).toBe(false);
});
test('a personal entry does not leak across a name boundary', () => {
// '/favorites-export' must not be covered by the '/favorites' personal entry.
expect(isRequestAllowedAtLevel(music, 'read', 'POST', '/api/music/favorites-export')).toBe(false);
expect(isRequestAllowedAtLevel(fixture, 'read', 'POST', '/api/fixture/favorites-export')).toBe(false);
});
test('a plugin gets the same rule through readOnlyWrites', () => {
// The two lists are concatenated, so a plugin declaring per-caller paths on the one field its
// manifest has behaves identically to a core capability declaring `personal`. This is what music
// relies on now that it ships as one.
const plugin: Capability = { ...fixture, personal: undefined, readOnlyWrites: ['/favorites', '/now-playing'] };
expect(isRequestAllowedAtLevel(plugin, 'read', 'POST', '/api/fixture/favorites/7')).toBe(true);
expect(isRequestAllowedAtLevel(plugin, 'read', 'POST', '/api/fixture/scan')).toBe(false);
});
});
+9 -18
View File
@@ -51,9 +51,13 @@
//
// A grant carries a level, `read` or `write`. `read` permits safe methods (GET/HEAD/OPTIONS) anywhere in
// the capability, plus mutations under `personal` — sub-paths that hold the CALLER'S own data and nothing
// else. Music is the worked example: `/favorites`, `/now-playing` and `/playlists` are already per-caller
// in the sidecar contract because every sidecar request carries `X-Officer-User`. So "may a member write
// here" is a property of the endpoint, not a policy knob someone has to remember to set.
// else. `dashboards` is the worked example: a dashboard belongs to the account that made it, so the whole
// surface is personal and a read grant is really "your own, fully". So "may a member write here" is a
// property of the endpoint, not a policy knob someone has to remember to set.
//
// Music was this note's example until 2026-08-15, when it left with `plugins/music/`. A plugin declares
// the same thing through its manifest's `readOnlyWrites` — `isRequestAllowedAtLevel` merges the two lists,
// so they are one mechanism under two names.
export type CapabilityKind = 'core' | 'app' | 'confined' | 'execution' | 'admin';
@@ -75,8 +79,8 @@ export type Capability = {
routes?: string[];
/**
* Sub-paths, relative to each `api` prefix, that a READ grant may still mutate because they hold only
* the caller's own data. Matched as a prefix after the capability's own: `/favorites` on the `music`
* capability permits `POST /api/music/favorites/123`.
* the caller's own data. Matched as a prefix after the capability's own: `/devices` on the `notify`
* capability permits `POST /api/notify/devices/123`.
*/
personal?: string[];
/**
@@ -146,19 +150,6 @@ const CORE_REGISTRY: Capability[] = [
// commenting on their own issues — security theatre with a real cost and no benefit.
personal: ['/'],
},
{
key: 'music',
label: 'Music',
description: 'The music library, playback, and your own favourites and playlists',
kind: 'app',
// api: ['/music'], // plugin — switched off 2026-08-13
api: [],
// ws: ['cliamp', 'cliamp-audio'], // plugin — switched off 2026-08-13
routes: ['/music'],
// Already per-caller in the sidecar contract (X-Officer-User), which is what makes them safe to write
// at read level. The library itself — scanning, tags, file moves — is not, and is not listed.
personal: ['/favorites', '/now-playing', '/playlists', '/queue'],
},
{
key: 'photos',
label: 'Photos',
-2
View File
@@ -20,7 +20,6 @@ import { settingsRouter } from './api/settings/settings';
import { dashboardsRouter } from './api/dashboards';
import { router as fileBrowserRouter } from './api/file-browser/router';
import { pluginsRouter } from './api/plugins/router';
// import { musicRouter } from './api/music/router';
// import { vaultRouter } from './api/vault/router';
// import { publicVaultRouter, VAULT_ONLY_PREFIXES, isBitwardenClient } from './api/vault/public-router';
import { agentHandoffRouter } from './api/agent-handoff/router';
@@ -133,7 +132,6 @@ const PROTECTED_MOUNTS: [prefix: string, router: ReturnType<typeof createRouter>
['/api-keys', apiKeysRouter], // your own keys; the `account` core capability covers it
['/dashboards', dashboardsRouter],
['/file-browser', fileBrowserRouter],
// ['/music', musicRouter], // plugin — switched off 2026-08-13
// ['/slskd', slskdRouter], // plugin — switched off 2026-08-13
['/terminal', terminalRouter],
// ['/memos', memosRouter], // plugin — switched off 2026-08-13
-503
View File
@@ -1,503 +0,0 @@
import { mkdirSync, writeFileSync } from 'node:fs';
import { join, basename } from 'node:path';
import type { SidecarCommand, SidecarEvent } from '../protocol';
import { createSidecarConnector } from '../connect';
import { streamAudioFile } from './stream-audio';
import { cliampUpgradeData, musicWebsocket } from './cliamp-ws';
import { ensurePulseAudio } from './pulse-audio';
import { startNightlyReindex, stopNightlyReindex } from './nightly-reindex';
import {
reindexNow,
reindexFull,
ensureCacheSetup,
MUSIC_ROOT,
getIndexStatus,
getManifest,
albumVersion,
metaFilePath,
coverFilePath,
discographyFilePath,
posterFilePath,
lyricsFilePath,
onIndexProgress,
buildReport,
} from './indexer';
import {
getMusicFavorites,
addMusicFavorite,
removeMusicFavorite,
getNowPlaying,
setNowPlaying,
clearNowPlaying,
getPlaylists,
getPlaylist,
createPlaylist,
renamePlaylist,
deletePlaylist,
addPlaylistItems,
setPlaylistItems,
type FavoriteKind,
} from 'officerdb';
import { DATA_PATH } from '../../data-path';
import { API_URL } from '../../officer-url.mjs';
// ── Per-user state validation ──
// The authenticated user id arrives in X-Officer-User (the platform proxy injects it after auth; we're
// loopback-only so we trust it). Favorite/playlist `key`s are opaque paths we never interpret.
const userIdOf = (req: Request): number | null => {
const n = Number(req.headers.get('x-officer-user'));
return Number.isInteger(n) && n > 0 ? n : null;
};
const FAVORITE_KINDS = new Set<FavoriteKind>(['track', 'album', 'artist']);
const isKind = (k: unknown): k is FavoriteKind => typeof k === 'string' && FAVORITE_KINDS.has(k as FavoriteKind);
const PLAYLIST_NAME_MAX = 200;
const cleanName = (v: unknown): string | null => {
if (typeof v !== 'string') return null;
const n = v.trim();
return n && n.length <= PLAYLIST_NAME_MAX ? n : null;
};
const asKeys = (v: unknown): string[] | null =>
Array.isArray(v) && v.every((k) => typeof k === 'string' && k) ? (v as string[]) : null;
// The music sidecar (officer-music). Same philosophy as the other officer-* sidecars: a singleton
// process that registers with the API server. It OWNS an audio-streaming HTTP server (all path
// resolution + streaming + ffprobe duration happens here); the platform API is just a thin proxy that
// authenticates and forwards to us. The server listens on a random loopback port, reported to the API
// on connect so it can route `/api/music/*` here.
//
// ─────────────────────────────────────────────────────────────────────────────────────────────────
// HTTP CONTRACT — the full `/api/music/*` surface (this fetch handler is the source of truth; the
// platform side is an opaque catch-all proxy). All routes are reached as `/api/music/<name>`, authed
// upstream by userMiddleware (Bearer header or `?token=` for media). Data shapes are the exported
// `IndexStatus` / `IndexReport` / `IndexMeta` types in indexer.ts.
//
// GET /stream?path=<home-relative> audio with Range→206 (Content-Range/Length/Accept-Ranges)
// + `X-Audio-Duration` (seconds, ffprobe). 400/404/416.
// GET /manifest pure read of the last completed index (NO build triggered) —
// { version, generatedAt, albums: { "<rel>": { v, cover, tracks, videos?, disco? } } }
// GET /meta?path=<rel> album meta.json (IndexMeta). ETag: <v>; If-None-Match → 304.
// GET /cover?path=<rel> compressed cover.jpg. ETag: <v>; If-None-Match → 304.
// GET /poster?path=<rel>&file=<video> compressed video poster (frame grab). ETag: <v>; 304. 404 if none.
// GET /lyrics?path=<rel>&file=<track> track lyrics text (X-Lyrics-Format: lrc|txt). ETag: <v>; 304. 404 if none.
// GET /image?path=<rel>&file=<img> loose folder image bytes (image/*, the ORIGINAL). ETag: <v>; 304. 404 if none.
// GET /discography?path=<artist rel> artist discography.json (only where manifest entry has
// disco:true) = { artist, albums: { "<[year] album folder>":
// "<Type>" } }, Type ∈ Studio/Live/Compilation/Single/EP/…
// ETag: <v>; If-None-Match → 304. Source: each artist folder's
// _discography.md (normalized; the md itself is never modified).
// POST /reindex[?full=1] run the build to COMPLETION, then return the final IndexStatus.
// default = incremental (skips unchanged); ?full=1 = full staged
// rebuild + atomic swap (backfill a meta-format change).
// GET /reindex/status IndexStatus snapshot.
// GET /reindex/stream SSE. Triggers a build if idle (`?trigger=0` = watch-only).
// `event: progress` (IndexStatus) throttled ~200ms, then one
// `event: done` (IndexReport) and the stream closes.
// GET /health "ok".
//
// ── Per-user state (USER data in Postgres, not library data). User id in X-Officer-User, injected by
// the platform proxy after auth; `key`s are opaque paths (track homePath / album|artist rel). ──
// GET /favorites { tracks[], albums[], artists[] } (keys, newest first).
// POST /favorites { kind, key } add (idempotent). kind ∈ track|album|artist.
// DELETE /favorites?kind=&key= remove.
// GET /now-playing[?device=] last snapshot for that device, or null. device '' = default/phone, 'web' = browser.
// PUT /now-playing[?device=] { homePath, dir?, title?, artist?, album?, durationSec?, positionSec? } upsert.
// DELETE /now-playing[?device=] clear that device's snapshot.
// GET /playlists [{ id, name, count, createdAt, updatedAt }] (recent first).
// POST /playlists { name } create → 201 row; 409 if name taken.
// GET /playlists/:id { id, name, items:[keys], … }; 404 if not the user's.
// PATCH /playlists/:id { name } rename; 404 / 409.
// DELETE /playlists/:id delete (items cascade); 404.
// POST /playlists/:id/items { keys[] } append → { count }; 404.
// PUT /playlists/:id/items { keys[] } replace whole list (reorder/remove) → { count }; 404.
//
// `<rel>` = album folder path relative to the Music root (e.g. "Albums/AC-DC/[1980] Back in Black").
// `v` = per-album version stamp; unchanged `v` ⇒ nothing changed ⇒ the phone can skip re-downloading.
// ─────────────────────────────────────────────────────────────────────────────────────────────────
// ── Audio-streaming HTTP server ──
/** Grab an ephemeral free port by briefly binding one and releasing it. */
function getFreePort(): number {
const probe = Bun.serve({ port: 0, hostname: '127.0.0.1', fetch: () => new Response('') });
const port = probe.port;
probe.stop(true);
if (port == null) throw new Error('failed to acquire a free port');
return port;
}
const port = getFreePort();
// Ensure the cache is a symlink-to-slot before serving/building, so full reindexes can swap atomically.
await ensureCacheSetup();
// Nightly full reindex at 3am (staged + atomic swap).
startNightlyReindex();
// No filesystem watcher on ~/Music. Bun's recursive fs.watch costs one inotify watch per ENTRY, files
// included — ~92k for this library against a 65536 ceiling — so it could never establish, and the
// ENOSPC came back asynchronously as an unhandled 'error' event that killed this whole sidecar 17k
// times over. It also drained the per-UID watch pool, starving every other watcher on the machine.
// Reindexing is triggered instead: the ↻ button in the music browser (POST /reindex, incremental) and
// the nightly full rebuild above. `reindexFolder` in the indexer is retained and currently unused — it
// is the targeted hook for whatever writes to ~/Music (slskd, transmission, download-media) to declare
// the one folder it just wrote, which is the cheap version of what the watcher was guessing at.
// PulseAudio daemon + the `virtual_out` null sink both cliamp halves depend on. Officer used to do this at
// its own boot, which meant every restart of a process with no audio responsibilities re-checked the sink.
ensurePulseAudio();
const server = Bun.serve({
port,
hostname: '127.0.0.1',
// Bun caps the server-level idleTimeout at 255s. Keep it there as the baseline; the build/stream
// endpoints (which can idle for a whole from-scratch rebuild) extend it per-request via server.timeout.
idleTimeout: 255,
async fetch(req, server) {
const url = new URL(req.url);
// The two cliamp sockets. Officer has already authenticated the browser and is relaying frames; the
// player and the capture themselves live here (cliamp-ws.ts).
const wsData = cliampUpgradeData(url.pathname, url.searchParams);
if (wsData) {
if (server.upgrade(req, { data: wsData })) return undefined as unknown as Response;
return new Response('Expected a WebSocket upgrade', { status: 400 });
}
// A from-scratch reindex can take many minutes with no bytes flowing on the triggering request.
// Give the build endpoints a 30-min idle timeout so they aren't dropped (/manifest is a pure read now).
if (url.pathname === '/reindex' || url.pathname === '/reindex/stream') {
server.timeout(req, 1800);
}
const json = (data: unknown, init?: ResponseInit) =>
new Response(JSON.stringify(data), {
...init,
headers: { 'Content-Type': 'application/json', ...init?.headers },
});
// Version-stamped artifacts (cover/meta/…) and the manifest carry an ETag(=v) but must be REVALIDATED,
// not served blind from the browser cache — otherwise a changed cover keeps showing the old image at
// the same URL. `no-cache` = cache but always revalidate; the ETag/If-None-Match then makes it a cheap
// 304 when nothing changed. (The platform proxy forwards If-None-Match so this works end-to-end.)
const NO_CACHE = { 'Cache-Control': 'no-cache' } as const;
if (url.pathname === '/health') return new Response('ok');
// ── Streaming ──
if (url.pathname === '/stream') {
const path = url.searchParams.get('path');
if (!path) return new Response('path is required', { status: 400 });
return streamAudioFile(path, req.headers.get('range'));
}
// ── Index build ──
if (url.pathname === '/reindex') {
if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
// Run to completion, THEN respond — so the caller's manifest read right after is fresh. Both join an
// in-flight build rather than starting a second.
// default : incremental — near-instant, skips unchanged albums by version stamp.
// ?full=1 : full from-scratch rebuild into a fresh slot, swapped in atomically (staged + safe) —
// use to backfill a meta-format change (e.g. a new track field) across the WHOLE library.
const full = url.searchParams.get('full') === '1' || url.searchParams.get('full') === 'true';
const result = await (full ? reindexFull() : reindexNow());
return json(result);
}
if (url.pathname === '/reindex/status') return json(getIndexStatus());
// SSE progress stream (for the app + the CLI). Triggers a build if idle (unless ?trigger=0), then
// streams `progress` events until the build finishes, ending with a `done` event carrying the report.
if (url.pathname === '/reindex/stream') {
const trigger = url.searchParams.get('trigger') !== '0';
if (trigger) void reindexNow();
const encoder = new TextEncoder();
const stream = new ReadableStream({
start(controller) {
let closed = false;
let unsub = () => {};
const send = (event: string, data: unknown) => {
if (closed) return;
try {
controller.enqueue(encoder.encode(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`));
} catch {
/* stream closed */
}
};
const finish = (s: ReturnType<typeof getIndexStatus>) => {
send('done', buildReport(s));
unsub();
closed = true;
try {
controller.close();
} catch {
/* already closed */
}
};
send('progress', getIndexStatus());
const cur = getIndexStatus();
if (!cur.running) {
finish(cur); // nothing running → emit the last report and close
return;
}
unsub = onIndexProgress((s) => {
send('progress', s);
if (!s.running && s.finishedAt) finish(s);
});
},
});
return new Response(stream, {
headers: { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', Connection: 'keep-alive' },
});
}
// ── Sync surface ──
if (url.pathname === '/manifest') {
// Pure read — returns the last completed index. It does NOT trigger a build (that could kick off a
// long/full rebuild on a plain app refresh); use POST /reindex explicitly to pick up disk changes.
return json(await getManifest(), { headers: NO_CACHE });
}
// Video poster (a compressed frame grab). Keyed by the video's folder rel + its filename.
if (url.pathname === '/poster') {
const rel = url.searchParams.get('path');
const file = url.searchParams.get('file');
if (rel === null || !file) return new Response('path and file are required', { status: 400 });
const posterPath = posterFilePath(rel, file);
if (!posterPath) return new Response('Invalid path', { status: 400 });
if (!(await Bun.file(posterPath).exists())) return new Response('Not found', { status: 404 });
const v = await albumVersion(rel);
if (v && req.headers.get('if-none-match') === v) return new Response(null, { status: 304, headers: NO_CACHE });
return new Response(Bun.file(posterPath), {
headers: { 'Content-Type': 'image/jpeg', ...NO_CACHE, ...(v ? { ETag: v } : {}) },
});
}
// Track lyrics (external .lrc/.txt sidecar or embedded, cached during indexing). Try synced first.
if (url.pathname === '/lyrics') {
const rel = url.searchParams.get('path');
const file = url.searchParams.get('file');
if (rel === null || !file) return new Response('path and file are required', { status: 400 });
for (const fmt of ['lrc', 'txt'] as const) {
const p = lyricsFilePath(rel, file, fmt);
if (p && (await Bun.file(p).exists())) {
const v = await albumVersion(rel);
if (v && req.headers.get('if-none-match') === v)
return new Response(null, { status: 304, headers: NO_CACHE });
return new Response(Bun.file(p), {
headers: {
'Content-Type': 'text/plain; charset=utf-8',
'X-Lyrics-Format': fmt,
...NO_CACHE,
...(v ? { ETag: v } : {}),
},
});
}
}
return new Response('Not found', { status: 404 });
}
// Folder image (band photo / booklet scan) — served as the ORIGINAL file from the library folder
// (no cached artifact). `path` = music-relative folder, `file` = image name (basename'd for safety).
if (url.pathname === '/image') {
const rel = url.searchParams.get('path') ?? '';
const file = basename(url.searchParams.get('file') ?? '');
if (!file) return new Response('file is required', { status: 400 });
const abs = join(MUSIC_ROOT, rel, file);
if (abs !== MUSIC_ROOT && !abs.startsWith(MUSIC_ROOT + '/')) return new Response('Invalid path', { status: 400 });
if (!(await Bun.file(abs).exists())) return new Response('Not found', { status: 404 });
const v = await albumVersion(rel);
if (v && req.headers.get('if-none-match') === v) return new Response(null, { status: 304, headers: NO_CACHE });
const ext = file.slice(file.lastIndexOf('.') + 1).toLowerCase();
const type =
ext === 'png' ? 'image/png' : ext === 'webp' ? 'image/webp' : ext === 'gif' ? 'image/gif' : 'image/jpeg';
return new Response(Bun.file(abs), { headers: { 'Content-Type': type, ...NO_CACHE, ...(v ? { ETag: v } : {}) } });
}
if (url.pathname === '/meta' || url.pathname === '/cover' || url.pathname === '/discography') {
const rel = url.searchParams.get('path');
if (rel === null) return new Response('path is required', { status: 400 });
const spec = {
'/meta': { file: metaFilePath(rel), type: 'application/json' },
'/cover': { file: coverFilePath(rel), type: 'image/jpeg' },
'/discography': { file: discographyFilePath(rel), type: 'application/json' },
}[url.pathname]!;
if (!spec.file) return new Response('Invalid path', { status: 400 });
if (!(await Bun.file(spec.file).exists())) return new Response('Not found', { status: 404 });
const v = await albumVersion(rel);
if (v && req.headers.get('if-none-match') === v) return new Response(null, { status: 304, headers: NO_CACHE });
return new Response(Bun.file(spec.file), {
headers: { 'Content-Type': spec.type, ...NO_CACHE, ...(v ? { ETag: v } : {}) },
});
}
// ── Per-user state (favorites / now-playing / playlists) ──────────────────────────────────────
// USER data backed by Postgres (NOT library data). The user id comes from X-Officer-User (see above).
const P = url.pathname;
if (P === '/favorites' || P === '/now-playing' || P === '/playlists' || P.startsWith('/playlists/')) {
const uid = userIdOf(req);
if (uid === null) return json({ error: 'unauthenticated' }, { status: 401 });
const m = req.method;
if (P === '/favorites') {
if (m === 'GET') return json(await getMusicFavorites(uid));
if (m === 'POST') {
const { kind, key } = (await req.json().catch(() => ({}))) as { kind?: unknown; key?: unknown };
if (!isKind(kind) || typeof key !== 'string' || !key)
return json({ error: 'kind and key required' }, { status: 400 });
await addMusicFavorite(uid, kind, key);
return json({ ok: true });
}
if (m === 'DELETE') {
const kind = url.searchParams.get('kind');
const key = url.searchParams.get('key');
if (!isKind(kind) || !key) return json({ error: 'kind and key required' }, { status: 400 });
await removeMusicFavorite(uid, kind, key);
return json({ ok: true });
}
return new Response('Method not allowed', { status: 405 });
}
if (P === '/now-playing') {
// Per-client resume state: `?device=` tags the caller ('' = default/phone, 'web' = the browser)
// so each keeps its own now-playing instead of sharing one row. Missing → '' (back-compat).
const device = url.searchParams.get('device') ?? '';
if (m === 'GET') return json(await getNowPlaying(uid, device));
if (m === 'PUT') {
const b = (await req.json().catch(() => ({}))) as Record<string, unknown>;
if (typeof b.homePath !== 'string' || !b.homePath)
return json({ error: 'homePath required' }, { status: 400 });
const str = (v: unknown) => (typeof v === 'string' ? v : undefined);
const num = (v: unknown) => (typeof v === 'number' && Number.isFinite(v) ? v : undefined);
await setNowPlaying(uid, device, {
homePath: b.homePath,
dir: str(b.dir),
title: str(b.title),
artist: str(b.artist),
album: str(b.album),
durationSec: num(b.durationSec),
positionSec: num(b.positionSec),
});
return json({ ok: true });
}
if (m === 'DELETE') {
await clearNowPlaying(uid, device);
return json({ ok: true });
}
return new Response('Method not allowed', { status: 405 });
}
if (P === '/playlists') {
if (m === 'GET') return json(await getPlaylists(uid));
if (m === 'POST') {
const { name } = (await req.json().catch(() => ({}))) as { name?: unknown };
const n = cleanName(name);
if (!n) return json({ error: 'name required (1-200 chars)' }, { status: 400 });
const row = await createPlaylist(uid, n);
return row
? json(row, { status: 201 })
: json({ error: 'a playlist with that name already exists' }, { status: 409 });
}
return new Response('Method not allowed', { status: 405 });
}
// /playlists/:id and /playlists/:id/items
const match = P.match(/^\/playlists\/(\d+)(\/items)?$/);
if (!match) return json({ error: 'not found' }, { status: 404 });
const id = Number(match[1]);
if (match[2]) {
// /playlists/:id/items — POST append, PUT replace (reorder/remove)
if (m !== 'POST' && m !== 'PUT') return new Response('Method not allowed', { status: 405 });
const { keys } = (await req.json().catch(() => ({}))) as { keys?: unknown };
const ks = asKeys(keys);
if (!ks) return json({ error: 'keys[] required' }, { status: 400 });
const count = await (m === 'POST' ? addPlaylistItems(uid, id, ks) : setPlaylistItems(uid, id, ks));
return count === null ? json({ error: 'not found' }, { status: 404 }) : json({ count });
}
if (m === 'GET') {
const pl = await getPlaylist(uid, id);
return pl ? json(pl) : json({ error: 'not found' }, { status: 404 });
}
if (m === 'PATCH') {
const { name } = (await req.json().catch(() => ({}))) as { name?: unknown };
const n = cleanName(name);
if (!n) return json({ error: 'name required (1-200 chars)' }, { status: 400 });
if (!(await getPlaylist(uid, id))) return json({ error: 'not found' }, { status: 404 });
const ok = await renamePlaylist(uid, id, n);
return ok ? json({ ok: true }) : json({ error: 'a playlist with that name already exists' }, { status: 409 });
}
if (m === 'DELETE') {
const ok = await deletePlaylist(uid, id);
return ok ? json({ ok: true }) : json({ error: 'not found' }, { status: 404 });
}
return new Response('Method not allowed', { status: 405 });
}
return new Response('Not found', { status: 404 });
},
websocket: musicWebsocket,
});
console.log(`[music] audio server listening on http://127.0.0.1:${port}`);
// Write the port to a well-known file so local tooling (scripts/reindex-music.ts) can find the server.
try {
mkdirSync(join(DATA_PATH, 'music'), { recursive: true });
writeFileSync(join(DATA_PATH, 'music', '.server'), String(port));
} catch {
/* best-effort */
}
// ── Command handlers ──
type ReplyFn = (msg: SidecarEvent) => void;
function handleCommand(cmd: SidecarCommand, reply: ReplyFn) {
switch (cmd.type) {
case 'ping':
reply({ type: 'pong', id: cmd.id });
break;
default:
reply({
type: 'error',
id: (cmd as SidecarCommand).id,
error: `Unknown command type: ${(cmd as Record<string, unknown>).type}`,
});
}
}
// ── Connect to API server ──
const connection = createSidecarConnector({
apiUrl: `${API_URL}/api/sidecar/register`,
name: 'music',
capabilities: ['music'],
onCommand(cmd, reply) {
handleCommand(cmd as SidecarCommand, reply as ReplyFn);
},
onConnected() {
// Tell the API where our audio server is listening, so it can proxy /api/music/* here.
connection.send({ type: 'music:server', port });
console.log(`[music] reported audio server port ${port} to API`);
},
});
// ── Graceful shutdown ──
function shutdown(signal: string) {
console.log(`[music] ${signal} received, shutting down...`);
stopNightlyReindex();
try {
server.stop(true);
} catch {
/* already stopped */
}
connection.destroy();
process.exit(0);
}
process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT'));
File diff suppressed because it is too large Load Diff
@@ -1,45 +0,0 @@
import { reindexFull } from './indexer';
// Nightly full-from-scratch reindex at 3am (server-local time). Uses reindexFull, so it builds into a
// fresh slot and atomically swaps it in only on success — the live index is never disrupted mid-build.
// Self-scheduling (a fresh setTimeout each night) rather than setInterval, so it always fires at 3am
// regardless of drift.
const REINDEX_HOUR = 3;
let timer: ReturnType<typeof setTimeout> | null = null;
function msUntilNextHour(hour: number): number {
const now = new Date();
const next = new Date(now);
next.setHours(hour, 0, 0, 0);
if (next <= now) next.setDate(next.getDate() + 1);
return next.getTime() - now.getTime();
}
export function startNightlyReindex(): void {
const schedule = () => {
const ms = msUntilNextHour(REINDEX_HOUR);
const at = new Date(Date.now() + ms);
console.log(
`[music] nightly full reindex scheduled for ${at.toLocaleString()} (in ${(ms / 3_600_000).toFixed(1)}h)`,
);
timer = setTimeout(async () => {
console.log('[music] nightly full reindex starting');
try {
await reindexFull();
} catch (err) {
console.error('[music] nightly full reindex error:', err instanceof Error ? err.message : err);
}
schedule(); // reschedule for the following night
}, ms);
};
schedule();
}
export function stopNightlyReindex(): void {
if (timer) {
clearTimeout(timer);
timer = null;
}
}
-110
View File
@@ -1,110 +0,0 @@
import { stat } from 'node:fs/promises';
import { resolve, sep } from 'node:path';
import { homedir } from 'node:os';
// All processing lives here (the platform is just a proxy). Files live under the owner's home — single
// super user, so HOME_DIR. Paths from the app are home-relative (e.g. "Music/Artist/Album/track.mp3"),
// exactly like file-browser /raw.
const ROOT_DIR = homedir();
const CONTENT_TYPES: Record<string, string> = {
mp3: 'audio/mpeg',
m4a: 'audio/mp4',
mp4: 'audio/mp4',
aac: 'audio/aac',
flac: 'audio/flac',
wav: 'audio/wav',
ogg: 'audio/ogg',
opus: 'audio/opus',
wma: 'audio/x-ms-wma',
};
// Probe duration once per file (keyed by absolute path + mtime) — the player makes many range requests
// per track, and we don't want to shell out to ffprobe on each one.
const durationCache = new Map<string, number>();
async function probeDuration(absPath: string, mtimeMs: number): Promise<number | undefined> {
const key = `${absPath}:${mtimeMs}`;
const cached = durationCache.get(key);
if (cached !== undefined) return cached;
try {
const proc = Bun.spawn(
[
'ffprobe',
'-v',
'error',
'-show_entries',
'format=duration',
'-of',
'default=noprint_wrappers=1:nokey=1',
absPath,
],
{ stdout: 'pipe', stderr: 'ignore' },
);
const out = (await new Response(proc.stdout).text()).trim();
await proc.exited;
const d = parseFloat(out);
if (Number.isFinite(d) && d > 0) {
durationCache.set(key, d);
return d;
}
} catch {
/* ffprobe missing or failed — no duration header */
}
return undefined;
}
/** Resolve a home-relative path within ROOT_DIR; null if it escapes (traversal). */
function resolveWithinRoot(relPath: string): string | null {
const clean = relPath.replace(/^\/+/, '');
const abs = resolve(ROOT_DIR, clean);
if (abs !== ROOT_DIR && !abs.startsWith(ROOT_DIR + sep)) return null;
return abs;
}
/** Serve an audio file with byte-range support + an X-Audio-Duration header (ffprobe-derived). */
export async function streamAudioFile(relPath: string, rangeHeader: string | null): Promise<Response> {
const absPath = resolveWithinRoot(relPath);
if (!absPath) return new Response('Invalid path', { status: 400 });
let s;
try {
s = await stat(absPath);
} catch {
return new Response('Not found', { status: 404 });
}
if (!s.isFile()) return new Response('Not a file', { status: 404 });
const total = s.size;
const ext = absPath.slice(absPath.lastIndexOf('.') + 1).toLowerCase();
const contentType = CONTENT_TYPES[ext] ?? 'application/octet-stream';
const duration = await probeDuration(absPath, s.mtimeMs);
const file = Bun.file(absPath);
const baseHeaders: Record<string, string> = {
'Content-Type': contentType,
'Accept-Ranges': 'bytes',
...(duration ? { 'X-Audio-Duration': String(duration) } : {}),
};
if (rangeHeader) {
const m = rangeHeader.match(/bytes=(\d*)-(\d*)/);
if (m) {
const start = m[1] ? parseInt(m[1], 10) : 0;
const end = m[2] ? parseInt(m[2], 10) : total - 1;
if (Number.isNaN(start) || start < 0 || end >= total || start > end) {
return new Response('Invalid range', { status: 416, headers: { 'Content-Range': `bytes */${total}` } });
}
return new Response(file.slice(start, end + 1), {
status: 206,
headers: {
...baseHeaders,
'Content-Range': `bytes ${start}-${end}/${total}`,
'Content-Length': String(end - start + 1),
},
});
}
}
return new Response(file, { status: 200, headers: { ...baseHeaders, 'Content-Length': String(total) } });
}
@@ -9,7 +9,6 @@ import { appRegistryMetas as dashboardMetas } from '../apps/Dashboards';
import { appRegistryMetas as chatHistoryMetas } from '../apps/ChatHistory';
import { appRegistryMetas as widgetMetas } from '../apps/Widgets';
import { appRegistryMetas as desktopMetas } from '../apps/Desktop';
import { appRegistryMetas as musicMetas } from '../apps/Music';
import { appRegistryMetas as soulseekMetas } from '../apps/Soulseek';
import { appRegistryMetas as photosMetas } from '../apps/Photos';
import { appRegistryMetas as jellyfinMetas } from '../apps/Jellyfin';
@@ -33,7 +32,6 @@ export const apps = [
...chatHistoryMetas,
...widgetMetas,
...desktopMetas,
...musicMetas,
...soulseekMetas,
...photosMetas,
...jellyfinMetas,
@@ -1,84 +0,0 @@
import { useEffect, useMemo, useRef } from 'react';
import { Loader2, Music4 } from 'lucide-react';
import type { LyricLine } from './lyrics';
import { seekPlayer } from './player-time';
import { useActiveLyricIndex } from './useLyrics';
type LyricsPaneProps = {
lines: LyricLine[] | null;
synced: boolean;
loading: boolean;
};
/**
* The lyrics sheet. Synced (.lrc) lyrics centre, highlight the current line, auto-scroll and seek on
* click; plain (.txt) lyrics left-align and scroll by hand only.
*
* It takes no position prop: it subscribes to the player clock itself and only re-renders when the
* highlight moves, so the sixty-frames-a-second feed never reaches the DOM.
*/
export const LyricsPane = ({ lines, synced, loading }: LyricsPaneProps) => {
const scrollRef = useRef<HTMLDivElement>(null);
const lineRefs = useRef<(HTMLParagraphElement | null)[]>([]);
const activeIndex = useActiveLyricIndex(lines, synced);
// Keep the active line ~40% down the panel. scrollTop rather than scrollIntoView, which would also
// scroll every ancestor and drag the whole workspace.
useEffect(() => {
if (!synced || activeIndex < 0) return;
const box = scrollRef.current;
const el = lineRefs.current[activeIndex];
if (!box || !el) return;
box.scrollTo({ top: Math.max(0, el.offsetTop - box.clientHeight * 0.4), behavior: 'smooth' });
}, [activeIndex, synced]);
const rendered = useMemo(() => {
if (!lines) return null;
return lines.map((line, i) => {
const active = synced && i === activeIndex;
const seekable = synced && line.timeSec != null;
return (
<p
key={i}
ref={(el) => {
lineRefs.current[i] = el;
}}
onClick={seekable ? () => seekPlayer(line.timeSec!) : undefined}
className={[
'py-1 text-[15px] font-semibold leading-7 transition-colors duration-200',
synced ? 'text-center' : 'text-left text-foreground/85',
// Only the colour changes on the active line — no weight or size change, so nothing reflows
// and the sheet does not jitter as the highlight moves.
active ? 'text-foreground' : synced ? 'text-muted-foreground' : '',
seekable ? 'cursor-pointer hover:text-foreground/80' : '',
]
.filter(Boolean)
.join(' ')}
>
{line.text || (synced ? '♪' : ' ')}
</p>
);
});
}, [lines, synced, activeIndex]);
const empty = !loading && (!lines || !lines.length);
return (
<div ref={scrollRef} className="h-full overflow-y-auto px-6 pt-3">
{loading && (
<div className="flex h-full items-center justify-center text-muted-foreground">
<Loader2 size={20} className="animate-spin" />
</div>
)}
{empty && (
<div className="flex h-full flex-col items-center justify-center gap-2 text-muted-foreground">
<Music4 size={28} className="opacity-40" />
<p className="text-sm">No lyrics for this track.</p>
</div>
)}
{!loading && <div className="mx-auto max-w-2xl">{rendered}</div>}
{/* Tail so the last lines can still scroll up to the 40% mark. */}
{!loading && synced && <div style={{ height: '55%' }} />}
</div>
);
};
@@ -1,49 +0,0 @@
import { useClient } from 'hooks/useClient';
import { MicVocal } from 'lucide-react';
import { LyricsPane } from './LyricsPane';
import { useLyrics } from './useLyrics';
import { useLyricsOpen } from './useLyricsOpen';
import { useMusicPlayer } from './useMusicPlayer';
/**
* The right-hand half of the /music detail panel when lyrics are on. It follows the PLAYING track, not
* the browsed album — which is why it reads the player rather than taking props from the album view.
*/
export const LyricsPanel = () => {
const { token } = useClient();
const { current } = useMusicPlayer();
const [, toggleLyrics] = useLyricsOpen();
const lyrics = useLyrics(current?.albumRel ?? '', current?.file ?? '', true, token);
// `dark` is not decoration: the theme tokens are CSS variables scoped to a `.dark` ancestor, so marking
// this subtree re-points foreground/muted-foreground/border at their dark values. Without it a light
// theme would paint near-black text on the black sheet.
return (
<div className="dark flex h-full flex-col bg-black">
<div className="flex shrink-0 items-center gap-2 border-b border-border px-4 py-2">
<MicVocal size={15} className="shrink-0 text-muted-foreground" />
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium text-foreground">{current?.title ?? current?.file ?? 'Lyrics'}</p>
{current?.artist && <p className="truncate text-xs text-muted-foreground">{current.artist}</p>}
</div>
<button
type="button"
onClick={toggleLyrics}
title="Hide lyrics"
className="shrink-0 cursor-pointer text-xs text-muted-foreground hover:text-foreground"
>
Close
</button>
</div>
<div className="min-h-0 flex-1">
{current ? (
<LyricsPane lines={lyrics.lines} synced={lyrics.synced} loading={lyrics.loading} />
) : (
<div className="flex h-full items-center justify-center px-6 text-center text-sm text-muted-foreground">
Play something to see its lyrics.
</div>
)}
</div>
</div>
);
};
@@ -1,103 +0,0 @@
import { useRef } from 'react';
import { useClient } from 'hooks/useClient';
import { MicVocal, Pause, Play } from 'lucide-react';
import { SeekBar } from '../apps/FileViewer/renderers/SeekBar';
import { coverUrl, fmtClock } from '../apps/Music/shared';
import { seekPlayer } from './player-time';
import { useLyricsOpen } from './useLyricsOpen';
import { useMusicPlayer } from './useMusicPlayer';
import { usePlayerClock } from './usePlayerClock';
/**
* The player, reduced to what the /music screen does not already show. The album view has the transport
* and the tracklist, so this is the scrubber — plus play/pause and the lyrics toggle, which are the two
* controls you can still want while browsing an album that ISN'T the one playing.
*
* It sits inside the detail panel, which is why the full dock hides on /music: two bars would be one bar
* too many, and the dock's own row costs the workspace its height on every screen.
*/
export const MusicMiniBar = () => {
const { token } = useClient();
const { current, playing, toggle } = useMusicPlayer();
const [lyricsOpen, toggleLyrics] = useLyricsOpen();
const { position, duration } = usePlayerClock();
const barRef = useRef<HTMLDivElement>(null);
if (!current) return null;
const onSeekDown = (ev: React.MouseEvent<HTMLDivElement>) => {
const seekAt = (clientX: number) => {
const bar = barRef.current;
if (!bar || !duration) return;
const rect = bar.getBoundingClientRect();
seekPlayer(Math.max(0, Math.min(1, (clientX - rect.left) / rect.width)) * duration);
};
ev.preventDefault();
seekAt(ev.clientX);
const onMove = (moveEv: MouseEvent) => seekAt(moveEv.clientX);
const onUp = () => {
window.removeEventListener('mousemove', onMove);
window.removeEventListener('mouseup', onUp);
};
window.addEventListener('mousemove', onMove);
window.addEventListener('mouseup', onUp);
};
return (
<div className="flex shrink-0 items-center gap-3 border-t border-border bg-card/60 px-4 py-2">
<div className="flex h-9 w-9 shrink-0 items-center justify-center overflow-hidden rounded bg-muted">
<img
src={coverUrl(current.albumRel, token)}
alt=""
className="h-full w-full object-cover"
onError={(ev) => {
(ev.currentTarget as HTMLImageElement).style.visibility = 'hidden';
}}
/>
</div>
<button
type="button"
onClick={toggle}
title={playing ? 'Pause' : 'Play'}
className="flex h-8 w-8 shrink-0 cursor-pointer items-center justify-center rounded-full bg-primary text-primary-foreground hover:opacity-90 active:scale-95"
>
{playing ? <Pause size={15} /> : <Play size={15} className="ml-0.5" />}
</button>
<div className="hidden w-48 min-w-0 shrink-0 sm:block">
<p className="truncate text-xs font-medium text-foreground">{current.title ?? current.file}</p>
{current.artist && <p className="truncate text-[11px] text-muted-foreground">{current.artist}</p>}
</div>
<span className="w-10 shrink-0 text-right font-mono text-[10px] tabular-nums text-muted-foreground">
{fmtClock(position)}
</span>
<div className="min-w-0 flex-1">
<SeekBar
barRef={barRef}
onSeekDown={onSeekDown}
pct={duration ? (position / duration) * 100 : 0}
trackClass="bg-muted"
fillClass="bg-primary"
thumbClass="border-background"
/>
</div>
<span className="w-10 shrink-0 font-mono text-[10px] tabular-nums text-muted-foreground">
{fmtClock(duration)}
</span>
<button
type="button"
onClick={toggleLyrics}
title={lyricsOpen ? 'Hide lyrics' : 'Show lyrics'}
aria-pressed={lyricsOpen}
className={`shrink-0 cursor-pointer p-1 hover:text-foreground ${
lyricsOpen ? 'text-primary' : 'text-muted-foreground'
}`}
>
<MicVocal size={16} />
</button>
</div>
);
};
@@ -4,8 +4,8 @@ import { useClient } from 'hooks/useClient';
import { useCapabilities } from 'hooks/useCapabilities';
import { Play, Pause, SkipBack, SkipForward, X, Volume2, VolumeX, Loader2, MicVocal } from 'lucide-react';
import { SeekBar } from '../apps/FileViewer/renderers/SeekBar';
import { MusicHeart } from '../apps/Music/MusicHeart';
import { fmtClock, musicPath, sortTracks, trackHomePath, type AlbumMeta, type NowPlaying } from '../apps/Music/shared';
import { MusicHeart } from './MusicHeart';
import { fmtClock, musicPath, sortTracks, trackHomePath, type AlbumMeta, type NowPlaying } from './shared';
import { useMusicPlayer, type PlayerTrack } from './useMusicPlayer';
import { GaplessEngine, type EngineTrack } from './gapless-engine';
import { publishPlayerTime, registerPlayerSeek } from './player-time';
@@ -1,2 +1,50 @@
export * from './useMusicPlayer';
export * from './MusicPlayerHost';
// The music player the SHELL hosts.
//
// ── Why this is still in the platform after music became a plugin ──
//
// Music was extracted on 2026-08-15 (`plugins/music/`) and this directory deliberately did not go with
// it. It is the one seam that extraction could not close, and the reason is not the overlay — it is the
// state:
//
// `useMusicPlayer` is imported from `officerdev` by `src/workspaces/widgets/MusicPlayer/`, the
// dashboard widget, which is out of scope and stays. The platform cannot import from a plugin, so the
// player state stays here whatever is decided about the UI around it — and two copies of it would mean
// two audio engines fighting over one pair of speakers.
//
// Given the state had to stay, the engine and the bar stayed with it rather than being split from the
// thing they drive. `MusicPlayerHost` is mounted once by `DashboardLayout`, OUTSIDE `<Routes>`, which is
// what makes playback survive navigation — and a plugin has no way to ask for that. Contributing one
// would mean a shell slot that renders a plugin-provided component on every route, which is exactly the
// escape hatch the plugin system deleted on purpose: there is no way to export a component, and that is
// what makes "every plugin route is a Workspace" a property of the shape rather than a rule to remember.
//
// The seam is inert without the plugin. `MusicPlayerHost` gates on `can('music')`, and `music` is now the
// plugin's permission — registered at install, gone at uninstall — so the overlay switches itself off
// with the plugin and no code here knows why.
//
// ── What the plugin imports, and from where ──
//
// The player API is below, on the `officerdev` barrel. The library VOCABULARY — `shared.ts`, the paths,
// sorting and tag shapes — is not: it declares `DirEntry`, `Track` and `Manifest`, names the barrel
// already spends on the FileBrowser. `plugins/music/web/shared.ts` takes it from the package's declared
// `officerdev/MusicPlayer/shared` subpath instead, which keeps one definition without renaming a type on
// its way through a barrel.
export { useMusicPlayer } from './useMusicPlayer';
export type { PlayerTrack, MusicPlayerState } from './useMusicPlayer';
export { MusicPlayerHost } from './MusicPlayerHost';
export { MusicHeart } from './MusicHeart';
export { useMusicFavorites } from './useMusicFavorites';
// The engine↔UI bridge. Module-level singletons on purpose: the lyrics pane and the /music scrubber live
// in another React tree from the host that owns the engine, so they meet here rather than through props.
export {
publishPlayerTime,
subscribePlayerTime,
registerPlayerSeek,
seekPlayer,
getPlayerTime,
getPlayerDuration,
} from './player-time';
export { useLyricsOpen } from './useLyricsOpen';
@@ -1,73 +0,0 @@
import { describe, expect, test } from 'bun:test';
import { parseLyrics, activeLineIndex } from './lyrics';
describe('parseLyrics', () => {
test('plain text is not synced and keeps every line, blanks included', () => {
const { synced, lines } = parseLyrics('first\n\n second \n');
expect(synced).toBe(false);
expect(lines.map((l) => l.text)).toEqual(['first', '', 'second', '']);
expect(lines.every((l) => l.timeSec === undefined)).toBe(true);
});
test('lrc timestamps parse to seconds, with hundredths', () => {
const { synced, lines } = parseLyrics('[00:12.50]hello\n[01:03]world');
expect(synced).toBe(true);
expect(lines).toEqual([
{ timeSec: 12.5, text: 'hello' },
{ timeSec: 63, text: 'world' },
]);
});
test('a single-digit fraction is tenths, not thousandths', () => {
expect(parseLyrics('[00:01.5]x').lines[0]?.timeSec).toBe(1.5);
});
test('metadata tags are dropped', () => {
const { lines } = parseLyrics('[ar:Artist]\n[ti:Title]\n[00:01.00]real');
expect(lines).toEqual([{ timeSec: 1, text: 'real' }]);
});
test('several stamps on one line become several lines, sorted by time', () => {
const { lines } = parseLyrics('[02:00.00][00:30.00]chorus\n[01:00.00]verse');
expect(lines).toEqual([
{ timeSec: 30, text: 'chorus' },
{ timeSec: 60, text: 'verse' },
{ timeSec: 120, text: 'chorus' },
]);
});
test('an untimed line inside a synced file survives, but blanks do not', () => {
const { lines } = parseLyrics('[00:01.00]a\n\nspoken\n');
expect(lines.map((l) => l.text)).toEqual(['spoken', 'a']);
});
test('an empty timed line is kept — it is a musical rest', () => {
expect(parseLyrics('[00:10.00]').lines).toEqual([{ timeSec: 10, text: '' }]);
});
});
describe('activeLineIndex', () => {
const lines = [
{ timeSec: 10, text: 'a' },
{ timeSec: 20, text: 'b' },
{ timeSec: 30, text: 'c' },
];
test('-1 before the first line', () => {
expect(activeLineIndex(lines, 0)).toBe(-1);
});
test('the 0.2s lookahead highlights fractionally early', () => {
expect(activeLineIndex(lines, 9.7)).toBe(-1);
expect(activeLineIndex(lines, 9.9)).toBe(0);
});
test('holds the last line past the end', () => {
expect(activeLineIndex(lines, 25)).toBe(1);
expect(activeLineIndex(lines, 9999)).toBe(2);
});
test('untimed lines never become active', () => {
expect(activeLineIndex([{ text: 'x' }, { timeSec: 5, text: 'y' }], 60)).toBe(1);
});
});
@@ -1,64 +0,0 @@
/**
* Parse lyrics text into displayable lines. `.lrc` carries `[mm:ss.xx]` timestamps (possibly several per
* line, e.g. repeated choruses) and metadata tags ([ar:], [ti:], …) which are dropped. `.txt` is plain.
* A synced result is sorted by time so the active-line lookup is a simple scan.
*
* Ported from the mobile app (packages/core/src/services/lyrics.ts) — same file format, same server,
* so the two must agree on what a line is.
*/
export type LyricLine = { timeSec?: number; text: string };
export type ParsedLyrics = { synced: boolean; lines: LyricLine[] };
const TIME_RE = /\[(\d{1,2}):(\d{2})(?:[.:](\d{1,3}))?\]/g;
const META_RE = /^\[(ar|ti|al|by|offset|length|re|ve|au|la|id):/i;
/**
* Lines are treated as synced whenever the text actually contains `[mm:ss]` timestamps — the server's
* `X-Lyrics-Format` header is not trusted (it need not survive a proxy, and embedded lyrics carrying
* timestamps should sync regardless of which file they came from). No timestamps → plain text.
*/
export function parseLyrics(text: string): ParsedLyrics {
if (!/\[\d{1,2}:\d{2}/.test(text)) {
return { synced: false, lines: text.split(/\r?\n/).map((t) => ({ text: t.trim() })) };
}
const out: LyricLine[] = [];
for (const rawLine of text.split(/\r?\n/)) {
if (META_RE.test(rawLine.trim())) continue;
const stamps: number[] = [];
let m: RegExpExecArray | null;
TIME_RE.lastIndex = 0;
while ((m = TIME_RE.exec(rawLine)) !== null) {
const min = Number(m[1]);
const sec = Number(m[2]);
// "[00:12.3]" is three tenths, not three milliseconds — pad right before reading as thousandths.
const frac = m[3] ? Number(`${m[3]}00`.slice(0, 3)) / 1000 : 0;
stamps.push(min * 60 + sec + frac);
}
const lyric = rawLine.replace(TIME_RE, '').trim();
if (!stamps.length) {
if (lyric) out.push({ text: lyric }); // a plain line inside an otherwise-synced file
continue;
}
for (const t of stamps) out.push({ timeSec: t, text: lyric });
}
const synced = out.some((l) => l.timeSec != null);
if (synced) out.sort((a, b) => (a.timeSec ?? 0) - (b.timeSec ?? 0));
return { synced, lines: out };
}
/**
* Index of the active line for a playback position (synced only); -1 before the first line. The 0.2s
* lookahead lands the highlight fractionally early, which reads as on-time — arriving late reads as lag.
*/
export function activeLineIndex(lines: LyricLine[], positionSec: number): number {
let idx = -1;
for (let i = 0; i < lines.length; i++) {
const t = lines[i]?.timeSec;
if (t == null) continue;
if (t <= positionSec + 0.2) idx = i;
else break;
}
return idx;
}
@@ -1,78 +0,0 @@
import { useEffect, useState } from 'react';
import type { LyricLine } from './lyrics';
import { activeLineIndex, parseLyrics } from './lyrics';
import { getPlayerTime, subscribePlayerTime } from './player-time';
export type UseLyrics = {
loading: boolean;
/** null while loading, and when the track has none. */
lines: LyricLine[] | null;
synced: boolean;
};
/**
* Fetch + parse the current track's lyrics. Gated on `enabled` so nothing is requested until the pane
* is actually open — the dock lives on every screen and most listening happens with it closed.
*
* Deliberately NOT gated on an index "has lyrics" flag the way the mobile app does: the web player's
* queue carries only what it needs to stream, and a 404 for a track without lyrics is cheaper than
* threading that flag through every producer of a queue.
*
* Auth goes in the query string rather than a header, matching how this component already builds its
* /stream and /cover URLs.
*/
export const useLyrics = (albumRel: string, file: string, enabled: boolean, token: string | null): UseLyrics => {
const [state, setState] = useState<UseLyrics>({ loading: false, lines: null, synced: false });
useEffect(() => {
if (!enabled || !albumRel || !file) {
setState({ loading: false, lines: null, synced: false });
return;
}
const url =
`/api/music/lyrics?path=${encodeURIComponent(albumRel)}&file=${encodeURIComponent(file)}` +
(token ? `&token=${encodeURIComponent(token)}` : '');
const ctrl = new AbortController();
setState({ loading: true, lines: null, synced: false });
fetch(url, { signal: ctrl.signal })
.then(async (res) => {
// 404 is the ordinary "this track has no lyrics" answer, not an error worth surfacing.
if (!res.ok) return setState({ loading: false, lines: null, synced: false });
const { synced, lines } = parseLyrics(await res.text());
setState({ loading: false, lines, synced });
})
.catch(() => {
if (!ctrl.signal.aborted) setState({ loading: false, lines: null, synced: false });
});
return () => ctrl.abort();
}, [albumRel, file, enabled, token]);
return state;
};
/**
* Index of the line to highlight, driven by the engine's position feed.
*
* The feed ticks every animation frame; this re-renders only when the index actually moves — React bails
* out of an identical setState — so a synced sheet repaints about once a line instead of sixty times a
* second, even though it lives nowhere near the component that owns the clock.
*/
export const useActiveLyricIndex = (lines: LyricLine[] | null, synced: boolean): number => {
const [index, setIndex] = useState(-1);
useEffect(() => {
if (!synced || !lines) {
setIndex(-1);
return;
}
setIndex(activeLineIndex(lines, getPlayerTime()));
return subscribePlayerTime((sec) => {
const next = activeLineIndex(lines, sec);
setIndex((prev) => (prev === next ? prev : next));
});
}, [lines, synced]);
return index;
};
@@ -1,16 +0,0 @@
import { useEffect, useState } from 'react';
import { getPlayerDuration, getPlayerTime, subscribePlayerTime } from './player-time';
/**
* Position + duration, straight off the engine's per-frame feed.
*
* A scrubber genuinely wants every frame, so — unlike the lyrics — this does re-render at 60fps. Keep it
* in the smallest component that draws the bar: whatever calls this hook repaints with it.
*/
export const usePlayerClock = () => {
const [clock, setClock] = useState(() => ({ position: getPlayerTime(), duration: getPlayerDuration() }));
useEffect(() => subscribePlayerTime((position, duration) => setClock({ position, duration })), []);
return clock;
};
@@ -1,202 +0,0 @@
import { useState, type ReactNode } from 'react';
import { Link, useNavigate } from 'react-router';
import { useClient } from 'hooks/useClient';
import { usePanelChannel } from 'hooks/usePanelChannel';
import { Heart, User, Disc3, Music, ChevronRight, X } from 'lucide-react';
import { useMusicPlayer, type PlayerTrack } from '../../MusicPlayer';
import { MusicHeart } from './MusicHeart';
import { useMusicFavorites } from './useMusicFavorites';
import {
MUSIC_FAV_CHANNEL,
coverUrl,
musicPath,
parseAlbumName,
sortTracks,
toRel,
type AlbumMeta,
type FavoriteKind,
} from './shared';
// The user's favorited artists / albums / tracks, grouped — shown in the right panel. Keys follow the
// favorites convention: album/artist are music-relative ("Albums/…"), tracks are home paths
// ("Music/…/file"). An album/artist row is a link into the library; a track row plays, so it stays a
// button — it mutates rather than navigates, even though it also moves the library to the album.
export const FavoritesView = () => {
const { get, token } = useClient();
const navigate = useNavigate();
const player = useMusicPlayer();
const { favorites } = useMusicFavorites();
const [, setFavOpen] = usePanelChannel<boolean>(MUSIC_FAV_CHANNEL, false);
const playTrack = async (homePath: string) => {
const cut = homePath.lastIndexOf('/');
const albumHome = homePath.slice(0, cut);
const file = homePath.slice(cut + 1);
const albumRel = toRel(albumHome);
try {
const meta = await get<AlbumMeta>(`/music/meta?path=${encodeURIComponent(albumRel)}`);
const q: PlayerTrack[] = sortTracks(meta.tracks).map((t) => ({ albumRel, file: t.file, title: t.title, artist: t.artist }));
player.playQueue(q, Math.max(0, q.findIndex((t) => t.file === file)));
} catch {
player.playQueue([{ albumRel, file }], 0);
}
navigate(musicPath(albumRel));
setFavOpen(false);
};
const empty = !favorites.artists.length && !favorites.albums.length && !favorites.tracks.length;
return (
<div className="h-full overflow-y-auto p-4 md:p-6">
<div className="mb-4 flex items-center gap-2">
<Heart size={20} className="fill-red-500 text-red-500" />
<h1 className="flex-1 text-2xl font-bold text-foreground">Favorites</h1>
<button
type="button"
onClick={() => setFavOpen(false)}
className="cursor-pointer rounded p-1.5 text-muted-foreground hover:bg-muted hover:text-foreground"
>
<X size={18} />
</button>
</div>
{empty ? (
<div className="flex flex-col items-center justify-center gap-3 py-24 text-center">
<Heart size={44} className="text-muted-foreground/30" />
<p className="text-sm text-muted-foreground">No favorites yet. Click the heart on any artist, album or track.</p>
</div>
) : (
<div className="flex flex-col gap-6">
<Section title="Artists" count={favorites.artists.length}>
{favorites.artists.map((key) => {
const segs = key.split('/');
return (
<FavRow
key={key}
kind="artist"
favKey={key}
cover={coverUrl(key, token)}
fallback={<User size={18} className="text-muted-foreground" />}
title={segs[segs.length - 1] ?? key}
to={musicPath(key)}
onClick={() => setFavOpen(false)}
chevron
/>
);
})}
</Section>
<Section title="Albums" count={favorites.albums.length}>
{favorites.albums.map((key) => {
const segs = key.split('/');
const { title, year } = parseAlbumName(segs[segs.length - 1] ?? key);
const artist = segs.length >= 3 ? segs[1] : '';
return (
<FavRow
key={key}
kind="album"
favKey={key}
cover={coverUrl(key, token)}
fallback={<Disc3 size={18} className="text-muted-foreground" />}
title={title}
subtitle={[artist, year].filter(Boolean).join(' · ')}
to={musicPath(key)}
onClick={() => setFavOpen(false)}
chevron
/>
);
})}
</Section>
<Section title="Tracks" count={favorites.tracks.length}>
{favorites.tracks.map((key) => {
const segs = key.split('/');
const base = segs[segs.length - 1] ?? key;
const albumRel = toRel(key.slice(0, Math.max(0, key.lastIndexOf('/'))));
const album = segs.length >= 2 ? parseAlbumName(segs[segs.length - 2]!).title : '';
return (
<FavRow
key={key}
kind="track"
favKey={key}
cover={coverUrl(albumRel, token)}
fallback={<Music size={18} className="text-muted-foreground" />}
title={base.replace(/\.[^/.]+$/, '')}
subtitle={album}
onClick={() => playTrack(key)}
/>
);
})}
</Section>
</div>
)}
</div>
);
};
const Section = ({ title, count, children }: { title: string; count: number; children: ReactNode }) =>
count ? (
<div>
<h2 className="mb-1 px-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
{title} <span className="text-muted-foreground/60">{count}</span>
</h2>
<div className="flex flex-col">{children}</div>
</div>
) : null;
// `to` makes the row an anchor (an album or artist, which is a place); without it the row is a button
// (a track, which plays). The heart stays a sibling either way — it must not be inside either one.
const FavRow = ({
kind,
favKey,
cover,
fallback,
title,
subtitle,
to,
onClick,
chevron,
}: {
kind: FavoriteKind;
favKey: string;
cover: string;
fallback: ReactNode;
title: string;
subtitle?: string;
to?: string;
onClick: () => void;
chevron?: boolean;
}) => {
const [failed, setFailed] = useState(false);
const inner = (
<>
<div className="flex h-11 w-11 shrink-0 items-center justify-center overflow-hidden rounded bg-muted">
{cover && !failed ? (
<img src={cover} alt="" className="h-full w-full object-cover" onError={() => setFailed(true)} />
) : (
fallback
)}
</div>
<div className="min-w-0 flex-1">
<span className="block truncate text-sm text-foreground">{title}</span>
{subtitle ? <span className="block truncate text-xs text-muted-foreground">{subtitle}</span> : null}
</div>
</>
);
const cls = 'flex min-w-0 flex-1 cursor-pointer items-center gap-3 text-left';
return (
<div className="group flex items-center gap-3 rounded-md px-2 py-1.5 hover:bg-muted">
{to ? (
<Link to={to} onClick={onClick} className={cls}>
{inner}
</Link>
) : (
<button type="button" onClick={onClick} className={cls}>
{inner}
</button>
)}
<MusicHeart kind={kind} favKey={favKey} size={16} className="shrink-0" />
{chevron ? <ChevronRight size={16} className="shrink-0 text-muted-foreground" /> : null}
</div>
);
};
@@ -1,226 +0,0 @@
import { useState, useEffect, type ReactNode } from 'react';
import { Link } from 'react-router';
import { useClient } from 'hooks/useClient';
import { usePanelChannel } from 'hooks/usePanelChannel';
import { Library, Music2, ChevronLeft, Folder, Search, X, RefreshCw, Heart } from 'lucide-react';
import {
MUSIC_ROOT,
MUSIC_FAV_CHANNEL,
MUSIC_RESYNC_CHANNEL,
coverUrl,
fuzzyMatch,
musicParentPath,
musicPath,
toRel,
useMusicCwd,
type LsResult,
type Manifest,
type ManifestAlbum,
} from './shared';
// A row's leading thumbnail: the folder's indexed cover (its folder.jpg/cover.jpg, server-compressed),
// falling back to an icon when it has none or the image fails to load.
const RowThumb = ({ src, fallback }: { src: string | null; fallback: ReactNode }) => {
const [failed, setFailed] = useState(false);
useEffect(() => setFailed(false), [src]);
return (
<div className="flex h-10 w-10 shrink-0 items-center justify-center overflow-hidden rounded bg-muted">
{src && !failed ? (
<img src={src} alt="" className="h-full w-full object-cover" onError={() => setFailed(true)} />
) : (
fallback
)}
</div>
);
};
// Hidden files/folders (dotfiles like .claude, .git) never belong in the library listing.
const visibleDirs = (r: LsResult) =>
r.entries
.filter((e) => e.type === 'directory' && !e.name.startsWith('.'))
.map((e) => e.name)
.sort();
// Left panel of the /music workspace — a single-column drill-down LIST navigator (libraries →
// artists → albums as list items; never a grid). Every row is a link to `/music?path=…`; MusicDetail
// (right panel) reads the same param and renders the rich detail (covers/grids/tracklist).
export const MusicBrowser = () => {
const { get, post, token } = useClient();
const cwd = useMusicCwd();
const [favOpen, setFavOpen] = usePanelChannel<boolean>(MUSIC_FAV_CHANNEL, false);
const [resync, setResync] = usePanelChannel<number>(MUSIC_RESYNC_CHANNEL, 0);
const [manifest, setManifest] = useState<Record<string, ManifestAlbum>>({});
const [libraries, setLibraries] = useState<string[]>([]);
const [folders, setFolders] = useState<string[]>([]);
const [query, setQuery] = useState('');
const [reindexing, setReindexing] = useState(false);
// Refetch manifest + libraries on mount and whenever a reindex bumps the resync nonce.
useEffect(() => {
get<Manifest>('/music/manifest')
.then((m) => setManifest(m.albums))
.catch(() => setManifest({}));
get<LsResult>(`/file-browser/ls?path=${encodeURIComponent(MUSIC_ROOT)}`)
.then((r) => setLibraries(visibleDirs(r)))
.catch(() => setLibraries([]));
}, [resync]);
// The container folder whose children we list = the current folder, or its parent when the current
// path is an album leaf (so its siblings stay listed while the right shows the tracklist).
const rel = toRel(cwd);
const isAlbum = (manifest[rel]?.tracks ?? 0) > 0;
const navFolder = !cwd ? null : isAlbum ? cwd.split('/').slice(0, -1).join('/') : cwd;
const selected = cwd ? cwd.split('/').pop() : null;
useEffect(() => {
if (!navFolder) {
setFolders([]);
return;
}
let cancelled = false;
get<LsResult>(`/file-browser/ls?path=${encodeURIComponent(navFolder)}`)
.then((r) => {
if (!cancelled) setFolders(visibleDirs(r));
})
.catch(() => {});
return () => {
cancelled = true;
};
}, [navFolder, resync]);
// Start each folder unfiltered.
useEffect(() => setQuery(''), [navFolder]);
// Trigger a server-side library rebuild, then bump the resync nonce so BOTH panels refetch their
// manifest / listings / meta (a fresh Date.now() value guarantees the effects re-run).
const reindex = async () => {
if (reindexing) return;
setReindexing(true);
try {
await post('/music/reindex');
setResync(Date.now());
} finally {
setReindexing(false);
}
};
const crumbs = navFolder ? navFolder.slice(MUSIC_ROOT.length + 1).split('/') : [];
const coverFor = (childRel: string) => (manifest[childRel]?.cover ? coverUrl(childRel, token) : null);
const shownLibraries = libraries.filter((l) => fuzzyMatch(query, l));
const shownFolders = folders.filter((f) => fuzzyMatch(query, f));
return (
<div className="flex h-full flex-col overflow-y-auto p-3">
<div className="flex items-center gap-1 pb-2">
{/* Favorites is a view of this panel, not a location, so it stays a channel — but going home
has to close it explicitly: the route doesn't change when you are already at the root. */}
<Link
to="/music"
onClick={() => setFavOpen(false)}
className="flex flex-1 cursor-pointer items-center gap-2 px-2 text-left text-foreground"
>
<Music2 size={20} className="text-primary" />
<span className="text-lg font-semibold">Music</span>
</Link>
<button
type="button"
onClick={() => setFavOpen(!favOpen)}
title="Favorites"
className="flex h-8 w-8 shrink-0 cursor-pointer items-center justify-center rounded-md hover:bg-muted"
>
<Heart
size={18}
className={favOpen ? 'fill-red-500 text-red-500' : 'text-muted-foreground hover:text-foreground'}
/>
</button>
</div>
<div className="mb-2 flex items-center gap-1.5 px-1">
<div className="flex h-8 min-w-0 flex-1 items-center gap-1.5 rounded-md border border-border bg-background px-2">
<Search size={13} className="shrink-0 text-muted-foreground" />
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Filter…"
className="min-w-0 flex-1 bg-transparent text-sm text-foreground placeholder:text-muted-foreground focus:outline-none"
/>
{query && (
<button
type="button"
onClick={() => setQuery('')}
className="shrink-0 text-muted-foreground hover:text-foreground"
>
<X size={13} />
</button>
)}
</div>
<button
type="button"
onClick={reindex}
disabled={reindexing}
title="Reindex library"
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-md border border-border text-muted-foreground hover:bg-muted hover:text-foreground disabled:opacity-50"
>
<RefreshCw size={14} className={reindexing ? 'animate-spin' : ''} />
</button>
</div>
{!navFolder ? (
<>
<div className="flex items-center gap-2 px-2 pb-1 text-xs font-medium uppercase tracking-wide text-muted-foreground">
<Library size={13} /> Libraries
</div>
<div className="flex flex-col gap-1.5">
{shownLibraries.map((lib) => (
<Link
key={lib}
to={musicPath(lib)}
className="flex items-center gap-3 truncate rounded-md px-2 py-2 text-left text-base text-muted-foreground hover:bg-muted/60 hover:text-foreground"
>
<RowThumb src={coverFor(lib)} fallback={<Library size={18} className="text-muted-foreground" />} />
<span className="truncate">{lib}</span>
</Link>
))}
{!shownLibraries.length && (
<span className="px-2 text-base text-muted-foreground">{query ? 'No matches' : 'No libraries'}</span>
)}
</div>
</>
) : (
<>
<Link
to={musicParentPath(toRel(navFolder))}
className="mb-1 flex items-center gap-1 truncate px-2 py-1 text-left text-xs text-muted-foreground hover:text-foreground"
>
<ChevronLeft size={13} className="shrink-0" />
<span className="truncate">{crumbs.join(' / ')}</span>
</Link>
<div className="flex flex-col gap-1.5">
{shownFolders.map((f) => (
<Link
key={f}
to={musicPath(toRel(`${navFolder}/${f}`))}
className={`flex items-center gap-3 truncate rounded-md px-2 py-2 text-left text-base ${
selected === f
? 'bg-muted text-foreground'
: 'text-muted-foreground hover:bg-muted/60 hover:text-foreground'
}`}
>
<RowThumb
src={coverFor(toRel(`${navFolder}/${f}`))}
fallback={<Folder size={18} className="text-muted-foreground" />}
/>
<span className="truncate">{f}</span>
</Link>
))}
{!shownFolders.length && (
<span className="px-2 py-2 text-base text-muted-foreground">
{query ? 'No matches' : 'No subfolders'}
</span>
)}
</div>
</>
)}
</div>
);
};
@@ -1,427 +0,0 @@
import type { ReactNode } from 'react';
import { createContext, useContext, useState, useEffect, useRef } from 'react';
import { Link, useNavigate } from 'react-router';
import { useClient } from 'hooks/useClient';
import { usePanelChannel } from 'hooks/usePanelChannel';
import { Play, Pause, ChevronLeft, MicVocal, Volume2 } from 'lucide-react';
import type { LayoutNode, PanelComponents } from '../../components/Workspace';
import { WorkspaceLayout } from '../../components/Workspace';
import { MusicHeart } from './MusicHeart';
import { FavoritesView } from './FavoritesView';
import { useMusicPlayer } from '../../MusicPlayer';
import type { PlayerTrack } from '../../MusicPlayer';
import { LyricsPanel } from '../../MusicPlayer/LyricsPanel';
import { MusicMiniBar } from '../../MusicPlayer/MusicMiniBar';
import { useLyricsOpen } from '../../MusicPlayer/useLyricsOpen';
import {
MUSIC_ROOT,
MUSIC_FAV_CHANNEL,
MUSIC_RESYNC_CHANNEL,
TYPE_ORDER,
coverUrl,
fmtDuration,
isAudio,
musicParentPath,
musicPath,
sortTracks,
toRel,
trackHomePath,
useMusicCwd,
type AlbumMeta,
type Discography,
type LsResult,
type Manifest,
type ManifestAlbum,
type Track,
} from './shared';
// Turning the lyrics on splits THIS panel in two rather than opening a panel of its own: the workspace
// system is a layout engine as well as a shell, so a nested WorkspaceLayout with a fixed layout and
// components keyed by panel id gets a resizable split with no persistence and no registry entries.
const LYRICS_LAYOUT: LayoutNode = {
type: 'group',
id: 'music-detail-split',
direction: 'horizontal',
children: [
{ node: { type: 'panel', id: 'music-detail-list', appType: null }, size: 62 },
{ node: { type: 'panel', id: 'music-detail-lyrics', appType: null }, size: 38 },
],
};
// The library view is rendered by MusicDetail itself and passed down through context, so toggling the
// split moves the same element rather than mounting a second copy — the fetched album, and every request
// that produced it, survives the toggle.
const LibraryViewContext = createContext<ReactNode>(null);
const LibraryViewPanel = () => <>{useContext(LibraryViewContext)}</>;
const LYRICS_PANELS: PanelComponents = {
'music-detail-list': LibraryViewPanel,
'music-detail-lyrics': LyricsPanel,
};
const keepLayout = () => {};
// Right panel of the /music workspace — renders the content of `/music?path=…`: an album (tracklist),
// an artist (album cards grouped by discography type), or a folder grid. Drilling in is a link, so it
// changes the address; playback goes through the app-wide player.
export const MusicDetail = () => {
const { token, get } = useClient();
const navigate = useNavigate();
const player = useMusicPlayer();
const cwd = useMusicCwd();
const [favOpen, setFavOpen] = usePanelChannel<boolean>(MUSIC_FAV_CHANNEL, false);
const [resync] = usePanelChannel<number>(MUSIC_RESYNC_CHANNEL, 0);
const [lyricsOpen, toggleLyrics] = useLyricsOpen();
const [manifest, setManifest] = useState<Record<string, ManifestAlbum>>({});
const [libraries, setLibraries] = useState<string[]>([]);
const [folders, setFolders] = useState<string[]>([]);
const [album, setAlbum] = useState<Track[] | null>(null);
const [disco, setDisco] = useState<Discography | null>(null);
const [loading, setLoading] = useState(false);
// On first mount with no location yet, open the currently-playing album — so a reload/return lands on
// the track you were listening to (the player itself restores via the saved now-playing snapshot).
// Once only, so it never yanks you back after you navigate away (e.g. up to the library root).
const autoNavRef = useRef(false);
useEffect(() => {
if (autoNavRef.current) return;
if (cwd) {
autoNavRef.current = true;
return;
}
if (player.current) {
autoNavRef.current = true;
// `replace`: landing on /music and being moved to the playing album is one arrival, not two, so
// Back should leave the screen rather than undo a jump the user never asked for.
navigate(musicPath(player.current.albumRel), { replace: true });
}
}, [player.current, cwd, navigate]);
// Navigating anywhere (left panel or from within Favorites) closes the Favorites view.
useEffect(() => {
setFavOpen(false);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [cwd]);
// Refetch manifest + libraries on mount and whenever a reindex bumps the resync nonce. A fresh manifest
// object identity also re-runs the [cwd, manifest] listing effect below, refreshing the folder grid /
// album meta for whatever is currently open — so the right panel updates in place, no nav required.
useEffect(() => {
get<Manifest>('/music/manifest')
.then((m) => setManifest(m.albums))
.catch(() => setManifest({}));
get<LsResult>(`/file-browser/ls?path=${encodeURIComponent(MUSIC_ROOT)}`)
.then((r) =>
setLibraries(
r.entries
.filter((e) => e.type === 'directory' && !e.name.startsWith('.'))
.map((e) => e.name)
.sort(),
),
)
.catch(() => setLibraries([]));
}, [resync]);
const rel = toRel(cwd);
const childRel = (name: string) => (rel ? `${rel}/${name}` : name);
useEffect(() => {
if (!cwd) {
setFolders([]);
setAlbum(null);
setDisco(null);
return;
}
let cancelled = false;
setLoading(true);
setAlbum(null);
setDisco(null);
setFolders([]);
get<LsResult>(`/file-browser/ls?path=${encodeURIComponent(cwd)}`)
.then(async (r) => {
if (cancelled) return;
setFolders(
r.entries
.filter((e) => e.type === 'directory' && !e.name.startsWith('.'))
.map((e) => e.name)
.sort(),
);
const audio = r.entries.filter((e) => e.type === 'file' && isAudio(e.name)).map((e) => e.name);
if (audio.length) {
try {
const meta = await get<AlbumMeta>(`/music/meta?path=${encodeURIComponent(rel)}`);
if (!cancelled) setAlbum(sortTracks(meta.tracks));
} catch {
if (!cancelled) setAlbum(audio.sort().map((f) => ({ file: f })));
}
} else if (manifest[rel]?.disco) {
try {
const d = await get<Discography>(`/music/discography?path=${encodeURIComponent(rel)}`);
if (!cancelled) setDisco(d);
} catch {
/* plain grid */
}
}
})
.catch(() => {})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [cwd, manifest]);
const playAlbum = async (albumRel: string, startIndex = 0) => {
try {
const meta = await get<AlbumMeta>(`/music/meta?path=${encodeURIComponent(albumRel)}`);
const queue: PlayerTrack[] = sortTracks(meta.tracks).map((t) => ({
albumRel,
file: t.file,
title: t.title,
artist: t.artist,
}));
player.playQueue(queue, startIndex);
} catch {
/* ignore */
}
};
const playCurrent = (i: number) => {
if (!album) return;
const queue: PlayerTrack[] = album.map((t) => ({ albumRel: rel, file: t.file, title: t.title, artist: t.artist }));
player.playQueue(queue, i);
};
const isCurrent = (albumRel: string, file: string) =>
player.current?.albumRel === albumRel && player.current?.file === file;
// The album's play button is the dock's play button when the dock is already on this album: same
// useGlobal state, so it shows pause while it plays and resumes where it stopped. It only starts the
// album from the top when something else (or nothing) is loaded.
const albumLoaded = !!album && player.current?.albumRel === rel;
const albumPlaying = albumLoaded && player.playing;
const toggleAlbum = () => (albumLoaded ? player.toggle() : playCurrent(0));
const crumbs = rel ? rel.split('/') : [];
// `r` is already the child's rel, so it is both the cover key and the link target — a library root and
// a nested album need no different treatment. Play and heart are siblings of the anchor, never inside it.
const Card = ({ r, name, playable }: { r: string; name: string; playable: boolean }) => (
<div className="group relative">
<Link
to={musicPath(r)}
className="flex w-full flex-col gap-2 rounded-lg bg-card/60 p-3 text-left transition-colors hover:bg-card"
>
<div className="aspect-square w-full overflow-hidden rounded-md bg-muted">
<img
src={coverUrl(r, token)}
alt=""
loading="lazy"
className="h-full w-full object-cover"
onError={(e) => {
(e.currentTarget as HTMLImageElement).style.visibility = 'hidden';
}}
/>
</div>
<span className="truncate text-sm font-medium text-foreground">{name}</span>
</Link>
{playable && (
<button
type="button"
onClick={() => playAlbum(r, 0)}
className="absolute bottom-14 right-4 flex h-10 w-10 translate-y-2 items-center justify-center rounded-full bg-primary text-primary-foreground opacity-0 shadow-lg transition-all group-hover:translate-y-0 group-hover:opacity-100 hover:scale-105"
>
<Play size={18} className="ml-0.5" />
</button>
)}
{playable && (
<MusicHeart
kind="album"
favKey={r}
size={18}
hoverReveal
className="absolute right-2 top-2 rounded-full bg-black/40 p-1.5 text-white"
/>
)}
</div>
);
const content = favOpen ? (
<div className="min-h-0 flex-1">
<FavoritesView />
</div>
) : (
<div className="min-h-0 flex-1 overflow-y-auto p-4 md:p-6">
{cwd && (
<Link
to={musicParentPath(rel)}
className="mb-4 flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground"
>
<ChevronLeft size={16} />
<span className="truncate">{crumbs.length ? crumbs.join(' / ') : cwd.split('/')[1]}</span>
</Link>
)}
{loading && <p className="text-sm text-muted-foreground">Loading</p>}
{/* Home — the library roots carry their own folder art (the manifest indexes them like any other
folder), so they get the same cards as everything else rather than a wall of flat tiles. */}
{!cwd && (
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-5">
{libraries.map((lib) => (
<Card key={lib} r={lib} name={lib} playable={(manifest[lib]?.tracks ?? 0) > 0} />
))}
</div>
)}
{/* Album */}
{album && (
<div className="flex flex-col gap-5">
<div className="flex items-end gap-5">
<div className="h-40 w-40 shrink-0 overflow-hidden rounded-lg bg-muted shadow-lg">
<img
src={coverUrl(rel, token)}
alt=""
className="h-full w-full object-cover"
onError={(e) => {
(e.currentTarget as HTMLImageElement).style.visibility = 'hidden';
}}
/>
</div>
<div className="min-w-0">
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">Album</p>
<h1 className="truncate text-3xl font-bold text-foreground">{crumbs[crumbs.length - 1]}</h1>
<p className="mt-1 truncate text-sm text-muted-foreground">
{crumbs[crumbs.length - 2] ?? ''} · {album.length} songs
</p>
<div className="mt-3 flex items-center gap-3">
<button
type="button"
onClick={toggleAlbum}
title={albumPlaying ? 'Pause' : 'Play'}
className="flex h-10 w-10 cursor-pointer items-center justify-center rounded-full bg-primary text-primary-foreground hover:scale-105"
>
{albumPlaying ? <Pause size={18} /> : <Play size={18} className="ml-0.5" />}
</button>
<MusicHeart kind="album" favKey={rel} size={24} className="p-1" />
<button
type="button"
onClick={toggleLyrics}
title={lyricsOpen ? 'Hide lyrics' : 'Show lyrics'}
aria-pressed={lyricsOpen}
className={`cursor-pointer p-1 hover:text-foreground ${
lyricsOpen ? 'text-primary' : 'text-muted-foreground'
}`}
>
<MicVocal size={22} />
</button>
</div>
</div>
</div>
<div className="flex flex-col">
{album.map((t, i) => {
const cur = isCurrent(rel, t.file);
const artist = t.artist ?? t.albumArtist ?? '';
const dur = fmtDuration(t.durationSec);
return (
<div
key={t.file}
className={`group flex items-center gap-3 rounded px-3 py-2 ${
cur ? 'bg-primary/10 text-primary' : 'text-foreground hover:bg-muted'
}`}
>
<button
type="button"
onClick={() => playCurrent(i)}
className="flex min-w-0 flex-1 items-center gap-3 text-left"
>
<span className="flex w-5 shrink-0 justify-end">
{cur ? (
<Volume2 size={15} className="text-primary" />
) : (
<span className="text-sm tabular-nums text-muted-foreground">{i + 1}</span>
)}
</span>
<span className="min-w-0 flex-1">
<span className={`block truncate text-sm ${cur ? 'font-medium' : ''}`}>{t.title ?? t.file}</span>
{artist && <span className="block truncate text-xs text-muted-foreground">{artist}</span>}
</span>
{dur && <span className="shrink-0 text-xs tabular-nums text-muted-foreground">{dur}</span>}
</button>
<MusicHeart
kind="track"
favKey={trackHomePath(rel, t.file)}
size={16}
hoverReveal
className="shrink-0"
/>
</div>
);
})}
</div>
</div>
)}
{/* Artist — discography sections */}
{disco && !album && (
<div className="flex flex-col gap-6">
<div className="flex items-center gap-3">
<h1 className="text-3xl font-bold text-foreground">{disco.artist}</h1>
<MusicHeart kind="artist" favKey={rel} size={22} className="p-1" />
</div>
{TYPE_ORDER.filter((type) => folders.some((f) => (disco.albums[f] ?? 'Other') === type)).map((type) => (
<section key={type}>
<h2 className="mb-2 text-lg font-semibold text-foreground">
{type === 'Studio' ? 'Studio Albums' : type}
</h2>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-5">
{folders
.filter((f) => (disco.albums[f] ?? 'Other') === type)
.map((f) => (
<Card key={f} r={childRel(f)} name={f} playable={(manifest[childRel(f)]?.tracks ?? 0) > 0} />
))}
</div>
</section>
))}
</div>
)}
{/* Grid — a library root, or an artist folder (crumbs>=2) whose albums aren't grouped by a
discography. Show an artist header + heart on the latter. */}
{!album && !disco && cwd && !loading && (
<div className="flex flex-col gap-6">
{crumbs.length >= 2 && (
<div className="flex items-center gap-3">
<h1 className="text-3xl font-bold text-foreground">{crumbs[crumbs.length - 1]}</h1>
<MusicHeart kind="artist" favKey={rel} size={22} className="p-1" />
</div>
)}
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-5">
{folders.map((f) => (
<Card key={f} r={childRel(f)} name={f} playable={(manifest[childRel(f)]?.tracks ?? 0) > 0} />
))}
{!folders.length && <p className="text-sm text-muted-foreground">Empty</p>}
</div>
</div>
)}
</div>
);
// The scrubber sits at the foot of this panel instead of the app-wide dock, which hides itself on
// /music: the album view already carries the transport, so all the dock added here was a second row.
const libraryView = (
<div className="flex h-full flex-col">
{content}
<MusicMiniBar />
</div>
);
if (!lyricsOpen) return libraryView;
return (
<LibraryViewContext.Provider value={libraryView}>
<WorkspaceLayout layout={LYRICS_LAYOUT} onLayoutChange={keepLayout} components={LYRICS_PANELS} noHeader />
</LibraryViewContext.Provider>
);
};
@@ -1,11 +0,0 @@
import type { AppRegistryMeta } from '../../AppRegistry';
import { Music, ListMusic } from 'lucide-react';
import { MusicBrowser } from './MusicBrowser';
import { MusicDetail } from './MusicDetail';
export { MusicBrowser, MusicDetail };
export const appRegistryMetas: AppRegistryMeta[] = [
{ key: 'music-browser', name: 'Library', icon: ListMusic, component: MusicBrowser, availableOnPanel: false },
{ key: 'music-detail', name: 'Music', icon: Music, component: MusicDetail, availableOnPanel: false },
];
+4
View File
@@ -111,6 +111,10 @@ export {
ARCHIVE_EXTS,
} from './apps/FileViewer';
export type { FileType } from './apps/FileViewer';
// The scrubber, shared by the FileViewer's audio/video renderers, the global player bar and the /music
// panels in `plugins/music/`. On the barrel rather than reached for by subpath because the package's
// `"./*"` export maps to `.ts` only, and this is a `.tsx`.
export { SeekBar, useSeekBar } from './apps/FileViewer/renderers/SeekBar';
export { TerminalView } from './apps/Terminal';
export type { TerminalViewProps } from './apps/Terminal';
export { DesktopView } from './apps/Desktop';