DVR update

This commit is contained in:
Dispatcharr 2025-09-03 21:35:45 -05:00
parent 5806464406
commit 00cc83882a
13 changed files with 2233 additions and 64 deletions

View file

@ -14,6 +14,11 @@ from .api_views import (
BulkUpdateChannelMembershipAPIView,
RecordingViewSet,
GetChannelStreamsAPIView,
SeriesRulesAPIView,
DeleteSeriesRuleAPIView,
EvaluateSeriesRulesAPIView,
BulkRemoveSeriesRecordingsAPIView,
BulkDeleteUpcomingRecordingsAPIView,
)
app_name = 'channels' # for DRF routing
@ -35,6 +40,12 @@ urlpatterns = [
path('channels/<int:channel_id>/streams/', GetChannelStreamsAPIView.as_view(), name='get_channel_streams'),
path('profiles/<int:profile_id>/channels/<int:channel_id>/', UpdateChannelMembershipAPIView.as_view(), name='update_channel_membership'),
path('profiles/<int:profile_id>/channels/bulk-update/', BulkUpdateChannelMembershipAPIView.as_view(), name='bulk_update_channel_membership'),
# DVR series rules (order matters: specific routes before catch-all slug)
path('series-rules/', SeriesRulesAPIView.as_view(), name='series_rules'),
path('series-rules/evaluate/', EvaluateSeriesRulesAPIView.as_view(), name='evaluate_series_rules'),
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'),
]
urlpatterns += router.urls

View file

@ -39,7 +39,7 @@ from .serializers import (
ChannelProfileSerializer,
RecordingSerializer,
)
from .tasks import match_epg_channels
from .tasks import match_epg_channels, evaluate_series_rules, evaluate_series_rules_impl
import django_filters
from django_filters.rest_framework import DjangoFilterBackend
from rest_framework.filters import SearchFilter, OrderingFilter
@ -47,6 +47,7 @@ from apps.epg.models import EPGData
from apps.vod.models import Movie, Series
from django.db.models import Q
from django.http import StreamingHttpResponse, FileResponse, Http404
from django.utils import timezone
import mimetypes
from rest_framework.pagination import PageNumberPagination
@ -1674,7 +1675,222 @@ class RecordingViewSet(viewsets.ModelViewSet):
serializer_class = RecordingSerializer
def get_permissions(self):
# Allow unauthenticated playback of recording files (like other streaming endpoints)
if getattr(self, 'action', None) == 'file':
return [AllowAny()]
try:
return [perm() for perm in permission_classes_by_action[self.action]]
except KeyError:
return [Authenticated()]
@action(detail=True, methods=["get"], url_path="file")
def file(self, request, pk=None):
"""Stream a recorded file with HTTP Range support for seeking."""
recording = get_object_or_404(Recording, pk=pk)
cp = recording.custom_properties or {}
file_path = cp.get("file_path")
file_name = cp.get("file_name") or "recording"
if not file_path or not os.path.exists(file_path):
raise Http404("Recording file not found")
# Guess content type
ext = os.path.splitext(file_path)[1].lower()
if ext == ".mp4":
content_type = "video/mp4"
elif ext == ".mkv":
content_type = "video/x-matroska"
else:
content_type = mimetypes.guess_type(file_path)[0] or "application/octet-stream"
file_size = os.path.getsize(file_path)
range_header = request.META.get("HTTP_RANGE", "").strip()
def file_iterator(path, start=0, end=None, chunk_size=8192):
with open(path, "rb") as f:
f.seek(start)
remaining = (end - start + 1) if end is not None else None
while True:
if remaining is not None and remaining <= 0:
break
bytes_to_read = min(chunk_size, remaining) if remaining is not None else chunk_size
data = f.read(bytes_to_read)
if not data:
break
if remaining is not None:
remaining -= len(data)
yield data
if range_header and range_header.startswith("bytes="):
# Parse Range header
try:
range_spec = range_header.split("=", 1)[1]
start_str, end_str = range_spec.split("-", 1)
start = int(start_str) if start_str else 0
end = int(end_str) if end_str else file_size - 1
start = max(0, start)
end = min(file_size - 1, end)
length = end - start + 1
resp = StreamingHttpResponse(
file_iterator(file_path, start, end),
status=206,
content_type=content_type,
)
resp["Content-Range"] = f"bytes {start}-{end}/{file_size}"
resp["Content-Length"] = str(length)
resp["Accept-Ranges"] = "bytes"
resp["Content-Disposition"] = f"inline; filename=\"{file_name}\""
return resp
except Exception:
# Fall back to full file if parsing fails
pass
# Full file response
response = FileResponse(open(file_path, "rb"), content_type=content_type)
response["Content-Length"] = str(file_size)
response["Accept-Ranges"] = "bytes"
response["Content-Disposition"] = f"inline; filename=\"{file_name}\""
return response
def destroy(self, request, *args, **kwargs):
"""Delete the Recording and remove the associated file from disk if present."""
instance = self.get_object()
cp = instance.custom_properties or {}
file_path = cp.get("file_path")
# Perform DB delete first, then try to remove file
response = super().destroy(request, *args, **kwargs)
library_dir = os.environ.get('DISPATCHARR_LIBRARY_DIR', '/library')
allowed_roots = ['/data/', library_dir.rstrip('/') + '/']
if file_path and isinstance(file_path, str) and any(file_path.startswith(root) for root in allowed_roots):
try:
if os.path.exists(file_path):
os.remove(file_path)
logger.info(f"Deleted recording file: {file_path}")
except Exception as e:
logger.warning(f"Failed to delete recording file {file_path}: {e}")
return response
class BulkDeleteUpcomingRecordingsAPIView(APIView):
"""Delete all upcoming (future) recordings."""
def get_permissions(self):
try:
return [perm() for perm in permission_classes_by_method[self.request.method]]
except KeyError:
return [Authenticated()]
def post(self, request):
now = timezone.now()
qs = Recording.objects.filter(start_time__gt=now)
removed = qs.count()
qs.delete()
try:
from core.utils import send_websocket_update
send_websocket_update('updates', 'update', {"success": True, "type": "recordings_refreshed", "removed": removed})
except Exception:
pass
return Response({"success": True, "removed": removed})
class SeriesRulesAPIView(APIView):
"""Manage DVR series recording rules (list/add)."""
def get_permissions(self):
try:
return [perm() for perm in permission_classes_by_method[self.request.method]]
except KeyError:
return [Authenticated()]
def get(self, request):
return Response({"rules": CoreSettings.get_dvr_series_rules()})
def post(self, request):
data = request.data or {}
tvg_id = str(data.get("tvg_id") or "").strip()
mode = (data.get("mode") or "all").lower()
title = data.get("title") or ""
if mode not in ("all", "new"):
return Response({"error": "mode must be 'all' or 'new'"}, status=status.HTTP_400_BAD_REQUEST)
if not tvg_id:
return Response({"error": "tvg_id is required"}, status=status.HTTP_400_BAD_REQUEST)
rules = CoreSettings.get_dvr_series_rules()
# Upsert by tvg_id
existing = next((r for r in rules if str(r.get("tvg_id")) == tvg_id), None)
if existing:
existing.update({"mode": mode, "title": title})
else:
rules.append({"tvg_id": tvg_id, "mode": mode, "title": title})
CoreSettings.set_dvr_series_rules(rules)
# Evaluate immediately for this tvg_id (async)
try:
evaluate_series_rules.delay(tvg_id)
except Exception:
pass
return Response({"success": True, "rules": rules})
class DeleteSeriesRuleAPIView(APIView):
def get_permissions(self):
try:
return [perm() for perm in permission_classes_by_method[self.request.method]]
except KeyError:
return [Authenticated()]
def delete(self, request, tvg_id):
tvg_id = str(tvg_id)
rules = [r for r in CoreSettings.get_dvr_series_rules() if str(r.get("tvg_id")) != tvg_id]
CoreSettings.set_dvr_series_rules(rules)
return Response({"success": True, "rules": rules})
class EvaluateSeriesRulesAPIView(APIView):
def get_permissions(self):
try:
return [perm() for perm in permission_classes_by_method[self.request.method]]
except KeyError:
return [Authenticated()]
def post(self, request):
tvg_id = request.data.get("tvg_id")
# Run synchronously so UI sees results immediately
result = evaluate_series_rules_impl(str(tvg_id)) if tvg_id else evaluate_series_rules_impl()
return Response({"success": True, **result})
class BulkRemoveSeriesRecordingsAPIView(APIView):
"""Bulk remove scheduled recordings for a series rule.
POST body:
- tvg_id: required (EPG channel id)
- title: optional (series title)
- scope: 'title' (default) or 'channel'
"""
def get_permissions(self):
try:
return [perm() for perm in permission_classes_by_method[self.request.method]]
except KeyError:
return [Authenticated()]
def post(self, request):
from django.utils import timezone
tvg_id = str(request.data.get("tvg_id") or "").strip()
title = request.data.get("title")
scope = (request.data.get("scope") or "title").lower()
if not tvg_id:
return Response({"error": "tvg_id is required"}, status=status.HTTP_400_BAD_REQUEST)
qs = Recording.objects.filter(
start_time__gte=timezone.now(),
custom_properties__program__tvg_id=tvg_id,
)
if scope == "title" and title:
qs = qs.filter(custom_properties__program__title=title)
count = qs.count()
qs.delete()
try:
from core.utils import send_websocket_update
send_websocket_update('updates', 'update', {"success": True, "type": "recordings_refreshed", "removed": count})
except Exception:
pass
return Response({"success": True, "removed": count})

