Don't jump progress bars back on stale Plex/Jellyfin positions
CI / check (push) Canceled after 0s

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MzFywujuH9TiuXbr9vjoX8
This commit is contained in:
2026-10-06 14:07:02 -04:00
co-authored by Claude Opus 5.5
parent c07df96d78
commit 75062d3644
2 changed files with 52 additions and 2 deletions
+25 -2
View File
@@ -151,6 +151,28 @@ export async function publishNowPlaying(userId: string) {
if (dto) await redis.publish(nowPlayingChannel(userId), JSON.stringify(dto)); 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<NowPlayingProvider>(['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. */ /** Apply one provider's poll result to the user's now-playing state. Returns whether they're playing. */
export async function applyPollResult( export async function applyPollResult(
userId: string, userId: string,
@@ -176,9 +198,10 @@ export async function applyPollResult(
const track = await upsertTrack(result.track); const track = await upsertTrack(result.track);
const changed = !current || current.trackId !== track.id || !current.isPlaying; 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 // A fresh position from the source: send it so viewers' progress bars realign
// (seeks, buffering, pauses we missed between polls). // (seeks, buffering, pauses we missed between polls).
const realign = !changed && result.progressMs != null; const realign = !changed && progressMs != null;
const newPlay = const newPlay =
!current || current.trackId !== track.id || (!current.isPlaying && now.getTime() - current.updatedAt.getTime() > REPLAY_GAP_MS); !current || current.trackId !== track.id || (!current.isPlaying && now.getTime() - current.updatedAt.getTime() > REPLAY_GAP_MS);
const values = { const values = {
@@ -187,7 +210,7 @@ export async function applyPollResult(
sourceApp: opts.sourceApp ?? null, sourceApp: opts.sourceApp ?? null,
sourceUrl: opts.sourceUrl ?? null, sourceUrl: opts.sourceUrl ?? null,
isPlaying: true, isPlaying: true,
progressMs: result.progressMs, progressMs,
updatedAt: now, updatedAt: now,
...(changed ? { startedAt: now } : {}), ...(changed ? { startedAt: now } : {}),
}; };
+27
View File
@@ -84,6 +84,33 @@ describe('realigning progress bars', () => {
expect(later!.progressMs).toBeGreaterThanOrEqual(124_000); 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 () => { it("doesn't send anything extra for sources without a position", async () => {
const { user } = await createUser(app(), 'alice'); const { user } = await createUser(app(), 'alice');
await applyPollResult(user.id, 'lastfm', playing(null)); await applyPollResult(user.id, 'lastfm', playing(null));