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:
Dispatcharr 2025-09-18 10:23:16 -05:00
parent edc18e07fe
commit 424a450654
15 changed files with 1056 additions and 114 deletions

View file

@ -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

View file

@ -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):

View 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'],
},
),
]

View file

@ -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 []

View file

@ -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)

View file

@ -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):

View file

View 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())

View file

@ -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)."""

View file

@ -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"

View file

@ -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' });

View file

@ -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;

View file

@ -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">

View file

@ -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."

View file

@ -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) => {