View file

@ -9,3 +9,12 @@ class ChannelsConfig(AppConfig):
def ready(self):
# Import signals so they get registered.
import apps.channels.signals
# Kick off DVR recovery shortly after startup (idempotent via Redis lock)
try:
from .tasks import recover_recordings_on_startup
# Schedule with a short delay to allow migrations/DB readiness
recover_recordings_on_startup.apply_async(countdown=5)
except Exception:
# Avoid hard failures at startup if Celery isn't ready yet
pass

View file

@ -8,7 +8,7 @@ from .models import Channel, Stream, ChannelProfile, ChannelProfileMembership, R
from apps.m3u.models import M3UAccount
from apps.epg.tasks import parse_programs_for_tvg_id
import logging, requests, time
from .tasks import run_recording
from .tasks import run_recording, prefetch_recording_artwork
from django.utils.timezone import now, is_aware, make_aware
from datetime import timedelta
@ -73,8 +73,9 @@ def create_profile_memberships(sender, instance, created, **kwargs):
def schedule_recording_task(instance):
eta = instance.start_time
# Pass recording_id first so task can persist metadata to the correct row
task = run_recording.apply_async(
args=[instance.channel_id, str(instance.start_time), str(instance.end_time)],
args=[instance.id, instance.channel_id, str(instance.start_time), str(instance.end_time)],
eta=eta
)
return task.id
@ -123,6 +124,11 @@ def schedule_task_on_save(sender, instance, created, **kwargs):
instance.save(update_fields=['task_id'])
else:
print("Start time is in the past. Not scheduling.")
# Kick off poster/artwork prefetch to enrich Upcoming cards
try:
prefetch_recording_artwork.apply_async(args=[instance.id], countdown=1)
except Exception as e:
print("Error scheduling artwork prefetch:", e)
except Exception as e:
import traceback
print("Error in post_save signal:", e)

File diff suppressed because it is too large Load diff

View file

@ -188,6 +188,12 @@ def refresh_epg_data(source_id):
fetch_schedules_direct(source)
source.save(update_fields=['updated_at'])
# After successful EPG refresh, evaluate DVR series rules to schedule new episodes
try:
from apps.channels.tasks import evaluate_series_rules
evaluate_series_rules.delay()
except Exception:
pass
except Exception as e:
logger.error(f"Error in refresh_epg_data for source {source_id}: {e}", exc_info=True)
try:

View file

@ -0,0 +1,27 @@
# Generated by Django 5.1.6 on 2025-03-01 14:10
from django.db import migrations
from django.utils.text import slugify
def add_dvr_templates(apps, schema_editor):
CoreSettings = apps.get_model("core", "CoreSettings")
defaults = [
(slugify("DVR TV Template"), "DVR TV Template", "TV_Shows/{show}/S{season:02d}E{episode:02d}.mkv"),
(slugify("DVR Movie Template"), "DVR Movie Template", "Movies/{title} ({year}).mkv"),
]
for key, name, value in defaults:
CoreSettings.objects.get_or_create(key=key, defaults={"name": name, "value": value})
class Migration(migrations.Migration):
dependencies = [
("core", "0014_default_proxy_settings"),
]
operations = [
migrations.RunPython(add_dvr_templates),
]

View file

