Add phone playback reporting and pause sharing to the API
Phones that read system media sessions report playback themselves:
- POST /me/devices issues a per-phone token that can only call
POST /devices/now-playing; revocable, hashed at rest, 10 per user
- Reports name the app ("via Spotify"); unknown apps use the OS label
- Push-reported playback expires after 9 minutes of silence, since
phones send a heartbeat every 4 minutes while playing
Users can pause sharing (PUT /me/sharing: 1h, 8h, indefinitely). While
paused, connections aren't polled, phone reports are dropped, nothing is
added to history, and the current track switches to "played … ago".
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MzFywujuH9TiuXbr9vjoX8
This commit is contained in:
@@ -0,0 +1,122 @@
|
||||
/**
|
||||
* Phones that report playback themselves (the Android app reads system media
|
||||
* sessions). Each phone registers once and gets a device token that can only
|
||||
* call POST /devices/now-playing, so a leaked token can't read or change
|
||||
* anything else, and the user can revoke a single phone.
|
||||
*/
|
||||
import { and, desc, eq, notInArray } from 'drizzle-orm';
|
||||
import type { FastifyInstance, FastifyRequest } from 'fastify';
|
||||
import { ANDROID_MUSIC_APPS, devicePlaybackSchema, registerDeviceSchema, type DeviceDTO } from '@musicshare/shared';
|
||||
import { db, schema } from '../db';
|
||||
import { requireUser } from '../lib/auth';
|
||||
import { randomToken, sha256 } from '../lib/crypto';
|
||||
import { HttpError } from '../lib/http';
|
||||
import { applyPollResult, isSharingPaused } from '../services/nowPlaying';
|
||||
|
||||
const MAX_DEVICES_PER_USER = 10;
|
||||
export const DEVICE_TOKEN_PREFIX = 'dev_';
|
||||
|
||||
type Device = typeof schema.devices.$inferSelect;
|
||||
|
||||
const toDeviceDTO = (d: Device): DeviceDTO => ({
|
||||
id: d.id,
|
||||
platform: d.platform,
|
||||
name: d.name,
|
||||
lastSeenAt: d.lastSeenAt?.toISOString() ?? null,
|
||||
createdAt: d.createdAt.toISOString(),
|
||||
});
|
||||
|
||||
function deviceToken(req: FastifyRequest): string | null {
|
||||
const header = req.headers.authorization;
|
||||
const token = header?.startsWith('Bearer ') ? header.slice(7) : null;
|
||||
return token?.startsWith(DEVICE_TOKEN_PREFIX) ? token : null;
|
||||
}
|
||||
|
||||
export async function deviceRoutes(app: FastifyInstance) {
|
||||
app.get('/me/devices', async (req) => {
|
||||
const user = requireUser(req);
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(schema.devices)
|
||||
.where(eq(schema.devices.userId, user.id))
|
||||
.orderBy(desc(schema.devices.createdAt));
|
||||
return rows.map(toDeviceDTO);
|
||||
});
|
||||
|
||||
app.post('/me/devices', async (req) => {
|
||||
const user = requireUser(req);
|
||||
const input = registerDeviceSchema.parse(req.body);
|
||||
const token = DEVICE_TOKEN_PREFIX + randomToken(32);
|
||||
const [device] = await db
|
||||
.insert(schema.devices)
|
||||
.values({ userId: user.id, tokenHash: sha256(token), platform: input.platform, name: input.name })
|
||||
.returning();
|
||||
|
||||
// Reinstalls register a new device; keep only the newest few.
|
||||
const keep = await db
|
||||
.select({ id: schema.devices.id })
|
||||
.from(schema.devices)
|
||||
.where(eq(schema.devices.userId, user.id))
|
||||
.orderBy(desc(schema.devices.createdAt))
|
||||
.limit(MAX_DEVICES_PER_USER);
|
||||
await db.delete(schema.devices).where(
|
||||
and(
|
||||
eq(schema.devices.userId, user.id),
|
||||
notInArray(
|
||||
schema.devices.id,
|
||||
keep.map((k) => k.id),
|
||||
),
|
||||
),
|
||||
);
|
||||
return { device: toDeviceDTO(device!), token };
|
||||
});
|
||||
|
||||
app.delete<{ Params: { id: string } }>('/me/devices/:id', async (req) => {
|
||||
const user = requireUser(req);
|
||||
if (!/^[0-9a-f-]{36}$/i.test(req.params.id)) throw new HttpError(404, 'Device not found');
|
||||
await db.delete(schema.devices).where(and(eq(schema.devices.id, req.params.id), eq(schema.devices.userId, user.id)));
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
app.post(
|
||||
'/devices/now-playing',
|
||||
{
|
||||
config: {
|
||||
rateLimit: {
|
||||
max: 120,
|
||||
timeWindow: '1 minute',
|
||||
keyGenerator: (req: FastifyRequest) => `device:${sha256(deviceToken(req) ?? req.ip)}`,
|
||||
},
|
||||
},
|
||||
},
|
||||
async (req, reply) => {
|
||||
const token = deviceToken(req);
|
||||
const [device] = token
|
||||
? await db.select().from(schema.devices).where(eq(schema.devices.tokenHash, sha256(token)))
|
||||
: [];
|
||||
if (!device) throw new HttpError(401, 'Unknown device');
|
||||
|
||||
const input = devicePlaybackSchema.parse(req.body);
|
||||
await db.update(schema.devices).set({ lastSeenAt: new Date() }).where(eq(schema.devices.id, device.id));
|
||||
const [owner] = await db.select().from(schema.users).where(eq(schema.users.id, device.userId));
|
||||
if (!owner || isSharingPaused(owner)) return reply.status(204).send(); // accepted, not shared
|
||||
const sourceApp = ANDROID_MUSIC_APPS[input.app] ?? input.appName ?? null;
|
||||
const track = input.track
|
||||
? {
|
||||
title: input.track.title,
|
||||
artist: input.track.artist || 'Unknown artist',
|
||||
album: input.track.album ?? null,
|
||||
durationMs: input.track.durationMs ?? null,
|
||||
}
|
||||
: null;
|
||||
|
||||
await applyPollResult(
|
||||
device.userId,
|
||||
'android_media_session',
|
||||
track ? { track, isPlaying: input.state === 'playing', progressMs: input.positionMs ?? null } : null,
|
||||
{ sourceApp },
|
||||
);
|
||||
return reply.status(204).send();
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { eq } from 'drizzle-orm';
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { setFeaturedSchema, updateMeSchema, type MeDTO } from '@musicshare/shared';
|
||||
import { setFeaturedSchema, setSharingSchema, updateMeSchema, type MeDTO } from '@musicshare/shared';
|
||||
import { db, schema } from '../db';
|
||||
import type { User } from '../db/schema';
|
||||
import { SESSION_COOKIE, requireUser } from '../lib/auth';
|
||||
@@ -8,7 +8,9 @@ import { effectivePlan, entitlementsFor } from '../lib/entitlements';
|
||||
import { HttpError } from '../lib/http';
|
||||
import { deezerTrack } from '../providers/catalog';
|
||||
import { enqueueResolve } from '../services/queues';
|
||||
import { unschedulePoll } from '../services/scheduler';
|
||||
import { connectionIdsForUser } from '../services/connections';
|
||||
import { isSharingPaused, pauseSharing } from '../services/nowPlaying';
|
||||
import { pollSoon, unschedulePoll } from '../services/scheduler';
|
||||
import { upsertTrack } from '../services/tracks';
|
||||
|
||||
export const toMeDTO = (u: User): MeDTO => ({
|
||||
@@ -19,6 +21,7 @@ export const toMeDTO = (u: User): MeDTO => ({
|
||||
email: u.email,
|
||||
bio: u.bio,
|
||||
preferredService: u.preferredService,
|
||||
sharingPausedUntil: isSharingPaused(u) ? u.sharingPausedUntil!.toISOString() : null,
|
||||
plan: effectivePlan(u),
|
||||
entitlements: entitlementsFor(u),
|
||||
});
|
||||
@@ -46,6 +49,21 @@ export async function meRoutes(app: FastifyInstance) {
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
app.put('/me/sharing', async (req) => {
|
||||
const user = requireUser(req);
|
||||
const { pause } = setSharingSchema.parse(req.body);
|
||||
const hours = { '1h': 1, '8h': 8, indefinitely: 24 * 365 * 100 } as const;
|
||||
const until = pause ? new Date(Date.now() + hours[pause] * 3600_000) : null;
|
||||
const [updated] = await db
|
||||
.update(schema.users)
|
||||
.set({ sharingPausedUntil: until })
|
||||
.where(eq(schema.users.id, user.id))
|
||||
.returning();
|
||||
if (until) await pauseSharing(user.id);
|
||||
else await pollSoon(await connectionIdsForUser(user.id)); // pick up what's playing right away
|
||||
return toMeDTO(updated!);
|
||||
});
|
||||
|
||||
app.put('/me/featured', async (req) => {
|
||||
const user = requireUser(req);
|
||||
const input = setFeaturedSchema.parse(req.body);
|
||||
|
||||
Reference in New Issue
Block a user