From b6442e642175845e1d7f8f202fe2cdfd3db2af73 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Sat, 18 Jul 2026 18:55:51 +0000 Subject: [PATCH] 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. --- CHANGELOG.md | 2 + apps/accounts/api_views.py | 5 +- apps/channels/tests/test_catchup_utils.py | 67 +++++++- apps/channels/utils.py | 18 ++ apps/output/tests/test_views.py | 162 ++++++++++++++++++ apps/output/views.py | 35 +++- apps/proxy/config.py | 10 +- apps/timeshift/api_views.py | 8 +- apps/timeshift/tests/test_sessions.py | 14 ++ apps/timeshift/tests/test_views.py | 17 ++ apps/timeshift/views.py | 23 ++- core/models.py | 48 +++--- core/tests/test_core.py | 66 ++++--- frontend/src/components/forms/User.jsx | 8 + .../forms/settings/SystemSettingsForm.jsx | 53 +++--- .../__tests__/SystemSettingsForm.test.jsx | 10 ++ frontend/src/utils/forms/UserUtils.js | 5 + .../utils/forms/__tests__/UserUtils.test.js | 19 ++ .../forms/settings/SystemSettingsFormUtils.js | 1 + .../__tests__/SystemSettingsFormUtils.test.js | 1 + frontend/src/utils/pages/SettingsUtils.js | 15 +- .../pages/__tests__/SettingsUtils.test.js | 51 ++++++ tests/test_user_preferences.py | 17 ++ 23 files changed, 549 insertions(+), 106 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 61f58881..a58d1a54 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/apps/accounts/api_views.py b/apps/accounts/api_views.py index b4cdc8bc..8e624370 100644 --- a/apps/accounts/api_views.py +++ b/apps/accounts/api_views.py @@ -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: diff --git a/apps/channels/tests/test_catchup_utils.py b/apps/channels/tests/test_catchup_utils.py index 31ea7e02..8f60900b 100644 --- a/apps/channels/tests/test_catchup_utils.py +++ b/apps/channels/tests/test_catchup_utils.py @@ -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.""" diff --git a/apps/channels/utils.py b/apps/channels/utils.py index e5f9b08c..cbba0cc9 100644 --- a/apps/channels/utils.py +++ b/apps/channels/utils.py @@ -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. diff --git a/apps/output/tests/test_views.py b/apps/output/tests/test_views.py index 6543a0ae..7e2b9778 100644 --- a/apps/output/tests/test_views.py +++ b/apps/output/tests/test_views.py @@ -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.""" diff --git a/apps/output/views.py b/apps/output/views.py index fae08722..5724a03b 100644 --- a/apps/output/views.py +++ b/apps/output/views.py @@ -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 diff --git a/apps/proxy/config.py b/apps/proxy/config.py index 9e757bd4..867040fd 100644 --- a/apps/proxy/config.py +++ b/apps/proxy/config.py @@ -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)""" diff --git a/apps/timeshift/api_views.py b/apps/timeshift/api_views.py index 7e216df8..b924631a 100644 --- a/apps/timeshift/api_views.py +++ b/apps/timeshift/api_views.py @@ -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) diff --git a/apps/timeshift/tests/test_sessions.py b/apps/timeshift/tests/test_sessions.py index 5d815bc0..6017be5b 100644 --- a/apps/timeshift/tests/test_sessions.py +++ b/apps/timeshift/tests/test_sessions.py @@ -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): diff --git a/apps/timeshift/tests/test_views.py b/apps/timeshift/tests/test_views.py index 79f3f436..40014949 100644 --- a/apps/timeshift/tests/test_views.py +++ b/apps/timeshift/tests/test_views.py @@ -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", diff --git a/apps/timeshift/views.py b/apps/timeshift/views.py index 6543e97a..5ebc416f 100644 --- a/apps/timeshift/views.py +++ b/apps/timeshift/views.py @@ -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")) diff --git a/core/models.py b/core/models.py index 762c8529..17be7c96 100644 --- a/core/models.py +++ b/core/models.py @@ -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): diff --git a/core/tests/test_core.py b/core/tests/test_core.py index d045512e..c5a236d0 100644 --- a/core/tests/test_core.py +++ b/core/tests/test_core.py @@ -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 diff --git a/frontend/src/components/forms/User.jsx b/frontend/src/components/forms/User.jsx index 8c908b48..ce78b926 100644 --- a/frontend/src/components/forms/User.jsx +++ b/frontend/src/components/forms/User.jsx @@ -260,6 +260,14 @@ const User = ({ user = null, isOpen, onClose }) => { })} key={form.key('hide_adult_content')} /> + )} diff --git a/frontend/src/components/forms/settings/SystemSettingsForm.jsx b/frontend/src/components/forms/settings/SystemSettingsForm.jsx index 82e631d2..2a8c9b75 100644 --- a/frontend/src/components/forms/settings/SystemSettingsForm.jsx +++ b/frontend/src/components/forms/settings/SystemSettingsForm.jsx @@ -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}`, }))} /> - -
- - Auto-Import Mapped Files - - - Automatically import media files when they are mapped to a channel. - -
- -
+ {!ipLookupEnvDisabled && ( - -
- - Enable IP Lookup - - - Fetch and display the instance's public IP and country flag in the - sidebar. - -
- -
+ )} + {isModular && ( <> diff --git a/frontend/src/components/forms/settings/__tests__/SystemSettingsForm.test.jsx b/frontend/src/components/forms/settings/__tests__/SystemSettingsForm.test.jsx index 09daace7..577141cb 100644 --- a/frontend/src/components/forms/settings/__tests__/SystemSettingsForm.test.jsx +++ b/frontend/src/components/forms/settings/__tests__/SystemSettingsForm.test.jsx @@ -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(); + expect(screen.getByTestId('catchup_enabled')).toBeInTheDocument(); + }); + it('does not show success alert on initial render', () => { setupMocks(); render(); @@ -286,6 +294,8 @@ describe('SystemSettingsForm', () => { max_system_events: 100, preferred_region: '', auto_import_mapped_files: true, + enable_ip_lookup: true, + catchup_enabled: true, }); }); diff --git a/frontend/src/utils/forms/UserUtils.js b/frontend/src/utils/forms/UserUtils.js index 3627d6ca..be8e5373 100644 --- a/frontend/src/utils/forms/UserUtils.js +++ b/frontend/src/utils/forms/UserUtils.js @@ -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: [], diff --git a/frontend/src/utils/forms/__tests__/UserUtils.test.js b/frontend/src/utils/forms/__tests__/UserUtils.test.js index 5157ac96..f1cc32af 100644 --- a/frontend/src/utils/forms/__tests__/UserUtils.test.js +++ b/frontend/src/utils/forms/__tests__/UserUtils.test.js @@ -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: [], diff --git a/frontend/src/utils/forms/settings/SystemSettingsFormUtils.js b/frontend/src/utils/forms/settings/SystemSettingsFormUtils.js index a30c9998..b2b4c187 100644 --- a/frontend/src/utils/forms/settings/SystemSettingsFormUtils.js +++ b/frontend/src/utils/forms/settings/SystemSettingsFormUtils.js @@ -4,5 +4,6 @@ export const getSystemSettingsFormInitialValues = () => { preferred_region: '', auto_import_mapped_files: true, enable_ip_lookup: true, + catchup_enabled: true, }; }; diff --git a/frontend/src/utils/forms/settings/__tests__/SystemSettingsFormUtils.test.js b/frontend/src/utils/forms/settings/__tests__/SystemSettingsFormUtils.test.js index c3a0cf41..a160cd21 100644 --- a/frontend/src/utils/forms/settings/__tests__/SystemSettingsFormUtils.test.js +++ b/frontend/src/utils/forms/settings/__tests__/SystemSettingsFormUtils.test.js @@ -12,6 +12,7 @@ describe('SystemSettingsFormUtils', () => { preferred_region: '', auto_import_mapped_files: true, enable_ip_lookup: true, + catchup_enabled: true, }); }); diff --git a/frontend/src/utils/pages/SettingsUtils.js b/frontend/src/utils/pages/SettingsUtils.js index b097accd..a24121f6 100644 --- a/frontend/src/utils/pages/SettingsUtils.js +++ b/frontend/src/utils/pages/SettingsUtils.js @@ -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 diff --git a/frontend/src/utils/pages/__tests__/SettingsUtils.test.js b/frontend/src/utils/pages/__tests__/SettingsUtils.test.js index 91417b6a..c3900f02 100644 --- a/frontend/src/utils/pages/__tests__/SettingsUtils.test.js +++ b/frontend/src/utils/pages/__tests__/SettingsUtils.test.js @@ -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}/'); diff --git a/tests/test_user_preferences.py b/tests/test_user_preferences.py index 80dcc992..fc35859f 100644 --- a/tests/test_user_preferences.py +++ b/tests/test_user_preferences.py @@ -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")