feat(timeshift): enhance catch-up programme handling and UI updates
Some checks are pending
Backend Tests / Plan test groups (push) Waiting to run
Backend Tests / (push) Blocked by required conditions
CI Pipeline / prepare (push) Waiting to run
CI Pipeline / docker (amd64, ubuntu-24.04) (push) Blocked by required conditions
CI Pipeline / docker (arm64, ubuntu-24.04-arm) (push) Blocked by required conditions
CI Pipeline / create-manifest (push) Blocked by required conditions
Build and Push Multi-Arch Docker Image / build-and-push (push) Waiting to run
Frontend Tests / test (push) Waiting to run

- Updated the catch-up stats UI to advance to the next EPG programme when playback continues past the original programme's end, improving user experience.
- Enhanced the `get_programme_info` function to resolve the guide entry based on the playhead position, ensuring accurate display of the current programme.
- Introduced new utility functions for computing catch-up playback position and checking if the playhead is outside the displayed programme window, optimizing session management.
- Added tests to validate the new functionality and ensure correct behaviour of the catch-up features.
This commit is contained in:
SergeantPanda 2026-07-16 23:10:06 +00:00
parent 6f62d807f4
commit 8bcc7c9ebe
6 changed files with 511 additions and 45 deletions

View file

@ -14,7 +14,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **Query-style catch-up compatibility.** Accepts `/streaming/timeshift.php` query-string catch-up requests in addition to the path-style endpoint. Path and query requests now use client-supplied duration hints when present, add a short provider-lag buffer, and fall back to EPG duration, then 120 minutes. Thanks [@dillardblom](https://github.com/dillardblom)
- **REST catch-up API for native apps.** `POST /api/catchup/sessions/` (JWT or API key) mints a server-side playback session and returns a tokenless `playback_url` at `/proxy/catchup/<channel_uuid>?session_id=...` for headerless video players. Sessions accept an optional programme `duration` in minutes, using the same buffered duration handling as XC clients. `POST /api/catchup/sessions/<session_id>/position/` lets native apps report local playhead / pause state for accurate admin stats without seeking the provider. `DELETE /api/catchup/sessions/<session_id>/` ends the session. OpenAPI docs cover handshake and idle TTL behaviour.
- **Catch-up admin APIs.** `GET /proxy/catchup/stats/` lists active catch-up viewers; `POST /proxy/catchup/programs/` returns batch EPG metadata for those sessions; `POST /proxy/catchup/stop_client/` stops a viewer from the admin UI.
- **Catch-up Stats UI.** Dedicated catch-up connection cards show channel logo, programme preview (watched/remaining timeline), playback position, bitrate, and a stop button. Stats refresh in real time via WebSocket (`timeshift_stats`) with desktop notifications when catch-up sessions start or end.
- **Catch-up Stats UI.** Dedicated catch-up connection cards show channel logo, programme preview (watched/remaining timeline), playback position, bitrate, and a stop button. When playback continues past the original programme into the catch-up buffer, the card advances to the next EPG programme. Stats refresh in real time via WebSocket (`timeshift_stats`) with desktop notifications when catch-up sessions start or end.
- **Catch-up flags** (`tv_archive`, `tv_archive_duration`) denormalized at import time for zero-cost output queries; end-of-refresh SQL rollup updates channels linked to the refreshed account and self-heals stale flags on those channels only (e.g. after catch-up streams are removed or an account is deactivated).
- **Strictly-UTC API surface, automatic per-provider timezone.** `server_info.timezone` is always `UTC` and the XC EPG `start`/`end` strings are emitted in UTC; the proxy converts the requested instant to the serving provider's own reported timezone (`server_info.timezone` captured on account refresh) at request time. No timezone to configure.
- **Multi-provider failover.** Catch-up walks the channel's catch-up streams in order (like live playback): if one provider cannot serve the archive the next is tried, each attempt with its own account context (credentials, reported timezone, user-agent). Accounts that fail with an auth/ban-class status are not retried via their other streams. Attempts prefer streams whose `catchup_days` cover the requested programme age, then fall back to shorter archives in channel order.

View file

@ -3,11 +3,14 @@
import logging
import math
import re
import time
from collections import namedtuple
from datetime import datetime, timezone
from datetime import datetime, timedelta, timezone
from urllib.parse import quote
from zoneinfo import ZoneInfo
from apps.timeshift.redis_keys import TimeshiftRedisKeys
logger = logging.getLogger(__name__)
# Credentials for the profile whose pool slot was reserved (not raw account fields).
@ -227,8 +230,15 @@ def resolve_catchup_duration(channel, timestamp_str, client_hint=None):
return get_programme_duration(channel, timestamp_str)
def get_programme_info(channel, timestamp_str):
"""Return EPG metadata for the programme airing at *timestamp_str*."""
def get_programme_info(channel, timestamp_str, position_secs=None):
"""Return EPG metadata for the programme airing at *timestamp_str*.
When ``position_secs`` is set (playhead within the archive, relative to the
programme that contains ``timestamp_str``), and that playhead is at or past
the programme's end, resolve the guide entry at the playhead instead. That
keeps catch-up stats cards on the show the viewer has actually reached when
they keep watching into the provider buffer / next programme.
"""
try:
dt = parse_catchup_timestamp(timestamp_str)
if dt is None:
@ -243,6 +253,19 @@ def get_programme_info(channel, timestamp_str):
if not programme:
return None
if position_secs is not None:
try:
offset = max(0.0, float(position_secs))
except (TypeError, ValueError):
offset = 0.0
playhead = programme.start_time + timedelta(seconds=offset)
if playhead >= programme.end_time:
advanced = channel.epg_data.programs.filter(
start_time__lte=playhead, end_time__gt=playhead
).first()
if advanced is not None:
programme = advanced
duration_seconds = (programme.end_time - programme.start_time).total_seconds()
return {
"title": programme.title,
@ -260,10 +283,19 @@ def get_catchup_programmes_for_sessions(sessions):
"""Resolve EPG metadata for catch-up stats cards (batch, on demand).
Each session dict needs ``session_id``, ``channel_uuid``, and
``programme_start``. Called by the frontend when active sessions or seek
position change, not on every stats poll.
``programme_start``. Optional ``position_secs`` (playhead within the
archive, relative to the programme containing ``programme_start``)
advances the returned programme when past that show's end. When omitted,
an estimate is taken from the session's Redis stats metadata when present.
"""
from apps.channels.models import Channel
from apps.timeshift.stats import (
_client_paused,
_decode_hash,
compute_playback_position_secs,
find_stats_channel_for_session,
)
from core.utils import RedisClient
if not sessions:
return []
@ -282,12 +314,40 @@ def get_catchup_programmes_for_sessions(sessions):
for ch in Channel.objects.filter(uuid__in=uuids).select_related("epg_data")
}
redis_client = RedisClient.get_client()
results = []
for session in valid:
channel_uuid = str(session["channel_uuid"])
programme_start = session["programme_start"]
channel = channels_by_uuid.get(channel_uuid)
position_secs = session.get("position_secs")
if position_secs is not None:
try:
position_secs = float(position_secs)
except (TypeError, ValueError):
position_secs = None
# Resolve the guide entry for the URL first so Redis playhead math has
# an EPG start; then re-resolve with position to advance past the end.
info = get_programme_info(channel, programme_start) if channel else None
if position_secs is None and info is not None and redis_client is not None:
position_secs = _position_secs_from_stats(
redis_client,
session_id=session["session_id"],
programme_start=programme_start,
epg_start_iso=info["start_time"],
compute_playback_position_secs=compute_playback_position_secs,
find_stats_channel_for_session=find_stats_channel_for_session,
client_paused=_client_paused,
decode_hash=_decode_hash,
)
if channel is not None and position_secs is not None:
advanced = get_programme_info(
channel, programme_start, position_secs=position_secs,
)
if advanced is not None:
info = advanced
entry = {
"session_id": session["session_id"],
"channel_uuid": channel_uuid,
@ -306,6 +366,46 @@ def get_catchup_programmes_for_sessions(sessions):
return results
def _position_secs_from_stats(
redis_client,
*,
session_id,
programme_start,
epg_start_iso,
compute_playback_position_secs,
find_stats_channel_for_session,
client_paused,
decode_hash,
):
"""Best-effort uncapped playhead from timeshift stats Redis metadata."""
try:
stats_channel_id = find_stats_channel_for_session(redis_client, session_id)
if not stats_channel_id:
return None
client_key = TimeshiftRedisKeys.client_metadata(stats_channel_id, session_id)
client_data = decode_hash(redis_client.hgetall(client_key))
if not client_data:
return None
playback_base_raw = client_data.get("playback_base_secs")
playback_base_secs = None
if playback_base_raw not in (None, ""):
try:
playback_base_secs = float(playback_base_raw)
except (TypeError, ValueError):
playback_base_secs = None
return compute_playback_position_secs(
programme_start,
epg_start_iso,
client_data.get("position_anchor_at"),
time.time(),
duration_secs=None,
playback_base_secs=playback_base_secs,
paused=client_paused(client_data.get("paused")),
)
except Exception:
return None
def build_timeshift_url_format_a(creds, stream_id, timestamp, duration_minutes):
"""QUERY layout: ``/streaming/timeshift.php?username=...&start=...``"""
return (

View file

@ -56,6 +56,43 @@ class GetProgrammeInfoTests(TestCase):
self.assertEqual(info["sub_title"], "Local Edition")
self.assertEqual(info["duration_secs"], 45 * 60)
def test_get_programme_info_advances_past_end(self):
from datetime import datetime, timedelta, timezone as dt_timezone
from unittest.mock import MagicMock
start = datetime(2026, 6, 8, 17, 0, tzinfo=dt_timezone.utc)
first = MagicMock(
title="Show A",
sub_title="",
description="",
start_time=start,
end_time=start + timedelta(minutes=30),
)
second = MagicMock(
title="Show B",
sub_title="",
description="",
start_time=start + timedelta(minutes=30),
end_time=start + timedelta(minutes=60),
)
channel = MagicMock()
def _filter(**kwargs):
qs = MagicMock()
dt = kwargs["start_time__lte"]
chosen = None
for prog in (first, second):
if prog.start_time <= dt and prog.end_time > dt:
chosen = prog
break
qs.first.return_value = chosen
return qs
channel.epg_data.programs.filter.side_effect = _filter
info = get_programme_info(channel, "2026-06-08:17-00", position_secs=31 * 60)
self.assertEqual(info["title"], "Show B")
self.assertEqual(info["duration_secs"], 30 * 60)
class ComputePlaybackPositionTests(TestCase):
EPG_START = "2026-07-10T14:00:00+00:00"

View file

@ -34,6 +34,11 @@ import {
stopTimeshiftSession,
stopVODClient,
} from '../utils/pages/StatsUtils.js';
import {
computeCatchupArchivePositionSecs,
computeCatchupPlaybackSeconds,
isCatchupPlayheadOutsideProgram,
} from '../utils/cards/TimeshiftConnectionCardUtils.js';
const VodConnectionCard = React.lazy(
() => import('../components/cards/VodConnectionCard.jsx')
);
@ -342,8 +347,9 @@ const StatsPage = () => {
};
}, [activeChannelIds]); // Only re-run when active channel set changes
// Programme metadata for catch-up cards: fetch when sessions or seek
// position (programme_start) change, then reschedule at programme end.
// Catch-up programme metadata: fetch when missing or when the playhead has
// left the cached programme's window; otherwise keep that result and only
// do cheap local checks until the next boundary.
const timeshiftProgrammeKey = useMemo(() => {
return timeshiftSessions
.map((session) => `${session.session_id}:${session.programme_start || ''}`)
@ -352,9 +358,31 @@ const StatsPage = () => {
}, [timeshiftSessions]);
const timeshiftSessionsRef = useRef(timeshiftSessions);
const catchupProgramsRef = useRef(catchupPrograms);
const programmeCheckRef = useRef(() => {});
const playbackFingerprintRef = useRef('');
useEffect(() => {
timeshiftSessionsRef.current = timeshiftSessions;
const fingerprint = timeshiftSessions
.map(
(session) =>
`${session.session_id}:${session.playback_base_secs ?? ''}:${session.position_anchor_at ?? ''}:${session.paused ? 1 : 0}`
)
.sort()
.join(',');
if (
playbackFingerprintRef.current &&
fingerprint !== playbackFingerprintRef.current
) {
programmeCheckRef.current();
}
playbackFingerprintRef.current = fingerprint;
}, [timeshiftSessions]);
useEffect(() => {
catchupProgramsRef.current = catchupPrograms;
}, [catchupPrograms]);
useEffect(() => {
if (!timeshiftProgrammeKey) {
@ -362,45 +390,151 @@ const StatsPage = () => {
return;
}
let cancelled = false;
let timer = null;
let inFlight = false;
const fetchPrograms = async () => {
const sessions = timeshiftSessionsRef.current.map((session) => ({
session_id: session.session_id,
channel_uuid: session.channel_uuid,
programme_start: session.programme_start,
}));
const programs = await getCatchupPrograms(sessions);
setCatchupPrograms(programs);
if (programs && Object.keys(programs).length > 0) {
const now = new Date();
let nearestEndTime = null;
Object.values(programs).forEach((program) => {
if (program && program.end_time) {
const endTime = new Date(program.end_time);
if (
endTime > now &&
(!nearestEndTime || endTime < nearestEndTime)
) {
nearestEndTime = endTime;
}
}
});
if (nearestEndTime) {
const timeUntilChange = nearestEndTime.getTime() - now.getTime();
const fetchDelay = Math.max(timeUntilChange + 5000, 0);
timer = setTimeout(fetchPrograms, fetchDelay);
}
const clearTimer = () => {
if (timer != null) {
clearTimeout(timer);
timer = null;
}
};
const sessionNeedsProgrammeFetch = (session, program) => {
if (!program?.start_time || program.duration_secs == null) {
return true;
}
return isCatchupPlayheadOutsideProgram({
programmeStart: session.programme_start,
programStartTime: program.start_time,
programDurationSecs: program.duration_secs,
positionAnchorAt: session.position_anchor_at,
playbackBaseSecs: session.playback_base_secs,
paused: Boolean(session.paused),
});
};
const msUntilNearestBoundary = () => {
let nearestMs = null;
timeshiftSessionsRef.current.forEach((session) => {
const program = catchupProgramsRef.current?.[session.session_id];
if (!program?.start_time || program.duration_secs == null) {
return;
}
const position = computeCatchupPlaybackSeconds({
programmeStart: session.programme_start,
programStartTime: program.start_time,
programDurationSecs: program.duration_secs,
positionAnchorAt: session.position_anchor_at,
playbackBaseSecs: session.playback_base_secs,
paused: Boolean(session.paused),
capToDuration: false,
allowNegative: true,
});
if (position == null) {
return;
}
if (position < 0) {
nearestMs = 0;
return;
}
const remainingMs = Math.max(
0,
(Number(program.duration_secs) - position) * 1000
);
if (nearestMs == null || remainingMs < nearestMs) {
nearestMs = remainingMs;
}
});
return nearestMs;
};
const scheduleCheck = () => {
clearTimer();
if (cancelled) {
return;
}
const sessions = timeshiftSessionsRef.current;
const programs = catchupProgramsRef.current || {};
if (
sessions.some((session) =>
sessionNeedsProgrammeFetch(session, programs[session.session_id])
)
) {
fetchPrograms();
return;
}
// Wake near the programme end; also at least every 2s so a seek/heartbeat
// that jumps the playhead is noticed without hitting the API.
const remainingMs = msUntilNearestBoundary();
const delay =
remainingMs == null
? 2000
: Math.min(Math.max(remainingMs + 250, 250), 2000);
timer = setTimeout(scheduleCheck, delay);
};
const fetchPrograms = async () => {
if (cancelled || inFlight) {
return;
}
inFlight = true;
clearTimer();
try {
const existingPrograms = catchupProgramsRef.current || {};
const sessions = timeshiftSessionsRef.current.map((session) => {
const existing = existingPrograms[session.session_id];
// Archive playhead relative to the URL programme (uncapped). When the
// card has already advanced and we only have URL math, omit position
// and let the API fall back to Redis.
const positionSecs = computeCatchupArchivePositionSecs({
programmeStart: session.programme_start,
programStartTime: existing?.start_time,
positionAnchorAt: session.position_anchor_at,
playbackBaseSecs: session.playback_base_secs,
paused: Boolean(session.paused),
});
const payload = {
session_id: session.session_id,
channel_uuid: session.channel_uuid,
programme_start: session.programme_start,
};
if (positionSecs != null) {
payload.position_secs = positionSecs;
}
return payload;
});
const programs = await getCatchupPrograms(sessions);
if (cancelled) {
return;
}
catchupProgramsRef.current = programs;
setCatchupPrograms(programs);
const stillOutside = timeshiftSessionsRef.current.some((session) =>
sessionNeedsProgrammeFetch(session, programs[session.session_id])
);
// No next guide entry (or unknown playhead): back off instead of spinning.
timer = setTimeout(
stillOutside ? fetchPrograms : scheduleCheck,
stillOutside ? 30_000 : 250
);
} finally {
inFlight = false;
}
};
programmeCheckRef.current = scheduleCheck;
fetchPrograms();
return () => {
if (timer) clearTimeout(timer);
cancelled = true;
clearTimer();
programmeCheckRef.current = () => {};
};
}, [timeshiftProgrammeKey]);

View file

@ -25,6 +25,8 @@ export const computeCatchupPlaybackSeconds = ({
playbackBaseSecs,
paused = false,
nowMs = getNowMs(),
capToDuration = true,
allowNegative = false,
}) => {
let elapsedSinceAnchor = 0;
if (!paused && positionAnchorAt != null) {
@ -34,12 +36,26 @@ export const computeCatchupPlaybackSeconds = ({
}
}
if (playbackBaseSecs != null && !Number.isNaN(Number(playbackBaseSecs))) {
let position = Number(playbackBaseSecs) + elapsedSinceAnchor;
if (position < 0) {
// Byte-range / client-reported bases are relative to the programme that
// contained the original catch-up URL. When the stats card has advanced to a
// later guide entry, rebase onto that programme's start.
let effectiveBase = playbackBaseSecs;
if (effectiveBase != null && !Number.isNaN(Number(effectiveBase))) {
const urlMs = parseCatchupTimestampMs(programmeStart);
const epgStartMs = programStartTime ? Date.parse(programStartTime) : null;
if (
urlMs != null &&
epgStartMs != null &&
!Number.isNaN(epgStartMs) &&
epgStartMs > urlMs
) {
effectiveBase = Number(effectiveBase) - (epgStartMs - urlMs) / 1000;
}
let position = Number(effectiveBase) + elapsedSinceAnchor;
if (!allowNegative && position < 0) {
position = 0;
}
if (programDurationSecs != null) {
if (capToDuration && programDurationSecs != null) {
position = Math.min(position, Number(programDurationSecs));
}
return position;
@ -52,15 +68,88 @@ export const computeCatchupPlaybackSeconds = ({
}
const urlOffsetSec = (urlMs - epgStartMs) / 1000;
let position = urlOffsetSec + elapsedSinceAnchor;
if (position < 0) {
if (!allowNegative && position < 0) {
position = 0;
}
if (programDurationSecs != null) {
if (capToDuration && programDurationSecs != null) {
position = Math.min(position, Number(programDurationSecs));
}
return position;
};
/**
* True when the catch-up playhead is outside the displayed programme window.
* Used to fetch the next guide entry once, then cache until that show ends.
*/
export const isCatchupPlayheadOutsideProgram = ({
programmeStart,
programStartTime,
programDurationSecs,
positionAnchorAt,
playbackBaseSecs,
paused = false,
nowMs = getNowMs(),
}) => {
if (programDurationSecs == null || !programStartTime) {
return true;
}
const position = computeCatchupPlaybackSeconds({
programmeStart,
programStartTime,
programDurationSecs,
positionAnchorAt,
playbackBaseSecs,
paused,
nowMs,
capToDuration: false,
allowNegative: true,
});
if (position == null) {
return false;
}
return position < 0 || position >= Number(programDurationSecs);
};
/**
* Archive playhead relative to the programme containing ``programmeStart``.
* Uncapped so callers can detect when viewing has moved past that show.
* Does not rebase onto a later displayed programme.
*/
export const computeCatchupArchivePositionSecs = ({
programmeStart,
programStartTime,
positionAnchorAt,
playbackBaseSecs,
paused = false,
nowMs = getNowMs(),
}) => {
let elapsedSinceAnchor = 0;
if (!paused && positionAnchorAt != null) {
const anchor = Number(positionAnchorAt);
if (!Number.isNaN(anchor)) {
elapsedSinceAnchor = Math.max(0, nowMs / 1000 - anchor);
}
}
// playback_base is always relative to the URL programme, even after the
// stats card has advanced to a later guide entry.
if (playbackBaseSecs != null && !Number.isNaN(Number(playbackBaseSecs))) {
return Math.max(0, Number(playbackBaseSecs) + elapsedSinceAnchor);
}
const urlMs = parseCatchupTimestampMs(programmeStart);
const epgStartMs = programStartTime ? Date.parse(programStartTime) : null;
if (urlMs == null || epgStartMs == null || Number.isNaN(epgStartMs)) {
return null;
}
// Only valid while programStartTime is the programme that contains the URL.
// Once the card has advanced, omit position and let the API use Redis.
if (epgStartMs > urlMs) {
return null;
}
return Math.max(0, (urlMs - epgStartMs) / 1000 + elapsedSinceAnchor);
};
export const calculateConnectionDuration = (connection) => {
const seconds = getConnectionDurationSeconds(connection);
return toFriendlyDuration(seconds, 'seconds');

View file

@ -1,6 +1,8 @@
import { describe, it, expect } from 'vitest';
import {
computeCatchupPlaybackSeconds,
computeCatchupArchivePositionSecs,
isCatchupPlayheadOutsideProgram,
parseCatchupTimestampMs,
} from '../TimeshiftConnectionCardUtils.js';
@ -48,4 +50,108 @@ describe('computeCatchupPlaybackSeconds', () => {
});
expect(position).toBeCloseTo(900, 5);
});
it('rebases byte-range playhead onto a later displayed programme', () => {
const position = computeCatchupPlaybackSeconds({
programmeStart: '2026-07-10:14-00',
programStartTime: '2026-07-10T14:30:00+00:00',
programDurationSecs: 1800,
positionAnchorAt: 1000,
playbackBaseSecs: 1900,
nowMs: 1000 * 1000,
});
// 1900s from 14:00 is 100s into the 14:30 programme.
expect(position).toBeCloseTo(100, 5);
});
it('can return uncapped position past programme end', () => {
const position = computeCatchupPlaybackSeconds({
programmeStart: '2026-07-10:14-00',
programStartTime: '2026-07-10T14:00:00+00:00',
programDurationSecs: 1800,
positionAnchorAt: 1000,
playbackBaseSecs: 1790,
nowMs: 1040 * 1000,
capToDuration: false,
});
expect(position).toBeCloseTo(1830, 5);
});
});
describe('isCatchupPlayheadOutsideProgram', () => {
it('is false while playhead is inside the programme', () => {
expect(
isCatchupPlayheadOutsideProgram({
programmeStart: '2026-07-10:14-00',
programStartTime: '2026-07-10T14:00:00+00:00',
programDurationSecs: 1800,
positionAnchorAt: 1000,
playbackBaseSecs: 900,
nowMs: 1000 * 1000,
})
).toBe(false);
});
it('is true once playhead reaches programme end', () => {
expect(
isCatchupPlayheadOutsideProgram({
programmeStart: '2026-07-10:14-00',
programStartTime: '2026-07-10T14:00:00+00:00',
programDurationSecs: 1800,
positionAnchorAt: 1000,
playbackBaseSecs: 1800,
nowMs: 1000 * 1000,
})
).toBe(true);
});
it('detects leaving a later displayed programme', () => {
expect(
isCatchupPlayheadOutsideProgram({
programmeStart: '2026-07-10:14-00',
programStartTime: '2026-07-10T14:30:00+00:00',
programDurationSecs: 1800,
positionAnchorAt: 1000,
playbackBaseSecs: 3600,
nowMs: 1000 * 1000,
})
).toBe(true);
});
it('detects rewind before a later displayed programme', () => {
expect(
isCatchupPlayheadOutsideProgram({
programmeStart: '2026-07-10:14-00',
programStartTime: '2026-07-10T14:30:00+00:00',
programDurationSecs: 1800,
positionAnchorAt: 1000,
playbackBaseSecs: 1200,
nowMs: 1000 * 1000,
})
).toBe(true);
});
});
describe('computeCatchupArchivePositionSecs', () => {
it('returns uncapped absolute playhead from playback base', () => {
const position = computeCatchupArchivePositionSecs({
programmeStart: '2026-07-10:14-00',
programStartTime: '2026-07-10T14:00:00+00:00',
positionAnchorAt: 1000,
playbackBaseSecs: 1900,
nowMs: 1030 * 1000,
});
expect(position).toBeCloseTo(1930, 5);
});
it('keeps URL-relative base after the card advances to a later programme', () => {
const position = computeCatchupArchivePositionSecs({
programmeStart: '2026-07-10:14-00',
programStartTime: '2026-07-10T14:30:00+00:00',
positionAnchorAt: 1000,
playbackBaseSecs: 1900,
nowMs: 1000 * 1000,
});
expect(position).toBeCloseTo(1900, 5);
});
});