diff --git a/apps/channels/models.py b/apps/channels/models.py
index abcecb77..9f1b641e 100644
--- a/apps/channels/models.py
+++ b/apps/channels/models.py
@@ -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}"
diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx
index 4fa2b9a9..a641a53b 100644
--- a/frontend/src/App.jsx
+++ b/frontend/src/App.jsx
@@ -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={}
/>
} />
+ } />
} />
} />
>
diff --git a/frontend/src/api.js b/frontend/src/api.js
index 9e1dfd55..9d8bf746 100644
--- a/frontend/src/api.js
+++ b/frontend/src/api.js
@@ -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();
+ }
}
diff --git a/frontend/src/components/Sidebar.jsx b/frontend/src/components/Sidebar.jsx
index 157381f0..00fb4045 100644
--- a/frontend/src/components/Sidebar.jsx
+++ b/frontend/src/components/Sidebar.jsx
@@ -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: , path: '/guide' },
+ { label: 'DVR', icon: , path: '/dvr' },
{ label: 'Stats', icon: , path: '/stats' },
{
label: 'Settings',
diff --git a/frontend/src/pages/DVR.jsx b/frontend/src/pages/DVR.jsx
new file mode 100644
index 00000000..7f3e009e
--- /dev/null
+++ b/frontend/src/pages/DVR.jsx
@@ -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 (
+
+
+
+ {recordingName}
+
+
+
+
+ deleteRecording(recording.id)}
+ >
+
+
+
+
+
+
+ Channel: {channels[recording.channel].name}
+
+ Start: {dayjs(recording.start_time).format('MMMM D, YYYY h:MMa')}
+ End: {dayjs(recording.end_time).format('MMMM D, YYYY h:MMa')}
+
+
+ );
+};
+
+const DVRPage = () => {
+ const theme = useMantineTheme();
+
+ const { recordings } = useChannelsStore();
+
+ const [recordingModalOpen, setRecordingModalOpen] = useState(false);
+
+ const openRecordingModal = () => {
+ setRecordingModalOpen(true);
+ };
+
+ const closeRecordingModal = () => {
+ setRecordingModalOpen(false);
+ };
+
+ return (
+
+ }
+ 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
+
+
+ {Object.values(recordings).map((recording) => (
+
+ ))}
+
+
+
+
+ );
+};
+
+export default DVRPage;
diff --git a/frontend/src/pages/Guide.jsx b/frontend/src/pages/Guide.jsx
index 3e65bc23..3db79049 100644
--- a/frontend/src/pages/Guide.jsx
+++ b/frontend/src/pages/Guide.jsx
@@ -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 }) {
}}
>
- {program.title}
+
+ {recording && (
+
+ )}
+ {program.title}
+
{programStart.format('h:mma')} - {programEnd.format('h:mma')}
@@ -464,13 +506,15 @@ export default function TVChannelGuide({ startDate, endDate }) {
{/* Only show the Watch button if currently live */}
-
+ {!recording && (
+
+ )}
{now.isAfter(dayjs(selectedProgram.start_time)) &&
now.isBefore(dayjs(selectedProgram.end_time)) && (
diff --git a/frontend/src/store/auth.jsx b/frontend/src/store/auth.jsx
index 2355d125..d6eb8053 100644
--- a/frontend/src/store/auth.jsx
+++ b/frontend/src/store/auth.jsx
@@ -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(),
diff --git a/frontend/src/store/channels.jsx b/frontend/src/store/channels.jsx
index 6f28c46b..d6185eea 100644
--- a/frontend/src/store/channels.jsx
+++ b/frontend/src/store/channels.jsx
@@ -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;