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("")
+ for key, value in user_info.items():
+ if key == 'exp_date' and value:
+ try:
+ from datetime import datetime
+ exp_date = datetime.fromtimestamp(float(value))
+ value = exp_date.strftime('%Y-%m-%d %H:%M:%S')
+ except (ValueError, TypeError):
+ pass
+ html_parts.append(f"- {key}: {value}
")
+ html_parts.append("
")
+
+ # Server Info
+ server_info = obj.custom_properties.get('server_info', {})
+ if server_info:
+ html_parts.append("Server Information:
")
+ html_parts.append("")
+ for key, value in server_info.items():
+ html_parts.append(f"- {key}: {value}
")
+ 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"
+ >
+
+
+ >
+ )}
+
+
+
+
+ );
+ })}
+
>
);
};