From 00cc83882ad68b7629ba8442f1f350761d37ec4e Mon Sep 17 00:00:00 2001 From: Dispatcharr Date: Wed, 3 Sep 2025 21:35:45 -0500 Subject: [PATCH] DVR update --- apps/channels/api_urls.py | 11 + apps/channels/api_views.py | 218 +++++- apps/channels/apps.py | 9 + apps/channels/signals.py | 10 +- apps/channels/tasks.py | 1043 ++++++++++++++++++++++++- apps/epg/tasks.py | 6 + core/migrations/0015_dvr_templates.py | 27 + core/models.py | 44 ++ frontend/src/WebSocket.jsx | 26 + frontend/src/api.js | 74 ++ frontend/src/pages/DVR.jsx | 636 ++++++++++++++- frontend/src/pages/Guide.jsx | 163 +++- frontend/src/pages/Settings.jsx | 30 + 13 files changed, 2233 insertions(+), 64 deletions(-) create mode 100644 core/migrations/0015_dvr_templates.py diff --git a/apps/channels/api_urls.py b/apps/channels/api_urls.py index 469ec773..7cfdc1b1 100644 --- a/apps/channels/api_urls.py +++ b/apps/channels/api_urls.py @@ -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//streams/', GetChannelStreamsAPIView.as_view(), name='get_channel_streams'), path('profiles//channels//', UpdateChannelMembershipAPIView.as_view(), name='update_channel_membership'), path('profiles//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//', DeleteSeriesRuleAPIView.as_view(), name='delete_series_rule'), + path('recordings/bulk-delete-upcoming/', BulkDeleteUpcomingRecordingsAPIView.as_view(), name='bulk_delete_upcoming_recordings'), ] urlpatterns += router.urls diff --git a/apps/channels/api_views.py b/apps/channels/api_views.py index 3b91d42c..d2d0b923 100644 --- a/apps/channels/api_views.py +++ b/apps/channels/api_views.py @@ -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}) diff --git a/apps/channels/apps.py b/apps/channels/apps.py index d6d29a80..7761a15e 100644 --- a/apps/channels/apps.py +++ b/apps/channels/apps.py @@ -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 diff --git a/apps/channels/signals.py b/apps/channels/signals.py index b8656ecc..d7a7414d 100644 --- a/apps/channels/signals.py +++ b/apps/channels/signals.py @@ -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) diff --git a/apps/channels/tasks.py b/apps/channels/tasks.py index a551028f..b70b8043 100755 --- a/apps/channels/tasks.py +++ b/apps/channels/tasks.py @@ -7,7 +7,7 @@ import requests import time import json import subprocess -from datetime import datetime +from datetime import datetime, timedelta import gc from celery import shared_task @@ -23,6 +23,7 @@ from asgiref.sync import async_to_sync from asgiref.sync import async_to_sync from channels.layers import get_channel_layer import tempfile +from urllib.parse import quote logger = logging.getLogger(__name__) @@ -240,15 +241,319 @@ def match_epg_channels(): cleanup_memory(log_usage=True, force_collection=True) +def evaluate_series_rules_impl(tvg_id: str | None = None): + """Synchronous implementation of series rule evaluation; returns details for debugging.""" + from django.utils import timezone + from apps.channels.models import Recording, Channel + from apps.epg.models import EPGData, ProgramData + + rules = CoreSettings.get_dvr_series_rules() + result = {"scheduled": 0, "details": []} + if not isinstance(rules, list) or not rules: + return result + + # Optionally filter for tvg_id + if tvg_id: + rules = [r for r in rules if str(r.get("tvg_id")) == str(tvg_id)] + if not rules: + result["details"].append({"tvg_id": tvg_id, "status": "no_rule"}) + return result + + now = timezone.now() + horizon = now + timedelta(days=7) + + # Preload existing recordings' program ids to avoid duplicates + existing_program_ids = set() + for rec in Recording.objects.all().only("custom_properties"): + try: + pid = rec.custom_properties.get("program", {}).get("id") if rec.custom_properties else None + if pid is not None: + # Normalize to string for consistent comparisons + existing_program_ids.add(str(pid)) + except Exception: + continue + + for rule in rules: + rv_tvg = str(rule.get("tvg_id") or "").strip() + mode = (rule.get("mode") or "all").lower() + series_title = (rule.get("title") or "").strip() + norm_series = normalize_name(series_title) if series_title else None + if not rv_tvg: + result["details"].append({"tvg_id": rv_tvg, "status": "invalid_rule"}) + continue + + epg = EPGData.objects.filter(tvg_id=rv_tvg).first() + if not epg: + result["details"].append({"tvg_id": rv_tvg, "status": "no_epg_match"}) + continue + + programs_qs = ProgramData.objects.filter( + epg=epg, + start_time__gte=now, + start_time__lte=horizon, + ) + if series_title: + programs_qs = programs_qs.filter(title__iexact=series_title) + programs = list(programs_qs.order_by("start_time")) + # Fallback: if no direct matches and we have a title, try normalized comparison in Python + if series_title and not programs: + all_progs = ProgramData.objects.filter( + epg=epg, + start_time__gte=now, + start_time__lte=horizon, + ).only("id", "title", "start_time", "end_time", "custom_properties", "tvg_id") + programs = [p for p in all_progs if normalize_name(p.title) == norm_series] + + channel = Channel.objects.filter(epg_data=epg).order_by("channel_number").first() + if not channel: + result["details"].append({"tvg_id": rv_tvg, "status": "no_channel_for_epg"}) + continue + + # + # Many providers list multiple future airings of the same episode + # (e.g., prime-time and a late-night repeat). Previously we scheduled + # a recording for each airing which shows up as duplicates in the DVR. + # + # To avoid that, we collapse programs to the earliest airing per + # unique episode using the best identifier available: + # - season+episode from ProgramData.custom_properties + # - onscreen_episode (e.g., S08E03) + # - sub_title (episode name), scoped by tvg_id+series title + # If none of the above exist, we fall back to keeping each program + # (usually movies or specials without episode identifiers). + # + def _episode_key(p: "ProgramData"): + try: + props = p.custom_properties or {} + season = props.get("season") + episode = props.get("episode") + onscreen = props.get("onscreen_episode") + except Exception: + season = episode = onscreen = None + base = f"{p.tvg_id or ''}|{(p.title or '').strip().lower()}" # series scope + if season is not None and episode is not None: + return f"{base}|s{season}e{episode}" + if onscreen: + return f"{base}|{str(onscreen).strip().lower()}" + if p.sub_title: + return f"{base}|{p.sub_title.strip().lower()}" + # No reliable episode identity; use the program id to avoid over-merging + return f"id:{p.id}" + + # Optionally filter to only brand-new episodes before grouping + if mode == "new": + filtered = [] + for p in programs: + try: + if (p.custom_properties or {}).get("new"): + filtered.append(p) + except Exception: + pass + programs = filtered + + # Pick the earliest airing for each episode key + earliest_by_key = {} + for p in programs: + k = _episode_key(p) + cur = earliest_by_key.get(k) + if cur is None or p.start_time < cur.start_time: + earliest_by_key[k] = p + + unique_programs = list(earliest_by_key.values()) + + created_here = 0 + for prog in unique_programs: + try: + # Skip if already scheduled by program id + if str(prog.id) in existing_program_ids: + continue + # Extra guard: skip if a recording exists for the same channel + timeslot + try: + from django.db.models import Q + if Recording.objects.filter( + channel=channel, + start_time=prog.start_time, + end_time=prog.end_time, + ).filter(Q(custom_properties__program__id=prog.id) | Q(custom_properties__program__title=prog.title)).exists(): + continue + except Exception: + continue # already scheduled/recorded + + rec = Recording.objects.create( + channel=channel, + start_time=prog.start_time, + end_time=prog.end_time, + custom_properties={ + "program": { + "id": prog.id, + "tvg_id": prog.tvg_id, + "title": prog.title, + "sub_title": prog.sub_title, + "description": prog.description, + "start_time": prog.start_time.isoformat(), + "end_time": prog.end_time.isoformat(), + } + }, + ) + existing_program_ids.add(str(prog.id)) + created_here += 1 + try: + prefetch_recording_artwork.apply_async(args=[rec.id], countdown=1) + except Exception: + pass + except Exception as e: + result["details"].append({"tvg_id": rv_tvg, "status": "error", "error": str(e)}) + continue + result["scheduled"] += created_here + result["details"].append({"tvg_id": rv_tvg, "title": series_title, "status": "ok", "created": created_here}) + + # Notify frontend to refresh + try: + channel_layer = get_channel_layer() + async_to_sync(channel_layer.group_send)( + 'updates', + {'type': 'update', 'data': {"success": True, "type": "recordings_refreshed", "scheduled": result["scheduled"]}}, + ) + except Exception: + pass + + return result + + @shared_task -def run_recording(channel_id, start_time_str, end_time_str): +def evaluate_series_rules(tvg_id: str | None = None): + return evaluate_series_rules_impl(tvg_id) + + +@shared_task +def _safe_name(s): + try: + import re + s = s or "" + # Remove forbidden filename characters and normalize spaces + s = re.sub(r'[\\/:*?"<>|]+', '', s) + s = s.strip() + return s + except Exception: + return s or "" + + +def _parse_epg_tv_movie_info(program): + """Return tuple (is_movie, season, episode, year, sub_title) from EPG ProgramData if available.""" + is_movie = False + season = None + episode = None + year = None + sub_title = program.get('sub_title') if isinstance(program, dict) else None + try: + from apps.epg.models import ProgramData + prog_id = program.get('id') if isinstance(program, dict) else None + epg_program = ProgramData.objects.filter(id=prog_id).only('custom_properties').first() if prog_id else None + if epg_program and epg_program.custom_properties: + cp = epg_program.custom_properties + # Determine categories + cats = [c.lower() for c in (cp.get('categories') or []) if isinstance(c, str)] + is_movie = 'movie' in cats or 'film' in cats + season = cp.get('season') + episode = cp.get('episode') + onscreen = cp.get('onscreen_episode') + if (season is None or episode is None) and isinstance(onscreen, str): + import re as _re + m = _re.search(r'[sS](\d+)[eE](\d+)', onscreen) + if m: + season = season or int(m.group(1)) + episode = episode or int(m.group(2)) + d = cp.get('date') + if d: + year = str(d)[:4] + except Exception: + pass + return is_movie, season, episode, year, sub_title + + +def _build_output_paths(channel, program, start_time, end_time): + """ + Build (final_path, temp_ts_path, final_filename) using DVR templates. + """ + from core.models import CoreSettings + # Root for DVR recordings: prefer DISPATCHARR_RECORDINGS_DIR, fallback to /data/recordings + library_root = os.environ.get('DISPATCHARR_RECORDINGS_DIR', '/data/recordings') + + is_movie, season, episode, year, sub_title = _parse_epg_tv_movie_info(program) + show = _safe_name(program.get('title') if isinstance(program, dict) else channel.name) + title = _safe_name(program.get('title') if isinstance(program, dict) else channel.name) + sub_title = _safe_name(sub_title) + season = int(season) if season is not None else 0 + episode = int(episode) if episode is not None else 0 + year = year or str(start_time.year) + + values = { + 'show': show, + 'title': title, + 'sub_title': sub_title, + 'season': season, + 'episode': episode, + 'year': year, + 'channel': _safe_name(channel.name), + 'start': start_time.strftime('%Y%m%d_%H%M%S'), + 'end': end_time.strftime('%Y%m%d_%H%M%S'), + } + + template = CoreSettings.get_dvr_movie_template() if is_movie else CoreSettings.get_dvr_tv_template() + # If TV and no season/episode info, use datetime fallback under TVShow//.mkv + rel_path = None + if not is_movie and (season == 0 or episode == 0): + # User-requested fallback when S/E missing + rel_path = f"TVShow/{show}/{values['start']}.mkv" + if not rel_path: + # Allow templates that omit extension; ensure .mkv + try: + rel_path = template.format(**values) + except Exception: + # Fallback minimal + rel_path = f"Recordings/{show}/S{season:02d}E{episode:02d}.mkv" + # If template contains a leading "Recordings/" (legacy), drop it because we already root at recordings dir + if rel_path.startswith(('Recordings/', 'recordings/')): + rel_path = rel_path.split('/', 1)[1] + if not rel_path.lower().endswith('.mkv'): + rel_path = f"{rel_path}.mkv" + + # Normalize path (strip ./) + if rel_path.startswith('./'): + rel_path = rel_path[2:] + final_path = rel_path if rel_path.startswith('/') else os.path.join(library_root, rel_path) + final_path = os.path.normpath(final_path) + # Ensure directory exists + os.makedirs(os.path.dirname(final_path), exist_ok=True) + + # Derive temp TS path in same directory + base_no_ext = os.path.splitext(os.path.basename(final_path))[0] + temp_ts_path = os.path.join(os.path.dirname(final_path), f"{base_no_ext}.ts") + return final_path, temp_ts_path, os.path.basename(final_path) + + +@shared_task +def run_recording(recording_id, channel_id, start_time_str, end_time_str): + """ + Execute a scheduled recording for the given channel/recording. + + Enhancements: + - Accepts recording_id so we can persist metadata back to the Recording row + - Persists basic file info (name/path) to Recording.custom_properties + - Attempts to capture stream stats from TS proxy (codec, resolution, fps, etc.) + - Attempts to capture a poster (via program.custom_properties) and store a Logo reference + """ channel = Channel.objects.get(id=channel_id) start_time = datetime.fromisoformat(start_time_str) end_time = datetime.fromisoformat(end_time_str) duration_seconds = int((end_time - start_time).total_seconds()) - filename = f'{slugify(channel.name)}-{start_time.strftime("%Y-%m-%d_%H-%M-%S")}.mp4' + # Build output paths from templates + # We need program info; will refine after we load Recording cp below + filename = None + final_path = None + temp_ts_path = None channel_layer = get_channel_layer() @@ -261,21 +566,389 @@ def run_recording(channel_id, start_time_str, end_time_str): ) logger.info(f"Starting recording for channel {channel.name}") - with requests.get(f"http://localhost:5656/proxy/ts/stream/{channel.uuid}", headers={ - 'User-Agent': 'Dispatcharr-DVR', - }, stream=True) as response: - # Raise an exception for bad responses (4xx, 5xx) - response.raise_for_status() - # Open the file in write-binary mode - with open(f"/data/recordings/{filename}", 'wb') as file: - start_time = time.time() # Start the timer - for chunk in response.iter_content(chunk_size=8192): # 8KB chunks - if time.time() - start_time > duration_seconds: - print(f"Timeout reached: {duration_seconds} seconds") + # Try to resolve the Recording row up front + recording_obj = None + try: + from .models import Recording, Logo + recording_obj = Recording.objects.get(id=recording_id) + # Prime custom_properties with file info/status + cp = recording_obj.custom_properties or {} + cp.update({ + "status": "recording", + "started_at": str(datetime.now()), + }) + # Provide a predictable playback URL for the frontend + cp["file_url"] = f"/api/channels/recordings/{recording_id}/file/" + cp["output_file_url"] = cp["file_url"] + + # Determine program info (may include id for deeper details) + program = cp.get("program") or {} + final_path, temp_ts_path, filename = _build_output_paths(channel, program, start_time, end_time) + cp["file_name"] = filename + cp["file_path"] = final_path + cp["_temp_file_path"] = temp_ts_path + + # Resolve poster the same way VODs do: + # 1) Prefer image(s) from EPG Program custom_properties (images/icon) + # 2) Otherwise reuse an existing VOD logo matching title (Movie/Series) + # 3) Otherwise save any direct poster URL from provided program fields + program = (cp.get("program") or {}) if isinstance(cp, dict) else {} + + def pick_best_image_from_epg_props(epg_props): + try: + images = epg_props.get("images") or [] + if not isinstance(images, list): + return None + # Prefer poster/cover and larger sizes + size_order = {"xxl": 6, "xl": 5, "l": 4, "m": 3, "s": 2, "xs": 1} + def score(img): + t = (img.get("type") or "").lower() + size = (img.get("size") or "").lower() + return ( + 2 if t in ("poster", "cover") else 1, + size_order.get(size, 0) + ) + best = None + for im in images: + if not isinstance(im, dict): + continue + url = im.get("url") + if not url: + continue + if best is None or score(im) > score(best): + best = im + return best.get("url") if best else None + except Exception: + return None + + poster_logo_id = None + poster_url = None + + # Try EPG Program custom_properties by ID + try: + from apps.epg.models import ProgramData + prog_id = program.get("id") + if prog_id: + epg_program = ProgramData.objects.filter(id=prog_id).only("custom_properties").first() + if epg_program and epg_program.custom_properties: + epg_props = epg_program.custom_properties or {} + poster_url = pick_best_image_from_epg_props(epg_props) + if not poster_url: + icon = epg_props.get("icon") + if isinstance(icon, str) and icon: + poster_url = icon + except Exception as e: + logger.debug(f"EPG image lookup failed: {e}") + + # Fallback: reuse VOD Logo by matching title + if not poster_url and not poster_logo_id: + try: + from apps.vod.models import Movie, Series + title = program.get("title") or channel.name + vod_logo = None + movie = Movie.objects.filter(name__iexact=title).select_related("logo").first() + if movie and movie.logo: + vod_logo = movie.logo + if not vod_logo: + series = Series.objects.filter(name__iexact=title).select_related("logo").first() + if series and series.logo: + vod_logo = series.logo + if vod_logo: + poster_logo_id = vod_logo.id + except Exception as e: + logger.debug(f"VOD logo fallback failed: {e}") + + # External metadata lookups (TMDB/OMDb) when EPG/VOD didn't provide an image + if not poster_url and not poster_logo_id: + try: + tmdb_key = os.environ.get('TMDB_API_KEY') + omdb_key = os.environ.get('OMDB_API_KEY') + title = (program.get('title') or channel.name or '').strip() + year = None + imdb_id = None + + # Try to derive year and imdb from EPG program custom_properties + try: + from apps.epg.models import ProgramData + prog_id = program.get('id') + epg_program = ProgramData.objects.filter(id=prog_id).only('custom_properties').first() if prog_id else None + if epg_program and epg_program.custom_properties: + d = epg_program.custom_properties.get('date') + if d and len(str(d)) >= 4: + year = str(d)[:4] + imdb_id = epg_program.custom_properties.get('imdb.com_id') or imdb_id + except Exception: + pass + + # TMDB: by IMDb ID + if not poster_url and tmdb_key and imdb_id: + try: + url = f"https://api.themoviedb.org/3/find/{quote(imdb_id)}?api_key={tmdb_key}&external_source=imdb_id" + resp = requests.get(url, timeout=5) + if resp.ok: + data = resp.json() or {} + picks = [] + for k in ('movie_results', 'tv_results', 'tv_episode_results', 'tv_season_results'): + lst = data.get(k) or [] + picks.extend(lst) + poster_path = None + for item in picks: + if item.get('poster_path'): + poster_path = item['poster_path'] + break + if poster_path: + poster_url = f"https://image.tmdb.org/t/p/w780{poster_path}" + except Exception: + pass + + # TMDB: by title (and year if available) + if not poster_url and tmdb_key and title: + try: + q = quote(title) + extra = f"&year={year}" if year else "" + url = f"https://api.themoviedb.org/3/search/multi?api_key={tmdb_key}&query={q}{extra}" + resp = requests.get(url, timeout=5) + if resp.ok: + data = resp.json() or {} + results = data.get('results') or [] + results.sort(key=lambda x: float(x.get('popularity') or 0), reverse=True) + for item in results: + if item.get('poster_path'): + poster_url = f"https://image.tmdb.org/t/p/w780{item['poster_path']}" + break + except Exception: + pass + + # OMDb fallback + if not poster_url and omdb_key: + try: + if imdb_id: + url = f"https://www.omdbapi.com/?apikey={omdb_key}&i={quote(imdb_id)}" + elif title: + yy = f"&y={year}" if year else "" + url = f"https://www.omdbapi.com/?apikey={omdb_key}&t={quote(title)}{yy}" + else: + url = None + if url: + resp = requests.get(url, timeout=5) + if resp.ok: + data = resp.json() or {} + p = data.get('Poster') + if p and p != 'N/A': + poster_url = p + except Exception: + pass + except Exception as e: + logger.debug(f"External poster lookup failed: {e}") + + # Keyless fallback providers (no API keys required) + if not poster_url and not poster_logo_id: + try: + title = (program.get('title') or channel.name or '').strip() + if title: + # 1) TVMaze (TV shows) - singlesearch by title + try: + url = f"https://api.tvmaze.com/singlesearch/shows?q={quote(title)}" + resp = requests.get(url, timeout=5) + if resp.ok: + data = resp.json() or {} + img = (data.get('image') or {}) + p = img.get('original') or img.get('medium') + if p: + poster_url = p + except Exception: + pass + + # 2) iTunes Search API (movies or tv shows) + if not poster_url: + try: + for media in ('movie', 'tvShow'): + url = f"https://itunes.apple.com/search?term={quote(title)}&media={media}&limit=1" + resp = requests.get(url, timeout=5) + if resp.ok: + data = resp.json() or {} + results = data.get('results') or [] + if results: + art = results[0].get('artworkUrl100') + if art: + # Scale up to 600x600 by convention + poster_url = art.replace('100x100', '600x600') + break + except Exception: + pass + except Exception as e: + logger.debug(f"Keyless poster lookup failed: {e}") + + # Last: check direct fields on provided program object + if not poster_url and not poster_logo_id: + for key in ("poster", "cover", "cover_big", "image", "icon"): + val = program.get(key) + if isinstance(val, dict): + candidate = val.get("url") + if candidate: + poster_url = candidate + break + elif isinstance(val, str) and val: + poster_url = val break - # Write the chunk to the file - file.write(chunk) + + # Create or assign Logo + if not poster_logo_id and poster_url and len(poster_url) <= 1000: + try: + logo, _ = Logo.objects.get_or_create(url=poster_url, defaults={"name": program.get("title") or channel.name}) + poster_logo_id = logo.id + except Exception as e: + logger.debug(f"Unable to persist poster to Logo: {e}") + + if poster_logo_id: + cp["poster_logo_id"] = poster_logo_id + if poster_url and "poster_url" not in cp: + cp["poster_url"] = poster_url + + # Ensure destination exists so it's visible immediately + try: + os.makedirs(os.path.dirname(final_path), exist_ok=True) + if not os.path.exists(final_path): + open(final_path, 'ab').close() + except Exception: + pass + + recording_obj.custom_properties = cp + recording_obj.save(update_fields=["custom_properties"]) + except Exception as e: + logger.debug(f"Unable to prime Recording metadata: {e}") + interrupted = False + interrupted_reason = None + bytes_written = 0 + + from requests.exceptions import ReadTimeout, ConnectionError as ReqConnectionError, ChunkedEncodingError + + # Determine internal base URL(s) for TS streaming + # Prefer explicit override, then try common ports for debug and docker + explicit = os.environ.get('DISPATCHARR_INTERNAL_TS_BASE_URL') + is_dev = (os.environ.get('DISPATCHARR_ENV', '').lower() == 'dev') or \ + (os.environ.get('DISPATCHARR_DEBUG', '').lower() == 'true') or \ + (os.environ.get('REDIS_HOST', 'redis') in ('localhost', '127.0.0.1')) + candidates = [] + if explicit: + candidates.append(explicit) + if is_dev: + # Debug container typically exposes API on 5656 + candidates.extend(['http://127.0.0.1:5656', 'http://127.0.0.1:9191']) + # Docker service name fallback + candidates.append(os.environ.get('DISPATCHARR_INTERNAL_API_BASE', 'http://web:9191')) + # Last-resort localhost ports + candidates.extend(['http://localhost:5656', 'http://localhost:9191']) + + chosen_base = None + last_error = None + bytes_written = 0 + interrupted = False + interrupted_reason = None + + # We'll attempt each base until we receive some data + for base in candidates: + try: + test_url = f"{base.rstrip('/')}/proxy/ts/stream/{channel.uuid}" + logger.info(f"DVR: trying TS base {base} -> {test_url}") + + with requests.get( + test_url, + headers={ + 'User-Agent': 'Dispatcharr-DVR', + }, + stream=True, + timeout=(10, 15), + ) as response: + response.raise_for_status() + + # Open the file and start copying; if we get any data within a short window, accept this base + got_any_data = False + test_window = 3.0 # seconds to detect first bytes + window_start = time.time() + + with open(temp_ts_path, 'wb') as file: + started_at = time.time() + for chunk in response.iter_content(chunk_size=8192): + if not chunk: + # keep-alives may be empty; continue + if not got_any_data and (time.time() - window_start) > test_window: + break + continue + # We have data + got_any_data = True + chosen_base = base + # Fall through to full recording loop using this same response/connection + file.write(chunk) + bytes_written += len(chunk) + elapsed = time.time() - started_at + if elapsed > duration_seconds: + break + # Continue draining the stream + for chunk2 in response.iter_content(chunk_size=8192): + if not chunk2: + continue + file.write(chunk2) + bytes_written += len(chunk2) + elapsed = time.time() - started_at + if elapsed > duration_seconds: + break + break # exit outer for-loop once we switched to full drain + + # If we wrote any bytes, treat as success and stop trying candidates + if bytes_written > 0: + logger.info(f"DVR: selected TS base {base}; wrote initial {bytes_written} bytes") + break + else: + last_error = f"no_data_from_{base}" + logger.warning(f"DVR: no data received from {base} within {test_window}s, trying next base") + # Clean up empty temp file + try: + if os.path.exists(temp_ts_path) and os.path.getsize(temp_ts_path) == 0: + os.remove(temp_ts_path) + except Exception: + pass + except Exception as e: + last_error = str(e) + logger.warning(f"DVR: attempt failed for base {base}: {e}") + + if chosen_base is None and bytes_written == 0: + interrupted = True + interrupted_reason = f"no_stream_data: {last_error or 'all_bases_failed'}" + else: + # If we ended before reaching planned duration, record reason + actual_elapsed = 0 + try: + actual_elapsed = os.path.getsize(temp_ts_path) and (duration_seconds) # Best effort; we streamed until duration or disconnect above + except Exception: + pass + # We cannot compute accurate elapsed here; fine to leave as is + pass + + # If no bytes were written at all, mark detail + if bytes_written == 0 and not interrupted: + interrupted = True + interrupted_reason = f"no_stream_data: {last_error or 'unknown'}" + + # Update DB status immediately so the UI reflects the change on the event below + try: + if recording_obj is None: + from .models import Recording + recording_obj = Recording.objects.get(id=recording_id) + cp_now = recording_obj.custom_properties or {} + cp_now.update({ + "status": "interrupted" if interrupted else "completed", + "ended_at": str(datetime.now()), + "file_name": filename or cp_now.get("file_name"), + "file_path": final_path or cp_now.get("file_path"), + }) + if interrupted and interrupted_reason: + cp_now["interrupted_reason"] = interrupted_reason + recording_obj.custom_properties = cp_now + recording_obj.save(update_fields=["custom_properties"]) + except Exception as e: + logger.debug(f"Failed to update immediate recording status: {e}") async_to_sync(channel_layer.group_send)( "updates", @@ -284,6 +957,342 @@ def run_recording(channel_id, start_time_str, end_time_str): "data": {"success": True, "type": "recording_ended", "channel": channel.name} }, ) - # After the loop, the file and response are closed automatically. logger.info(f"Finished recording for channel {channel.name}") + + # Remux TS to MKV container + remux_success = False + try: + if temp_ts_path and os.path.exists(temp_ts_path): + subprocess.run([ + "ffmpeg", "-y", "-i", temp_ts_path, "-c", "copy", final_path + ], check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE) + remux_success = os.path.exists(final_path) + # Clean up temp file on success + if remux_success: + try: + os.remove(temp_ts_path) + except Exception: + pass + except Exception as e: + logger.warning(f"MKV remux failed: {e}") + + # Persist final metadata to Recording (status, ended_at, and stream stats if available) + try: + if recording_obj is None: + from .models import Recording + recording_obj = Recording.objects.get(id=recording_id) + + cp = recording_obj.custom_properties or {} + cp.update({ + "ended_at": str(datetime.now()), + }) + if interrupted: + cp["status"] = "interrupted" + if interrupted_reason: + cp["interrupted_reason"] = interrupted_reason + else: + cp["status"] = "completed" + cp["bytes_written"] = bytes_written + cp["remux_success"] = remux_success + + # Try to get stream stats from TS proxy Redis metadata + try: + from core.utils import RedisClient + from apps.proxy.ts_proxy.redis_keys import RedisKeys + from apps.proxy.ts_proxy.constants import ChannelMetadataField + + r = RedisClient.get_client() + if r is not None: + metadata_key = RedisKeys.channel_metadata(str(channel.uuid)) + md = r.hgetall(metadata_key) + if md: + def _gv(bkey): + return md.get(bkey.encode('utf-8')) + + def _d(bkey, cast=str): + v = _gv(bkey) + try: + if v is None: + return None + s = v.decode('utf-8') + return cast(s) if cast is not str else s + except Exception: + return None + + stream_info = {} + # Video fields + for key, caster in [ + (ChannelMetadataField.VIDEO_CODEC, str), + (ChannelMetadataField.RESOLUTION, str), + (ChannelMetadataField.WIDTH, float), + (ChannelMetadataField.HEIGHT, float), + (ChannelMetadataField.SOURCE_FPS, float), + (ChannelMetadataField.PIXEL_FORMAT, str), + (ChannelMetadataField.VIDEO_BITRATE, float), + ]: + val = _d(key, caster) + if val is not None: + stream_info[key] = val + + # Audio fields + for key, caster in [ + (ChannelMetadataField.AUDIO_CODEC, str), + (ChannelMetadataField.SAMPLE_RATE, float), + (ChannelMetadataField.AUDIO_CHANNELS, str), + (ChannelMetadataField.AUDIO_BITRATE, float), + ]: + val = _d(key, caster) + if val is not None: + stream_info[key] = val + + if stream_info: + cp["stream_info"] = stream_info + except Exception as e: + logger.debug(f"Unable to capture stream stats for recording: {e}") + + # Removed: local thumbnail generation. We rely on EPG/VOD/TMDB/OMDb/keyless providers only. + + recording_obj.custom_properties = cp + recording_obj.save(update_fields=["custom_properties"]) + except Exception as e: + logger.debug(f"Unable to finalize Recording metadata: {e}") + + +@shared_task +def recover_recordings_on_startup(): + """ + On service startup, reschedule or resume recordings to handle server restarts. + - For recordings whose window includes 'now': mark interrupted and start a new recording for the remainder. + - For future recordings: ensure a task is scheduled at start_time. + Uses a Redis lock to ensure only one worker runs this recovery. + """ + try: + from django.utils import timezone + from .models import Recording + from core.utils import RedisClient + from .signals import schedule_recording_task + + redis = RedisClient.get_client() + if redis: + lock_key = "dvr:recover_lock" + # Set lock with 60s TTL; only first winner proceeds + if not redis.set(lock_key, "1", ex=60, nx=True): + return "Recovery already in progress" + + now = timezone.now() + + # Resume in-window recordings + active = Recording.objects.filter(start_time__lte=now, end_time__gt=now) + for rec in active: + try: + cp = rec.custom_properties or {} + # Mark interrupted due to restart; will flip to 'recording' when task starts + cp["status"] = "interrupted" + cp["interrupted_reason"] = "server_restarted" + rec.custom_properties = cp + rec.save(update_fields=["custom_properties"]) + + # Start recording for remaining window + run_recording.apply_async( + args=[rec.id, rec.channel_id, str(now), str(rec.end_time)], eta=now + ) + except Exception as e: + logger.warning(f"Failed to resume recording {rec.id}: {e}") + + # Ensure future recordings are scheduled + upcoming = Recording.objects.filter(start_time__gt=now, end_time__gt=now) + for rec in upcoming: + try: + # Schedule task at start_time + task_id = schedule_recording_task(rec) + if task_id: + rec.task_id = task_id + rec.save(update_fields=["task_id"]) + except Exception as e: + logger.warning(f"Failed to schedule recording {rec.id}: {e}") + + return "Recovery complete" + except Exception as e: + logger.error(f"Error during DVR recovery: {e}") + return f"Error: {e}" +def _resolve_poster_for_program(channel_name, program): + """Internal helper that attempts to resolve a poster URL and/or Logo id. + Returns (poster_logo_id, poster_url) where either may be None. + """ + poster_logo_id = None + poster_url = None + + # Try EPG Program images first + try: + from apps.epg.models import ProgramData + prog_id = program.get("id") if isinstance(program, dict) else None + if prog_id: + epg_program = ProgramData.objects.filter(id=prog_id).only("custom_properties").first() + if epg_program and epg_program.custom_properties: + epg_props = epg_program.custom_properties or {} + + def pick_best_image_from_epg_props(epg_props): + images = epg_props.get("images") or [] + if not isinstance(images, list): + return None + size_order = {"xxl": 6, "xl": 5, "l": 4, "m": 3, "s": 2, "xs": 1} + def score(img): + t = (img.get("type") or "").lower() + size = (img.get("size") or "").lower() + return (2 if t in ("poster", "cover") else 1, size_order.get(size, 0)) + best = None + for im in images: + if not isinstance(im, dict): + continue + url = im.get("url") + if not url: + continue + if best is None or score(im) > score(best): + best = im + return best.get("url") if best else None + + poster_url = pick_best_image_from_epg_props(epg_props) + if not poster_url: + icon = epg_props.get("icon") + if isinstance(icon, str) and icon: + poster_url = icon + except Exception: + pass + + # VOD logo fallback by title + if not poster_url and not poster_logo_id: + try: + from apps.vod.models import Movie, Series + title = (program.get("title") if isinstance(program, dict) else None) or channel_name + vod_logo = None + movie = Movie.objects.filter(name__iexact=title).select_related("logo").first() + if movie and movie.logo: + vod_logo = movie.logo + if not vod_logo: + series = Series.objects.filter(name__iexact=title).select_related("logo").first() + if series and series.logo: + vod_logo = series.logo + if vod_logo: + poster_logo_id = vod_logo.id + except Exception: + pass + + # Keyless providers (TVMaze & iTunes) + if not poster_url and not poster_logo_id: + try: + title = (program.get('title') if isinstance(program, dict) else None) or channel_name + if title: + # TVMaze + try: + url = f"https://api.tvmaze.com/singlesearch/shows?q={quote(title)}" + resp = requests.get(url, timeout=5) + if resp.ok: + data = resp.json() or {} + img = (data.get('image') or {}) + p = img.get('original') or img.get('medium') + if p: + poster_url = p + except Exception: + pass + # iTunes + if not poster_url: + try: + for media in ('movie', 'tvShow'): + url = f"https://itunes.apple.com/search?term={quote(title)}&media={media}&limit=1" + resp = requests.get(url, timeout=5) + if resp.ok: + data = resp.json() or {} + results = data.get('results') or [] + if results: + art = results[0].get('artworkUrl100') + if art: + poster_url = art.replace('100x100', '600x600') + break + except Exception: + pass + except Exception: + pass + + # Fallback: search existing Logo entries by name if we still have nothing + if not poster_logo_id and not poster_url: + try: + from .models import Logo + title = (program.get("title") if isinstance(program, dict) else None) or channel_name + existing = Logo.objects.filter(name__iexact=title).first() + if existing: + poster_logo_id = existing.id + poster_url = existing.url + except Exception: + pass + + # Save to Logo if URL available + if not poster_logo_id and poster_url and len(poster_url) <= 1000: + try: + from .models import Logo + logo, _ = Logo.objects.get_or_create(url=poster_url, defaults={"name": (program.get("title") if isinstance(program, dict) else None) or channel_name}) + poster_logo_id = logo.id + except Exception: + pass + + return poster_logo_id, poster_url + + +@shared_task +def prefetch_recording_artwork(recording_id): + """Prefetch poster info for a scheduled recording so the UI can show art in Upcoming.""" + try: + from .models import Recording + rec = Recording.objects.get(id=recording_id) + cp = rec.custom_properties or {} + program = cp.get("program") or {} + poster_logo_id, poster_url = _resolve_poster_for_program(rec.channel.name, program) + updated = False + if poster_logo_id and cp.get("poster_logo_id") != poster_logo_id: + cp["poster_logo_id"] = poster_logo_id + updated = True + if poster_url and cp.get("poster_url") != poster_url: + cp["poster_url"] = poster_url + updated = True + # Enrich with rating if available from ProgramData.custom_properties + try: + from apps.epg.models import ProgramData + prog_id = program.get("id") if isinstance(program, dict) else None + if prog_id: + epg_program = ProgramData.objects.filter(id=prog_id).only("custom_properties").first() + if epg_program and isinstance(epg_program.custom_properties, dict): + rating_val = epg_program.custom_properties.get("rating") + rating_sys = epg_program.custom_properties.get("rating_system") + season_val = epg_program.custom_properties.get("season") + episode_val = epg_program.custom_properties.get("episode") + onscreen = epg_program.custom_properties.get("onscreen_episode") + if rating_val and cp.get("rating") != rating_val: + cp["rating"] = rating_val + updated = True + if rating_sys and cp.get("rating_system") != rating_sys: + cp["rating_system"] = rating_sys + updated = True + if season_val is not None and cp.get("season") != season_val: + cp["season"] = season_val + updated = True + if episode_val is not None and cp.get("episode") != episode_val: + cp["episode"] = episode_val + updated = True + if onscreen and cp.get("onscreen_episode") != onscreen: + cp["onscreen_episode"] = onscreen + updated = True + except Exception: + pass + + if updated: + rec.custom_properties = cp + rec.save(update_fields=["custom_properties"]) + try: + from core.utils import send_websocket_update + send_websocket_update('updates', 'update', {"success": True, "type": "recording_updated", "recording_id": rec.id}) + except Exception: + pass + return "ok" + except Exception as e: + logger.debug(f"prefetch_recording_artwork failed: {e}") + return f"error: {e}" diff --git a/apps/epg/tasks.py b/apps/epg/tasks.py index eaea634e..0d0ebbb3 100644 --- a/apps/epg/tasks.py +++ b/apps/epg/tasks.py @@ -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: diff --git a/core/migrations/0015_dvr_templates.py b/core/migrations/0015_dvr_templates.py new file mode 100644 index 00000000..f764af09 --- /dev/null +++ b/core/migrations/0015_dvr_templates.py @@ -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), + ] diff --git a/core/models.py b/core/models.py index 843a708c..e8620bc2 100644 --- a/core/models.py +++ b/core/models.py @@ -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 diff --git a/frontend/src/WebSocket.jsx b/frontend/src/WebSocket.jsx index e2b31ba4..768d5ec1 100644 --- a/frontend/src/WebSocket.jsx +++ b/frontend/src/WebSocket.jsx @@ -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': diff --git a/frontend/src/api.js b/frontend/src/api.js index 5fe223ef..b5b1ca7e 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -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( diff --git a/frontend/src/pages/DVR.jsx b/frontend/src/pages/DVR.jsx index 97703b46..84c4e616 100644 --- a/frontend/src/pages/DVR.jsx +++ b/frontend/src/pages/DVR.jsx @@ -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 ( + onOpen?.()} + style={{ cursor: 'pointer' }} + > + {preview} + + ); +}; - 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 ( + { setChildRec(rec); setChildOpen(true); }}> + + {pr.title + + + {pr.sub_title || pr.title} + {se && {se}} + + {start.format('MMM D, YYYY h:mma')} – {end.format('h:mma')} + + + + + + + ); + }; + + return ( + + {isSeriesGroup ? ( + + {upcomingEpisodes.length === 0 && ( + No upcoming episodes found + )} + {upcomingEpisodes.map((ep) => ( + + ))} + {childOpen && childRec && ( + 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' } }); + }} + /> + )} + + ) : ( + + {recordingName} + + + {channel ? `${channel.channel_number} • ${channel.name}` : '—'} + + {onWatchLive && ( + + )} + {onWatchRecording && ( + + )} + + + {start.format('MMM D, YYYY h:mma')} – {end.format('h:mma')} + {rating && ( + + {rating} + + )} + {description && ( + {description} + )} + {statRows.length > 0 && ( + + Stream Stats + {statRows.map(([k, v]) => ( + + {k} + {v} + + ))} + + )} + + + )} + + ); +}; + +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 = ( { withBorder style={{ color: '#fff', - backgroundColor: '#27272A', + backgroundColor: isInterrupted ? '#2b1f20' : '#27272A', + borderColor: isInterrupted ? '#a33' : undefined, + height: '100%', + cursor: 'pointer', }} + onClick={() => onOpenDetails?.(recording)} > - - - {recordingName} + + + + {isInterrupted ? 'Interrupted' : isInProgress ? 'Recording' : isUpcoming ? 'Scheduled' : 'Completed'} + + {isInterrupted && } + + {recordingName} + + {isSeriesGroup && ( + Series + )} + {seLabel && !isSeriesGroup && ( + {seLabel} + )}
- + deleteRecording(recording.id)} + onClick={(e) => { e.stopPropagation(); deleteRecording(recording.id); }} > - +
- - Channel: - {channels[recording.channel].name} - + + {recordingName} + + + + Channel + + + {channel ? `${channel.channel_number} • ${channel.name}` : '—'} + + - - Start: - - {dayjs(new Date(recording.start_time)).format('MMMM D, YYYY h:mma')} + + + Time + + {start.format('MMM D, YYYY h:mma')} – {end.format('h:mma')} + + + {!isSeriesGroup && description && ( + onOpenDetails?.(recording)} /> + )} + + {isInterrupted && customProps.interrupted_reason && ( + {customProps.interrupted_reason} + )} + + + {isInProgress && ( + + )} + + {!isUpcoming && ( + + + + )} + + + + {/* If this card is a grouped upcoming series, show count */} + {recording._group_count > 1 && ( + + Next of {recording._group_count} -
- - End: - - {dayjs(new Date(recording.end_time)).format('MMMM D, YYYY h:mma')} - - + )}
); + if (!isSeriesGroup) return MainCard; + + // Stacked look for series groups: render two shadow layers behind the main card + return ( + + + + {MainCard} + + ); }; 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 ( - - {Object.values(recordings).map((recording) => ( - - ))} - + +
+ + Currently Recording + {inProgress.length} + + + {inProgress.map((rec) => ( + + ))} + {inProgress.length === 0 && ( + + Nothing recording right now. + + )} + +
+ +
+ + Upcoming Recordings + {upcoming.length} + + + {upcoming.map((rec) => ( + + ))} + {upcoming.length === 0 && ( + + No upcoming recordings. + + )} + +
+ +
+ + Previously Recorded + {completed.length} + + + {completed.map((rec) => ( + + ))} + {completed.length === 0 && ( + + No completed recordings yet. + + )} + +
+
+ + {/* Details Modal */} + {detailsRecording && ( + { + 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' } }); + }} + /> + )}
); }; diff --git a/frontend/src/pages/Guide.jsx b/frontend/src/pages/Guide.jsx index a12bc37a..e21dca32 100644 --- a/frontend/src/pages/Guide.jsx +++ b/frontend/src/pages/Guide.jsx @@ -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 && ( - {/* 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 && ( )} + + + {filteredChannels.length}{' '} {filteredChannels.length === 1 ? 'channel' : 'channels'} @@ -1298,7 +1354,98 @@ export default function TVChannelGuide({ startDate, endDate }) {
- {/* Modal removed since we're using expanded rows instead */} + {/* Record choice modal */} + {recordChoiceOpen && recordChoiceProgram && ( + 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' }, + }} + > + + + + + {recordingForProgram && ( + <> + + + + )} + {existingRuleMode && ( + + )} + + + )} + + {/* Series rules modal */} + {rulesOpen && ( + 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' }, + }} + > + + {(!rules || rules.length === 0) && ( + No series rules configured + )} + {rules && rules.map((r) => ( + + {r.title || r.tvg_id} — {r.mode === 'new' ? 'New episodes' : 'Every episode'} + + + + + + + + ))} + + + )}
); } diff --git a/frontend/src/pages/Settings.jsx b/frontend/src/pages/Settings.jsx index f1b4419a..f862677d 100644 --- a/frontend/src/pages/Settings.jsx +++ b/frontend/src/pages/Settings.jsx @@ -403,6 +403,36 @@ const SettingsPage = () => { {authUser.user_level == USER_LEVELS.ADMIN && ( <> + + DVR Recording Paths + +
+ + + + + + + +
+
+
Stream Settings