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:
@@ -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' }),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -4,3 +4,4 @@ export * from './schemas';
|
||||
export * from './plans';
|
||||
export * from './client';
|
||||
export * from './time';
|
||||
export * from './devices';
|
||||
|
||||
@@ -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() });
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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 /);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user