@ -151,6 +151,9 @@ PREFERRED_REGION_KEY = slugify("Preferred Region")
AUTO_IMPORT_MAPPED_FILES = slugify("Auto-Import Mapped Files")
NETWORK_ACCESS = slugify("Network Access")
PROXY_SETTINGS_KEY = slugify("Proxy Settings")
DVR_TV_TEMPLATE_KEY = slugify("DVR TV Template")
DVR_MOVIE_TEMPLATE_KEY = slugify("DVR Movie Template")
DVR_SERIES_RULES_KEY = slugify("DVR Series Rules")
class CoreSettings(models.Model):
@ -213,3 +216,44 @@ class CoreSettings(models.Model):
"channel_shutdown_delay": 0,
"channel_init_grace_period": 5,
}
@classmethod
def get_dvr_tv_template(cls):
try:
return cls.objects.get(key=DVR_TV_TEMPLATE_KEY).value
except cls.DoesNotExist:
# Default: relative to recordings root (/data/recordings)
return "TV_Shows/{show}/S{season:02d}E{episode:02d}.mkv"
@classmethod
def get_dvr_movie_template(cls):
try:
return cls.objects.get(key=DVR_MOVIE_TEMPLATE_KEY).value
except cls.DoesNotExist:
return "Movies/{title} ({year}).mkv"
@classmethod
def get_dvr_series_rules(cls):
"""Return list of series recording rules. Each: {tvg_id, title, mode: 'all'|'new'}"""
import json
try:
raw = cls.objects.get(key=DVR_SERIES_RULES_KEY).value
rules = json.loads(raw) if raw else []
if isinstance(rules, list):
return rules
return []
except cls.DoesNotExist:
# Initialize empty if missing
cls.objects.create(key=DVR_SERIES_RULES_KEY, name="DVR Series Rules", value="[]")
return []
@classmethod
def set_dvr_series_rules(cls, rules):
import json
try:
obj, _ = cls.objects.get_or_create(key=DVR_SERIES_RULES_KEY, defaults={"name": "DVR Series Rules", "value": "[]"})
obj.value = json.dumps(rules)
obj.save(update_fields=["value"])
return rules
except Exception:
return rules

View file

@ -283,11 +283,32 @@ export const WebsocketProvider = ({ children }) => {
);
break;
case 'recording_updated':
try {
await useChannelsStore.getState().fetchRecordings();
} catch (e) {
console.warn('Failed to refresh recordings on update:', e);
}
break;
case 'recordings_refreshed':
try {
await useChannelsStore.getState().fetchRecordings();
} catch (e) {
console.warn('Failed to refresh recordings on refreshed:', e);
}
break;
case 'recording_started':
notifications.show({
title: 'Recording started!',
message: `Started recording channel ${parsedEvent.data.channel}`,
});
try {
await useChannelsStore.getState().fetchRecordings();
} catch (e) {
console.warn('Failed to refresh recordings on start:', e);
}
break;
case 'recording_ended':
@ -295,6 +316,11 @@ export const WebsocketProvider = ({ children }) => {
title: 'Recording finished!',
message: `Stopped recording channel ${parsedEvent.data.channel}`,
});
try {
await useChannelsStore.getState().fetchRecordings();
} catch (e) {
console.warn('Failed to refresh recordings on end:', e);
}
break;
case 'epg_fetch_error':

View file

@ -1655,6 +1655,80 @@ export default class API {
}
}
// DVR Series Rules
static async listSeriesRules() {
try {
const resp = await request(`${host}/api/channels/series-rules/`);
return resp?.rules || [];
} catch (e) {
errorNotification('Failed to load series rules', e);
return [];
}
}
static async createSeriesRule(values) {
try {
const resp = await request(`${host}/api/channels/series-rules/`, {
method: 'POST',
body: values,
});
notifications.show({ title: 'Series rule saved' });
return resp;
} catch (e) {
errorNotification('Failed to save series rule', e);
throw e;
}
}
static async deleteSeriesRule(tvgId) {
try {
await request(`${host}/api/channels/series-rules/${tvgId}/`, { method: 'DELETE' });
notifications.show({ title: 'Series rule removed' });
} catch (e) {
errorNotification('Failed to remove series rule', e);
throw e;
}
}
static async deleteAllUpcomingRecordings() {
try {
const resp = await request(`${host}/api/channels/recordings/bulk-delete-upcoming/`, {
method: 'POST',
});
notifications.show({ title: `Removed ${resp.removed || 0} upcoming` });
useChannelsStore.getState().fetchRecordings();
return resp;
} catch (e) {
errorNotification('Failed to delete upcoming recordings', e);
throw e;
}
}
static async evaluateSeriesRules(tvgId = null) {
try {
await request(`${host}/api/channels/series-rules/evaluate/`, {
method: 'POST',
body: tvgId ? { tvg_id: tvgId } : {},
});
} catch (e) {
errorNotification('Failed to evaluate series rules', e);
}
}
static async bulkRemoveSeriesRecordings({ tvg_id, title = null, scope = 'title' }) {
try {
const resp = await request(`${host}/api/channels/series-rules/bulk-remove/`, {
method: 'POST',
body: { tvg_id, title, scope },
});
notifications.show({ title: `Removed ${resp.removed || 0} scheduled` });
return resp;
} catch (e) {
errorNotification('Failed to bulk-remove scheduled recordings', e);
throw e;
}
}
static async switchStream(channelId, streamId) {
try {
const response = await request(

View file

@ -7,7 +7,10 @@ import {
Center,
Container,
Flex,
Badge,
Group,
Image,
Modal,
SimpleGrid,
Stack,
Text,
@ -19,6 +22,7 @@ import {
Gauge,
HardDriveDownload,
HardDriveUpload,
AlertTriangle,
SquarePlus,
SquareX,
Timer,
@ -29,30 +33,315 @@ import dayjs from 'dayjs';
import duration from 'dayjs/plugin/duration';
import relativeTime from 'dayjs/plugin/relativeTime';
import useChannelsStore from '../store/channels';
import useSettingsStore from '../store/settings';
import useVideoStore from '../store/useVideoStore';
import RecordingForm from '../components/forms/Recording';
import API from '../api';
dayjs.extend(duration);
dayjs.extend(relativeTime);
const RecordingCard = ({ recording }) => {
const channels = useChannelsStore((s) => s.channels);
// 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;
if (!description) return null;
return (
<Text
size="xs"
c="dimmed"
lineClamp={2}
title={description}
onClick={() => onOpen?.()}
style={{ cursor: 'pointer' }}
>
{preview}
</Text>
);
};
console.log(recording);
const RecordingDetailsModal = ({ opened, onClose, recording, channel, posterUrl, onWatchLive, onWatchRecording, env_mode }) => {
if (!recording) return null;
const customProps = recording.custom_properties || {};
const program = customProps.program || {};
const recordingName = program.title || 'Custom Recording';
const description = program.description || customProps.description || '';
const start = dayjs(recording.start_time);
const end = dayjs(recording.end_time);
const stats = customProps.stream_info || {};
const statRows = [
['Video Codec', stats.video_codec],
['Resolution', stats.resolution || (stats.width && stats.height ? `${stats.width}x${stats.height}` : null)],
['FPS', stats.source_fps],
['Video Bitrate', stats.video_bitrate && `${stats.video_bitrate} kb/s`],
['Audio Codec', stats.audio_codec],
['Audio Channels', stats.audio_channels],
['Sample Rate', stats.sample_rate && `${stats.sample_rate} Hz`],
['Audio Bitrate', stats.audio_bitrate && `${stats.audio_bitrate} kb/s`],
].filter(([, v]) => v !== null && v !== undefined && v !== '');
// Rating (if available)
const rating = customProps.rating || customProps.rating_value || (program && program.custom_properties && program.custom_properties.rating);
const ratingSystem = customProps.rating_system || 'MPAA';
const fileUrl = customProps.file_url || customProps.output_file_url;
const canWatchRecording = (customProps.status === 'completed' || customProps.status === 'interrupted') && Boolean(fileUrl);
// Prefix in dev (Vite) if needed
let resolvedPosterUrl = posterUrl;
if (typeof import.meta !== 'undefined' && import.meta.env && import.meta.env.DEV) {
if (resolvedPosterUrl && resolvedPosterUrl.startsWith('/')) {
resolvedPosterUrl = `${window.location.protocol}//${window.location.hostname}:5656${resolvedPosterUrl}`;
}
}
// 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 upcomingEpisodes = React.useMemo(() => {
if (!isSeriesGroup) return [];
const arr = Array.isArray(allRecordings) ? allRecordings : Object.values(allRecordings || {});
const tvid = program.tvg_id || '';
const titleKey = (program.title || '').toLowerCase();
const filtered = arr.filter((r) => {
const cp = r.custom_properties || {};
const pr = cp.program || {};
if ((pr.tvg_id || '') !== tvid) return false;
if ((pr.title || '').toLowerCase() !== titleKey) return false;
const st = dayjs(r.start_time);
return st.isAfter(dayjs());
});
// Deduplicate by program.id if present, else by time+title
const seen = new Set();
const deduped = [];
for (const r of filtered) {
const cp = r.custom_properties || {};
const pr = cp.program || {};
// Prefer season/episode or onscreen code; else fall back to sub_title; else program id/slot
const season = cp.season ?? pr?.custom_properties?.season;
const episode = cp.episode ?? pr?.custom_properties?.episode;
const onscreen = cp.onscreen_episode ?? pr?.custom_properties?.onscreen_episode;
let key = null;
if (season != null && episode != null) key = `se:${season}:${episode}`;
else if (onscreen) key = `onscreen:${String(onscreen).toLowerCase()}`;
else if (pr.sub_title) key = `sub:${(pr.sub_title || '').toLowerCase()}`;
else if (pr.id != null) key = `id:${pr.id}`;
else key = `slot:${r.channel}|${r.start_time}|${r.end_time}|${(pr.title||'')}`;
if (seen.has(key)) continue;
seen.add(key);
deduped.push(r);
}
return deduped.sort((a, b) => dayjs(a.start_time) - dayjs(b.start_time));
}, [allRecordings, isSeriesGroup, program.tvg_id, program.title]);
const EpisodeRow = ({ rec }) => {
const cp = rec.custom_properties || {};
const pr = cp.program || {};
const start = dayjs(rec.start_time);
const end = dayjs(rec.end_time);
const season = cp.season ?? pr?.custom_properties?.season;
const episode = cp.episode ?? pr?.custom_properties?.episode;
const onscreen = cp.onscreen_episode ?? pr?.custom_properties?.onscreen_episode;
const se = season && episode ? `S${String(season).padStart(2,'0')}E${String(episode).padStart(2,'0')}` : (onscreen || null);
const posterLogoId = cp.poster_logo_id;
let purl = posterLogoId ? `/api/channels/logos/${posterLogoId}/cache/` : cp.poster_url || posterUrl || '/logo.png';
if (typeof import.meta !== 'undefined' && import.meta.env && import.meta.env.DEV && purl && purl.startsWith('/')) {
purl = `${window.location.protocol}//${window.location.hostname}:5656${purl}`;
}
const onRemove = async (e) => {
e?.stopPropagation?.();
try { await API.deleteRecording(rec.id); } catch {}
try { await useChannelsStore.getState().fetchRecordings(); } catch {}
};
return (
<Card withBorder radius="md" padding="sm" style={{ backgroundColor: '#27272A', cursor: 'pointer' }} onClick={() => { setChildRec(rec); setChildOpen(true); }}>
<Flex gap="sm" align="center">
<Image src={purl} w={64} h={64} fit="contain" radius="sm" alt={pr.title || recordingName} fallbackSrc="/logo.png" />
<Stack gap={4} style={{ flex: 1 }}>
<Group justify="space-between">
<Text fw={600} size="sm" lineClamp={1} title={pr.sub_title || pr.title}>{pr.sub_title || pr.title}</Text>
{se && <Badge color="gray" variant="light">{se}</Badge>}
</Group>
<Text size="xs">{start.format('MMM D, YYYY h:mma')} {end.format('h:mma')}</Text>
</Stack>
<Group gap={6}>
<Button size="xs" color="red" variant="light" onClick={onRemove}>Remove</Button>
</Group>
</Flex>
</Card>
);
};
return (
<Modal
opened={opened}
onClose={onClose}
title={isSeriesGroup ? `Series: ${recordingName}` : recordingName}
size="lg"
centered
radius="md"
zIndex={9999}
overlayProps={{ color: '#000', backgroundOpacity: 0.55, blur: 0 }}
styles={{
content: { backgroundColor: '#18181B', color: 'white' },
header: { backgroundColor: '#18181B', color: 'white', borderBottom: '1px solid #27272A' },
title: { color: 'white' },
}}
>
{isSeriesGroup ? (
<Stack gap={10}>
{upcomingEpisodes.length === 0 && (
<Text size="sm" c="dimmed">No upcoming episodes found</Text>
)}
{upcomingEpisodes.map((ep) => (
<EpisodeRow key={`ep-${ep.id}`} rec={ep} />
))}
{childOpen && childRec && (
<RecordingDetailsModal
opened={childOpen}
onClose={() => setChildOpen(false)}
recording={childRec}
channel={channels[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
) || '/logo.png'}
env_mode={env_mode}
onWatchLive={() => {
const rec = childRec;
const now = dayjs();
const s = dayjs(rec.start_time);
const e = dayjs(rec.end_time);
if (now.isAfter(s) && now.isBefore(e)) {
const ch = channels[rec.channel];
if (!ch) return;
let url = `/proxy/ts/stream/${ch.uuid}`;
if (env_mode === 'dev') {
url = `${window.location.protocol}//${window.location.hostname}:5656${url}`;
}
useVideoStore.getState().showVideo(url, 'live');
}
}}
onWatchRecording={() => {
let fileUrl = childRec.custom_properties?.file_url || childRec.custom_properties?.output_file_url;
if (!fileUrl) return;
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' } });
}}
/>
)}
</Stack>
) : (
<Flex gap="lg" align="flex-start">
<Image src={resolvedPosterUrl} w={180} h={240} fit="contain" radius="sm" alt={recordingName} fallbackSrc="/logo.png" />
<Stack gap={8} style={{ flex: 1 }}>
<Group justify="space-between" align="center">
<Text c="dimmed" size="sm">{channel ? `${channel.channel_number}${channel.name}` : '—'}</Text>
<Group gap={8}>
{onWatchLive && (
<Button size="xs" variant="light" onClick={(e) => { e.stopPropagation?.(); onWatchLive(); }}>Watch Live</Button>
)}
{onWatchRecording && (
<Button size="xs" variant="default" onClick={(e) => { e.stopPropagation?.(); onWatchRecording(); }} disabled={!canWatchRecording}>Watch</Button>
)}
</Group>
</Group>
<Text size="sm">{start.format('MMM D, YYYY h:mma')} {end.format('h:mma')}</Text>
{rating && (
<Group gap={8}>
<Badge color="yellow" title={ratingSystem}>{rating}</Badge>
</Group>
)}
{description && (
<Text size="sm" style={{ whiteSpace: 'pre-wrap' }}>{description}</Text>
)}
{statRows.length > 0 && (
<Stack gap={4} pt={6}>
<Text fw={600} size="sm">Stream Stats</Text>
{statRows.map(([k, v]) => (
<Group key={k} justify="space-between">
<Text size="xs" c="dimmed">{k}</Text>
<Text size="xs">{v}</Text>
</Group>
))}
</Stack>
)}
</Stack>
</Flex>
)}
</Modal>
);
};
const RecordingCard = ({ recording, category, onOpenDetails }) => {
const channels = useChannelsStore((s) => s.channels);
const env_mode = useSettingsStore((s) => s.environment.env_mode);
const showVideo = useVideoStore((s) => s.showVideo);
const channel = channels?.[recording.channel];
const deleteRecording = (id) => {
API.deleteRecording(id);
};
const customProps = recording.custom_properties || {};
let recordingName = 'Custom Recording';
if (customProps.program) {
recordingName = customProps.program.title;
const program = customProps.program || {};
const recordingName = program.title || 'Custom Recording';
const description = program.description || customProps.description || '';
// Poster or channel logo
const posterLogoId = customProps.poster_logo_id;
let posterUrl = posterLogoId
? `/api/channels/logos/${posterLogoId}/cache/`
: customProps.poster_url || channel?.logo?.cache_url || '/logo.png';
// Prefix API host in dev if using a relative path
if (env_mode === 'dev' && posterUrl && posterUrl.startsWith('/')) {
posterUrl = `${window.location.protocol}//${window.location.hostname}:5656${posterUrl}`;
}
console.log(recording);
const start = dayjs(recording.start_time);
const end = dayjs(recording.end_time);
const now = dayjs();
const status = customProps.status;
const isTimeActive = now.isAfter(start) && now.isBefore(end);
const isInterrupted = status === 'interrupted';
const isInProgress = isTimeActive; // Show as recording by time, regardless of status glitches
const isUpcoming = now.isBefore(start);
const isSeriesGroup = Boolean(recording._group_count && recording._group_count > 1);
// Season/Episode display if present
const season = customProps.season ?? program?.custom_properties?.season;
const episode = customProps.episode ?? program?.custom_properties?.episode;
const onscreen = customProps.onscreen_episode ?? program?.custom_properties?.onscreen_episode;
const seLabel = season && episode ? `S${String(season).padStart(2,'0')}E${String(episode).padStart(2,'0')}` : (onscreen || null);
return (
const handleWatchLive = () => {
if (!channel) return;
let url = `/proxy/ts/stream/${channel.uuid}`;
if (env_mode === 'dev') {
url = `${window.location.protocol}//${window.location.hostname}:5656${url}`;
}
showVideo(url, 'live');
};
const handleWatchRecording = () => {
// Only enable if backend provides a playable file URL in custom properties
let fileUrl = customProps.file_url || customProps.output_file_url;
if (!fileUrl) return;
if (env_mode === 'dev' && fileUrl.startsWith('/')) {
fileUrl = `${window.location.protocol}//${window.location.hostname}:5656${fileUrl}`;
}
showVideo(fileUrl, 'vod', { name: recordingName, logo: { url: posterUrl } });
};
const MainCard = (
<Card
shadow="sm"
padding="md"
@ -60,54 +349,154 @@ const RecordingCard = ({ recording }) => {
withBorder
style={{
color: '#fff',
backgroundColor: '#27272A',
backgroundColor: isInterrupted ? '#2b1f20' : '#27272A',
borderColor: isInterrupted ? '#a33' : undefined,
height: '100%',
cursor: 'pointer',
}}
onClick={() => onOpenDetails?.(recording)}
>
<Flex justify="space-between" align="center" style={{ paddingBottom: 5 }}>
<Group>
<Text fw={500}>{recordingName}</Text>
<Flex justify="space-between" align="center" style={{ paddingBottom: 8 }}>
<Group gap={8}>
<Badge color={isInterrupted ? 'red.7' : isInProgress ? 'red.6' : isUpcoming ? 'yellow.6' : 'gray.6'}>
{isInterrupted ? 'Interrupted' : isInProgress ? 'Recording' : isUpcoming ? 'Scheduled' : 'Completed'}
</Badge>
{isInterrupted && <AlertTriangle size={16} color="#ffa94d" />}
<Text fw={600} lineClamp={1} title={recordingName}>
{recordingName}
</Text>
{isSeriesGroup && (
<Badge color="teal" variant="filled">Series</Badge>
)}
{seLabel && !isSeriesGroup && (
<Badge color="gray" variant="light">{seLabel}</Badge>
)}
</Group>
<Center>
<Tooltip label="Delete / Cancel">
<Tooltip label={isUpcoming || isInProgress ? 'Cancel' : 'Delete'}>
<ActionIcon
variant="transparent"
color="red.9"
onClick={() => deleteRecording(recording.id)}
onClick={(e) => { e.stopPropagation(); deleteRecording(recording.id); }}
>
<SquareX size="24" />
<SquareX size="20" />
</ActionIcon>
</Tooltip>
</Center>
</Flex>
<Group justify="space-between">
<Text size="sm">Channel:</Text>
<Text size="sm">{channels[recording.channel].name}</Text>
</Group>
<Flex gap="sm" align="center">
<Image
src={posterUrl}
w={64}
h={64}
fit="contain"
radius="sm"
alt={recordingName}
fallbackSrc="/logo.png"
/>
<Stack gap={6} style={{ flex: 1 }}>
<Group justify="space-between">
<Text size="sm" c="dimmed">
Channel
</Text>
<Text size="sm">
{channel ? `${channel.channel_number}${channel.name}` : '—'}
</Text>
</Group>
<Group justify="space-between">
<Text size="sm">Start:</Text>
<Text size="sm">
{dayjs(new Date(recording.start_time)).format('MMMM D, YYYY h:mma')}
<Group justify="space-between">
<Text size="sm" c="dimmed">
Time
</Text>
<Text size="sm">{start.format('MMM D, YYYY h:mma')} {end.format('h:mma')}</Text>
</Group>
{!isSeriesGroup && description && (
<RecordingSynopsis description={description} onOpen={() => onOpenDetails?.(recording)} />
)}
{isInterrupted && customProps.interrupted_reason && (
<Text size="xs" c="red.4">{customProps.interrupted_reason}</Text>
)}
<Group justify="flex-end" gap="xs" pt={4}>
{isInProgress && (
<Button size="xs" variant="light" onClick={(e) => { e.stopPropagation(); handleWatchLive(); }}>
Watch Live
</Button>
)}
{!isUpcoming && (
<Tooltip label={customProps.file_url || customProps.output_file_url ? 'Watch recording' : 'Recording playback not available yet'}>
<Button
size="xs"
variant="default"
onClick={(e) => { e.stopPropagation(); handleWatchRecording(); }}
disabled={customProps.status === 'recording' || !(customProps.file_url || customProps.output_file_url)}
>
Watch
</Button>
</Tooltip>
)}
</Group>
</Stack>
</Flex>
{/* If this card is a grouped upcoming series, show count */}
{recording._group_count > 1 && (
<Text size="xs" c="dimmed" style={{ position: 'absolute', bottom: 6, right: 12 }}>
Next of {recording._group_count}
</Text>
</Group>
<Group justify="space-between">
<Text size="sm">End:</Text>
<Text size="sm">
{dayjs(new Date(recording.end_time)).format('MMMM D, YYYY h:mma')}
</Text>
</Group>
)}
</Card>
);
if (!isSeriesGroup) return MainCard;
// Stacked look for series groups: render two shadow layers behind the main card
return (
<Box style={{ position: 'relative' }}>
<Box
style={{
position: 'absolute',
inset: 0,
transform: 'translate(10px, 10px) rotate(-1deg)',
borderRadius: 12,
backgroundColor: '#1f1f23',
border: '1px solid #2f2f34',
boxShadow: '0 6px 18px rgba(0,0,0,0.35)',
pointerEvents: 'none',
zIndex: 0,
}}
/>
<Box
style={{
position: 'absolute',
inset: 0,
transform: 'translate(5px, 5px) rotate(1deg)',
borderRadius: 12,
backgroundColor: '#232327',
border: '1px solid #333',
boxShadow: '0 4px 12px rgba(0,0,0,0.30)',
pointerEvents: 'none',
zIndex: 1,
}}
/>
<Box style={{ position: 'relative', zIndex: 2 }}>{MainCard}</Box>
</Box>
);
};
const DVRPage = () => {
const theme = useMantineTheme();
const recordings = useChannelsStore((s) => s.recordings);
const fetchRecordings = useChannelsStore((s) => s.fetchRecordings);
const channels = useChannelsStore((s) => s.channels);
const fetchChannels = useChannelsStore((s) => s.fetchChannels);
const [recordingModalOpen, setRecordingModalOpen] = useState(false);
const [detailsOpen, setDetailsOpen] = useState(false);
const [detailsRecording, setDetailsRecording] = useState(null);
const openRecordingModal = () => {
setRecordingModalOpen(true);
@ -117,6 +506,94 @@ const DVRPage = () => {
setRecordingModalOpen(false);
};
const openDetails = (recording) => {
setDetailsRecording(recording);
setDetailsOpen(true);
};
const closeDetails = () => setDetailsOpen(false);
useEffect(() => {
// Ensure channels and recordings are loaded for this view
if (!channels || Object.keys(channels).length === 0) {
fetchChannels();
}
fetchRecordings();
}, []);
// Re-render every second so time-based bucketing updates without a refresh
const [now, setNow] = useState(dayjs());
useEffect(() => {
const interval = setInterval(() => setNow(dayjs()), 1000);
return () => clearInterval(interval);
}, []);
// Categorize recordings
const { inProgress, upcoming, completed } = useMemo(() => {
const inProgress = [];
const upcoming = [];
const completed = [];
const list = Array.isArray(recordings) ? recordings : Object.values(recordings || {});
// ID-based dedupe guard in case store returns duplicates
const seenIds = new Set();
for (const rec of list) {
if (rec && rec.id != null) {
const k = String(rec.id);
if (seenIds.has(k)) continue;
seenIds.add(k);
}
const s = dayjs(rec.start_time);
const e = dayjs(rec.end_time);
const status = rec.custom_properties?.status;
if (status === 'interrupted' || status === 'completed') {
completed.push(rec);
} else {
if (now.isAfter(s) && now.isBefore(e)) inProgress.push(rec);
else if (now.isBefore(s)) upcoming.push(rec);
else completed.push(rec);
}
}
// Deduplicate in-progress and upcoming by program id or channel+slot
const dedupeByProgramOrSlot = (arr) => {
const out = [];
const sigs = new Set();
for (const r of arr) {
const cp = r.custom_properties || {};
const pr = cp.program || {};
const sig = pr?.id != null ? `id:${pr.id}` : `slot:${r.channel}|${r.start_time}|${r.end_time}|${(pr.title||'')}`;
if (sigs.has(sig)) continue;
sigs.add(sig);
out.push(r);
}
return out;
};
const inProgressDedup = dedupeByProgramOrSlot(inProgress).sort((a, b) => dayjs(b.start_time) - dayjs(a.start_time));
// Group upcoming by series title+tvg_id (keep only next episode)
const grouped = new Map();
const upcomingDedup = dedupeByProgramOrSlot(upcoming).sort((a, b) => dayjs(a.start_time) - dayjs(b.start_time));
for (const rec of upcomingDedup) {
const cp = rec.custom_properties || {};
const prog = cp.program || {};
const key = `${prog.tvg_id || ''}|${(prog.title || '').toLowerCase()}`;
if (!grouped.has(key)) {
grouped.set(key, { rec, count: 1 });
} else {
const entry = grouped.get(key);
entry.count += 1;
}
}
const upcomingGrouped = Array.from(grouped.values()).map((e) => {
const item = { ...e.rec };
item._group_count = e.count;
return item;
});
completed.sort((a, b) => dayjs(b.end_time) - dayjs(a.end_time));
return { inProgress: inProgressDedup, upcoming: upcomingGrouped, completed };
}, [recordings]);
return (
<Box style={{ padding: 10 }}>
<Button
@ -134,16 +611,103 @@ const DVRPage = () => {
>
New Recording
</Button>
<SimpleGrid cols={5} spacing="md" style={{ paddingTop: 10 }}>
{Object.values(recordings).map((recording) => (
<RecordingCard recording={recording} />
))}
</SimpleGrid>
<Stack gap="lg" style={{ paddingTop: 12 }}>
<div>
<Group justify="space-between" mb={8}>
<Title order={4}>Currently Recording</Title>
<Badge color="red.6">{inProgress.length}</Badge>
</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} />
))}
{inProgress.length === 0 && (
<Text size="sm" c="dimmed">
Nothing recording right now.
</Text>
)}
</SimpleGrid>
</div>
<div>
<Group justify="space-between" mb={8}>
<Title order={4}>Upcoming Recordings</Title>
<Badge color="yellow.6">{upcoming.length}</Badge>
</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} />
))}
{upcoming.length === 0 && (
<Text size="sm" c="dimmed">
No upcoming recordings.
</Text>
)}
</SimpleGrid>
</div>
<div>
<Group justify="space-between" mb={8}>
<Title order={4}>Previously Recorded</Title>
<Badge color="gray.6">{completed.length}</Badge>
</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} />
))}
{completed.length === 0 && (
<Text size="sm" c="dimmed">
No completed recordings yet.
</Text>
)}
</SimpleGrid>
</div>
</Stack>
<RecordingForm
isOpen={recordingModalOpen}
onClose={closeRecordingModal}
/>
{/* Details Modal */}
{detailsRecording && (
<RecordingDetailsModal
opened={detailsOpen}
onClose={closeDetails}
recording={detailsRecording}
channel={channels[detailsRecording.channel]}
posterUrl={(
detailsRecording.custom_properties?.poster_logo_id
? `/api/channels/logos/${detailsRecording.custom_properties.poster_logo_id}/cache/`
: detailsRecording.custom_properties?.poster_url || channels[detailsRecording.channel]?.logo?.cache_url
) || '/logo.png'}
env_mode={useSettingsStore.getState().environment.env_mode}
onWatchLive={() => {
const rec = detailsRecording;
const now = dayjs();
const s = dayjs(rec.start_time);
const e = dayjs(rec.end_time);
if (now.isAfter(s) && now.isBefore(e)) {
// call into child RecordingCard behavior by constructing a URL like there
const channel = channels[rec.channel];
if (!channel) return;
let url = `/proxy/ts/stream/${channel.uuid}`;
if (useSettingsStore.getState().environment.env_mode === 'dev') {
url = `${window.location.protocol}//${window.location.hostname}:5656${url}`;
}
useVideoStore.getState().showVideo(url, 'live');
}
}}
onWatchRecording={() => {
let fileUrl = detailsRecording.custom_properties?.file_url || detailsRecording.custom_properties?.output_file_url;
if (!fileUrl) return;
if (useSettingsStore.getState().environment.env_mode === 'dev' && fileUrl.startsWith('/')) {
fileUrl = `${window.location.protocol}//${window.location.hostname}:5656${fileUrl}`;
}
useVideoStore.getState().showVideo(fileUrl, 'vod', { name: detailsRecording.custom_properties?.program?.title || 'Recording', logo: { url: (detailsRecording.custom_properties?.poster_logo_id ? `/api/channels/logos/${detailsRecording.custom_properties.poster_logo_id}/cache/` : channels[detailsRecording.channel]?.logo?.cache_url) || '/logo.png' } });
}}
/>
)}
</Box>
);
};

