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:
SergeantPanda 2026-07-18 18:55:51 +00:00
parent 2b62928b42
commit b6442e6421
23 changed files with 549 additions and 106 deletions

View file

@ -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:

View file

@ -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."""

View file

@ -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.

View file

@ -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."""

View file

@ -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

View file

@ -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)"""

View file

@ -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)

View file

@ -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):

View file

@ -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",

View file

@ -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"))