mirror of
https://github.com/Dispatcharr/Dispatcharr.git
synced 2026-07-17 16:50:53 +00:00
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:
parent
4f798e9a28
commit
a90872079b
4 changed files with 84 additions and 8 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue