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;
|
positionSec?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** The user's last "currently playing" snapshot, or null if none. */
|
/** This (user, device)'s last "currently playing" snapshot, or null if none. */
|
||||||
export async function getNowPlaying(userId: number): Promise<NowPlaying | null> {
|
export async function getNowPlaying(userId: number, device: string): Promise<NowPlaying | null> {
|
||||||
const [row] = await db
|
const [row] = await db
|
||||||
.select({
|
.select({
|
||||||
homePath: musicNowPlaying.homePath,
|
homePath: musicNowPlaying.homePath,
|
||||||
@@ -68,14 +68,15 @@ export async function getNowPlaying(userId: number): Promise<NowPlaying | null>
|
|||||||
updatedAt: musicNowPlaying.updatedAt,
|
updatedAt: musicNowPlaying.updatedAt,
|
||||||
})
|
})
|
||||||
.from(musicNowPlaying)
|
.from(musicNowPlaying)
|
||||||
.where(eq(musicNowPlaying.userId, userId));
|
.where(and(eq(musicNowPlaying.userId, userId), eq(musicNowPlaying.device, device)));
|
||||||
return row ?? null;
|
return row ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Upsert the user's "currently playing" snapshot (one row per user). */
|
/** Upsert this (user, device)'s "currently playing" snapshot (one row per user+device). */
|
||||||
export async function setNowPlaying(userId: number, np: NowPlayingInput): Promise<void> {
|
export async function setNowPlaying(userId: number, device: string, np: NowPlayingInput): Promise<void> {
|
||||||
const values = {
|
const values = {
|
||||||
userId,
|
userId,
|
||||||
|
device,
|
||||||
homePath: np.homePath,
|
homePath: np.homePath,
|
||||||
dir: np.dir ?? '',
|
dir: np.dir ?? '',
|
||||||
title: np.title ?? '',
|
title: np.title ?? '',
|
||||||
@@ -89,7 +90,7 @@ export async function setNowPlaying(userId: number, np: NowPlayingInput): Promis
|
|||||||
.insert(musicNowPlaying)
|
.insert(musicNowPlaying)
|
||||||
.values(values)
|
.values(values)
|
||||||
.onConflictDoUpdate({
|
.onConflictDoUpdate({
|
||||||
target: musicNowPlaying.userId,
|
target: [musicNowPlaying.userId, musicNowPlaying.device],
|
||||||
set: {
|
set: {
|
||||||
homePath: values.homePath,
|
homePath: values.homePath,
|
||||||
dir: values.dir,
|
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). */
|
/** Clear this (user, device)'s "currently playing" (on close/stop). */
|
||||||
export async function clearNowPlaying(userId: number): Promise<void> {
|
export async function clearNowPlaying(userId: number, device: string): Promise<void> {
|
||||||
await db.delete(musicNowPlaying).where(eq(musicNowPlaying.userId, userId));
|
await db.delete(musicNowPlaying).where(and(eq(musicNowPlaying.userId, userId), eq(musicNowPlaying.device, device)));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Named playlists ────────────────────────────────────────────────────────────────────────────────
|
// ── 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';
|
import { users } from './auth';
|
||||||
|
|
||||||
// Per-user music favorites. `key` is an opaque path the app supplies and the server never interprets:
|
// 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)],
|
(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,
|
// Per-(user, device) "currently playing" for resume-across-launch: the current track + playback
|
||||||
// plus a light metadata snapshot so the resume card renders before the library index has synced on a
|
// position, plus a light metadata snapshot so the resume card renders before the library index has
|
||||||
// fresh device. `dir` is the folder to rebuild the album queue from ('' for a cross-album queue). One
|
// synced on a fresh device. `dir` is the folder to rebuild the album queue from ('' for a cross-album
|
||||||
// row per user (upserted; the app writes it throttled while playing and on pause/track-change/close).
|
// queue). `device` is an opaque client tag ('' = default/phone, 'web' = the browser) so each client
|
||||||
export const musicNowPlaying = pgTable('music_now_playing', {
|
// 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')
|
userId: integer('user_id')
|
||||||
.primaryKey()
|
.notNull()
|
||||||
.references(() => users.id, { onDelete: 'cascade' }),
|
.references(() => users.id, { onDelete: 'cascade' }),
|
||||||
|
device: text('device').notNull().default(''),
|
||||||
homePath: text('home_path').notNull(),
|
homePath: text('home_path').notNull(),
|
||||||
dir: text('dir').notNull().default(''),
|
dir: text('dir').notNull().default(''),
|
||||||
title: text('title').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),
|
durationSec: real('duration_sec').notNull().default(0),
|
||||||
positionSec: real('position_sec').notNull().default(0),
|
positionSec: real('position_sec').notNull().default(0),
|
||||||
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
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).
|
// GET /favorites { tracks[], albums[], artists[] } (keys, newest first).
|
||||||
// POST /favorites { kind, key } add (idempotent). kind ∈ track|album|artist.
|
// POST /favorites { kind, key } add (idempotent). kind ∈ track|album|artist.
|
||||||
// DELETE /favorites?kind=&key= remove.
|
// DELETE /favorites?kind=&key= remove.
|
||||||
// GET /now-playing last snapshot, or null.
|
// GET /now-playing[?device=] last snapshot for that device, or null. device '' = default/phone, 'web' = browser.
|
||||||
// PUT /now-playing { homePath, dir?, title?, artist?, album?, durationSec?, positionSec? } upsert.
|
// PUT /now-playing[?device=] { homePath, dir?, title?, artist?, album?, durationSec?, positionSec? } upsert.
|
||||||
// DELETE /now-playing clear.
|
// DELETE /now-playing[?device=] clear that device's snapshot.
|
||||||
// GET /playlists [{ id, name, count, createdAt, updatedAt }] (recent first).
|
// GET /playlists [{ id, name, count, createdAt, updatedAt }] (recent first).
|
||||||
// POST /playlists { name } create → 201 row; 409 if name taken.
|
// POST /playlists { name } create → 201 row; 409 if name taken.
|
||||||
// GET /playlists/:id { id, name, items:[keys], … }; 404 if not the user's.
|
// 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 (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') {
|
if (m === 'PUT') {
|
||||||
const b = (await req.json().catch(() => ({}))) as Record<string, unknown>;
|
const b = (await req.json().catch(() => ({}))) as Record<string, unknown>;
|
||||||
if (typeof b.homePath !== 'string' || !b.homePath)
|
if (typeof b.homePath !== 'string' || !b.homePath)
|
||||||
return json({ error: 'homePath required' }, { status: 400 });
|
return json({ error: 'homePath required' }, { status: 400 });
|
||||||
const str = (v: unknown) => (typeof v === 'string' ? v : undefined);
|
const str = (v: unknown) => (typeof v === 'string' ? v : undefined);
|
||||||
const num = (v: unknown) => (typeof v === 'number' && Number.isFinite(v) ? v : undefined);
|
const num = (v: unknown) => (typeof v === 'number' && Number.isFinite(v) ? v : undefined);
|
||||||
await setNowPlaying(uid, {
|
await setNowPlaying(uid, device, {
|
||||||
homePath: b.homePath,
|
homePath: b.homePath,
|
||||||
dir: str(b.dir),
|
dir: str(b.dir),
|
||||||
title: str(b.title),
|
title: str(b.title),
|
||||||
@@ -360,7 +363,7 @@ const server = Bun.serve({
|
|||||||
return json({ ok: true });
|
return json({ ok: true });
|
||||||
}
|
}
|
||||||
if (m === 'DELETE') {
|
if (m === 'DELETE') {
|
||||||
await clearNowPlaying(uid);
|
await clearNowPlaying(uid, device);
|
||||||
return json({ ok: true });
|
return json({ ok: true });
|
||||||
}
|
}
|
||||||
return new Response('Method not allowed', { status: 405 });
|
return new Response('Method not allowed', { status: 405 });
|
||||||
|
|||||||
@@ -76,7 +76,7 @@ export const MusicPlayerHost = () => {
|
|||||||
|
|
||||||
const persist = () => {
|
const persist = () => {
|
||||||
if (!current) return;
|
if (!current) return;
|
||||||
put('/music/now-playing', {
|
put('/music/now-playing?device=web', {
|
||||||
homePath: trackHomePath(current.albumRel, current.file),
|
homePath: trackHomePath(current.albumRel, current.file),
|
||||||
dir: `Music/${current.albumRel}`,
|
dir: `Music/${current.albumRel}`,
|
||||||
title: current.title ?? '',
|
title: current.title ?? '',
|
||||||
@@ -126,7 +126,7 @@ export const MusicPlayerHost = () => {
|
|||||||
restoredRef.current = true;
|
restoredRef.current = true;
|
||||||
if (queue.length) return;
|
if (queue.length) return;
|
||||||
(async () => {
|
(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;
|
if (!snap?.homePath) return;
|
||||||
const albumRel = (snap.dir || snap.homePath.split('/').slice(0, -1).join('/')).replace(/^Music\//, '');
|
const albumRel = (snap.dir || snap.homePath.split('/').slice(0, -1).join('/')).replace(/^Music\//, '');
|
||||||
const file = snap.homePath.split('/').pop() ?? '';
|
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
|
// 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.)
|
// the next load. (Merely close()-ing the local queue would leave the server snapshot to bring it back.)
|
||||||
const handleClose = () => {
|
const handleClose = () => {
|
||||||
del('/music/now-playing').catch(() => {});
|
del('/music/now-playing?device=web').catch(() => {});
|
||||||
close();
|
close();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user