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
@@ -50,3 +50,11 @@ export async function watchUsers(userIds: string[]) {
);
await pollSoon(rows.map((r) => r.id));
}
export async function connectionIdsForUser(userId: string): Promise<string[]> {
const rows = await db
.select({ id: schema.connections.id })
.from(schema.connections)
.where(and(eq(schema.connections.userId, userId), eq(schema.connections.status, 'active')));
return rows.map((r) => r.id);
}
+21 -1
View File
@@ -14,6 +14,10 @@ export const toUserSummary = (u: Pick<User, 'id' | 'username' | 'displayName' |
avatarUrl: u.avatarUrl,
});
/** Phones report changes and a heartbeat every few minutes; if they go quiet (app killed, no signal), stop showing "listening now". */
export const DEVICE_STALE_MS = 9 * 60_000;
const PUSH_SOURCES = new Set<NowPlayingProvider>(['android_media_session']);
export async function getNowPlaying(userIds: string[]): Promise<NowPlayingDTO[]> {
if (userIds.length === 0) return [];
const rows = await db
@@ -26,8 +30,9 @@ export async function getNowPlaying(userIds: string[]): Promise<NowPlayingDTO[]>
.map(({ np, user }) => ({
user: toUserSummary(user),
track: tracks.get(np.trackId)!,
isPlaying: np.isPlaying,
isPlaying: np.isPlaying && !(PUSH_SOURCES.has(np.source) && Date.now() - np.updatedAt.getTime() > DEVICE_STALE_MS),
source: np.source,
sourceApp: np.sourceApp,
progressMs: np.progressMs,
updatedAt: np.updatedAt.toISOString(),
}))
@@ -68,6 +73,19 @@ export async function getRecentPlays(userId: string, limit = 10): Promise<Recent
return plays.map((p) => ({ track: tracks.get(p.trackId)!, source: p.source, playedAt: p.startedAt.toISOString() }));
}
export const isSharingPaused = (user: Pick<User, 'sharingPausedUntil'>) =>
Boolean(user.sharingPausedUntil && user.sharingPausedUntil > new Date());
/** Starting a pause: whatever was playing becomes "played … ago" for friends. */
export async function pauseSharing(userId: string) {
const updated = await db
.update(schema.nowPlaying)
.set({ isPlaying: false, updatedAt: new Date() })
.where(and(eq(schema.nowPlaying.userId, userId), eq(schema.nowPlaying.isPlaying, true)))
.returning({ userId: schema.nowPlaying.userId });
if (updated.length) await publishNowPlaying(userId);
}
export async function publishNowPlaying(userId: string) {
const [dto] = await getNowPlaying([userId]);
if (dto) await redis.publish(nowPlayingChannel(userId), JSON.stringify(dto));
@@ -78,6 +96,7 @@ export async function applyPollResult(
userId: string,
source: NowPlayingProvider,
result: PollResult | null,
opts: { sourceApp?: string | null } = {},
): Promise<boolean> {
const [current] = await db.select().from(schema.nowPlaying).where(eq(schema.nowPlaying.userId, userId));
const now = new Date();
@@ -102,6 +121,7 @@ export async function applyPollResult(
const values = {
trackId: track.id,
source,
sourceApp: opts.sourceApp ?? null,
isPlaying: true,
progressMs: result.progressMs,
updatedAt: now,