diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx
index 4467759e..749a53c1 100644
--- a/frontend/src/App.jsx
+++ b/frontend/src/App.jsx
@@ -16,6 +16,7 @@ import DVR from './pages/DVR';
import Settings from './pages/Settings';
import Users from './pages/Users';
import LogosPage from './pages/Logos';
+import VODsPage from './pages/VODs';
import useAuthStore from './store/auth';
import FloatingVideo from './components/FloatingVideo';
import { WebsocketProvider } from './WebSocket';
@@ -135,6 +136,7 @@ const App = () => {
} />
} />
} />
+ } />
>
) : (
} />
diff --git a/frontend/src/api.js b/frontend/src/api.js
index ddaccbc7..cfdf1a90 100644
--- a/frontend/src/api.js
+++ b/frontend/src/api.js
@@ -1689,4 +1689,76 @@ export default class API {
errorNotification('Failed to retrieve streams by IDs', e);
}
}
+
+ // VOD Methods
+ static async getVODs(params = {}) {
+ try {
+ const searchParams = new URLSearchParams(params);
+ const response = await request(`${host}/api/vod/vods/?${searchParams.toString()}`);
+ return response;
+ } catch (e) {
+ errorNotification('Failed to retrieve VODs', e);
+ }
+ }
+
+ static async getVODCategories() {
+ try {
+ const response = await request(`${host}/api/vod/categories/`);
+ return response;
+ } catch (e) {
+ errorNotification('Failed to retrieve VOD categories', e);
+ }
+ }
+
+ static async getSeries(params = {}) {
+ try {
+ const searchParams = new URLSearchParams(params);
+ const response = await request(`${host}/api/vod/series/?${searchParams.toString()}`);
+ return response;
+ } catch (e) {
+ errorNotification('Failed to retrieve series', e);
+ }
+ }
+
+ static async getSeriesEpisodes(seriesId, params = {}) {
+ try {
+ const searchParams = new URLSearchParams(params);
+ const response = await request(`${host}/api/vod/series/${seriesId}/episodes/?${searchParams.toString()}`);
+ return response;
+ } catch (e) {
+ errorNotification('Failed to retrieve series episodes', e);
+ }
+ }
+
+ static async getVODConnections() {
+ try {
+ const response = await request(`${host}/api/vod/connections/`);
+ return response;
+ } catch (e) {
+ errorNotification('Failed to retrieve VOD connections', e);
+ }
+ }
+
+ static async refreshVODContent(accountId) {
+ try {
+ const response = await request(`${host}/api/m3u/accounts/${accountId}/refresh-vod/`, {
+ method: 'POST'
+ });
+ return response;
+ } catch (e) {
+ errorNotification('Failed to refresh VOD content', e);
+ }
+ }
+
+ static async updateVODPosition(vodUuid, clientId, position) {
+ try {
+ const response = await request(`${host}/proxy/vod/stream/${vodUuid}/position/`, {
+ method: 'POST',
+ body: { client_id: clientId, position }
+ });
+ return response;
+ } catch (e) {
+ errorNotification('Failed to update playback position', e);
+ }
+ }
}
diff --git a/frontend/src/components/Sidebar.jsx b/frontend/src/components/Sidebar.jsx
index 03ad831e..998bc768 100644
--- a/frontend/src/components/Sidebar.jsx
+++ b/frontend/src/components/Sidebar.jsx
@@ -99,13 +99,18 @@ const Sidebar = ({ collapsed, toggleDrawer, drawerWidth, miniDrawerWidth }) => {
path: '/channels',
badge: `(${Object.keys(channels).length})`,
},
+ {
+ label: 'VODs',
+ path: '/vods',
+ icon: ,
+ },
{
label: 'M3U & EPG Manager',
icon: ,
path: '/sources',
},
{ label: 'TV Guide', icon: , path: '/guide' },
- { label: 'DVR', icon: , path: '/dvr' },
+ { label: 'DVR', icon: , path: '/dvr' },
{ label: 'Stats', icon: , path: '/stats' },
{
label: 'Users',
diff --git a/frontend/src/constants.js b/frontend/src/constants.js
index 19f9955f..27b12c08 100644
--- a/frontend/src/constants.js
+++ b/frontend/src/constants.js
@@ -303,3 +303,25 @@ export const REGION_CHOICES = [
{ value: 'zm', label: 'ZM' },
{ value: 'zw', label: 'ZW' },
];
+
+export const VOD_TYPES = {
+ MOVIE: 'movie',
+ EPISODE: 'episode'
+};
+
+export const VOD_FILTERS = {
+ ALL: 'all',
+ MOVIES: 'movies',
+ SERIES: 'series'
+};
+
+export const VOD_SORT_OPTIONS = [
+ { value: 'name', label: 'Name' },
+ { value: 'year', label: 'Year' },
+ { value: 'created_at', label: 'Date Added' },
+ { value: 'rating', label: 'Rating' }
+];
+
+export const CONTAINER_EXTENSIONS = [
+ 'mp4', 'mkv', 'avi', 'mov', 'wmv', 'flv', 'webm', 'm4v', 'ts', 'mpg'
+];
diff --git a/frontend/src/pages/VODs.jsx b/frontend/src/pages/VODs.jsx
new file mode 100644
index 00000000..2da647e6
--- /dev/null
+++ b/frontend/src/pages/VODs.jsx
@@ -0,0 +1,418 @@
+import React, { useState, useEffect } from 'react';
+import {
+ Box,
+ Button,
+ Card,
+ Flex,
+ Group,
+ Image,
+ Text,
+ Title,
+ Select,
+ TextInput,
+ Pagination,
+ Badge,
+ Grid,
+ Loader,
+ Stack,
+ SegmentedControl,
+ ActionIcon,
+ Modal
+} from '@mantine/core';
+import { Search, Play, Calendar, Clock, Star } from 'lucide-react';
+import { useDisclosure } from '@mantine/hooks';
+import useVODStore from '../store/useVODStore';
+import useVideoStore from '../store/useVideoStore';
+
+const VODCard = ({ vod, onClick }) => {
+ const isEpisode = vod.type === 'episode';
+
+ const formatDuration = (minutes) => {
+ if (!minutes) return '';
+ const hours = Math.floor(minutes / 60);
+ const mins = minutes % 60;
+ return hours > 0 ? `${hours}h ${mins}m` : `${mins}m`;
+ };
+
+ const getDisplayTitle = () => {
+ if (isEpisode && vod.series) {
+ const seasonEp = vod.season_number && vod.episode_number
+ ? `S${vod.season_number.toString().padStart(2, '0')}E${vod.episode_number.toString().padStart(2, '0')}`
+ : '';
+ return (
+
+ {vod.series.name}
+ {seasonEp} - {vod.name}
+
+ );
+ }
+ return {vod.name};
+ };
+
+ return (
+ onClick(vod)}
+ >
+
+
+ {vod.logo?.url ? (
+
+ ) : (
+
+
+
+ )}
+
+ {
+ e.stopPropagation();
+ onClick(vod);
+ }}
+ >
+
+
+
+
+ {isEpisode ? 'Episode' : 'Movie'}
+
+
+
+
+
+ {getDisplayTitle()}
+
+
+ {vod.year && (
+
+
+ {vod.year}
+
+ )}
+
+ {vod.duration && (
+
+
+ {formatDuration(vod.duration)}
+
+ )}
+
+ {vod.rating && (
+
+
+ {vod.rating}
+
+ )}
+
+
+ {vod.genre && (
+
+ {vod.genre}
+
+ )}
+
+
+ );
+};
+
+const SeriesCard = ({ series, onClick }) => {
+ return (
+ onClick(series)}
+ >
+
+
+ {series.logo?.url ? (
+
+ ) : (
+
+
+
+ )}
+
+
+
+
+ {series.name}
+
+
+ {series.year && (
+
+
+ {series.year}
+
+ )}
+
+
+ {series.episode_count} episodes
+
+
+
+ {series.genre && (
+
+ {series.genre}
+
+ )}
+
+
+ );
+};
+
+const SeriesModal = ({ series, opened, onClose }) => {
+ const { fetchSeriesEpisodes, vods, loading } = useVODStore();
+ const showVideo = useVideoStore((s) => s.showVideo);
+
+ useEffect(() => {
+ if (opened && series) {
+ fetchSeriesEpisodes(series.id);
+ }
+ }, [opened, series, fetchSeriesEpisodes]);
+
+ const episodes = Object.values(vods).filter(
+ vod => vod.type === 'episode' && vod.series?.id === series?.id
+ ).sort((a, b) => {
+ if (a.season_number !== b.season_number) {
+ return (a.season_number || 0) - (b.season_number || 0);
+ }
+ return (a.episode_number || 0) - (b.episode_number || 0);
+ });
+
+ const handlePlayEpisode = (episode) => {
+ const streamUrl = `${window.location.origin}${episode.stream_url}`;
+ showVideo(streamUrl);
+ };
+
+ if (!series) return null;
+
+ return (
+
+
+ {series.description && (
+
+ {series.description}
+
+ )}
+
+
+ {series.year && {series.year}}
+ {series.rating && {series.rating}}
+ {series.genre && {series.genre}}
+
+
+ Episodes
+
+ {loading ? (
+
+
+
+ ) : (
+
+ {episodes.map(episode => (
+
+
+
+ ))}
+
+ )}
+
+
+ );
+};
+
+const VODsPage = () => {
+ const {
+ vods,
+ series,
+ categories,
+ loading,
+ filters,
+ currentPage,
+ totalCount,
+ pageSize,
+ setFilters,
+ setPage,
+ fetchVODs,
+ fetchSeries,
+ fetchCategories
+ } = useVODStore();
+
+ const showVideo = useVideoStore((s) => s.showVideo);
+ const [selectedSeries, setSelectedSeries] = useState(null);
+ const [seriesModalOpened, { open: openSeriesModal, close: closeSeriesModal }] = useDisclosure(false);
+
+ useEffect(() => {
+ fetchCategories();
+ }, [fetchCategories]);
+
+ useEffect(() => {
+ if (filters.type === 'series') {
+ fetchSeries();
+ } else {
+ fetchVODs();
+ }
+ }, [filters, currentPage, fetchVODs, fetchSeries]);
+
+ const handlePlayVOD = (vod) => {
+ const streamUrl = `${window.location.origin}${vod.stream_url}`;
+ showVideo(streamUrl);
+ };
+
+ const handleSeriesClick = (series) => {
+ setSelectedSeries(series);
+ openSeriesModal();
+ };
+
+ const categoryOptions = [
+ { value: '', label: 'All Categories' },
+ ...Object.values(categories).map(cat => ({
+ value: cat.name,
+ label: cat.name
+ }))
+ ];
+
+ const totalPages = Math.ceil(totalCount / pageSize);
+
+ return (
+
+
+
+ Video on Demand
+
+
+ {/* Filters */}
+
+ setFilters({ type: value })}
+ data={[
+ { label: 'All', value: 'all' },
+ { label: 'Movies', value: 'movies' },
+ { label: 'Series', value: 'series' }
+ ]}
+ />
+
+ }
+ value={filters.search}
+ onChange={(e) => setFilters({ search: e.target.value })}
+ style={{ minWidth: 200 }}
+ />
+
+
+
+ {/* Content */}
+ {loading ? (
+
+
+
+ ) : (
+ <>
+ {filters.type === 'series' ? (
+
+ {Object.values(series).map(seriesItem => (
+
+
+
+ ))}
+
+ ) : (
+
+ {Object.values(vods).map(vod => (
+
+
+
+ ))}
+
+ )}
+
+ {/* Pagination */}
+ {totalPages > 1 && (
+
+
+
+ )}
+ >
+ )}
+
+
+ {/* Series Episodes Modal */}
+
+
+ );
+};
+
+export default VODsPage;
diff --git a/frontend/src/store/useVODStore.jsx b/frontend/src/store/useVODStore.jsx
new file mode 100644
index 00000000..ed723c80
--- /dev/null
+++ b/frontend/src/store/useVODStore.jsx
@@ -0,0 +1,131 @@
+import { create } from 'zustand';
+import API from '../api';
+
+const host = window.location.origin;
+
+const useVODStore = create((set, get) => ({
+ // State
+ vods: {},
+ series: {},
+ categories: {},
+ loading: false,
+ error: null,
+
+ // Filters and pagination
+ currentPage: 1,
+ pageSize: 50,
+ totalCount: 0,
+ filters: {
+ type: 'all', // 'all', 'movies', 'series'
+ category: '',
+ search: '',
+ year: null,
+ seriesId: null
+ },
+
+ // Actions
+ setLoading: (loading) => set({ loading }),
+ setError: (error) => set({ error }),
+
+ setFilters: (newFilters) => set((state) => ({
+ filters: { ...state.filters, ...newFilters },
+ currentPage: 1 // Reset to first page when filters change
+ })),
+
+ setPage: (page) => set({ currentPage: page }),
+
+ fetchVODs: async () => {
+ const { filters, currentPage, pageSize } = get();
+ set({ loading: true, error: null });
+
+ try {
+ const params = new URLSearchParams({
+ page: currentPage.toString(),
+ page_size: pageSize.toString(),
+ ...Object.fromEntries(
+ Object.entries(filters).filter(([_, value]) =>
+ value !== null && value !== '' && value !== 'all'
+ )
+ )
+ });
+
+ const response = await API.request(`${host}/api/vod/vods/?${params}`);
+
+ set({
+ vods: response.results || response,
+ totalCount: response.count || response.length || 0,
+ loading: false
+ });
+ } catch (error) {
+ set({ error: error.message, loading: false });
+ }
+ },
+
+ fetchSeries: async () => {
+ const { filters, currentPage, pageSize } = get();
+ set({ loading: true, error: null });
+
+ try {
+ const params = new URLSearchParams({
+ page: currentPage.toString(),
+ page_size: pageSize.toString(),
+ search: filters.search || ''
+ });
+
+ const response = await API.request(`${host}/api/vod/series/?${params}`);
+
+ set({
+ series: response.results || response,
+ totalCount: response.count || response.length || 0,
+ loading: false
+ });
+ } catch (error) {
+ set({ error: error.message, loading: false });
+ }
+ },
+
+ fetchCategories: async () => {
+ set({ loading: true, error: null });
+
+ try {
+ const response = await API.request(`${host}/api/vod/categories/`);
+ set({
+ categories: response.results || response,
+ loading: false
+ });
+ } catch (error) {
+ set({ error: error.message, loading: false });
+ }
+ },
+
+ fetchSeriesEpisodes: async (seriesId) => {
+ set({ loading: true, error: null });
+
+ try {
+ const response = await API.request(`${host}/api/vod/series/${seriesId}/episodes/`);
+ set({
+ vods: response.results || response,
+ totalCount: response.count || response.length || 0,
+ loading: false
+ });
+ } catch (error) {
+ set({ error: error.message, loading: false });
+ }
+ },
+
+ // Clear data
+ clearVODs: () => set({ vods: {}, totalCount: 0 }),
+ clearSeries: () => set({ series: {} }),
+ clearFilters: () => set({
+ filters: {
+ type: 'all',
+ category: '',
+ search: '',
+ year: null,
+ seriesId: null
+ },
+ currentPage: 1
+ })
+}));
+
+export default useVODStore;