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
+63
View File
@@ -0,0 +1,63 @@
import { z } from 'zod';
/**
* Android music apps whose media sessions we share by default (package → display name).
* Anything else (video, podcasts, browsers) is ignored unless the user opts in, so
* friends don't see YouTube videos or audiobooks in their feed.
*/
export const ANDROID_MUSIC_APPS: Record<string, string> = {
'com.spotify.music': 'Spotify',
'com.google.android.apps.youtube.music': 'YouTube Music',
'com.apple.android.music': 'Apple Music',
'com.aspiro.tidal': 'TIDAL',
'deezer.android.app': 'Deezer',
'com.soundcloud.android': 'SoundCloud',
'com.amazon.mp3': 'Amazon Music',
'com.pandora.android': 'Pandora',
'com.qobuz.music': 'Qobuz',
'tv.plex.labs.plexamp': 'Plexamp',
'com.unicornsonlsd.finamp': 'Finamp',
'app.symfonium.android': 'Symfonium',
'com.bandcamp.android': 'Bandcamp',
'com.anghami': 'Anghami',
'com.jio.media.jiobeats': 'JioSaavn',
'com.maxmpz.audioplayer': 'Poweramp',
'org.videolan.vlc': 'VLC',
'com.kapp.youtube.final': 'YMusic',
'app.revanced.android.apps.youtube.music': 'YouTube Music',
};
export const registerDeviceSchema = z.object({
platform: z.enum(['android', 'ios']),
name: z.string().trim().min(1).max(100),
});
const reportedTrack = z.object({
title: z.string().trim().min(1).max(500),
artist: z.string().trim().max(500),
album: z.string().trim().max(500).nullish(),
durationMs: z.number().int().positive().max(24 * 3600_000).nullish(),
});
/** What a phone reports when playback changes in a music app. */
export const devicePlaybackSchema = z
.object({
/** Android package name (or iOS bundle id) of the playing app. */
app: z.string().trim().min(1).max(200),
/** The app's label as the OS reports it; used for apps we don't know. */
appName: z.string().trim().max(100).nullish(),
state: z.enum(['playing', 'paused', 'stopped']),
track: reportedTrack.nullish(),
positionMs: z.number().int().min(0).nullish(),
})
.refine((v) => v.state === 'stopped' || v.track, { message: 'track is required unless stopped', path: ['track'] });
export type DevicePlaybackInput = z.infer<typeof devicePlaybackSchema>;
export interface DeviceDTO {
id: string;
platform: 'android' | 'ios';
name: string;
lastSeenAt: string | null;
createdAt: string;
}