Files
musicshare/apps/api/test/progress.test.ts
T
chrisandClaude Opus 5.5 be0a00a2c1
CI / check (push) Canceled after 0s
Song progress bar, and poll right after a track ends
- Feed and profile cards show a progress bar with elapsed/total time, only
  when the source reports a real position (Plex, Jellyfin, the Android
  reader). It ticks locally between updates. Apple Music's guessed
  position is no longer reported.
- The Android reader advances MediaSession's stale position to now, stamps
  when it was read (the server corrects for upload delay), and reports
  seeks. The server pushes an update to watchers when a position jumps.
- When a polled source says where in the song someone is, the next poll is
  scheduled just after the song ends (never sooner than 5s), instead of
  waiting out the full interval.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MzFywujuH9TiuXbr9vjoX8
2026-10-06 01:24:20 -04:00

81 lines
3.3 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 } 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('seeking', () => {
it('tells watchers when the position jumps, but not as it ticks along', async () => {
const { user } = await createUser(app(), 'alice');
await applyPollResult(user.id, 'plex', playing(0));
// Pretend the last poll was 10 seconds ago.
await db
.update(schema.nowPlaying)
.set({ updatedAt: new Date(Date.now() - 10_000) })
.where(eq(schema.nowPlaying.userId, user.id));
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)); // right where it should be
await new Promise((r) => setTimeout(r, 150));
expect(messages).toHaveLength(0);
await applyPollResult(user.id, 'plex', playing(120_000)); // skipped ahead
await new Promise((r) => setTimeout(r, 150));
expect(messages).toHaveLength(1);
expect(JSON.parse(messages[0]!)).toMatchObject({ progressMs: 120_000 });
});
});