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'
}
/>
+
+