diff --git a/apps/timeshift/tests/test_views.py b/apps/timeshift/tests/test_views.py index 91fb2161..ebe3069e 100644 --- a/apps/timeshift/tests/test_views.py +++ b/apps/timeshift/tests/test_views.py @@ -453,6 +453,12 @@ class _FakeRedis: hash_value = self.store.get(key) return hash_value.get(field) if isinstance(hash_value, dict) else None + def hdel(self, key, *fields): + hash_value = self.store.get(key) + if not isinstance(hash_value, dict): + return 0 + return sum(1 for f in fields if hash_value.pop(f, None) is not None) + def expire(self, key, ttl): return 1 if key in self.store else 0 @@ -1607,6 +1613,206 @@ class TimeshiftSessionReuseTests(TestCase): # Reuse reserved once; failover reserved only for the other account. self.assertEqual(reserve_mock.call_count, 2) + def _call_reused_session(self, timestamp, media_id): + """Drive _stream_reused_session against a session anchored at 17-00 + (provider position 19-00) with a NEW requested timestamp.""" + profile = MagicMock(id=31, custom_properties={}) + tz_profile = MagicMock( + custom_properties={"server_info": {"timezone": "Europe/Brussels"}} + ) + account = MagicMock(id=1) + account.profiles.filter.return_value.first.return_value = tz_profile + ok = MagicMock(status_code=200) + with patch.object(views.M3UAccount.objects, "get", return_value=account), \ + patch.object(views, "_attempt_timeshift_stream", + return_value=ok) as attempt_mock: + response = views._stream_reused_session( + self.redis, + session_id=self.SESSION, + descriptor={ + "account_id": "1", + "stream_id": "111", + "provider_timestamp": "2026-06-08:19-00", + }, + profile=profile, + channel=self.channel, + media_id=media_id, + safe_ts=timestamp.replace(":", "-"), + timestamp=timestamp, + duration_minutes=40, + client_id=self.SESSION, + client_ip="1.2.3.4", + range_header=None, + channel_logo_id=None, + user=self.user, + debug=False, + ) + return response, ok, attempt_mock + + def test_reused_session_serves_requested_timestamp_not_stored_anchor(self): + # Field bug: rewind to X, play, then FF — TiviMate keeps the + # ?session_id= query when rebuilding the seek URL with a new start, + # and the reused session replayed the STORED position, snapping + # playback back to the rewind anchor X. The reuse path must always + # serve the REQUESTED timestamp. + _seed_pool_session(self.redis, session_id=self.SESSION) + response, ok, attempt_mock = self._call_reused_session( + "2026-06-08:17-30", "timeshift_8_2026-06-08-17-30", + ) + self.assertIs(response, ok) + # June -> CEST: 17:30 UTC reaches the provider as 19:30 local — never + # the session's stored 19:00 anchor. + self.assertEqual( + attempt_mock.call_args.kwargs["provider_timestamp"], + "2026-06-08:19-30", + ) + + def test_reused_session_moves_pool_entry_to_requested_position(self): + # The descriptor must follow the seek so fingerprint matching and + # same-channel displacement compare against the position actually + # being served. + _seed_pool_session(self.redis, session_id=self.SESSION) + self._call_reused_session( + "2026-06-08:17-30", "timeshift_8_2026-06-08-17-30", + ) + self.assertEqual( + self.redis.hget(self._pool_key(), "media_id"), + "timeshift_8_2026-06-08-17-30", + ) + self.assertEqual( + self.redis.hget(self._pool_key(), "provider_timestamp"), + "2026-06-08:19-30", + ) + + def test_position_move_drops_previous_byte_state(self): + # content_length / serving_range describe ONE provider file; carrying + # them across a seek would feed the near-EOF/displacement heuristics + # another programme's size. A same-position update must keep them. + _seed_pool_session(self.redis, session_id=self.SESSION) + self.redis.hset(self._pool_key(), mapping={ + "content_length": "2000000000", "serving_range": "start", + }) + self._call_reused_session( + "2026-06-08:17-30", "timeshift_8_2026-06-08-17-30", + ) + self.assertIsNone(self.redis.hget(self._pool_key(), "content_length")) + self.assertIsNone(self.redis.hget(self._pool_key(), "serving_range")) + + # Same-position call (media unchanged): byte state survives. + self.redis.hset(self._pool_key(), mapping={ + "content_length": "1000000", "serving_range": "range", + }) + self._call_reused_session( + "2026-06-08:17-30", "timeshift_8_2026-06-08-17-30", + ) + self.assertEqual( + self.redis.hget(self._pool_key(), "content_length"), "1000000", + ) + + def test_position_update_never_resurrects_vanished_entry(self): + # If the pool key expired/vanished mid-request, writing to it would + # recreate a partial TTL-less hash that 503-wedges the session_id. + views._update_pool_position( + self.redis, self.SESSION, + media_id="timeshift_8_2026-06-08-17-30", + provider_timestamp="2026-06-08:19-30", + ) + self.assertFalse(self.redis.exists(self._pool_key())) + + def test_reused_session_tz_falls_back_to_reserved_profile(self): + # No active default profile -> the reserved profile's server_info is + # the only tz source left; conversion must still apply. + _seed_pool_session(self.redis, session_id=self.SESSION) + profile = MagicMock(id=31, custom_properties={ + "server_info": {"timezone": "Europe/Brussels"}, + }) + account = MagicMock(id=1) + account.profiles.filter.return_value.first.return_value = None + ok = MagicMock(status_code=200) + with patch.object(views.M3UAccount.objects, "get", return_value=account), \ + patch.object(views, "_attempt_timeshift_stream", + return_value=ok) as attempt_mock: + views._stream_reused_session( + self.redis, + session_id=self.SESSION, + descriptor={ + "account_id": "1", + "stream_id": "111", + "provider_timestamp": "2026-06-08:19-00", + }, + profile=profile, + channel=self.channel, + media_id="timeshift_8_2026-06-08-17-30", + safe_ts="2026-06-08-17-30", + timestamp="2026-06-08:17-30", + duration_minutes=40, + client_id=self.SESSION, + client_ip="1.2.3.4", + range_header=None, + channel_logo_id=None, + user=self.user, + debug=False, + ) + self.assertEqual( + attempt_mock.call_args.kwargs["provider_timestamp"], + "2026-06-08:19-30", + ) + + def test_seek_same_session_new_timestamp_serves_new_position(self): + # End-to-end repro of the field report: idle session anchored at + # 17-00, then a timestamp-jump request for 17-30 carrying the SAME + # session_id (no Range header). + _seed_pool_session(self.redis, session_id=TEST_SESSION_ID) + with patch.object(views, "release_profile_slot"): + views._release_pool_session(self.redis, TEST_SESSION_ID, 31) + + request = self.factory.get( + f"/timeshift/u/p/8/2026-06-08:17-30/8.ts?session_id={TEST_SESSION_ID}" + ) + profile = MagicMock(id=31, custom_properties={}) + tz_profile = MagicMock( + custom_properties={"server_info": {"timezone": "Europe/Brussels"}} + ) + account = MagicMock(id=1) + account.profiles.filter.return_value.first.return_value = tz_profile + ok = MagicMock(status_code=200) + with patch.object(views, "_authenticate_user", return_value=self.user), \ + patch.object(views, "network_access_allowed", return_value=True), \ + patch.object(views, "Channel") as channel_cls, \ + patch.object(views, "_user_can_access_channel", return_value=True), \ + patch.object(views, "get_channel_catchup_streams", + return_value=[_make_catchup_stream( + account_id=1, stream_id="111", profile_id=31)]), \ + patch.object(views, "get_programme_duration", return_value=40), \ + patch.object(views, "check_user_stream_limits", return_value=True), \ + patch.object(views, "RedisClient") as redis_cls, \ + patch.object(views, "reserve_profile_slot", + return_value=(True, 1, None)), \ + patch.object(views, "release_profile_slot"), \ + patch.object(views.M3UAccount.objects, "get", return_value=account), \ + patch.object(views.M3UAccountProfile.objects, "get", + return_value=profile), \ + patch.object(views, "get_user_active_connections", return_value=[]), \ + patch.object(views, "_attempt_timeshift_stream", + return_value=ok) as attempt_mock: + redis_cls.get_client.return_value = self.redis + channel_cls.objects.get.return_value = MagicMock( + id=8, name="Test", logo_id=None, + ) + response = views.timeshift_proxy( + request, "u", "p", "8", "2026-06-08:17-30", "8.ts", + ) + self.assertIs(response, ok) + attempt_mock.assert_called_once() + self.assertEqual( + attempt_mock.call_args.kwargs["provider_timestamp"], + "2026-06-08:19-30", + ) + self.assertEqual( + self.redis.hget(self._pool_key(), "media_id"), + "timeshift_8_2026-06-08-17-30", + ) + class TimeshiftSessionRedirectTests(TestCase): """First request must establish a session via 301 redirect (VOD-style).""" diff --git a/apps/timeshift/views.py b/apps/timeshift/views.py index a10c1a28..77454664 100644 --- a/apps/timeshift/views.py +++ b/apps/timeshift/views.py @@ -198,6 +198,7 @@ def timeshift_proxy(request, username, password, stream_id, timestamp, duration) descriptor=descriptor, profile=profile, channel=channel, + media_id=media_id, safe_ts=safe_ts, timestamp=timestamp, duration_minutes=duration_minutes, @@ -590,6 +591,38 @@ def _store_pool_serving_range(redis_client, session_id, range_header): logger.debug("Timeshift pool serving_range store failed: %s", exc) +def _update_pool_position(redis_client, session_id, *, media_id, provider_timestamp): + """Move a reused session's descriptor to the position actually served. + + A seek keeps the session (provider-slot continuity) but changes the + catch-up position; the descriptor must follow it so later fingerprint + matches and same-channel displacement compare against the position the + session is really on. When the position actually moves, the byte state of + the PREVIOUS position's file (content_length, serving_range) is dropped — + keeping it would feed the near-EOF/displacement heuristics another + programme's size; the next successful open repopulates both. + """ + if redis_client is None or not session_id: + return + key = _pool_key(session_id) + try: + with _pool_lock(redis_client, session_id): + entry = redis_client.hgetall(key) + if not entry: + # Entry vanished (Redis restart/eviction): writing here would + # resurrect a partial, TTL-less hash that wedges the session. + return + position_changed = entry.get("media_id") != str(media_id) + redis_client.hset(key, mapping={ + "media_id": str(media_id), + "provider_timestamp": str(provider_timestamp), + }) + if position_changed: + redis_client.hdel(key, "content_length", "serving_range") + except Exception as exc: + logger.debug("Timeshift pool position update failed: %s", exc) + + def _store_pool_content_length(redis_client, session_id, upstream_response): if redis_client is None or not session_id or upstream_response is None: return @@ -892,6 +925,7 @@ def _stream_reused_session( descriptor, profile, channel, + media_id, safe_ts, timestamp, duration_minutes, @@ -909,15 +943,30 @@ def _stream_reused_session( _discard_pool_session(redis_client, session_id, profile.id) return None - provider_timestamp = descriptor.get("provider_timestamp") - if not provider_timestamp: - provider_tz_name = None - server_info = (profile.custom_properties or {}).get("server_info") or {} - if isinstance(server_info, dict): - provider_tz_name = server_info.get("timezone") - provider_timestamp = convert_timestamp_to_provider_tz( - timestamp, provider_tz_name - ) + # The stored provider_timestamp is the position this session was CREATED + # for. Clients (TiviMate) keep the ?session_id= query when they rebuild + # the seek URL with a new start, so a reused session must serve the + # REQUESTED position, never replay the stored one — otherwise every + # timestamp-jump FF/RW snaps back to the session's original anchor. The + # provider zone is a property of the account (server_info reported on + # auth, stored on the default profile), so recomputing costs one trivial + # conversion. + provider_tz_name = None + tz_profile = ( + m3u_account.profiles.filter(is_active=True, is_default=True).first() + or profile + ) + server_info = (tz_profile.custom_properties or {}).get("server_info") or {} + if isinstance(server_info, dict): + provider_tz_name = server_info.get("timezone") + provider_timestamp = convert_timestamp_to_provider_tz(timestamp, provider_tz_name) + + # Move the descriptor to the position actually served so fingerprint + # matching and same-channel displacement keep working after the seek. + _update_pool_position( + redis_client, session_id, + media_id=media_id, provider_timestamp=provider_timestamp, + ) release_cb = _make_release_once(redis_client, session_id, profile.id) try: @@ -926,7 +975,7 @@ def _stream_reused_session( profile=profile, stream_id_value=descriptor["stream_id"], provider_timestamp=provider_timestamp, - provider_tz_name=None, + provider_tz_name=provider_tz_name, duration_minutes=duration_minutes, channel=channel, safe_ts=safe_ts,