From c023cde8fe17e1b886181d8945de08d43f1312c0 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Tue, 9 Sep 2025 16:05:58 -0500 Subject: [PATCH 1/5] Add custom properties to M3UAccountProfile and implement account info retrieval --- apps/m3u/admin.py | 107 +++++++++++++++++- .../0018_add_profile_custom_properties.py | 18 +++ apps/m3u/models.py | 70 ++++++++++++ apps/m3u/serializers.py | 1 + apps/m3u/tasks.py | 27 +++++ core/xtream_codes.py | 41 +++++++ 6 files changed, 263 insertions(+), 1 deletion(-) create mode 100644 apps/m3u/migrations/0018_add_profile_custom_properties.py 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/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..3ce4b1bb 100644 --- a/apps/m3u/serializers.py +++ b/apps/m3u/serializers.py @@ -40,6 +40,7 @@ class M3UAccountProfileSerializer(serializers.ModelSerializer): "current_viewers", "search_pattern", "replace_pattern", + "custom_properties", ] read_only_fields = ["id"] diff --git a/apps/m3u/tasks.py b/apps/m3u/tasks.py index 16391eae..e3a8bd32 100644 --- a/apps/m3u/tasks.py +++ b/apps/m3u/tasks.py @@ -1191,6 +1191,33 @@ def refresh_m3u_groups(account_id, use_cache=False, full_refresh=False): logger.debug(f"Authenticating with XC server {server_url}") auth_result = xc_client.authenticate() logger.debug(f"Authentication response: {auth_result}") + + # Save account information to all active profiles + try: + from apps.m3u.models import M3UAccountProfile + + profiles = M3UAccountProfile.objects.filter( + m3u_account=account, + is_active=True + ) + + # Get structured account information from XC client + account_info = xc_client.get_account_info() + + # Update each profile with account information + for profile in profiles: + # Merge with existing custom_properties if they exist + existing_props = profile.custom_properties or {} + existing_props.update(account_info) + profile.custom_properties = existing_props + profile.save(update_fields=['custom_properties']) + + logger.info(f"Saved account information to {profiles.count()} profiles for account {account.name}") + + except Exception as save_error: + logger.warning(f"Failed to save account information to profiles: {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) diff --git a/core/xtream_codes.py b/core/xtream_codes.py index 6a30b5d4..b4d976c5 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.now().isoformat(), + 'auth_timestamp': datetime.now().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: From c25de2a19116c396cc449381f752f450190746e0 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Tue, 9 Sep 2025 16:29:24 -0500 Subject: [PATCH 2/5] Add AccountInfoModal component and integrate account information display in M3UProfiles --- .../src/components/forms/AccountInfoModal.jsx | 345 ++++++++++++++++++ frontend/src/components/forms/M3UProfiles.jsx | 222 ++++++++--- 2 files changed, 517 insertions(+), 50 deletions(-) create mode 100644 frontend/src/components/forms/AccountInfoModal.jsx diff --git a/frontend/src/components/forms/AccountInfoModal.jsx b/frontend/src/components/forms/AccountInfoModal.jsx new file mode 100644 index 00000000..4c0fba39 --- /dev/null +++ b/frontend/src/components/forms/AccountInfoModal.jsx @@ -0,0 +1,345 @@ +import React from 'react'; +import { + Modal, + Text, + Box, + Group, + Badge, + Table, + Stack, + Divider, + Alert, + Loader, + Center, +} from '@mantine/core'; +import { + Info, + Clock, + Users, + CheckCircle, + XCircle, + AlertTriangle, +} from 'lucide-react'; + +const AccountInfoModal = ({ isOpen, onClose, profile }) => { + if (!profile || !profile.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 } = profile.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) + : new Date(parseInt(timestamp) * 1000); + return date.toLocaleString(); + } 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 - {profile.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 */} + + + + + Last Updated: + + + {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..0371b9cc 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,8 +19,10 @@ 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(); @@ -34,6 +37,8 @@ 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); useEffect(() => { try { @@ -113,6 +118,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 +190,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" + > + + + + )} + + + + + ); + })}