mirror of
https://github.com/Dispatcharr/Dispatcharr.git
synced 2026-01-23 02:35:14 +00:00
Applied our Prettier formatting to all frontend code.
This commit is contained in:
parent
59c6b0565e
commit
648e2bb2dd
25 changed files with 1492 additions and 1244 deletions
|
|
@ -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(() => {
|
||||
|
|
|
|||
|
|
@ -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}`,
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
|
|
|
|||
|
|
@ -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 || '';
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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.`}
|
||||
|
|
|
|||
|
|
@ -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={
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
import table from "./table";
|
||||
import table from './table';
|
||||
|
||||
export const TableHelper = table;
|
||||
|
|
|
|||
|
|
@ -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' }}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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={{
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
},
|
||||
}));
|
||||
|
|
|
|||
|
|
@ -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: [],
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}),
|
||||
}));
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
),
|
||||
})),
|
||||
}));
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue