mirror of
https://github.com/Dispatcharr/Dispatcharr.git
synced 2026-07-20 16:51:10 +00:00
feat(catchup): enhance catchup functionality with user and system settings
This update introduces a new `is_catchup_enabled` function to determine if catch-up is allowed for users based on their custom properties and system settings. The `UserViewSet` is modified to restrict admin-managed properties, including catch-up access. Additionally, various views and tests are updated to incorporate catch-up checks, ensuring that users without access receive appropriate error responses. The frontend is enhanced with a catch-up toggle in user and system settings forms, allowing for better management of catch-up capabilities.
This commit is contained in:
parent
2b62928b42
commit
b6442e6421
23 changed files with 549 additions and 106 deletions
|
|
@ -252,8 +252,9 @@ class UserViewSet(viewsets.ModelViewSet):
|
|||
request.data.pop(key, None)
|
||||
|
||||
# Strip admin-managed keys from custom_properties so users cannot
|
||||
# set their own XC credentials or network rules via this endpoint.
|
||||
ADMIN_ONLY_PROPS = {"xc_password", "allowed_networks"}
|
||||
# set their own XC credentials, network rules, or catchup access
|
||||
# via this endpoint.
|
||||
ADMIN_ONLY_PROPS = {"xc_password", "allowed_networks", "catchup_enabled"}
|
||||
cp = request.data.get("custom_properties")
|
||||
if isinstance(cp, dict):
|
||||
for key in ADMIN_ONLY_PROPS:
|
||||
|
|
|
|||
|
|
@ -1,11 +1,68 @@
|
|||
from unittest.mock import patch
|
||||
|
||||
from django.core.cache import cache
|
||||
from django.test import RequestFactory, TestCase
|
||||
|
||||
from apps.accounts.models import User
|
||||
from apps.channels.models import Channel, ChannelStream, Stream
|
||||
from apps.channels.utils import resolve_xc_epg_prev_days
|
||||
from apps.channels.utils import is_catchup_enabled, resolve_xc_epg_prev_days
|
||||
from apps.m3u.models import M3UAccount
|
||||
from core.models import SYSTEM_SETTINGS_KEY, CoreSettings
|
||||
|
||||
|
||||
class IsCatchupEnabledTests(TestCase):
|
||||
def setUp(self):
|
||||
cache.clear()
|
||||
self.user = User(username="catchup-gate", custom_properties={})
|
||||
# Drop any leftover catchup_enabled so default-on tests see a clean slate.
|
||||
obj = CoreSettings.objects.filter(key=SYSTEM_SETTINGS_KEY).first()
|
||||
if obj is not None:
|
||||
value = dict(obj.value or {})
|
||||
if "catchup_enabled" in value:
|
||||
value.pop("catchup_enabled", None)
|
||||
obj.value = value
|
||||
obj.save()
|
||||
|
||||
def tearDown(self):
|
||||
# CoreSettings writes populate Redis; DB rollback does not clear it.
|
||||
cache.clear()
|
||||
|
||||
def _set_system_catchup(self, enabled):
|
||||
obj, _ = CoreSettings.objects.get_or_create(
|
||||
key=SYSTEM_SETTINGS_KEY,
|
||||
defaults={"name": "System Settings", "value": {}},
|
||||
)
|
||||
value = dict(obj.value or {})
|
||||
value["catchup_enabled"] = enabled
|
||||
obj.value = value
|
||||
obj.save()
|
||||
|
||||
def test_defaults_to_enabled(self):
|
||||
self.assertTrue(is_catchup_enabled())
|
||||
self.assertTrue(is_catchup_enabled(user=self.user))
|
||||
|
||||
def test_system_disable_blocks_everyone(self):
|
||||
self._set_system_catchup(False)
|
||||
self.assertFalse(is_catchup_enabled())
|
||||
self.assertFalse(is_catchup_enabled(user=self.user))
|
||||
|
||||
def test_user_disable_only_affects_that_user(self):
|
||||
self._set_system_catchup(True)
|
||||
self.user.custom_properties = {"catchup_enabled": False}
|
||||
other = User(username="other", custom_properties={})
|
||||
self.assertFalse(is_catchup_enabled(user=self.user))
|
||||
self.assertTrue(is_catchup_enabled(user=other))
|
||||
|
||||
def test_user_disable_skips_system_settings_lookup(self):
|
||||
self.user.custom_properties = {"catchup_enabled": False}
|
||||
with patch.object(CoreSettings, "get_catchup_enabled") as system_mock:
|
||||
self.assertFalse(is_catchup_enabled(user=self.user))
|
||||
system_mock.assert_not_called()
|
||||
|
||||
def test_system_disable_overrides_user_enable(self):
|
||||
self._set_system_catchup(False)
|
||||
self.user.custom_properties = {"catchup_enabled": True}
|
||||
self.assertFalse(is_catchup_enabled(user=self.user))
|
||||
|
||||
|
||||
class ResolveXcEpgPrevDaysTests(TestCase):
|
||||
|
|
@ -44,6 +101,14 @@ class ResolveXcEpgPrevDaysTests(TestCase):
|
|||
self.assertEqual(resolve_xc_epg_prev_days(request, self.user), 3)
|
||||
mock_compute.assert_not_called()
|
||||
|
||||
@patch("apps.channels.utils.compute_provider_archive_days_capped", return_value=14)
|
||||
def test_auto_detect_still_runs_when_catchup_disabled(self, mock_compute):
|
||||
"""Catchup disable must not suppress historical EPG lookback."""
|
||||
self.user.custom_properties = {"catchup_enabled": False}
|
||||
request = self.factory.get("/xmltv.php")
|
||||
self.assertEqual(resolve_xc_epg_prev_days(request, self.user), 14)
|
||||
mock_compute.assert_called_once()
|
||||
|
||||
|
||||
class CatchupRollupActiveAccountTests(TestCase):
|
||||
"""Denormalized catch-up flags ignore disabled M3U accounts."""
|
||||
|
|
|
|||
|
|
@ -89,6 +89,24 @@ def resolve_xc_epg_prev_days(request, user, *, auto_detect_fallback=True):
|
|||
return 0
|
||||
|
||||
|
||||
def is_catchup_enabled(*, user=None):
|
||||
"""Return whether catch-up / timeshift is allowed.
|
||||
|
||||
When a *user* is supplied, their ``custom_properties.catchup_enabled``
|
||||
(default True) is checked first (no DB). System
|
||||
``system_settings.catchup_enabled`` (default True, Redis-cached) can
|
||||
disable catch-up for everyone. Both flags are JSON booleans.
|
||||
"""
|
||||
from core.models import CoreSettings
|
||||
|
||||
if user is not None:
|
||||
props = getattr(user, "custom_properties", None) or {}
|
||||
if props.get("catchup_enabled") is False:
|
||||
return False
|
||||
|
||||
return CoreSettings.get_catchup_enabled()
|
||||
|
||||
|
||||
def get_channel_catchup_streams(channel):
|
||||
"""Active catch-up streams for a channel, in ``channelstream`` order.
|
||||
|
||||
|
|
|
|||
|
|
@ -937,6 +937,168 @@ class XcLiveStreamsNullChannelNumberTests(TestCase):
|
|||
self.assertNotIn(by_id[unnumbered.id]["num"], {5})
|
||||
|
||||
|
||||
class XcLiveStreamsCatchupAdvertisingTests(TestCase):
|
||||
"""XC live streams omit tv_archive when catchup is disabled for the user."""
|
||||
|
||||
def setUp(self):
|
||||
from django.core.cache import cache
|
||||
|
||||
cache.clear()
|
||||
self.factory = RequestFactory()
|
||||
self.user = User.objects.create_user(
|
||||
username=f"xc-catchup-{uuid4().hex[:8]}",
|
||||
password="pass",
|
||||
user_level=10,
|
||||
custom_properties={"xc_password": "xcpass"},
|
||||
)
|
||||
self.request = self.factory.get("/player_api.php")
|
||||
self.group = ChannelGroup.objects.create(name=f"Group {uuid4().hex[:8]}")
|
||||
self.channel = Channel.objects.create(
|
||||
name="Catchup Ch",
|
||||
channel_number=1,
|
||||
channel_group=self.group,
|
||||
user_level=0,
|
||||
is_catchup=True,
|
||||
catchup_days=7,
|
||||
)
|
||||
|
||||
def tearDown(self):
|
||||
# CoreSettings writes populate Redis; DB rollback does not clear it.
|
||||
from django.core.cache import cache
|
||||
|
||||
cache.clear()
|
||||
|
||||
def test_tv_archive_advertised_when_catchup_enabled(self):
|
||||
streams = xc_get_live_streams(self.request, self.user)
|
||||
self.assertEqual(len(streams), 1)
|
||||
self.assertEqual(streams[0]["tv_archive"], 1)
|
||||
self.assertEqual(streams[0]["tv_archive_duration"], 7)
|
||||
|
||||
def test_tv_archive_cleared_when_user_disables_catchup(self):
|
||||
self.user.custom_properties = {
|
||||
**(self.user.custom_properties or {}),
|
||||
"catchup_enabled": False,
|
||||
}
|
||||
self.user.save(update_fields=["custom_properties"])
|
||||
streams = xc_get_live_streams(self.request, self.user)
|
||||
self.assertEqual(len(streams), 1)
|
||||
self.assertEqual(streams[0]["tv_archive"], 0)
|
||||
self.assertEqual(streams[0]["tv_archive_duration"], 0)
|
||||
|
||||
def test_tv_archive_cleared_when_system_disables_catchup(self):
|
||||
from core.models import SYSTEM_SETTINGS_KEY, CoreSettings
|
||||
|
||||
obj, _ = CoreSettings.objects.get_or_create(
|
||||
key=SYSTEM_SETTINGS_KEY,
|
||||
defaults={"name": "System Settings", "value": {}},
|
||||
)
|
||||
value = dict(obj.value or {})
|
||||
value["catchup_enabled"] = False
|
||||
obj.value = value
|
||||
obj.save()
|
||||
streams = xc_get_live_streams(self.request, self.user)
|
||||
self.assertEqual(len(streams), 1)
|
||||
self.assertEqual(streams[0]["tv_archive"], 0)
|
||||
self.assertEqual(streams[0]["tv_archive_duration"], 0)
|
||||
|
||||
|
||||
class XcGetEpgCatchupGateTests(TestCase):
|
||||
"""Catch-up disable clears has_archive; lookback follows prev_days only."""
|
||||
|
||||
def setUp(self):
|
||||
from django.core.cache import cache
|
||||
from django.utils import timezone
|
||||
|
||||
from apps.epg.models import ProgramData
|
||||
|
||||
cache.clear()
|
||||
self.factory = RequestFactory()
|
||||
self.user = User.objects.create_user(
|
||||
username=f"xc-epg-cu-{uuid4().hex[:8]}",
|
||||
password="pass",
|
||||
user_level=10,
|
||||
custom_properties={"xc_password": "xcpass"},
|
||||
)
|
||||
self.group = ChannelGroup.objects.create(name=f"Group {uuid4().hex[:8]}")
|
||||
self.epg = EPGData.objects.create(tvg_id=f"cu-{uuid4().hex[:8]}", name="CU EPG")
|
||||
self.channel = Channel.objects.create(
|
||||
name="Catchup EPG Ch",
|
||||
channel_number=1,
|
||||
channel_group=self.group,
|
||||
user_level=0,
|
||||
is_catchup=True,
|
||||
catchup_days=7,
|
||||
epg_data=self.epg,
|
||||
)
|
||||
now = timezone.now()
|
||||
ProgramData.objects.create(
|
||||
epg=self.epg,
|
||||
start_time=now - timedelta(days=3),
|
||||
end_time=now - timedelta(days=3) + timedelta(hours=1),
|
||||
title="Past Show",
|
||||
)
|
||||
ProgramData.objects.create(
|
||||
epg=self.epg,
|
||||
start_time=now - timedelta(minutes=30),
|
||||
end_time=now + timedelta(minutes=30),
|
||||
title="Now Show",
|
||||
)
|
||||
|
||||
def tearDown(self):
|
||||
from django.core.cache import cache
|
||||
|
||||
cache.clear()
|
||||
|
||||
def _listings(self, *, extra_query=None):
|
||||
import base64
|
||||
|
||||
from apps.output.views import xc_get_epg
|
||||
|
||||
params = {
|
||||
"action": "get_simple_data_table",
|
||||
"stream_id": str(self.channel.id),
|
||||
}
|
||||
if extra_query:
|
||||
params.update(extra_query)
|
||||
request = self.factory.get("/player_api.php", params)
|
||||
listings = xc_get_epg(request, self.user)["epg_listings"]
|
||||
for item in listings:
|
||||
item["_title"] = base64.b64decode(item["title"]).decode()
|
||||
return listings
|
||||
|
||||
def test_catchup_enabled_expands_lookback_from_catchup_days(self):
|
||||
listings = self._listings()
|
||||
titles = [item["_title"] for item in listings]
|
||||
self.assertIn("Past Show", titles)
|
||||
past = next(item for item in listings if item["_title"] == "Past Show")
|
||||
self.assertEqual(past["has_archive"], 1)
|
||||
|
||||
def test_catchup_disabled_does_not_force_catchup_days_lookback(self):
|
||||
"""With no prev_days set, disable must not inject channel.catchup_days."""
|
||||
self.user.custom_properties = {
|
||||
**(self.user.custom_properties or {}),
|
||||
"catchup_enabled": False,
|
||||
}
|
||||
self.user.save(update_fields=["custom_properties"])
|
||||
listings = self._listings()
|
||||
titles = [item["_title"] for item in listings]
|
||||
self.assertNotIn("Past Show", titles)
|
||||
self.assertIn("Now Show", titles)
|
||||
|
||||
def test_catchup_disabled_honors_explicit_prev_days(self):
|
||||
self.user.custom_properties = {
|
||||
**(self.user.custom_properties or {}),
|
||||
"catchup_enabled": False,
|
||||
"epg_prev_days": 7,
|
||||
}
|
||||
self.user.save(update_fields=["custom_properties"])
|
||||
listings = self._listings()
|
||||
titles = [item["_title"] for item in listings]
|
||||
self.assertIn("Past Show", titles)
|
||||
past = next(item for item in listings if item["_title"] == "Past Show")
|
||||
self.assertEqual(past["has_archive"], 0)
|
||||
|
||||
|
||||
class XcXmltvNullChannelNumberTests(OutputEndpointTestMixin, TestCase):
|
||||
"""XC XMLTV EPG must not crash when a visible channel has no channel number."""
|
||||
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ from django.http import HttpResponse, JsonResponse, Http404, HttpResponseForbidd
|
|||
import json
|
||||
from django.urls import reverse
|
||||
from apps.channels.models import Channel, ChannelProfile, ChannelGroup, Stream
|
||||
from apps.channels.utils import format_channel_number
|
||||
from apps.channels.utils import format_channel_number, is_catchup_enabled
|
||||
from django.db.models import Prefetch
|
||||
from django.views.decorators.csrf import csrf_exempt
|
||||
from django.views.decorators.http import require_http_methods
|
||||
|
|
@ -691,7 +691,15 @@ def _xc_live_streams_setup(request, user, category_id):
|
|||
return channels, channel_num_map, _get_default_group_id, _logo_url_prefix, _logo_url_suffix
|
||||
|
||||
|
||||
def _xc_channel_entry(channel, channel_num_map, _get_default_group_id, _logo_url_prefix, _logo_url_suffix):
|
||||
def _xc_channel_entry(
|
||||
channel,
|
||||
channel_num_map,
|
||||
_get_default_group_id,
|
||||
_logo_url_prefix,
|
||||
_logo_url_suffix,
|
||||
*,
|
||||
catchup_allowed=True,
|
||||
):
|
||||
channel_num_int = channel_num_map.get(channel.id)
|
||||
if channel_num_int is None:
|
||||
effective_num = channel.effective_channel_number
|
||||
|
|
@ -702,7 +710,7 @@ def _xc_channel_entry(channel, channel_num_map, _get_default_group_id, _logo_url
|
|||
effective_group = channel.effective_channel_group_obj
|
||||
group_id = effective_group.id if effective_group else _get_default_group_id()
|
||||
|
||||
if channel.is_catchup:
|
||||
if catchup_allowed and channel.is_catchup:
|
||||
tv_archive = 1
|
||||
tv_archive_duration = channel.catchup_days
|
||||
else:
|
||||
|
|
@ -733,8 +741,12 @@ def _xc_channel_entry(channel, channel_num_map, _get_default_group_id, _logo_url
|
|||
def xc_get_live_streams(request, user, category_id=None):
|
||||
channels, channel_num_map, _get_default_group_id, _logo_url_prefix, _logo_url_suffix = \
|
||||
_xc_live_streams_setup(request, user, category_id)
|
||||
catchup_allowed = is_catchup_enabled(user=user)
|
||||
return [
|
||||
_xc_channel_entry(ch, channel_num_map, _get_default_group_id, _logo_url_prefix, _logo_url_suffix)
|
||||
_xc_channel_entry(
|
||||
ch, channel_num_map, _get_default_group_id, _logo_url_prefix, _logo_url_suffix,
|
||||
catchup_allowed=catchup_allowed,
|
||||
)
|
||||
for ch in channels
|
||||
]
|
||||
|
||||
|
|
@ -742,11 +754,15 @@ def xc_get_live_streams(request, user, category_id=None):
|
|||
def _xc_stream_live_streams(request, user, category_id=None):
|
||||
channels, channel_num_map, _get_default_group_id, _logo_url_prefix, _logo_url_suffix = \
|
||||
_xc_live_streams_setup(request, user, category_id)
|
||||
catchup_allowed = is_catchup_enabled(user=user)
|
||||
yield "["
|
||||
sep = ""
|
||||
for channel in channels:
|
||||
yield sep + json.dumps(
|
||||
_xc_channel_entry(channel, channel_num_map, _get_default_group_id, _logo_url_prefix, _logo_url_suffix)
|
||||
_xc_channel_entry(
|
||||
channel, channel_num_map, _get_default_group_id, _logo_url_prefix, _logo_url_suffix,
|
||||
catchup_allowed=catchup_allowed,
|
||||
)
|
||||
)
|
||||
sep = ","
|
||||
yield "]"
|
||||
|
|
@ -859,9 +875,12 @@ def xc_get_epg(request, user, short=False):
|
|||
now = django_timezone.now()
|
||||
|
||||
# XC catch-up clients expect past programmes when prev_days was not set.
|
||||
_channel_is_catchup = getattr(channel, "is_catchup", False)
|
||||
# Only expand from channel.catchup_days while catch-up is allowed; when
|
||||
# disabled, honor URL / user epg_prev_days alone (no forced lookback).
|
||||
catchup_allowed = is_catchup_enabled(user=user)
|
||||
channel_is_catchup = getattr(channel, "is_catchup", False)
|
||||
_channel_catchup_days = min(getattr(channel, "catchup_days", 0) or 0, 30)
|
||||
if _channel_is_catchup and prev_days == 0:
|
||||
if catchup_allowed and channel_is_catchup and prev_days == 0:
|
||||
prev_days = _channel_catchup_days
|
||||
|
||||
lookback_cutoff = now - timedelta(days=prev_days)
|
||||
|
|
@ -909,7 +928,7 @@ def xc_get_epg(request, user, short=False):
|
|||
|
||||
output = {"epg_listings": []}
|
||||
|
||||
if _channel_is_catchup:
|
||||
if channel_is_catchup and catchup_allowed:
|
||||
archive_window = timedelta(days=_channel_catchup_days)
|
||||
else:
|
||||
archive_window = None
|
||||
|
|
|
|||
|
|
@ -16,11 +16,19 @@ class BaseConfig:
|
|||
BUFFERING_TIMEOUT = 15 # Seconds to wait for buffering before switching streams
|
||||
BUFFER_SPEED = 1 # What speed to condsider the stream buffering, 1x is normal speed, 2x is double speed, etc.
|
||||
|
||||
# Cache for proxy settings (class-level, shared across all instances)
|
||||
# Cache for proxy settings (class-level, shared across all instances).
|
||||
# Backed by CoreSettings Redis group cache; this local copy avoids Redis
|
||||
# chatter inside the proxy hot path. Cleared when proxy_settings is saved.
|
||||
_proxy_settings_cache = None
|
||||
_proxy_settings_cache_time = 0
|
||||
_proxy_settings_cache_ttl = 10 # Cache for 10 seconds
|
||||
|
||||
@classmethod
|
||||
def clear_proxy_settings_cache(cls):
|
||||
"""Drop process-local proxy settings (called on CoreSettings invalidate)."""
|
||||
cls._proxy_settings_cache = None
|
||||
cls._proxy_settings_cache_time = 0
|
||||
|
||||
@classmethod
|
||||
def get_proxy_settings(cls):
|
||||
"""Get proxy settings from CoreSettings JSON data with fallback to defaults (cached)"""
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ from rest_framework.views import APIView
|
|||
|
||||
from apps.accounts.permissions import IsStandardUser
|
||||
from apps.channels.models import Channel
|
||||
from apps.channels.utils import get_channel_catchup_streams
|
||||
from apps.channels.utils import get_channel_catchup_streams, is_catchup_enabled
|
||||
from core.utils import RedisClient
|
||||
from dispatcharr.utils import network_access_allowed
|
||||
|
||||
|
|
@ -113,6 +113,12 @@ class CatchupSessionCreateAPIView(APIView):
|
|||
if not network_access_allowed(request, "STREAMS", user):
|
||||
return Response({"error": "Forbidden"}, status=status.HTTP_403_FORBIDDEN)
|
||||
|
||||
if not is_catchup_enabled(user=user):
|
||||
return Response(
|
||||
{"error": "Catch-up is disabled"},
|
||||
status=status.HTTP_403_FORBIDDEN,
|
||||
)
|
||||
|
||||
body = CatchupSessionCreateSerializer(data=request.data)
|
||||
body.is_valid(raise_exception=True)
|
||||
|
||||
|
|
|
|||
|
|
@ -171,6 +171,20 @@ class CatchupSessionApiTests(TestCase):
|
|||
)
|
||||
self.assertEqual(response.status_code, 400)
|
||||
|
||||
@patch("apps.timeshift.api_views.is_catchup_enabled", return_value=False)
|
||||
@patch("apps.timeshift.api_views.network_access_allowed", return_value=True)
|
||||
def test_post_rejects_when_catchup_disabled(self, _net, _enabled):
|
||||
response = self.client.post(
|
||||
self._create_url(),
|
||||
{
|
||||
"channel_uuid": str(self.channel.uuid),
|
||||
"start": "2026-06-08T17:00:00Z",
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
self.assertEqual(response.status_code, 403)
|
||||
self.assertEqual(response.json()["error"], "Catch-up is disabled")
|
||||
|
||||
@patch.object(sessions.RedisClient, "get_client")
|
||||
@patch("apps.timeshift.api_views.network_access_allowed", return_value=True)
|
||||
def test_delete_revokes_own_session(self, _net, redis_mock):
|
||||
|
|
|
|||
|
|
@ -5301,6 +5301,23 @@ class CatchupProxyTests(TestCase):
|
|||
self.assertEqual(response.status_code, 400)
|
||||
self.assertIn(b"Missing start", response.content)
|
||||
|
||||
def test_catchup_disabled_returns_403(self):
|
||||
request = self.factory.get(
|
||||
f"/proxy/catchup/{self.channel_uuid}?start=2026-06-08T17:00:00Z",
|
||||
)
|
||||
force_authenticate(request, user=self.user)
|
||||
channel = MagicMock(id=8, uuid=self.channel_uuid)
|
||||
with 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, "is_catchup_enabled", return_value=False), \
|
||||
patch.object(views, "get_channel_catchup_streams") as catchup_mock:
|
||||
channel_cls.objects.get.return_value = channel
|
||||
response = views.catchup_proxy(request, self.channel_uuid)
|
||||
self.assertEqual(response.status_code, 403)
|
||||
self.assertIn(b"Catch-up is disabled", response.content)
|
||||
catchup_mock.assert_not_called()
|
||||
|
||||
def test_missing_session_id_redirects(self):
|
||||
request = self.factory.get(
|
||||
f"/proxy/catchup/{self.channel_uuid}?start=2026-06-08T17:00:00Z",
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ from drf_spectacular.utils import OpenApiParameter, extend_schema
|
|||
from apps.accounts.authentication import ApiKeyAuthentication, QueryParamJWTAuthentication
|
||||
from apps.accounts.models import User
|
||||
from apps.channels.models import Channel
|
||||
from apps.channels.utils import get_channel_catchup_streams
|
||||
from apps.channels.utils import get_channel_catchup_streams, is_catchup_enabled
|
||||
from apps.m3u.connection_pool import release_profile_slot, reserve_profile_slot
|
||||
from apps.m3u.models import M3UAccount, M3UAccountProfile
|
||||
from apps.m3u.tasks import get_transformed_credentials
|
||||
|
|
@ -254,7 +254,9 @@ def _timeshift_proxy_impl(
|
|||
def catchup_proxy(request, channel_id):
|
||||
"""Native API catch-up playback for a channel."""
|
||||
if not network_access_allowed(request, "STREAMS"):
|
||||
return JsonResponse({"error": "Forbidden"}, status=403)
|
||||
return _finalize_timeshift_response(
|
||||
JsonResponse({"error": "Forbidden"}, status=403)
|
||||
)
|
||||
|
||||
auth_user = (
|
||||
request.user
|
||||
|
|
@ -272,9 +274,11 @@ def catchup_proxy(request, channel_id):
|
|||
resolved = resolve_catchup_playback(session_id, channel_id)
|
||||
if resolved is None:
|
||||
if auth_user is None:
|
||||
return JsonResponse(
|
||||
{"error": "Invalid or expired playback session"},
|
||||
status=401,
|
||||
return _finalize_timeshift_response(
|
||||
JsonResponse(
|
||||
{"error": "Invalid or expired playback session"},
|
||||
status=401,
|
||||
)
|
||||
)
|
||||
else:
|
||||
session_user, bound_start, bound_duration = resolved
|
||||
|
|
@ -286,7 +290,9 @@ def catchup_proxy(request, channel_id):
|
|||
client_duration_hint = bound_duration
|
||||
|
||||
if user is None:
|
||||
return JsonResponse({"error": "Authentication required"}, status=401)
|
||||
return _finalize_timeshift_response(
|
||||
JsonResponse({"error": "Authentication required"}, status=401)
|
||||
)
|
||||
|
||||
try:
|
||||
channel = Channel.objects.get(uuid=channel_id)
|
||||
|
|
@ -315,6 +321,11 @@ def _serve_catchup(request, user, channel, timestamp, client_duration_hint=None)
|
|||
otherwise EPG is used, falling back to ``DEFAULT_DURATION_MINUTES``.
|
||||
A ``DURATION_BUFFER_MINUTES`` pad is applied either way for provider lag.
|
||||
"""
|
||||
if not is_catchup_enabled(user=user):
|
||||
return _finalize_timeshift_response(
|
||||
HttpResponseForbidden("Catch-up is disabled")
|
||||
)
|
||||
|
||||
if parse_catchup_timestamp(timestamp) is None:
|
||||
return _finalize_timeshift_response(HttpResponseBadRequest("Invalid timestamp"))
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue