mirror of
https://github.com/Dispatcharr/Dispatcharr.git
synced 2026-01-23 02:35:14 +00:00
Added DVR offset
Added pre/post offset for DVR recordings.
This commit is contained in:
parent
ab36b28b51
commit
c85316b912
5 changed files with 238 additions and 2 deletions
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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'}"""
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
}
|
||||
/>
|
||||
<NumberInput
|
||||
label="Start early (minutes)"
|
||||
description="Begin recording this many minutes before the scheduled start."
|
||||
min={0}
|
||||
step={1}
|
||||
{...form.getInputProps('dvr-pre-offset-minutes')}
|
||||
key={form.key('dvr-pre-offset-minutes')}
|
||||
id={
|
||||
settings['dvr-pre-offset-minutes']?.id ||
|
||||
'dvr-pre-offset-minutes'
|
||||
}
|
||||
name={
|
||||
settings['dvr-pre-offset-minutes']?.key ||
|
||||
'dvr-pre-offset-minutes'
|
||||
}
|
||||
/>
|
||||
<NumberInput
|
||||
label="End late (minutes)"
|
||||
description="Continue recording this many minutes after the scheduled end."
|
||||
min={0}
|
||||
step={1}
|
||||
{...form.getInputProps('dvr-post-offset-minutes')}
|
||||
key={form.key('dvr-post-offset-minutes')}
|
||||
id={
|
||||
settings['dvr-post-offset-minutes']?.id ||
|
||||
'dvr-post-offset-minutes'
|
||||
}
|
||||
name={
|
||||
settings['dvr-post-offset-minutes']?.key ||
|
||||
'dvr-post-offset-minutes'
|
||||
}
|
||||
/>
|
||||
<TextInput
|
||||
label="TV Path Template"
|
||||
description="Supports {show}, {season}, {episode}, {sub_title}, {channel}, {year}, {start}, {end}. Use format specifiers like {season:02d}. Relative paths are under your library dir."
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue