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

@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
- **Catch-up enable/disable controls.** System Settings includes an **Enable Catchup** toggle (default on) that blocks timeshift and catch-up playback for everyone and clears XC `tv_archive` / `tv_archive_duration` advertising when off. Per-user **Enable Catchup** on the user Permissions tab does the same for that user only (admin-managed; not writable via `PATCH /me/`). Channel/stream catch-up indicators remain visible in the web UI.
- **Native XZ decompression for EPG sources and uploaded M3U playlists.** `.xz`-compressed XMLTV and M3U files are now decompressed using Python's stdlib `lzma` module. EPG ingestion detects XZ via magic bytes, file extension (including compound names like `.xml.xz`), or MIME type; uploaded M3U `.xz` playlists stream line-by-line like `.gz`. The `/data/epgs` auto-import scanner now includes `.xz` files alongside `.xml`, `.gz`, and `.zip`. (Closes #1414) — Thanks [@MotWakorb](https://github.com/MotWakorb)
- **XC catch-up (timeshift) support**. Adds a native `/timeshift/{user}/{pass}/{duration}/{timestamp}/{channel_id}.ts` endpoint that proxies replay sessions from an Xtream Codes provider to IPTV clients. Auth reuses the same `xc_password` custom-property the live `/live/.../{id}.ts` endpoint already uses. Catch-up sessions appear on the Stats page in dedicated connection cards (separate from live and VOD) and respect per-channel access rules. (Closes #133) — Thanks [@cedric-marcoux](https://github.com/cedric-marcoux)
- **Query-style catch-up compatibility.** Accepts `/streaming/timeshift.php` query-string catch-up requests in addition to the path-style endpoint. Path and query requests now use client-supplied duration hints when present, add a short provider-lag buffer, and fall back to EPG duration, then 120 minutes. Thanks [@dillardblom](https://github.com/dillardblom)
@ -37,6 +38,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Performance
- **CoreSettings JSON groups are cached in Redis.** `CoreSettings._get_group()` caches stream, system, proxy, DVR, EPG, user-limit, and network-access settings so hot paths (including `network_access_allowed` on every stream/XC/catchup request and catch-up enable checks) no longer hit Postgres on each call. Cache entries invalidate on `CoreSettings` save or delete; proxy workers also clear their short process-local proxy-settings copy when `proxy_settings` changes.
- **M3U/XC stream refresh is faster on large accounts.** Steady-state refreshes split `bulk_update` into a lightweight touch pass (`last_seen` / `is_stale` only) for unchanged streams and a full column update only when provider metadata or catch-up fields actually change.
- **M3U stream filters compile once per refresh.** Account filters are regex-compiled before batch workers start; accounts with no filters skip per-stream filter checks entirely.
- **M3U refresh releases parse catalogs sooner.** Standard accounts stream-parse the on-disk M3U instead of loading the full file into RAM; `extinf_data` is dropped immediately after batch DB work, before stale cleanup and auto-sync.

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

View file

@ -198,33 +198,13 @@ USER_LIMITS_SETTINGS_KEY = "user_limit_settings"
# Redis cache for CoreSettings JSON groups. Primary invalidation is post_save /
# post_delete; TTL is a safety net if a writer bypasses signals.
# A version key is bumped on invalidate so a concurrent miss cannot re-poison
# Redis with a stale DB snapshot after delete.
_GROUP_CACHE_PREFIX = "coresettings:group:"
_GROUP_CACHE_VER_PREFIX = "coresettings:groupver:"
_GROUP_CACHE_TTL_SECONDS = 300
def setting_flag_enabled(value, *, default=True):
"""Parse a settings/JSON flag that should default to *default* when unset.
Explicit ``False`` / ``0`` / common false strings disable. Explicit
``True`` / ``1`` / common true strings enable. Other types fall back to
*default* so garbage payloads do not accidentally enable a kill-switch.
"""
if value is None:
return default
if value is False or value == 0:
return False
if value is True or value == 1:
return True
if isinstance(value, str):
lowered = value.strip().lower()
if lowered in ("false", "0", "no", "off", ""):
return False
if lowered in ("true", "1", "yes", "on"):
return True
return default
return default
class CoreSettings(models.Model):
key = models.CharField(
max_length=255,
@ -245,12 +225,21 @@ class CoreSettings(models.Model):
def group_cache_key(cls, key):
return f"{_GROUP_CACHE_PREFIX}{key}"
@classmethod
def group_cache_ver_key(cls, key):
return f"{_GROUP_CACHE_VER_PREFIX}{key}"
@classmethod
def invalidate_group_cache(cls, key):
"""Drop the cached JSON for a settings group (all workers share Redis)."""
import time
from django.core.cache import cache
cache.delete(cls.group_cache_key(key))
# Monotonic bump so in-flight _get_group fills skip cache.set.
# timeout=None: never expire (version must outlive group entries).
cache.set(cls.group_cache_ver_key(key), time.time_ns(), timeout=None)
if key == PROXY_SETTINGS_KEY:
# Proxy workers also keep a short process-local copy.
try:
@ -276,10 +265,12 @@ class CoreSettings(models.Model):
defaults = defaults or {}
cache_key = cls.group_cache_key(key)
ver_key = cls.group_cache_ver_key(key)
cached = cache.get(cache_key)
if isinstance(cached, dict):
return copy.deepcopy(cached)
ver_before = cache.get(ver_key)
try:
value = cls.objects.get(key=key).value or defaults
if not isinstance(value, dict):
@ -288,7 +279,10 @@ class CoreSettings(models.Model):
value = defaults
value = copy.deepcopy(value)
cache.set(cache_key, value, timeout=_GROUP_CACHE_TTL_SECONDS)
# Skip fill if an invalidate landed during the DB read (avoids
# re-caching a stale snapshot for the full TTL).
if cache.get(ver_key) == ver_before:
cache.set(cache_key, value, timeout=_GROUP_CACHE_TTL_SECONDS)
return copy.deepcopy(value)
@classmethod
@ -489,10 +483,8 @@ class CoreSettings(models.Model):
@classmethod
def get_catchup_enabled(cls):
"""Whether catch-up / timeshift is enabled system-wide (default True)."""
return setting_flag_enabled(
cls.get_system_settings().get("catchup_enabled"),
default=True,
)
# Stored as a JSON boolean by System Settings; default on when unset.
return cls.get_system_settings().get("catchup_enabled", True) is not False
@classmethod
def get_system_time_zone(cls):

View file

@ -9,34 +9,9 @@ from core.models import (
DVR_SETTINGS_KEY,
EPG_SETTINGS_KEY,
SYSTEM_SETTINGS_KEY,
setting_flag_enabled,
)
class SettingFlagEnabledTests(SimpleTestCase):
def test_defaults_when_missing(self):
self.assertTrue(setting_flag_enabled(None, default=True))
self.assertFalse(setting_flag_enabled(None, default=False))
def test_bool_and_int(self):
self.assertFalse(setting_flag_enabled(False))
self.assertTrue(setting_flag_enabled(True))
self.assertFalse(setting_flag_enabled(0))
self.assertTrue(setting_flag_enabled(1))
def test_common_strings(self):
self.assertFalse(setting_flag_enabled("false"))
self.assertFalse(setting_flag_enabled("0"))
self.assertFalse(setting_flag_enabled("off"))
self.assertTrue(setting_flag_enabled("true"))
self.assertTrue(setting_flag_enabled("1"))
self.assertTrue(setting_flag_enabled("yes"))
def test_garbage_falls_back_to_default(self):
self.assertTrue(setting_flag_enabled("maybe", default=True))
self.assertFalse(setting_flag_enabled([], default=False))
class CoreSettingsGroupCacheTests(TestCase):
"""_get_group Redis cache: hit after first read, invalidate on save."""
@ -44,6 +19,10 @@ class CoreSettingsGroupCacheTests(TestCase):
cache.clear()
CoreSettings.objects.filter(key=SYSTEM_SETTINGS_KEY).delete()
def tearDown(self):
# DB rollback does not undo Redis entries written during the test.
cache.clear()
def test_second_read_does_not_query_database(self):
CoreSettings.objects.create(
key=SYSTEM_SETTINGS_KEY,
@ -79,6 +58,43 @@ class CoreSettingsGroupCacheTests(TestCase):
# Row gone: defaults apply (catchup enabled)
self.assertTrue(CoreSettings.get_catchup_enabled())
def test_stale_fill_does_not_repoison_after_invalidate(self):
"""A miss that read DB before invalidate must not rewrite Redis."""
CoreSettings.objects.create(
key=SYSTEM_SETTINGS_KEY,
name="System Settings",
value={"catchup_enabled": True},
)
cache_key = CoreSettings.group_cache_key(SYSTEM_SETTINGS_KEY)
cache.delete(cache_key)
real_get = CoreSettings.objects.get
cached_sets = []
def racing_get(*args, **kwargs):
row = real_get(*args, **kwargs)
# Concurrent writer: bump version after this miss read the row.
CoreSettings.invalidate_group_cache(SYSTEM_SETTINGS_KEY)
return row
real_set = cache.set
def tracking_set(key, value, timeout=None, **kwargs):
cached_sets.append(key)
return real_set(key, value, timeout=timeout, **kwargs)
with patch.object(CoreSettings.objects, "get", side_effect=racing_get), \
patch.object(cache, "set", side_effect=tracking_set):
CoreSettings.get_system_settings()
self.assertNotIn(cache_key, cached_sets)
# Writer left DB at True; a later read may refill, but not with a
# skipped stale set over a newer disable. Flip DB and confirm.
obj = CoreSettings.objects.get(key=SYSTEM_SETTINGS_KEY)
obj.value = {"catchup_enabled": False}
obj.save()
self.assertFalse(CoreSettings.get_catchup_enabled())
def test_nested_mutation_does_not_poison_cache(self):
from core.models import DVR_SETTINGS_KEY

View file

@ -260,6 +260,14 @@ const User = ({ user = null, isOpen, onClose }) => {
})}
key={form.key('hide_adult_content')}
/>
<Switch
label="Enable Catchup"
description="When disabled, this user cannot access timeshift or catchup endpoints, and their channels are not advertised as supporting catchup"
{...form.getInputProps('catchup_enabled', {
type: 'checkbox',
})}
key={form.key('catchup_enabled')}
/>
</Stack>
</TabsPanel>
)}

View file

@ -10,12 +10,10 @@ import {
Button,
Divider,
Flex,
Group,
NumberInput,
Select,
Stack,
Switch,
Text,
} from '@mantine/core';
import ConnectionSecurityPanel from './ConnectionSecurityPanel.jsx';
import { useForm } from '@mantine/form';
@ -95,39 +93,28 @@ const SystemSettingsForm = React.memo(({ active }) => {
value: `${r.value}`,
}))}
/>
<Group justify="space-between" pt={5}>
<div>
<Text size="sm" fw={500}>
Auto-Import Mapped Files
</Text>
<Text size="xs" c="dimmed">
Automatically import media files when they are mapped to a channel.
</Text>
</div>
<Switch
{...form.getInputProps('auto_import_mapped_files', {
type: 'checkbox',
})}
id="auto_import_mapped_files"
/>
</Group>
<Switch
label="Auto-Import Mapped Files"
description="Automatically import media files when they are mapped to a channel."
{...form.getInputProps('auto_import_mapped_files', {
type: 'checkbox',
})}
id="auto_import_mapped_files"
/>
{!ipLookupEnvDisabled && (
<Group justify="space-between" pt={5}>
<div>
<Text size="sm" fw={500}>
Enable IP Lookup
</Text>
<Text size="xs" c="dimmed">
Fetch and display the instance's public IP and country flag in the
sidebar.
</Text>
</div>
<Switch
{...form.getInputProps('enable_ip_lookup', { type: 'checkbox' })}
id="enable_ip_lookup"
/>
</Group>
<Switch
label="Enable IP Lookup"
description="Fetch and display the instance's public IP and country flag in the sidebar."
{...form.getInputProps('enable_ip_lookup', { type: 'checkbox' })}
id="enable_ip_lookup"
/>
)}
<Switch
label="Enable Catchup"
description="When disabled, timeshift and catchup endpoints are blocked for all users, and channels are not advertised as supporting catchup to clients. Catchup capability is still shown in the web UI."
{...form.getInputProps('catchup_enabled', { type: 'checkbox' })}
id="catchup_enabled"
/>
{isModular && (
<>
<Divider my="md" label="Connection Security" labelPosition="left" />

View file

@ -114,6 +114,8 @@ const setupMocks = ({
max_system_events: settings?.max_system_events ?? 100,
preferred_region: '',
auto_import_mapped_files: true,
enable_ip_lookup: true,
catchup_enabled: true,
};
const formMock = {
@ -207,6 +209,12 @@ describe('SystemSettingsForm', () => {
).toBeInTheDocument();
});
it('renders the Enable Catchup switch', () => {
setupMocks();
render(<SystemSettingsForm active={true} />);
expect(screen.getByTestId('catchup_enabled')).toBeInTheDocument();
});
it('does not show success alert on initial render', () => {
setupMocks();
render(<SystemSettingsForm active={true} />);
@ -286,6 +294,8 @@ describe('SystemSettingsForm', () => {
max_system_events: 100,
preferred_region: '',
auto_import_mapped_files: true,
enable_ip_lookup: true,
catchup_enabled: true,
});
});

View file

@ -50,6 +50,7 @@ export const userToFormValues = (user) => {
? `${customProps.output_profile}`
: '',
hide_adult_content: customProps.hide_adult_content || false,
catchup_enabled: customProps.catchup_enabled !== false,
epg_days: customProps.epg_days || 0,
epg_prev_days: customProps.epg_prev_days || 0,
allowed_ips: [
@ -80,6 +81,9 @@ export const formValuesToPayload = (values, existingUser) => {
customProps.hide_adult_content = payload.hide_adult_content || false;
delete payload.hide_adult_content;
customProps.catchup_enabled = payload.catchup_enabled !== false;
delete payload.catchup_enabled;
customProps.epg_days = payload.epg_days || 0;
delete payload.epg_days;
@ -119,6 +123,7 @@ export const getFormInitialValues = () => {
output_profile: '',
channel_profiles: [],
hide_adult_content: false,
catchup_enabled: true,
epg_days: 0,
epg_prev_days: 0,
allowed_ips: [],

View file

@ -189,6 +189,7 @@ describe('UserUtils', () => {
expect(result.output_format).toBe('');
expect(result.output_profile).toBe('');
expect(result.hide_adult_content).toBe(false);
expect(result.catchup_enabled).toBe(true);
expect(result.epg_days).toBe(0);
expect(result.epg_prev_days).toBe(0);
});
@ -200,6 +201,7 @@ describe('UserUtils', () => {
output_format: 'ts',
output_profile: 5,
hide_adult_content: true,
catchup_enabled: false,
epg_days: 7,
epg_prev_days: 2,
allowed_networks: {
@ -213,6 +215,7 @@ describe('UserUtils', () => {
expect(result.output_format).toBe('ts');
expect(result.output_profile).toBe('5');
expect(result.hide_adult_content).toBe(true);
expect(result.catchup_enabled).toBe(false);
expect(result.epg_days).toBe(7);
expect(result.epg_prev_days).toBe(2);
});
@ -246,6 +249,7 @@ describe('UserUtils', () => {
output_format: 'ts',
output_profile: '3',
hide_adult_content: true,
catchup_enabled: false,
epg_days: 7,
epg_prev_days: 2,
allowed_ips: ['192.168.1.0/24'],
@ -285,6 +289,20 @@ describe('UserUtils', () => {
expect(result.custom_properties.hide_adult_content).toBe(true);
});
it('moves catchup_enabled into custom_properties', () => {
const result = formValuesToPayload(makeValues(), null);
expect(result.catchup_enabled).toBeUndefined();
expect(result.custom_properties.catchup_enabled).toBe(false);
});
it('defaults catchup_enabled to true when omitted', () => {
const result = formValuesToPayload(
makeValues({ catchup_enabled: undefined }),
null
);
expect(result.custom_properties.catchup_enabled).toBe(true);
});
it('moves epg_days and epg_prev_days into custom_properties', () => {
const result = formValuesToPayload(makeValues(), null);
expect(result.epg_days).toBeUndefined();
@ -362,6 +380,7 @@ describe('UserUtils', () => {
output_profile: '',
channel_profiles: [],
hide_adult_content: false,
catchup_enabled: true,
epg_days: 0,
epg_prev_days: 0,
allowed_ips: [],

View file

@ -4,5 +4,6 @@ export const getSystemSettingsFormInitialValues = () => {
preferred_region: '',
auto_import_mapped_files: true,
enable_ip_lookup: true,
catchup_enabled: true,
};
};

View file

@ -12,6 +12,7 @@ describe('SystemSettingsFormUtils', () => {
preferred_region: '',
auto_import_mapped_files: true,
enable_ip_lookup: true,
catchup_enabled: true,
});
});

View file

@ -68,6 +68,7 @@ export const saveChangedSettings = async (settings, changedSettings) => {
'preferred_region',
'auto_import_mapped_files',
'enable_ip_lookup',
'catchup_enabled',
];
for (const formKey in changedSettings) {
@ -135,9 +136,17 @@ export const saveChangedSettings = async (settings, changedSettings) => {
'schedule_enabled',
'auto_import_mapped_files',
'enable_ip_lookup',
'catchup_enabled',
];
if (booleanFields.includes(formKey) && value != null) {
value = typeof value === 'boolean' ? value : Boolean(value);
if (typeof value === 'boolean') {
// keep as-is
} else if (typeof value === 'string') {
const lowered = value.trim().toLowerCase();
value = ['true', '1', 'yes', 'on'].includes(lowered);
} else {
value = value === 1;
}
}
// Route to appropriate group
@ -364,6 +373,10 @@ export const parseSettings = (settings) => {
typeof systemSettings.enable_ip_lookup === 'boolean'
? systemSettings.enable_ip_lookup
: true;
parsed.catchup_enabled =
typeof systemSettings.catchup_enabled === 'boolean'
? systemSettings.catchup_enabled
: true;
}
// Proxy and network access are already grouped objects

View file

@ -119,6 +119,56 @@ describe('SettingsUtils', () => {
});
});
it('should persist catchup_enabled false in system_settings', async () => {
const settings = {
system_settings: {
id: 3,
key: 'system_settings',
value: { catchup_enabled: true },
},
};
const changedSettings = {
catchup_enabled: false,
};
API.updateSetting.mockResolvedValue({});
await SettingsUtils.saveChangedSettings(settings, changedSettings);
expect(API.updateSetting).toHaveBeenCalledWith({
id: 3,
key: 'system_settings',
value: {
catchup_enabled: false,
},
});
});
it('should coerce string false for boolean settings without enabling', async () => {
const settings = {
system_settings: {
id: 3,
key: 'system_settings',
value: {},
},
};
const changedSettings = {
catchup_enabled: 'false',
};
API.updateSetting.mockResolvedValue({});
await SettingsUtils.saveChangedSettings(settings, changedSettings);
expect(API.updateSetting).toHaveBeenCalledWith({
id: 3,
key: 'system_settings',
value: {
catchup_enabled: false,
},
});
});
it('should convert ID fields to integers', async () => {
const settings = {
stream_settings: {
@ -299,6 +349,7 @@ describe('SettingsUtils', () => {
expect(result.m3u_hash_key).toEqual(['channel_name', 'channel_number']);
expect(result.preferred_region).toBe('US');
expect(result.auto_import_mapped_files).toBe(true);
expect(result.catchup_enabled).toBe(true);
// Check DVR settings
expect(result.tv_template).toBe('/media/tv/{show}/{season}/');

View file

@ -140,3 +140,20 @@ class UserPreferencesAPITests(TestCase):
self.assertEqual(self.user.user_level, original_level)
self.assertFalse(self.user.is_staff)
self.assertFalse(self.user.is_superuser)
def test_patch_me_cannot_set_catchup_enabled(self):
"""catchup_enabled is admin-managed; /me must not change it."""
self.user.custom_properties = {"catchup_enabled": False, "theme": "dark"}
self.user.save(update_fields=["custom_properties"])
response = self.client.patch(
self.me_url,
{"custom_properties": {"catchup_enabled": True, "theme": "light"}},
format="json",
)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.user.refresh_from_db()
# Stripped from input; existing False preserved. Other props still update.
self.assertFalse(self.user.custom_properties.get("catchup_enabled"))
self.assertEqual(self.user.custom_properties.get("theme"), "light")