diff --git a/CHANGELOG.md b/CHANGELOG.md index 82aae09f..857138ee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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/?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//position/` lets native apps report local playhead / pause state for accurate admin stats without seeking the provider. `DELETE /api/catchup/sessions//` 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. diff --git a/apps/timeshift/helpers.py b/apps/timeshift/helpers.py index 292ae2db..adc80861 100644 --- a/apps/timeshift/helpers.py +++ b/apps/timeshift/helpers.py @@ -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 ( diff --git a/apps/timeshift/tests/test_stats.py b/apps/timeshift/tests/test_stats.py index d74d22fb..0503a4ea 100644 --- a/apps/timeshift/tests/test_stats.py +++ b/apps/timeshift/tests/test_stats.py @@ -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" diff --git a/frontend/src/pages/Stats.jsx b/frontend/src/pages/Stats.jsx index 38320fee..3061260b 100644 --- a/frontend/src/pages/Stats.jsx +++ b/frontend/src/pages/Stats.jsx @@ -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]); diff --git a/frontend/src/utils/cards/TimeshiftConnectionCardUtils.js b/frontend/src/utils/cards/TimeshiftConnectionCardUtils.js index 10b86ab5..bd4f9fb3 100644 --- a/frontend/src/utils/cards/TimeshiftConnectionCardUtils.js +++ b/frontend/src/utils/cards/TimeshiftConnectionCardUtils.js @@ -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'); diff --git a/frontend/src/utils/cards/__tests__/TimeshiftConnectionCardUtils.test.js b/frontend/src/utils/cards/__tests__/TimeshiftConnectionCardUtils.test.js index 979a63fb..b7f3d605 100644 --- a/frontend/src/utils/cards/__tests__/TimeshiftConnectionCardUtils.test.js +++ b/frontend/src/utils/cards/__tests__/TimeshiftConnectionCardUtils.test.js @@ -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); + }); });