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