mirror of
https://github.com/Dispatcharr/Dispatcharr.git
synced 2026-07-17 16:50:53 +00:00
first run at recordings, among other things
This commit is contained in:
parent
e8fdd88bca
commit
00f6e7c1cd
17 changed files with 506 additions and 95 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
24
apps/channels/migrations/0014_recording.py
Normal file
24
apps/channels/migrations/0014_recording.py
Normal file
|
|
@ -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')),
|
||||
],
|
||||
),
|
||||
]
|
||||
|
|
@ -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}"
|
||||
|
|
|
|||
|
|
@ -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']
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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}")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
24
frontend/package-lock.json
generated
24
frontend/package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -140,7 +140,6 @@ const Channel = ({ channel = null, isOpen, onClose }) => {
|
|||
logo_id: `${channel.logo?.id}`,
|
||||
});
|
||||
|
||||
console.log(channel);
|
||||
setChannelStreams(channel.streams);
|
||||
} else {
|
||||
formik.resetForm();
|
||||
|
|
|
|||
|
|
@ -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 }) => {
|
|||
// }}
|
||||
>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center' }}>
|
||||
<Text>Max Streams: {item.max_streams}</Text>
|
||||
<Checkbox
|
||||
label="Is Active"
|
||||
checked={item.is_active}
|
||||
onChange={() => toggleActive(item)}
|
||||
color="primary"
|
||||
/>
|
||||
<ActionIcon
|
||||
onClick={() => editProfile(item)}
|
||||
color="yellow.5"
|
||||
variant="transparent"
|
||||
>
|
||||
<SquarePen size="18" />
|
||||
</ActionIcon>
|
||||
<ActionIcon
|
||||
onClick={() => deleteProfile(item.id)}
|
||||
color="red.9"
|
||||
variant="transparent"
|
||||
>
|
||||
<SquareMinus size="18" />
|
||||
</ActionIcon>
|
||||
<Group justify="space-between">
|
||||
<Text fw={600}>{item.name}</Text>
|
||||
<Switch
|
||||
checked={item.is_active}
|
||||
onChange={() => toggleActive(item)}
|
||||
disabled={item.is_default}
|
||||
style={{ paddingTop: 6 }}
|
||||
/>
|
||||
</Group>
|
||||
|
||||
<Flex gap="sm">
|
||||
<NumberInput
|
||||
label="Max Streams"
|
||||
value={item.max_streams}
|
||||
disabled={item.is_default}
|
||||
onChange={(value) => modifyMaxStreams(value, item)}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
|
||||
{!item.is_default && (
|
||||
<Group
|
||||
align="flex-end"
|
||||
gap="xs"
|
||||
style={{ paddingBottom: 8 }}
|
||||
>
|
||||
<ActionIcon
|
||||
size="sm"
|
||||
variant="transparent"
|
||||
color={theme.tailwind.yellow[3]}
|
||||
onClick={() => editProfile(item)}
|
||||
>
|
||||
<SquarePen size="=20" />
|
||||
</ActionIcon>
|
||||
|
||||
<ActionIcon
|
||||
color={theme.tailwind.red[6]}
|
||||
onClick={() => deleteProfile(item.id)}
|
||||
size="small"
|
||||
variant="transparent"
|
||||
>
|
||||
<SquareMinus size="20" />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
)}
|
||||
</Flex>
|
||||
</Box>
|
||||
</Card>
|
||||
))}
|
||||
|
|
|
|||
122
frontend/src/components/forms/Recording.jsx
Normal file
122
frontend/src/components/forms/Recording.jsx
Normal file
|
|
@ -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 (
|
||||
<Modal opened={isOpen} onClose={onClose} title="Channel Recording">
|
||||
<Alert
|
||||
variant="light"
|
||||
color="yellow"
|
||||
title="Scheduling Conflicts"
|
||||
icon={<CircleAlert />}
|
||||
style={{ paddingBottom: 5 }}
|
||||
>
|
||||
Recordings may fail if active streams or overlapping recordings use up
|
||||
all available streams
|
||||
</Alert>
|
||||
|
||||
<form onSubmit={form.onSubmit(onSubmit)}>
|
||||
<Select
|
||||
{...form.getInputProps('channel_id')}
|
||||
label="Channel"
|
||||
key={form.key('channel_id')}
|
||||
searchable
|
||||
data={Object.values(channels).map((channel) => ({
|
||||
value: `${channel.id}`,
|
||||
label: channel.name,
|
||||
}))}
|
||||
/>
|
||||
|
||||
<DateTimePicker
|
||||
{...form.getInputProps('start_time')}
|
||||
key={form.key('start_time')}
|
||||
id="start_time"
|
||||
label="Start Time"
|
||||
valueFormat="M/DD/YYYY hh:mm A"
|
||||
/>
|
||||
|
||||
<DateTimePicker
|
||||
{...form.getInputProps('end_time')}
|
||||
key={form.key('end_time')}
|
||||
id="end_time"
|
||||
label="End Time"
|
||||
valueFormat="M/DD/YYYY hh:mm A"
|
||||
/>
|
||||
|
||||
<Flex mih={50} gap="xs" justify="flex-end" align="flex-end">
|
||||
<Button type="submit" variant="contained" size="small">
|
||||
Submit
|
||||
</Button>
|
||||
</Flex>
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default DVR;
|
||||
|
|
@ -4,6 +4,7 @@ import useChannelsStore from '../../store/channels';
|
|||
import { notifications } from '@mantine/notifications';
|
||||
import API from '../../api';
|
||||
import ChannelForm from '../forms/Channel';
|
||||
import RecordingForm from '../forms/Recording';
|
||||
import { TableHelper } from '../../helpers';
|
||||
import utils from '../../utils';
|
||||
import logo from '../../images/logo.png';
|
||||
|
|
@ -23,6 +24,8 @@ import {
|
|||
Copy,
|
||||
CircleCheck,
|
||||
ScanEye,
|
||||
EllipsisVertical,
|
||||
CircleEllipsis,
|
||||
} from 'lucide-react';
|
||||
import ghostImage from '../../images/ghost.svg';
|
||||
import {
|
||||
|
|
@ -42,6 +45,7 @@ import {
|
|||
Center,
|
||||
Container,
|
||||
Switch,
|
||||
Menu,
|
||||
} from '@mantine/core';
|
||||
|
||||
const ChannelStreams = ({ channel, isExpanded }) => {
|
||||
|
|
@ -250,8 +254,13 @@ const ChannelsTable = ({ }) => {
|
|||
channelsPageSelection,
|
||||
} = useChannelsStore();
|
||||
|
||||
const {
|
||||
environment: { env_mode },
|
||||
} = useSettingsStore();
|
||||
|
||||
const [channel, setChannel] = useState(null);
|
||||
const [channelModalOpen, setChannelModalOpen] = useState(false);
|
||||
const [recordingModalOpen, setRecordingModalOpen] = useState(false);
|
||||
const [rowSelection, setRowSelection] = useState([]);
|
||||
const [channelGroupOptions, setChannelGroupOptions] = useState([]);
|
||||
const [selectedProfile, setSelectedProfile] = useState(
|
||||
|
|
@ -259,6 +268,8 @@ const ChannelsTable = ({ }) => {
|
|||
);
|
||||
const [channelsEnabledHeaderSwitch, setChannelsEnabledHeaderSwitch] =
|
||||
useState(false);
|
||||
const [moreActionsAnchorEl, setMoreActionsAnchorEl] = useState(null);
|
||||
const [actionsOpenRow, setActionsOpenRow] = useState(null);
|
||||
|
||||
const [hdhrUrl, setHDHRUrl] = useState(hdhrUrlBase);
|
||||
const [epgUrl, setEPGUrl] = useState(epgUrlBase);
|
||||
|
|
@ -322,30 +333,22 @@ const ChannelsTable = ({ }) => {
|
|||
id: 'enabled',
|
||||
Header: () => {
|
||||
if (Object.values(rowSelection).length == 0) {
|
||||
return (
|
||||
<Container style={{ paddingLeft: 15 }}>
|
||||
<ScanEye size="16" />
|
||||
</Container>
|
||||
);
|
||||
return <ScanEye size="16" style={{ marginRight: 8 }} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Container style={{ paddingLeft: 8 }}>
|
||||
<Switch
|
||||
size="xs"
|
||||
checked={
|
||||
selectedProfileId == '0' || channelsEnabledHeaderSwitch
|
||||
}
|
||||
onChange={() => {
|
||||
console.log(channelsPageSelection);
|
||||
toggleChannelEnabled(
|
||||
channelsPageSelection.map((row) => row.id),
|
||||
!channelsEnabledHeaderSwitch
|
||||
);
|
||||
}}
|
||||
disabled={selectedProfileId == '0'}
|
||||
/>
|
||||
</Container>
|
||||
<Switch
|
||||
size="xs"
|
||||
checked={selectedProfileId == '0' || channelsEnabledHeaderSwitch}
|
||||
onChange={() => {
|
||||
console.log(channelsPageSelection);
|
||||
toggleChannelEnabled(
|
||||
channelsPageSelection.map((row) => row.id),
|
||||
!channelsEnabledHeaderSwitch
|
||||
);
|
||||
}}
|
||||
disabled={selectedProfileId == '0'}
|
||||
/>
|
||||
);
|
||||
},
|
||||
enableSorting: false,
|
||||
|
|
@ -357,25 +360,32 @@ const ChannelsTable = ({ }) => {
|
|||
return selectedProfileChannels.find((channel) => row.id == channel.id)
|
||||
.enabled;
|
||||
},
|
||||
size: 20,
|
||||
mantineTableHeadCellProps: {
|
||||
// align: 'center',
|
||||
align: 'right',
|
||||
style: {
|
||||
backgroundColor: '#3F3F46',
|
||||
minWidth: '20px',
|
||||
width: '50px !important',
|
||||
justifyContent: 'center',
|
||||
// paddingLeft: 8,
|
||||
paddingRight: 0,
|
||||
width: '40px',
|
||||
minWidth: '40px',
|
||||
maxWidth: '40px',
|
||||
// // minWidth: '20px',
|
||||
// // width: '50px !important',
|
||||
// // justifyContent: 'center',
|
||||
padding: 0,
|
||||
// // paddingLeft: 8,
|
||||
// // paddingRight: 0,
|
||||
},
|
||||
},
|
||||
mantineTableBodyCellProps: {
|
||||
// align: 'center',
|
||||
align: 'right',
|
||||
style: {
|
||||
minWidth: '20px',
|
||||
justifyContent: 'center',
|
||||
paddingLeft: 0,
|
||||
paddingRight: 0,
|
||||
width: '40px',
|
||||
minWidth: '40px',
|
||||
maxWidth: '40px',
|
||||
// // minWidth: '20px',
|
||||
// // justifyContent: 'center',
|
||||
// // paddingLeft: 0,
|
||||
// // paddingRight: 0,
|
||||
padding: 0,
|
||||
},
|
||||
},
|
||||
Cell: ({ row, cell }) => (
|
||||
|
|
@ -391,25 +401,27 @@ const ChannelsTable = ({ }) => {
|
|||
},
|
||||
{
|
||||
header: '#',
|
||||
size: 30,
|
||||
size: 50,
|
||||
maxSize: 50,
|
||||
accessorKey: 'channel_number',
|
||||
mantineTableHeadCellProps: {
|
||||
style: {
|
||||
backgroundColor: '#3F3F46',
|
||||
minWidth: '20px',
|
||||
justifyContent: 'center',
|
||||
paddingLeft: 15,
|
||||
paddingRight: 0,
|
||||
},
|
||||
align: 'right',
|
||||
// // style: {
|
||||
// // backgroundColor: '#3F3F46',
|
||||
// // // minWidth: '20px',
|
||||
// // // justifyContent: 'center',
|
||||
// // // paddingLeft: 15,
|
||||
// // paddingRight: 0,
|
||||
// // },
|
||||
},
|
||||
mantineTableBodyCellProps: {
|
||||
align: 'center',
|
||||
style: {
|
||||
minWidth: '20px',
|
||||
justifyContent: 'center',
|
||||
paddingLeft: 0,
|
||||
paddingRight: 0,
|
||||
},
|
||||
align: 'right',
|
||||
// // style: {
|
||||
// // minWidth: '20px',
|
||||
// // // justifyContent: 'center',
|
||||
// // paddingLeft: 0,
|
||||
// // paddingRight: 0,
|
||||
// // },
|
||||
},
|
||||
},
|
||||
{
|
||||
|
|
@ -481,6 +493,9 @@ const ChannelsTable = ({ }) => {
|
|||
size: 55,
|
||||
mantineTableBodyCellProps: {
|
||||
align: 'center',
|
||||
style: {
|
||||
maxWidth: '55px',
|
||||
},
|
||||
},
|
||||
Cell: ({ cell }) => (
|
||||
<Grid
|
||||
|
|
@ -534,6 +549,11 @@ const ChannelsTable = ({ }) => {
|
|||
await API.deleteChannel(id);
|
||||
};
|
||||
|
||||
const createRecording = (channel) => {
|
||||
setChannel(channel);
|
||||
setRecordingModalOpen(true);
|
||||
};
|
||||
|
||||
function handleWatchStream(channelNumber) {
|
||||
let vidUrl = `/proxy/ts/stream/${channelNumber}`;
|
||||
if (env_mode == 'dev') {
|
||||
|
|
@ -602,6 +622,11 @@ const ChannelsTable = ({ }) => {
|
|||
setChannelModalOpen(false);
|
||||
};
|
||||
|
||||
const closeRecordingForm = () => {
|
||||
// setChannel(null);
|
||||
setRecordingModalOpen(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
setIsLoading(false);
|
||||
|
|
@ -699,6 +724,11 @@ const ChannelsTable = ({ }) => {
|
|||
);
|
||||
};
|
||||
|
||||
const handleMoreActionsClick = (event, rowId) => {
|
||||
setMoreActionsAnchorEl(event.currentTarget);
|
||||
setActionsOpenRow(rowId);
|
||||
};
|
||||
|
||||
const table = useMantineReactTable({
|
||||
...TableHelper.defaultProperties,
|
||||
columns,
|
||||
|
|
@ -734,27 +764,30 @@ const ChannelsTable = ({ }) => {
|
|||
enableExpandAll: false,
|
||||
displayColumnDefOptions: {
|
||||
'mrt-row-select': {
|
||||
// size: 20,
|
||||
size: 10,
|
||||
maxSize: 10,
|
||||
mantineTableHeadCellProps: {
|
||||
// align: 'center',
|
||||
align: 'right',
|
||||
style: {
|
||||
paddingLeft: 7,
|
||||
width: '30px',
|
||||
minWidth: '30px',
|
||||
paddding: 0,
|
||||
// paddingLeft: 7,
|
||||
width: '20px',
|
||||
minWidth: '20px',
|
||||
backgroundColor: '#3F3F46',
|
||||
},
|
||||
},
|
||||
mantineTableBodyCellProps: {
|
||||
align: 'center',
|
||||
align: 'right',
|
||||
style: {
|
||||
// paddingLeft: 10,
|
||||
width: '30px',
|
||||
minWidth: '30px',
|
||||
paddingLeft: 0,
|
||||
width: '20px',
|
||||
minWidth: '20px',
|
||||
},
|
||||
},
|
||||
},
|
||||
'mrt-row-expand': {
|
||||
size: 20,
|
||||
maxSize: 20,
|
||||
header: '',
|
||||
mantineTableHeadCellProps: {
|
||||
style: {
|
||||
|
|
@ -762,6 +795,7 @@ const ChannelsTable = ({ }) => {
|
|||
paddingLeft: 2,
|
||||
width: '20px',
|
||||
minWidth: '20px',
|
||||
maxWidth: '20px',
|
||||
backgroundColor: '#3F3F46',
|
||||
},
|
||||
},
|
||||
|
|
@ -771,14 +805,19 @@ const ChannelsTable = ({ }) => {
|
|||
paddingLeft: 2,
|
||||
width: '20px',
|
||||
minWidth: '20px',
|
||||
maxWidth: '20px',
|
||||
},
|
||||
},
|
||||
},
|
||||
'mrt-row-actions': {
|
||||
size: 60,
|
||||
size: 85,
|
||||
maxWidth: 85,
|
||||
mantineTableHeadCellProps: {
|
||||
align: 'center',
|
||||
style: {
|
||||
paddingLeft: 10,
|
||||
minWidth: '85px',
|
||||
maxWidth: '85px',
|
||||
paddingRight: 40,
|
||||
fontWeight: 'normal',
|
||||
color: 'rgb(207,207,207)',
|
||||
backgroundColor: '#3F3F46',
|
||||
|
|
@ -786,6 +825,9 @@ const ChannelsTable = ({ }) => {
|
|||
},
|
||||
mantineTableBodyCellProps: {
|
||||
style: {
|
||||
minWidth: '85px',
|
||||
maxWidth: '85px',
|
||||
paddingLeft: 0,
|
||||
paddingRight: 10,
|
||||
},
|
||||
},
|
||||
|
|
@ -810,7 +852,7 @@ const ChannelsTable = ({ }) => {
|
|||
<Center>
|
||||
<Tooltip label="Edit Channel">
|
||||
<ActionIcon
|
||||
size="sm"
|
||||
size="xs"
|
||||
variant="transparent"
|
||||
color={theme.tailwind.yellow[3]}
|
||||
onClick={() => {
|
||||
|
|
@ -823,7 +865,7 @@ const ChannelsTable = ({ }) => {
|
|||
|
||||
<Tooltip label="Delete Channel">
|
||||
<ActionIcon
|
||||
size="sm"
|
||||
size="xs"
|
||||
variant="transparent"
|
||||
color={theme.tailwind.red[6]}
|
||||
onClick={() => deleteChannel(row.original.id)}
|
||||
|
|
@ -834,7 +876,7 @@ const ChannelsTable = ({ }) => {
|
|||
|
||||
<Tooltip label="Preview Channel">
|
||||
<ActionIcon
|
||||
size="sm"
|
||||
size="xs"
|
||||
variant="transparent"
|
||||
color={theme.tailwind.green[5]}
|
||||
onClick={() => handleWatchStream(row.original.uuid)}
|
||||
|
|
@ -842,6 +884,41 @@ const ChannelsTable = ({ }) => {
|
|||
<CirclePlay size="18" />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
||||
{env_mode == 'dev' && (
|
||||
<Menu>
|
||||
<Menu.Target>
|
||||
<ActionIcon
|
||||
onClick={(event) =>
|
||||
handleMoreActionsClick(event, row.original.id)
|
||||
}
|
||||
variant="transparent"
|
||||
size="sm"
|
||||
>
|
||||
<CircleEllipsis size="18" />
|
||||
</ActionIcon>
|
||||
</Menu.Target>
|
||||
|
||||
<Menu.Dropdown>
|
||||
<Menu.Item
|
||||
onClick={() => createRecording(row.original)}
|
||||
leftSection={
|
||||
<div
|
||||
style={{
|
||||
borderRadius: '50%',
|
||||
width: '10px',
|
||||
height: '10px',
|
||||
display: 'flex',
|
||||
backgroundColor: 'red',
|
||||
}}
|
||||
></div>
|
||||
}
|
||||
>
|
||||
Record
|
||||
</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
)}
|
||||
</Center>
|
||||
</Box>
|
||||
),
|
||||
|
|
@ -1170,6 +1247,12 @@ const ChannelsTable = ({ }) => {
|
|||
isOpen={channelModalOpen}
|
||||
onClose={closeChannelForm}
|
||||
/>
|
||||
|
||||
<RecordingForm
|
||||
channel={channel}
|
||||
isOpen={recordingModalOpen}
|
||||
onClose={closeRecordingForm}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -312,7 +312,7 @@ export default function TVChannelGuide({ startDate, endDate }) {
|
|||
}}
|
||||
>
|
||||
<img
|
||||
src={channel.logo.cache_url || logo}
|
||||
src={channel.logo?.cache_url || logo}
|
||||
alt={channel.name}
|
||||
style={{
|
||||
width: '100%',
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue