Files
music/db/queries.ts
T
Claude Opus 5 6e07da7a36 music, extracted from the platform into its own repository
Everything the plugin is, moved out of officerdev/platform on 2026-08-15 —
41 files, unchanged from the tree they left.

  manifest.ts   identity, one permission, ffmpeg/ffprobe declared
  api/          the sidecar proxy; the prefix comes from mountPrefix()
  sidecar/      the whole /api/music contract — indexing, streaming, per-user state
  db/           music_favorites, _playlists, _playlist_items, _now_playing
  web/          panels, layout, and the player: engine, bar, lyrics, favourites
  cliamp/       the second playback path, parked — not working, kept deliberately
  widgets/      the dashboard widget, parked — plugins cannot contribute widgets
  assets/       icon.png, the dock tile
  scripts/      the reindex CLI

PLUGIN.md is the design record: what moved, what stayed, what broke, and why.
MUSIC_API.md is the contract the phone and tablet apps speak, and the reason
the sidecar's HTTP shape is not free to change.

── It does not build here, and that is the point ──

The platform resolves `hooks/useClient`, `officerdev`, `officerdb/db` and `@@/*`
through the workspace links in its own node_modules. Measured from this
directory, outside the platform checkout, every one of them fails to resolve —
7 imports in the backend, ~29 in the frontend.

So this repository is the source of truth, not yet a buildable unit. Making it
one means the host API becoming something a plugin can depend on rather than
something it reaches into. That is the next problem, and having the code here
is what makes it unavoidable rather than theoretical.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 17:34:51 +00:00

234 lines
9.3 KiB
TypeScript

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;
});
}