diff --git a/apps/channels/serializers.py b/apps/channels/serializers.py index d7da52a5..51bfe0a0 100644 --- a/apps/channels/serializers.py +++ b/apps/channels/serializers.py @@ -450,9 +450,41 @@ class RecordingSerializer(serializers.ModelSerializer): read_only_fields = ["task_id"] def validate(self, data): + from core.models import CoreSettings start_time = data.get("start_time") end_time = data.get("end_time") + # If this is an EPG-based recording (program provided), apply global pre/post offsets + try: + cp = data.get("custom_properties") or {} + is_epg_based = isinstance(cp, dict) and isinstance(cp.get("program"), (dict,)) + except Exception: + is_epg_based = False + + if is_epg_based and start_time and end_time: + try: + pre_min = int(CoreSettings.get_dvr_pre_offset_minutes()) + except Exception: + pre_min = 0 + try: + post_min = int(CoreSettings.get_dvr_post_offset_minutes()) + except Exception: + post_min = 0 + from datetime import timedelta + try: + if pre_min and pre_min > 0: + start_time = start_time - timedelta(minutes=pre_min) + except Exception: + pass + try: + if post_min and post_min > 0: + end_time = end_time + timedelta(minutes=post_min) + except Exception: + pass + # write back adjusted times so scheduling uses them + data["start_time"] = start_time + data["end_time"] = end_time + now = timezone.now() # timezone-aware current time if end_time < now: diff --git a/apps/channels/tasks.py b/apps/channels/tasks.py index 09404138..d4faa7bb 100755 --- a/apps/channels/tasks.py +++ b/apps/channels/tasks.py @@ -379,10 +379,33 @@ def evaluate_series_rules_impl(tvg_id: str | None = None): except Exception: continue # already scheduled/recorded + # Apply global DVR pre/post offsets (in minutes) + try: + pre_min = int(CoreSettings.get_dvr_pre_offset_minutes()) + except Exception: + pre_min = 0 + try: + post_min = int(CoreSettings.get_dvr_post_offset_minutes()) + except Exception: + post_min = 0 + + adj_start = prog.start_time + adj_end = prog.end_time + try: + if pre_min and pre_min > 0: + adj_start = adj_start - timedelta(minutes=pre_min) + except Exception: + pass + try: + if post_min and post_min > 0: + adj_end = adj_end + timedelta(minutes=post_min) + except Exception: + pass + rec = Recording.objects.create( channel=channel, - start_time=prog.start_time, - end_time=prog.end_time, + start_time=adj_start, + end_time=adj_end, custom_properties={ "program": { "id": prog.id, @@ -425,6 +448,85 @@ def evaluate_series_rules(tvg_id: str | None = None): return evaluate_series_rules_impl(tvg_id) +def reschedule_upcoming_recordings_for_offset_change_impl(): + """Recalculate start/end for all future EPG-based recordings using current DVR offsets. + + Only recordings that have not yet started (start_time > now) and that were + scheduled from EPG data (custom_properties.program present) are updated. + """ + from django.utils import timezone + from django.utils.dateparse import parse_datetime + from apps.channels.models import Recording + + now = timezone.now() + + try: + pre_min = int(CoreSettings.get_dvr_pre_offset_minutes()) + except Exception: + pre_min = 0 + try: + post_min = int(CoreSettings.get_dvr_post_offset_minutes()) + except Exception: + post_min = 0 + + changed = 0 + scanned = 0 + + for rec in Recording.objects.filter(start_time__gt=now).iterator(): + scanned += 1 + try: + cp = rec.custom_properties or {} + program = cp.get("program") if isinstance(cp, dict) else None + if not isinstance(program, dict): + continue + base_start = program.get("start_time") + base_end = program.get("end_time") + if not base_start or not base_end: + continue + start_dt = parse_datetime(str(base_start)) + end_dt = parse_datetime(str(base_end)) + if start_dt is None or end_dt is None: + continue + + adj_start = start_dt + adj_end = end_dt + try: + if pre_min and pre_min > 0: + adj_start = adj_start - timedelta(minutes=pre_min) + except Exception: + pass + try: + if post_min and post_min > 0: + adj_end = adj_end + timedelta(minutes=post_min) + except Exception: + pass + + if rec.start_time != adj_start or rec.end_time != adj_end: + rec.start_time = adj_start + rec.end_time = adj_end + rec.save(update_fields=["start_time", "end_time"]) + changed += 1 + except Exception: + continue + + # Notify frontend to refresh + try: + channel_layer = get_channel_layer() + async_to_sync(channel_layer.group_send)( + 'updates', + {'type': 'update', 'data': {"success": True, "type": "recordings_refreshed", "rescheduled": changed}}, + ) + except Exception: + pass + + return {"changed": changed, "scanned": scanned, "pre": pre_min, "post": post_min} + + +@shared_task +def reschedule_upcoming_recordings_for_offset_change(): + return reschedule_upcoming_recordings_for_offset_change_impl() + + @shared_task def _safe_name(s): try: diff --git a/core/api_views.py b/core/api_views.py index 6b9743f6..9de5aa5a 100644 --- a/core/api_views.py +++ b/core/api_views.py @@ -71,6 +71,39 @@ class CoreSettingsViewSet(viewsets.ModelViewSet): if instance.value != request.data["value"]: rehash_streams.delay(request.data["value"].split(",")) + # If DVR pre/post offsets changed, reschedule upcoming recordings + try: + from core.models import DVR_PRE_OFFSET_MINUTES_KEY, DVR_POST_OFFSET_MINUTES_KEY + if instance.key in (DVR_PRE_OFFSET_MINUTES_KEY, DVR_POST_OFFSET_MINUTES_KEY): + if instance.value != request.data.get("value"): + try: + # Prefer async task if Celery is available + from apps.channels.tasks import reschedule_upcoming_recordings_for_offset_change + reschedule_upcoming_recordings_for_offset_change.delay() + except Exception: + # Fallback to synchronous implementation + from apps.channels.tasks import reschedule_upcoming_recordings_for_offset_change_impl + reschedule_upcoming_recordings_for_offset_change_impl() + except Exception: + pass + + return response + + def create(self, request, *args, **kwargs): + response = super().create(request, *args, **kwargs) + # If creating DVR pre/post offset settings, also reschedule upcoming recordings + try: + key = request.data.get("key") + from core.models import DVR_PRE_OFFSET_MINUTES_KEY, DVR_POST_OFFSET_MINUTES_KEY + if key in (DVR_PRE_OFFSET_MINUTES_KEY, DVR_POST_OFFSET_MINUTES_KEY): + try: + from apps.channels.tasks import reschedule_upcoming_recordings_for_offset_change + reschedule_upcoming_recordings_for_offset_change.delay() + except Exception: + from apps.channels.tasks import reschedule_upcoming_recordings_for_offset_change_impl + reschedule_upcoming_recordings_for_offset_change_impl() + except Exception: + pass return response @action(detail=False, methods=["post"], url_path="check") def check(self, request, *args, **kwargs): diff --git a/core/models.py b/core/models.py index 2823c65e..f9d49f7b 100644 --- a/core/models.py +++ b/core/models.py @@ -158,6 +158,8 @@ DVR_TV_FALLBACK_DIR_KEY = slugify("DVR TV Fallback Dir") DVR_TV_FALLBACK_TEMPLATE_KEY = slugify("DVR TV Fallback Template") DVR_MOVIE_FALLBACK_TEMPLATE_KEY = slugify("DVR Movie Fallback Template") DVR_COMSKIP_ENABLED_KEY = slugify("DVR Comskip Enabled") +DVR_PRE_OFFSET_MINUTES_KEY = slugify("DVR Pre-Offset Minutes") +DVR_POST_OFFSET_MINUTES_KEY = slugify("DVR Post-Offset Minutes") class CoreSettings(models.Model): @@ -272,6 +274,34 @@ class CoreSettings(models.Model): except cls.DoesNotExist: return False + @classmethod + def get_dvr_pre_offset_minutes(cls): + """Minutes to start recording before scheduled start (default 0).""" + try: + val = cls.objects.get(key=DVR_PRE_OFFSET_MINUTES_KEY).value + return int(val) + except cls.DoesNotExist: + return 0 + except Exception: + try: + return int(float(val)) + except Exception: + return 0 + + @classmethod + def get_dvr_post_offset_minutes(cls): + """Minutes to stop recording after scheduled end (default 0).""" + try: + val = cls.objects.get(key=DVR_POST_OFFSET_MINUTES_KEY).value + return int(val) + except cls.DoesNotExist: + return 0 + except Exception: + try: + return int(float(val)) + except Exception: + return 0 + @classmethod def get_dvr_series_rules(cls): """Return list of series recording rules. Each: {tvg_id, title, mode: 'all'|'new'}""" diff --git a/frontend/src/pages/Settings.jsx b/frontend/src/pages/Settings.jsx index 85ea85f1..1768c640 100644 --- a/frontend/src/pages/Settings.jsx +++ b/frontend/src/pages/Settings.jsx @@ -77,6 +77,8 @@ const SettingsPage = () => { 'dvr-tv-fallback-template': '', 'dvr-movie-fallback-template': '', 'dvr-comskip-enabled': false, + 'dvr-pre-offset-minutes': 0, + 'dvr-post-offset-minutes': 0, }, validate: { @@ -136,6 +138,11 @@ const SettingsPage = () => { case 'm3u-hash-key': val = value.value.split(','); break; + case 'dvr-pre-offset-minutes': + case 'dvr-post-offset-minutes': + val = Number.parseInt(value.value || '0', 10); + if (Number.isNaN(val)) val = 0; + break; default: val = value.value; break; @@ -442,6 +449,38 @@ const SettingsPage = () => { 'dvr-comskip-enabled' } /> + +