basic DVR UI, custom properties for recordings

This commit is contained in:
dekzter 2025-04-07 15:01:44 -04:00
parent e507c6f23c
commit e2850441ae
8 changed files with 235 additions and 11 deletions

View file

@ -410,6 +410,7 @@ class Recording(models.Model):
start_time = models.DateTimeField()
end_time = models.DateTimeField()
task_id = models.CharField(max_length=255, null=True, blank=True)
custom_properties = models.TextField(null=True, blank=True)
def __str__(self):
return f"{self.channel.name} - {self.start_time} to {self.end_time}"

View file

@ -13,6 +13,7 @@ import M3U from './pages/M3U';
import EPG from './pages/EPG';
import Guide from './pages/Guide';
import Stats from './pages/Stats';
import DVR from './pages/DVR';
import Settings from './pages/Settings';
import StreamProfiles from './pages/StreamProfiles';
import useAuthStore from './store/auth';
@ -127,6 +128,7 @@ const App = () => {
element={<StreamProfiles />}
/>
<Route path="/guide" element={<Guide />} />
<Route path="/dvr" element={<DVR />} />
<Route path="/stats" element={<Stats />} />
<Route path="/settings" element={<Settings />} />
</>

View file

@ -1035,6 +1035,19 @@ export default class API {
.updateProfileChannels(channelIds, profileId, enabled);
}
static async getRecordings() {
const response = await fetch(`${host}/api/channels/recordings/`, {
headers: {
Authorization: `Bearer ${await API.getAuthToken()}`,
'Content-Type': 'application/json',
},
});
const retval = await response.json();
return retval;
}
static async createRecording(values) {
const response = await fetch(`${host}/api/channels/recordings/`, {
method: 'POST',
@ -1046,7 +1059,20 @@ export default class API {
});
const retval = await response.json();
useChannelsStore.getState().fetchRecordings();
return retval;
}
static async deleteRecording(id) {
const response = await fetch(`${host}/api/channels/recordings/${id}/`, {
method: 'DELETE',
headers: {
Authorization: `Bearer ${await API.getAuthToken()}`,
'Content-Type': 'application/json',
},
});
useChannelsStore.getState().fetchRecordings();
}
}

View file

@ -9,6 +9,7 @@ import {
Settings as LucideSettings,
Copy,
ChartLine,
Video,
} from 'lucide-react';
import {
Avatar,
@ -80,6 +81,7 @@ const Sidebar = ({ collapsed, toggleDrawer, drawerWidth, miniDrawerWidth }) => {
path: '/stream-profiles',
},
{ label: 'TV Guide', icon: <LayoutGrid size={20} />, path: '/guide' },
{ label: 'DVR', icon: <Video size={20} />, path: '/dvr' },
{ label: 'Stats', icon: <ChartLine size={20} />, path: '/stats' },
{
label: 'Settings',

135
frontend/src/pages/DVR.jsx Normal file
View file

@ -0,0 +1,135 @@
import React, { useMemo, useState, useEffect } from 'react';
import {
ActionIcon,
Box,
Button,
Card,
Center,
Container,
Flex,
Group,
SimpleGrid,
Stack,
Text,
Title,
Tooltip,
useMantineTheme,
} from '@mantine/core';
import {
Gauge,
HardDriveDownload,
HardDriveUpload,
SquarePlus,
SquareX,
Timer,
Users,
Video,
} from 'lucide-react';
import dayjs from 'dayjs';
import duration from 'dayjs/plugin/duration';
import relativeTime from 'dayjs/plugin/relativeTime';
import useChannelsStore from '../store/channels';
import RecordingForm from '../components/forms/Recording';
import API from '../api';
dayjs.extend(duration);
dayjs.extend(relativeTime);
const RecordingCard = ({ recording }) => {
const { channels } = useChannelsStore();
const deleteRecording = (id) => {
API.deleteRecording(id);
};
const customProps = JSON.parse(recording.custom_properties);
let recordingName = 'Custom Recording';
if (customProps.program) {
recordingName = customProps.program.title;
}
return (
<Card
shadow="sm"
padding="md"
radius="md"
withBorder
style={{
color: '#fff',
backgroundColor: '#27272A',
}}
>
<Flex justify="space-between" align="center">
<Group>
<Text fw={500}>{recordingName}</Text>
</Group>
<Center>
<Tooltip label="Delete / Cancel">
<ActionIcon
variant="transparent"
color="red.9"
onClick={() => deleteRecording(recording.id)}
>
<SquareX size="24" />
</ActionIcon>
</Tooltip>
</Center>
</Flex>
<Text size="sm">Channel: {channels[recording.channel].name}</Text>
<Text size="sm">
Start: {dayjs(recording.start_time).format('MMMM D, YYYY h:MMa')}
End: {dayjs(recording.end_time).format('MMMM D, YYYY h:MMa')}
</Text>
</Card>
);
};
const DVRPage = () => {
const theme = useMantineTheme();
const { recordings } = useChannelsStore();
const [recordingModalOpen, setRecordingModalOpen] = useState(false);
const openRecordingModal = () => {
setRecordingModalOpen(true);
};
const closeRecordingModal = () => {
setRecordingModalOpen(false);
};
return (
<Box style={{ padding: 10 }}>
<Button
leftSection={<SquarePlus size={18} />}
variant="light"
size="sm"
onClick={openRecordingModal}
p={5}
color={theme.tailwind.green[5]}
style={{
borderWidth: '1px',
borderColor: theme.tailwind.green[5],
color: 'white',
}}
>
New Recording
</Button>
<SimpleGrid cols={5} spacing="md" style={{ paddingTop: 10 }}>
{Object.values(recordings).map((recording) => (
<RecordingCard recording={recording} />
))}
</SimpleGrid>
<RecordingForm
isOpen={recordingModalOpen}
onClose={closeRecordingModal}
/>
</Box>
);
};
export default DVRPage;

View file

@ -16,6 +16,7 @@ import {
Text,
Paper,
Grid,
Group,
} from '@mantine/core';
import './guide.css';
@ -31,12 +32,13 @@ const MODAL_WIDTH = 600;
const MODAL_HEIGHT = 400;
export default function TVChannelGuide({ startDate, endDate }) {
const { channels } = useChannelsStore();
const { channels, recordings } = useChannelsStore();
const [programs, setPrograms] = useState([]);
const [guideChannels, setGuideChannels] = useState([]);
const [now, setNow] = useState(dayjs());
const [selectedProgram, setSelectedProgram] = useState(null);
const [recording, setRecording] = useState(null);
const [loading, setLoading] = useState(true);
const {
environment: { env_mode },
@ -70,6 +72,7 @@ export default function TVChannelGuide({ startDate, endDate }) {
);
setGuideChannels(filteredChannels);
console.log(fetched);
setPrograms(fetched);
setLoading(false);
};
@ -158,13 +161,17 @@ export default function TVChannelGuide({ startDate, endDate }) {
return guideChannels.find((ch) => ch.epg_data?.tvg_id === tvgId);
}
const record = (program) => {
const record = async (program) => {
const channel = findChannelByTvgId(program.tvg_id);
API.createRecording({
await API.createRecording({
channel: `${channel.id}`,
start_time: program.start_time,
end_time: program.end_time,
custom_properties: JSON.stringify({
program,
}),
});
notifications.show({ title: 'Recording scheduled' });
};
// The Watch Now click => show floating video
@ -190,6 +197,18 @@ export default function TVChannelGuide({ startDate, endDate }) {
// On program click, open the details modal
function handleProgramClick(program, event) {
setSelectedProgram(program);
setRecording(
recordings.find((recording) => {
if (recording.custom_properties) {
const customProps = JSON.parse(recording.custom_properties);
if (customProps.program && customProps.program.id == program.id) {
return recording;
}
}
return null;
})
);
}
// Close the modal
@ -206,6 +225,16 @@ export default function TVChannelGuide({ startDate, endDate }) {
const durationMinutes = programEnd.diff(programStart, 'minute');
const leftPx = (startOffsetMinutes / MINUTE_INCREMENT) * MINUTE_BLOCK_WIDTH;
const widthPx = (durationMinutes / MINUTE_INCREMENT) * MINUTE_BLOCK_WIDTH;
const recording = recordings.find((recording) => {
if (recording.custom_properties) {
const customProps = JSON.parse(recording.custom_properties);
if (customProps.program && customProps.program.id == program.id) {
return recording;
}
}
return null;
});
// Highlight if currently live
const isLive = now.isAfter(programStart) && now.isBefore(programEnd);
@ -250,7 +279,20 @@ export default function TVChannelGuide({ startDate, endDate }) {
}}
>
<Text size="md" style={{ fontWeight: 'bold' }}>
{program.title}
<Group gap="xs">
{recording && (
<div
style={{
borderRadius: '50%',
width: '10px',
height: '10px',
display: 'flex',
backgroundColor: 'red',
}}
></div>
)}
{program.title}
</Group>
</Text>
<Text size="sm" noWrap>
{programStart.format('h:mma')} - {programEnd.format('h:mma')}
@ -464,13 +506,15 @@ export default function TVChannelGuide({ startDate, endDate }) {
</Text>
{/* Only show the Watch button if currently live */}
<Flex mih={50} gap="xs" justify="flex-end" align="flex-end">
<Button
variant="transparent"
color="gray"
onClick={() => record(selectedProgram)}
>
Record
</Button>
{!recording && (
<Button
variant="transparent"
color="gray"
onClick={() => record(selectedProgram)}
>
Record
</Button>
)}
{now.isAfter(dayjs(selectedProgram.start_time)) &&
now.isBefore(dayjs(selectedProgram.end_time)) && (

View file

@ -36,6 +36,7 @@ const useAuthStore = create((set, get) => ({
useChannelsStore.getState().fetchChannelGroups(),
useChannelsStore.getState().fetchLogos(),
useChannelsStore.getState().fetchChannelProfiles(),
useChannelsStore.getState().fetchRecordings(),
useUserAgentsStore.getState().fetchUserAgents(),
usePlaylistsStore.getState().fetchPlaylists(),
useEPGsStore.getState().fetchEPGs(),

View file

@ -16,6 +16,7 @@ const useChannelsStore = create((set, get) => ({
activeChannels: {},
activeClients: {},
logos: {},
recordings: [],
isLoading: false,
error: null,
@ -371,6 +372,18 @@ const useChannelsStore = create((set, get) => ({
};
});
},
fetchRecordings: async () => {
set({ isLoading: true, error: null });
try {
set({
recordings: await api.getRecordings(),
});
} catch (error) {
console.error('Failed to fetch recordings:', error);
set({ error: 'Failed to load recordings.', isLoading: false });
}
},
}));
export default useChannelsStore;