mirror of
https://github.com/Dispatcharr/Dispatcharr.git
synced 2026-01-23 02:35:14 +00:00
DVR update
This commit is contained in:
parent
5806464406
commit
00cc83882a
13 changed files with 2233 additions and 64 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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:
|
||||
|
|
|
|||
27
core/migrations/0015_dvr_templates.py
Normal file
27
core/migrations/0015_dvr_templates.py
Normal 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),
|
||||
]
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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':
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue