Enhancement: Update session management details and improve stream limit handling in timeshift proxy. Enhance tests for decisive account failover and session probe logic.

This commit is contained in:
SergeantPanda 2026-07-06 19:49:19 -05:00
parent 4f798e9a28
commit a90872079b
4 changed files with 84 additions and 8 deletions

View file

@ -14,7 +14,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **Strictly-UTC API surface, automatic per-provider timezone.** `server_info.timezone` is always `UTC` and the XC EPG `start`/`end` strings are emitted in UTC; the proxy converts the requested instant to the serving provider's own reported timezone (`server_info.timezone` captured on account refresh) at request time. No timezone to configure.
- **Multi-provider failover.** Catch-up walks the channel's catch-up streams in order (like live playback): if one provider cannot serve the archive the next is tried, each attempt with its own account context (credentials, reported timezone, user-agent). Accounts that fail with an auth/ban-class status are not retried via their other streams.
- **Provider pool accounting.** Catch-up reserves a provider profile slot (`connection_pool`) before connecting upstream — same contract as live and VOD — and releases it when the session ends. When the default profile is at capacity it walks the account's alternate profiles with their own resolved credentials, and answers `503` when every profile is full.
- **Per-client session pool.** The first request without `session_id` receives a `301` redirect to the same URL with a stable `?session_id=` query parameter; that ID becomes the client identity for stats, stop keys, and pool coordination. Each viewer gets their own Redis-backed pool entry even when watching the same programme; idle sessions can be reclaimed via fingerprint matching (same user and programme, IP + user-agent score). Real scrubs within a session stop the in-flight stream through the standard stop-key mechanism; parallel startup probes (full-file, open-ended, and EOF tail `Range` requests) are deferred with `503` instead of opening extra upstream connections. Stream-limit exemptions for `ignore_same_channel_connections` apply only to sibling requests from the same `session_id`.
- **Per-client session pool.** The first request without `session_id` receives a `301` redirect to the same URL with a stable `?session_id=` query parameter; that ID becomes the client identity for stats, stop keys, and pool coordination. Each viewer gets their own Redis-backed pool entry even when watching the same programme; idle sessions can be reclaimed via fingerprint matching (same user and programme, IP + user-agent score). Real scrubs within a session stop the in-flight stream through the standard stop-key mechanism; parallel startup probes (full-file, open-ended, and EOF tail `Range` requests) are deferred with `503` instead of opening extra upstream connections. Parallel HTTP probes from the same `session_id` for the same programme do not count as extra streams toward the user limit; distinct sessions still each consume a slot.
- **`xmltv_prev_days_override` under `Settings → EPG`** (default `0` = auto-detect). Verbose timeshift logging follows the standard logger DEBUG level.
- **EPG XMLTV `prev_days` auto-detection** from the provider's largest `tv_archive_duration` (capped at 30 days), overridable via setting or `?prev_days=` URL parameter.
- **Byte path streams directly via `iter_content` + generator yield** (same pattern as the VOD proxy), with an upfront MPEG-TS sync peek that strips any PHP-warning preamble before bytes reach strict demuxers. Throughput stays comfortably above the typical 5 Mbps FHD bitrate so clients don't buffer.

View file

@ -185,7 +185,7 @@ def check_user_stream_limits(user, client_id, media_id=None):
# Timeshift sibling range/probe requests share one provider slot per
# session_id. Each distinct client/session still consumes its own slot.
if ignore_same_channel and media_id:
if media_id and client_id:
media_id_str = str(media_id)
for conn in active_connections:
if conn.get('type') != 'timeshift':
@ -195,7 +195,7 @@ def check_user_stream_limits(user, client_id, media_id=None):
conn_media_id = str(conn.get('media_id') or '')
if conn_media_id == media_id_str or conn_media_id.startswith(f"{media_id_str}_"):
logger.debug(
f"[stream limits][{client_id}] Same timeshift session probe for {media_id} allowed (ignore_same_channel=True)"
f"[stream limits][{client_id}] Same timeshift session probe for {media_id} allowed"
)
return True

View file

