Applied our Prettier formatting to all frontend code.

This commit is contained in:
SergeantPanda 2025-08-31 09:50:37 -05:00
parent 59c6b0565e
commit 648e2bb2dd
25 changed files with 1492 additions and 1244 deletions

View file

@ -93,55 +93,55 @@ const Sidebar = ({ collapsed, toggleDrawer, drawerWidth, miniDrawerWidth }) => {
const navItems =
authUser && authUser.user_level == USER_LEVELS.ADMIN
? [
{
label: 'Channels',
icon: <ListOrdered size={20} />,
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: <Database size={20} />, path: '/dvr' },
{ label: 'Stats', icon: <ChartLine size={20} />, path: '/stats' },
{
label: 'Users',
icon: <User size={20} />,
path: '/users',
},
{
label: 'Logo Manager',
icon: <FileImage size={20} />,
path: '/logos',
},
{
label: 'Settings',
icon: <LucideSettings size={20} />,
path: '/settings',
},
]
{
label: 'Channels',
icon: <ListOrdered size={20} />,
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: <Database size={20} />, path: '/dvr' },
{ label: 'Stats', icon: <ChartLine size={20} />, path: '/stats' },
{
label: 'Users',
icon: <User size={20} />,
path: '/users',
},
{
label: 'Logo Manager',
icon: <FileImage size={20} />,
path: '/logos',
},
{
label: 'Settings',
icon: <LucideSettings size={20} />,
path: '/settings',
},
]
: [
{
label: 'Channels',
icon: <ListOrdered size={20} />,
path: '/channels',
badge: `(${Object.keys(channels).length})`,
},
{ label: 'TV Guide', icon: <LayoutGrid size={20} />, path: '/guide' },
{
label: 'Settings',
icon: <LucideSettings size={20} />,
path: '/settings',
},
];
{
label: 'Channels',
icon: <ListOrdered size={20} />,
path: '/channels',
badge: `(${Object.keys(channels).length})`,
},
{ label: 'TV Guide', icon: <LayoutGrid size={20} />, path: '/guide' },
{
label: 'Settings',
icon: <LucideSettings size={20} />,
path: '/settings',
},
];
// Fetch environment settings including version on component mount
useEffect(() => {

View file

@ -58,7 +58,7 @@ const ChannelBatchForm = ({ channelIds, isOpen, onClose }) => {
const values = {
...form.getValues(),
}; // Handle channel group ID - convert to integer if it exists
}; // Handle channel group ID - convert to integer if it exists
if (selectedChannelGroup && selectedChannelGroup !== '-1') {
values.channel_group_id = parseInt(selectedChannelGroup);
} else {
@ -68,7 +68,10 @@ const ChannelBatchForm = ({ channelIds, isOpen, onClose }) => {
// Handle stream profile ID - convert special values
if (!values.stream_profile_id || values.stream_profile_id === '-1') {
delete values.stream_profile_id;
} else if (values.stream_profile_id === '0' || values.stream_profile_id === 0) {
} else if (
values.stream_profile_id === '0' ||
values.stream_profile_id === 0
) {
values.stream_profile_id = null; // Convert "use default" to null
}
@ -84,7 +87,7 @@ const ChannelBatchForm = ({ channelIds, isOpen, onClose }) => {
// Refresh both the channels table data and the main channels store
await Promise.all([
API.requeryChannels(),
useChannelsStore.getState().fetchChannels()
useChannelsStore.getState().fetchChannels(),
]);
onClose();
} catch (error) {
@ -131,7 +134,7 @@ const ChannelBatchForm = ({ channelIds, isOpen, onClose }) => {
{ id: '-1', name: '(no change)' },
...groupOptions.filter((group) =>
group.name.toLowerCase().includes(groupFilter.toLowerCase())
)
),
];
if (!isOpen) {
@ -172,8 +175,10 @@ const ChannelBatchForm = ({ channelIds, isOpen, onClose }) => {
key={form.key('channel_group')}
onClick={() => setGroupPopoverOpened(true)}
size="xs"
style={{ flex: 1 }} rightSection={
form.getValues().channel_group && form.getValues().channel_group !== '(no change)' && (
style={{ flex: 1 }}
rightSection={
form.getValues().channel_group &&
form.getValues().channel_group !== '(no change)' && (
<ActionIcon
size="xs"
variant="subtle"
@ -282,7 +287,7 @@ const ChannelBatchForm = ({ channelIds, isOpen, onClose }) => {
key={form.key('stream_profile_id')}
data={[
{ value: '-1', label: '(no change)' },
{ value: '0', label: '(use default)' }
{ value: '0', label: '(use default)' },
].concat(
streamProfiles.map((option) => ({
value: `${option.id}`,

View file

@ -38,7 +38,10 @@ const ChannelGroup = ({ channelGroup = null, isOpen, onClose }) => {
let newGroup;
if (channelGroup) {
newGroup = await API.updateChannelGroup({ id: channelGroup.id, ...values });
newGroup = await API.updateChannelGroup({
id: channelGroup.id,
...values,
});
} else {
newGroup = await API.addChannelGroup(values);
}

File diff suppressed because it is too large Load diff

View file

@ -413,7 +413,10 @@ const M3U = ({
size="sm"
onClick={() => {
// If this is an XC account with VOD enabled, fetch VOD categories
if (m3uAccount?.account_type === 'XC' && m3uAccount?.enable_vod) {
if (
m3uAccount?.account_type === 'XC' &&
m3uAccount?.enable_vod
) {
fetchCategories();
}
setGroupFilterModalOpen(true);

View file

@ -61,7 +61,7 @@ const M3UProfiles = ({ playlist = null, isOpen, onClose }) => {
if (!playlist || !playlist.id) return;
// Get profile details for the confirmation dialog
const profileObj = profiles.find(p => p.id === id);
const profileObj = profiles.find((p) => p.id === id);
setProfileToDelete(profileObj);
setDeleteTarget(id);
@ -195,13 +195,13 @@ const M3UProfiles = ({ playlist = null, isOpen, onClose }) => {
New
</Button>
</Flex>
</Modal> <M3UProfile
</Modal>{' '}
<M3UProfile
m3u={playlist}
profile={profile}
isOpen={profileEditorOpen}
onClose={closeEditor}
/>
<ConfirmationDialog
opened={confirmDeleteOpen}
onClose={() => setConfirmDeleteOpen(false)}

View file

@ -44,7 +44,7 @@ const User = ({ user = null, isOpen, onClose }) => {
username: !values.username
? 'Username is required'
: values.user_level == USER_LEVELS.STREAMER &&
!values.username.match(/^[a-z0-9]+$/i)
!values.username.match(/^[a-z0-9]+$/i)
? 'Streamer username must be alphanumeric'
: null,
password:
@ -74,9 +74,7 @@ const User = ({ user = null, isOpen, onClose }) => {
const onSubmit = async () => {
const values = form.getValues();
const { ...customProps } = JSON.parse(
user?.custom_properties || '{}'
);
const { ...customProps } = JSON.parse(user?.custom_properties || '{}');
// Always save xc_password, even if it's empty (to allow clearing)
customProps.xc_password = values.xc_password || '';

View file

@ -1,7 +1,13 @@
import React, { useMemo, useState, useEffect } from 'react';
import API from '../../api';
import { copyToClipboard } from '../../utils';
import { GripHorizontal, SquareMinus, ChevronDown, ChevronRight, Eye } from 'lucide-react';
import {
GripHorizontal,
SquareMinus,
ChevronDown,
ChevronRight,
Eye,
} from 'lucide-react';
import {
Box,
ActionIcon,
@ -14,7 +20,6 @@ import {
Tooltip,
Collapse,
Button,
} from '@mantine/core';
import { notifications } from '@mantine/notifications';
import {
@ -178,23 +183,49 @@ const ChannelStreams = ({ channel, isExpanded }) => {
// Helper function to categorize stream stats
const categorizeStreamStats = (stats) => {
if (!stats) return { basic: {}, video: {}, audio: {}, technical: {}, other: {} };
if (!stats)
return { basic: {}, video: {}, audio: {}, technical: {}, other: {} };
const categories = {
basic: {},
video: {},
audio: {},
technical: {},
other: {}
other: {},
};
// Define which stats go in which category
const categoryMapping = {
basic: ['resolution', 'video_codec', 'source_fps', 'audio_codec', 'audio_channels'],
video: ['video_bitrate', 'pixel_format', 'width', 'height', 'aspect_ratio', 'frame_rate'],
audio: ['audio_bitrate', 'sample_rate', 'audio_format', 'audio_channels_layout'],
technical: ['stream_type', 'container_format', 'duration', 'file_size', 'ffmpeg_output_bitrate', 'input_bitrate'],
other: [] // Will catch anything not categorized above
basic: [
'resolution',
'video_codec',
'source_fps',
'audio_codec',
'audio_channels',
],
video: [
'video_bitrate',
'pixel_format',
'width',
'height',
'aspect_ratio',
'frame_rate',
],
audio: [
'audio_bitrate',
'sample_rate',
'audio_format',
'audio_channels_layout',
],
technical: [
'stream_type',
'container_format',
'duration',
'file_size',
'ffmpeg_output_bitrate',
'input_bitrate',
],
other: [], // Will catch anything not categorized above
};
// Categorize each stat
@ -238,7 +269,8 @@ const ChannelStreams = ({ channel, isExpanded }) => {
if (typeof value === 'number') {
if (value < 1024) return `${value} B`;
if (value < 1024 * 1024) return `${(value / 1024).toFixed(2)} KB`;
if (value < 1024 * 1024 * 1024) return `${(value / (1024 * 1024)).toFixed(2)} MB`;
if (value < 1024 * 1024 * 1024)
return `${(value / (1024 * 1024)).toFixed(2)} MB`;
return `${(value / (1024 * 1024 * 1024)).toFixed(2)} GB`;
}
return value;
@ -269,7 +301,10 @@ const ChannelStreams = ({ channel, isExpanded }) => {
{Object.entries(stats).map(([key, value]) => (
<Tooltip key={key} label={`${key}: ${formatStatValue(key, value)}`}>
<Badge size="xs" variant="light" color="gray">
{key.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase())}: {formatStatValue(key, value)}
{key
.replace(/_/g, ' ')
.replace(/\b\w/g, (l) => l.toUpperCase())}
: {formatStatValue(key, value)}
</Badge>
</Tooltip>
))}
@ -304,19 +339,23 @@ const ChannelStreams = ({ channel, isExpanded }) => {
accessorKey: 'name',
cell: ({ row }) => {
const stream = row.original;
const playlistName = playlists[stream.m3u_account]?.name || 'Unknown';
const accountName = m3uAccountsMap[stream.m3u_account] || playlistName;
const playlistName =
playlists[stream.m3u_account]?.name || 'Unknown';
const accountName =
m3uAccountsMap[stream.m3u_account] || playlistName;
// Categorize stream stats
const categorizedStats = categorizeStreamStats(stream.stream_stats);
const hasAdvancedStats = Object.values(categorizedStats).some(category =>
Object.keys(category).length > 0
const hasAdvancedStats = Object.values(categorizedStats).some(
(category) => Object.keys(category).length > 0
);
return (
<Box>
<Group gap="xs" align="center">
<Text fw={500} size="sm">{stream.name}</Text>
<Text fw={500} size="sm">
{stream.name}
</Text>
<Badge size="xs" variant="light" color="teal">
{accountName}
</Badge>
@ -338,7 +377,9 @@ const ChannelStreams = ({ channel, isExpanded }) => {
const success = await copyToClipboard(stream.url);
notifications.show({
title: success ? 'URL Copied' : 'Copy Failed',
message: success ? 'Stream URL copied to clipboard' : 'Failed to copy URL to clipboard',
message: success
? 'Stream URL copied to clipboard'
: 'Failed to copy URL to clipboard',
color: success ? 'green' : 'red',
});
}}
@ -351,7 +392,9 @@ const ChannelStreams = ({ channel, isExpanded }) => {
size="xs"
color="blue"
variant="light"
onClick={() => handleWatchStream(stream.stream_hash || stream.id)}
onClick={() =>
handleWatchStream(stream.stream_hash || stream.id)
}
style={{ marginLeft: 2 }}
>
<Eye size={16} />
@ -365,16 +408,26 @@ const ChannelStreams = ({ channel, isExpanded }) => {
{stream.stream_stats && (
<Group gap="xs" mt={4} align="center">
{/* Video Information */}
{(stream.stream_stats.video_codec || stream.stream_stats.resolution || stream.stream_stats.video_bitrate || stream.stream_stats.source_fps) && (
{(stream.stream_stats.video_codec ||
stream.stream_stats.resolution ||
stream.stream_stats.video_bitrate ||
stream.stream_stats.source_fps) && (
<>
<Text size="xs" c="dimmed" fw={500}>Video:</Text>
<Text size="xs" c="dimmed" fw={500}>
Video:
</Text>
{stream.stream_stats.resolution && (
<Badge size="xs" variant="light" color="red">
{stream.stream_stats.resolution}
</Badge>
)}
{stream.stream_stats.video_bitrate && (
<Badge size="xs" variant="light" color="orange" style={{ textTransform: 'none' }}>
<Badge
size="xs"
variant="light"
color="orange"
style={{ textTransform: 'none' }}
>
{stream.stream_stats.video_bitrate} kbps
</Badge>
)}
@ -392,9 +445,12 @@ const ChannelStreams = ({ channel, isExpanded }) => {
)}
{/* Audio Information */}
{(stream.stream_stats.audio_codec || stream.stream_stats.audio_channels) && (
{(stream.stream_stats.audio_codec ||
stream.stream_stats.audio_channels) && (
<>
<Text size="xs" c="dimmed" fw={500}>Audio:</Text>
<Text size="xs" c="dimmed" fw={500}>
Audio:
</Text>
{stream.stream_stats.audio_channels && (
<Badge size="xs" variant="light" color="pink">
{stream.stream_stats.audio_channels}
@ -409,11 +465,18 @@ const ChannelStreams = ({ channel, isExpanded }) => {
)}
{/* Output Bitrate */}
{(stream.stream_stats.ffmpeg_output_bitrate) && (
{stream.stream_stats.ffmpeg_output_bitrate && (
<>
<Text size="xs" c="dimmed" fw={500}>Output Bitrate:</Text>
<Text size="xs" c="dimmed" fw={500}>
Output Bitrate:
</Text>
{stream.stream_stats.ffmpeg_output_bitrate && (
<Badge size="xs" variant="light" color="orange" style={{ textTransform: 'none' }}>
<Badge
size="xs"
variant="light"
color="orange"
style={{ textTransform: 'none' }}
>
{stream.stream_stats.ffmpeg_output_bitrate} kbps
</Badge>
)}
@ -428,27 +491,47 @@ const ChannelStreams = ({ channel, isExpanded }) => {
<Button
variant="subtle"
size="xs"
leftSection={expandedAdvancedStats.has(stream.id) ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
leftSection={
expandedAdvancedStats.has(stream.id) ? (
<ChevronDown size={12} />
) : (
<ChevronRight size={12} />
)
}
onClick={() => toggleAdvancedStats(stream.id)}
c="dimmed"
>
{expandedAdvancedStats.has(stream.id) ? 'Hide' : 'Show'} Advanced Stats
{expandedAdvancedStats.has(stream.id) ? 'Hide' : 'Show'}{' '}
Advanced Stats
</Button>
</Group>
)}
{/* Advanced Stats (expandable) */}
<Collapse in={expandedAdvancedStats.has(stream.id)}>
<Box mt="sm" p="xs" style={{ backgroundColor: 'rgba(0,0,0,0.1)', borderRadius: '4px' }}>
<Box
mt="sm"
p="xs"
style={{
backgroundColor: 'rgba(0,0,0,0.1)',
borderRadius: '4px',
}}
>
{renderStatsCategory('Video', categorizedStats.video)}
{renderStatsCategory('Audio', categorizedStats.audio)}
{renderStatsCategory('Technical', categorizedStats.technical)}
{renderStatsCategory(
'Technical',
categorizedStats.technical
)}
{renderStatsCategory('Other', categorizedStats.other)}
{/* Show when stats were last updated */}
{stream.stream_stats_updated_at && (
<Text size="xs" c="dimmed" mt="xs">
Last updated: {new Date(stream.stream_stats_updated_at).toLocaleString()}
Last updated:{' '}
{new Date(
stream.stream_stats_updated_at
).toLocaleString()}
</Text>
)}
</Box>

View file

@ -216,11 +216,13 @@ const ChannelRowActions = React.memo(
}
);
const ChannelsTable = ({ }) => {
const ChannelsTable = ({}) => {
const theme = useMantineTheme();
const channelGroups = useChannelsStore((s) => s.channelGroups);
const canEditChannelGroup = useChannelsStore((s) => s.canEditChannelGroup);
const canDeleteChannelGroup = useChannelsStore((s) => s.canDeleteChannelGroup);
const canDeleteChannelGroup = useChannelsStore(
(s) => s.canDeleteChannelGroup
);
/**
* STORES
@ -302,12 +304,12 @@ const ChannelsTable = ({ }) => {
const [m3uParams, setM3uParams] = useState({
cachedlogos: true,
direct: false,
tvg_id_source: 'channel_number'
tvg_id_source: 'channel_number',
});
const [epgParams, setEpgParams] = useState({
cachedlogos: true,
tvg_id_source: 'channel_number',
days: 0
days: 0,
});
/**
@ -382,7 +384,11 @@ const ChannelsTable = ({ }) => {
const editChannel = async (ch = null) => {
// Use table's selected state instead of store state to avoid stale selections
const currentSelection = table ? table.selectedTableIds : [];
console.log('editChannel called with:', { ch, currentSelection, tableExists: !!table });
console.log('editChannel called with:', {
ch,
currentSelection,
tableExists: !!table,
});
if (currentSelection.length > 1) {
setChannelBatchModalOpen(true);
@ -393,7 +399,7 @@ const ChannelsTable = ({ }) => {
const selectedId = currentSelection[0];
// Use table data since that's what's currently displayed
channelToEdit = data.find(d => d.id === selectedId);
channelToEdit = data.find((d) => d.id === selectedId);
}
setChannel(channelToEdit);
setChannelModalOpen(true);
@ -538,7 +544,8 @@ const ChannelsTable = ({ }) => {
const params = new URLSearchParams();
if (!m3uParams.cachedlogos) params.append('cachedlogos', 'false');
if (m3uParams.direct) params.append('direct', 'true');
if (m3uParams.tvg_id_source !== 'channel_number') params.append('tvg_id_source', m3uParams.tvg_id_source);
if (m3uParams.tvg_id_source !== 'channel_number')
params.append('tvg_id_source', m3uParams.tvg_id_source);
const baseUrl = m3uUrl;
return params.toString() ? `${baseUrl}?${params.toString()}` : baseUrl;
@ -547,7 +554,8 @@ const ChannelsTable = ({ }) => {
const buildEPGUrl = () => {
const params = new URLSearchParams();
if (!epgParams.cachedlogos) params.append('cachedlogos', 'false');
if (epgParams.tvg_id_source !== 'channel_number') params.append('tvg_id_source', epgParams.tvg_id_source);
if (epgParams.tvg_id_source !== 'channel_number')
params.append('tvg_id_source', epgParams.tvg_id_source);
if (epgParams.days > 0) params.append('days', epgParams.days.toString());
const baseUrl = epgUrl;
@ -558,7 +566,9 @@ const ChannelsTable = ({ }) => {
const success = await copyToClipboard(buildM3UUrl());
notifications.show({
title: success ? 'M3U URL Copied!' : 'Copy Failed',
message: success ? 'The M3U URL has been copied to your clipboard.' : 'Failed to copy M3U URL to clipboard',
message: success
? 'The M3U URL has been copied to your clipboard.'
: 'Failed to copy M3U URL to clipboard',
color: success ? 'green' : 'red',
});
};
@ -567,7 +577,9 @@ const ChannelsTable = ({ }) => {
const success = await copyToClipboard(buildEPGUrl());
notifications.show({
title: success ? 'EPG URL Copied!' : 'Copy Failed',
message: success ? 'The EPG URL has been copied to your clipboard.' : 'Failed to copy EPG URL to clipboard',
message: success
? 'The EPG URL has been copied to your clipboard.'
: 'Failed to copy EPG URL to clipboard',
color: success ? 'green' : 'red',
});
};
@ -576,7 +588,9 @@ const ChannelsTable = ({ }) => {
const success = await copyToClipboard(hdhrUrl);
notifications.show({
title: success ? 'HDHR URL Copied!' : 'Copy Failed',
message: success ? 'The HDHR URL has been copied to your clipboard.' : 'Failed to copy HDHR URL to clipboard',
message: success
? 'The HDHR URL has been copied to your clipboard.'
: 'Failed to copy HDHR URL to clipboard',
color: success ? 'green' : 'red',
});
};
@ -860,8 +874,8 @@ const ChannelsTable = ({ }) => {
return hasStreams
? {} // Default style for channels with streams
: {
className: 'no-streams-row', // Add a class instead of background color
};
className: 'no-streams-row', // Add a class instead of background color
};
},
});
@ -909,7 +923,13 @@ const ChannelsTable = ({ }) => {
Links:
</Text>
<Group gap={5} style={{ paddingLeft: 10 }}>
<Popover withArrow shadow="md" zIndex={1000} position="bottom-start" withinPortal>
<Popover
withArrow
shadow="md"
zIndex={1000}
position="bottom-start"
withinPortal
>
<Popover.Target>
<Button
leftSection={<Tv2 size={18} />}
@ -931,7 +951,7 @@ const ChannelsTable = ({ }) => {
style={{
minWidth: 250,
maxWidth: 'min(400px, 80vw)',
width: 'max-content'
width: 'max-content',
}}
>
<TextInput value={hdhrUrl} size="small" readOnly />
@ -946,7 +966,13 @@ const ChannelsTable = ({ }) => {
</Group>
</Popover.Dropdown>
</Popover>
<Popover withArrow shadow="md" zIndex={1000} position="bottom-start" withinPortal>
<Popover
withArrow
shadow="md"
zIndex={1000}
position="bottom-start"
withinPortal
>
<Popover.Target>
<Button
leftSection={<ScreenShare size={18} />}
@ -967,7 +993,7 @@ const ChannelsTable = ({ }) => {
style={{
minWidth: 300,
maxWidth: 'min(500px, 90vw)',
width: 'max-content'
width: 'max-content',
}}
onClick={stopPropagation}
onMouseDown={stopPropagation}
@ -987,47 +1013,60 @@ const ChannelsTable = ({ }) => {
<Copy size="16" />
</ActionIcon>
}
/><Group justify="space-between">
/>
<Group justify="space-between">
<Text size="sm">Use cached logos</Text>
<Switch
size="sm"
checked={m3uParams.cachedlogos}
onChange={(event) => setM3uParams(prev => ({
...prev,
cachedlogos: event.target.checked
}))}
onChange={(event) =>
setM3uParams((prev) => ({
...prev,
cachedlogos: event.target.checked,
}))
}
/>
</Group>
<Group justify="space-between">
<Text size="sm">Direct stream URLs</Text>
<Switch
size="sm"
checked={m3uParams.direct}
onChange={(event) => setM3uParams(prev => ({
...prev,
direct: event.target.checked
}))}
onChange={(event) =>
setM3uParams((prev) => ({
...prev,
direct: event.target.checked,
}))
}
/>
</Group> <Select
</Group>{' '}
<Select
label="TVG-ID Source"
size="xs"
value={m3uParams.tvg_id_source}
onChange={(value) => setM3uParams(prev => ({
...prev,
tvg_id_source: value
}))}
onChange={(value) =>
setM3uParams((prev) => ({
...prev,
tvg_id_source: value,
}))
}
comboboxProps={{ withinPortal: false }}
data={[
{ value: 'channel_number', label: 'Channel Number' },
{ value: 'tvg_id', label: 'TVG-ID' },
{ value: 'gracenote', label: 'Gracenote Station ID' }
{ value: 'gracenote', label: 'Gracenote Station ID' },
]}
/>
</Stack>
</Popover.Dropdown>
</Popover>
<Popover withArrow shadow="md" zIndex={1000} position="bottom-start" withinPortal>
<Popover
withArrow
shadow="md"
zIndex={1000}
position="bottom-start"
withinPortal
>
<Popover.Target>
<Button
leftSection={<Scroll size={18} />}
@ -1049,7 +1088,7 @@ const ChannelsTable = ({ }) => {
style={{
minWidth: 300,
maxWidth: 'min(450px, 85vw)',
width: 'max-content'
width: 'max-content',
}}
onClick={stopPropagation}
onMouseDown={stopPropagation}
@ -1075,25 +1114,29 @@ const ChannelsTable = ({ }) => {
<Switch
size="sm"
checked={epgParams.cachedlogos}
onChange={(event) => setEpgParams(prev => ({
...prev,
cachedlogos: event.target.checked
}))}
onChange={(event) =>
setEpgParams((prev) => ({
...prev,
cachedlogos: event.target.checked,
}))
}
/>
</Group>
<Select
label="TVG-ID Source"
size="xs"
value={epgParams.tvg_id_source}
onChange={(value) => setEpgParams(prev => ({
...prev,
tvg_id_source: value
}))}
onChange={(value) =>
setEpgParams((prev) => ({
...prev,
tvg_id_source: value,
}))
}
comboboxProps={{ withinPortal: false }}
data={[
{ value: 'channel_number', label: 'Channel Number' },
{ value: 'tvg_id', label: 'TVG-ID' },
{ value: 'gracenote', label: 'Gracenote Station ID' }
{ value: 'gracenote', label: 'Gracenote Station ID' },
]}
/>
<NumberInput
@ -1102,10 +1145,12 @@ const ChannelsTable = ({ }) => {
min={0}
max={365}
value={epgParams.days}
onChange={(value) => setEpgParams(prev => ({
...prev,
days: value || 0
}))}
onChange={(value) =>
setEpgParams((prev) => ({
...prev,
days: value || 0,
}))
}
/>
</Stack>
</Popover.Dropdown>

View file

@ -108,7 +108,8 @@ const ChannelTableHeader = ({
const [channelNumAssignmentStart, setChannelNumAssignmentStart] = useState(1);
const [assignNumbersModalOpen, setAssignNumbersModalOpen] = useState(false);
const [groupManagerOpen, setGroupManagerOpen] = useState(false);
const [confirmDeleteProfileOpen, setConfirmDeleteProfileOpen] = useState(false);
const [confirmDeleteProfileOpen, setConfirmDeleteProfileOpen] =
useState(false);
const [profileToDelete, setProfileToDelete] = useState(null);
const profiles = useChannelsStore((s) => s.profiles);

View file

@ -552,8 +552,7 @@ const EPGsTable = () => {
padding: 0,
// gap: 1,
}}
>
</Box>
></Box>
</Paper>
<Box
@ -590,14 +589,15 @@ const EPGsTable = () => {
Name: ${epgToDelete.name}
Source Type: ${epgToDelete.source_type}
${epgToDelete.url
? `URL: ${epgToDelete.url}`
: epgToDelete.api_key
? `API Key: ${epgToDelete.api_key}`
: epgToDelete.file_path
? `File Path: ${epgToDelete.file_path}`
: ''
}
${
epgToDelete.url
? `URL: ${epgToDelete.url}`
: epgToDelete.api_key
? `API Key: ${epgToDelete.api_key}`
: epgToDelete.file_path
? `File Path: ${epgToDelete.file_path}`
: ''
}
This will remove all related program information and channel associations.
This action cannot be undone.`}

View file

@ -63,14 +63,17 @@ const StreamRowActions = ({
const fetchLogos = useLogosStore((s) => s.fetchLogos);
const createChannelFromStream = async () => {
const selectedChannelProfileId = useChannelsStore.getState().selectedProfileId;
const selectedChannelProfileId =
useChannelsStore.getState().selectedProfileId;
await API.createChannelFromStream({
name: row.original.name,
channel_number: null,
stream_id: row.original.id,
// Only pass channel_profile_ids if a specific profile is selected (not "All")
...(selectedChannelProfileId !== '0' && { channel_profile_ids: selectedChannelProfileId }),
...(selectedChannelProfileId !== '0' && {
channel_profile_ids: selectedChannelProfileId,
}),
});
await API.requeryChannels();
fetchLogos();
@ -103,7 +106,7 @@ const StreamRowActions = ({
'ID:',
row.original.id,
'Hash:',
row.original.stream_hash,
row.original.stream_hash
);
handleWatchStream(row.original.stream_hash);
}, [row.original, handleWatchStream]); // Add proper dependencies to ensure correct stream
@ -175,7 +178,7 @@ const StreamRowActions = ({
);
};
const StreamsTable = ({ }) => {
const StreamsTable = ({}) => {
const theme = useMantineTheme();
/**
@ -196,7 +199,10 @@ const StreamsTable = ({ }) => {
// const [allRowsSelected, setAllRowsSelected] = useState(false);
// Add local storage for page size
const [storedPageSize, setStoredPageSize] = useLocalStorage('streams-page-size', 50);
const [storedPageSize, setStoredPageSize] = useLocalStorage(
'streams-page-size',
50
);
const [pagination, setPagination] = useState({
pageIndex: 0,
pageSize: storedPageSize,
@ -409,7 +415,8 @@ const StreamsTable = ({ }) => {
const createChannelsFromStreams = async () => {
setIsLoading(true);
try {
const selectedChannelProfileId = useChannelsStore.getState().selectedProfileId;
const selectedChannelProfileId =
useChannelsStore.getState().selectedProfileId;
// Try to fetch the actual stream data for selected streams
let streamsData = [];
@ -419,12 +426,14 @@ const StreamsTable = ({ }) => {
console.warn('Could not fetch stream details, using IDs only:', error);
}
const streamData = selectedStreamIds.map(streamId => {
const stream = streamsData.find(s => s.id === streamId);
const streamData = selectedStreamIds.map((streamId) => {
const stream = streamsData.find((s) => s.id === streamId);
return {
stream_id: streamId,
name: stream?.name || `Stream ${streamId}`,
...(selectedChannelProfileId !== '0' && { channel_profile_ids: selectedChannelProfileId }),
...(selectedChannelProfileId !== '0' && {
channel_profile_ids: selectedChannelProfileId,
}),
};
});
@ -716,10 +725,10 @@ const StreamsTable = ({ }) => {
style={
selectedStreamIds.length > 0 && selectedChannelIds.length === 1
? {
borderWidth: '1px',
borderColor: theme.tailwind.green[5],
color: 'white',
}
borderWidth: '1px',
borderColor: theme.tailwind.green[5],
color: 'white',
}
: undefined
}
disabled={

View file

@ -1,3 +1,3 @@
import table from "./table";
import table from './table';
export const TableHelper = table;

View file

@ -21,7 +21,14 @@ const ChannelsPage = () => {
}
return (
<div style={{ height: '100vh', width: '100%', display: 'flex', overflowX: 'auto' }}>
<div
style={{
height: '100vh',
width: '100%',
display: 'flex',
overflowX: 'auto',
}}
>
<Allotment
defaultSizes={[50, 50]}
style={{ height: '100%', width: '100%', minWidth: '600px' }}

View file

@ -8,7 +8,8 @@ const M3UPage = () => {
const isLoading = useUserAgentsStore((state) => state.isLoading);
const error = useUserAgentsStore((state) => state.error);
if (isLoading) return <div>Loading...</div>;
if (error) return <div>Error: {error}</div>; return (
if (error) return <div>Error: {error}</div>;
return (
<Stack
style={{
padding: 10,

View file

@ -1,8 +1,8 @@
// src/components/Dashboard.js
import React, { useState } from "react";
import React, { useState } from 'react';
const Dashboard = () => {
const [newStream, setNewStream] = useState("");
const [newStream, setNewStream] = useState('');
return (
<div>

View file

@ -81,8 +81,10 @@ export default function TVChannelGuide({ startDate, endDate }) {
console.log(`Received ${fetched.length} programs`);
// Include ALL channels, sorted by channel number - don't filter by EPG data
const sortedChannels = Object.values(channels)
.sort((a, b) => (a.channel_number || Infinity) - (b.channel_number || Infinity));
const sortedChannels = Object.values(channels).sort(
(a, b) =>
(a.channel_number || Infinity) - (b.channel_number || Infinity)
);
console.log(`Using all ${sortedChannels.length} available channels`);
@ -523,7 +525,7 @@ export default function TVChannelGuide({ startDate, endDate }) {
const isLive = now.isAfter(programStart) && now.isBefore(programEnd);
// Determine if the program has ended
const isPast = now.isAfter(programEnd); // Check if this program is expanded
const isPast = now.isAfter(programEnd); // Check if this program is expanded
const isExpanded = expandedProgramId === program.id;
// Set the height based on expanded state
@ -638,9 +640,11 @@ export default function TVChannelGuide({ startDate, endDate }) {
overflow: 'hidden',
}}
>
{programStart.format(timeFormat)} - {programEnd.format(timeFormat)}
{programStart.format(timeFormat)} -{' '}
{programEnd.format(timeFormat)}
</Text>
</Box> {/* Description is always shown but expands when row is expanded */}
</Box>{' '}
{/* Description is always shown but expands when row is expanded */}
{program.description && (
<Box
style={{
@ -663,7 +667,6 @@ export default function TVChannelGuide({ startDate, endDate }) {
</Text>
</Box>
)}
{/* Expanded content */}
{isExpanded && (
<Box style={{ marginTop: 'auto' }}>
@ -771,8 +774,8 @@ export default function TVChannelGuide({ startDate, endDate }) {
// Handle date-time formats
const [timeFormatSetting] = useLocalStorage('time-format', '12h');
const [dateFormatSetting] = useLocalStorage('date-format', 'mdy');
const timeFormat = timeFormatSetting === '12h' ? "h:mm A" : "HH:mm";
const dateFormat = dateFormatSetting === 'mdy' ? "MMMM D" : "D MMMM";
const timeFormat = timeFormatSetting === '12h' ? 'h:mm A' : 'HH:mm';
const dateFormat = dateFormatSetting === 'mdy' ? 'MMMM D' : 'D MMMM';
return (
<Box
@ -805,7 +808,9 @@ export default function TVChannelGuide({ startDate, endDate }) {
TV Guide
</Title>
<Flex align="center" gap="md">
<Text>{now.format(`dddd, ${dateFormat}, YYYY • ${timeFormat}`)}</Text>
<Text>
{now.format(`dddd, ${dateFormat}, YYYY • ${timeFormat}`)}
</Text>
<Tooltip label="Jump to current time">
<ActionIcon
onClick={scrollToNow}
@ -863,10 +868,10 @@ export default function TVChannelGuide({ startDate, endDate }) {
{(searchQuery !== '' ||
selectedGroupId !== 'all' ||
selectedProfileId !== 'all') && (
<Button variant="subtle" onClick={clearFilters} size="sm">
Clear Filters
</Button>
)}
<Button variant="subtle" onClick={clearFilters} size="sm">
Clear Filters
</Button>
)}
<Text size="sm" color="dimmed">
{filteredChannels.length}{' '}
@ -933,100 +938,102 @@ export default function TVChannelGuide({ startDate, endDate }) {
borderBottom: '1px solid #27272A',
width: hourTimeline.length * HOUR_WIDTH,
}}
> {hourTimeline.map((hourData) => {
const { time, isNewDay } = hourData;
>
{' '}
{hourTimeline.map((hourData) => {
const { time, isNewDay } = hourData;
return (
<Box
key={time.format()}
style={{
width: HOUR_WIDTH,
height: '40px',
position: 'relative',
color: '#a0aec0',
borderRight: '1px solid #8DAFAA',
cursor: 'pointer',
borderLeft: isNewDay ? '2px solid #3BA882' : 'none', // Highlight day boundaries
backgroundColor: isNewDay ? '#1E2A27' : '#1B2421', // Subtle background for new days
}}
onClick={(e) => handleTimeClick(time, e)}
>
{/* Remove the special day label for new days since we'll show day for all hours */}
{/* Position time label at the left border of each hour block */}
<Text
size="sm"
return (
<Box
key={time.format()}
style={{
position: 'absolute',
top: '8px', // Consistent positioning for all hours
left: '4px',
transform: 'none',
borderRadius: '2px',
lineHeight: 1.2,
textAlign: 'left',
width: HOUR_WIDTH,
height: '40px',
position: 'relative',
color: '#a0aec0',
borderRight: '1px solid #8DAFAA',
cursor: 'pointer',
borderLeft: isNewDay ? '2px solid #3BA882' : 'none', // Highlight day boundaries
backgroundColor: isNewDay ? '#1E2A27' : '#1B2421', // Subtle background for new days
}}
onClick={(e) => handleTimeClick(time, e)}
>
{/* Show day above time for every hour using the same format */}
{/* Remove the special day label for new days since we'll show day for all hours */}
{/* Position time label at the left border of each hour block */}
<Text
span
size="xs"
size="sm"
style={{
display: 'block',
opacity: 0.7,
fontWeight: isNewDay ? 600 : 400, // Still emphasize day transitions
color: isNewDay ? '#3BA882' : undefined,
position: 'absolute',
top: '8px', // Consistent positioning for all hours
left: '4px',
transform: 'none',
borderRadius: '2px',
lineHeight: 1.2,
textAlign: 'left',
}}
>
{formatDayLabel(time)}{' '}
{/* Use same formatDayLabel function for all hours */}
</Text>
{time.format(timeFormat)}
<Text span size="xs" ml={1} opacity={0.7}>
{/*time.format('A')*/}
</Text>
</Text>
{/* Hour boundary marker - more visible */}
<Box
style={{
position: 'absolute',
left: 0,
top: 0,
bottom: 0,
width: '1px',
backgroundColor: '#27272A',
zIndex: 10,
}}
/>
{/* Quarter hour tick marks */}
<Box
style={{
position: 'absolute',
bottom: 0,
width: '100%',
display: 'flex',
justifyContent: 'space-between',
padding: '0 1px',
}}
>
{[15, 30, 45].map((minute) => (
<Box
key={minute}
{/* Show day above time for every hour using the same format */}
<Text
span
size="xs"
style={{
width: '1px',
height: '8px',
backgroundColor: '#718096',
position: 'absolute',
bottom: 0,
left: `${(minute / 60) * 100}%`,
display: 'block',
opacity: 0.7,
fontWeight: isNewDay ? 600 : 400, // Still emphasize day transitions
color: isNewDay ? '#3BA882' : undefined,
}}
/>
))}
>
{formatDayLabel(time)}{' '}
{/* Use same formatDayLabel function for all hours */}
</Text>
{time.format(timeFormat)}
<Text span size="xs" ml={1} opacity={0.7}>
{/*time.format('A')*/}
</Text>
</Text>
{/* Hour boundary marker - more visible */}
<Box
style={{
position: 'absolute',
left: 0,
top: 0,
bottom: 0,
width: '1px',
backgroundColor: '#27272A',
zIndex: 10,
}}
/>
{/* Quarter hour tick marks */}
<Box
style={{
position: 'absolute',
bottom: 0,
width: '100%',
display: 'flex',
justifyContent: 'space-between',
padding: '0 1px',
}}
>
{[15, 30, 45].map((minute) => (
<Box
key={minute}
style={{
width: '1px',
height: '8px',
backgroundColor: '#718096',
position: 'absolute',
bottom: 0,
left: `${(minute / 60) * 100}%`,
}}
/>
))}
</Box>
</Box>
</Box>
);
})}
);
})}
</Box>
</Box>
</Box>
@ -1222,7 +1229,9 @@ export default function TVChannelGuide({ startDate, endDate }) {
>
{channelPrograms.length > 0 ? (
channelPrograms.map((program) => (
<div key={`${channel.id}-${program.id}-${program.start_time}`}>
<div
key={`${channel.id}-${program.id}-${program.start_time}`}
>
{renderProgram(program, start)}
</div>
))
@ -1230,7 +1239,9 @@ export default function TVChannelGuide({ startDate, endDate }) {
// Simple placeholder for channels with no program data - 2 hour blocks
<>
{/* Generate repeating placeholder blocks every 2 hours across the timeline */}
{Array.from({ length: Math.ceil(hourTimeline.length / 2) }).map((_, index) => (
{Array.from({
length: Math.ceil(hourTimeline.length / 2),
}).map((_, index) => (
<Box
key={`placeholder-${channel.id}-${index}`}
style={{

View file

@ -1,8 +1,8 @@
// src/components/Home.js
import React, { useState } from "react";
import React, { useState } from 'react';
const Home = () => {
const [newChannel, setNewChannel] = useState("");
const [newChannel, setNewChannel] = useState('');
return (
<div>

View file

@ -454,16 +454,13 @@ const SettingsPage = () => {
{...form.getInputProps('preferred-region')}
key={form.key('preferred-region')}
id={
settings['preferred-region']?.id ||
'preferred-region'
settings['preferred-region']?.id || 'preferred-region'
}
name={
settings['preferred-region']?.key ||
'preferred-region'
settings['preferred-region']?.key || 'preferred-region'
}
label={
settings['preferred-region']?.name ||
'Preferred Region'
settings['preferred-region']?.name || 'Preferred Region'
}
data={regionChoices.map((r) => ({
label: r.label,
@ -471,10 +468,7 @@ const SettingsPage = () => {
}))}
/>
<Group
justify="space-between"
style={{ paddingTop: 5 }}
>
<Group justify="space-between" style={{ paddingTop: 5 }}>
<Text size="sm" fw={500}>
Auto-Import Mapped Files
</Text>
@ -571,9 +565,7 @@ const SettingsPage = () => {
</Accordion.Control>
<Accordion.Panel>
<form
onSubmit={networkAccessForm.onSubmit(
onNetworkAccessSubmit
)}
onSubmit={networkAccessForm.onSubmit(onNetworkAccessSubmit)}
>
<Stack gap="sm">
{networkAccessSaved && (
@ -628,9 +620,7 @@ const SettingsPage = () => {
</Accordion.Control>
<Accordion.Panel>
<form
onSubmit={proxySettingsForm.onSubmit(
onProxySettingsSubmit
)}
onSubmit={proxySettingsForm.onSubmit(onProxySettingsSubmit)}
>
<Stack gap="sm">
{proxySettingsSaved && (
@ -647,7 +637,7 @@ const SettingsPage = () => {
'buffering_timeout',
'redis_chunk_ttl',
'channel_shutdown_delay',
'channel_init_grace_period'
'channel_init_grace_period',
].includes(key);
const isFloatField = key === 'buffering_speed';
@ -660,9 +650,15 @@ const SettingsPage = () => {
{...proxySettingsForm.getInputProps(key)}
description={config.description || null}
min={0}
max={key === 'buffering_timeout' ? 300 :
key === 'redis_chunk_ttl' ? 3600 :
key === 'channel_shutdown_delay' ? 300 : 60}
max={
key === 'buffering_timeout'
? 300
: key === 'redis_chunk_ttl'
? 3600
: key === 'channel_shutdown_delay'
? 300
: 60
}
/>
);
} else if (isFloatField) {
@ -728,7 +724,11 @@ const SettingsPage = () => {
setRehashDialogType(null);
}}
onConfirm={handleRehashConfirm}
title={rehashDialogType === 'save' ? 'Save Settings and Rehash Streams' : 'Confirm Stream Rehash'}
title={
rehashDialogType === 'save'
? 'Save Settings and Rehash Streams'
: 'Confirm Stream Rehash'
}
message={
<div style={{ whiteSpace: 'pre-line' }}>
{`Are you sure you want to rehash all streams?
@ -740,7 +740,9 @@ M3U refreshes will be blocked until this process finishes.
Please ensure you have time to let this complete before proceeding.`}
</div>
}
confirmLabel={rehashDialogType === 'save' ? 'Save and Rehash' : 'Start Rehash'}
confirmLabel={
rehashDialogType === 'save' ? 'Save and Rehash' : 'Start Rehash'
}
cancelLabel="Cancel"
actionKey="rehash-streams"
onSuppressChange={suppressWarning}

View file

@ -54,7 +54,7 @@ const useChannelsStore = create((set, get) => ({
hasChannels: group.channel_count > 0,
hasM3UAccounts: group.m3u_account_count > 0,
canEdit: group.m3u_account_count === 0,
canDelete: group.channel_count === 0 && group.m3u_account_count === 0
canDelete: group.channel_count === 0 && group.m3u_account_count === 0,
};
return acc;
}, {});
@ -152,9 +152,14 @@ const useChannelsStore = create((set, get) => ({
updateChannels: (channels) => {
// Ensure channels is an array
if (!Array.isArray(channels)) {
console.error('updateChannels expects an array, received:', typeof channels, channels);
console.error(
'updateChannels expects an array, received:',
typeof channels,
channels
);
return;
} const channelsByUUID = {};
}
const channelsByUUID = {};
const updatedChannels = channels.reduce((acc, chan) => {
channelsByUUID[chan.uuid] = chan.id;
acc[chan.id] = chan;
@ -405,12 +410,14 @@ const useChannelsStore = create((set, get) => ({
// Add helper methods for validation
canEditChannelGroup: (groupIdOrGroup) => {
const groupId = typeof groupIdOrGroup === 'object' ? groupIdOrGroup.id : groupIdOrGroup;
const groupId =
typeof groupIdOrGroup === 'object' ? groupIdOrGroup.id : groupIdOrGroup;
return get().channelGroups[groupId]?.canEdit ?? true;
},
canDeleteChannelGroup: (groupIdOrGroup) => {
const groupId = typeof groupIdOrGroup === 'object' ? groupIdOrGroup.id : groupIdOrGroup;
const groupId =
typeof groupIdOrGroup === 'object' ? groupIdOrGroup.id : groupIdOrGroup;
return get().channelGroups[groupId]?.canDelete ?? true;
},
}));

View file

@ -10,7 +10,8 @@ const useChannelsTableStore = create((set, get) => ({
sorting: [{ id: 'channel_number', desc: false }],
pagination: {
pageIndex: 0,
pageSize: JSON.parse(localStorage.getItem('channel-table-prefs'))?.pageSize || 50,
pageSize:
JSON.parse(localStorage.getItem('channel-table-prefs'))?.pageSize || 50,
},
selectedChannelIds: [],
allQueryIds: [],

View file

@ -80,16 +80,21 @@ const useEPGsStore = create((set) => ({
speed: data.speed,
elapsed_time: data.elapsed_time,
time_remaining: data.time_remaining,
status: data.status || 'in_progress'
}
status: data.status || 'in_progress',
},
};
// Set the EPG source status based on the update
// First prioritize explicit status values from the backend
const sourceStatus = data.status ? data.status // Use explicit status if provided
: data.action === "downloading" ? "fetching"
: data.action === "parsing_channels" || data.action === "parsing_programs" ? "parsing"
: data.progress === 100 ? "success" // Mark as success when progress is 100%
const sourceStatus = data.status
? data.status // Use explicit status if provided
: data.action === 'downloading'
? 'fetching'
: data.action === 'parsing_channels' ||
data.action === 'parsing_programs'
? 'parsing'
: data.progress === 100
? 'success' // Mark as success when progress is 100%
: state.epgs[data.source]?.status || 'idle';
// Create a new epgs object with the updated source status
@ -98,13 +103,16 @@ const useEPGsStore = create((set) => ({
[data.source]: {
...state.epgs[data.source],
status: sourceStatus,
last_message: data.status === 'error' ? (data.error || 'Unknown error') : state.epgs[data.source]?.last_message
}
last_message:
data.status === 'error'
? data.error || 'Unknown error'
: state.epgs[data.source]?.last_message,
},
};
return {
refreshProgress: newRefreshProgress,
epgs: newEpgs
epgs: newEpgs,
};
}),
}));

View file

@ -2,380 +2,385 @@ import { create } from 'zustand';
import api from '../api';
const useVODStore = create((set, get) => ({
movies: {},
series: {},
episodes: {},
categories: {},
loading: false,
error: null,
filters: {
type: 'all', // 'all', 'movies', 'series'
search: '',
category: '',
},
currentPage: 1,
totalCount: 0,
pageSize: 20,
movies: {},
series: {},
episodes: {},
categories: {},
loading: false,
error: null,
filters: {
type: 'all', // 'all', 'movies', 'series'
search: '',
category: '',
},
currentPage: 1,
totalCount: 0,
pageSize: 20,
setFilters: (newFilters) =>
set((state) => ({
filters: { ...state.filters, ...newFilters },
currentPage: 1, // Reset to first page when filters change
})),
setFilters: (newFilters) =>
set((state) => ({
filters: { ...state.filters, ...newFilters },
currentPage: 1, // Reset to first page when filters change
})),
setPage: (page) =>
set(() => ({
currentPage: page,
})),
setPage: (page) =>
set(() => ({
currentPage: page,
})),
fetchMovies: async () => {
try {
set({ loading: true, error: null });
const state = get();
const params = new URLSearchParams();
fetchMovies: async () => {
try {
set({ loading: true, error: null });
const state = get();
const params = new URLSearchParams();
params.append('page', state.currentPage);
params.append('page_size', state.pageSize);
params.append('page', state.currentPage);
params.append('page_size', state.pageSize);
if (state.filters.search) {
params.append('search', state.filters.search);
}
if (state.filters.search) {
params.append('search', state.filters.search);
}
if (state.filters.category) {
params.append('category', state.filters.category);
}
if (state.filters.category) {
params.append('category', state.filters.category);
}
const response = await api.getMovies(params);
const response = await api.getMovies(params);
// Handle both paginated and non-paginated responses
const results = response.results || response;
const count = response.count || results.length;
// Handle both paginated and non-paginated responses
const results = response.results || response;
const count = response.count || results.length;
set({
movies: results.reduce((acc, movie) => {
acc[movie.id] = movie;
return acc;
}, {}),
totalCount: count,
loading: false,
});
} catch (error) {
console.error('Failed to fetch movies:', error);
set({ error: 'Failed to load movies.', loading: false });
}
},
set({
movies: results.reduce((acc, movie) => {
acc[movie.id] = movie;
return acc;
}, {}),
totalCount: count,
loading: false,
});
} catch (error) {
console.error('Failed to fetch movies:', error);
set({ error: 'Failed to load movies.', loading: false });
}
},
fetchSeries: async () => {
set({ loading: true, error: null });
try {
const state = get();
const params = new URLSearchParams();
fetchSeries: async () => {
set({ loading: true, error: null });
try {
const state = get();
const params = new URLSearchParams();
params.append('page', state.currentPage);
params.append('page_size', state.pageSize);
params.append('page', state.currentPage);
params.append('page_size', state.pageSize);
if (state.filters.search) {
params.append('search', state.filters.search);
}
if (state.filters.search) {
params.append('search', state.filters.search);
}
if (state.filters.category) {
params.append('category', state.filters.category);
}
if (state.filters.category) {
params.append('category', state.filters.category);
}
const response = await api.getSeries(params);
const response = await api.getSeries(params);
// Handle both paginated and non-paginated responses
const results = response.results || response;
const count = response.count || results.length;
// Handle both paginated and non-paginated responses
const results = response.results || response;
const count = response.count || results.length;
set({
series: results.reduce((acc, series) => {
acc[series.id] = series;
return acc;
}, {}),
totalCount: count,
loading: false,
});
} catch (error) {
console.error('Failed to fetch series:', error);
set({ error: 'Failed to load series.', loading: false });
}
},
set({
series: results.reduce((acc, series) => {
acc[series.id] = series;
return acc;
}, {}),
totalCount: count,
loading: false,
});
} catch (error) {
console.error('Failed to fetch series:', error);
set({ error: 'Failed to load series.', loading: false });
}
},
fetchSeriesEpisodes: async (seriesId) => {
set({ loading: true, error: null });
try {
const response = await api.getSeriesEpisodes(seriesId);
fetchSeriesEpisodes: async (seriesId) => {
set({ loading: true, error: null });
try {
const response = await api.getSeriesEpisodes(seriesId);
set((state) => ({
episodes: {
...state.episodes,
...response.reduce((acc, episode) => {
acc[episode.id] = episode;
return acc;
}, {}),
},
loading: false,
}));
set((state) => ({
episodes: {
...state.episodes,
...response.reduce((acc, episode) => {
acc[episode.id] = episode;
return acc;
}, {}),
},
loading: false,
}));
return response;
} catch (error) {
console.error('Failed to fetch series episodes:', error);
set({ error: 'Failed to load episodes.', loading: false });
throw error; // Re-throw to allow calling component to handle
}
},
return response;
} catch (error) {
console.error('Failed to fetch series episodes:', error);
set({ error: 'Failed to load episodes.', loading: false });
throw error; // Re-throw to allow calling component to handle
}
},
fetchMovieDetails: async (movieId) => {
set({ loading: true, error: null });
try {
const response = await api.getMovieDetails(movieId);
fetchMovieDetails: async (movieId) => {
set({ loading: true, error: null });
try {
const response = await api.getMovieDetails(movieId);
// Transform the response data to match our expected format
const movieDetails = {
id: response.id || movieId,
name: response.name || '',
description: response.description || '',
year: response.year || null,
genre: response.genre || '',
rating: response.rating || '',
duration_secs: response.duration_secs || null,
stream_url: response.url || '',
logo: response.logo_url || null,
type: 'movie',
director: response.director || '',
actors: response.actors || '',
country: response.country || '',
tmdb_id: response.tmdb_id || '',
imdb_id: response.imdb_id || '',
m3u_account: response.m3u_account || '',
};
console.log('Fetched Movie Details:', movieDetails);
set((state) => ({
movies: {
...state.movies,
[movieDetails.id]: movieDetails,
},
loading: false,
}));
// Transform the response data to match our expected format
const movieDetails = {
id: response.id || movieId,
name: response.name || '',
description: response.description || '',
year: response.year || null,
genre: response.genre || '',
rating: response.rating || '',
duration_secs: response.duration_secs || null,
stream_url: response.url || '',
logo: response.logo_url || null,
type: 'movie',
director: response.director || '',
actors: response.actors || '',
country: response.country || '',
tmdb_id: response.tmdb_id || '',
imdb_id: response.imdb_id || '',
m3u_account: response.m3u_account || '',
};
console.log('Fetched Movie Details:', movieDetails);
set((state) => ({
movies: {
...state.movies,
[movieDetails.id]: movieDetails,
},
loading: false,
}));
return movieDetails;
} catch (error) {
console.error('Failed to fetch movie details:', error);
set({ error: 'Failed to load movie details.', loading: false });
throw error;
}
},
return movieDetails;
} catch (error) {
console.error('Failed to fetch movie details:', error);
set({ error: 'Failed to load movie details.', loading: false });
throw error;
}
},
fetchMovieDetailsFromProvider: async (movieId) => {
set({ loading: true, error: null });
try {
const response = await api.getMovieProviderInfo(movieId);
fetchMovieDetailsFromProvider: async (movieId) => {
set({ loading: true, error: null });
try {
const response = await api.getMovieProviderInfo(movieId);
// Transform the response data to match our expected format
const movieDetails = {
id: response.id || movieId,
name: response.name || '',
description: response.description || response.plot || '',
year: response.year || null,
genre: response.genre || '',
rating: response.rating || '',
duration_secs: response.duration_secs || null,
stream_url: response.stream_url || '',
logo: response.logo || response.cover || null,
type: 'movie',
director: response.director || '',
actors: response.actors || response.cast || '',
country: response.country || '',
tmdb_id: response.tmdb_id || '',
youtube_trailer: response.youtube_trailer || '',
// Additional provider fields
backdrop_path: response.backdrop_path || [],
release_date: response.release_date || response.releasedate || '',
movie_image: response.movie_image || null,
o_name: response.o_name || '',
age: response.age || '',
episode_run_time: response.episode_run_time || null,
bitrate: response.bitrate || 0,
video: response.video || {},
audio: response.audio || {},
};
// Transform the response data to match our expected format
const movieDetails = {
id: response.id || movieId,
name: response.name || '',
description: response.description || response.plot || '',
year: response.year || null,
genre: response.genre || '',
rating: response.rating || '',
duration_secs: response.duration_secs || null,
stream_url: response.stream_url || '',
logo: response.logo || response.cover || null,
type: 'movie',
director: response.director || '',
actors: response.actors || response.cast || '',
country: response.country || '',
tmdb_id: response.tmdb_id || '',
youtube_trailer: response.youtube_trailer || '',
// Additional provider fields
backdrop_path: response.backdrop_path || [],
release_date: response.release_date || response.releasedate || '',
movie_image: response.movie_image || null,
o_name: response.o_name || '',
age: response.age || '',
episode_run_time: response.episode_run_time || null,
bitrate: response.bitrate || 0,
video: response.video || {},
audio: response.audio || {},
};
set({ loading: false }); // Only update loading state
set({ loading: false }); // Only update loading state
// Do NOT merge or overwrite the store entry
return movieDetails;
} catch (error) {
console.error('Failed to fetch movie details from provider:', error);
set({ error: 'Failed to load movie details from provider.', loading: false });
throw error;
}
},
// Do NOT merge or overwrite the store entry
return movieDetails;
} catch (error) {
console.error('Failed to fetch movie details from provider:', error);
set({
error: 'Failed to load movie details from provider.',
loading: false,
});
throw error;
}
},
fetchMovieProviders: async (movieId) => {
try {
const response = await api.getMovieProviders(movieId);
return response || [];
} catch (error) {
console.error('Failed to fetch movie providers:', error);
throw error;
}
},
fetchMovieProviders: async (movieId) => {
try {
const response = await api.getMovieProviders(movieId);
return response || [];
} catch (error) {
console.error('Failed to fetch movie providers:', error);
throw error;
}
},
fetchSeriesProviders: async (seriesId) => {
try {
const response = await api.getSeriesProviders(seriesId);
return response || [];
} catch (error) {
console.error('Failed to fetch series providers:', error);
throw error;
}
},
fetchSeriesProviders: async (seriesId) => {
try {
const response = await api.getSeriesProviders(seriesId);
return response || [];
} catch (error) {
console.error('Failed to fetch series providers:', error);
throw error;
}
},
fetchCategories: async () => {
try {
const response = await api.getVODCategories();
// Handle both array and paginated responses
const results = response.results || response;
fetchCategories: async () => {
try {
const response = await api.getVODCategories();
// Handle both array and paginated responses
const results = response.results || response;
set({
categories: results.reduce((acc, category) => {
acc[category.id] = category;
return acc;
}, {}),
});
} catch (error) {
console.error('Failed to fetch VOD categories:', error);
set({ error: 'Failed to load categories.' });
}
},
set({
categories: results.reduce((acc, category) => {
acc[category.id] = category;
return acc;
}, {}),
});
} catch (error) {
console.error('Failed to fetch VOD categories:', error);
set({ error: 'Failed to load categories.' });
}
},
addMovie: (movie) =>
set((state) => ({
movies: { ...state.movies, [movie.id]: movie },
})),
addMovie: (movie) =>
set((state) => ({
movies: { ...state.movies, [movie.id]: movie },
})),
updateMovie: (movie) =>
set((state) => ({
movies: { ...state.movies, [movie.id]: movie },
})),
updateMovie: (movie) =>
set((state) => ({
movies: { ...state.movies, [movie.id]: movie },
})),
removeMovie: (movieId) =>
set((state) => {
const updatedMovies = { ...state.movies };
delete updatedMovies[movieId];
return { movies: updatedMovies };
}),
removeMovie: (movieId) =>
set((state) => {
const updatedMovies = { ...state.movies };
delete updatedMovies[movieId];
return { movies: updatedMovies };
}),
addSeries: (series) =>
set((state) => ({
series: { ...state.series, [series.id]: series },
})),
addSeries: (series) =>
set((state) => ({
series: { ...state.series, [series.id]: series },
})),
updateSeries: (series) =>
set((state) => ({
series: { ...state.series, [series.id]: series },
})),
updateSeries: (series) =>
set((state) => ({
series: { ...state.series, [series.id]: series },
})),
removeSeries: (seriesId) =>
set((state) => {
const updatedSeries = { ...state.series };
delete updatedSeries[seriesId];
return { series: updatedSeries };
}),
removeSeries: (seriesId) =>
set((state) => {
const updatedSeries = { ...state.series };
delete updatedSeries[seriesId];
return { series: updatedSeries };
}),
fetchSeriesInfo: async (seriesId) => {
set({ loading: true, error: null });
try {
const response = await api.getSeriesInfo(seriesId);
fetchSeriesInfo: async (seriesId) => {
set({ loading: true, error: null });
try {
const response = await api.getSeriesInfo(seriesId);
// Transform the response data to match our expected format
const seriesInfo = {
id: response.id || seriesId,
name: response.name || '',
description: response.description || response.custom_properties?.plot || '',
year: response.year || null,
genre: response.genre || '',
rating: response.rating || '',
logo: response.cover || null,
type: 'series',
director: response.custom_properties?.director || '',
cast: response.custom_properties?.cast || '',
country: response.country || '',
tmdb_id: response.tmdb_id || '',
imdb_id: response.imdb_id || '',
episode_count: response.episode_count || 0,
// Additional provider fields
backdrop_path: response.custom_properties?.backdrop_path || [],
release_date: response.release_date || '',
series_image: response.series_image || null,
o_name: response.o_name || '',
age: response.age || '',
m3u_account: response.m3u_account || '',
youtube_trailer: response.custom_properties?.youtube_trailer || '',
};
// Transform the response data to match our expected format
const seriesInfo = {
id: response.id || seriesId,
name: response.name || '',
description:
response.description || response.custom_properties?.plot || '',
year: response.year || null,
genre: response.genre || '',
rating: response.rating || '',
logo: response.cover || null,
type: 'series',
director: response.custom_properties?.director || '',
cast: response.custom_properties?.cast || '',
country: response.country || '',
tmdb_id: response.tmdb_id || '',
imdb_id: response.imdb_id || '',
episode_count: response.episode_count || 0,
// Additional provider fields
backdrop_path: response.custom_properties?.backdrop_path || [],
release_date: response.release_date || '',
series_image: response.series_image || null,
o_name: response.o_name || '',
age: response.age || '',
m3u_account: response.m3u_account || '',
youtube_trailer: response.custom_properties?.youtube_trailer || '',
};
let episodesData = {};
let episodesData = {};
// Handle episodes - check if they're in the response
if (response.episodes) {
Object.entries(response.episodes).forEach(([seasonNumber, seasonEpisodes]) => {
seasonEpisodes.forEach((episode) => {
const episodeData = {
id: episode.id,
stream_id: episode.id,
name: episode.title || '',
description: episode.plot || '',
season_number: parseInt(seasonNumber) || 0,
episode_number: episode.episode_number || 0,
duration_secs: episode.duration_secs || null,
rating: episode.rating || '',
container_extension: episode.container_extension || '',
series: {
id: seriesInfo.id,
name: seriesInfo.name
},
type: 'episode',
uuid: episode.id, // Use the stream ID as UUID for playback
logo: episode.movie_image ? { url: episode.movie_image } : null,
air_date: episode.air_date || null,
movie_image: episode.movie_image || null,
tmdb_id: episode.tmdb_id || '',
imdb_id: episode.imdb_id || '',
};
episodesData[episode.id] = episodeData;
});
});
// Update episodes in the store
set((state) => ({
episodes: {
...state.episodes,
...episodesData,
},
}));
}
set((state) => ({
// Handle episodes - check if they're in the response
if (response.episodes) {
Object.entries(response.episodes).forEach(
([seasonNumber, seasonEpisodes]) => {
seasonEpisodes.forEach((episode) => {
const episodeData = {
id: episode.id,
stream_id: episode.id,
name: episode.title || '',
description: episode.plot || '',
season_number: parseInt(seasonNumber) || 0,
episode_number: episode.episode_number || 0,
duration_secs: episode.duration_secs || null,
rating: episode.rating || '',
container_extension: episode.container_extension || '',
series: {
...state.series,
[seriesInfo.id]: seriesInfo,
id: seriesInfo.id,
name: seriesInfo.name,
},
loading: false,
}));
type: 'episode',
uuid: episode.id, // Use the stream ID as UUID for playback
logo: episode.movie_image ? { url: episode.movie_image } : null,
air_date: episode.air_date || null,
movie_image: episode.movie_image || null,
tmdb_id: episode.tmdb_id || '',
imdb_id: episode.imdb_id || '',
};
episodesData[episode.id] = episodeData;
});
}
);
// Return series info with episodes array for easy access
return {
...seriesInfo,
episodesList: Object.values(episodesData)
};
} catch (error) {
console.error('Failed to fetch series info:', error);
set({ error: 'Failed to load series details.', loading: false });
throw error;
}
},
// Update episodes in the store
set((state) => ({
episodes: {
...state.episodes,
...episodesData,
},
}));
}
set((state) => ({
series: {
...state.series,
[seriesInfo.id]: seriesInfo,
},
loading: false,
}));
// Return series info with episodes array for easy access
return {
...seriesInfo,
episodesList: Object.values(episodesData),
};
} catch (error) {
console.error('Failed to fetch series info:', error);
set({ error: 'Failed to load series details.', loading: false });
throw error;
}
},
}));
export default useVODStore;

View file

@ -1,5 +1,5 @@
import { create } from "zustand";
import api from "../api";
import { create } from 'zustand';
import api from '../api';
const useUserAgentsStore = create((set) => ({
userAgents: [],
@ -12,8 +12,8 @@ const useUserAgentsStore = create((set) => ({
const userAgents = await api.getUserAgents();
set({ userAgents: userAgents, isLoading: false });
} catch (error) {
console.error("Failed to fetch userAgents:", error);
set({ error: "Failed to load userAgents.", isLoading: false });
console.error('Failed to fetch userAgents:', error);
set({ error: 'Failed to load userAgents.', isLoading: false });
}
},
@ -25,14 +25,14 @@ const useUserAgentsStore = create((set) => ({
updateUserAgent: (userAgent) =>
set((state) => ({
userAgents: state.userAgents.map((ua) =>
ua.id === userAgent.id ? userAgent : ua,
ua.id === userAgent.id ? userAgent : ua
),
})),
removeUserAgents: (userAgentIds) =>
set((state) => ({
userAgents: state.userAgents.filter(
(userAgent) => !userAgentIds.includes(userAgent.id),
(userAgent) => !userAgentIds.includes(userAgent.id)
),
})),
}));

View file

@ -1,29 +1,29 @@
import { create } from 'zustand';
const useWarningsStore = create((set) => ({
// Map of action keys to whether they're suppressed
suppressedWarnings: {},
// Map of action keys to whether they're suppressed
suppressedWarnings: {},
// Function to check if a warning is suppressed
isWarningSuppressed: (actionKey) => {
const state = useWarningsStore.getState();
return state.suppressedWarnings[actionKey] === true;
},
// Function to check if a warning is suppressed
isWarningSuppressed: (actionKey) => {
const state = useWarningsStore.getState();
return state.suppressedWarnings[actionKey] === true;
},
// Function to suppress a warning
suppressWarning: (actionKey, suppressed = true) => {
set((state) => ({
suppressedWarnings: {
...state.suppressedWarnings,
[actionKey]: suppressed
}
}));
},
// Function to suppress a warning
suppressWarning: (actionKey, suppressed = true) => {
set((state) => ({
suppressedWarnings: {
...state.suppressedWarnings,
[actionKey]: suppressed,
},
}));
},
// Function to reset all suppressions
resetSuppressions: () => {
set({ suppressedWarnings: {} });
}
// Function to reset all suppressions
resetSuppressions: () => {
set({ suppressedWarnings: {} });
},
}));
export default useWarningsStore;