music: per-device now-playing (browser vs phone)

Key music_now_playing on (user_id, device) instead of user_id alone so the
browser ('web') and phone ('' default) each keep their own resume snapshot
instead of sharing one row. Sidecar /now-playing threads device (?device=,
default '') through get/set/clearNowPlaying; the web client tags its calls
?device=web. Phone unchanged → '' bucket, inherits the existing row.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-29 03:13:16 +00:00
co-authored by Claude Opus 4.8
parent 91ed03d514
commit f3dc4415bb
4 changed files with 47 additions and 36 deletions
+10 -9
View File
@@ -54,8 +54,8 @@ export type NowPlayingInput = {
positionSec?: number;
};
/** The user's last "currently playing" snapshot, or null if none. */
export async function getNowPlaying(userId: number): Promise<NowPlaying | null> {
/** 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,
@@ -68,14 +68,15 @@ export async function getNowPlaying(userId: number): Promise<NowPlaying | null>
updatedAt: musicNowPlaying.updatedAt,
})
.from(musicNowPlaying)
.where(eq(musicNowPlaying.userId, userId));
.where(and(eq(musicNowPlaying.userId, userId), eq(musicNowPlaying.device, device)));
return row ?? null;
}
/** Upsert the user's "currently playing" snapshot (one row per user). */
export async function setNowPlaying(userId: number, np: NowPlayingInput): Promise<void> {
/** 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 ?? '',
@@ -89,7 +90,7 @@ export async function setNowPlaying(userId: number, np: NowPlayingInput): Promis
.insert(musicNowPlaying)
.values(values)
.onConflictDoUpdate({
target: musicNowPlaying.userId,
target: [musicNowPlaying.userId, musicNowPlaying.device],
set: {
homePath: values.homePath,
dir: values.dir,
@@ -103,9 +104,9 @@ export async function setNowPlaying(userId: number, np: NowPlayingInput): Promis
});
}
/** Clear the user's "currently playing" (on close/stop). */
export async function clearNowPlaying(userId: number): Promise<void> {
await db.delete(musicNowPlaying).where(eq(musicNowPlaying.userId, userId));
/** 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 ────────────────────────────────────────────────────────────────────────────────
+25 -18
View File
@@ -1,4 +1,4 @@
import { pgTable, serial, integer, text, real, timestamp, unique, index } from 'drizzle-orm/pg-core';
import { pgTable, serial, integer, text, real, timestamp, unique, index, primaryKey } from 'drizzle-orm/pg-core';
import { users } from './auth';
// Per-user music favorites. `key` is an opaque path the app supplies and the server never interprets:
@@ -54,20 +54,27 @@ export const musicPlaylistItems = pgTable(
(t) => [index('idx_music_playlist_items_playlist').on(t.playlistId, t.position)],
);
// Per-user "currently playing" for resume-across-launch/device: 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). One
// row per user (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')
.primaryKey()
.references(() => users.id, { onDelete: 'cascade' }),
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(),
});
// 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({ columns: [t.userId, t.device] })],
);
+9 -6
View File
@@ -98,9 +98,9 @@ const asKeys = (v: unknown): string[] | null =>
// 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 last snapshot, or null.
// PUT /now-playing { homePath, dir?, title?, artist?, album?, durationSec?, positionSec? } upsert.
// DELETE /now-playing clear.
// 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.
@@ -341,14 +341,17 @@ const server = Bun.serve({
}
if (P === '/now-playing') {
if (m === 'GET') return json(await getNowPlaying(uid));
// 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, {
await setNowPlaying(uid, device, {
homePath: b.homePath,
dir: str(b.dir),
title: str(b.title),
@@ -360,7 +363,7 @@ const server = Bun.serve({
return json({ ok: true });
}
if (m === 'DELETE') {
await clearNowPlaying(uid);
await clearNowPlaying(uid, device);
return json({ ok: true });
}
return new Response('Method not allowed', { status: 405 });
@@ -76,7 +76,7 @@ export const MusicPlayerHost = () => {
const persist = () => {
if (!current) return;
put('/music/now-playing', {
put('/music/now-playing?device=web', {
homePath: trackHomePath(current.albumRel, current.file),
dir: `Music/${current.albumRel}`,
title: current.title ?? '',
@@ -126,7 +126,7 @@ export const MusicPlayerHost = () => {
restoredRef.current = true;
if (queue.length) return;
(async () => {
const snap = await get<NowPlaying | null>('/music/now-playing').catch(() => null);
const snap = await get<NowPlaying | null>('/music/now-playing?device=web').catch(() => null);
if (!snap?.homePath) return;
const albumRel = (snap.dir || snap.homePath.split('/').slice(0, -1).join('/')).replace(/^Music\//, '');
const file = snap.homePath.split('/').pop() ?? '';
@@ -236,7 +236,7 @@ export const MusicPlayerHost = () => {
// Closing the dock also clears the saved "currently playing" snapshot, so it doesn't get restored on
// the next load. (Merely close()-ing the local queue would leave the server snapshot to bring it back.)
const handleClose = () => {
del('/music/now-playing').catch(() => {});
del('/music/now-playing?device=web').catch(() => {});
close();
};