diff --git a/apps/m3u/admin.py b/apps/m3u/admin.py index 1b77decc..c9b9ad0d 100644 --- a/apps/m3u/admin.py +++ b/apps/m3u/admin.py @@ -1,6 +1,6 @@ from django.contrib import admin from django.utils.html import format_html -from .models import M3UAccount, M3UFilter, ServerGroup, UserAgent +from .models import M3UAccount, M3UFilter, ServerGroup, UserAgent, M3UAccountProfile import json @@ -83,3 +83,108 @@ class M3UFilterAdmin(admin.ModelAdmin): class ServerGroupAdmin(admin.ModelAdmin): list_display = ("name",) search_fields = ("name",) + + +@admin.register(M3UAccountProfile) +class M3UAccountProfileAdmin(admin.ModelAdmin): + list_display = ( + "name", + "m3u_account", + "is_default", + "is_active", + "max_streams", + "current_viewers", + "account_status_display", + "account_expiration_display", + "last_refresh_display", + ) + list_filter = ("is_active", "is_default", "m3u_account__account_type") + search_fields = ("name", "m3u_account__name") + readonly_fields = ("account_info_display",) + + def account_status_display(self, obj): + """Display account status from custom properties""" + status = obj.get_account_status() + if status: + # Create colored status display + color_map = { + 'Active': 'green', + 'Expired': 'red', + 'Disabled': 'red', + 'Banned': 'red', + } + color = color_map.get(status, 'black') + return format_html( + '{}', + color, + status + ) + return "Unknown" + account_status_display.short_description = "Account Status" + + def account_expiration_display(self, obj): + """Display account expiration from custom properties""" + expiration = obj.get_account_expiration() + if expiration: + from datetime import datetime + if expiration < datetime.now(): + return format_html( + '{}', + expiration.strftime('%Y-%m-%d %H:%M') + ) + else: + return format_html( + '{}', + expiration.strftime('%Y-%m-%d %H:%M') + ) + return "Unknown" + account_expiration_display.short_description = "Expires" + + def last_refresh_display(self, obj): + """Display last refresh time from custom properties""" + last_refresh = obj.get_last_refresh() + if last_refresh: + return last_refresh.strftime('%Y-%m-%d %H:%M:%S') + return "Never" + last_refresh_display.short_description = "Last Refresh" + + def account_info_display(self, obj): + """Display formatted account information from custom properties""" + if not obj.custom_properties: + return "No account information available" + + html_parts = [] + + # User Info + user_info = obj.custom_properties.get('user_info', {}) + if user_info: + html_parts.append("

User Information:

") + html_parts.append("") + + # Server Info + server_info = obj.custom_properties.get('server_info', {}) + if server_info: + html_parts.append("

Server Information:

") + html_parts.append("") + + # Last Refresh + last_refresh = obj.custom_properties.get('last_refresh') + if last_refresh: + html_parts.append(f"

Last Refresh: {last_refresh}

