Dispatcharr/frontend/src/components/tables/M3UsTable.jsx
2025-05-24 07:19:43 -04:00

922 lines
25 KiB
JavaScript

import React, {
useEffect,
useMemo,
useRef,
useState,
useCallback,
} from 'react';
import API from '../../api';
import usePlaylistsStore from '../../store/playlists';
import M3UForm from '../forms/M3U';
import { TableHelper } from '../../helpers';
import {
useMantineTheme,
Paper,
Button,
Flex,
Text,
Box,
ActionIcon,
Tooltip,
Switch,
Progress,
Stack,
Badge,
Group,
Center,
} from '@mantine/core';
import {
SquareMinus,
SquarePen,
RefreshCcw,
Check,
X,
ArrowUpDown,
ArrowUpNarrowWide,
ArrowDownWideNarrow,
} from 'lucide-react';
import { IconSquarePlus } from '@tabler/icons-react'; // Import custom icons
import dayjs from 'dayjs';
import useSettingsStore from '../../store/settings';
import useLocalStorage from '../../hooks/useLocalStorage';
import ConfirmationDialog from '../../components/ConfirmationDialog';
import useWarningsStore from '../../store/warnings';
import { CustomTable, useTable } from './CustomTable';
// Helper function to format status text
const formatStatusText = (status) => {
switch (status) {
case 'idle':
return 'Idle';
case 'fetching':
return 'Fetching';
case 'parsing':
return 'Parsing';
case 'error':
return 'Error';
case 'success':
return 'Success';
case 'pending_setup':
return 'Pending Setup';
default:
return status
? status.charAt(0).toUpperCase() + status.slice(1)
: 'Unknown';
}
};
// Helper function to get status text color
const getStatusColor = (status) => {
switch (status) {
case 'idle':
return 'gray.5';
case 'fetching':
return 'blue.5';
case 'parsing':
return 'indigo.5';
case 'error':
return 'red.5';
case 'success':
return 'green.5';
case 'pending_setup':
return 'orange.5'; // Orange to indicate action needed
default:
return 'gray.5';
}
};
const RowActions = ({
tableSize,
editPlaylist,
deletePlaylist,
row,
refreshPlaylist,
}) => {
const iconSize =
tableSize == 'default' ? 'sm' : tableSize == 'compact' ? 'xs' : 'md';
return (
<>
<ActionIcon
variant="transparent"
size={iconSize}
color="yellow.5"
onClick={() => {
editPlaylist(row.original);
}}
>
<SquarePen size={tableSize === 'compact' ? 16 : 18} />
</ActionIcon>
<ActionIcon
variant="transparent"
size={iconSize}
color="red.9"
onClick={() => deletePlaylist(row.original.id)}
>
<SquareMinus size={tableSize === 'compact' ? 16 : 18} />
</ActionIcon>
<ActionIcon
variant="transparent"
size={iconSize}
color="blue.5"
onClick={() => refreshPlaylist(row.original.id)}
disabled={!row.original.is_active}
>
<RefreshCcw size={tableSize === 'compact' ? 16 : 18} />
</ActionIcon>
</>
);
};
const M3UTable = () => {
const [playlist, setPlaylist] = useState(null);
const [playlistModalOpen, setPlaylistModalOpen] = useState(false);
const [groupFilterModalOpen, setGroupFilterModalOpen] = useState(false);
const [rowSelection, setRowSelection] = useState([]);
const [activeFilterValue, setActiveFilterValue] = useState('all');
const [playlistCreated, setPlaylistCreated] = useState(false);
const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false);
const [deleteTarget, setDeleteTarget] = useState(null);
const [playlistToDelete, setPlaylistToDelete] = useState(null);
const [data, setData] = useState([]);
const [sorting, setSorting] = useState([{ id: 'name', desc: '' }]);
const playlists = usePlaylistsStore((s) => s.playlists);
const refreshProgress = usePlaylistsStore((s) => s.refreshProgress);
const setRefreshProgress = usePlaylistsStore((s) => s.setRefreshProgress);
const editPlaylistId = usePlaylistsStore((s) => s.editPlaylistId);
const setEditPlaylistId = usePlaylistsStore((s) => s.setEditPlaylistId);
const isWarningSuppressed = useWarningsStore((s) => s.isWarningSuppressed);
const suppressWarning = useWarningsStore((s) => s.suppressWarning);
const theme = useMantineTheme();
const [tableSize] = useLocalStorage('table-size', 'default');
const generateStatusString = (data) => {
if (data.progress == 100) {
return 'Idle';
}
switch (data.action) {
case 'initializing':
return buildInitializingStats();
case 'downloading':
return buildDownloadingStats(data);
case 'processing_groups':
return buildGroupProcessingStats(data);
case 'parsing':
return buildParsingStats(data);
default:
return data.status === 'error'
? buildErrorStats(data)
: `${data.action || 'Processing'}...`;
}
};
const buildDownloadingStats = (data) => {
if (data.progress == 100) {
return 'Download complete!';
}
if (data.progress == 0) {
return 'Downloading...';
}
// Format time remaining in minutes:seconds
const timeRemaining = data.time_remaining
? `${Math.floor(data.time_remaining / 60)}:${String(Math.floor(data.time_remaining % 60)).padStart(2, '0')}`
: 'calculating...';
// Format speed with appropriate unit (KB/s or MB/s)
const speed =
data.speed >= 1024
? `${(data.speed / 1024).toFixed(2)} MB/s`
: `${Math.round(data.speed)} KB/s`;
return (
<Box>
<Flex direction="column" gap={2}>
<Flex justify="space-between" align="center">
<Text size="xs" fw={500}>
Downloading:
</Text>
<Text size="xs">{parseInt(data.progress)}%</Text>
</Flex>
<Flex justify="space-between" align="center">
<Text size="xs" fw={500}>
Speed:
</Text>
<Text size="xs">{speed}</Text>
</Flex>
<Flex justify="space-between" align="center">
<Text size="xs" fw={500}>
Time left:
</Text>
<Text size="xs">{timeRemaining}</Text>
</Flex>
</Flex>
</Box>
);
};
const buildGroupProcessingStats = (data) => {
if (data.progress == 100) {
return 'Groups processed!';
}
if (data.progress == 0) {
return 'Processing groups...';
}
// Format time displays if available
const elapsedTime = data.elapsed_time
? `${Math.floor(data.elapsed_time / 60)}:${String(Math.floor(data.elapsed_time % 60)).padStart(2, '0')}`
: null;
return (
<Box>
<Flex direction="column" gap={2}>
<Flex justify="space-between" align="center">
<Text size="xs" fw={500}>
Processing groups:
</Text>
<Text size="xs">{parseInt(data.progress)}%</Text>
</Flex>
{elapsedTime && (
<Flex justify="space-between" align="center">
<Text size="xs" fw={500}>
Elapsed:
</Text>
<Text size="xs">{elapsedTime}</Text>
</Flex>
)}
{data.groups_processed && (
<Flex justify="space-between" align="center">
<Text size="xs" fw={500}>
Groups:
</Text>
<Text size="xs">{data.groups_processed}</Text>
</Flex>
)}
</Flex>
</Box>
);
};
const buildErrorStats = (data) => {
return (
<Box>
<Flex direction="column" gap={2}>
<Flex align="center">
<Text size="xs" fw={500} color="red">
Error:
</Text>
</Flex>
<Text size="xs" color="red" style={{ lineHeight: 1.3 }}>
{data.error || 'Unknown error occurred'}
</Text>
</Flex>
</Box>
);
};
const buildParsingStats = (data) => {
if (data.progress == 100) {
return 'Parsing complete!';
}
if (data.progress == 0) {
return 'Parsing...';
}
// Format time displays
const timeRemaining = data.time_remaining
? `${Math.floor(data.time_remaining / 60)}:${String(Math.floor(data.time_remaining % 60)).padStart(2, '0')}`
: 'calculating...';
const elapsedTime = data.elapsed_time
? `${Math.floor(data.elapsed_time / 60)}:${String(Math.floor(data.elapsed_time % 60)).padStart(2, '0')}`
: '0:00';
return (
<Box>
<Flex direction="column" gap={2}>
<Flex justify="space-between" align="center">
<Text size="xs" fw={500} style={{ width: '80px' }}>
Parsing:
</Text>
<Text size="xs">{parseInt(data.progress)}%</Text>
</Flex>
{data.elapsed_time && (
<Flex justify="space-between" align="center">
<Text size="xs" fw={500} style={{ width: '80px' }}>
Elapsed:
</Text>
<Text size="xs">{elapsedTime}</Text>
</Flex>
)}
{data.time_remaining && (
<Flex justify="space-between" align="center">
<Text size="xs" fw={500} style={{ width: '60px' }}>
Remaining:
</Text>
<Text size="xs">{timeRemaining}</Text>
</Flex>
)}
{data.streams_processed && (
<Flex justify="space-between" align="center">
<Text size="xs" fw={500} style={{ width: '80px' }}>
Streams:
</Text>
<Text size="xs">{data.streams_processed}</Text>
</Flex>
)}
</Flex>
</Box>
);
};
const buildInitializingStats = () => {
return (
<Box>
<Flex direction="column" gap={2}>
<Flex align="center">
<Text size="xs" fw={500}>
Initializing refresh...
</Text>
</Flex>
</Flex>
</Box>
);
};
const editPlaylist = async (playlist = null) => {
if (playlist) {
setPlaylist(playlist);
}
setPlaylistModalOpen(true);
};
const refreshPlaylist = async (id) => {
// Provide immediate visual feedback before the API call
setRefreshProgress(id, {
action: 'initializing',
progress: 0,
account: id,
type: 'm3u_refresh',
});
try {
await API.refreshPlaylist(id);
// No need to set again since WebSocket will update us once the task starts
} catch (error) {
// If the API call fails, show an error state
setRefreshProgress(id, {
action: 'error',
progress: 0,
account: id,
type: 'm3u_refresh',
error: 'Failed to start refresh task',
status: 'error',
});
}
};
const deletePlaylist = async (id) => {
// Get playlist details for the confirmation dialog
const playlist = playlists.find((p) => p.id === id);
setPlaylistToDelete(playlist);
setDeleteTarget(id);
// Skip warning if it's been suppressed
if (isWarningSuppressed('delete-m3u')) {
return executeDeletePlaylist(id);
}
setConfirmDeleteOpen(true);
};
const executeDeletePlaylist = async (id) => {
setIsLoading(true);
await API.deletePlaylist(id);
setIsLoading(false);
setConfirmDeleteOpen(false);
};
const toggleActive = async (playlist) => {
try {
// Send only the is_active field to trigger our special handling
await API.updatePlaylist(
{
id: playlist.id,
is_active: !playlist.is_active,
},
true
); // Add a new parameter to indicate this is just a toggle
} catch (error) {
console.error('Error toggling active state:', error);
}
};
const columns = useMemo(
() => [
{
header: 'Name',
accessorKey: 'name',
size: 200,
sortable: true,
},
{
header: 'Account Type',
accessorKey: 'account_type',
sortable: true,
size: 150,
cell: ({ cell }) => {
const value = cell.getValue();
return value === 'XC' ? 'XC' : 'M3U';
},
},
{
header: 'URL / File',
accessorKey: 'server_url',
cell: ({ cell, row }) => {
const value = cell.getValue() || row.original.file_path || '';
return (
<Tooltip label={value} disabled={!value}>
<div
style={{
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
maxWidth: '100%',
}}
>
{value}
</div>
</Tooltip>
);
},
},
{
header: 'Max Streams',
accessorKey: 'max_streams',
sortable: true,
size: 150,
},
{
header: 'Status',
accessorKey: 'status',
size: 150,
cell: ({ cell }) => {
const value = cell.getValue();
if (!value) return null;
// Match EPG table styling with Text component - always use xs size
return (
<Text size="xs" c={getStatusColor(value)}>
{formatStatusText(value)}
</Text>
);
},
},
{
header: 'Status Message',
accessorKey: 'last_message',
cell: ({ cell, row }) => {
const value = cell.getValue();
const data = row.original;
// Get account id to check for refresh progress
const accountId = data.id;
const progressData = refreshProgress[accountId];
// If we have active progress data for this account, show that instead
if (progressData && progressData.progress < 100) {
return (
<Box
style={{
// Use full height of the cell with proper spacing
height: '100%',
width: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'flex-start',
// Add some padding to give content room to breathe
padding: '4px 0',
}}
>
{generateStatusString(progressData)}
</Box>
);
}
// No progress data, display normal status message
if (!value) return null;
// Show error message with red styling for errors
if (data.status === 'error') {
return (
<Tooltip label={value} multiline width={300}>
<Text
c="dimmed"
size="xs"
lineClamp={2}
style={{ color: theme.colors.red[6], lineHeight: 1.3 }}
>
{value}
</Text>
</Tooltip>
);
}
// Show success message with green styling for success
if (data.status === 'success') {
return (
<Tooltip label={value} multiline width={300}>
<Text
c="dimmed"
size="xs"
style={{
color: theme.colors.green[6],
lineHeight: 1.3,
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
}}
>
{value}
</Text>
</Tooltip>
);
}
// For all other status values, just use dimmed text
return (
<Tooltip label={value} multiline width={300}>
<Text
c="dimmed"
size="xs"
lineClamp={2}
style={{ lineHeight: 1.1 }}
>
{value}
</Text>
</Tooltip>
);
},
},
{
header: 'Updated',
accessorKey: 'updated_at',
size: 150,
cell: ({ cell }) => {
const value = cell.getValue();
return value ? (
<Text size="xs">{new Date(value).toLocaleString()}</Text>
) : (
<Text size="xs">Never</Text>
);
},
},
{
header: 'Active',
accessorKey: 'is_active',
size: 50,
cell: ({ cell, row }) => {
return (
<Box sx={{ display: 'flex', justifyContent: 'center' }}>
<Switch
size="xs"
checked={cell.getValue()}
onChange={() => toggleActive(row.original)}
/>
</Box>
);
},
},
{
id: 'actions',
header: 'Actions',
size: tableSize == 'compact' ? 75 : 100,
},
],
[refreshPlaylist, editPlaylist, deletePlaylist, toggleActive]
);
//optionally access the underlying virtualizer instance
const rowVirtualizerInstanceRef = useRef(null);
const [isLoading, setIsLoading] = useState(true);
const closeModal = (newPlaylist = null) => {
if (newPlaylist) {
setPlaylistCreated(true);
setPlaylist(newPlaylist);
} else {
setPlaylistModalOpen(false);
setPlaylist(null);
setPlaylistCreated(false);
}
};
useEffect(() => {
//scroll to the top of the table when the sorting changes
try {
rowVirtualizerInstanceRef.current?.scrollToIndex?.(0);
} catch (error) {
console.error(error);
}
}, [sorting]);
// Listen for edit playlist requests from notifications
useEffect(() => {
setData(
playlists
.filter((playlist) => playlist.locked === false)
.sort((a, b) => {
// First sort by active status (active items first)
if (a.is_active !== b.is_active) {
return a.is_active ? -1 : 1;
}
// Then sort by name (case-insensitive)
return a.name.toLowerCase().localeCompare(b.name.toLowerCase());
})
);
if (editPlaylistId) {
const playlistToEdit = playlists.find((p) => p.id === editPlaylistId);
if (playlistToEdit) {
editPlaylist(playlistToEdit);
// Reset the ID after handling
setEditPlaylistId(null);
}
}
}, [editPlaylistId, playlists]);
const onSortingChange = (column) => {
console.log(column);
const sortField = sorting[0]?.id;
const sortDirection = sorting[0]?.desc;
const newSorting = [];
if (sortField == column) {
if (sortDirection == false) {
newSorting[0] = {
id: column,
desc: true,
};
}
} else {
newSorting[0] = {
id: column,
desc: false,
};
}
setSorting(newSorting);
if (newSorting.length > 0) {
const compareColumn = newSorting[0].id;
const compareDesc = newSorting[0].desc;
setData(
playlists
.filter((playlist) => playlist.locked === false)
.sort((a, b) => {
console.log(a);
console.log(newSorting[0].id);
if (a[compareColumn] !== b[compareColumn]) {
return compareDesc ? 1 : -1;
}
return 0;
})
);
}
};
const renderHeaderCell = (header) => {
let sortingIcon = ArrowUpDown;
if (sorting[0]?.id == header.id) {
if (sorting[0].desc === false) {
sortingIcon = ArrowUpNarrowWide;
} else {
sortingIcon = ArrowDownWideNarrow;
}
}
switch (header.id) {
default:
return (
<Group>
<Text size="sm" name={header.id}>
{header.column.columnDef.header}
</Text>
{header.column.columnDef.sortable && (
<Center>
{React.createElement(sortingIcon, {
onClick: () => onSortingChange(header.id),
size: 14,
})}
</Center>
)}
</Group>
);
}
};
const renderBodyCell = useCallback(({ cell, row }) => {
switch (cell.column.id) {
case 'actions':
return (
<RowActions
tableSize={tableSize}
editPlaylist={editPlaylist}
deletePlaylist={deletePlaylist}
row={row}
refreshPlaylist={refreshPlaylist}
/>
);
}
}, []);
const table = useTable({
columns,
// Sort data before passing to table: active first, then by name
data,
allRowIds: data.map((playlist) => playlist.id),
enablePagination: false,
enableRowVirtualization: true,
enableRowSelection: false,
onRowSelectionChange: setRowSelection,
renderTopToolbar: false,
sorting,
manualSorting: true,
rowVirtualizerInstanceRef, //optional
rowVirtualizerOptions: { overscan: 5 }, //optionally customize the row virtualizer
bodyCellRenderFns: {
actions: renderBodyCell,
},
headerCellRenderFns: {
name: renderHeaderCell,
account_type: renderHeaderCell,
server_url: renderHeaderCell,
max_streams: renderHeaderCell,
status: renderHeaderCell,
last_message: renderHeaderCell,
updated_at: renderHeaderCell,
is_active: renderHeaderCell,
actions: renderHeaderCell,
},
mantineTableContainerProps: {
style: {
height: 'calc(40vh - 10px)',
overflowX: 'auto', // Ensure horizontal scrolling works
},
},
mantineTableProps: {
...TableHelper.defaultProperties.mantineTableProps,
className: `table-size-${tableSize}`,
},
// Add custom cell styles to match CustomTable's sizing
tableCellProps: ({ cell }) => {
return {
fontSize:
tableSize === 'compact'
? 'var(--mantine-font-size-xs)'
: 'var(--mantine-font-size-sm)',
padding: tableSize === 'compact' ? '2px 8px' : '4px 10px',
};
},
// Additional text styling to match ChannelsTable
tableBodyProps: () => ({
fontSize:
tableSize === 'compact'
? 'var(--mantine-font-size-xs)'
: 'var(--mantine-font-size-sm)',
}),
});
return (
<Box>
<Flex
style={{ display: 'flex', alignItems: 'center', paddingBottom: 10 }}
gap={15}
>
<Text
h={24}
style={{
fontFamily: 'Inter, sans-serif',
fontWeight: 500,
fontSize: '20px',
lineHeight: 1,
letterSpacing: '-0.3px',
color: 'gray.6', // Adjust this to match MUI's theme.palette.text.secondary
marginBottom: 0,
}}
>
M3U Accounts
</Text>
</Flex>
<Paper
style={{
bgcolor: theme.palette.background.paper,
borderRadius: 2,
}}
>
{/* Top toolbar with Remove, Assign, Auto-match, and Add buttons */}
<Box
style={{
display: 'flex',
// alignItems: 'center',
// backgroundColor: theme.palette.background.paper,
justifyContent: 'flex-end',
padding: 10,
// gap: 1,
}}
>
<Flex gap={6}>
<Tooltip label="Assign">
<Button
leftSection={<IconSquarePlus size={14} />}
variant="light"
size="xs"
onClick={() => editPlaylist()}
p={5}
color="green"
style={{
borderWidth: '1px',
borderColor: 'green',
color: 'white',
}}
>
Add
</Button>
</Tooltip>
</Flex>
</Box>
</Paper>
<Box
style={{
display: 'flex',
flexDirection: 'column',
height: 'calc(40vh - 10px)',
}}
>
<Box
style={{
flex: 1,
overflowY: 'auto',
overflowX: 'hidden',
border: 'solid 1px rgb(68,68,68)',
borderRadius: 'var(--mantine-radius-default)',
}}
>
<CustomTable table={table} />
</Box>
</Box>
<M3UForm
m3uAccount={playlist}
isOpen={playlistModalOpen}
onClose={closeModal}
playlistCreated={playlistCreated}
/>
<ConfirmationDialog
opened={confirmDeleteOpen}
onClose={() => setConfirmDeleteOpen(false)}
onConfirm={() => executeDeletePlaylist(deleteTarget)}
title="Confirm M3U Account Deletion"
message={
playlistToDelete ? (
<div style={{ whiteSpace: 'pre-line' }}>
{`Are you sure you want to delete the following M3U account?
Name: ${playlistToDelete.name}
Type: ${playlistToDelete.account_type === 'XC' ? 'Xtream Codes' : 'Standard'}
Server: ${playlistToDelete.server_url || 'Local file'}
This will remove all related streams and may affect channels using these streams.
This action cannot be undone.`}
</div>
) : (
'Are you sure you want to delete this M3U account? This action cannot be undone.'
)
}
confirmLabel="Delete"
cancelLabel="Cancel"
actionKey="delete-m3u"
onSuppressChange={suppressWarning}
size="lg"
/>
</Box>
);
};
export default M3UTable;