Merge pull request #366 from Dispatcharr:XC-account-info

Enhance M3U account profile with custom properties and account info retrieval
This commit is contained in:
SergeantPanda 2025-09-09 18:28:07 -05:00 committed by GitHub
commit f218eaad51
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 1209 additions and 56 deletions

View file

@ -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(
'<span style="color: {};">{}</span>',
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(
'<span style="color: red;">{}</span>',
expiration.strftime('%Y-%m-%d %H:%M')
)
else:
return format_html(
'<span style="color: green;">{}</span>',
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("<h3>User Information:</h3>")
html_parts.append("<ul>")
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"<li><strong>{key}:</strong> {value}</li>")
html_parts.append("</ul>")
# Server Info
server_info = obj.custom_properties.get('server_info', {})
if server_info:
html_parts.append("<h3>Server Information:</h3>")
html_parts.append("<ul>")
for key, value in server_info.items():
html_parts.append(f"<li><strong>{key}:</strong> {value}</li>")
html_parts.append("</ul>")
# Last Refresh
last_refresh = obj.custom_properties.get('last_refresh')
if last_refresh:
html_parts.append(f"<p><strong>Last Refresh:</strong> {last_refresh}</p>")
return format_html(''.join(html_parts)) if html_parts else "No account information available"
account_info_display.short_description = "Account Information"

View file

@ -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/<int:profile_id>/",
RefreshAccountInfoAPIView.as_view(),
name="m3u_refresh_account_info",
),
]
urlpatterns += router.urls

View file

@ -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"""

View file

@ -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),
),
]

View file

@ -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):

View file

@ -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")

View file

@ -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."""

View file

@ -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:

View file

