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>
234 lines
9.3 KiB
TypeScript
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;
|
|
});
|
|
}
|