") + + return format_html(''.join(html_parts)) if html_parts else "No account information available" + + account_info_display.short_description = "Account Information" 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/migrations/0018_add_profile_custom_properties.py b/apps/m3u/migrations/0018_add_profile_custom_properties.py new file mode 100644 index 00000000..d616c598 --- /dev/null +++ b/apps/m3u/migrations/0018_add_profile_custom_properties.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.4 on 2025-09-09 20:57 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('m3u', '0017_alter_m3uaccount_custom_properties_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='m3uaccountprofile', + name='custom_properties', + field=models.JSONField(blank=True, default=dict, help_text='Custom properties for storing account information from provider (e.g., XC account details, expiration dates)', null=True), + ), + ] diff --git a/apps/m3u/models.py b/apps/m3u/models.py index 9870aa93..b812ad6c 100644 --- a/apps/m3u/models.py +++ b/apps/m3u/models.py @@ -263,6 +263,12 @@ class M3UAccountProfile(models.Model): max_length=255, ) current_viewers = models.PositiveIntegerField(default=0) + custom_properties = models.JSONField( + default=dict, + blank=True, + null=True, + help_text="Custom properties for storing account information from provider (e.g., XC account details, expiration dates)" + ) class Meta: constraints = [ @@ -274,6 +280,70 @@ class M3UAccountProfile(models.Model): def __str__(self): return f"{self.name} ({self.m3u_account.name})" + def get_account_expiration(self): + """Get account expiration date from custom properties if available""" + if not self.custom_properties: + return None + + user_info = self.custom_properties.get('user_info', {}) + exp_date = user_info.get('exp_date') + + if exp_date: + try: + from datetime import datetime + # XC exp_date is typically a Unix timestamp + if isinstance(exp_date, (int, float)): + return datetime.fromtimestamp(exp_date) + elif isinstance(exp_date, str): + # Try to parse as timestamp first, then as ISO date + try: + return datetime.fromtimestamp(float(exp_date)) + except ValueError: + return datetime.fromisoformat(exp_date) + except (ValueError, TypeError): + pass + + return None + + def get_account_status(self): + """Get account status from custom properties if available""" + if not self.custom_properties: + return None + + user_info = self.custom_properties.get('user_info', {}) + return user_info.get('status') + + def get_max_connections(self): + """Get maximum connections from custom properties if available""" + if not self.custom_properties: + return None + + user_info = self.custom_properties.get('user_info', {}) + return user_info.get('max_connections') + + def get_active_connections(self): + """Get active connections from custom properties if available""" + if not self.custom_properties: + return None + + user_info = self.custom_properties.get('user_info', {}) + return user_info.get('active_cons') + + def get_last_refresh(self): + """Get last refresh timestamp from custom properties if available""" + if not self.custom_properties: + return None + + last_refresh = self.custom_properties.get('last_refresh') + if last_refresh: + try: + from datetime import datetime + return datetime.fromisoformat(last_refresh) + except (ValueError, TypeError): + pass + + return None + @receiver(models.signals.post_save, sender=M3UAccount) def create_profile_for_m3u_account(sender, instance, created, **kwargs): diff --git a/apps/m3u/serializers.py b/apps/m3u/serializers.py index 6e5882a7..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 = [ @@ -40,8 +51,10 @@ class M3UAccountProfileSerializer(serializers.ModelSerializer): "current_viewers", "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 16391eae..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,6 +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 + ) + + # Update each profile with account information using its own transformed credentials + for profile in profiles: + 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 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) @@ -1990,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/core/xtream_codes.py b/core/xtream_codes.py index 6a30b5d4..9b56197a 100644 --- a/core/xtream_codes.py +++ b/core/xtream_codes.py @@ -136,6 +136,47 @@ class Client: logger.error(traceback.format_exc()) raise + def get_account_info(self): + """Get account information from the last authentication response""" + if not self.server_info: + raise ValueError("Not authenticated. Call authenticate() first.") + + from datetime import datetime + + # Extract relevant account information + user_info = self.server_info.get('user_info', {}) + server_info = self.server_info.get('server_info', {}) + + account_info = { + 'last_refresh': datetime.utcnow().isoformat() + 'Z', # Explicit UTC with Z suffix + 'auth_timestamp': datetime.utcnow().timestamp(), + 'user_info': { + 'username': user_info.get('username'), + 'password': user_info.get('password'), + 'message': user_info.get('message'), + 'auth': user_info.get('auth'), + 'status': user_info.get('status'), + 'exp_date': user_info.get('exp_date'), + 'is_trial': user_info.get('is_trial'), + 'active_cons': user_info.get('active_cons'), + 'created_at': user_info.get('created_at'), + 'max_connections': user_info.get('max_connections'), + 'allowed_output_formats': user_info.get('allowed_output_formats', []) + }, + 'server_info': { + 'url': server_info.get('url'), + 'port': server_info.get('port'), + 'https_port': server_info.get('https_port'), + 'server_protocol': server_info.get('server_protocol'), + 'rtmp_port': server_info.get('rtmp_port'), + 'timezone': server_info.get('timezone'), + 'timestamp_now': server_info.get('timestamp_now'), + 'time_now': server_info.get('time_now') + } + } + + return account_info + def get_live_categories(self): """Get live TV categories""" try: 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 new file mode 100644 index 00000000..fa44df90 --- /dev/null +++ b/frontend/src/components/forms/AccountInfoModal.jsx @@ -0,0 +1,438 @@ +import React, { useState, useMemo } from 'react'; +import { + Modal, + Text, + Box, + Group, + Badge, + Table, + Stack, + Divider, + Alert, + Loader, + Center, + ActionIcon, + Tooltip, +} from '@mantine/core'; +import { notifications } from '@mantine/notifications'; +import { + Info, + Clock, + Users, + CheckCircle, + XCircle, + AlertTriangle, + RefreshCw, +} from 'lucide-react'; +import API from '../../api'; +import usePlaylistsStore from '../../store/playlists'; + +const AccountInfoModal = ({ isOpen, onClose, profile, onRefresh }) => { + const [isRefreshing, setIsRefreshing] = useState(false); + + // Get fresh profile data from store to ensure we have the latest custom_properties + const profiles = usePlaylistsStore((state) => state.profiles); + const currentProfile = useMemo(() => { + if (!profile?.id || !profile?.account?.id) return profile; + + // Find the current profile in the store by ID + const accountProfiles = profiles[profile.account.id] || []; + const freshProfile = accountProfiles.find((p) => p.id === profile.id); + + // Return fresh profile if found, otherwise fall back to the passed profile + return freshProfile || profile; + }, [profile, profiles]); + + const handleRefresh = async () => { + if (!currentProfile?.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(currentProfile.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 (!currentProfile || !currentProfile.custom_properties) { + return ( + +
+ + + No account information available + + Account information will be available after the next refresh for + XtreamCodes accounts. + + +
+
+ ); + } + + const { user_info, server_info, last_refresh } = + currentProfile.custom_properties || {}; + + // Helper function to format timestamps + const formatTimestamp = (timestamp) => { + if (!timestamp) return 'Unknown'; + try { + const date = + typeof timestamp === 'string' && timestamp.includes('T') + ? new Date(timestamp) // This should handle ISO format properly + : new Date(parseInt(timestamp) * 1000); + + // Convert to user's local time and display with timezone + return date.toLocaleString(undefined, { + year: 'numeric', + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + timeZoneName: 'short', + }); + } catch { + return 'Invalid date'; + } + }; + + // Helper function to get time remaining + const getTimeRemaining = (expTimestamp) => { + if (!expTimestamp) return null; + try { + const expDate = new Date(parseInt(expTimestamp) * 1000); + const now = new Date(); + const diffMs = expDate - now; + + if (diffMs <= 0) return 'Expired'; + + const days = Math.floor(diffMs / (1000 * 60 * 60 * 24)); + const hours = Math.floor( + (diffMs % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60) + ); + + if (days > 0) { + return `${days} day${days !== 1 ? 's' : ''} ${hours} hour${hours !== 1 ? 's' : ''}`; + } else { + return `${hours} hour${hours !== 1 ? 's' : ''}`; + } + } catch { + return 'Unknown'; + } + }; + + // Helper function to get status badge + const getStatusBadge = (status) => { + const statusConfig = { + Active: { color: 'green', icon: CheckCircle }, + Expired: { color: 'red', icon: XCircle }, + Disabled: { color: 'red', icon: XCircle }, + Banned: { color: 'red', icon: XCircle }, + }; + + const config = statusConfig[status] || { + color: 'gray', + icon: AlertTriangle, + }; + const Icon = config.icon; + + return ( + } + > + {status || 'Unknown'} + + ); + }; + + const timeRemaining = user_info?.exp_date + ? getTimeRemaining(user_info.exp_date) + : null; + const isExpired = timeRemaining === 'Expired'; + + return ( + + + + Account Information - {currentProfile.name} + + + } + size="lg" + > + + {/* Account Status Overview */} + + + + Account Status + + {getStatusBadge(user_info?.status)} + + + {isExpired && ( + } + color="red" + variant="light" + mb="md" + > + This account has expired! + + )} + + + + + {/* Key Information Cards */} + + + + + Expires + + + {user_info?.exp_date + ? formatTimestamp(user_info.exp_date) + : 'Unknown'} + + {timeRemaining && ( + + {timeRemaining === 'Expired' + ? 'Expired' + : `${timeRemaining} remaining`} + + )} + + + + + + Connections + + + {user_info?.active_cons || '0'} /{' '} + {user_info?.max_connections || 'Unknown'} + + + Active / Max + + + + + + + {/* Detailed Information Table */} + + + Account Details + + + + + + Username + + {user_info?.username || 'Unknown'} + + + Account Created + + {user_info?.created_at + ? formatTimestamp(user_info.created_at) + : 'Unknown'} + + + + Trial Account + + + {user_info?.is_trial === '1' ? 'Yes' : 'No'} + + + + {user_info?.allowed_output_formats && + user_info.allowed_output_formats.length > 0 && ( + + Allowed Formats + + + {user_info.allowed_output_formats.map( + (format, index) => ( + + {format.toUpperCase()} + + ) + )} + + + + )} + +
+
+ + {/* Server Information */} + {server_info && Object.keys(server_info).length > 0 && ( + <> + + + + Server Information + + + + {server_info.url && ( + + + Server URL + + + + {server_info.url} + + + + )} + {server_info.port && ( + + Port + + + {server_info.port} + + + + )} + {server_info.https_port && ( + + HTTPS Port + + + {server_info.https_port} + + + + )} + {server_info.timezone && ( + + Timezone + {server_info.timezone} + + )} + +
+
+ + )} + + {/* Last Refresh Info */} + + + + {/* Show refresh button for XtreamCodes accounts */} + {currentProfile?.account?.is_xtream_codes && ( + + + + + + )} + + + Last Account Info Refresh: + + + {last_refresh ? formatTimestamp(last_refresh) : 'Never'} + + + + +
+
+ ); +}; + +export default AccountInfoModal; diff --git a/frontend/src/components/forms/M3UProfiles.jsx b/frontend/src/components/forms/M3UProfiles.jsx index 455be473..db7ceb39 100644 --- a/frontend/src/components/forms/M3UProfiles.jsx +++ b/frontend/src/components/forms/M3UProfiles.jsx @@ -1,6 +1,7 @@ import React, { useState, useEffect } from 'react'; import API from '../../api'; import M3UProfile from './M3UProfile'; +import AccountInfoModal from './AccountInfoModal'; import usePlaylistsStore from '../../store/playlists'; import ConfirmationDialog from '../ConfirmationDialog'; import useWarningsStore from '../../store/warnings'; @@ -18,13 +19,16 @@ import { Center, Group, Switch, + Badge, + Stack, } from '@mantine/core'; -import { SquareMinus, SquarePen } from 'lucide-react'; +import { SquareMinus, SquarePen, Info } from 'lucide-react'; 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); @@ -34,6 +38,15 @@ const M3UProfiles = ({ playlist = null, isOpen, onClose }) => { const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false); const [deleteTarget, setDeleteTarget] = useState(null); const [profileToDelete, setProfileToDelete] = useState(null); + 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 { @@ -113,6 +126,59 @@ const M3UProfiles = ({ playlist = null, isOpen, onClose }) => { setProfileEditorOpen(false); }; + const showAccountInfo = (profile) => { + setSelectedProfileForInfo(profile); + setAccountInfoOpen(true); + }; + + const closeAccountInfo = () => { + setSelectedProfileForInfo(null); + setAccountInfoOpen(false); + }; + + // Helper function to get account status from profile + const getAccountStatus = (profile) => { + if (!profile.custom_properties?.user_info) return null; + return profile.custom_properties.user_info.status; + }; + + // Helper function to check if account is expired + const isAccountExpired = (profile) => { + if (!profile.custom_properties?.user_info?.exp_date) return false; + try { + const expDate = new Date( + parseInt(profile.custom_properties.user_info.exp_date) * 1000 + ); + return expDate < new Date(); + } catch { + return false; + } + }; + + // Helper function to get account expiration info + const getExpirationInfo = (profile) => { + if (!profile.custom_properties?.user_info?.exp_date) return null; + try { + const expDate = new Date( + parseInt(profile.custom_properties.user_info.exp_date) * 1000 + ); + const now = new Date(); + const diffMs = expDate - now; + + if (diffMs <= 0) return { text: 'Expired', color: 'red' }; + + const days = Math.floor(diffMs / (1000 * 60 * 60 * 24)); + if (days > 30) return { text: `${days} days`, color: 'green' }; + if (days > 7) return { text: `${days} days`, color: 'yellow' }; + if (days > 0) return { text: `${days} days`, color: 'orange' }; + + const hours = Math.floor(diffMs / (1000 * 60 * 60)); + return { text: `${hours}h`, color: 'red' }; + } catch { + return null; + } + }; + // Don't render if modal is not open, or if playlist data is invalid if (!isOpen || !playlist || !playlist.id) { return <>; @@ -132,57 +198,116 @@ const M3UProfiles = ({ playlist = null, isOpen, onClose }) => { // Sort remaining profiles alphabetically by name return a.name.localeCompare(b.name); }) - .map((item) => ( - - - - {item.name} - toggleActive(item)} - disabled={item.is_default} - style={{ paddingTop: 6 }} - /> - + .map((item) => { + const accountStatus = getAccountStatus(item); + const expirationInfo = getExpirationInfo(item); + const expired = isAccountExpired(item); - - modifyMaxStreams(value, item)} - style={{ flex: 1 }} - /> - - {!item.is_default && ( - - editProfile(item)} - > - - - - deleteProfile(item.id)} - size="small" - variant="transparent" - > - - + return ( + + + {/* Header with name and status badges */} + + + {item.name} + {playlist?.account_type === 'XC' && + item.custom_properties && ( + + {/* Account status badge */} + {accountStatus && ( + + {accountStatus} + + )} + {/* Expiration badge */} + {expirationInfo && ( + + {expirationInfo.text} + + )} + {/* Info button next to badges */} + showAccountInfo(item)} + title="View account information" + style={{ + backgroundColor: 'rgba(34, 139, 230, 0.1)', + color: '#228be6', + }} + > + + + + )} - )} - - - - ))} + + + {/* Max Streams and Actions */} + + modifyMaxStreams(value, item)} + style={{ flex: 1 }} + /> + + + {/* Toggle switch */} + toggleActive(item)} + disabled={item.is_default} + label="Active" + labelPosition="left" + size="sm" + /> + + {!item.is_default && ( + <> + editProfile(item)} + title="Edit profile" + > + + + + deleteProfile(item.id)} + size="small" + variant="transparent" + title="Delete profile" + > + + + + )} + + + + + ); + })}