mirror of
https://github.com/Dispatcharr/Dispatcharr.git
synced 2026-07-17 16:50:53 +00:00
Merge pull request #1425 from cedric-marcoux:fix/timeshift-seek-position
fix(timeshift): serve the requested position when reusing a session (FF/RW snaps back to rewind anchor)
This commit is contained in:
commit
6f2e99f47d
2 changed files with 265 additions and 10 deletions
|
|
@ -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)."""
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue