From 75062d36447d7714f88da9f6a1f83ad4ee88d6f9 Mon Sep 17 00:00:00 2001 From: chris Date: Tue, 6 Oct 2026 14:07:02 -0400 Subject: [PATCH] Don't jump progress bars back on stale Plex/Jellyfin positions Media servers only learn the position when the player checks in (~10s), so a poll can return an old number. A position up to 12s behind our running estimate is now treated as stale; positions ahead, or further behind (a rewind), are taken as-is. Phone-reported positions are exact and always taken. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MzFywujuH9TiuXbr9vjoX8 --- apps/api/src/services/nowPlaying.ts | 27 +++++++++++++++++++++++++-- apps/api/test/progress.test.ts | 27 +++++++++++++++++++++++++++ 2 files changed, 52 insertions(+), 2 deletions(-) diff --git a/apps/api/src/services/nowPlaying.ts b/apps/api/src/services/nowPlaying.ts index 1a4b619..ebc6e50 100644 --- a/apps/api/src/services/nowPlaying.ts +++ b/apps/api/src/services/nowPlaying.ts @@ -151,6 +151,28 @@ export async function publishNowPlaying(userId: string) { if (dto) await redis.publish(nowPlayingChannel(userId), JSON.stringify(dto)); } +/** How far behind our own estimate a reported position can be and still count as stale, not a rewind. */ +const STALE_POSITION_MS = 12_000; +/** Sources whose position is relayed by a media server (phones report their own, exactly). */ +const LAGGY_POSITION_SOURCES = new Set(['plex', 'jellyfin']); + +/** + * Plex and Jellyfin only learn the position when the player checks in (every ~10s), + * so a poll can return a number from several seconds ago. Behind our running + * estimate by a little: stale, keep the estimate (so bars don't jump back). Ahead, + * or far behind (they rewound): believe it. + */ +function trustedPosition( + current: { progressMs: number | null; updatedAt: Date; isPlaying: boolean }, + reported: number | null, + now: Date, +): number | null { + if (reported == null || current.progressMs == null || !current.isPlaying) return reported; + const expected = current.progressMs + (now.getTime() - current.updatedAt.getTime()); + const behind = expected - reported; + return behind > 0 && behind <= STALE_POSITION_MS ? expected : reported; +} + /** Apply one provider's poll result to the user's now-playing state. Returns whether they're playing. */ export async function applyPollResult( userId: string, @@ -176,9 +198,10 @@ export async function applyPollResult( const track = await upsertTrack(result.track); const changed = !current || current.trackId !== track.id || !current.isPlaying; + const progressMs = changed || !LAGGY_POSITION_SOURCES.has(source) ? result.progressMs : trustedPosition(current, result.progressMs, now); // A fresh position from the source: send it so viewers' progress bars realign // (seeks, buffering, pauses we missed between polls). - const realign = !changed && result.progressMs != null; + const realign = !changed && progressMs != null; const newPlay = !current || current.trackId !== track.id || (!current.isPlaying && now.getTime() - current.updatedAt.getTime() > REPLAY_GAP_MS); const values = { @@ -187,7 +210,7 @@ export async function applyPollResult( sourceApp: opts.sourceApp ?? null, sourceUrl: opts.sourceUrl ?? null, isPlaying: true, - progressMs: result.progressMs, + progressMs, updatedAt: now, ...(changed ? { startedAt: now } : {}), }; diff --git a/apps/api/test/progress.test.ts b/apps/api/test/progress.test.ts index 064bd00..9c5ecef 100644 --- a/apps/api/test/progress.test.ts +++ b/apps/api/test/progress.test.ts @@ -84,6 +84,33 @@ describe('realigning progress bars', () => { expect(later!.progressMs).toBeGreaterThanOrEqual(124_000); }); + it('ignores a Plex position that is only a little behind (stale), but believes rewinds and jumps ahead', async () => { + const { user } = await createUser(app(), 'alice'); + await applyPollResult(user.id, 'plex', playing(30_000)); + const ago = (ms: number) => + db.update(schema.nowPlaying).set({ updatedAt: new Date(Date.now() - ms) }).where(eq(schema.nowPlaying.userId, user.id)); + const stored = async () => (await db.select().from(schema.nowPlaying).where(eq(schema.nowPlaying.userId, user.id)))[0]!.progressMs!; + + await ago(10_000); // we'd expect ~40s now; Plex still says 33s + await applyPollResult(user.id, 'plex', playing(33_000)); + expect(await stored()).toBeGreaterThanOrEqual(40_000); + + await ago(0); + await applyPollResult(user.id, 'plex', playing(5_000)); // rewound to near the start + expect(await stored()).toBe(5_000); + + await applyPollResult(user.id, 'plex', playing(90_000)); // skipped ahead + expect(await stored()).toBe(90_000); + }); + + it('takes phone-reported positions as they are', async () => { + const { user } = await createUser(app(), 'alice'); + await applyPollResult(user.id, 'android_media_session', playing(30_000)); + await applyPollResult(user.id, 'android_media_session', playing(25_000)); + const [np] = await db.select().from(schema.nowPlaying).where(eq(schema.nowPlaying.userId, user.id)); + expect(np!.progressMs).toBe(25_000); + }); + it("doesn't send anything extra for sources without a position", async () => { const { user } = await createUser(app(), 'alice'); await applyPollResult(user.id, 'lastfm', playing(null));