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 @@
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { db, schema } from '../src/db';
|
||||
import { DEVICE_STALE_MS } from '../src/services/nowPlaying';
|
||||
import { bearer, createUser, follow, useTestApp } from './helpers';
|
||||
|
||||
const app = useTestApp();
|
||||
|
||||
async function registerDevice(token: string) {
|
||||
const res = await app().inject({ method: 'POST', url: '/me/devices', headers: bearer(token), payload: { platform: 'android', name: 'Pixel 9' } });
|
||||
expect(res.statusCode).toBe(200);
|
||||
return res.json() as { device: { id: string }; token: string };
|
||||
}
|
||||
|
||||
const report = (deviceToken: string, payload: object) =>
|
||||
app().inject({ method: 'POST', url: '/devices/now-playing', headers: bearer(deviceToken), payload });
|
||||
|
||||
const spotify = (state: 'playing' | 'paused' | 'stopped', title = 'Atmosphere') => ({
|
||||
app: 'com.spotify.music',
|
||||
state,
|
||||
track: state === 'stopped' ? undefined : { title, artist: 'Joy Division', album: 'Closer', durationMs: 250_000 },
|
||||
positionMs: 1000,
|
||||
});
|
||||
|
||||
describe('device playback reporting', () => {
|
||||
it('registers a device and returns a one-time token, storing only its hash', async () => {
|
||||
const { token } = await createUser(app(), 'alice');
|
||||
const { device, token: deviceToken } = await registerDevice(token);
|
||||
expect(deviceToken).toMatch(/^dev_/);
|
||||
const [row] = await db.select().from(schema.devices).where(eq(schema.devices.id, device.id));
|
||||
expect(row!.tokenHash).not.toContain(deviceToken);
|
||||
expect((await app().inject({ url: '/me/devices', headers: bearer(token) })).json()).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('reports playback as "via Spotify" and friends see it', async () => {
|
||||
const alice = await createUser(app(), 'alice');
|
||||
const bob = await createUser(app(), 'bob');
|
||||
await follow(app(), bob.token, 'alice');
|
||||
const { token: deviceToken } = await registerDevice(alice.token);
|
||||
|
||||
expect((await report(deviceToken, spotify('playing'))).statusCode).toBe(204);
|
||||
const [item] = (await app().inject({ url: '/feed', headers: bearer(bob.token) })).json();
|
||||
expect(item).toMatchObject({
|
||||
user: { username: 'alice' },
|
||||
track: { title: 'Atmosphere', artist: 'Joy Division', album: 'Closer', durationMs: 250_000 },
|
||||
isPlaying: true,
|
||||
source: 'android_media_session',
|
||||
sourceApp: 'Spotify',
|
||||
});
|
||||
});
|
||||
|
||||
it('uses the OS app name for apps it does not know', async () => {
|
||||
const { token, user } = await createUser(app(), 'alice');
|
||||
const { token: deviceToken } = await registerDevice(token);
|
||||
await report(deviceToken, { ...spotify('playing'), app: 'com.example.player', appName: 'Example Player' });
|
||||
const [np] = await db.select().from(schema.nowPlaying).where(eq(schema.nowPlaying.userId, user.id));
|
||||
expect(np!.sourceApp).toBe('Example Player');
|
||||
});
|
||||
|
||||
it('pause and stop end "listening now"', async () => {
|
||||
const { token } = await createUser(app(), 'alice');
|
||||
const { token: deviceToken } = await registerDevice(token);
|
||||
const isPlaying = async () => (await app().inject({ url: '/users/alice' })).json().nowPlaying.isPlaying;
|
||||
|
||||
await report(deviceToken, spotify('playing'));
|
||||
expect(await isPlaying()).toBe(true);
|
||||
await report(deviceToken, spotify('paused'));
|
||||
expect(await isPlaying()).toBe(false);
|
||||
await report(deviceToken, spotify('playing'));
|
||||
await report(deviceToken, { app: 'com.spotify.music', state: 'stopped' });
|
||||
expect(await isPlaying()).toBe(false);
|
||||
});
|
||||
|
||||
it('stops showing "listening now" if the phone goes quiet', async () => {
|
||||
const { token, user } = await createUser(app(), 'alice');
|
||||
const { token: deviceToken } = await registerDevice(token);
|
||||
await report(deviceToken, spotify('playing'));
|
||||
await db
|
||||
.update(schema.nowPlaying)
|
||||
.set({ updatedAt: new Date(Date.now() - DEVICE_STALE_MS - 1000) })
|
||||
.where(eq(schema.nowPlaying.userId, user.id));
|
||||
expect((await app().inject({ url: '/users/alice' })).json().nowPlaying.isPlaying).toBe(false);
|
||||
});
|
||||
|
||||
it('device tokens cannot be used as a login, and user tokens cannot report', async () => {
|
||||
const { token } = await createUser(app(), 'alice');
|
||||
const { token: deviceToken } = await registerDevice(token);
|
||||
expect((await app().inject({ url: '/me', headers: bearer(deviceToken) })).statusCode).toBe(401);
|
||||
expect((await report(token, spotify('playing'))).statusCode).toBe(401);
|
||||
expect((await report('dev_bogus', spotify('playing'))).statusCode).toBe(401);
|
||||
});
|
||||
|
||||
it('revoking a device stops its token working', async () => {
|
||||
const { token } = await createUser(app(), 'alice');
|
||||
const { device, token: deviceToken } = await registerDevice(token);
|
||||
await app().inject({ method: 'DELETE', url: `/me/devices/${device.id}`, headers: bearer(token) });
|
||||
expect((await report(deviceToken, spotify('playing'))).statusCode).toBe(401);
|
||||
});
|
||||
|
||||
it("can't revoke someone else's device", async () => {
|
||||
const alice = await createUser(app(), 'alice');
|
||||
const mallory = await createUser(app(), 'mallory');
|
||||
const { device, token: deviceToken } = await registerDevice(alice.token);
|
||||
await app().inject({ method: 'DELETE', url: `/me/devices/${device.id}`, headers: bearer(mallory.token) });
|
||||
expect((await report(deviceToken, spotify('playing'))).statusCode).toBe(204);
|
||||
});
|
||||
|
||||
it('keeps only the 10 newest devices per user', async () => {
|
||||
const { token } = await createUser(app(), 'alice');
|
||||
const first = await registerDevice(token);
|
||||
for (let i = 0; i < 10; i++) await registerDevice(token);
|
||||
expect((await app().inject({ url: '/me/devices', headers: bearer(token) })).json()).toHaveLength(10);
|
||||
expect((await report(first.token, spotify('playing'))).statusCode).toBe(401);
|
||||
});
|
||||
|
||||
it('rejects malformed reports', async () => {
|
||||
const { token } = await createUser(app(), 'alice');
|
||||
const { token: deviceToken } = await registerDevice(token);
|
||||
expect((await report(deviceToken, { app: 'com.spotify.music', state: 'playing' })).statusCode).toBe(400);
|
||||
expect((await report(deviceToken, { ...spotify('playing'), track: { title: '', artist: 'x' } })).statusCode).toBe(400);
|
||||
});
|
||||
});
|
||||
@@ -17,7 +17,7 @@ function assertTestTargets() {
|
||||
|
||||
export async function resetState() {
|
||||
assertTestTargets();
|
||||
await sql`truncate users, tracks, track_links, sessions, follows, connections, now_playing, featured_songs, listens restart identity cascade`;
|
||||
await sql`truncate users, tracks, track_links, sessions, follows, connections, now_playing, featured_songs, listens, devices restart identity cascade`;
|
||||
await redis.flushdb();
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { db, schema } from '../src/db';
|
||||
import { redis } from '../src/lib/redis';
|
||||
import { applyPollResult } from '../src/services/nowPlaying';
|
||||
import { upsertConnection } from '../src/services/connections';
|
||||
import { bearer, createUser, useTestApp } from './helpers';
|
||||
|
||||
const app = useTestApp();
|
||||
|
||||
const setSharing = (token: string, pause: string | null) =>
|
||||
app().inject({ method: 'PUT', url: '/me/sharing', headers: bearer(token), payload: { pause } });
|
||||
|
||||
async function deviceFor(token: string) {
|
||||
const res = await app().inject({ method: 'POST', url: '/me/devices', headers: bearer(token), payload: { platform: 'android', name: 'Pixel' } });
|
||||
return res.json().token as string;
|
||||
}
|
||||
const report = (deviceToken: string, title: string) =>
|
||||
app().inject({
|
||||
method: 'POST',
|
||||
url: '/devices/now-playing',
|
||||
headers: bearer(deviceToken),
|
||||
payload: { app: 'com.spotify.music', state: 'playing', track: { title, artist: 'New Order' } },
|
||||
});
|
||||
|
||||
describe('pausing sharing', () => {
|
||||
it.each([
|
||||
['1h', 3600_000],
|
||||
['8h', 8 * 3600_000],
|
||||
])('pause %s sets a resume time', async (pause, ms) => {
|
||||
const { token } = await createUser(app(), 'alice');
|
||||
const me = (await setSharing(token, pause)).json();
|
||||
const until = new Date(me.sharingPausedUntil).getTime();
|
||||
expect(Math.abs(until - (Date.now() + ms))).toBeLessThan(5000);
|
||||
});
|
||||
|
||||
it('turns what was playing into "played … ago" immediately', async () => {
|
||||
const { token, user } = await createUser(app(), 'alice');
|
||||
await applyPollResult(user.id, 'spotify', { track: { title: 'Ceremony', artist: 'New Order' }, isPlaying: true, progressMs: 0 });
|
||||
await setSharing(token, 'indefinitely');
|
||||
expect((await app().inject({ url: '/users/alice' })).json().nowPlaying.isPlaying).toBe(false);
|
||||
});
|
||||
|
||||
it('drops phone reports and records no history while paused, then shares again on resume', async () => {
|
||||
const { token } = await createUser(app(), 'alice');
|
||||
const deviceToken = await deviceFor(token);
|
||||
await setSharing(token, 'indefinitely');
|
||||
|
||||
expect((await report(deviceToken, 'Temptation')).statusCode).toBe(204);
|
||||
let profile = (await app().inject({ url: '/users/alice' })).json();
|
||||
expect(profile.nowPlaying).toBeNull();
|
||||
expect(profile.recent).toEqual([]);
|
||||
|
||||
expect((await setSharing(token, null)).json().sharingPausedUntil).toBeNull();
|
||||
await report(deviceToken, 'Temptation');
|
||||
profile = (await app().inject({ url: '/users/alice' })).json();
|
||||
expect(profile.nowPlaying).toMatchObject({ track: { title: 'Temptation' }, isPlaying: true });
|
||||
});
|
||||
|
||||
it('resuming polls connections right away', async () => {
|
||||
const { token, user } = await createUser(app(), 'alice');
|
||||
const conn = await upsertConnection({ userId: user.id, provider: 'lastfm', externalUserId: 'x', displayName: null });
|
||||
await setSharing(token, 'indefinitely');
|
||||
await redis.zadd('poll:due', Date.now() + 600_000, conn.id);
|
||||
await setSharing(token, null);
|
||||
expect(Number(await redis.zscore('poll:due', conn.id))).toBeLessThanOrEqual(Date.now());
|
||||
});
|
||||
|
||||
it('an expired pause counts as sharing', async () => {
|
||||
const { token, user } = await createUser(app(), 'alice');
|
||||
await db.update(schema.users).set({ sharingPausedUntil: new Date(Date.now() - 1000) }).where(eq(schema.users.id, user.id));
|
||||
expect((await app().inject({ url: '/me', headers: bearer(token) })).json().sharingPausedUntil).toBeNull();
|
||||
await report(await deviceFor(token), 'Blue Monday');
|
||||
expect((await app().inject({ url: '/users/alice' })).json().nowPlaying.isPlaying).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects unknown durations', async () => {
|
||||
const { token } = await createUser(app(), 'alice');
|
||||
expect((await setSharing(token, 'forever')).statusCode).toBe(400);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user