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:
@@ -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 ────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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,14 +54,19 @@ 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', {
|
||||
// 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')
|
||||
.primaryKey()
|
||||
.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(''),
|
||||
@@ -70,4 +75,6 @@ export const musicNowPlaying = pgTable('music_now_playing', {
|
||||
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] })],
|
||||
);
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user