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:
@@ -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 } : {}),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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));
|
||||||
|
|||||||
Reference in New Issue
Block a user