From 00f6e7c1cd6d4ef0b63fded4d3423229b7a2ee8b Mon Sep 17 00:00:00 2001 From: dekzter Date: Sat, 5 Apr 2025 18:59:09 -0400 Subject: [PATCH] first run at recordings, among other things --- apps/channels/api_urls.py | 2 + apps/channels/api_views.py | 9 +- apps/channels/migrations/0014_recording.py | 24 ++ apps/channels/models.py | 9 + apps/channels/serializers.py | 9 +- apps/channels/signals.py | 48 +++- apps/channels/tasks.py | 34 +++ docker/init/03-init-dispatcharr.sh | 4 +- frontend/package-lock.json | 24 +- frontend/package.json | 1 + frontend/src/App.jsx | 1 + frontend/src/api.js | 15 ++ frontend/src/components/forms/Channel.jsx | 1 - frontend/src/components/forms/M3UProfiles.jsx | 89 ++++++-- frontend/src/components/forms/Recording.jsx | 122 +++++++++++ .../src/components/tables/ChannelsTable.jsx | 207 ++++++++++++------ frontend/src/pages/Guide.jsx | 2 +- 17 files changed, 506 insertions(+), 95 deletions(-) create mode 100644 apps/channels/migrations/0014_recording.py create mode 100644 frontend/src/components/forms/Recording.jsx diff --git a/apps/channels/api_urls.py b/apps/channels/api_urls.py index 9dc9de37..fc02d7e5 100644 --- a/apps/channels/api_urls.py +++ b/apps/channels/api_urls.py @@ -10,6 +10,7 @@ from .api_views import ( ChannelProfileViewSet, UpdateChannelMembershipAPIView, BulkUpdateChannelMembershipAPIView, + RecordingViewSet, ) app_name = 'channels' # for DRF routing @@ -20,6 +21,7 @@ router.register(r'groups', ChannelGroupViewSet, basename='channel-group') router.register(r'channels', ChannelViewSet, basename='channel') router.register(r'logos', LogoViewSet, basename='logo') router.register(r'profiles', ChannelProfileViewSet, basename='profile') +router.register(r'recordings', RecordingViewSet, basename='recording') urlpatterns = [ # Bulk delete is a single APIView, not a ViewSet diff --git a/apps/channels/api_views.py b/apps/channels/api_views.py index 9c391b77..b3d97fee 100644 --- a/apps/channels/api_views.py +++ b/apps/channels/api_views.py @@ -10,8 +10,8 @@ from django.shortcuts import get_object_or_404 from django.db import transaction import os, json, requests -from .models import Stream, Channel, ChannelGroup, Logo, ChannelProfile, ChannelProfileMembership -from .serializers import StreamSerializer, ChannelSerializer, ChannelGroupSerializer, LogoSerializer, ChannelProfileMembershipSerializer, BulkChannelProfileMembershipSerializer, ChannelProfileSerializer +from .models import Stream, Channel, ChannelGroup, Logo, ChannelProfile, ChannelProfileMembership, Recording +from .serializers import StreamSerializer, ChannelSerializer, ChannelGroupSerializer, LogoSerializer, ChannelProfileMembershipSerializer, BulkChannelProfileMembershipSerializer, ChannelProfileSerializer, RecordingSerializer from .tasks import match_epg_channels import django_filters from django_filters.rest_framework import DjangoFilterBackend @@ -565,3 +565,8 @@ class BulkUpdateChannelMembershipAPIView(APIView): return Response({"status": "success"}, status=status.HTTP_200_OK) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + +class RecordingViewSet(viewsets.ModelViewSet): + queryset = Recording.objects.all() + serializer_class = RecordingSerializer + permission_classes = [IsAuthenticated] diff --git a/apps/channels/migrations/0014_recording.py b/apps/channels/migrations/0014_recording.py new file mode 100644 index 00000000..44fa681c --- /dev/null +++ b/apps/channels/migrations/0014_recording.py @@ -0,0 +1,24 @@ +# Generated by Django 5.1.6 on 2025-04-05 22:25 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dispatcharr_channels', '0013_alter_logo_url'), + ] + + operations = [ + migrations.CreateModel( + name='Recording', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('start_time', models.DateTimeField()), + ('end_time', models.DateTimeField()), + ('task_id', models.CharField(blank=True, max_length=255, null=True)), + ('channel', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='recordings', to='dispatcharr_channels.channel')), + ], + ), + ] diff --git a/apps/channels/models.py b/apps/channels/models.py index 8a8ddcec..abcecb77 100644 --- a/apps/channels/models.py +++ b/apps/channels/models.py @@ -404,3 +404,12 @@ class Logo(models.Model): def __str__(self): return self.name + +class Recording(models.Model): + channel = models.ForeignKey("Channel", on_delete=models.CASCADE, related_name="recordings") + start_time = models.DateTimeField() + end_time = models.DateTimeField() + task_id = models.CharField(max_length=255, null=True, blank=True) + + def __str__(self): + return f"{self.channel.name} - {self.start_time} to {self.end_time}" diff --git a/apps/channels/serializers.py b/apps/channels/serializers.py index f1d89b41..3ee336a7 100644 --- a/apps/channels/serializers.py +++ b/apps/channels/serializers.py @@ -1,5 +1,5 @@ from rest_framework import serializers -from .models import Stream, Channel, ChannelGroup, ChannelStream, ChannelGroupM3UAccount, Logo, ChannelProfile, ChannelProfileMembership +from .models import Stream, Channel, ChannelGroup, ChannelStream, ChannelGroupM3UAccount, Logo, ChannelProfile, ChannelProfileMembership, Recording from apps.epg.serializers import EPGDataSerializer from core.models import StreamProfile from apps.epg.models import EPGData @@ -232,3 +232,10 @@ class ChannelGroupM3UAccountSerializer(serializers.ModelSerializer): # Optionally, if you only need the id of the ChannelGroup, you can customize it like this: # channel_group = serializers.PrimaryKeyRelatedField(queryset=ChannelGroup.objects.all()) + + +class RecordingSerializer(serializers.ModelSerializer): + class Meta: + model = Recording + fields = '__all__' + read_only_fields = ['task_id'] diff --git a/apps/channels/signals.py b/apps/channels/signals.py index 7fcb452e..076f9876 100644 --- a/apps/channels/signals.py +++ b/apps/channels/signals.py @@ -1,11 +1,14 @@ # apps/channels/signals.py -from django.db.models.signals import m2m_changed, pre_save, post_save +from django.db.models.signals import m2m_changed, pre_save, post_save, post_delete from django.dispatch import receiver -from .models import Channel, Stream, ChannelProfile, ChannelProfileMembership +from django.utils.timezone import now +from celery.result import AsyncResult +from .models import Channel, Stream, ChannelProfile, ChannelProfileMembership, Recording from apps.m3u.models import M3UAccount from apps.epg.tasks import parse_programs_for_tvg_id -import logging +import logging, requests, time +from .tasks import run_recording logger = logging.getLogger(__name__) @@ -62,3 +65,42 @@ def create_profile_memberships(sender, instance, created, **kwargs): ChannelProfileMembership(channel_profile=instance, channel=channel) for channel in channels ]) + +def schedule_recording_task(instance): + eta = instance.start_time + task = run_recording.apply_async( + args=[instance.channel_id, str(instance.start_time), str(instance.end_time)], + eta=eta + ) + return task.id + +def revoke_task(task_id): + if task_id: + AsyncResult(task_id).revoke() + +@receiver(pre_save, sender=Recording) +def revoke_old_task_on_update(sender, instance, **kwargs): + if not instance.pk: + return # New instance + try: + old = Recording.objects.get(pk=instance.pk) + if old.task_id and ( + old.start_time != instance.start_time or + old.end_time != instance.end_time or + old.channel_id != instance.channel_id + ): + revoke_task(old.task_id) + instance.task_id = None + except Recording.DoesNotExist: + pass + +@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']) + +@receiver(post_delete, sender=Recording) +def revoke_task_on_delete(sender, instance, **kwargs): + revoke_task(instance.task_id) diff --git a/apps/channels/tasks.py b/apps/channels/tasks.py index 7acf5831..9ac4d475 100644 --- a/apps/channels/tasks.py +++ b/apps/channels/tasks.py @@ -2,12 +2,16 @@ import logging import os import re +import requests +import time +from datetime import datetime from celery import shared_task from rapidfuzz import fuzz from sentence_transformers import util from django.conf import settings from django.db import transaction +from django.utils.text import slugify from apps.channels.models import Channel from apps.epg.models import EPGData, EPGSource @@ -217,3 +221,33 @@ def match_epg_channels(): ) return f"Done. Matched {total_matched} channel(s)." + +@shared_task +def run_recording(channel_id, start_time_str, end_time_str): + 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' + + 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") + break + # Write the chunk to the file + file.write(chunk) + + # After the loop, the file and response are closed automatically. + logger.info(f"Finished recording for channel {channel.name}") diff --git a/docker/init/03-init-dispatcharr.sh b/docker/init/03-init-dispatcharr.sh index d374ad69..0525d26f 100644 --- a/docker/init/03-init-dispatcharr.sh +++ b/docker/init/03-init-dispatcharr.sh @@ -1,6 +1,7 @@ #!/bin/bash mkdir -p /data/logos +mkdir -p /data/recordings mkdir -p /app/logo_cache mkdir -p /app/media @@ -14,7 +15,8 @@ if [ "$(id -u)" = "0" ]; then chown $PUID:$PGID /app/uwsgi.sock chown -R $PUID:$PGID /app - chown -R $PUID:$PGID /data/logos + # Needs to own ALL of /data except db, we handle that below + chown -R $PUID:$PGID /data # Permissions chown -R postgres:postgres /data/db diff --git a/frontend/package-lock.json b/frontend/package-lock.json index f1abbe02..4f1856f1 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -12,6 +12,7 @@ "@mantine/core": "^7.17.2", "@mantine/dates": "^7.17.2", "@mantine/dropzone": "^7.17.2", + "@mantine/form": "^7.17.3", "@mantine/hooks": "^7.17.2", "@mantine/notifications": "^7.17.2", "@tabler/icons-react": "^3.31.0", @@ -1148,6 +1149,19 @@ "react-dom": "^18.x || ^19.x" } }, + "node_modules/@mantine/form": { + "version": "7.17.3", + "resolved": "https://registry.npmjs.org/@mantine/form/-/form-7.17.3.tgz", + "integrity": "sha512-ktERldD8f9lrjjz6wIbwMnNbAZq8XEWPx4K5WuFyjXaK0PI8D+gsXIGKMtA5rVrAUFHCWCdbK3yLgtjJNki8ew==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "klona": "^2.0.6" + }, + "peerDependencies": { + "react": "^18.x || ^19.x" + } + }, "node_modules/@mantine/hooks": { "version": "7.17.2", "resolved": "https://registry.npmjs.org/@mantine/hooks/-/hooks-7.17.2.tgz", @@ -2795,7 +2809,6 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "dev": true, "license": "MIT" }, "node_modules/fast-equals": { @@ -3296,6 +3309,15 @@ "json-buffer": "3.0.1" } }, + "node_modules/klona": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/klona/-/klona-2.0.6.tgz", + "integrity": "sha512-dhG34DXATL5hSxJbIexCft8FChFXtmskoZYnoPWjXQuebWYCNkVeV3KkGegCK9CP1oswI/vQibS2GY7Em/sJJA==", + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, "node_modules/levn": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index 27a7b049..5677558b 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -14,6 +14,7 @@ "@mantine/core": "^7.17.2", "@mantine/dates": "^7.17.2", "@mantine/dropzone": "^7.17.2", + "@mantine/form": "^7.17.3", "@mantine/hooks": "^7.17.2", "@mantine/notifications": "^7.17.2", "@tabler/icons-react": "^3.31.0", diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 2386efb5..4fa2b9a9 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -23,6 +23,7 @@ import '@mantine/core/styles.css'; // Ensure Mantine global styles load import '@mantine/notifications/styles.css'; import 'mantine-react-table/styles.css'; import '@mantine/dropzone/styles.css'; +import '@mantine/dates/styles.css'; import './index.css'; import mantineTheme from './mantineTheme'; import API from './api'; diff --git a/frontend/src/api.js b/frontend/src/api.js index 259ac289..2d40959f 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -1034,4 +1034,19 @@ export default class API { .getState() .updateProfileChannels(channelIds, profileId, enabled); } + + static async createRecording(values) { + const response = await fetch(`${host}/api/channels/recordings/`, { + method: 'POST', + headers: { + Authorization: `Bearer ${await API.getAuthToken()}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(values), + }); + + const retval = await response.json(); + + return retval; + } } diff --git a/frontend/src/components/forms/Channel.jsx b/frontend/src/components/forms/Channel.jsx index 4742a89c..ca2f2330 100644 --- a/frontend/src/components/forms/Channel.jsx +++ b/frontend/src/components/forms/Channel.jsx @@ -140,7 +140,6 @@ const Channel = ({ channel = null, isOpen, onClose }) => { logo_id: `${channel.logo?.id}`, }); - console.log(channel); setChannelStreams(channel.streams); } else { formik.resetForm(); diff --git a/frontend/src/components/forms/M3UProfiles.jsx b/frontend/src/components/forms/M3UProfiles.jsx index f5da6e58..609e2251 100644 --- a/frontend/src/components/forms/M3UProfiles.jsx +++ b/frontend/src/components/forms/M3UProfiles.jsx @@ -1,4 +1,4 @@ -import React, { useState, useMemo } from 'react'; +import React, { useState, useMemo, useEffect } from 'react'; import API from '../../api'; import M3UProfile from './M3UProfile'; import usePlaylistsStore from '../../store/playlists'; @@ -11,13 +11,25 @@ import { Box, ActionIcon, Text, + NumberInput, + useMantineTheme, + Center, + Group, + Switch, } from '@mantine/core'; import { SquareMinus, SquarePen } from 'lucide-react'; const M3UProfiles = ({ playlist = null, isOpen, onClose }) => { - const profiles = usePlaylistsStore((state) => state.profiles[playlist.id]); + const theme = useMantineTheme(); + + const { profiles: allProfiles } = usePlaylistsStore(); const [profileEditorOpen, setProfileEditorOpen] = useState(false); const [profile, setProfile] = useState(null); + const [profiles, setProfiles] = useState([]); + + useEffect(() => { + setProfiles(allProfiles[playlist.id]); + }, [allProfiles]); const editProfile = (profile = null) => { if (profile) { @@ -38,6 +50,13 @@ const M3UProfiles = ({ playlist = null, isOpen, onClose }) => { }); }; + const modifyMaxStreams = async (value, item) => { + await API.updateM3UProfile(playlist.id, { + ...item, + max_streams: value, + }); + }; + const closeEditor = () => { setProfile(null); setProfileEditorOpen(false); @@ -62,27 +81,51 @@ const M3UProfiles = ({ playlist = null, isOpen, onClose }) => { // }} > - Max Streams: {item.max_streams} - toggleActive(item)} - color="primary" - /> - editProfile(item)} - color="yellow.5" - variant="transparent" - > - - - deleteProfile(item.id)} - color="red.9" - variant="transparent" - > - - + + {item.name} + toggleActive(item)} + disabled={item.is_default} + style={{ paddingTop: 6 }} + /> + + + + modifyMaxStreams(value, item)} + style={{ flex: 1 }} + /> + + {!item.is_default && ( + + editProfile(item)} + > + + + + deleteProfile(item.id)} + size="small" + variant="transparent" + > + + + + )} + ))} diff --git a/frontend/src/components/forms/Recording.jsx b/frontend/src/components/forms/Recording.jsx new file mode 100644 index 00000000..db19e4a1 --- /dev/null +++ b/frontend/src/components/forms/Recording.jsx @@ -0,0 +1,122 @@ +// Modal.js +import React, { useState, useEffect } from 'react'; +import { useFormik } from 'formik'; +import * as Yup from 'yup'; +import API from '../../api'; +import useEPGsStore from '../../store/epgs'; +import { + LoadingOverlay, + TextInput, + Button, + Checkbox, + Modal, + Flex, + NativeSelect, + NumberInput, + Space, + Select, + Alert, +} from '@mantine/core'; +import useChannelsStore from '../../store/channels'; +import { DateTimePicker } from '@mantine/dates'; +import { CircleAlert } from 'lucide-react'; +import { isNotEmpty, useForm } from '@mantine/form'; + +const DVR = ({ recording = null, channel = null, isOpen, onClose }) => { + const { channels } = useChannelsStore(); + + let startTime = new Date(); + startTime.setMinutes(Math.ceil(startTime.getMinutes() / 30) * 30); + startTime.setSeconds(0); + startTime.setMilliseconds(0); + + let endTime = new Date(); + endTime.setMinutes(Math.ceil(endTime.getMinutes() / 30) * 30); + endTime.setSeconds(0); + endTime.setMilliseconds(0); + endTime.setHours(endTime.getHours() + 1); + + const form = useForm({ + mode: 'uncontrolled', + initialValues: { + channel_id: recording + ? recording.channel_id + : channel + ? `${channel.id}` + : '', + start_time: recording ? recording.start_time : startTime, + end_time: recording ? recording.end_time : endTime, + }, + + validate: { + channel_id: isNotEmpty('Select a channel'), + start_time: isNotEmpty('Select a start time'), + end_time: isNotEmpty('Select an end time'), + }, + }); + + const onSubmit = async () => { + const { channel_id, ...values } = form.getValues(); + await API.createRecording({ + ...values, + channel: channel_id, + }); + onClose(); + }; + + if (!isOpen) { + return <>; + } + + return ( + + } + style={{ paddingBottom: 5 }} + > + Recordings may fail if active streams or overlapping recordings use up + all available streams + + +
+