fix(timeshift): serve the requested position when reusing a session

Clients (TiviMate) keep the ?session_id= query when they rebuild the seek
URL with a new start timestamp, so every timestamp-jump FF/RW landed on
the reused session's STORED provider_timestamp — playback snapped back to
the position the session was created for (the rewind anchor), 100%
reproducible: two requests with different start values on one session
returned byte-identical streams.

The reuse path now always recomputes the provider timestamp from the
REQUESTED one (the provider zone is a property of the account — read from
the default profile's server_info, same as the fresh path) and moves the
pool descriptor (media_id + provider_timestamp) to the position actually
served, so fingerprint matching and same-channel displacement keep
comparing against reality. Slot continuity is unchanged.

Regression tests: reused session serves the requested timestamp (unit),
descriptor follows the seek (unit), and an end-to-end timestamp-jump with
the same session_id reaches the new position through timeshift_proxy.
This commit is contained in:
Cédric Marcoux 2026-07-09 07:42:19 +02:00
parent 080a2bbd74
commit 7655060149
2 changed files with 171 additions and 10 deletions

View file

@ -1607,6 +1607,132 @@ 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_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)."""

View file

@ -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,25 @@ 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.
"""
if redis_client is None or not session_id:
return
try:
redis_client.hset(_pool_key(session_id), mapping={
"media_id": str(media_id),
"provider_timestamp": str(provider_timestamp),
})
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 +912,7 @@ def _stream_reused_session(
descriptor,
profile,
channel,
media_id,
safe_ts,
timestamp,
duration_minutes,
@ -909,15 +930,29 @@ 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_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 +961,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,