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
125 lines
5.7 KiB
TypeScript
125 lines
5.7 KiB
TypeScript
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, getNowPlaying } 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('realigning progress bars', () => {
|
|
it('sends the fresh position on every poll that has one, advanced to send time', async () => {
|
|
const { user } = await createUser(app(), 'alice');
|
|
await applyPollResult(user.id, 'plex', playing(0));
|
|
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));
|
|
await db
|
|
.update(schema.nowPlaying)
|
|
.set({ updatedAt: new Date(Date.now() - 3_000) }) // as if that poll was 3s ago
|
|
.where(eq(schema.nowPlaying.userId, user.id));
|
|
await applyPollResult(user.id, 'plex', playing(120_000)); // skipped ahead
|
|
await new Promise((r) => setTimeout(r, 150));
|
|
const sent = messages.map((m) => JSON.parse(m).progressMs as number);
|
|
expect(sent).toHaveLength(2);
|
|
expect(sent[0]).toBeGreaterThanOrEqual(10_500);
|
|
expect(sent[0]).toBeLessThan(11_500);
|
|
expect(sent[1]).toBeGreaterThanOrEqual(120_000);
|
|
expect(sent[1]).toBeLessThan(121_000);
|
|
|
|
// Fetched later, the position has moved on by the time since the poll.
|
|
const [np] = await getNowPlaying([user.id]);
|
|
await db.update(schema.nowPlaying).set({ updatedAt: new Date(Date.now() - 4_000) }).where(eq(schema.nowPlaying.userId, user.id));
|
|
const [later] = await getNowPlaying([user.id]);
|
|
expect(np!.progressMs).toBeGreaterThanOrEqual(120_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 () => {
|
|
const { user } = await createUser(app(), 'alice');
|
|
await applyPollResult(user.id, 'lastfm', playing(null));
|
|
const messages: string[] = [];
|
|
await sub.subscribe(nowPlayingChannel(user.id));
|
|
sub.on('message', (_channel, msg) => messages.push(msg));
|
|
await applyPollResult(user.id, 'lastfm', playing(null));
|
|
await new Promise((r) => setTimeout(r, 150));
|
|
expect(messages).toHaveLength(0);
|
|
});
|
|
});
|