@ -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}`

View file

@ -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(

View file

@ -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: <XCircle size={16} />,
});
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: <CheckCircle size={16} />,
});
// 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: <XCircle size={16} />,
});
}
} 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 (
<Modal opened={isOpen} onClose={onClose} title="Account Information">
<Center p="lg">
<Stack align="center" spacing="md">
<Info size={48} color="gray" />
<Text c="dimmed">No account information available</Text>
<Text size="sm" c="dimmed" ta="center">
Account information will be available after the next refresh for
XtreamCodes accounts.
</Text>
</Stack>
</Center>
</Modal>
);
}
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 (
<Badge
color={config.color}
variant="light"
leftSection={<Icon size={12} />}
>
{status || 'Unknown'}
</Badge>
);
};
const timeRemaining = user_info?.exp_date
? getTimeRemaining(user_info.exp_date)
: null;
const isExpired = timeRemaining === 'Expired';
return (
<Modal
opened={isOpen}
onClose={onClose}
title={
<Group spacing="sm">
<Info size={20} color="var(--mantine-color-blue-6)" />
<Text fw={600} size="lg">
Account Information - {currentProfile.name}
</Text>
</Group>
}
size="lg"
>
<Stack spacing="md">
{/* Account Status Overview */}
<Box>
<Group justify="space-between" mb="sm">
<Text fw={600} size="lg">
Account Status
</Text>
{getStatusBadge(user_info?.status)}
</Group>
{isExpired && (
<Alert
icon={<AlertTriangle size={16} />}
color="red"
variant="light"
mb="md"
>
This account has expired!
</Alert>
)}
</Box>
<Divider />
{/* Key Information Cards */}
<Group grow>
<Box
p="md"
style={{
backgroundColor: isExpired
? 'rgba(255, 107, 107, 0.08)'
: 'rgba(64, 192, 87, 0.08)',
border: `1px solid ${isExpired ? 'rgba(255, 107, 107, 0.2)' : 'rgba(64, 192, 87, 0.2)'}`,
borderRadius: 8,
}}
>
<Group spacing="xs" mb="xs">
<Clock
size={16}
color={
isExpired
? 'var(--mantine-color-red-6)'
: 'var(--mantine-color-green-6)'
}
/>
<Text fw={500}>Expires</Text>
</Group>
<Text size="lg" fw={600} c={isExpired ? 'red' : 'green'}>
{user_info?.exp_date
? formatTimestamp(user_info.exp_date)
: 'Unknown'}
</Text>
{timeRemaining && (
<Text size="sm" c={isExpired ? 'red' : 'green'}>
{timeRemaining === 'Expired'
? 'Expired'
: `${timeRemaining} remaining`}
</Text>
)}
</Box>
<Box
p="md"
style={{
backgroundColor: 'rgba(34, 139, 230, 0.08)',
border: '1px solid rgba(34, 139, 230, 0.2)',
borderRadius: 8,
}}
>
<Group spacing="xs" mb="xs">
<Users size={16} color="var(--mantine-color-blue-6)" />
<Text fw={500}>Connections</Text>
</Group>
<Text size="lg" fw={600} c="blue">
{user_info?.active_cons || '0'} /{' '}
{user_info?.max_connections || 'Unknown'}
</Text>
<Text size="sm" c="dimmed">
Active / Max
</Text>
</Box>
</Group>
<Divider />
{/* Detailed Information Table */}
<Box>
<Text fw={600} mb="sm">
Account Details
</Text>
<Table striped highlightOnHover>
<Table.Tbody>
<Table.Tr>
<Table.Td fw={500} w="40%">
Username
</Table.Td>
<Table.Td>{user_info?.username || 'Unknown'}</Table.Td>
</Table.Tr>
<Table.Tr>
<Table.Td fw={500}>Account Created</Table.Td>
<Table.Td>
{user_info?.created_at
? formatTimestamp(user_info.created_at)
: 'Unknown'}
</Table.Td>
</Table.Tr>
<Table.Tr>
<Table.Td fw={500}>Trial Account</Table.Td>
<Table.Td>
<Badge
color={user_info?.is_trial === '1' ? 'orange' : 'blue'}
variant="light"
size="sm"
>
{user_info?.is_trial === '1' ? 'Yes' : 'No'}
</Badge>
</Table.Td>
</Table.Tr>
{user_info?.allowed_output_formats &&
user_info.allowed_output_formats.length > 0 && (
<Table.Tr>
<Table.Td fw={500}>Allowed Formats</Table.Td>
<Table.Td>
<Group spacing="xs">
{user_info.allowed_output_formats.map(
(format, index) => (
<Badge key={index} variant="outline" size="sm">
{format.toUpperCase()}
</Badge>
)
)}
</Group>
</Table.Td>
</Table.Tr>
)}
</Table.Tbody>
</Table>
</Box>
{/* Server Information */}
{server_info && Object.keys(server_info).length > 0 && (
<>
<Divider />
<Box>
<Text fw={600} mb="sm">
Server Information
</Text>
<Table striped highlightOnHover>
<Table.Tbody>
{server_info.url && (
<Table.Tr>
<Table.Td fw={500} w="40%">
Server URL
</Table.Td>
<Table.Td>
<Text size="sm" family="monospace">
{server_info.url}
</Text>
</Table.Td>
</Table.Tr>
)}
{server_info.port && (
<Table.Tr>
<Table.Td fw={500}>Port</Table.Td>
<Table.Td>
<Badge variant="outline" size="sm">
{server_info.port}
</Badge>
</Table.Td>
</Table.Tr>
)}
{server_info.https_port && (
<Table.Tr>
<Table.Td fw={500}>HTTPS Port</Table.Td>
<Table.Td>
<Badge variant="outline" size="sm" color="green">
{server_info.https_port}
</Badge>
</Table.Td>
</Table.Tr>
)}
{server_info.timezone && (
<Table.Tr>
<Table.Td fw={500}>Timezone</Table.Td>
<Table.Td>{server_info.timezone}</Table.Td>
</Table.Tr>
)}
</Table.Tbody>
</Table>
</Box>
</>
)}
{/* Last Refresh Info */}
<Divider />
<Box
p="sm"
style={{
backgroundColor: 'rgba(134, 142, 150, 0.08)',
border: '1px solid rgba(134, 142, 150, 0.2)',
borderRadius: 6,
}}
>
<Group spacing="xs" align="center" position="apart">
{/* Show refresh button for XtreamCodes accounts */}
{currentProfile?.account?.is_xtream_codes && (
<Tooltip label="Refresh Account Info Now" position="top">
<ActionIcon
size="sm"
variant="light"
color="blue"
onClick={handleRefresh}
loading={isRefreshing}
disabled={isRefreshing}
>
<RefreshCw size={14} />
</ActionIcon>
</Tooltip>
)}
<Group spacing="xs" align="center">
<Text fw={500} size="sm">
Last Account Info Refresh:
</Text>
<Badge variant="light" color="gray" size="sm">
{last_refresh ? formatTimestamp(last_refresh) : 'Never'}
</Badge>
</Group>
</Group>
</Box>
</Stack>
</Modal>
);
};
export default AccountInfoModal;

View file

@ -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) => (
<Card key={item.id}>
<Box sx={{ display: 'flex', alignItems: 'center' }}>
<Group justify="space-between">
<Text fw={600}>{item.name}</Text>
<Switch
checked={item.is_active}
onChange={() => toggleActive(item)}
disabled={item.is_default}
style={{ paddingTop: 6 }}
/>
</Group>
.map((item) => {
const accountStatus = getAccountStatus(item);
const expirationInfo = getExpirationInfo(item);
const expired = isAccountExpired(item);
<Flex gap="sm">
<NumberInput
label="Max Streams"
value={item.max_streams}
disabled={item.is_default}
onChange={(value) => modifyMaxStreams(value, item)}
style={{ flex: 1 }}
/>
{!item.is_default && (
<Group
align="flex-end"
gap="xs"
style={{ paddingBottom: 8 }}
>
<ActionIcon
size="sm"
variant="transparent"
color={theme.tailwind.yellow[3]}
onClick={() => editProfile(item)}
>
<SquarePen size="20" />
</ActionIcon>
<ActionIcon
color={theme.tailwind.red[6]}
onClick={() => deleteProfile(item.id)}
size="small"
variant="transparent"
>
<SquareMinus size="20" />
</ActionIcon>
return (
<Card key={item.id}>
<Stack spacing="sm">
{/* Header with name and status badges */}
<Group justify="space-between" align="center">
<Group spacing="sm" align="center">
<Text fw={600}>{item.name}</Text>
{playlist?.account_type === 'XC' &&
item.custom_properties && (
<Group spacing="xs">
{/* Account status badge */}
{accountStatus && (
<Badge
size="sm"
color={
accountStatus === 'Active'
? 'green'
: expired
? 'red'
: 'gray'
}
variant="light"
>
{accountStatus}
</Badge>
)}
{/* Expiration badge */}
{expirationInfo && (
<Badge
size="sm"
color={expirationInfo.color}
variant="outline"
>
{expirationInfo.text}
</Badge>
)}
{/* Info button next to badges */}
<ActionIcon
size="sm"
variant="filled"
color="blue"
onClick={() => showAccountInfo(item)}
title="View account information"
style={{
backgroundColor: 'rgba(34, 139, 230, 0.1)',
color: '#228be6',
}}
>
<Info size="16" />
</ActionIcon>
</Group>
)}
</Group>
)}
</Flex>
</Box>
</Card>
))}
</Group>
{/* Max Streams and Actions */}
<Flex gap="sm" align="flex-end">
<NumberInput
label="Max Streams"
value={item.max_streams}
disabled={item.is_default}
onChange={(value) => modifyMaxStreams(value, item)}
style={{ flex: 1 }}
/>
<Group spacing="xs" style={{ paddingBottom: 8 }}>
{/* Toggle switch */}
<Switch
checked={item.is_active}
onChange={() => toggleActive(item)}
disabled={item.is_default}
label="Active"
labelPosition="left"
size="sm"
/>
{!item.is_default && (
<>
<ActionIcon
size="sm"
variant="transparent"
color={theme.tailwind.yellow[3]}
onClick={() => editProfile(item)}
title="Edit profile"
>
<SquarePen size="20" />
</ActionIcon>
<ActionIcon
color={theme.tailwind.red[6]}
onClick={() => deleteProfile(item.id)}
size="small"
variant="transparent"
title="Delete profile"
>
<SquareMinus size="20" />
</ActionIcon>
</>
)}
</Group>
</Flex>
</Stack>
</Card>
);
})}
<Flex mih={50} gap="xs" justify="flex-end" align="flex-end">
<Button
@ -227,6 +352,12 @@ This action cannot be undone.`}
onSuppressChange={suppressWarning}
size="md"
/>
<AccountInfoModal
isOpen={accountInfoOpen}
onClose={closeAccountInfo}
profile={selectedProfileForInfo}
onRefresh={handleRefreshAccountInfo}
/>
</>
);
};