- Feed and profile cards show a progress bar with elapsed/total time, only when the source reports a real position (Plex, Jellyfin, the Android reader). It ticks locally between updates. Apple Music's guessed position is no longer reported. - The Android reader advances MediaSession's stale position to now, stamps when it was read (the server corrects for upload delay), and reports seeks. The server pushes an update to watchers when a position jumps. - When a polled source says where in the song someone is, the next poll is scheduled just after the song ends (never sooner than 5s), instead of waiting out the full interval. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MzFywujuH9TiuXbr9vjoX8
This commit is contained in:
@@ -65,6 +65,7 @@ export const appleMusicProvider: PollingProvider = {
|
||||
const track = toIncomingTrack(latest);
|
||||
const duration = latest.attributes?.durationInMillis ?? DEFAULT_DURATION_MS;
|
||||
if (!track || !meta.lastSeenAt || now - meta.lastSeenAt > duration + GRACE_MS) return null;
|
||||
return { track, isPlaying: true, progressMs: now - meta.lastSeenAt };
|
||||
// Recently-played has no position, and a guess would make a progress bar lie.
|
||||
return { track, isPlaying: true, progressMs: null };
|
||||
},
|
||||
};
|
||||
|
||||
@@ -15,6 +15,7 @@ export interface IncomingTrack {
|
||||
export interface PollResult {
|
||||
track: IncomingTrack;
|
||||
isPlaying: boolean;
|
||||
/** Position in the track, only when the source actually reports it (never estimated). */
|
||||
progressMs: number | null;
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
*/
|
||||
import { and, desc, eq, notInArray } from 'drizzle-orm';
|
||||
import type { FastifyInstance, FastifyRequest } from 'fastify';
|
||||
import { ANDROID_MUSIC_APPS, devicePlaybackSchema, registerDeviceSchema, type DeviceDTO } from '@musicshare/shared';
|
||||
import { ANDROID_MUSIC_APPS, devicePlaybackSchema, registerDeviceSchema, type DeviceDTO, type DevicePlaybackInput } from '@musicshare/shared';
|
||||
import { db, schema } from '../db';
|
||||
import { requireUser } from '../lib/auth';
|
||||
import { randomToken, sha256 } from '../lib/crypto';
|
||||
@@ -113,10 +113,24 @@ export async function deviceRoutes(app: FastifyInstance) {
|
||||
await applyPollResult(
|
||||
device.userId,
|
||||
'android_media_session',
|
||||
track ? { track, isPlaying: input.state === 'playing', progressMs: input.positionMs ?? null } : null,
|
||||
track ? { track, isPlaying: input.state === 'playing', progressMs: reportedPosition(input) } : null,
|
||||
{ sourceApp },
|
||||
);
|
||||
return reply.status(204).send();
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reports can reach us a while after they were made (WorkManager retries, the
|
||||
* 4-minute heartbeat resends the last one), so advance a playing position by the
|
||||
* time since the phone read it. Ignore a timestamp from a badly wrong clock.
|
||||
*/
|
||||
export function reportedPosition(input: DevicePlaybackInput, now = Date.now()): number | null {
|
||||
if (input.positionMs == null) return null;
|
||||
if (input.state !== 'playing' || !input.positionAt) return input.positionMs;
|
||||
const age = now - input.positionAt;
|
||||
if (age < -60_000 || age > 15 * 60_000) return input.positionMs;
|
||||
const position = input.positionMs + Math.max(0, age);
|
||||
return input.track?.durationMs ? Math.min(position, input.track.durationMs) : position;
|
||||
}
|
||||
|
||||
@@ -94,6 +94,8 @@ export async function publishNowPlaying(userId: string) {
|
||||
if (dto) await redis.publish(nowPlayingChannel(userId), JSON.stringify(dto));
|
||||
}
|
||||
|
||||
const SEEK_THRESHOLD_MS = 5_000;
|
||||
|
||||
/** Apply one provider's poll result to the user's now-playing state. Returns whether they're playing. */
|
||||
export async function applyPollResult(
|
||||
userId: string,
|
||||
@@ -119,6 +121,13 @@ export async function applyPollResult(
|
||||
|
||||
const track = await upsertTrack(result.track);
|
||||
const changed = !current || current.trackId !== track.id || !current.isPlaying;
|
||||
// Same song, but not where it should be by now: a seek (or a restart). Tell
|
||||
// watchers, or their progress bars would drift.
|
||||
const jumped =
|
||||
!changed &&
|
||||
current.progressMs != null &&
|
||||
result.progressMs != null &&
|
||||
Math.abs(result.progressMs - (current.progressMs + (now.getTime() - current.updatedAt.getTime()))) > SEEK_THRESHOLD_MS;
|
||||
const newPlay =
|
||||
!current || current.trackId !== track.id || (!current.isPlaying && now.getTime() - current.updatedAt.getTime() > REPLAY_GAP_MS);
|
||||
const values = {
|
||||
@@ -137,6 +146,7 @@ export async function applyPollResult(
|
||||
.onConflictDoUpdate({ target: schema.nowPlaying.userId, set: values });
|
||||
|
||||
if (newPlay) await recordListen(userId, track.id, source, now);
|
||||
if (jumped) await publishNowPlaying(userId);
|
||||
if (changed) {
|
||||
await publishNowPlaying(userId);
|
||||
const [t] = await db
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
* which is what keeps third-party API usage proportional to real engagement.
|
||||
*/
|
||||
import { redis } from '../lib/redis';
|
||||
import type { PollResult } from '../providers/types';
|
||||
|
||||
const DUE_KEY = 'poll:due';
|
||||
const WATCHED_TTL_SECONDS = 90;
|
||||
@@ -31,6 +32,23 @@ export function ensureScheduled(connectionIds: string[]) {
|
||||
return redis.zadd(DUE_KEY, 'NX', ...connectionIds.flatMap((id) => [now, id]));
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* When the source says exactly where in the song they are, check again just
|
||||
* after it ends instead of waiting out the full interval: the next song shows
|
||||
* up on time, and the progress bar doesn't sit at the end.
|
||||
*/
|
||||
export function nextPollDelay(intervalMs: number, result: PollResult | null): number {
|
||||
const duration = result?.track.durationMs;
|
||||
if (!result?.isPlaying || result.progressMs == null || !duration) return intervalMs;
|
||||
const remaining = duration - result.progressMs;
|
||||
if (remaining <= 0) return intervalMs;
|
||||
return Math.min(intervalMs, Math.max(remaining + TRACK_END_MARGIN_MS, MIN_POLL_GAP_MS));
|
||||
}
|
||||
|
||||
const TRACK_END_MARGIN_MS = 2_000; // let the player move on to the next song first
|
||||
const MIN_POLL_GAP_MS = 5_000;
|
||||
|
||||
export const unschedulePoll = (connectionId: string) => redis.zrem(DUE_KEY, connectionId);
|
||||
|
||||
const CLAIM_SCRIPT = `
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
claimDue,
|
||||
ensureScheduled,
|
||||
isWatched,
|
||||
nextPollDelay,
|
||||
schedulePoll,
|
||||
unschedulePoll,
|
||||
} from './services/scheduler';
|
||||
@@ -58,7 +59,7 @@ async function pollConnection(connectionId: string) {
|
||||
: playing
|
||||
? PLAYING_UNWATCHED_POLL_SECONDS
|
||||
: IDLE_POLL_SECONDS;
|
||||
await schedulePoll(conn.id, seconds * 1000);
|
||||
await schedulePoll(conn.id, nextPollDelay(seconds * 1000, result));
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
if (err instanceof ProviderAuthError) {
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import { db, schema } from '../src/db';
|
||||
import { createRedis, nowPlayingChannel } from '../src/lib/redis';
|
||||
import { reportedPosition } from '../src/routes/devices';
|
||||
import { applyPollResult } from '../src/services/nowPlaying';
|
||||
import { nextPollDelay } from '../src/services/scheduler';
|
||||
import { createUser, useTestApp } from './helpers';
|
||||
|
||||
const app = useTestApp();
|
||||
const sub = createRedis();
|
||||
afterEach(() => sub.unsubscribe());
|
||||
|
||||
const playing = (progressMs: number | null, durationMs: number | null = 200_000) => ({
|
||||
track: { title: 'Windowlicker', artist: 'Aphex Twin', durationMs },
|
||||
isPlaying: true,
|
||||
progressMs,
|
||||
});
|
||||
|
||||
describe('polling at the end of a track', () => {
|
||||
it('polls just after the song ends when that comes before the next regular poll', () => {
|
||||
expect(nextPollDelay(60_000, playing(180_000))).toBe(22_000); // 20s left + 2s margin
|
||||
});
|
||||
it('keeps the regular interval when the song ends later', () => {
|
||||
expect(nextPollDelay(10_000, playing(60_000))).toBe(10_000);
|
||||
});
|
||||
it('never polls more than every 5 seconds', () => {
|
||||
expect(nextPollDelay(60_000, playing(199_500))).toBe(5_000);
|
||||
});
|
||||
it('keeps the interval without a position, a length, or while paused', () => {
|
||||
expect(nextPollDelay(60_000, playing(null))).toBe(60_000);
|
||||
expect(nextPollDelay(60_000, playing(1000, null))).toBe(60_000);
|
||||
expect(nextPollDelay(60_000, { ...playing(180_000), isPlaying: false })).toBe(60_000);
|
||||
expect(nextPollDelay(60_000, null)).toBe(60_000);
|
||||
});
|
||||
});
|
||||
|
||||
describe('phone-reported positions', () => {
|
||||
const now = Date.parse('2026-10-06T12:00:00Z');
|
||||
const report = (positionAt: number | null, state: 'playing' | 'paused' = 'playing') => ({
|
||||
app: 'com.spotify.music',
|
||||
state,
|
||||
track: { title: 'X', artist: 'Y', durationMs: 200_000 },
|
||||
positionMs: 30_000,
|
||||
positionAt,
|
||||
});
|
||||
it('advances by the time since the phone read it', () => {
|
||||
expect(reportedPosition(report(now - 4_000), now)).toBe(34_000);
|
||||
});
|
||||
it("doesn't advance while paused, or trust a wildly wrong clock", () => {
|
||||
expect(reportedPosition(report(now - 4_000, 'paused'), now)).toBe(30_000);
|
||||
expect(reportedPosition(report(now - 3 * 3600_000), now)).toBe(30_000);
|
||||
expect(reportedPosition(report(null), now)).toBe(30_000);
|
||||
});
|
||||
});
|
||||
|
||||
describe('seeking', () => {
|
||||
it('tells watchers when the position jumps, but not as it ticks along', async () => {
|
||||
const { user } = await createUser(app(), 'alice');
|
||||
await applyPollResult(user.id, 'plex', playing(0));
|
||||
// Pretend the last poll was 10 seconds ago.
|
||||
await db
|
||||
.update(schema.nowPlaying)
|
||||
.set({ updatedAt: new Date(Date.now() - 10_000) })
|
||||
.where(eq(schema.nowPlaying.userId, user.id));
|
||||
|
||||
const messages: string[] = [];
|
||||
await sub.subscribe(nowPlayingChannel(user.id));
|
||||
sub.on('message', (_channel, msg) => messages.push(msg));
|
||||
|
||||
await applyPollResult(user.id, 'plex', playing(10_500)); // right where it should be
|
||||
await new Promise((r) => setTimeout(r, 150));
|
||||
expect(messages).toHaveLength(0);
|
||||
|
||||
await applyPollResult(user.id, 'plex', playing(120_000)); // skipped ahead
|
||||
await new Promise((r) => setTimeout(r, 150));
|
||||
expect(messages).toHaveLength(1);
|
||||
expect(JSON.parse(messages[0]!)).toMatchObject({ progressMs: 120_000 });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user