Initial frontend commit for vods.

This commit is contained in:
SergeantPanda 2025-08-02 10:48:48 -05:00
parent 84aa631196
commit 386a03381c
6 changed files with 651 additions and 1 deletions

View file

@ -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 = () => {
<Route path="/users" element={<Users />} />
<Route path="/settings" element={<Settings />} />
<Route path="/logos" element={<LogosPage />} />
<Route path="/vods" element={<VODsPage />} />
</>
) : (
<Route path="/login" element={<Login needsSuperuser />} />

View file

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

View file

@ -99,13 +99,18 @@ const Sidebar = ({ collapsed, toggleDrawer, drawerWidth, miniDrawerWidth }) => {
path: '/channels',
badge: `(${Object.keys(channels).length})`,
},
{
label: 'VODs',
path: '/vods',
icon: <Video size={20} />,
},
{
label: 'M3U & EPG Manager',
icon: <Play size={20} />,
path: '/sources',
},
{ label: 'TV Guide', icon: <LayoutGrid size={20} />, path: '/guide' },
{ label: 'DVR', icon: <Video size={20} />, path: '/dvr' },
{ label: 'DVR', icon: <Database size={20} />, path: '/dvr' },
{ label: 'Stats', icon: <ChartLine size={20} />, path: '/stats' },
{
label: 'Users',

View file

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

418
frontend/src/pages/VODs.jsx Normal file
View file

@ -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 (
<Stack spacing={4}>
<Text size="sm" color="dimmed">{vod.series.name}</Text>
<Text weight={500}>{seasonEp} - {vod.name}</Text>
</Stack>
);
}
return <Text weight={500}>{vod.name}</Text>;
};
return (
<Card
shadow="sm"
padding="md"
radius="md"
withBorder
style={{ cursor: 'pointer', backgroundColor: '#27272A' }}
onClick={() => onClick(vod)}
>
<Card.Section>
<Box style={{ position: 'relative', height: 200 }}>
{vod.logo?.url ? (
<Image
src={vod.logo.url}
height={200}
alt={vod.name}
fit="cover"
/>
) : (
<Box
style={{
height: 200,
backgroundColor: '#404040',
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
}}
>
<Play size={48} color="#666" />
</Box>
)}
<ActionIcon
style={{
position: 'absolute',
top: 8,
right: 8,
backgroundColor: 'rgba(0,0,0,0.7)'
}}
onClick={(e) => {
e.stopPropagation();
onClick(vod);
}}
>
<Play size={16} color="white" />
</ActionIcon>
<Badge
style={{
position: 'absolute',
bottom: 8,
left: 8
}}
color={isEpisode ? 'blue' : 'green'}
>
{isEpisode ? 'Episode' : 'Movie'}
</Badge>
</Box>
</Card.Section>
<Stack spacing={8} mt="md">
{getDisplayTitle()}
<Group spacing={16}>
{vod.year && (
<Group spacing={4}>
<Calendar size={14} color="#666" />
<Text size="xs" color="dimmed">{vod.year}</Text>
</Group>
)}
{vod.duration && (
<Group spacing={4}>
<Clock size={14} color="#666" />
<Text size="xs" color="dimmed">{formatDuration(vod.duration)}</Text>
</Group>
)}
{vod.rating && (
<Group spacing={4}>
<Star size={14} color="#666" />
<Text size="xs" color="dimmed">{vod.rating}</Text>
</Group>
)}
</Group>
{vod.genre && (
<Text size="xs" color="dimmed" lineClamp={1}>
{vod.genre}
</Text>
)}
</Stack>
</Card>
);
};
const SeriesCard = ({ series, onClick }) => {
return (
<Card
shadow="sm"
padding="md"
radius="md"
withBorder
style={{ cursor: 'pointer', backgroundColor: '#27272A' }}
onClick={() => onClick(series)}
>
<Card.Section>
<Box style={{ position: 'relative', height: 200 }}>
{series.logo?.url ? (
<Image
src={series.logo.url}
height={200}
alt={series.name}
fit="cover"
/>
) : (
<Box
style={{
height: 200,
backgroundColor: '#404040',
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
}}
>
<Play size={48} color="#666" />
</Box>
)}
</Box>
</Card.Section>
<Stack spacing={8} mt="md">
<Text weight={500}>{series.name}</Text>
<Group spacing={16}>
{series.year && (
<Group spacing={4}>
<Calendar size={14} color="#666" />
<Text size="xs" color="dimmed">{series.year}</Text>
</Group>
)}
<Text size="xs" color="dimmed">
{series.episode_count} episodes
</Text>
</Group>
{series.genre && (
<Text size="xs" color="dimmed" lineClamp={1}>
{series.genre}
</Text>
)}
</Stack>
</Card>
);
};
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 (
<Modal
opened={opened}
onClose={onClose}
title={series.name}
size="xl"
centered
>
<Stack spacing="md">
{series.description && (
<Text size="sm" color="dimmed">
{series.description}
</Text>
)}
<Group spacing="md">
{series.year && <Badge color="blue">{series.year}</Badge>}
{series.rating && <Badge color="yellow">{series.rating}</Badge>}
{series.genre && <Badge color="gray">{series.genre}</Badge>}
</Group>
<Title order={4}>Episodes</Title>
{loading ? (
<Flex justify="center" py="xl">
<Loader />
</Flex>
) : (
<Grid>
{episodes.map(episode => (
<Grid.Col span={6} key={episode.id}>
<VODCard vod={episode} onClick={handlePlayEpisode} />
</Grid.Col>
))}
</Grid>
)}
</Stack>
</Modal>
);
};
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 (
<Box p="md">
<Stack spacing="md">
<Group position="apart">
<Title order={2}>Video on Demand</Title>
</Group>
{/* Filters */}
<Group spacing="md">
<SegmentedControl
value={filters.type}
onChange={(value) => setFilters({ type: value })}
data={[
{ label: 'All', value: 'all' },
{ label: 'Movies', value: 'movies' },
{ label: 'Series', value: 'series' }
]}
/>
<TextInput
placeholder="Search VODs..."
icon={<Search size={16} />}
value={filters.search}
onChange={(e) => setFilters({ search: e.target.value })}
style={{ minWidth: 200 }}
/>
<Select
placeholder="Category"
data={categoryOptions}
value={filters.category}
onChange={(value) => setFilters({ category: value })}
clearable
style={{ minWidth: 150 }}
/>
</Group>
{/* Content */}
{loading ? (
<Flex justify="center" py="xl">
<Loader size="lg" />
</Flex>
) : (
<>
{filters.type === 'series' ? (
<Grid>
{Object.values(series).map(seriesItem => (
<Grid.Col span={3} key={seriesItem.id}>
<SeriesCard
series={seriesItem}
onClick={handleSeriesClick}
/>
</Grid.Col>
))}
</Grid>
) : (
<Grid>
{Object.values(vods).map(vod => (
<Grid.Col span={3} key={vod.id}>
<VODCard vod={vod} onClick={handlePlayVOD} />
</Grid.Col>
))}
</Grid>
)}
{/* Pagination */}
{totalPages > 1 && (
<Flex justify="center" mt="md">
<Pagination
page={currentPage}
onChange={setPage}
total={totalPages}
/>
</Flex>
)}
</>
)}
</Stack>
{/* Series Episodes Modal */}
<SeriesModal
series={selectedSeries}
opened={seriesModalOpened}
onClose={closeSeriesModal}
/>
</Box>
);
};
export default VODsPage;

View file

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