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));
}
/** 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. */
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 } : {}),
};