From 30e82fb30202b0734c4ffa38b35b175b2d99f56f Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Sun, 4 May 2025 19:33:03 -0500 Subject: [PATCH] Lots of status message fixes. --- apps/epg/tasks.py | 30 +++- apps/m3u/tasks.py | 158 ++++++++++++++---- frontend/src/WebSocket.jsx | 70 +++++++- .../src/components/M3URefreshNotification.jsx | 17 ++ frontend/src/components/tables/M3UsTable.jsx | 98 ++++++++--- 5 files changed, 303 insertions(+), 70 deletions(-) diff --git a/apps/epg/tasks.py b/apps/epg/tasks.py index 56b7bda0..52f8f5a1 100644 --- a/apps/epg/tasks.py +++ b/apps/epg/tasks.py @@ -386,7 +386,10 @@ def parse_channels_only(source): # If the source has a URL, fetch the data before continuing if source.url: logger.info(f"Fetching new EPG data from URL: {source.url}") - if not fetch_xmltv(source): # Check if fetch was successful + fetch_success = fetch_xmltv(source) # Store the result + + # Only proceed if fetch was successful AND file exists + if not fetch_success: logger.error(f"Failed to fetch EPG data from URL: {source.url}") # Update status to error source.status = 'error' @@ -545,15 +548,36 @@ def parse_programs_for_tvg_id(epg_id): # Fetch new data before continuing if epg_source.url: logger.info(f"Fetching new EPG data from URL: {epg_source.url}") - fetch_xmltv(epg_source) + # Properly check the return value from fetch_xmltv + fetch_success = fetch_xmltv(epg_source) - # Check if fetch was successful + # If fetch was not successful or the file still doesn't exist, abort + if not fetch_success: + logger.error(f"Failed to fetch EPG data, cannot parse programs for tvg_id: {epg.tvg_id}") + # Update status to error if not already set + epg_source.status = 'error' + epg_source.last_message = f"Failed to download EPG data, cannot parse programs" + epg_source.save(update_fields=['status', 'last_message']) + send_epg_update(epg_source.id, "parsing_programs", 100, status="error", error="Failed to download EPG file") + release_task_lock('parse_epg_programs', epg_id) + return + + # Also check if the file exists after download if not os.path.exists(new_path): logger.error(f"Failed to fetch EPG data, file still missing at: {new_path}") + epg_source.status = 'error' + epg_source.last_message = f"Failed to download EPG data, file missing after download" + epg_source.save(update_fields=['status', 'last_message']) + send_epg_update(epg_source.id, "parsing_programs", 100, status="error", error="File not found after download") release_task_lock('parse_epg_programs', epg_id) return else: logger.error(f"No URL provided for EPG source {epg_source.name}, cannot fetch new data") + # Update status to error + epg_source.status = 'error' + epg_source.last_message = f"No URL provided, cannot fetch EPG data" + epg_source.save(update_fields=['status', 'last_message']) + send_epg_update(epg_source.id, "parsing_programs", 100, status="error", error="No URL provided") release_task_lock('parse_epg_programs', epg_id) return diff --git a/apps/m3u/tasks.py b/apps/m3u/tasks.py index 78d74cbb..5a6fbee3 100644 --- a/apps/m3u/tasks.py +++ b/apps/m3u/tasks.py @@ -82,36 +82,77 @@ def fetch_m3u_lines(account, use_cache=False): send_m3u_update(account.id, "downloading", 100) except Exception as e: logger.error(f"Error fetching M3U from URL {account.server_url}: {e}") - return [] + # Update account status and send error notification + account.status = M3UAccount.Status.ERROR + account.last_message = f"Error downloading M3U file: {str(e)}" + account.save(update_fields=['status', 'last_message']) + send_m3u_update(account.id, "downloading", 100, status="error", error=f"Error downloading M3U file: {str(e)}") + return [], False # Return empty list and False for success + + # Check if the file exists and is not empty + if not os.path.exists(file_path) or os.path.getsize(file_path) == 0: + error_msg = f"M3U file not found or empty: {file_path}" + logger.error(error_msg) + account.status = M3UAccount.Status.ERROR + account.last_message = error_msg + account.save(update_fields=['status', 'last_message']) + send_m3u_update(account.id, "downloading", 100, status="error", error=error_msg) + return [], False # Return empty list and False for success + + try: + with open(file_path, 'r', encoding='utf-8') as f: + return f.readlines(), True + except Exception as e: + error_msg = f"Error reading M3U file: {str(e)}" + logger.error(error_msg) + account.status = M3UAccount.Status.ERROR + account.last_message = error_msg + account.save(update_fields=['status', 'last_message']) + send_m3u_update(account.id, "downloading", 100, status="error", error=error_msg) + return [], False - with open(file_path, 'r', encoding='utf-8') as f: - return f.readlines() elif account.file_path: try: if account.file_path.endswith('.gz'): with gzip.open(account.file_path, 'rt', encoding='utf-8') as f: - return f.readlines() + return f.readlines(), True elif account.file_path.endswith('.zip'): with zipfile.ZipFile(account.file_path, 'r') as zip_file: for name in zip_file.namelist(): if name.endswith('.m3u'): with zip_file.open(name) as f: - return [line.decode('utf-8') for line in f.readlines()] - logger.warning(f"No .m3u file found in ZIP archive: {account.file_path}") - return [] + return [line.decode('utf-8') for line in f.readlines()], True + + error_msg = f"No .m3u file found in ZIP archive: {account.file_path}" + logger.warning(error_msg) + account.status = M3UAccount.Status.ERROR + account.last_message = error_msg + account.save(update_fields=['status', 'last_message']) + send_m3u_update(account.id, "downloading", 100, status="error", error=error_msg) + return [], False else: with open(account.file_path, 'r', encoding='utf-8') as f: - return f.readlines() + return f.readlines(), True except (IOError, OSError, zipfile.BadZipFile, gzip.BadGzipFile) as e: - logger.error(f"Error opening file {account.file_path}: {e}") - return [] + error_msg = f"Error opening file {account.file_path}: {e}" + logger.error(error_msg) + account.status = M3UAccount.Status.ERROR + account.last_message = error_msg + account.save(update_fields=['status', 'last_message']) + send_m3u_update(account.id, "downloading", 100, status="error", error=error_msg) + return [], False - - # Return an empty list if neither server_url nor uploaded_file is available - return [] + # Neither server_url nor uploaded_file is available + error_msg = "No M3U source available (missing URL and file)" + logger.error(error_msg) + account.status = M3UAccount.Status.ERROR + account.last_message = error_msg + account.save(update_fields=['status', 'last_message']) + send_m3u_update(account.id, "downloading", 100, status="error", error=error_msg) + return [], False def parse_extinf_line(line: str) -> dict: """ @@ -458,6 +499,12 @@ def refresh_m3u_groups(account_id, use_cache=False, full_refresh=False): try: xc_client.authenticate() except Exception as e: + error_msg = f"Failed to authenticate with XC server: {str(e)}" + logger.error(error_msg) + account.status = M3UAccount.Status.ERROR + account.last_message = error_msg + account.save(update_fields=['status', 'last_message']) + send_m3u_update(account_id, "processing_groups", 100, status="error", error=error_msg) release_task_lock('refresh_m3u_account_groups', account_id) return f"M3UAccount with ID={account_id} failed to authenticate with XC server.", None @@ -467,7 +514,14 @@ def refresh_m3u_groups(account_id, use_cache=False, full_refresh=False): "xc_id": category["category_id"], } else: - for line in fetch_m3u_lines(account, use_cache): + # Here's the key change - use the success flag from fetch_m3u_lines + lines, success = fetch_m3u_lines(account, use_cache) + if not success: + # If fetch failed, don't continue processing + release_task_lock('refresh_m3u_account_groups', account_id) + return f"Failed to fetch M3U data for account_id={account_id}.", None + + for line in lines: line = line.strip() if line.startswith("#EXTINF"): parsed = parse_extinf_line(line) @@ -523,6 +577,7 @@ def refresh_single_m3u_account(account_id): account = M3UAccount.objects.get(id=account_id, is_active=True) if not account.is_active: logger.info(f"Account {account_id} is not active, skipping.") + release_task_lock('refresh_single_m3u_account', account_id) return # Set status to fetching @@ -535,7 +590,6 @@ def refresh_single_m3u_account(account_id): return f"M3UAccount with ID={account_id} not found or inactive." # Fetch M3U lines and handle potential issues - # lines = fetch_m3u_lines(account) # Extracted fetch logic into separate function extinf_data = [] groups = None @@ -549,14 +603,45 @@ def refresh_single_m3u_account(account_id): if not extinf_data: try: - extinf_data, groups = refresh_m3u_groups(account_id, full_refresh=True) - if not groups: + result = refresh_m3u_groups(account_id, full_refresh=True) + + # Check if the result indicates an error (None tuple or tuple with empty values) + if not result or not result[0] or not result[1]: + logger.error(f"Failed to refresh M3U groups for account {account_id}") + # The error already has been recorded by refresh_m3u_groups +# Just release the lock and exit - no need to set parsing status at all release_task_lock('refresh_single_m3u_account', account_id) - return "Failed to update m3u account, task may already be running" - except: + return "Failed to update m3u account - download failed or other error" + + extinf_data, groups = result + if not groups: + logger.error(f"No groups found for account {account_id}") + account.status = M3UAccount.Status.ERROR + account.last_message = "No channel groups found in M3U source" + account.save(update_fields=['status', 'last_message']) + send_m3u_update(account_id, "parsing", 100, status="error", error="No channel groups found") + release_task_lock('refresh_single_m3u_account', account_id) + return "Failed to update m3u account, no groups found" + + except Exception as e: + logger.error(f"Exception in refresh_m3u_groups: {str(e)}") + account.status = M3UAccount.Status.ERROR + account.last_message = f"Error refreshing M3U groups: {str(e)}" + account.save(update_fields=['status', 'last_message']) + send_m3u_update(account_id, "parsing", 100, status="error", error=f"Error refreshing M3U groups: {str(e)}") release_task_lock('refresh_single_m3u_account', account_id) return "Failed to update m3u account" +# Only proceed with parsing if we actually have data and no errors were encountered + if not extinf_data or not groups: + logger.error(f"No data to process for account {account_id}") + account.status = M3UAccount.Status.ERROR + account.last_message = "No data available for processing" + account.save(update_fields=['status', 'last_message']) + send_m3u_update(account_id, "parsing", 100, status="error", error="No data available for processing") + release_task_lock('refresh_single_m3u_account', account_id) + return "Failed to update m3u account, no data available" + hash_keys = CoreSettings.get_m3u_hash_key().split(",") existing_groups = {group.name: group.id for group in ChannelGroup.objects.filter( @@ -652,27 +737,11 @@ def refresh_single_m3u_account(account_id): # Now run cleanup cleanup_streams(account_id) - # Send final update with complete metrics - elapsed_time = time.time() - start_time - send_m3u_update( - account_id, - "parsing", - 100, - elapsed_time=elapsed_time, - time_remaining=0, - streams_processed=streams_processed, - streams_created=streams_created, - streams_updated=streams_updated, - streams_deleted=streams_deleted, - message=account.last_message - ) - - end_time = time.time() # Calculate elapsed time - elapsed_time = end_time - start_time + elapsed_time = time.time() - start_time - # Set status to success and update timestamp + # Set status to success and update timestamp BEFORE sending the final update account.status = M3UAccount.Status.SUCCESS account.last_message = ( f"Processing completed in {elapsed_time:.1f} seconds. " @@ -682,6 +751,21 @@ def refresh_single_m3u_account(account_id): account.updated_at = timezone.now() account.save(update_fields=['status', 'last_message', 'updated_at']) + # Send final update with complete metrics and explicitly include success status + send_m3u_update( + account_id, + "parsing", + 100, + status="success", # Explicitly set status to success + elapsed_time=elapsed_time, + time_remaining=0, + streams_processed=streams_processed, + streams_created=streams_created, + streams_updated=streams_updated, + streams_deleted=streams_deleted, + message=account.last_message + ) + print(f"Function took {elapsed_time} seconds to execute.") except Exception as e: diff --git a/frontend/src/WebSocket.jsx b/frontend/src/WebSocket.jsx index ef753f2f..8dff3ae9 100644 --- a/frontend/src/WebSocket.jsx +++ b/frontend/src/WebSocket.jsx @@ -187,7 +187,22 @@ export const WebsocketProvider = ({ children }) => { break; case 'm3u_refresh': + // Update the store with progress information setRefreshProgress(parsedEvent.data); + + // If complete (progress is 100%), also update the playlist status + if (parsedEvent.data.progress === 100 && parsedEvent.data.account) { + const playlistsState = usePlaylistsStore.getState(); + const playlist = playlistsState.playlists.find(p => p.id === parsedEvent.data.account); + + if (playlist) { + playlistsState.updatePlaylist({ + ...playlist, + status: parsedEvent.data.status || 'success', + last_message: parsedEvent.data.message || playlist.last_message + }); + } + } break; case 'channel_stats': @@ -272,15 +287,54 @@ export const WebsocketProvider = ({ children }) => { const epgsState = useEPGsStore.getState(); epgsState.updateEPGProgress(parsedEvent.data); - // If progress is complete (100%), show a notification and refresh EPG data - if (parsedEvent.data.progress === 100 && parsedEvent.data.action === "parsing_programs") { - notifications.show({ - title: 'EPG Processing Complete', - message: 'EPG data has been updated successfully', - color: 'green.5', - }); + // If we have source_id/account info, update the EPG source status + if (parsedEvent.data.source_id || parsedEvent.data.account) { + const sourceId = parsedEvent.data.source_id || parsedEvent.data.account; + const epg = epgsState.epgs[sourceId]; - fetchEPGData(); + if (epg) { + // Check for any indication of an error (either via status or error field) + const hasError = parsedEvent.data.status === "error" || + !!parsedEvent.data.error || + (parsedEvent.data.message && parsedEvent.data.message.toLowerCase().includes("error")); + + if (hasError) { + // Handle error state + const errorMessage = parsedEvent.data.error || parsedEvent.data.message || "Unknown error occurred"; + + epgsState.updateEPG({ + ...epg, + status: 'error', + last_message: errorMessage + }); + + // Show notification for the error + notifications.show({ + title: 'EPG Refresh Error', + message: errorMessage, + color: 'red.5', + }); + } + // Update status on completion only if no errors + else if (parsedEvent.data.progress === 100) { + epgsState.updateEPG({ + ...epg, + status: parsedEvent.data.status || 'success', + last_message: parsedEvent.data.message || epg.last_message + }); + + // Only show success notification if we've finished parsing programs and had no errors + if (parsedEvent.data.action === "parsing_programs") { + notifications.show({ + title: 'EPG Processing Complete', + message: 'EPG data has been updated successfully', + color: 'green.5', + }); + + fetchEPGData(); + } + } + } } break; diff --git a/frontend/src/components/M3URefreshNotification.jsx b/frontend/src/components/M3URefreshNotification.jsx index b1c1984f..2cb6fdd8 100644 --- a/frontend/src/components/M3URefreshNotification.jsx +++ b/frontend/src/components/M3URefreshNotification.jsx @@ -34,8 +34,24 @@ export default function M3URefreshNotification() { [data.account]: data, }); + // Check for error status FIRST before doing anything else + if (data.status === "error") { + // Only show the error notification if we have a complete task (progress=100) + // or if it's explicitly flagged as an error + if (data.progress === 100) { + notifications.show({ + title: `M3U Processing: ${playlist.name}`, + message: `${data.action || 'Processing'} failed: ${data.error || "Unknown error"}`, + color: 'red', + autoClose: 5000, // Keep error visible a bit longer + }); + } + return; // Exit early for any error status + } + const taskProgress = data.progress; + // Only show start and completion notifications for normal operation if (data.progress != 0 && data.progress != 100) { console.log('not 0 or 100'); return; @@ -61,6 +77,7 @@ export default function M3URefreshNotification() { } else if (taskProgress == 100) { message = `${message} complete!`; + // Only trigger additional fetches on successful completion if (data.action == 'parsing') { fetchStreams(); } else if (data.action == 'processing_groups') { diff --git a/frontend/src/components/tables/M3UsTable.jsx b/frontend/src/components/tables/M3UsTable.jsx index 13a0564d..9f59f6c3 100644 --- a/frontend/src/components/tables/M3UsTable.jsx +++ b/frontend/src/components/tables/M3UsTable.jsx @@ -14,11 +14,37 @@ import { ActionIcon, Tooltip, Switch, + Progress, + Stack, } from '@mantine/core'; import { SquareMinus, SquarePen, RefreshCcw, Check, X } from 'lucide-react'; import { IconSquarePlus } from '@tabler/icons-react'; // Import custom icons import dayjs from 'dayjs'; +// 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'; + 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'; + default: return 'gray.5'; + } +}; + const M3UTable = () => { const [playlist, setPlaylist] = useState(null); const [playlistModalOpen, setPlaylistModalOpen] = useState(false); @@ -258,34 +284,62 @@ const M3UTable = () => { }, { header: 'Status', - accessorFn: (row) => { - if (!row.id) { - return ''; - } - if (!refreshProgress[row.id]) { - return 'Idle'; + accessorKey: 'status', + size: 100, + minSize: 80, + Cell: ({ row }) => { + const data = row.original; + + // Check if there's an active progress for this M3U + if (refreshProgress[data.id] && refreshProgress[data.id].progress < 100) { + return generateStatusString(refreshProgress[data.id]); } - return generateStatusString(refreshProgress[row.id]); - }, - size: 180, - minSize: 150, - Cell: ({ cell }) => { - const value = cell.getValue(); - - // Return a visual component for the status - if (typeof value === 'object') { - return value; - } - - // For simple string statuses + // Return simple text display with appropriate color return ( - - {value} + + {formatStatusText(data.status)} ); }, }, + { + header: 'Status Message', + accessorKey: 'last_message', + size: 250, + minSize: 150, + enableSorting: false, + Cell: ({ row }) => { + const data = row.original; + + // Show error message when status is error + if (data.status === 'error' && data.last_message) { + return ( + + + {data.last_message} + + + ); + } + + // Show success message for successful sources + if (data.status === 'success' && data.last_message) { + return ( + + {data.last_message} + + ); + } + + // Otherwise return empty cell + return null; + }, + }, { header: 'Active', accessorKey: 'is_active', @@ -313,7 +367,7 @@ const M3UTable = () => { enableSorting: false, }, ], - [refreshProgress] + [refreshProgress, theme] ); //optionally access the underlying virtualizer instance