diff --git a/apps/channels/serializers.py b/apps/channels/serializers.py index 3ee336a7..e08f0466 100644 --- a/apps/channels/serializers.py +++ b/apps/channels/serializers.py @@ -4,6 +4,8 @@ from apps.epg.serializers import EPGDataSerializer from core.models import StreamProfile from apps.epg.models import EPGData from django.urls import reverse +from rest_framework import serializers +from django.utils import timezone class LogoSerializer(serializers.ModelSerializer): cache_url = serializers.SerializerMethodField() @@ -239,3 +241,20 @@ class RecordingSerializer(serializers.ModelSerializer): model = Recording fields = '__all__' read_only_fields = ['task_id'] + + def validate(self, data): + start_time = data.get('start_time') + end_time = data.get('end_time') + + now = timezone.now() # timezone-aware current time + + if end_time < now: + raise serializers.ValidationError("End time must be in the future.") + + if start_time < now: + # Optional: Adjust start_time if it's in the past but end_time is in the future + data['start_time'] = now # or: timezone.now() + timedelta(seconds=1) + if end_time <= data['start_time']: + raise serializers.ValidationError("End time must be after start time.") + + return data diff --git a/apps/channels/signals.py b/apps/channels/signals.py index 076f9876..660de04c 100644 --- a/apps/channels/signals.py +++ b/apps/channels/signals.py @@ -9,6 +9,8 @@ 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 django.utils.timezone import now, is_aware, make_aware +from datetime import timedelta logger = logging.getLogger(__name__) @@ -96,10 +98,32 @@ def revoke_old_task_on_update(sender, instance, **kwargs): @receiver(post_save, sender=Recording) def schedule_task_on_save(sender, instance, created, **kwargs): - if not instance.task_id and instance.start_time > now(): - task_id = schedule_recording_task(instance) - instance.task_id = task_id - instance.save(update_fields=['task_id']) + try: + if not instance.task_id: + start_time = instance.start_time + + # Make both datetimes aware (in UTC) + if not is_aware(start_time): + print("Start time was not aware, making aware") + start_time = make_aware(start_time) + + current_time = now() + + # Debug log + print(f"Start time: {start_time}, Now: {current_time}") + + # Optionally allow slight fudge factor (1 second) to ensure scheduling happens + if start_time > current_time - timedelta(seconds=1): + print("Scheduling recording task!") + task_id = schedule_recording_task(instance) + instance.task_id = task_id + instance.save(update_fields=['task_id']) + else: + print("Start time is in the past. Not scheduling.") + except Exception as e: + import traceback + print("Error in post_save signal:", e) + traceback.print_exc() @receiver(post_delete, sender=Recording) def revoke_task_on_delete(sender, instance, **kwargs): diff --git a/apps/channels/tasks.py b/apps/channels/tasks.py index ce08e57c..7e271846 100644 --- a/apps/channels/tasks.py +++ b/apps/channels/tasks.py @@ -17,6 +17,9 @@ from apps.channels.models import Channel from apps.epg.models import EPGData, EPGSource from core.models import CoreSettings +from channels.layers import get_channel_layer +from asgiref.sync import async_to_sync + from asgiref.sync import async_to_sync from channels.layers import get_channel_layer from core.utils import SentenceTransformer @@ -238,6 +241,16 @@ def run_recording(channel_id, start_time_str, 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' + channel_layer = get_channel_layer() + + async_to_sync(channel_layer.group_send)( + "updates", + { + "type": "update", + "data": {"success": True, "type": "recording_started", "channel": channel.name} + }, + ) + 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', @@ -255,5 +268,13 @@ def run_recording(channel_id, start_time_str, end_time_str): # Write the chunk to the file file.write(chunk) + async_to_sync(channel_layer.group_send)( + "updates", + { + "type": "update", + "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}") diff --git a/frontend/src/WebSocket.jsx b/frontend/src/WebSocket.jsx index 73939cf3..6bd048a4 100644 --- a/frontend/src/WebSocket.jsx +++ b/frontend/src/WebSocket.jsx @@ -127,6 +127,20 @@ export const WebsocketProvider = ({ children }) => { setProfilePreview(event.data.search_preview, event.data.result); break; + case 'recording_started': + notifications.show({ + title: 'Recording started!', + message: `Started recording channel ${event.data.channel}`, + }); + break; + + case 'recording_ended': + notifications.show({ + title: 'Recording finished!', + message: `Stopped recording channel ${event.data.channel}`, + }); + break; + default: console.error(`Unknown websocket event type: ${event.type}`); break; diff --git a/frontend/src/pages/Guide.jsx b/frontend/src/pages/Guide.jsx index f566cd69..5f22d1d4 100644 --- a/frontend/src/pages/Guide.jsx +++ b/frontend/src/pages/Guide.jsx @@ -158,6 +158,15 @@ export default function TVChannelGuide({ startDate, endDate }) { return guideChannels.find((ch) => ch.epg_data?.tvg_id === tvgId); } + const record = (program) => { + const channel = findChannelByTvgId(program.tvg_id); + API.createRecording({ + channel: `${channel.id}`, + start_time: program.start_time, + end_time: program.end_time, + }); + }; + // The “Watch Now” click => show floating video const { showVideo } = useVideoStore(); // or useVideoStore() function handleWatchStream(program) { @@ -457,6 +466,13 @@ export default function TVChannelGuide({ startDate, endDate }) { {now.isAfter(dayjs(selectedProgram.start_time)) && now.isBefore(dayjs(selectedProgram.end_time)) && ( +