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:
2026-10-03 17:37:30 -04:00
co-authored by Claude Opus 5.5
parent 6317004004
commit 70fc7efbbe
24 changed files with 2500 additions and 7 deletions
+8
View File
@@ -6,6 +6,7 @@ import type {
TrackSearchResultDTO,
UserSummaryDTO,
} from './dto';
import type { DeviceDTO } from './devices';
import type { LoginInput, RegisterInput, SetFeaturedInput, UpdateMeInput } from './schemas';
export class ApiError extends Error {
@@ -59,6 +60,7 @@ export function createApiClient({ baseUrl, getToken, credentials }: ApiClientOpt
me: () => request<MeDTO>('/me'),
updateMe: (input: UpdateMeInput) => request<MeDTO>('/me', { method: 'PATCH', body: input }),
deleteAccount: () => request('/me', { method: 'DELETE' }),
setSharing: (pause: '1h' | '8h' | 'indefinitely' | null) => request<MeDTO>('/me/sharing', { method: 'PUT', body: { pause } }),
setFeatured: (input: SetFeaturedInput) => request('/me/featured', { method: 'PUT', body: input }),
clearFeatured: () => request('/me/featured', { method: 'DELETE' }),
@@ -80,6 +82,12 @@ export function createApiClient({ baseUrl, getToken, credentials }: ApiClientOpt
connectJellyfin: (input: { serverUrl: string; username: string; password: string }) =>
request<ConnectionDTO>('/connections/jellyfin', { body: input }),
disconnect: (provider: string) => request(`/connections/${q(provider)}`, { method: 'DELETE' }),
devices: () => request<DeviceDTO[]>('/me/devices'),
/** The token is returned once; it can only report playback for this user. */
registerDevice: (input: { platform: 'android' | 'ios'; name: string }) =>
request<{ device: DeviceDTO; token: string }>('/me/devices', { body: input }),
removeDevice: (id: string) => request(`/me/devices/${q(id)}`, { method: 'DELETE' }),
};
}