From 84c752761a62e7f0950ec75c2e86507119425fe3 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Tue, 9 Sep 2025 18:15:33 -0500 Subject: [PATCH] Ability to refresh account info from account info modal and provide notification on results. --- apps/m3u/api_urls.py | 6 + apps/m3u/api_views.py | 50 +++- apps/m3u/serializers.py | 14 +- apps/m3u/tasks.py | 253 ++++++++++++++++-- frontend/src/WebSocket.jsx | 27 +- frontend/src/api.js | 16 ++ .../src/components/forms/AccountInfoModal.jsx | 90 ++++++- frontend/src/components/forms/M3UProfiles.jsx | 9 + 8 files changed, 433 insertions(+), 32 deletions(-) diff --git a/apps/m3u/api_urls.py b/apps/m3u/api_urls.py index 80e54bb2..6a80a1fe 100644 --- a/apps/m3u/api_urls.py +++ b/apps/m3u/api_urls.py @@ -6,6 +6,7 @@ from .api_views import ( ServerGroupViewSet, RefreshM3UAPIView, RefreshSingleM3UAPIView, + RefreshAccountInfoAPIView, UserAgentViewSet, M3UAccountProfileViewSet, ) @@ -33,6 +34,11 @@ urlpatterns = [ RefreshSingleM3UAPIView.as_view(), name="m3u_refresh_single", ), + path( + "refresh-account-info//", + RefreshAccountInfoAPIView.as_view(), + name="m3u_refresh_account_info", + ), ] urlpatterns += router.urls diff --git a/apps/m3u/api_views.py b/apps/m3u/api_views.py index da975fd9..9c5d5c14 100644 --- a/apps/m3u/api_views.py +++ b/apps/m3u/api_views.py @@ -30,7 +30,7 @@ from .serializers import ( M3UAccountProfileSerializer, ) -from .tasks import refresh_single_m3u_account, refresh_m3u_accounts +from .tasks import refresh_single_m3u_account, refresh_m3u_accounts, refresh_account_info import json @@ -348,6 +348,54 @@ class RefreshSingleM3UAPIView(APIView): ) +class RefreshAccountInfoAPIView(APIView): + """Triggers account info refresh for a single M3U account""" + + def get_permissions(self): + try: + return [ + perm() for perm in permission_classes_by_method[self.request.method] + ] + except KeyError: + return [Authenticated()] + + @swagger_auto_schema( + operation_description="Triggers a refresh of account information for a specific M3U profile", + responses={202: "Account info refresh initiated", 400: "Profile not found or not XtreamCodes"}, + ) + def post(self, request, profile_id, format=None): + try: + from .models import M3UAccountProfile + profile = M3UAccountProfile.objects.get(id=profile_id) + account = profile.m3u_account + + if account.account_type != M3UAccount.Types.XC: + return Response( + { + "success": False, + "error": "Account info refresh is only available for XtreamCodes accounts", + }, + status=status.HTTP_400_BAD_REQUEST, + ) + + refresh_account_info.delay(profile_id) + return Response( + { + "success": True, + "message": f"Account info refresh initiated for profile {profile.name}.", + }, + status=status.HTTP_202_ACCEPTED, + ) + except M3UAccountProfile.DoesNotExist: + return Response( + { + "success": False, + "error": "Profile not found", + }, + status=status.HTTP_404_NOT_FOUND, + ) + + class UserAgentViewSet(viewsets.ModelViewSet): """Handles CRUD operations for User Agents""" diff --git a/apps/m3u/serializers.py b/apps/m3u/serializers.py index 3ce4b1bb..052b9733 100644 --- a/apps/m3u/serializers.py +++ b/apps/m3u/serializers.py @@ -29,6 +29,17 @@ class M3UFilterSerializer(serializers.ModelSerializer): class M3UAccountProfileSerializer(serializers.ModelSerializer): + account = serializers.SerializerMethodField() + + def get_account(self, obj): + """Include basic account information for frontend use""" + return { + 'id': obj.m3u_account.id, + 'name': obj.m3u_account.name, + 'account_type': obj.m3u_account.account_type, + 'is_xtream_codes': obj.m3u_account.account_type == 'XC' + } + class Meta: model = M3UAccountProfile fields = [ @@ -41,8 +52,9 @@ class M3UAccountProfileSerializer(serializers.ModelSerializer): "search_pattern", "replace_pattern", "custom_properties", + "account", ] - read_only_fields = ["id"] + read_only_fields = ["id", "account"] def create(self, validated_data): m3u_account = self.context.get("m3u_account") diff --git a/apps/m3u/tasks.py b/apps/m3u/tasks.py index e3a8bd32..2caeb519 100644 --- a/apps/m3u/tasks.py +++ b/apps/m3u/tasks.py @@ -1176,13 +1176,13 @@ def refresh_m3u_groups(account_id, use_cache=False, full_refresh=False): ) logger.info( - f"Creating XCClient with URL: {server_url}, Username: {account.username}, User-Agent: {user_agent_string}" + f"Creating XCClient with URL: {account.server_url}, Username: {account.username}, User-Agent: {user_agent_string}" ) # Create XCClient with explicit error handling try: with XCClient( - server_url, account.username, account.password, user_agent_string + account.server_url, account.username, account.password, user_agent_string ) as xc_client: logger.info(f"XCClient instance created successfully") @@ -1191,33 +1191,54 @@ def refresh_m3u_groups(account_id, use_cache=False, full_refresh=False): logger.debug(f"Authenticating with XC server {server_url}") auth_result = xc_client.authenticate() logger.debug(f"Authentication response: {auth_result}") - + # Save account information to all active profiles try: from apps.m3u.models import M3UAccountProfile - + profiles = M3UAccountProfile.objects.filter( m3u_account=account, is_active=True ) - - # Get structured account information from XC client - account_info = xc_client.get_account_info() - - # Update each profile with account information + + # Update each profile with account information using its own transformed credentials for profile in profiles: - # Merge with existing custom_properties if they exist - existing_props = profile.custom_properties or {} - existing_props.update(account_info) - profile.custom_properties = existing_props - profile.save(update_fields=['custom_properties']) - - logger.info(f"Saved account information to {profiles.count()} profiles for account {account.name}") - + try: + # Get transformed credentials for this specific profile + profile_url, profile_username, profile_password = get_transformed_credentials(account, profile) + + # Create a separate XC client for this profile's credentials + with XCClient( + profile_url, + profile_username, + profile_password, + user_agent_string + ) as profile_client: + # Authenticate with this profile's credentials + if profile_client.authenticate(): + # Get account information specific to this profile's credentials + profile_account_info = profile_client.get_account_info() + + # Merge with existing custom_properties if they exist + existing_props = profile.custom_properties or {} + existing_props.update(profile_account_info) + profile.custom_properties = existing_props + profile.save(update_fields=['custom_properties']) + + logger.info(f"Updated account information for profile '{profile.name}' with transformed credentials") + else: + logger.warning(f"Failed to authenticate profile '{profile.name}' with transformed credentials") + + except Exception as profile_error: + logger.error(f"Failed to update account information for profile '{profile.name}': {str(profile_error)}") + # Continue with other profiles even if one fails + + logger.info(f"Processed account information for {profiles.count()} profiles for account {account.name}") + except Exception as save_error: - logger.warning(f"Failed to save account information to profiles: {str(save_error)}") + logger.warning(f"Failed to process profile account information: {str(save_error)}") # Don't fail the whole process if saving account info fails - + except Exception as e: error_msg = f"Failed to authenticate with XC server: {str(e)}" logger.error(error_msg) @@ -2017,6 +2038,200 @@ def sync_auto_channels(account_id, scan_start_time=None): return f"Auto sync error: {str(e)}" +def get_transformed_credentials(account, profile=None): + """ + Get transformed credentials for XtreamCodes API calls. + + Args: + account: M3UAccount instance + profile: M3UAccountProfile instance (optional, if not provided will use primary profile) + + Returns: + tuple: (transformed_url, transformed_username, transformed_password) + """ + import re + import urllib.parse + + # If no profile is provided, find the primary active profile + if profile is None: + try: + from apps.m3u.models import M3UAccountProfile + profile = M3UAccountProfile.objects.filter( + m3u_account=account, + is_active=True + ).first() + if profile: + logger.debug(f"Using primary profile '{profile.name}' for URL transformation") + else: + logger.debug(f"No active profiles found for account {account.name}, using base credentials") + except Exception as e: + logger.warning(f"Could not get primary profile for account {account.name}: {e}") + profile = None + + base_url = account.server_url + base_username = account.username + base_password = account.password # Build a complete URL with credentials (similar to how IPTV URLs are structured) + # Format: http://server.com:port/username/password/rest_of_path + if base_url and base_username and base_password: + # Remove trailing slash from server URL if present + clean_server_url = base_url.rstrip('/') + + # Build the complete URL with embedded credentials + complete_url = f"{clean_server_url}/{base_username}/{base_password}/" + logger.debug(f"Built complete URL: {complete_url}") + + # Apply profile-specific transformations if profile is provided + if profile and profile.search_pattern and profile.replace_pattern: + try: + # Handle backreferences in the replacement pattern + safe_replace_pattern = re.sub(r'\$(\d+)', r'\\\1', profile.replace_pattern) + + # Apply transformation to the complete URL + transformed_complete_url = re.sub(profile.search_pattern, safe_replace_pattern, complete_url) + logger.info(f"Transformed complete URL: {complete_url} -> {transformed_complete_url}") + + # Extract components from the transformed URL + # Pattern: http://server.com:port/username/password/ + parsed_url = urllib.parse.urlparse(transformed_complete_url) + path_parts = [part for part in parsed_url.path.split('/') if part] + + if len(path_parts) >= 2: + # Extract username and password from path + transformed_username = path_parts[0] + transformed_password = path_parts[1] + + # Rebuild server URL without the username/password path + transformed_url = f"{parsed_url.scheme}://{parsed_url.netloc}" + if parsed_url.port: + transformed_url = f"{parsed_url.scheme}://{parsed_url.hostname}:{parsed_url.port}" + + logger.debug(f"Extracted transformed credentials:") + logger.debug(f" Server URL: {transformed_url}") + logger.debug(f" Username: {transformed_username}") + logger.debug(f" Password: {transformed_password}") + + return transformed_url, transformed_username, transformed_password + else: + logger.warning(f"Could not extract credentials from transformed URL: {transformed_complete_url}") + return base_url, base_username, base_password + + except Exception as e: + logger.error(f"Error transforming URL for profile {profile.name if profile else 'unknown'}: {e}") + return base_url, base_username, base_password + else: + # No profile or no transformation patterns + return base_url, base_username, base_password + else: + logger.warning(f"Missing credentials for account {account.name}") + return base_url, base_username, base_password + + +@shared_task +def refresh_account_info(profile_id): + """Refresh only the account information for a specific M3U profile.""" + if not acquire_task_lock("refresh_account_info", profile_id): + return f"Account info refresh task already running for profile_id={profile_id}." + + try: + from apps.m3u.models import M3UAccountProfile + import re + + profile = M3UAccountProfile.objects.get(id=profile_id) + account = profile.m3u_account + + if account.account_type != M3UAccount.Types.XC: + release_task_lock("refresh_account_info", profile_id) + return f"Profile {profile_id} belongs to account {account.id} which is not an XtreamCodes account." + + # Get transformed credentials using the helper function + transformed_url, transformed_username, transformed_password = get_transformed_credentials(account, profile) + + # Initialize XtreamCodes client with extracted/transformed credentials + client = XCClient( + transformed_url, + transformed_username, + transformed_password, + account.get_user_agent(), + ) # Authenticate and get account info + auth_result = client.authenticate() + if not auth_result: + error_msg = f"Authentication failed for profile {profile.name} ({profile_id})" + logger.error(error_msg) + + # Send error notification to frontend via websocket + send_websocket_update( + "updates", + "update", + { + "type": "account_info_refresh_error", + "profile_id": profile_id, + "profile_name": profile.name, + "error": "Authentication failed with the provided credentials", + "message": f"Failed to authenticate profile '{profile.name}'. Please check the credentials." + } + ) + + release_task_lock("refresh_account_info", profile_id) + return error_msg + + # Get account information + account_info = client.get_account_info() + + # Update only this specific profile with the new account info + if not profile.custom_properties: + profile.custom_properties = {} + profile.custom_properties.update(account_info) + profile.save() + + # Send success notification to frontend via websocket + send_websocket_update( + "updates", + "update", + { + "type": "account_info_refresh_success", + "profile_id": profile_id, + "profile_name": profile.name, + "message": f"Account information successfully refreshed for profile '{profile.name}'" + } + ) + + release_task_lock("refresh_account_info", profile_id) + return f"Account info refresh completed for profile {profile_id} ({profile.name})." + + except M3UAccountProfile.DoesNotExist: + error_msg = f"Profile {profile_id} not found" + logger.error(error_msg) + + send_websocket_update( + "updates", + "update", + { + "type": "account_refresh_error", + "profile_id": profile_id, + "error": "Profile not found", + "message": f"Profile {profile_id} not found" + } + ) + + release_task_lock("refresh_account_info", profile_id) + return error_msg + except Exception as e: + error_msg = f"Error refreshing account info for profile {profile_id}: {str(e)}" + logger.error(error_msg) + + send_websocket_update( + "updates", + "update", + { + "type": "account_refresh_error", + "profile_id": profile_id, + "error": str(e), + "message": f"Failed to refresh account info: {str(e)}" + } + ) + + release_task_lock("refresh_account_info", profile_id) + return error_msg @shared_task def refresh_single_m3u_account(account_id): """Splits M3U processing into chunks and dispatches them as parallel tasks.""" diff --git a/frontend/src/WebSocket.jsx b/frontend/src/WebSocket.jsx index 9ed17c64..819a923f 100644 --- a/frontend/src/WebSocket.jsx +++ b/frontend/src/WebSocket.jsx @@ -465,7 +465,10 @@ export const WebsocketProvider = ({ children }) => { try { await fetchEPGs(); } catch (e) { - console.warn('Failed to refresh EPG sources after change notification:', e); + console.warn( + 'Failed to refresh EPG sources after change notification:', + e + ); } break; @@ -524,6 +527,28 @@ export const WebsocketProvider = ({ children }) => { fetchLogos(); break; + case 'account_info_refresh_success': + notifications.show({ + title: 'Account Info Refreshed', + message: `Successfully updated account information for ${parsedEvent.data.profile_name}`, + color: 'green', + autoClose: 4000, + }); + // Trigger refresh of playlists to update the UI + fetchPlaylists(); + break; + + case 'account_info_refresh_error': + notifications.show({ + title: 'Account Info Refresh Failed', + message: + parsedEvent.data.error || + 'Failed to refresh account information', + color: 'red', + autoClose: 8000, + }); + break; + default: console.error( `Unknown websocket event type: ${parsedEvent.data?.type}` diff --git a/frontend/src/api.js b/frontend/src/api.js index c32a3bda..110d6b75 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -1145,6 +1145,22 @@ export default class API { } } + static async refreshAccountInfo(profileId) { + try { + const response = await request(`${host}/api/m3u/refresh-account-info/${profileId}/`, { + method: 'POST', + }); + return response; + } catch (e) { + // If it's a structured error response, return it instead of throwing + if (e.body && typeof e.body === 'object') { + return e.body; + } + errorNotification(`Failed to refresh account info for profile ${profileId}`, e); + throw e; + } + } + static async addM3UFilter(accountId, values) { try { const response = await request( diff --git a/frontend/src/components/forms/AccountInfoModal.jsx b/frontend/src/components/forms/AccountInfoModal.jsx index 03ff3d62..59c64701 100644 --- a/frontend/src/components/forms/AccountInfoModal.jsx +++ b/frontend/src/components/forms/AccountInfoModal.jsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useState } from 'react'; import { Modal, Text, @@ -11,7 +11,10 @@ import { Alert, Loader, Center, + ActionIcon, + Tooltip, } from '@mantine/core'; +import { notifications } from '@mantine/notifications'; import { Info, Clock, @@ -19,9 +22,58 @@ import { CheckCircle, XCircle, AlertTriangle, + RefreshCw, } from 'lucide-react'; +import API from '../../api'; -const AccountInfoModal = ({ isOpen, onClose, profile }) => { +const AccountInfoModal = ({ isOpen, onClose, profile, onRefresh }) => { + const [isRefreshing, setIsRefreshing] = useState(false); + + const handleRefresh = async () => { + if (!profile?.id) { + notifications.show({ + title: 'Error', + message: 'Unable to refresh: Profile information not available', + color: 'red', + icon: , + }); + return; + } + + setIsRefreshing(true); + + try { + const data = await API.refreshAccountInfo(profile.id); + + if (data.success) { + notifications.show({ + title: 'Success', + message: + 'Account info refresh initiated. The information will be updated shortly.', + color: 'green', + icon: , + }); + + // Call the parent's refresh function if provided + if (onRefresh) { + // Wait a moment for the backend to process, then refresh + setTimeout(onRefresh, 2000); + } + } else { + notifications.show({ + title: 'Error', + message: data.error || 'Failed to refresh account information', + color: 'red', + icon: , + }); + } + } catch { + // Error notification is already handled by the API function + // Just need to handle the UI state + } finally { + setIsRefreshing(false); + } + }; if (!profile || !profile.custom_properties) { return ( @@ -39,7 +91,8 @@ const AccountInfoModal = ({ isOpen, onClose, profile }) => { ); } - const { user_info, server_info, last_refresh } = profile.custom_properties; + const { user_info, server_info, last_refresh } = + profile.custom_properties || {}; // Helper function to format timestamps const formatTimestamp = (timestamp) => { @@ -337,13 +390,30 @@ const AccountInfoModal = ({ isOpen, onClose, profile }) => { borderRadius: 6, }} > - - - Last Account Info Refresh: - - - {last_refresh ? formatTimestamp(last_refresh) : 'Never'} - + + {/* Show refresh button for XtreamCodes accounts */} + {profile?.account?.is_xtream_codes && ( + + + + + + )} + + + Last Account Info Refresh: + + + {last_refresh ? formatTimestamp(last_refresh) : 'Never'} + + diff --git a/frontend/src/components/forms/M3UProfiles.jsx b/frontend/src/components/forms/M3UProfiles.jsx index 0371b9cc..db7ceb39 100644 --- a/frontend/src/components/forms/M3UProfiles.jsx +++ b/frontend/src/components/forms/M3UProfiles.jsx @@ -28,6 +28,7 @@ const M3UProfiles = ({ playlist = null, isOpen, onClose }) => { const theme = useMantineTheme(); const allProfiles = usePlaylistsStore((s) => s.profiles); + const fetchPlaylist = usePlaylistsStore((s) => s.fetchPlaylist); const isWarningSuppressed = useWarningsStore((s) => s.isWarningSuppressed); const suppressWarning = useWarningsStore((s) => s.suppressWarning); @@ -40,6 +41,13 @@ const M3UProfiles = ({ playlist = null, isOpen, onClose }) => { const [accountInfoOpen, setAccountInfoOpen] = useState(false); const [selectedProfileForInfo, setSelectedProfileForInfo] = useState(null); + const handleRefreshAccountInfo = async () => { + // Refresh the playlist data to get updated account info + if (playlist?.id) { + await fetchPlaylist(playlist.id); + } + }; + useEffect(() => { try { // Make sure playlist exists, has an id, and profiles exist for this playlist @@ -348,6 +356,7 @@ This action cannot be undone.`} isOpen={accountInfoOpen} onClose={closeAccountInfo} profile={selectedProfileForInfo} + onRefresh={handleRefreshAccountInfo} /> );