Ability to refresh account info from account info modal and provide notification on results.

This commit is contained in:
SergeantPanda 2025-09-09 18:15:33 -05:00
parent d1a5143312
commit 84c752761a
8 changed files with 433 additions and 32 deletions

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

@ -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 = [
@ -41,8 +52,9 @@ class M3UAccountProfileSerializer(serializers.ModelSerializer):
"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,33 +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
)
# Get structured account information from XC client
account_info = xc_client.get_account_info()
# Update each profile with account information
# Update each profile with account information using its own transformed credentials
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}")
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 save account information to profiles: {str(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)
@ -2017,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

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

@ -1,4 +1,4 @@
import React from 'react';
import React, { useState } from 'react';
import {
Modal,
Text,
@ -11,7 +11,10 @@ import {
Alert,
Loader,
Center,
ActionIcon,
Tooltip,
} from '@mantine/core';
import { notifications } from '@mantine/notifications';
import {
Info,
Clock,
@ -19,9 +22,58 @@ import {
CheckCircle,
XCircle,
AlertTriangle,
RefreshCw,
} from 'lucide-react';
import API from '../../api';
const AccountInfoModal = ({ isOpen, onClose, profile }) => {
const AccountInfoModal = ({ isOpen, onClose, profile, onRefresh }) => {
const [isRefreshing, setIsRefreshing] = useState(false);
const handleRefresh = async () => {
if (!profile?.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(profile.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 (!profile || !profile.custom_properties) {
return (
<Modal opened={isOpen} onClose={onClose} title="Account Information">
@ -39,7 +91,8 @@ const AccountInfoModal = ({ isOpen, onClose, profile }) => {
);
}
const { user_info, server_info, last_refresh } = profile.custom_properties;
const { user_info, server_info, last_refresh } =
profile.custom_properties || {};
// Helper function to format timestamps
const formatTimestamp = (timestamp) => {
@ -337,13 +390,30 @@ const AccountInfoModal = ({ isOpen, onClose, profile }) => {
borderRadius: 6,
}}
>
<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 spacing="xs" align="center" position="apart">
{/* Show refresh button for XtreamCodes accounts */}
{profile?.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>

View file

@ -28,6 +28,7 @@ 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);
@ -40,6 +41,13 @@ const M3UProfiles = ({ playlist = null, isOpen, onClose }) => {
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 {
// Make sure playlist exists, has an id, and profiles exist for this playlist
@ -348,6 +356,7 @@ This action cannot be undone.`}
isOpen={accountInfoOpen}
onClose={closeAccountInfo}
profile={selectedProfileForInfo}
onRefresh={handleRefreshAccountInfo}
/>
</>
);