@ -1556,6 +1556,57 @@ class TimeshiftSessionReuseTests(TestCase):
1,
)
def test_reuse_decisive_failure_skips_same_account_in_failover(self):
_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)
streams = [
_make_catchup_stream(account_id=1, stream_id="111", profile_id=31),
_make_catchup_stream(account_id=1, stream_id="112", profile_id=31),
_make_catchup_stream(account_id=2, stream_id="222", profile_id=41),
]
profile = MagicMock(id=31, custom_properties={})
account = MagicMock(id=1)
decisive = MagicMock(status_code=403)
decisive.timeshift_decisive = True
ok = MagicMock(status_code=200)
request = self.factory.get(_proxy_url(TEST_SESSION_ID))
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=streams), \
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)) as reserve_mock, \
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_transformed_credentials", side_effect=_fake_creds), \
patch.object(views, "get_user_active_connections", return_value=[]), \
patch.object(views, "build_timeshift_candidate_urls",
return_value=["http://example.test/x.ts"]), \
patch.object(views, "_stream_from_provider",
side_effect=[decisive, ok]) as stream_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-00", "8.ts",
)
self.assertIs(response, ok)
self.assertEqual(stream_mock.call_count, 2)
self.assertEqual(
[c.kwargs["account_id"] for c in stream_mock.call_args_list], [1, 2]
)
# Reuse reserved once; failover reserved only for the other account.
self.assertEqual(reserve_mock.call_count, 2)
class TimeshiftSessionRedirectTests(TestCase):
"""First request must establish a session via 301 redirect (VOD-style)."""
@ -1637,6 +1688,22 @@ class TimeshiftStreamLimitExemptionTests(TestCase):
)
self.assertTrue(allowed)
def test_same_session_probe_allowed_without_ignore_same_channel(self):
connections = [{
"media_id": f"{self.MEDIA}_111",
"client_id": TEST_SESSION_ID,
"connected_at": 0.0,
"type": "timeshift",
}]
with patch("apps.proxy.utils.get_user_active_connections",
return_value=connections), \
patch("apps.proxy.utils.CoreSettings.get_user_limits_settings",
return_value=self._limits_settings(ignore_same_channel=False)):
allowed = _check_user_stream_limits(
self.user, TEST_SESSION_ID, media_id=self.MEDIA,
)
self.assertTrue(allowed)
def test_different_session_same_programme_counts_against_limit(self):
connections = [{
"media_id": f"{self.MEDIA}_111",
@ -1943,7 +2010,6 @@ class TimeshiftScrubPreemptTests(TestCase):
reserve_mock.assert_called_once_with(profile, self.redis)
class RollupSelfHealDbTests(TestCase):
"""Catch-up flag consistency after stream removal.

View file

@ -187,6 +187,9 @@ def timeshift_proxy(request, username, password, stream_id, timestamp, duration)
redis_client, effective_session_id, user_id=user.id,
)
last_response = None
decisive_accounts = set()
if acquired is not None:
descriptor, profile = acquired
reuse_response = _stream_reused_session(
@ -206,7 +209,16 @@ def timeshift_proxy(request, username, password, stream_id, timestamp, duration)
debug=debug,
)
if reuse_response is not None:
return reuse_response
if reuse_response.status_code < 400:
return reuse_response
if getattr(reuse_response, "timeshift_passthrough", False) is True:
return reuse_response
last_response = reuse_response
if getattr(reuse_response, "timeshift_decisive", False):
try:
decisive_accounts.add(int(descriptor["account_id"]))
except (ValueError, TypeError):
pass
if pool_exists and pool_busy and not _should_displace_busy_playback(
range_header, pool_content_length, busy_serving_range,
@ -224,8 +236,6 @@ def timeshift_proxy(request, username, password, stream_id, timestamp, duration)
)
return HttpResponse("Stream slot busy", status=503)
last_response = None
decisive_accounts = set()
capacity_blocked = False
for catchup_stream in catchup_streams:
m3u_account = catchup_stream.m3u_account
@ -943,7 +953,7 @@ def _stream_reused_session(
return response
_discard_pool_session(redis_client, session_id, profile.id)
return None
return response
class _SlotReleasingStream: