mirror of
https://github.com/Dispatcharr/Dispatcharr.git
synced 2026-01-23 02:35:14 +00:00
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:
commit
f218eaad51
12 changed files with 1209 additions and 56 deletions
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"""
|
||||
|
||||
|
|
|
|||
18
apps/m3u/migrations/0018_add_profile_custom_properties.py
Normal file
18
apps/m3u/migrations/0018_add_profile_custom_properties.py
Normal 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),
|
||||
),
|
||||
]
|
||||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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}`
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
438
frontend/src/components/forms/AccountInfoModal.jsx
Normal file
438
frontend/src/components/forms/AccountInfoModal.jsx
Normal 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;
|
||||
|
|
@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue