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>
This commit is contained in:
+233
@@ -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;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { pgTable, serial, integer, text, real, timestamp, index, primaryKey, uniqueIndex } from 'drizzle-orm/pg-core';
|
||||
import { users } from 'officerdb/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] })],
|
||||
);
|
||||
Reference in New Issue
Block a user