mirror of
https://github.com/Dispatcharr/Dispatcharr.git
synced 2026-07-17 16:50:53 +00:00
feat(timeshift): add position reporting for catch-up sessions
- Introduced a new endpoint `POST /api/catchup/sessions/<session_id>/position/` for native clients to report their playhead position and pause state during catch-up sessions. - Updated the catch-up session handling to include a `paused` flag, allowing accurate tracking of playback state without seeking the provider stream. - Enhanced the Redis storage mechanism to accommodate the new position and pause data, ensuring real-time updates for admin stats. - Added tests to validate the new position reporting functionality and its integration with existing catch-up features.
This commit is contained in:
parent
14bfd25d9a
commit
6f62d807f4
11 changed files with 395 additions and 11 deletions
|
|
@ -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/<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. `DELETE /api/catchup/sessions/<session_id>/` 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/<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 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).
|
||||
|
|
|
|||
|
|
@ -10,6 +10,11 @@ urlpatterns = [
|
|||
api_views.CatchupSessionCreateAPIView.as_view(),
|
||||
name="catchup-session-create",
|
||||
),
|
||||
path(
|
||||
"sessions/<str:session_id>/position/",
|
||||
api_views.CatchupSessionPositionAPIView.as_view(),
|
||||
name="catchup-session-position",
|
||||
),
|
||||
path(
|
||||
"sessions/<str:session_id>/",
|
||||
api_views.CatchupSessionDestroyAPIView.as_view(),
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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 = []
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<>
|
||||
<Group gap={5} wrap="nowrap">
|
||||
<Radio size="14" style={{ color: '#22c55e', flexShrink: 0 }} />
|
||||
<Text size="xs" fw={500} c="green.5" style={{ flexShrink: 0 }}>
|
||||
<Radio size="14" style={{ color: accentIconColor, flexShrink: 0 }} />
|
||||
<Text size="xs" fw={500} c={accentColor} style={{ flexShrink: 0 }}>
|
||||
{label}
|
||||
</Text>
|
||||
<Tooltip label={program.title}>
|
||||
|
|
|
|||
|
|
@ -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 = ({
|
|||
<ProgramPreview
|
||||
program={programmePreview}
|
||||
timelineMode="catchup"
|
||||
label="Watching:"
|
||||
label={timeshiftSession.paused ? 'Paused:' : 'Watching:'}
|
||||
accentColor={timeshiftSession.paused ? 'yellow.5' : 'green.5'}
|
||||
accentIconColor={timeshiftSession.paused ? '#eab308' : '#22c55e'}
|
||||
playbackElapsedSeconds={playbackElapsedSeconds}
|
||||
/>
|
||||
</Box>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue