first run at recordings, among other things

This commit is contained in:
dekzter 2025-04-05 18:59:09 -04:00
parent e8fdd88bca
commit 00f6e7c1cd
17 changed files with 506 additions and 95 deletions

View file

@ -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

View file

@ -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]

View 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')),
],
),
]

View file

@ -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}"

View file

@ -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']

View file

@ -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)

View file

@ -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}")

View file

@ -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

View file

@ -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",

View file

@ -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",

View file

@ -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';

View file

@ -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;
}
}

View file

@ -140,7 +140,6 @@ const Channel = ({ channel = null, isOpen, onClose }) => {
logo_id: `${channel.logo?.id}`,
});
console.log(channel);
setChannelStreams(channel.streams);
} else {
formik.resetForm();

View file

@ -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>
))}

View 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;

View file

@ -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>
);
};

View file

@ -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%',