forked from Mirrors/Dispatcharr
DVR Features and bug fixes
Added ability to use custom comskip.ini Added series recording without reliance on EPG Fixed comskip bug Fixed timezone mismatch when scheduling DVR recordings No migrations completed yet
This commit is contained in:
parent
edc18e07fe
commit
424a450654
15 changed files with 1056 additions and 114 deletions
|
|
@ -13,12 +13,14 @@ from .api_views import (
|
|||
UpdateChannelMembershipAPIView,
|
||||
BulkUpdateChannelMembershipAPIView,
|
||||
RecordingViewSet,
|
||||
RecurringRecordingRuleViewSet,
|
||||
GetChannelStreamsAPIView,
|
||||
SeriesRulesAPIView,
|
||||
DeleteSeriesRuleAPIView,
|
||||
EvaluateSeriesRulesAPIView,
|
||||
BulkRemoveSeriesRecordingsAPIView,
|
||||
BulkDeleteUpcomingRecordingsAPIView,
|
||||
ComskipConfigAPIView,
|
||||
)
|
||||
|
||||
app_name = 'channels' # for DRF routing
|
||||
|
|
@ -30,6 +32,7 @@ router.register(r'channels', ChannelViewSet, basename='channel')
|
|||
router.register(r'logos', LogoViewSet, basename='logo')
|
||||
router.register(r'profiles', ChannelProfileViewSet, basename='profile')
|
||||
router.register(r'recordings', RecordingViewSet, basename='recording')
|
||||
router.register(r'recurring-rules', RecurringRecordingRuleViewSet, basename='recurring-rule')
|
||||
|
||||
urlpatterns = [
|
||||
# Bulk delete is a single APIView, not a ViewSet
|
||||
|
|
@ -46,6 +49,7 @@ urlpatterns = [
|
|||
path('series-rules/bulk-remove/', BulkRemoveSeriesRecordingsAPIView.as_view(), name='bulk_remove_series_recordings'),
|
||||
path('series-rules/<str:tvg_id>/', DeleteSeriesRuleAPIView.as_view(), name='delete_series_rule'),
|
||||
path('recordings/bulk-delete-upcoming/', BulkDeleteUpcomingRecordingsAPIView.as_view(), name='bulk_delete_upcoming_recordings'),
|
||||
path('dvr/comskip-config/', ComskipConfigAPIView.as_view(), name='comskip_config'),
|
||||
]
|
||||
|
||||
urlpatterns += router.urls
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ from .models import (
|
|||
ChannelProfile,
|
||||
ChannelProfileMembership,
|
||||
Recording,
|
||||
RecurringRecordingRule,
|
||||
)
|
||||
from .serializers import (
|
||||
StreamSerializer,
|
||||
|
|
@ -38,8 +39,17 @@ from .serializers import (
|
|||
BulkChannelProfileMembershipSerializer,
|
||||
ChannelProfileSerializer,
|
||||
RecordingSerializer,
|
||||
RecurringRecordingRuleSerializer,
|
||||
)
|
||||
from .tasks import (
|
||||
match_epg_channels,
|
||||
evaluate_series_rules,
|
||||
evaluate_series_rules_impl,
|
||||
match_single_channel_epg,
|
||||
match_selected_channels_epg,
|
||||
sync_recurring_rule_impl,
|
||||
purge_recurring_rule_impl,
|
||||
)
|
||||
from .tasks import match_epg_channels, evaluate_series_rules, evaluate_series_rules_impl, match_single_channel_epg, match_selected_channels_epg
|
||||
import django_filters
|
||||
from django_filters.rest_framework import DjangoFilterBackend
|
||||
from rest_framework.filters import SearchFilter, OrderingFilter
|
||||
|
|
@ -49,10 +59,12 @@ from django.db.models import Q
|
|||
from django.http import StreamingHttpResponse, FileResponse, Http404
|
||||
from django.utils import timezone
|
||||
import mimetypes
|
||||
from django.conf import settings
|
||||
|
||||
from rest_framework.pagination import PageNumberPagination
|
||||
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
|
|
@ -1653,6 +1665,41 @@ class BulkUpdateChannelMembershipAPIView(APIView):
|
|||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
|
||||
class RecurringRecordingRuleViewSet(viewsets.ModelViewSet):
|
||||
queryset = RecurringRecordingRule.objects.all().select_related("channel")
|
||||
serializer_class = RecurringRecordingRuleSerializer
|
||||
|
||||
def get_permissions(self):
|
||||
return [IsAdmin()]
|
||||
|
||||
def perform_create(self, serializer):
|
||||
rule = serializer.save()
|
||||
try:
|
||||
sync_recurring_rule_impl(rule.id, drop_existing=True)
|
||||
except Exception as err:
|
||||
logger.warning(f"Failed to initialize recurring rule {rule.id}: {err}")
|
||||
return rule
|
||||
|
||||
def perform_update(self, serializer):
|
||||
rule = serializer.save()
|
||||
try:
|
||||
if rule.enabled:
|
||||
sync_recurring_rule_impl(rule.id, drop_existing=True)
|
||||
else:
|
||||
purge_recurring_rule_impl(rule.id)
|
||||
except Exception as err:
|
||||
logger.warning(f"Failed to resync recurring rule {rule.id}: {err}")
|
||||
return rule
|
||||
|
||||
def perform_destroy(self, instance):
|
||||
rule_id = instance.id
|
||||
super().perform_destroy(instance)
|
||||
try:
|
||||
purge_recurring_rule_impl(rule_id)
|
||||
except Exception as err:
|
||||
logger.warning(f"Failed to purge recordings for rule {rule_id}: {err}")
|
||||
|
||||
|
||||
class RecordingViewSet(viewsets.ModelViewSet):
|
||||
queryset = Recording.objects.all()
|
||||
serializer_class = RecordingSerializer
|
||||
|
|
@ -1832,6 +1879,49 @@ class RecordingViewSet(viewsets.ModelViewSet):
|
|||
return response
|
||||
|
||||
|
||||
class ComskipConfigAPIView(APIView):
|
||||
"""Upload or inspect the custom comskip.ini used by DVR processing."""
|
||||
|
||||
parser_classes = [MultiPartParser, FormParser]
|
||||
|
||||
def get_permissions(self):
|
||||
return [IsAdmin()]
|
||||
|
||||
def get(self, request):
|
||||
path = CoreSettings.get_dvr_comskip_custom_path()
|
||||
exists = bool(path and os.path.exists(path))
|
||||
return Response({"path": path, "exists": exists})
|
||||
|
||||
def post(self, request):
|
||||
uploaded = request.FILES.get("file") or request.FILES.get("comskip_ini")
|
||||
if not uploaded:
|
||||
return Response({"error": "No file provided"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
name = (uploaded.name or "").lower()
|
||||
if not name.endswith(".ini"):
|
||||
return Response({"error": "Only .ini files are allowed"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
if uploaded.size and uploaded.size > 1024 * 1024:
|
||||
return Response({"error": "File too large (limit 1MB)"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
dest_dir = os.path.join(settings.MEDIA_ROOT, "comskip")
|
||||
os.makedirs(dest_dir, exist_ok=True)
|
||||
dest_path = os.path.join(dest_dir, "comskip.ini")
|
||||
|
||||
try:
|
||||
with open(dest_path, "wb") as dest:
|
||||
for chunk in uploaded.chunks():
|
||||
dest.write(chunk)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to save uploaded comskip.ini: {e}")
|
||||
return Response({"error": "Unable to save file"}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||
|
||||
# Persist path setting so DVR processing picks it up immediately
|
||||
CoreSettings.set_dvr_comskip_custom_path(dest_path)
|
||||
|
||||
return Response({"success": True, "path": dest_path, "exists": os.path.exists(dest_path)})
|
||||
|
||||
|
||||
class BulkDeleteUpcomingRecordingsAPIView(APIView):
|
||||
"""Delete all upcoming (future) recordings."""
|
||||
def get_permissions(self):
|
||||
|
|
|
|||
31
apps/channels/migrations/0026_recurringrecordingrule.py
Normal file
31
apps/channels/migrations/0026_recurringrecordingrule.py
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
# Generated by Django 5.0.14 on 2025-09-18 14:56
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('dispatcharr_channels', '0025_alter_channelgroupm3uaccount_custom_properties_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='RecurringRecordingRule',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('days_of_week', models.JSONField(default=list)),
|
||||
('start_time', models.TimeField()),
|
||||
('end_time', models.TimeField()),
|
||||
('enabled', models.BooleanField(default=True)),
|
||||
('name', models.CharField(blank=True, max_length=255)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('channel', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='recurring_rules', to='dispatcharr_channels.channel')),
|
||||
],
|
||||
options={
|
||||
'ordering': ['channel', 'start_time'],
|
||||
},
|
||||
),
|
||||
]
|
||||
|
|
@ -601,3 +601,33 @@ class Recording(models.Model):
|
|||
|
||||
def __str__(self):
|
||||
return f"{self.channel.name} - {self.start_time} to {self.end_time}"
|
||||
|
||||
|
||||
class RecurringRecordingRule(models.Model):
|
||||
"""Rule describing a recurring manual DVR schedule."""
|
||||
|
||||
channel = models.ForeignKey(
|
||||
"Channel",
|
||||
on_delete=models.CASCADE,
|
||||
related_name="recurring_rules",
|
||||
)
|
||||
days_of_week = models.JSONField(default=list)
|
||||
start_time = models.TimeField()
|
||||
end_time = models.TimeField()
|
||||
enabled = models.BooleanField(default=True)
|
||||
name = models.CharField(max_length=255, blank=True)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ["channel", "start_time"]
|
||||
|
||||
def __str__(self):
|
||||
channel_name = getattr(self.channel, "name", str(self.channel_id))
|
||||
return f"Recurring rule for {channel_name}"
|
||||
|
||||
def cleaned_days(self):
|
||||
try:
|
||||
return sorted({int(d) for d in (self.days_of_week or []) if 0 <= int(d) <= 6})
|
||||
except Exception:
|
||||
return []
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ from .models import (
|
|||
ChannelProfile,
|
||||
ChannelProfileMembership,
|
||||
Recording,
|
||||
RecurringRecordingRule,
|
||||
)
|
||||
from apps.epg.serializers import EPGDataSerializer
|
||||
from core.models import StreamProfile
|
||||
|
|
@ -454,6 +455,13 @@ class RecordingSerializer(serializers.ModelSerializer):
|
|||
start_time = data.get("start_time")
|
||||
end_time = data.get("end_time")
|
||||
|
||||
if start_time and timezone.is_naive(start_time):
|
||||
start_time = timezone.make_aware(start_time, timezone.get_current_timezone())
|
||||
data["start_time"] = start_time
|
||||
if end_time and timezone.is_naive(end_time):
|
||||
end_time = timezone.make_aware(end_time, timezone.get_current_timezone())
|
||||
data["end_time"] = end_time
|
||||
|
||||
# If this is an EPG-based recording (program provided), apply global pre/post offsets
|
||||
try:
|
||||
cp = data.get("custom_properties") or {}
|
||||
|
|
@ -497,3 +505,31 @@ class RecordingSerializer(serializers.ModelSerializer):
|
|||
raise serializers.ValidationError("End time must be after start time.")
|
||||
|
||||
return data
|
||||
|
||||
|
||||
class RecurringRecordingRuleSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = RecurringRecordingRule
|
||||
fields = "__all__"
|
||||
read_only_fields = ["created_at", "updated_at"]
|
||||
|
||||
def validate_days_of_week(self, value):
|
||||
if not value:
|
||||
raise serializers.ValidationError("Select at least one day of the week")
|
||||
cleaned = []
|
||||
for entry in value:
|
||||
try:
|
||||
iv = int(entry)
|
||||
except (TypeError, ValueError):
|
||||
raise serializers.ValidationError("Days of week must be integers 0-6")
|
||||
if iv < 0 or iv > 6:
|
||||
raise serializers.ValidationError("Days of week must be between 0 (Monday) and 6 (Sunday)")
|
||||
cleaned.append(iv)
|
||||
return sorted(set(cleaned))
|
||||
|
||||
def validate(self, attrs):
|
||||
start = attrs.get("start_time") or getattr(self.instance, "start_time", None)
|
||||
end = attrs.get("end_time") or getattr(self.instance, "end_time", None)
|
||||
if start and end and end <= start:
|
||||
raise serializers.ValidationError("End time must be after start time")
|
||||
return super().validate(attrs)
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import requests
|
|||
import time
|
||||
import json
|
||||
import subprocess
|
||||
import signal
|
||||
from datetime import datetime, timedelta
|
||||
import gc
|
||||
|
||||
|
|
@ -1095,6 +1096,130 @@ def reschedule_upcoming_recordings_for_offset_change():
|
|||
return reschedule_upcoming_recordings_for_offset_change_impl()
|
||||
|
||||
|
||||
def _notify_recordings_refresh():
|
||||
try:
|
||||
from core.utils import send_websocket_update
|
||||
send_websocket_update('updates', 'update', {"success": True, "type": "recordings_refreshed"})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def purge_recurring_rule_impl(rule_id: int) -> int:
|
||||
"""Remove all future recordings created by a recurring rule."""
|
||||
from django.utils import timezone
|
||||
from .models import Recording
|
||||
|
||||
now = timezone.now()
|
||||
try:
|
||||
removed, _ = Recording.objects.filter(
|
||||
start_time__gte=now,
|
||||
custom_properties__rule__id=rule_id,
|
||||
).delete()
|
||||
except Exception:
|
||||
removed = 0
|
||||
if removed:
|
||||
_notify_recordings_refresh()
|
||||
return removed
|
||||
|
||||
|
||||
def sync_recurring_rule_impl(rule_id: int, drop_existing: bool = True, horizon_days: int = 14) -> int:
|
||||
"""Ensure recordings exist for a recurring rule within the scheduling horizon."""
|
||||
from django.utils import timezone
|
||||
from .models import RecurringRecordingRule, Recording
|
||||
|
||||
rule = RecurringRecordingRule.objects.filter(pk=rule_id).select_related("channel").first()
|
||||
now = timezone.now()
|
||||
removed = 0
|
||||
if drop_existing:
|
||||
removed = purge_recurring_rule_impl(rule_id)
|
||||
|
||||
if not rule or not rule.enabled:
|
||||
return 0
|
||||
|
||||
days = rule.cleaned_days()
|
||||
if not days:
|
||||
return 0
|
||||
|
||||
tz = timezone.get_current_timezone()
|
||||
horizon = now + timedelta(days=horizon_days)
|
||||
start_date = now.date()
|
||||
end_date = horizon.date()
|
||||
total_created = 0
|
||||
|
||||
for offset in range((end_date - start_date).days + 1):
|
||||
target_date = start_date + timedelta(days=offset)
|
||||
if target_date.weekday() not in days:
|
||||
continue
|
||||
try:
|
||||
start_dt = timezone.make_aware(datetime.combine(target_date, rule.start_time), tz)
|
||||
end_dt = timezone.make_aware(datetime.combine(target_date, rule.end_time), tz)
|
||||
except Exception:
|
||||
continue
|
||||
if end_dt <= start_dt or start_dt <= now:
|
||||
continue
|
||||
exists = Recording.objects.filter(
|
||||
channel=rule.channel,
|
||||
start_time=start_dt,
|
||||
custom_properties__rule__id=rule.id,
|
||||
).exists()
|
||||
if exists:
|
||||
continue
|
||||
description = rule.name or f"Recurring recording for {rule.channel.name}"
|
||||
cp = {
|
||||
"rule": {
|
||||
"type": "recurring",
|
||||
"id": rule.id,
|
||||
"days_of_week": days,
|
||||
"name": rule.name or "",
|
||||
},
|
||||
"status": "scheduled",
|
||||
"description": description,
|
||||
"program": {
|
||||
"title": rule.name or rule.channel.name,
|
||||
"description": description,
|
||||
"start_time": start_dt.isoformat(),
|
||||
"end_time": end_dt.isoformat(),
|
||||
},
|
||||
}
|
||||
try:
|
||||
Recording.objects.create(
|
||||
channel=rule.channel,
|
||||
start_time=start_dt,
|
||||
end_time=end_dt,
|
||||
custom_properties=cp,
|
||||
)
|
||||
total_created += 1
|
||||
except Exception as err:
|
||||
logger.warning(f"Failed to create recurring recording for rule {rule.id}: {err}")
|
||||
|
||||
if removed or total_created:
|
||||
_notify_recordings_refresh()
|
||||
|
||||
return total_created
|
||||
|
||||
|
||||
@shared_task
|
||||
def rebuild_recurring_rule(rule_id: int, horizon_days: int = 14):
|
||||
return sync_recurring_rule_impl(rule_id, drop_existing=True, horizon_days=horizon_days)
|
||||
|
||||
|
||||
@shared_task
|
||||
def maintain_recurring_recordings():
|
||||
from .models import RecurringRecordingRule
|
||||
|
||||
total = 0
|
||||
for rule_id in RecurringRecordingRule.objects.filter(enabled=True).values_list("id", flat=True):
|
||||
try:
|
||||
total += sync_recurring_rule_impl(rule_id, drop_existing=False)
|
||||
except Exception as err:
|
||||
logger.warning(f"Recurring rule maintenance failed for {rule_id}: {err}")
|
||||
return total
|
||||
|
||||
|
||||
@shared_task
|
||||
def purge_recurring_rule(rule_id: int):
|
||||
return purge_recurring_rule_impl(rule_id)
|
||||
|
||||
@shared_task
|
||||
def _safe_name(s):
|
||||
try:
|
||||
|
|
@ -1817,6 +1942,7 @@ def comskip_process_recording(recording_id: int):
|
|||
Safe to call even if comskip is not installed; stores status in custom_properties.comskip.
|
||||
"""
|
||||
import shutil
|
||||
from django.db import DatabaseError
|
||||
from .models import Recording
|
||||
# Helper to broadcast status over websocket
|
||||
def _ws(status: str, extra: dict | None = None):
|
||||
|
|
@ -1834,7 +1960,33 @@ def comskip_process_recording(recording_id: int):
|
|||
except Recording.DoesNotExist:
|
||||
return "not_found"
|
||||
|
||||
cp = rec.custom_properties or {}
|
||||
cp = rec.custom_properties.copy() if isinstance(rec.custom_properties, dict) else {}
|
||||
|
||||
def _persist_custom_properties():
|
||||
"""Persist updated custom_properties without raising if the row disappeared."""
|
||||
try:
|
||||
updated = Recording.objects.filter(pk=recording_id).update(custom_properties=cp)
|
||||
if not updated:
|
||||
logger.warning(
|
||||
"Recording %s vanished before comskip status could be saved",
|
||||
recording_id,
|
||||
)
|
||||
return False
|
||||
except DatabaseError as db_err:
|
||||
logger.warning(
|
||||
"Failed to persist comskip status for recording %s: %s",
|
||||
recording_id,
|
||||
db_err,
|
||||
)
|
||||
return False
|
||||
except Exception as unexpected:
|
||||
logger.warning(
|
||||
"Unexpected error while saving comskip status for recording %s: %s",
|
||||
recording_id,
|
||||
unexpected,
|
||||
)
|
||||
return False
|
||||
return True
|
||||
file_path = (cp or {}).get("file_path")
|
||||
if not file_path or not os.path.exists(file_path):
|
||||
return "no_file"
|
||||
|
|
@ -1845,8 +1997,7 @@ def comskip_process_recording(recording_id: int):
|
|||
comskip_bin = shutil.which("comskip")
|
||||
if not comskip_bin:
|
||||
cp["comskip"] = {"status": "skipped", "reason": "comskip_not_installed"}
|
||||
rec.custom_properties = cp
|
||||
rec.save(update_fields=["custom_properties"])
|
||||
_persist_custom_properties()
|
||||
_ws('skipped', {"reason": "comskip_not_installed"})
|
||||
return "comskip_missing"
|
||||
|
||||
|
|
@ -1858,24 +2009,59 @@ def comskip_process_recording(recording_id: int):
|
|||
|
||||
try:
|
||||
cmd = [comskip_bin, "--output", os.path.dirname(file_path)]
|
||||
# Prefer system ini if present to squelch warning and get sane defaults
|
||||
for ini_path in ("/etc/comskip/comskip.ini", "/app/docker/comskip.ini"):
|
||||
if os.path.exists(ini_path):
|
||||
# Prefer user-specified INI, fall back to known defaults
|
||||
ini_candidates = []
|
||||
try:
|
||||
custom_ini = CoreSettings.get_dvr_comskip_custom_path()
|
||||
if custom_ini:
|
||||
ini_candidates.append(custom_ini)
|
||||
except Exception as ini_err:
|
||||
logger.debug(f"Unable to load custom comskip.ini path: {ini_err}")
|
||||
ini_candidates.extend(["/etc/comskip/comskip.ini", "/app/docker/comskip.ini"])
|
||||
selected_ini = None
|
||||
for ini_path in ini_candidates:
|
||||
if ini_path and os.path.exists(ini_path):
|
||||
selected_ini = ini_path
|
||||
cmd.extend([f"--ini={ini_path}"])
|
||||
break
|
||||
cmd.append(file_path)
|
||||
subprocess.run(cmd, check=True)
|
||||
subprocess.run(
|
||||
cmd,
|
||||
check=True,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
)
|
||||
except subprocess.CalledProcessError as e:
|
||||
stderr_tail = (e.stderr or "").strip().splitlines()
|
||||
stderr_tail = stderr_tail[-5:] if stderr_tail else []
|
||||
detail = {
|
||||
"status": "error",
|
||||
"reason": "comskip_failed",
|
||||
"returncode": e.returncode,
|
||||
}
|
||||
if e.returncode and e.returncode < 0:
|
||||
try:
|
||||
detail["signal"] = signal.Signals(-e.returncode).name
|
||||
except Exception:
|
||||
detail["signal"] = f"signal_{-e.returncode}"
|
||||
if stderr_tail:
|
||||
detail["stderr"] = "\n".join(stderr_tail)
|
||||
if selected_ini:
|
||||
detail["ini_path"] = selected_ini
|
||||
cp["comskip"] = detail
|
||||
_persist_custom_properties()
|
||||
_ws('error', {"reason": "comskip_failed", "returncode": e.returncode})
|
||||
return "comskip_failed"
|
||||
except Exception as e:
|
||||
cp["comskip"] = {"status": "error", "reason": f"comskip_failed: {e}"}
|
||||
rec.custom_properties = cp
|
||||
rec.save(update_fields=["custom_properties"])
|
||||
_persist_custom_properties()
|
||||
_ws('error', {"reason": str(e)})
|
||||
return "comskip_failed"
|
||||
|
||||
if not os.path.exists(edl_path):
|
||||
cp["comskip"] = {"status": "error", "reason": "edl_not_found"}
|
||||
rec.custom_properties = cp
|
||||
rec.save(update_fields=["custom_properties"])
|
||||
_persist_custom_properties()
|
||||
_ws('error', {"reason": "edl_not_found"})
|
||||
return "no_edl"
|
||||
|
||||
|
|
@ -1893,8 +2079,7 @@ def comskip_process_recording(recording_id: int):
|
|||
duration = _ffprobe_duration(file_path)
|
||||
if duration is None:
|
||||
cp["comskip"] = {"status": "error", "reason": "duration_unknown"}
|
||||
rec.custom_properties = cp
|
||||
rec.save(update_fields=["custom_properties"])
|
||||
_persist_custom_properties()
|
||||
_ws('error', {"reason": "duration_unknown"})
|
||||
return "no_duration"
|
||||
|
||||
|
|
@ -1923,9 +2108,14 @@ def comskip_process_recording(recording_id: int):
|
|||
keep.append((cur, duration))
|
||||
|
||||
if not commercials or sum((e - s) for s, e in commercials) <= 0.5:
|
||||
cp["comskip"] = {"status": "completed", "skipped": True, "edl": os.path.basename(edl_path)}
|
||||
rec.custom_properties = cp
|
||||
rec.save(update_fields=["custom_properties"])
|
||||
cp["comskip"] = {
|
||||
"status": "completed",
|
||||
"skipped": True,
|
||||
"edl": os.path.basename(edl_path),
|
||||
}
|
||||
if selected_ini:
|
||||
cp["comskip"]["ini_path"] = selected_ini
|
||||
_persist_custom_properties()
|
||||
_ws('skipped', {"reason": "no_commercials", "commercials": 0})
|
||||
return "no_commercials"
|
||||
|
||||
|
|
@ -1975,14 +2165,14 @@ def comskip_process_recording(recording_id: int):
|
|||
"segments_kept": len(parts),
|
||||
"commercials": len(commercials),
|
||||
}
|
||||
rec.custom_properties = cp
|
||||
rec.save(update_fields=["custom_properties"])
|
||||
if selected_ini:
|
||||
cp["comskip"]["ini_path"] = selected_ini
|
||||
_persist_custom_properties()
|
||||
_ws('completed', {"commercials": len(commercials), "segments_kept": len(parts)})
|
||||
return "ok"
|
||||
except Exception as e:
|
||||
cp["comskip"] = {"status": "error", "reason": str(e)}
|
||||
rec.custom_properties = cp
|
||||
rec.save(update_fields=["custom_properties"])
|
||||
_persist_custom_properties()
|
||||
_ws('error', {"reason": str(e)})
|
||||
return f"error:{e}"
|
||||
def _resolve_poster_for_program(channel_name, program):
|
||||
|
|
|
|||
0
apps/channels/tests/__init__.py
Normal file
0
apps/channels/tests/__init__.py
Normal file
40
apps/channels/tests/test_recurring_rules.py
Normal file
40
apps/channels/tests/test_recurring_rules.py
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
from datetime import datetime, timedelta
|
||||
from django.test import TestCase
|
||||
from django.utils import timezone
|
||||
|
||||
from apps.channels.models import Channel, RecurringRecordingRule, Recording
|
||||
from apps.channels.tasks import sync_recurring_rule_impl, purge_recurring_rule_impl
|
||||
|
||||
|
||||
class RecurringRecordingRuleTasksTests(TestCase):
|
||||
def test_sync_recurring_rule_creates_and_purges_recordings(self):
|
||||
now = timezone.now()
|
||||
channel = Channel.objects.create(channel_number=1, name='Test Channel')
|
||||
|
||||
start_time = (now + timedelta(minutes=15)).time().replace(second=0, microsecond=0)
|
||||
end_time = (now + timedelta(minutes=75)).time().replace(second=0, microsecond=0)
|
||||
|
||||
rule = RecurringRecordingRule.objects.create(
|
||||
channel=channel,
|
||||
days_of_week=[now.weekday()],
|
||||
start_time=start_time,
|
||||
end_time=end_time,
|
||||
)
|
||||
|
||||
created = sync_recurring_rule_impl(rule.id, drop_existing=True, horizon_days=1)
|
||||
self.assertEqual(created, 1)
|
||||
|
||||
recording = Recording.objects.filter(custom_properties__rule__id=rule.id).first()
|
||||
self.assertIsNotNone(recording)
|
||||
self.assertEqual(recording.channel, channel)
|
||||
self.assertEqual(recording.custom_properties.get('rule', {}).get('id'), rule.id)
|
||||
|
||||
expected_start = timezone.make_aware(
|
||||
datetime.combine(recording.start_time.date(), start_time),
|
||||
timezone.get_current_timezone(),
|
||||
)
|
||||
self.assertLess(abs((recording.start_time - expected_start).total_seconds()), 60)
|
||||
|
||||
removed = purge_recurring_rule_impl(rule.id)
|
||||
self.assertEqual(removed, 1)
|
||||
self.assertFalse(Recording.objects.filter(custom_properties__rule__id=rule.id).exists())
|
||||
|
|
@ -158,6 +158,7 @@ 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_COMSKIP_CUSTOM_PATH_KEY = slugify("DVR Comskip Custom Path")
|
||||
DVR_PRE_OFFSET_MINUTES_KEY = slugify("DVR Pre-Offset Minutes")
|
||||
DVR_POST_OFFSET_MINUTES_KEY = slugify("DVR Post-Offset Minutes")
|
||||
|
||||
|
|
@ -274,6 +275,27 @@ class CoreSettings(models.Model):
|
|||
except cls.DoesNotExist:
|
||||
return False
|
||||
|
||||
@classmethod
|
||||
def get_dvr_comskip_custom_path(cls):
|
||||
"""Return configured comskip.ini path or empty string if unset."""
|
||||
try:
|
||||
return cls.objects.get(key=DVR_COMSKIP_CUSTOM_PATH_KEY).value
|
||||
except cls.DoesNotExist:
|
||||
return ""
|
||||
|
||||
@classmethod
|
||||
def set_dvr_comskip_custom_path(cls, path: str | None):
|
||||
"""Persist the comskip.ini path setting, normalizing nulls to empty string."""
|
||||
value = (path or "").strip()
|
||||
obj, _ = cls.objects.get_or_create(
|
||||
key=DVR_COMSKIP_CUSTOM_PATH_KEY,
|
||||
defaults={"name": "DVR Comskip Custom Path", "value": value},
|
||||
)
|
||||
if obj.value != value:
|
||||
obj.value = value
|
||||
obj.save(update_fields=["value"])
|
||||
return value
|
||||
|
||||
@classmethod
|
||||
def get_dvr_pre_offset_minutes(cls):
|
||||
"""Minutes to start recording before scheduled start (default 0)."""
|
||||
|
|
|
|||
|
|
@ -211,6 +211,10 @@ CELERY_BEAT_SCHEDULE = {
|
|||
"task": "core.tasks.scan_and_process_files", # Direct task call
|
||||
"schedule": 20.0, # Every 20 seconds
|
||||
},
|
||||
"maintain-recurring-recordings": {
|
||||
"task": "apps.channels.tasks.maintain_recurring_recordings",
|
||||
"schedule": 3600.0, # Once an hour ensure recurring schedules stay ahead
|
||||
},
|
||||
}
|
||||
|
||||
MEDIA_ROOT = BASE_DIR / "media"
|
||||
|
|
|
|||
|
|
@ -1873,6 +1873,70 @@ export default class API {
|
|||
}
|
||||
}
|
||||
|
||||
static async getComskipConfig() {
|
||||
try {
|
||||
return await request(`${host}/api/channels/dvr/comskip-config/`);
|
||||
} catch (e) {
|
||||
errorNotification('Failed to retrieve comskip configuration', e);
|
||||
}
|
||||
}
|
||||
|
||||
static async uploadComskipIni(file) {
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
return await request(`${host}/api/channels/dvr/comskip-config/`, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
} catch (e) {
|
||||
errorNotification('Failed to upload comskip.ini', e);
|
||||
}
|
||||
}
|
||||
|
||||
static async listRecurringRules() {
|
||||
try {
|
||||
const response = await request(`${host}/api/channels/recurring-rules/`);
|
||||
return response;
|
||||
} catch (e) {
|
||||
errorNotification('Failed to retrieve recurring DVR rules', e);
|
||||
}
|
||||
}
|
||||
|
||||
static async createRecurringRule(payload) {
|
||||
try {
|
||||
const response = await request(`${host}/api/channels/recurring-rules/`, {
|
||||
method: 'POST',
|
||||
body: payload,
|
||||
});
|
||||
return response;
|
||||
} catch (e) {
|
||||
errorNotification('Failed to create recurring DVR rule', e);
|
||||
}
|
||||
}
|
||||
|
||||
static async updateRecurringRule(ruleId, payload) {
|
||||
try {
|
||||
const response = await request(`${host}/api/channels/recurring-rules/${ruleId}/`, {
|
||||
method: 'PATCH',
|
||||
body: payload,
|
||||
});
|
||||
return response;
|
||||
} catch (e) {
|
||||
errorNotification(`Failed to update recurring rule ${ruleId}`, e);
|
||||
}
|
||||
}
|
||||
|
||||
static async deleteRecurringRule(ruleId) {
|
||||
try {
|
||||
await request(`${host}/api/channels/recurring-rules/${ruleId}/`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
} catch (e) {
|
||||
errorNotification(`Failed to delete recurring rule ${ruleId}`, e);
|
||||
}
|
||||
}
|
||||
|
||||
static async deleteRecording(id) {
|
||||
try {
|
||||
await request(`${host}/api/channels/recordings/${id}/`, { method: 'DELETE' });
|
||||
|
|
|
|||
|
|
@ -1,117 +1,300 @@
|
|||
// Modal.js
|
||||
import React from 'react';
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import API from '../../api';
|
||||
import { Button, Modal, Flex, Select, Alert } from '@mantine/core';
|
||||
import useChannelsStore from '../../store/channels';
|
||||
import { DateTimePicker } from '@mantine/dates';
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Modal,
|
||||
Select,
|
||||
Stack,
|
||||
SegmentedControl,
|
||||
MultiSelect,
|
||||
Group,
|
||||
Text,
|
||||
} from '@mantine/core';
|
||||
import { DateTimePicker, TimeInput } from '@mantine/dates';
|
||||
import { CircleAlert } from 'lucide-react';
|
||||
import { isNotEmpty, useForm } from '@mantine/form';
|
||||
import useChannelsStore from '../../store/channels';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
|
||||
const DVR = ({ recording = null, channel = null, isOpen, onClose }) => {
|
||||
const DAY_OPTIONS = [
|
||||
{ value: '6', label: 'Sun' },
|
||||
{ value: '0', label: 'Mon' },
|
||||
{ value: '1', label: 'Tue' },
|
||||
{ value: '2', label: 'Wed' },
|
||||
{ value: '3', label: 'Thu' },
|
||||
{ value: '4', label: 'Fri' },
|
||||
{ value: '5', label: 'Sat' },
|
||||
];
|
||||
|
||||
const asDate = (value) => {
|
||||
if (!value) return null;
|
||||
if (value instanceof Date) return value;
|
||||
const parsed = new Date(value);
|
||||
return Number.isNaN(parsed.getTime()) ? null : parsed;
|
||||
};
|
||||
|
||||
const toIsoIfDate = (value) => {
|
||||
const dt = asDate(value);
|
||||
return dt ? dt.toISOString() : value;
|
||||
};
|
||||
|
||||
const toTimeString = (value) => {
|
||||
const dt = asDate(value);
|
||||
if (!dt) return '';
|
||||
const hours = String(dt.getHours()).padStart(2, '0');
|
||||
const minutes = String(dt.getMinutes()).padStart(2, '0');
|
||||
return `${hours}:${minutes}`;
|
||||
};
|
||||
|
||||
const createRoundedDate = (minutesAhead = 0) => {
|
||||
const dt = new Date();
|
||||
dt.setSeconds(0);
|
||||
dt.setMilliseconds(0);
|
||||
dt.setMinutes(Math.ceil(dt.getMinutes() / 30) * 30);
|
||||
if (minutesAhead) {
|
||||
dt.setMinutes(dt.getMinutes() + minutesAhead);
|
||||
}
|
||||
return dt;
|
||||
};
|
||||
|
||||
const RecordingModal = ({ recording = null, channel = null, isOpen, onClose }) => {
|
||||
const channels = useChannelsStore((s) => s.channels);
|
||||
const fetchRecordings = useChannelsStore((s) => s.fetchRecordings);
|
||||
const fetchRecurringRules = useChannelsStore((s) => s.fetchRecurringRules);
|
||||
|
||||
let startTime = new Date();
|
||||
startTime.setMinutes(Math.ceil(startTime.getMinutes() / 30) * 30);
|
||||
startTime.setSeconds(0);
|
||||
startTime.setMilliseconds(0);
|
||||
const [mode, setMode] = useState('single');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
let endTime = new Date();
|
||||
endTime.setMinutes(Math.ceil(endTime.getMinutes() / 30) * 30);
|
||||
endTime.setSeconds(0);
|
||||
endTime.setMilliseconds(0);
|
||||
endTime.setHours(endTime.getHours() + 1);
|
||||
const defaultStart = createRoundedDate();
|
||||
const defaultEnd = createRoundedDate(60);
|
||||
|
||||
const form = useForm({
|
||||
mode: 'uncontrolled',
|
||||
const singleForm = useForm({
|
||||
mode: 'controlled',
|
||||
initialValues: {
|
||||
channel_id: recording
|
||||
? recording.channel_id
|
||||
? `${recording.channel}`
|
||||
: channel
|
||||
? `${channel.id}`
|
||||
: '',
|
||||
start_time: recording ? recording.start_time : startTime,
|
||||
end_time: recording ? recording.end_time : endTime,
|
||||
start_time: recording ? asDate(recording.start_time) || defaultStart : defaultStart,
|
||||
end_time: recording ? asDate(recording.end_time) || defaultEnd : defaultEnd,
|
||||
},
|
||||
|
||||
validate: {
|
||||
channel_id: isNotEmpty('Select a channel'),
|
||||
start_time: isNotEmpty('Select a start time'),
|
||||
end_time: isNotEmpty('Select an end time'),
|
||||
end_time: (value, values) => {
|
||||
const start = asDate(values.start_time);
|
||||
const end = asDate(value);
|
||||
if (!end) return 'Select an end time';
|
||||
if (start && end <= start) return 'End time must be after start time';
|
||||
return null;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = async () => {
|
||||
const { channel_id, ...values } = form.getValues();
|
||||
const recurringForm = useForm({
|
||||
mode: 'controlled',
|
||||
initialValues: {
|
||||
channel_id: channel ? `${channel.id}` : '',
|
||||
days_of_week: [],
|
||||
start_time: defaultStart,
|
||||
end_time: defaultEnd,
|
||||
},
|
||||
validate: {
|
||||
channel_id: isNotEmpty('Select a channel'),
|
||||
days_of_week: (value) => (value && value.length ? null : 'Pick at least one day'),
|
||||
start_time: isNotEmpty('Select a start time'),
|
||||
end_time: (value, values) => {
|
||||
const start = asDate(values.start_time);
|
||||
const end = asDate(value);
|
||||
if (!end) return 'Select an end time';
|
||||
if (start && end <= start) return 'End time must be after start time';
|
||||
return null;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
console.log(values);
|
||||
|
||||
await API.createRecording({
|
||||
...values,
|
||||
channel: channel_id,
|
||||
const channelOptions = useMemo(() => {
|
||||
const list = Object.values(channels || {});
|
||||
list.sort((a, b) => {
|
||||
const aNum = Number(a.channel_number) || 0;
|
||||
const bNum = Number(b.channel_number) || 0;
|
||||
if (aNum === bNum) {
|
||||
return (a.name || '').localeCompare(b.name || '');
|
||||
}
|
||||
return aNum - bNum;
|
||||
});
|
||||
return list.map((item) => ({ value: `${item.id}`, label: item.name || `Channel ${item.id}` }));
|
||||
}, [channels]);
|
||||
|
||||
form.reset();
|
||||
onClose();
|
||||
const resetForms = () => {
|
||||
singleForm.reset();
|
||||
recurringForm.reset();
|
||||
setMode('single');
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
resetForms();
|
||||
onClose?.();
|
||||
};
|
||||
|
||||
const handleSingleSubmit = async (values) => {
|
||||
try {
|
||||
setSubmitting(true);
|
||||
await API.createRecording({
|
||||
channel: values.channel_id,
|
||||
start_time: toIsoIfDate(values.start_time),
|
||||
end_time: toIsoIfDate(values.end_time),
|
||||
});
|
||||
await fetchRecordings();
|
||||
notifications.show({
|
||||
title: 'Recording scheduled',
|
||||
message: 'One-time recording added to DVR queue',
|
||||
color: 'green',
|
||||
autoClose: 2500,
|
||||
});
|
||||
handleClose();
|
||||
} catch (error) {
|
||||
console.error('Failed to create recording', error);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRecurringSubmit = async (values) => {
|
||||
try {
|
||||
setSubmitting(true);
|
||||
await API.createRecurringRule({
|
||||
channel: values.channel_id,
|
||||
days_of_week: (values.days_of_week || []).map((d) => Number(d)),
|
||||
start_time: toTimeString(values.start_time),
|
||||
end_time: toTimeString(values.end_time),
|
||||
});
|
||||
await Promise.all([fetchRecurringRules(), fetchRecordings()]);
|
||||
notifications.show({
|
||||
title: 'Recurring rule saved',
|
||||
message: 'Future slots will be scheduled automatically',
|
||||
color: 'green',
|
||||
autoClose: 2500,
|
||||
});
|
||||
handleClose();
|
||||
} catch (error) {
|
||||
console.error('Failed to create recurring rule', error);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const onSubmit = mode === 'single'
|
||||
? singleForm.onSubmit(handleSingleSubmit)
|
||||
: recurringForm.onSubmit(handleRecurringSubmit);
|
||||
|
||||
if (!isOpen) {
|
||||
return <></>;
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal opened={isOpen} onClose={onClose} title="Channel Recording">
|
||||
<Modal opened={isOpen} onClose={handleClose} title="Channel Recording">
|
||||
<Alert
|
||||
variant="light"
|
||||
color="yellow"
|
||||
title="Scheduling Conflicts"
|
||||
icon={<CircleAlert />}
|
||||
style={{ paddingBottom: 5 }}
|
||||
style={{ paddingBottom: 5, marginBottom: 12 }}
|
||||
>
|
||||
Recordings may fail if active streams or overlapping recordings use up
|
||||
all available streams
|
||||
Recordings may fail if active streams or overlapping recordings use up all available tuners.
|
||||
</Alert>
|
||||
|
||||
<form onSubmit={form.onSubmit(onSubmit)}>
|
||||
<Select
|
||||
{...form.getInputProps('channel_id')}
|
||||
label="Channel"
|
||||
key={form.key('channel_id')}
|
||||
searchable
|
||||
data={Object.values(channels).map((channel) => ({
|
||||
value: `${channel.id}`,
|
||||
label: channel.name,
|
||||
}))}
|
||||
<Stack gap="md">
|
||||
<SegmentedControl
|
||||
value={mode}
|
||||
onChange={setMode}
|
||||
data={[
|
||||
{ value: 'single', label: 'One-time' },
|
||||
{ value: 'recurring', label: 'Recurring' },
|
||||
]}
|
||||
/>
|
||||
|
||||
<DateTimePicker
|
||||
{...form.getInputProps('start_time')}
|
||||
key={form.key('start_time')}
|
||||
id="start_time"
|
||||
label="Start Time"
|
||||
valueFormat="M/DD/YYYY hh:mm A"
|
||||
/>
|
||||
<form onSubmit={onSubmit}>
|
||||
<Stack gap="md">
|
||||
{mode === 'single' ? (
|
||||
<Select
|
||||
{...singleForm.getInputProps('channel_id')}
|
||||
key={singleForm.key('channel_id')}
|
||||
label="Channel"
|
||||
placeholder="Select channel"
|
||||
searchable
|
||||
data={channelOptions}
|
||||
/>
|
||||
) : (
|
||||
<Select
|
||||
{...recurringForm.getInputProps('channel_id')}
|
||||
key={recurringForm.key('channel_id')}
|
||||
label="Channel"
|
||||
placeholder="Select channel"
|
||||
searchable
|
||||
data={channelOptions}
|
||||
/>
|
||||
)}
|
||||
|
||||
<DateTimePicker
|
||||
{...form.getInputProps('end_time')}
|
||||
key={form.key('end_time')}
|
||||
id="end_time"
|
||||
label="End Time"
|
||||
valueFormat="M/DD/YYYY hh:mm A"
|
||||
/>
|
||||
{mode === 'single' ? (
|
||||
<>
|
||||
<DateTimePicker
|
||||
{...singleForm.getInputProps('start_time')}
|
||||
key={singleForm.key('start_time')}
|
||||
label="Start"
|
||||
valueFormat="MMM D, YYYY hh:mm A"
|
||||
/>
|
||||
<DateTimePicker
|
||||
{...singleForm.getInputProps('end_time')}
|
||||
key={singleForm.key('end_time')}
|
||||
label="End"
|
||||
valueFormat="MMM D, YYYY hh:mm A"
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<MultiSelect
|
||||
{...recurringForm.getInputProps('days_of_week')}
|
||||
key={recurringForm.key('days_of_week')}
|
||||
label="Every"
|
||||
placeholder="Select days"
|
||||
data={DAY_OPTIONS}
|
||||
searchable
|
||||
clearable
|
||||
nothingFound="No match"
|
||||
/>
|
||||
<Group grow>
|
||||
<TimeInput
|
||||
{...recurringForm.getInputProps('start_time')}
|
||||
key={recurringForm.key('start_time')}
|
||||
label="Start time"
|
||||
withSeconds={false}
|
||||
/>
|
||||
<TimeInput
|
||||
{...recurringForm.getInputProps('end_time')}
|
||||
key={recurringForm.key('end_time')}
|
||||
label="End time"
|
||||
withSeconds={false}
|
||||
/>
|
||||
</Group>
|
||||
<Text c="dimmed" size="xs">
|
||||
Recurring recordings create upcoming events up to two weeks in advance.
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Flex mih={50} gap="xs" justify="flex-end" align="flex-end">
|
||||
<Button
|
||||
type="submit"
|
||||
variant="contained"
|
||||
size="small"
|
||||
disabled={form.submitting}
|
||||
>
|
||||
Submit
|
||||
</Button>
|
||||
</Flex>
|
||||
</form>
|
||||
<Group justify="flex-end">
|
||||
<Button type="submit" loading={submitting}>
|
||||
{mode === 'single' ? 'Schedule Recording' : 'Save Rule'}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</form>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default DVR;
|
||||
export default RecordingModal;
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import {
|
|||
Text,
|
||||
Title,
|
||||
Tooltip,
|
||||
Switch,
|
||||
useMantineTheme,
|
||||
} from '@mantine/core';
|
||||
import {
|
||||
|
|
@ -28,6 +29,7 @@ import {
|
|||
Timer,
|
||||
Users,
|
||||
Video,
|
||||
Trash2,
|
||||
} from 'lucide-react';
|
||||
import dayjs from 'dayjs';
|
||||
import duration from 'dayjs/plugin/duration';
|
||||
|
|
@ -42,10 +44,20 @@ import API from '../api';
|
|||
dayjs.extend(duration);
|
||||
dayjs.extend(relativeTime);
|
||||
|
||||
const RECURRING_DAY_OPTIONS = [
|
||||
{ value: 6, label: 'Sun' },
|
||||
{ value: 0, label: 'Mon' },
|
||||
{ value: 1, label: 'Tue' },
|
||||
{ value: 2, label: 'Wed' },
|
||||
{ value: 3, label: 'Thu' },
|
||||
{ value: 4, label: 'Fri' },
|
||||
{ value: 5, label: 'Sat' },
|
||||
];
|
||||
|
||||
// Short preview that triggers the details modal when clicked
|
||||
const RecordingSynopsis = ({ description, onOpen }) => {
|
||||
const truncated = description?.length > 140;
|
||||
const preview = truncated ? `${description.slice(0, 140).trim()}…` : description;
|
||||
const preview = truncated ? `${description.slice(0, 140).trim()}...` : description;
|
||||
if (!description) return null;
|
||||
return (
|
||||
<Text
|
||||
|
|
@ -61,16 +73,40 @@ const RecordingSynopsis = ({ description, onOpen }) => {
|
|||
);
|
||||
};
|
||||
|
||||
const RecordingDetailsModal = ({ opened, onClose, recording, channel, posterUrl, onWatchLive, onWatchRecording, env_mode }) => {
|
||||
if (!recording) return null;
|
||||
const formatRuleDays = (days) => {
|
||||
if (!Array.isArray(days) || days.length === 0) {
|
||||
return 'No days selected';
|
||||
}
|
||||
const normalized = new Set(days.map((d) => Number(d)));
|
||||
const ordered = RECURRING_DAY_OPTIONS.filter((opt) => normalized.has(opt.value));
|
||||
if (!ordered.length) {
|
||||
return 'No days selected';
|
||||
}
|
||||
return ordered.map((opt) => opt.label).join(', ');
|
||||
};
|
||||
|
||||
const customProps = recording.custom_properties || {};
|
||||
const formatRuleTime = (time) => {
|
||||
if (!time) return '';
|
||||
const parsed = dayjs(time, 'HH:mm:ss');
|
||||
if (!parsed.isValid()) {
|
||||
return time;
|
||||
}
|
||||
return parsed.format('h:mm A');
|
||||
};
|
||||
|
||||
const RecordingDetailsModal = ({ opened, onClose, recording, channel, posterUrl, onWatchLive, onWatchRecording, env_mode }) => {
|
||||
const allRecordings = useChannelsStore((s) => s.recordings);
|
||||
const channelMap = useChannelsStore((s) => s.channels);
|
||||
const [childOpen, setChildOpen] = React.useState(false);
|
||||
const [childRec, setChildRec] = React.useState(null);
|
||||
|
||||
const safeRecording = recording || {};
|
||||
const customProps = safeRecording.custom_properties || {};
|
||||
const program = customProps.program || {};
|
||||
const recordingName = program.title || 'Custom Recording';
|
||||
const subTitle = program.sub_title || '';
|
||||
const description = program.description || customProps.description || '';
|
||||
const start = dayjs(recording.start_time);
|
||||
const end = dayjs(recording.end_time);
|
||||
const start = dayjs(safeRecording.start_time);
|
||||
const end = dayjs(safeRecording.end_time);
|
||||
const stats = customProps.stream_info || {};
|
||||
|
||||
const statRows = [
|
||||
|
|
@ -99,12 +135,7 @@ const RecordingDetailsModal = ({ opened, onClose, recording, channel, posterUrl,
|
|||
}
|
||||
}
|
||||
|
||||
// If this card represented a grouped series (next of N), show a series modal listing episodes
|
||||
const allRecordings = useChannelsStore((s) => s.recordings);
|
||||
const channels = useChannelsStore((s) => s.channels);
|
||||
const [childOpen, setChildOpen] = React.useState(false);
|
||||
const [childRec, setChildRec] = React.useState(null);
|
||||
const isSeriesGroup = Boolean(recording._group_count && recording._group_count > 1);
|
||||
const isSeriesGroup = Boolean(safeRecording._group_count && safeRecording._group_count > 1);
|
||||
const upcomingEpisodes = React.useMemo(() => {
|
||||
if (!isSeriesGroup) return [];
|
||||
const arr = Array.isArray(allRecordings) ? allRecordings : Object.values(allRecordings || {});
|
||||
|
|
@ -141,6 +172,8 @@ const RecordingDetailsModal = ({ opened, onClose, recording, channel, posterUrl,
|
|||
return deduped.sort((a, b) => dayjs(a.start_time) - dayjs(b.start_time));
|
||||
}, [allRecordings, isSeriesGroup, program.tvg_id, program.title]);
|
||||
|
||||
if (!recording) return null;
|
||||
|
||||
const EpisodeRow = ({ rec }) => {
|
||||
const cp = rec.custom_properties || {};
|
||||
const pr = cp.program || {};
|
||||
|
|
@ -208,11 +241,11 @@ const RecordingDetailsModal = ({ opened, onClose, recording, channel, posterUrl,
|
|||
opened={childOpen}
|
||||
onClose={() => setChildOpen(false)}
|
||||
recording={childRec}
|
||||
channel={channels[childRec.channel]}
|
||||
channel={channelMap[childRec.channel]}
|
||||
posterUrl={(
|
||||
childRec.custom_properties?.poster_logo_id
|
||||
? `/api/channels/logos/${childRec.custom_properties.poster_logo_id}/cache/`
|
||||
: childRec.custom_properties?.poster_url || channels[childRec.channel]?.logo?.cache_url
|
||||
: childRec.custom_properties?.poster_url || channelMap[childRec.channel]?.logo?.cache_url
|
||||
) || '/logo.png'}
|
||||
env_mode={env_mode}
|
||||
onWatchLive={() => {
|
||||
|
|
@ -221,7 +254,7 @@ const RecordingDetailsModal = ({ opened, onClose, recording, channel, posterUrl,
|
|||
const s = dayjs(rec.start_time);
|
||||
const e = dayjs(rec.end_time);
|
||||
if (now.isAfter(s) && now.isBefore(e)) {
|
||||
const ch = channels[rec.channel];
|
||||
const ch = channelMap[rec.channel];
|
||||
if (!ch) return;
|
||||
let url = `/proxy/ts/stream/${ch.uuid}`;
|
||||
if (env_mode === 'dev') {
|
||||
|
|
@ -236,7 +269,7 @@ const RecordingDetailsModal = ({ opened, onClose, recording, channel, posterUrl,
|
|||
if (env_mode === 'dev' && fileUrl.startsWith('/')) {
|
||||
fileUrl = `${window.location.protocol}//${window.location.hostname}:5656${fileUrl}`;
|
||||
}
|
||||
useVideoStore.getState().showVideo(fileUrl, 'vod', { name: childRec.custom_properties?.program?.title || 'Recording', logo: { url: (childRec.custom_properties?.poster_logo_id ? `/api/channels/logos/${childRec.custom_properties.poster_logo_id}/cache/` : channels[childRec.channel]?.logo?.cache_url) || '/logo.png' } });
|
||||
useVideoStore.getState().showVideo(fileUrl, 'vod', { name: childRec.custom_properties?.program?.title || 'Recording', logo: { url: (childRec.custom_properties?.poster_logo_id ? `/api/channels/logos/${childRec.custom_properties.poster_logo_id}/cache/` : channelMap[childRec.channel]?.logo?.cache_url) || '/logo.png' } });
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
|
@ -289,7 +322,7 @@ const RecordingDetailsModal = ({ opened, onClose, recording, channel, posterUrl,
|
|||
);
|
||||
};
|
||||
|
||||
const RecordingCard = ({ recording, category, onOpenDetails }) => {
|
||||
const RecordingCard = ({ recording, onOpenDetails }) => {
|
||||
const channels = useChannelsStore((s) => s.channels);
|
||||
const env_mode = useSettingsStore((s) => s.environment.env_mode);
|
||||
const showVideo = useVideoStore((s) => s.showVideo);
|
||||
|
|
@ -438,6 +471,9 @@ const RecordingCard = ({ recording, category, onOpenDetails }) => {
|
|||
{isSeriesGroup && (
|
||||
<Badge color="teal" variant="filled">Series</Badge>
|
||||
)}
|
||||
{customProps?.rule?.type === 'recurring' && (
|
||||
<Badge color="blue" variant="light">Recurring</Badge>
|
||||
)}
|
||||
{seLabel && !isSeriesGroup && (
|
||||
<Badge color="gray" variant="light">{seLabel}</Badge>
|
||||
)}
|
||||
|
|
@ -586,10 +622,13 @@ const DVRPage = () => {
|
|||
const fetchRecordings = useChannelsStore((s) => s.fetchRecordings);
|
||||
const channels = useChannelsStore((s) => s.channels);
|
||||
const fetchChannels = useChannelsStore((s) => s.fetchChannels);
|
||||
const recurringRules = useChannelsStore((s) => s.recurringRules) || [];
|
||||
const fetchRecurringRules = useChannelsStore((s) => s.fetchRecurringRules);
|
||||
|
||||
const [recordingModalOpen, setRecordingModalOpen] = useState(false);
|
||||
const [detailsOpen, setDetailsOpen] = useState(false);
|
||||
const [detailsRecording, setDetailsRecording] = useState(null);
|
||||
const [busyRuleId, setBusyRuleId] = useState(null);
|
||||
|
||||
const openRecordingModal = () => {
|
||||
setRecordingModalOpen(true);
|
||||
|
|
@ -611,8 +650,45 @@ const DVRPage = () => {
|
|||
fetchChannels();
|
||||
}
|
||||
fetchRecordings();
|
||||
fetchRecurringRules();
|
||||
}, []);
|
||||
|
||||
const handleDeleteRule = async (ruleId) => {
|
||||
setBusyRuleId(ruleId);
|
||||
try {
|
||||
await API.deleteRecurringRule(ruleId);
|
||||
await Promise.all([fetchRecurringRules(), fetchRecordings()]);
|
||||
notifications.show({
|
||||
title: 'Recurring rule removed',
|
||||
message: 'Future recordings for this rule were cancelled',
|
||||
color: 'red',
|
||||
autoClose: 2500,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to delete recurring rule', error);
|
||||
} finally {
|
||||
setBusyRuleId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleRule = async (rule, enabled) => {
|
||||
setBusyRuleId(rule.id);
|
||||
try {
|
||||
await API.updateRecurringRule(rule.id, { enabled });
|
||||
await Promise.all([fetchRecurringRules(), fetchRecordings()]);
|
||||
notifications.show({
|
||||
title: enabled ? 'Recurring rule enabled' : 'Recurring rule paused',
|
||||
message: enabled ? 'Future occurrences will be scheduled automatically' : 'Upcoming recordings removed',
|
||||
color: enabled ? 'green' : 'yellow',
|
||||
autoClose: 2500,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to update recurring rule', error);
|
||||
} finally {
|
||||
setBusyRuleId(null);
|
||||
}
|
||||
};
|
||||
|
||||
// Re-render every second so time-based bucketing updates without a refresh
|
||||
const [now, setNow] = useState(dayjs());
|
||||
useEffect(() => {
|
||||
|
|
@ -705,6 +781,56 @@ const DVRPage = () => {
|
|||
New Recording
|
||||
</Button>
|
||||
<Stack gap="lg" style={{ paddingTop: 12 }}>
|
||||
<div>
|
||||
<Group justify="space-between" mb={8}>
|
||||
<Title order={4}>Recurring Rules</Title>
|
||||
<Badge color="blue.6">{recurringRules.length}</Badge>
|
||||
</Group>
|
||||
{recurringRules.length === 0 ? (
|
||||
<Text size="sm" c="dimmed">
|
||||
No recurring rules yet. Create one from the New Recording dialog.
|
||||
</Text>
|
||||
) : (
|
||||
<Stack gap="sm">
|
||||
{recurringRules.map((rule) => {
|
||||
const ch = channels?.[rule.channel];
|
||||
const channelName = ch?.name || `Channel ${rule.channel}`;
|
||||
const range = `${formatRuleTime(rule.start_time)} – ${formatRuleTime(rule.end_time)}`;
|
||||
const days = formatRuleDays(rule.days_of_week);
|
||||
return (
|
||||
<Card key={`rule-${rule.id}`} withBorder radius="md" padding="sm">
|
||||
<Group justify="space-between" align="center">
|
||||
<Stack gap={2} style={{ flex: 1 }}>
|
||||
<Group gap={6}>
|
||||
<Text fw={600}>{channelName}</Text>
|
||||
{!rule.enabled && <Badge color="gray" size="xs">Paused</Badge>}
|
||||
</Group>
|
||||
<Text size="sm" c="dimmed">{days} • {range}</Text>
|
||||
</Stack>
|
||||
<Group gap="xs">
|
||||
<Switch
|
||||
size="sm"
|
||||
checked={Boolean(rule.enabled)}
|
||||
onChange={(event) => handleToggleRule(rule, event.currentTarget.checked)}
|
||||
disabled={busyRuleId === rule.id}
|
||||
/>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="red"
|
||||
onClick={() => handleDeleteRule(rule.id)}
|
||||
disabled={busyRuleId === rule.id}
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
</Group>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Group justify="space-between" mb={8}>
|
||||
<Title order={4}>Currently Recording</Title>
|
||||
|
|
@ -712,7 +838,7 @@ const DVRPage = () => {
|
|||
</Group>
|
||||
<SimpleGrid cols={3} spacing="md" breakpoints={[{ maxWidth: '62rem', cols: 2 }, { maxWidth: '36rem', cols: 1 }]}>
|
||||
{inProgress.map((rec) => (
|
||||
<RecordingCard key={`rec-${rec.id}`} recording={rec} category="in_progress" onOpenDetails={openDetails} />
|
||||
<RecordingCard key={`rec-${rec.id}`} recording={rec} onOpenDetails={openDetails} />
|
||||
))}
|
||||
{inProgress.length === 0 && (
|
||||
<Text size="sm" c="dimmed">
|
||||
|
|
@ -729,7 +855,7 @@ const DVRPage = () => {
|
|||
</Group>
|
||||
<SimpleGrid cols={3} spacing="md" breakpoints={[{ maxWidth: '62rem', cols: 2 }, { maxWidth: '36rem', cols: 1 }]}>
|
||||
{upcoming.map((rec) => (
|
||||
<RecordingCard key={`rec-${rec.id}`} recording={rec} category="upcoming" onOpenDetails={openDetails} />
|
||||
<RecordingCard key={`rec-${rec.id}`} recording={rec} onOpenDetails={openDetails} />
|
||||
))}
|
||||
{upcoming.length === 0 && (
|
||||
<Text size="sm" c="dimmed">
|
||||
|
|
@ -746,7 +872,7 @@ const DVRPage = () => {
|
|||
</Group>
|
||||
<SimpleGrid cols={3} spacing="md" breakpoints={[{ maxWidth: '62rem', cols: 2 }, { maxWidth: '36rem', cols: 1 }]}>
|
||||
{completed.map((rec) => (
|
||||
<RecordingCard key={`rec-${rec.id}`} recording={rec} category="completed" onOpenDetails={openDetails} />
|
||||
<RecordingCard key={`rec-${rec.id}`} recording={rec} onOpenDetails={openDetails} />
|
||||
))}
|
||||
{completed.length === 0 && (
|
||||
<Text size="sm" c="dimmed">
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import {
|
|||
Center,
|
||||
Flex,
|
||||
Group,
|
||||
FileInput,
|
||||
MultiSelect,
|
||||
Select,
|
||||
Stack,
|
||||
|
|
@ -20,6 +21,7 @@ import {
|
|||
NumberInput,
|
||||
} from '@mantine/core';
|
||||
import { isNotEmpty, useForm } from '@mantine/form';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
import UserAgentsTable from '../components/tables/UserAgentsTable';
|
||||
import StreamProfilesTable from '../components/tables/StreamProfilesTable';
|
||||
import useLocalStorage from '../hooks/useLocalStorage';
|
||||
|
|
@ -57,6 +59,10 @@ const SettingsPage = () => {
|
|||
// Add a new state to track the dialog type
|
||||
const [rehashDialogType, setRehashDialogType] = useState(null); // 'save' or 'rehash'
|
||||
|
||||
const [comskipFile, setComskipFile] = useState(null);
|
||||
const [comskipUploadLoading, setComskipUploadLoading] = useState(false);
|
||||
const [comskipConfig, setComskipConfig] = useState({ path: '', exists: false });
|
||||
|
||||
// UI / local storage settings
|
||||
const [tableSize, setTableSize] = useLocalStorage('table-size', 'default');
|
||||
const [timeFormat, setTimeFormat] = useLocalStorage('time-format', '12h');
|
||||
|
|
@ -77,6 +83,7 @@ const SettingsPage = () => {
|
|||
'dvr-tv-fallback-template': '',
|
||||
'dvr-movie-fallback-template': '',
|
||||
'dvr-comskip-enabled': false,
|
||||
'dvr-comskip-custom-path': '',
|
||||
'dvr-pre-offset-minutes': 0,
|
||||
'dvr-post-offset-minutes': 0,
|
||||
},
|
||||
|
|
@ -155,6 +162,12 @@ const SettingsPage = () => {
|
|||
);
|
||||
|
||||
form.setValues(formValues);
|
||||
if (formValues['dvr-comskip-custom-path']) {
|
||||
setComskipConfig((prev) => ({
|
||||
path: formValues['dvr-comskip-custom-path'],
|
||||
exists: prev.exists,
|
||||
}));
|
||||
}
|
||||
|
||||
const networkAccessSettings = JSON.parse(
|
||||
settings['network-access'].value || '{}'
|
||||
|
|
@ -177,6 +190,26 @@ const SettingsPage = () => {
|
|||
}
|
||||
}, [settings]);
|
||||
|
||||
useEffect(() => {
|
||||
const loadComskipConfig = async () => {
|
||||
try {
|
||||
const response = await API.getComskipConfig();
|
||||
if (response) {
|
||||
setComskipConfig({
|
||||
path: response.path || '',
|
||||
exists: Boolean(response.exists),
|
||||
});
|
||||
if (response.path) {
|
||||
form.setFieldValue('dvr-comskip-custom-path', response.path);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load comskip config', error);
|
||||
}
|
||||
};
|
||||
loadComskipConfig();
|
||||
}, []);
|
||||
|
||||
const onSubmit = async () => {
|
||||
const values = form.getValues();
|
||||
const changedSettings = {};
|
||||
|
|
@ -258,6 +291,39 @@ const SettingsPage = () => {
|
|||
setProxySettingsSaved(true);
|
||||
};
|
||||
|
||||
const onComskipUpload = async () => {
|
||||
if (!comskipFile) {
|
||||
return;
|
||||
}
|
||||
|
||||
setComskipUploadLoading(true);
|
||||
try {
|
||||
const response = await API.uploadComskipIni(comskipFile);
|
||||
if (response?.path) {
|
||||
notifications.show({
|
||||
title: 'comskip.ini uploaded',
|
||||
message: response.path,
|
||||
autoClose: 3000,
|
||||
color: 'green',
|
||||
});
|
||||
form.setFieldValue('dvr-comskip-custom-path', response.path);
|
||||
useSettingsStore.getState().updateSetting({
|
||||
...(settings['dvr-comskip-custom-path'] || {
|
||||
key: 'dvr-comskip-custom-path',
|
||||
name: 'DVR Comskip Custom Path',
|
||||
}),
|
||||
value: response.path,
|
||||
});
|
||||
setComskipConfig({ path: response.path, exists: true });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to upload comskip.ini', error);
|
||||
} finally {
|
||||
setComskipUploadLoading(false);
|
||||
setComskipFile(null);
|
||||
}
|
||||
};
|
||||
|
||||
const resetProxySettingsToDefaults = () => {
|
||||
const defaultValues = {
|
||||
buffering_timeout: 15,
|
||||
|
|
@ -449,6 +515,44 @@ const SettingsPage = () => {
|
|||
'dvr-comskip-enabled'
|
||||
}
|
||||
/>
|
||||
<TextInput
|
||||
label="Custom comskip.ini path"
|
||||
description="Leave blank to use the built-in defaults."
|
||||
placeholder="/app/docker/comskip.ini"
|
||||
{...form.getInputProps('dvr-comskip-custom-path')}
|
||||
key={form.key('dvr-comskip-custom-path')}
|
||||
id={
|
||||
settings['dvr-comskip-custom-path']?.id ||
|
||||
'dvr-comskip-custom-path'
|
||||
}
|
||||
name={
|
||||
settings['dvr-comskip-custom-path']?.key ||
|
||||
'dvr-comskip-custom-path'
|
||||
}
|
||||
/>
|
||||
<Group align="flex-end" gap="sm">
|
||||
<FileInput
|
||||
placeholder="Select comskip.ini"
|
||||
accept=".ini"
|
||||
value={comskipFile}
|
||||
onChange={setComskipFile}
|
||||
clearable
|
||||
disabled={comskipUploadLoading}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<Button
|
||||
variant="light"
|
||||
onClick={onComskipUpload}
|
||||
disabled={!comskipFile || comskipUploadLoading}
|
||||
>
|
||||
{comskipUploadLoading ? 'Uploading...' : 'Upload comskip.ini'}
|
||||
</Button>
|
||||
</Group>
|
||||
<Text size="xs" c="dimmed">
|
||||
{comskipConfig.exists && comskipConfig.path
|
||||
? `Using ${comskipConfig.path}`
|
||||
: 'No custom comskip.ini uploaded.'}
|
||||
</Text>
|
||||
<NumberInput
|
||||
label="Start early (minutes)"
|
||||
description="Begin recording this many minutes before the scheduled start."
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ const useChannelsStore = create((set, get) => ({
|
|||
activeChannels: {},
|
||||
activeClients: {},
|
||||
recordings: [],
|
||||
recurringRules: [],
|
||||
isLoading: false,
|
||||
error: null,
|
||||
forceUpdate: 0,
|
||||
|
|
@ -408,6 +409,23 @@ const useChannelsStore = create((set, get) => ({
|
|||
}
|
||||
},
|
||||
|
||||
fetchRecurringRules: async () => {
|
||||
try {
|
||||
const rules = await api.listRecurringRules();
|
||||
set({ recurringRules: Array.isArray(rules) ? rules : [] });
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch recurring DVR rules:', error);
|
||||
set({ error: 'Failed to load recurring DVR rules.' });
|
||||
}
|
||||
},
|
||||
|
||||
removeRecurringRule: (id) =>
|
||||
set((state) => ({
|
||||
recurringRules: Array.isArray(state.recurringRules)
|
||||
? state.recurringRules.filter((rule) => String(rule?.id) !== String(id))
|
||||
: [],
|
||||
})),
|
||||
|
||||
// Optimistically remove a single recording from the local store
|
||||
removeRecording: (id) =>
|
||||
set((state) => {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue