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:
@@ -0,0 +1,233 @@
|
||||
import { eq, and, desc, asc, sql } from 'drizzle-orm';
|
||||
import { db } from 'officerdb/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;
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user