View file

@ -21,6 +21,8 @@ import {
ActionIcon,
Tooltip,
Transition,
Modal,
Stack,
} from '@mantine/core';
import { Search, X, Clock, Video, Calendar, Play } from 'lucide-react';
import './guide.css';
@ -50,6 +52,11 @@ export default function TVChannelGuide({ startDate, endDate }) {
const [now, setNow] = useState(dayjs());
const [expandedProgramId, setExpandedProgramId] = useState(null); // Track expanded program
const [recordingForProgram, setRecordingForProgram] = useState(null);
const [recordChoiceOpen, setRecordChoiceOpen] = useState(false);
const [recordChoiceProgram, setRecordChoiceProgram] = useState(null);
const [existingRuleMode, setExistingRuleMode] = useState(null);
const [rulesOpen, setRulesOpen] = useState(false);
const [rules, setRules] = useState([]);
const [loading, setLoading] = useState(true);
const [initialScrollComplete, setInitialScrollComplete] = useState(false);
@ -282,19 +289,61 @@ export default function TVChannelGuide({ startDate, endDate }) {
);
}
const record = async (program) => {
const openRecordChoice = async (program) => {
setRecordChoiceProgram(program);
setRecordChoiceOpen(true);
try {
const rules = await API.listSeriesRules();
const rule = (rules || []).find((r) => String(r.tvg_id) === String(program.tvg_id));
setExistingRuleMode(rule ? rule.mode : null);
} catch {}
// Also detect if this program already has a scheduled recording
try {
const rec = (recordings || []).find((r) => r?.custom_properties?.program?.id == program.id);
setRecordingForProgram(rec || null);
} catch {}
};
const recordOne = async (program) => {
const channel = findChannelByTvgId(program.tvg_id);
await API.createRecording({
channel: `${channel.id}`,
start_time: program.start_time,
end_time: program.end_time,
custom_properties: {
program,
},
custom_properties: { program },
});
notifications.show({ title: 'Recording scheduled' });
};
const saveSeriesRule = async (program, mode) => {
await API.createSeriesRule({ tvg_id: program.tvg_id, mode, title: program.title });
await API.evaluateSeriesRules(program.tvg_id);
// Refresh recordings so icons and DVR reflect new schedules
try {
await useChannelsStore.getState().fetchRecordings();
} catch (e) {
console.warn('Failed to refresh recordings after saving series rule', e);
}
notifications.show({ title: mode === 'new' ? 'Record new episodes' : 'Record all episodes' });
};
const openRules = async () => {
setRulesOpen(true);
try {
const r = await API.listSeriesRules();
setRules(r);
} catch (e) {
// handled by API
}
};
const deleteAllUpcoming = async () => {
const ok = window.confirm('Delete ALL upcoming recordings?');
if (!ok) return;
await API.deleteAllUpcomingRecordings();
try { await useChannelsStore.getState().fetchRecordings(); } catch {}
};
// The Watch Now click => show floating video
const showVideo = useVideoStore((s) => s.showVideo);
function handleWatchStream(program) {
@ -671,8 +720,8 @@ export default function TVChannelGuide({ startDate, endDate }) {
{isExpanded && (
<Box style={{ marginTop: 'auto' }}>
<Flex gap="md" justify="flex-end" mt={8}>
{/* Only show Record button if not already recording AND not in the past */}
{!recording && !isPast && (
{/* Always show Record for not-past; it opens options (schedule/remove) */}
{!isPast && (
<Button
leftSection={<Calendar size={14} />}
variant="filled"
@ -680,7 +729,7 @@ export default function TVChannelGuide({ startDate, endDate }) {
size="xs"
onClick={(e) => {
e.stopPropagation();
record(program);
openRecordChoice(program);
}}
>
Record
@ -873,6 +922,13 @@ export default function TVChannelGuide({ startDate, endDate }) {
</Button>
)}
<Button variant="light" size="sm" onClick={openRules}>
Series Rules
</Button>
<Button variant="light" color="red" size="sm" onClick={deleteAllUpcoming}>
Delete upcoming
</Button>
<Text size="sm" color="dimmed">
{filteredChannels.length}{' '}
{filteredChannels.length === 1 ? 'channel' : 'channels'}
@ -1298,7 +1354,98 @@ export default function TVChannelGuide({ startDate, endDate }) {
</Box>
</Box>
{/* Modal removed since we're using expanded rows instead */}
{/* Record choice modal */}
{recordChoiceOpen && recordChoiceProgram && (
<Modal
opened={recordChoiceOpen}
onClose={() => setRecordChoiceOpen(false)}
title={`Record: ${recordChoiceProgram.title}`}
centered
radius="md"
zIndex={9999}
overlayProps={{ color: '#000', backgroundOpacity: 0.55, blur: 0 }}
styles={{
content: { backgroundColor: '#18181B', color: 'white' },
header: { backgroundColor: '#18181B', color: 'white', borderBottom: '1px solid #27272A' },
title: { color: 'white' },
}}
>
<Flex direction="column" gap="sm">
<Button onClick={() => { recordOne(recordChoiceProgram); setRecordChoiceOpen(false); }}>Just this one</Button>
<Button variant="light" onClick={() => { saveSeriesRule(recordChoiceProgram, 'all'); setRecordChoiceOpen(false); }}>Every episode</Button>
<Button variant="light" onClick={() => { saveSeriesRule(recordChoiceProgram, 'new'); setRecordChoiceOpen(false); }}>New episodes only</Button>
{recordingForProgram && (
<>
<Button color="orange" variant="light" onClick={async () => {
try { await API.deleteRecording(recordingForProgram.id); } catch {}
try { await useChannelsStore.getState().fetchRecordings(); } catch {}
setRecordChoiceOpen(false);
}}>Remove this recording</Button>
<Button color="red" variant="light" onClick={async () => {
try {
await API.bulkRemoveSeriesRecordings({ tvg_id: recordChoiceProgram.tvg_id, title: recordChoiceProgram.title, scope: 'title' });
await useChannelsStore.getState().fetchRecordings();
} catch {}
setRecordChoiceOpen(false);
}}>Remove this series (scheduled)</Button>
</>
)}
{existingRuleMode && (
<Button color="red" variant="subtle" onClick={async () => { await API.deleteSeriesRule(recordChoiceProgram.tvg_id); setExistingRuleMode(null); setRecordChoiceOpen(false); }}>Remove series rule ({existingRuleMode})</Button>
)}
</Flex>
</Modal>
)}
{/* Series rules modal */}
{rulesOpen && (
<Modal
opened={rulesOpen}
onClose={() => setRulesOpen(false)}
title="Series Recording Rules"
centered
radius="md"
zIndex={9999}
overlayProps={{ color: '#000', backgroundOpacity: 0.55, blur: 0 }}
styles={{
content: { backgroundColor: '#18181B', color: 'white' },
header: { backgroundColor: '#18181B', color: 'white', borderBottom: '1px solid #27272A' },
title: { color: 'white' },
}}
>
<Stack gap="sm">
{(!rules || rules.length === 0) && (
<Text size="sm" c="dimmed">No series rules configured</Text>
)}
{rules && rules.map((r) => (
<Flex key={`${r.tvg_id}-${r.mode}`} justify="space-between" align="center">
<Text size="sm">{r.title || r.tvg_id} {r.mode === 'new' ? 'New episodes' : 'Every episode'}</Text>
<Group gap="xs">
<Button size="xs" variant="subtle" onClick={async () => {
await API.evaluateSeriesRules(r.tvg_id);
try { await useChannelsStore.getState().fetchRecordings(); } catch {}
notifications.show({ title: 'Evaluated', message: 'Checked for episodes' });
}}>Evaluate Now</Button>
<Button size="xs" variant="light" color="orange" onClick={async () => {
await API.bulkRemoveSeriesRecordings({ tvg_id: r.tvg_id, title: r.title, scope: 'title' });
try { await useChannelsStore.getState().fetchRecordings(); } catch {}
}}>Remove this series (scheduled)</Button>
<Button size="xs" variant="light" color="red" onClick={async () => {
await API.bulkRemoveSeriesRecordings({ tvg_id: r.tvg_id, scope: 'channel' });
try { await useChannelsStore.getState().fetchRecordings(); } catch {}
}}>Remove all on channel</Button>
<Button size="xs" color="red" variant="light" onClick={async () => {
await API.deleteSeriesRule(r.tvg_id);
const updated = await API.listSeriesRules();
setRules(updated);
notifications.show({ title: 'Rule removed' });
}}>Remove</Button>
</Group>
</Flex>
))}
</Stack>
</Modal>
)}
</Box>
);
}

View file

@ -403,6 +403,36 @@ const SettingsPage = () => {
{authUser.user_level == USER_LEVELS.ADMIN && (
<>
<Accordion.Item value="dvr-settings">
<Accordion.Control>DVR Recording Paths</Accordion.Control>
<Accordion.Panel>
<form onSubmit={form.onSubmit(onSubmit)}>
<Stack gap="sm">
<TextInput
label="TV Path Template"
description="Supports {show}, {season}, {episode}, {sub_title}, {channel}, {year}, {start}, {end}. Use format specifiers like {season:02d}. Relative paths are under your library dir."
placeholder="Recordings/TV_Shows/{show}/S{season:02d}E{episode:02d}.mkv"
{...form.getInputProps('dvr-tv-template')}
key={form.key('dvr-tv-template')}
id={settings['dvr-tv-template']?.id || 'dvr-tv-template'}
name={settings['dvr-tv-template']?.key || 'dvr-tv-template'}
/>
<TextInput
label="Movie Path Template"
description="Supports {title}, {year}, {channel}, {start}, {end}. Relative paths are under your library dir."
placeholder="Recordings/Movies/{title} ({year}).mkv"
{...form.getInputProps('dvr-movie-template')}
key={form.key('dvr-movie-template')}
id={settings['dvr-movie-template']?.id || 'dvr-movie-template'}
name={settings['dvr-movie-template']?.key || 'dvr-movie-template'}
/>
<Flex mih={50} gap="xs" justify="flex-end" align="flex-end">
<Button type="submit" variant="default">Save</Button>
</Flex>
</Stack>
</form>
</Accordion.Panel>
</Accordion.Item>
<Accordion.Item value="stream-settings">
<Accordion.Control>Stream Settings</Accordion.Control>
<Accordion.Panel>