diff --git a/CHANGELOG.md b/CHANGELOG.md index b336b834..82aae09f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,7 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Native XZ decompression for EPG sources and uploaded M3U playlists.** `.xz`-compressed XMLTV and M3U files are now decompressed using Python's stdlib `lzma` module. EPG ingestion detects XZ via magic bytes, file extension (including compound names like `.xml.xz`), or MIME type; uploaded M3U `.xz` playlists stream line-by-line like `.gz`. The `/data/epgs` auto-import scanner now includes `.xz` files alongside `.xml`, `.gz`, and `.zip`. (Closes #1414) — Thanks [@MotWakorb](https://github.com/MotWakorb) - **XC catch-up (timeshift) support**. Adds a native `/timeshift/{user}/{pass}/{duration}/{timestamp}/{channel_id}.ts` endpoint that proxies replay sessions from an Xtream Codes provider to IPTV clients. Auth reuses the same `xc_password` custom-property the live `/live/.../{id}.ts` endpoint already uses. Catch-up sessions appear on the Stats page in dedicated connection cards (separate from live and VOD) and respect per-channel access rules. (Closes #133) — Thanks [@cedric-marcoux](https://github.com/cedric-marcoux) - **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. `DELETE /api/catchup/sessions//` ends the session. OpenAPI docs cover handshake and idle TTL behaviour. + - **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 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). diff --git a/apps/timeshift/api_urls.py b/apps/timeshift/api_urls.py index afcecddb..1f93fb16 100644 --- a/apps/timeshift/api_urls.py +++ b/apps/timeshift/api_urls.py @@ -10,6 +10,11 @@ urlpatterns = [ api_views.CatchupSessionCreateAPIView.as_view(), name="catchup-session-create", ), + path( + "sessions//position/", + api_views.CatchupSessionPositionAPIView.as_view(), + name="catchup-session-position", + ), path( "sessions//", api_views.CatchupSessionDestroyAPIView.as_view(), diff --git a/apps/timeshift/api_views.py b/apps/timeshift/api_views.py index 4a90e64a..7e216df8 100644 --- a/apps/timeshift/api_views.py +++ b/apps/timeshift/api_views.py @@ -9,6 +9,7 @@ from rest_framework.views import APIView from apps.accounts.permissions import IsStandardUser from apps.channels.models import Channel from apps.channels.utils import get_channel_catchup_streams +from core.utils import RedisClient from dispatcharr.utils import network_access_allowed from .helpers import MAX_DURATION_MINUTES, parse_catchup_timestamp @@ -19,7 +20,11 @@ from .sessions import ( delete_catchup_session, user_owns_catchup_session, ) -from .views import _user_can_access_channel +from .stats import update_catchup_session_position +from .views import _trigger_timeshift_stats_update, _user_can_access_channel + +# Programme length cap expressed in seconds for position reports. +_MAX_POSITION_SECS = MAX_DURATION_MINUTES * 60 class CatchupSessionCreateSerializer(serializers.Serializer): @@ -73,6 +78,11 @@ class CatchupSessionCreateAPIView(APIView): "**``start``** is the programme's broadcast start time in UTC " "(from EPG ``start_time``). It selects *which* archived show to " "fetch, not when the viewer pressed play.\n\n" + "**``duration``** is optional programme length in minutes. Native " + "clients should send it when their guide knows the programme " + "length. Dispatcharr uses it before local EPG duration, adds a " + "short provider-lag buffer, and falls back to EPG duration, then " + "the default archive window when omitted.\n\n" f"The player should open ``playback_url`` within " f"**{HANDSHAKE_TTL_SECONDS} seconds** (see ``expires_at``). After the " "first byte request, the session stays valid with a " @@ -186,3 +196,79 @@ class CatchupSessionDestroyAPIView(APIView): delete_catchup_session(session_id) return Response(status=status.HTTP_204_NO_CONTENT) + + +class CatchupSessionPositionSerializer(serializers.Serializer): + position_secs = serializers.FloatField( + min_value=0, + max_value=_MAX_POSITION_SECS, + help_text=( + "Current playhead within the programme, in seconds from programme start." + ), + ) + paused = serializers.BooleanField( + required=False, + help_text=( + "When true, admin stats freeze at ``position_secs`` (no wall-clock " + "advance). When false, clear pause. Omit to leave pause state unchanged." + ), + ) + + +class CatchupSessionPositionAPIView(APIView): + """Native clients report local playhead / pause for catch-up stats polish.""" + + permission_classes = [IsStandardUser] + + @extend_schema( + description=( + "Update the reported playhead for an active catch-up session.\n\n" + "Native apps can call this while paused or after local scrubbing so " + "admin stats stay aligned with what the viewer sees. This does " + "**not** seek the provider stream; HTTP ``Range`` / a new archive " + "open still control bytes.\n\n" + "Requires an active playback connection for ``session_id``. Also " + "refreshes the session idle TTL." + ), + request=CatchupSessionPositionSerializer, + responses={ + 204: None, + 400: inline_serializer( + name="CatchupSessionPositionError", + fields={"error": serializers.CharField()}, + ), + 403: inline_serializer( + name="CatchupSessionPositionForbidden", + fields={"error": serializers.CharField()}, + ), + 404: inline_serializer( + name="CatchupSessionPositionNotFound", + fields={"error": serializers.CharField()}, + ), + }, + tags=["catchup"], + ) + def post(self, request, session_id): + if not network_access_allowed(request, "STREAMS", request.user): + return Response({"error": "Forbidden"}, status=status.HTTP_403_FORBIDDEN) + + if not user_owns_catchup_session(session_id, request.user.id): + return Response({"error": "Session not found"}, status=status.HTTP_404_NOT_FOUND) + + body = CatchupSessionPositionSerializer(data=request.data) + body.is_valid(raise_exception=True) + + updated = update_catchup_session_position( + session_id, + position_secs=body.validated_data["position_secs"], + paused=body.validated_data.get("paused"), + user_id=request.user.id, + ) + if not updated: + return Response( + {"error": "No active playback for this session"}, + status=status.HTTP_404_NOT_FOUND, + ) + + _trigger_timeshift_stats_update(RedisClient.get_client()) + return Response(status=status.HTTP_204_NO_CONTENT) diff --git a/apps/timeshift/stats.py b/apps/timeshift/stats.py index de5cf857..ff4e3066 100644 --- a/apps/timeshift/stats.py +++ b/apps/timeshift/stats.py @@ -13,6 +13,7 @@ from apps.m3u.models import M3UAccountProfile from apps.proxy.live_proxy.constants import ChannelMetadataField from apps.timeshift.redis_keys import TimeshiftRedisKeys, parse_stats_channel_id from apps.timeshift.helpers import parse_catchup_timestamp +from core.utils import RedisClient logger = logging.getLogger(__name__) @@ -222,6 +223,7 @@ def compute_playback_position_secs( current_time, duration_secs=None, playback_base_secs=None, + paused=False, ): """Best-effort catch-up play position in seconds within the programme. @@ -233,9 +235,12 @@ def compute_playback_position_secs( Native players (VLC) often keep the programme URL fixed and seek via ``Range: bytes=…``; in that case ``playback_base_secs`` carries the mapped position at the anchor instead of the URL timestamp offset. + + When ``paused`` is true, wall-clock since the anchor is ignored so admin + stats stay frozen at the last reported playhead. """ elapsed_since_anchor = 0.0 - if position_anchor_at: + if not paused and position_anchor_at: try: elapsed_since_anchor = max(0.0, current_time - float(position_anchor_at)) except (TypeError, ValueError): @@ -288,6 +293,81 @@ def find_stats_channel_for_session(redis_client, session_id): return None +def _client_paused(raw_value): + if raw_value is None or raw_value == "": + return False + value = _decode_value(raw_value).strip().lower() + return value in {"1", "true", "yes"} + + +def update_catchup_session_position( + session_id, + *, + position_secs, + paused=None, + user_id=None, + redis_client=None, +): + """Record a native client's playhead for catch-up stats. + + Updates the active stats client hash for ``session_id`` and refreshes the + API session idle TTL. Does not seek the provider stream. + + Returns: + ``True`` when metadata was updated, ``False`` when there is no active + playback stats entry (or Redis is unavailable). + """ + from apps.timeshift.sessions import touch_catchup_session + + if redis_client is None: + redis_client = RedisClient.get_client() + if redis_client is None or not session_id: + return False + + stats_channel_id = find_stats_channel_for_session(redis_client, session_id) + if not stats_channel_id: + return False + + client_key = TimeshiftRedisKeys.client_metadata(stats_channel_id, session_id) + client_set_key = TimeshiftRedisKeys.clients(stats_channel_id) + metadata_key = TimeshiftRedisKeys.channel_metadata(stats_channel_id) + try: + if not redis_client.exists(client_key): + return False + if user_id is not None: + stored_user = redis_client.hget(client_key, "user_id") + if stored_user is not None and str(_decode_value(stored_user)) != str(user_id): + return False + except Exception: + return False + + now = str(time.time()) + mapping = { + "playback_base_secs": str(float(position_secs)), + "position_anchor_at": now, + "last_active": now, + } + # Must match apps.timeshift.views.CLIENT_TTL_SECONDS (stats / fingerprint). + client_ttl_seconds = 60 + + try: + pipe = redis_client.pipeline(transaction=False) + pipe.hset(client_key, mapping=mapping) + if paused is True: + pipe.hset(client_key, "paused", "1") + elif paused is False: + pipe.hdel(client_key, "paused") + pipe.expire(client_key, client_ttl_seconds) + pipe.expire(client_set_key, client_ttl_seconds) + pipe.expire(metadata_key, client_ttl_seconds) + pipe.execute() + except Exception: + return False + + touch_catchup_session(session_id, redis_client=redis_client) + return True + + def build_timeshift_stats_data(redis_client): """Build catch-up stats payload from Redis session metadata.""" empty = { @@ -369,6 +449,8 @@ def build_timeshift_stats_data(redis_client): except (TypeError, ValueError): playback_base_secs = None + paused = _client_paused(client_data.get("paused")) + connected_at = client_data.get("connected_at") duration = 0 if connected_at: @@ -388,6 +470,7 @@ def build_timeshift_stats_data(redis_client): "programme_start": programme_start, "position_anchor_at": client_data.get("position_anchor_at"), "playback_base_secs": playback_base_secs, + "paused": paused, "m3u_profile_id": int(m3u_profile_id) if m3u_profile_id else None, "ip_address": client_data.get("ip_address", "Unknown"), "user_agent": client_data.get("user_agent", "unknown"), @@ -467,6 +550,7 @@ def build_timeshift_stats_data(redis_client): "programme_start": conn["programme_start"], "position_anchor_at": position_anchor_at, "playback_base_secs": playback_base_secs, + "paused": bool(conn.get("paused")), "resolution": conn.get("resolution"), "source_fps": conn.get("source_fps"), "video_codec": conn.get("video_codec"), diff --git a/apps/timeshift/tests/test_sessions.py b/apps/timeshift/tests/test_sessions.py index 385fe292..5d815bc0 100644 --- a/apps/timeshift/tests/test_sessions.py +++ b/apps/timeshift/tests/test_sessions.py @@ -206,6 +206,139 @@ class CatchupSessionApiTests(TestCase): self.assertEqual(deleted.status_code, 404) +@override_settings( + CACHES={ + "default": { + "BACKEND": "django.core.cache.backends.locmem.LocMemCache", + "LOCATION": "catchup-session-position-tests", + } + }, + REST_FRAMEWORK={ + "DEFAULT_AUTHENTICATION_CLASSES": [ + "rest_framework_simplejwt.authentication.JWTAuthentication", + "apps.accounts.authentication.ApiKeyAuthentication", + ], + "DEFAULT_PERMISSION_CLASSES": [ + "apps.accounts.permissions.IsAdmin", + ], + }, +) +class CatchupSessionPositionApiTests(TestCase): + @classmethod + def setUpTestData(cls): + cls.user = User.objects.create( + username="catchup-position-user", + user_level=User.UserLevel.STANDARD, + ) + cls.account = M3UAccount.objects.create( + name="catchup-position-acct", + server_url="http://example.test", + account_type="XC", + is_active=True, + ) + cls.channel = Channel.objects.create( + name="Catchup Position Channel", + is_catchup=True, + catchup_days=7, + ) + cls.stream = Stream.objects.create( + name="catchup-position-stream", + url="http://example.test/live", + m3u_account=cls.account, + is_catchup=True, + catchup_days=7, + custom_properties={"stream_id": "111"}, + ) + ChannelStream.objects.create( + channel=cls.channel, stream=cls.stream, order=0, + ) + + def setUp(self): + from apps.timeshift.tests.test_views import _FakeRedis + + self.client = APIClient() + self.client.force_authenticate(user=self.user) + self.redis = _FakeRedis() + + def _seed_active_playback(self, session_id): + stats_channel_id = f"{self.channel.id}_{session_id}" + self.redis.hset( + TimeshiftRedisKeys.channel_metadata(stats_channel_id), + mapping={"state": "active"}, + ) + self.redis.sadd(TimeshiftRedisKeys.clients(stats_channel_id), session_id) + self.redis.hset( + TimeshiftRedisKeys.client_metadata(stats_channel_id, session_id), + mapping={ + "user_id": str(self.user.id), + "username": self.user.username, + "programme_start": "2026-06-08T17:00:00Z", + "position_anchor_at": "1000.0", + }, + ) + return stats_channel_id + + @patch("apps.timeshift.api_views._trigger_timeshift_stats_update") + @patch("apps.timeshift.api_views.RedisClient.get_client") + @patch("apps.timeshift.stats.RedisClient.get_client") + @patch("apps.timeshift.sessions.RedisClient.get_client") + @patch("apps.timeshift.api_views.network_access_allowed", return_value=True) + def test_position_updates_active_session( + self, _net, sessions_redis, stats_redis, api_redis, trigger_mock, + ): + sessions_redis.return_value = self.redis + stats_redis.return_value = self.redis + api_redis.return_value = self.redis + created = self.client.post( + "/api/catchup/sessions/", + { + "channel_uuid": str(self.channel.uuid), + "start": "2026-06-08T17:00:00Z", + }, + format="json", + ) + session_id = created.json()["session_id"] + stats_channel_id = self._seed_active_playback(session_id) + + response = self.client.post( + f"/api/catchup/sessions/{session_id}/position/", + {"position_secs": 842, "paused": True}, + format="json", + ) + self.assertEqual(response.status_code, 204) + client_key = TimeshiftRedisKeys.client_metadata(stats_channel_id, session_id) + data = self.redis.hgetall(client_key) + self.assertEqual(data["playback_base_secs"], "842.0") + self.assertEqual(data["paused"], "1") + trigger_mock.assert_called_once() + + @patch("apps.timeshift.api_views.RedisClient.get_client") + @patch("apps.timeshift.stats.RedisClient.get_client") + @patch("apps.timeshift.sessions.RedisClient.get_client") + @patch("apps.timeshift.api_views.network_access_allowed", return_value=True) + def test_position_without_playback_returns_404( + self, _net, sessions_redis, stats_redis, api_redis, + ): + sessions_redis.return_value = self.redis + stats_redis.return_value = self.redis + api_redis.return_value = self.redis + created = self.client.post( + "/api/catchup/sessions/", + { + "channel_uuid": str(self.channel.uuid), + "start": "2026-06-08T17:00:00Z", + }, + format="json", + ) + session_id = created.json()["session_id"] + response = self.client.post( + f"/api/catchup/sessions/{session_id}/position/", + {"position_secs": 10}, + format="json", + ) + self.assertEqual(response.status_code, 404) + + class CatchupSessionResolveTests(TestCase): @classmethod def setUpTestData(cls): diff --git a/apps/timeshift/tests/test_stats.py b/apps/timeshift/tests/test_stats.py index c410f66f..d74d22fb 100644 --- a/apps/timeshift/tests/test_stats.py +++ b/apps/timeshift/tests/test_stats.py @@ -21,6 +21,7 @@ from apps.timeshift.stats import ( resolve_stats_playback_fields, seed_stream_stats_metadata, stream_stats_to_metadata_fields, + update_catchup_session_position, ) from apps.timeshift.tests.test_views import _FakeRedis @@ -108,6 +109,18 @@ class ComputePlaybackPositionTests(TestCase): ) self.assertAlmostEqual(pos, 5 * 60) + def test_paused_freezes_wall_clock_advance(self): + pos = compute_playback_position_secs( + "2026-07-10:14-00", + self.EPG_START, + position_anchor_at=1000.0, + current_time=1300.0, + duration_secs=3600, + playback_base_secs=900.0, + paused=True, + ) + self.assertAlmostEqual(pos, 900.0) + class ByteRangePlaybackTests(TestCase): def test_compute_playback_base_from_byte_range(self): @@ -264,12 +277,45 @@ class BuildTimeshiftStatsDataTests(TestCase): self.assertNotIn("playback_position_secs", session) self.assertEqual(session["channel_name"], "Catch-up Stats Channel") self.assertEqual(session["resolution"], "1920x1080") + self.assertFalse(session["paused"]) self.assertEqual(session["connections"][0]["ip_address"], "10.0.0.5") def test_find_stats_channel_for_session(self): found = find_stats_channel_for_session(self.redis, self.session_id) self.assertEqual(found, self.stats_channel_id) + def test_update_catchup_session_position_sets_base_and_pause(self): + updated = update_catchup_session_position( + self.session_id, + position_secs=842.5, + paused=True, + user_id=1, + redis_client=self.redis, + ) + self.assertTrue(updated) + client_key = RedisKeys.client_metadata(self.stats_channel_id, self.session_id) + data = self.redis.hgetall(client_key) + self.assertEqual(data["playback_base_secs"], "842.5") + self.assertEqual(data["paused"], "1") + self.assertIsNotNone(data.get("position_anchor_at")) + + @patch("apps.timeshift.stats.Channel") + def test_build_includes_paused_flag(self, mock_channel_model): + channel = MagicMock() + channel.id = self.channel_id + channel.name = "Catch-up Stats Channel" + channel.uuid = "00000000-0000-0000-0000-000000000042" + channel.logo_id = None + channel.logo = None + mock_channel_model.objects.select_related.return_value.filter.return_value = [ + channel, + ] + client_key = RedisKeys.client_metadata(self.stats_channel_id, self.session_id) + self.redis.hset(client_key, mapping={"paused": "1", "playback_base_secs": "100"}) + session = build_timeshift_stats_data(self.redis)["timeshift_sessions"][0] + self.assertTrue(session["paused"]) + self.assertEqual(session["playback_base_secs"], 100.0) + @patch("apps.timeshift.stats.Channel") def test_skips_clients_missing_required_metadata(self, mock_channel_model): mock_channel_model.objects.select_related.return_value.filter.return_value = [] diff --git a/apps/timeshift/views.py b/apps/timeshift/views.py index e56dd5a5..b84e0c36 100644 --- a/apps/timeshift/views.py +++ b/apps/timeshift/views.py @@ -2839,6 +2839,10 @@ def _register_stats_client( pipe.hset(client_key, mapping=client_payload) if playback_base_secs is None: pipe.hdel(client_key, "playback_base_secs") + # Fresh proxy reanchor clears client-reported pause; EOF probes keep + # the prior anchor and therefore leave ``paused`` alone. + if str(position_anchor_at) == str(now): + pipe.hdel(client_key, "paused") pipe.expire(client_key, CLIENT_TTL_SECONDS) pipe.sadd(client_set_key, client_id) pipe.expire(client_set_key, CLIENT_TTL_SECONDS) diff --git a/frontend/src/components/ProgramPreview.jsx b/frontend/src/components/ProgramPreview.jsx index 5163c0a5..0471ed53 100644 --- a/frontend/src/components/ProgramPreview.jsx +++ b/frontend/src/components/ProgramPreview.jsx @@ -84,6 +84,8 @@ const ProgramPreview = ({ label = 'Now Playing:', timelineMode = 'live', playbackElapsedSeconds = 0, + accentColor = 'green.5', + accentIconColor = '#22c55e', }) => { const [isExpanded, setIsExpanded] = useState(false); @@ -119,8 +121,8 @@ const ProgramPreview = ({ return ( <> - - + + {label} diff --git a/frontend/src/components/cards/TimeshiftConnectionCard.jsx b/frontend/src/components/cards/TimeshiftConnectionCard.jsx index 88736af4..fedec0ed 100644 --- a/frontend/src/components/cards/TimeshiftConnectionCard.jsx +++ b/frontend/src/components/cards/TimeshiftConnectionCard.jsx @@ -164,7 +164,7 @@ const TimeshiftConnectionCard = ({ null; // Play position from URL timestamp + EPG window + stream-open anchor. - const playbackBaseRef = useRef({ base: null, receivedAtMs: 0 }); + const playbackBaseRef = useRef({ base: null, receivedAtMs: 0, paused: false }); useEffect(() => { const computed = computeCatchupPlaybackSeconds({ programmeStart: timeshiftSession.programme_start, @@ -172,27 +172,35 @@ const TimeshiftConnectionCard = ({ programDurationSecs: programmePreview?.duration_secs, positionAnchorAt: timeshiftSession.position_anchor_at, playbackBaseSecs: timeshiftSession.playback_base_secs, + paused: Boolean(timeshiftSession.paused), nowMs: Date.now(), }); if (computed != null) { playbackBaseRef.current = { base: computed, receivedAtMs: Date.now(), + paused: Boolean(timeshiftSession.paused), }; } }, [ timeshiftSession.programme_start, timeshiftSession.position_anchor_at, timeshiftSession.playback_base_secs, + timeshiftSession.paused, programmePreview?.start_time, programmePreview?.duration_secs, ]); - const { base: playbackBase, receivedAtMs: playbackReceivedAtMs } = - playbackBaseRef.current; + const { + base: playbackBase, + receivedAtMs: playbackReceivedAtMs, + paused: playbackPaused, + } = playbackBaseRef.current; const playbackElapsedSeconds = playbackBase != null - ? playbackBase + (Date.now() - playbackReceivedAtMs) / 1000 + ? playbackPaused + ? playbackBase + : playbackBase + (Date.now() - playbackReceivedAtMs) / 1000 : getConnectionDurationSeconds(connection); const getConnectionStartTime = useCallback( @@ -291,7 +299,9 @@ const TimeshiftConnectionCard = ({ diff --git a/frontend/src/utils/cards/TimeshiftConnectionCardUtils.js b/frontend/src/utils/cards/TimeshiftConnectionCardUtils.js index 46a400e5..10b86ab5 100644 --- a/frontend/src/utils/cards/TimeshiftConnectionCardUtils.js +++ b/frontend/src/utils/cards/TimeshiftConnectionCardUtils.js @@ -23,10 +23,11 @@ export const computeCatchupPlaybackSeconds = ({ programDurationSecs, positionAnchorAt, playbackBaseSecs, + paused = false, nowMs = getNowMs(), }) => { let elapsedSinceAnchor = 0; - if (positionAnchorAt != null) { + if (!paused && positionAnchorAt != null) { const anchor = Number(positionAnchorAt); if (!Number.isNaN(anchor)) { elapsedSinceAnchor = Math.max(0, nowMs / 1000 - anchor); diff --git a/frontend/src/utils/cards/__tests__/TimeshiftConnectionCardUtils.test.js b/frontend/src/utils/cards/__tests__/TimeshiftConnectionCardUtils.test.js index 90524e2a..979a63fb 100644 --- a/frontend/src/utils/cards/__tests__/TimeshiftConnectionCardUtils.test.js +++ b/frontend/src/utils/cards/__tests__/TimeshiftConnectionCardUtils.test.js @@ -35,4 +35,17 @@ describe('computeCatchupPlaybackSeconds', () => { }); expect(position).toBeCloseTo(1830, 5); }); + + it('does not advance while paused', () => { + const position = computeCatchupPlaybackSeconds({ + programmeStart: '2026-07-10:14-00', + programStartTime: '2026-07-10T14:00:00+00:00', + programDurationSecs: 3600, + positionAnchorAt: 1000, + playbackBaseSecs: 900, + paused: true, + nowMs: 1300 * 1000, + }); + expect(position).toBeCloseTo(900, 5); + }); });