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' }),
};
}
+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;
}
+1
View File
@@ -7,6 +7,7 @@ function item(username: string, opts: { pinnedAt?: string; isPlaying?: boolean;
track: { id: 't', title: 't', artist: 'a', album: null, artworkUrl: null, durationMs: null, isrc: null, links: {} },
isPlaying: opts.isPlaying ?? false,
source: 'spotify',
sourceApp: null,
progressMs: null,
updatedAt: opts.updatedAt ?? '2026-01-01T00:00:00.000Z',
pinnedAt: opts.pinnedAt ?? null,
+4
View File
@@ -23,6 +23,8 @@ export interface MeDTO extends UserSummaryDTO {
email: string;
bio: string | null;
preferredService: StreamingService | null;
/** ISO time sharing resumes, or null when sharing. Far future means "until turned back on". */
sharingPausedUntil: string | null;
plan: Plan;
entitlements: Entitlements;
}
@@ -32,6 +34,8 @@ export interface NowPlayingDTO {
track: TrackDTO;
isPlaying: boolean;
source: NowPlayingProvider;
/** The app it's playing in, when a phone reported it (e.g. "Spotify"). */
sourceApp: string | null;
progressMs: number | null;
updatedAt: string;
}
+1
View File
@@ -4,3 +4,4 @@ export * from './schemas';
export * from './plans';
export * from './client';
export * from './time';
export * from './devices';
+4
View File
@@ -47,3 +47,7 @@ export const jellyfinConnectSchema = z.object({
username: z.string().trim().min(1).max(128),
password: z.string().max(256).default(''),
});
/** Pause sharing: for an hour, until tomorrow, until turned back on, or resume (null). */
export const PAUSE_OPTIONS = ['1h', '8h', 'indefinitely'] as const;
export const setSharingSchema = z.object({ pause: z.enum(PAUSE_OPTIONS).nullable() });
+1 -1
View File
@@ -30,7 +30,7 @@ export const SERVICE_LABELS: Record<StreamingService | NowPlayingProvider, strin
listenbrainz: 'ListenBrainz',
plex: 'Plex',
jellyfin: 'Jellyfin',
android_media_session: 'Android',
android_media_session: 'your phone',
};
/** Fallback when we have no direct link: a search on that service. */
+12 -1
View File
@@ -1,5 +1,5 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { timeAgo } from './time';
import { pausedUntilLabel, timeAgo } from './time';
describe('timeAgo', () => {
beforeEach(() => {
@@ -18,3 +18,14 @@ describe('timeAgo', () => {
expect(timeAgo(iso)).toBe(expected);
});
});
describe('pausedUntilLabel', () => {
const now = new Date(2026, 9, 3, 14, 0);
it('says "until you turn it back on" for indefinite pauses', () => {
expect(pausedUntilLabel(new Date(2126, 0, 1).toISOString(), now)).toBe('until you turn it back on');
});
it('shows a time for today and "tomorrow" past midnight', () => {
expect(pausedUntilLabel(new Date(2026, 9, 3, 15, 0).toISOString(), now)).toMatch(/^until \d/);
expect(pausedUntilLabel(new Date(2026, 9, 4, 1, 0).toISOString(), now)).toMatch(/^until tomorrow /);
});
});
+8
View File
@@ -5,3 +5,11 @@ export function timeAgo(iso: string): string {
if (s < 86400) return `${Math.floor(s / 3600)}h ago`;
return `${Math.floor(s / 86400)}d ago`;
}
/** "until 5:30 PM", "until tomorrow 1:00 AM", or "until you turn it back on" for an indefinite pause. */
export function pausedUntilLabel(iso: string, now = new Date()): string {
const until = new Date(iso);
if (until.getTime() - now.getTime() > 30 * 86_400_000) return 'until you turn it back on';
const time = until.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' });
return until.toDateString() === now.toDateString() ? `until ${time}` : `until tomorrow ${time}`;
}