chore(cleanup): - Removed dead VODConnectionManager class (apps/proxy/vod_proxy/connection_manager.py) and its associated helpers, which had been superseded by MultiWorkerVODConnectionManager. All active code already used the multi-worker implementation. Removed the unused VODConnectionManager import from vod_proxy/views.py, the unscheduled cleanup_vod_connections task from apps/proxy/tasks.py, and the unscheduled cleanup_vod_persistent_connections task from core/tasks.py.
Some checks are pending
CI Pipeline / prepare (push) Waiting to run
CI Pipeline / docker (amd64, ubuntu-24.04) (push) Blocked by required conditions
CI Pipeline / docker (arm64, ubuntu-24.04-arm) (push) Blocked by required conditions
CI Pipeline / create-manifest (push) Blocked by required conditions
Build and Push Multi-Arch Docker Image / build-and-push (push) Waiting to run
Frontend Tests / test (push) Waiting to run

- Removed dead VOD URL routes: `VODPlaylistView` (playlist generation), `VODPositionView` (position tracking), and the class-based `VODStatsView` (replaced by the existing function-based `vod_stats` view).
- Removed dead `updateVODPosition()` API method from `frontend/src/api.js`, which called the now-removed position tracking endpoint.
This commit is contained in:
SergeantPanda 2026-04-09 19:59:50 -05:00
parent 7f64642003
commit dcefc6541c
7 changed files with 259 additions and 1906 deletions

View file

@ -7,10 +7,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
### Security
- Hardened the HLS proxy `change_stream` endpoint by converting it from a plain Django view to a DRF `@api_view` with `@permission_classes([IsAdmin])`, ensuring the endpoint actually enforces admin-only access. The previous decorator arrangement (`@csrf_exempt` + `@permission_classes`) had no effect on a plain Django view.
### Removed
- Removed dead `VODConnectionManager` class (`apps/proxy/vod_proxy/connection_manager.py`) and its associated helpers, which had been superseded by `MultiWorkerVODConnectionManager`. All active code already used the multi-worker implementation. Removed the unused `VODConnectionManager` import from `vod_proxy/views.py`, the unscheduled `cleanup_vod_connections` task from `apps/proxy/tasks.py`, and the unscheduled `cleanup_vod_persistent_connections` task from `core/tasks.py`.
- Removed dead VOD URL routes: `VODPlaylistView` (playlist generation), `VODPositionView` (position tracking), and the class-based `VODStatsView` (replaced by the existing function-based `vod_stats` view).
- Removed dead `updateVODPosition()` API method from `frontend/src/api.js`, which called the now-removed position tracking endpoint.
### Changed
- Refactored frontend form components (`AccountInfoModal`, `AssignChannelNumbers`, `Channel`, `ChannelBatch`, `ChannelGroup`, `Connection`, `CronBuilder`, `DummyEPG`, and `EPG`) to extract business logic into dedicated utility modules under `src/utils/forms/`. Each extracted module is covered by unit tests. Mantine compound component references (`Table.Tbody`, `Popover.Target`, `Accordion.Item`, etc.) have been updated to use flat named imports. — Thanks [@nick4810](https://github.com/nick4810)
- Improved the EPG BOM fix from v0.22.1: replaced the `lstrip(b'\xef\xbb\xbf')` / `startswith` approach with `start.find(b'<?xml')`, which locates the XML declaration regardless of any leading bytes BOM, whitespace, or other encoding markers without needing to know what those bytes are.
## [0.22.1] - 2026-04-05

View file

@ -1,16 +1,11 @@
# yourapp/tasks.py
from celery import shared_task
from channels.layers import get_channel_layer
from asgiref.sync import async_to_sync
import redis
import json
import logging
import re
import gc # Add import for garbage collection
import gc
from core.utils import RedisClient
from apps.proxy.ts_proxy.channel_status import ChannelStatus
from core.utils import send_websocket_update
from apps.proxy.vod_proxy.connection_manager import get_connection_manager
logger = logging.getLogger(__name__)
@ -61,12 +56,4 @@ def fetch_channel_stats():
all_channels = None
gc.collect()
@shared_task
def cleanup_vod_connections():
"""Clean up stale VOD connections"""
try:
connection_manager = get_connection_manager()
connection_manager.cleanup_stale_connections(max_age_seconds=3600) # 1 hour
logger.info("VOD connection cleanup completed")
except Exception as e:
logger.error(f"Error in VOD connection cleanup: {e}", exc_info=True)

File diff suppressed because it is too large Load diff

View file

@ -13,15 +13,8 @@ urlpatterns = [
path('<str:content_type>/<uuid:content_id>', stream_vod, name='vod_stream'),
path('<str:content_type>/<uuid:content_id>/<int:profile_id>/', stream_vod, name='vod_stream_with_profile'),
# VOD playlist generation
path('playlist/', views.VODPlaylistView.as_view(), name='vod_playlist'),
path('playlist/<int:profile_id>/', views.VODPlaylistView.as_view(), name='vod_playlist_with_profile'),
# Position tracking
path('position/<uuid:content_id>/', views.VODPositionView.as_view(), name='vod_position'),
# VOD Stats
path('stats/', views.VODStatsView.as_view(), name='vod_stats'),
path('stats/', views.vod_stats, name='vod_stats'),
# Stop VOD client connection
path('stop_client/', views.stop_vod_client, name='stop_vod_client'),

View file

@ -7,18 +7,16 @@ import time
import random
import logging
import requests
from django.http import StreamingHttpResponse, JsonResponse, Http404, HttpResponse
from django.http import JsonResponse, Http404, HttpResponse
from django.shortcuts import get_object_or_404
from django.views.decorators.csrf import csrf_exempt
from django.utils.decorators import method_decorator
from django.views import View
from apps.vod.models import Movie, Series, Episode
from apps.m3u.models import M3UAccount, M3UAccountProfile
from apps.proxy.vod_proxy.connection_manager import VODConnectionManager
from apps.m3u.models import M3UAccountProfile
from apps.proxy.vod_proxy.multi_worker_connection_manager import MultiWorkerVODConnectionManager, infer_content_type_from_url, get_vod_client_stop_key
from .utils import get_client_info, create_vod_response
from rest_framework.decorators import api_view
from .utils import get_client_info
from rest_framework.decorators import api_view, permission_classes
from apps.accounts.models import User
from apps.accounts.permissions import IsAdmin
from apps.proxy.utils import check_user_stream_limits
logger = logging.getLogger(__name__)
@ -676,368 +674,267 @@ def head_vod(request, content_type, content_id, session_id=None, profile_id=None
logger.error(f"[VOD-HEAD] Error in HEAD request: {e}", exc_info=True)
return HttpResponse(f"HEAD error: {str(e)}", status=500)
@method_decorator(csrf_exempt, name='dispatch')
class VODPlaylistView(View):
"""Generate M3U playlists for VOD content"""
@api_view(["GET"])
@permission_classes([IsAdmin])
def vod_stats(request):
"""Get current VOD connection statistics"""
try:
connection_manager = MultiWorkerVODConnectionManager.get_instance()
redis_client = connection_manager.redis_client
def get(self, request, profile_id=None):
"""Generate VOD playlist"""
try:
# Get profile if specified
m3u_profile = None
if profile_id:
if not redis_client:
return JsonResponse({'error': 'Redis not available'}, status=500)
# Get all VOD persistent connections (consolidated data)
pattern = "vod_persistent_connection:*"
cursor = 0
connections = []
current_time = time.time()
while True:
cursor, keys = redis_client.scan(cursor, match=pattern, count=100)
for key in keys:
try:
m3u_profile = M3UAccountProfile.objects.get(
id=profile_id,
is_active=True
)
except M3UAccountProfile.DoesNotExist:
return HttpResponse("Profile not found", status=404)
connection_data = redis_client.hgetall(key)
# Generate playlist content
playlist_content = self._generate_playlist(m3u_profile)
if connection_data:
# Extract session ID from key
session_id = key.replace('vod_persistent_connection:', '')
response = HttpResponse(playlist_content, content_type='application/vnd.apple.mpegurl')
response['Content-Disposition'] = 'attachment; filename="vod_playlist.m3u8"'
return response
# Decode Redis hash data
combined_data = {}
for k, v in connection_data.items():
combined_data[k] = v
except Exception as e:
logger.error(f"Error generating VOD playlist: {e}")
return HttpResponse("Playlist generation error", status=500)
# Get content info from the connection data (using correct field names)
content_type = combined_data.get('content_obj_type', 'unknown')
content_uuid = combined_data.get('content_uuid', 'unknown')
client_id = session_id
def _generate_playlist(self, m3u_profile=None):
"""Generate M3U playlist content for VOD"""
lines = ["#EXTM3U"]
# Get content info with enhanced metadata
content_name = "Unknown"
content_metadata = {}
try:
if content_type == 'movie':
content_obj = Movie.objects.select_related('logo').get(uuid=content_uuid)
content_name = content_obj.name
# Add movies
movies = Movie.objects.filter(is_active=True)
if m3u_profile:
movies = movies.filter(m3u_account=m3u_profile.m3u_account)
# Get duration from content object
duration_secs = None
if hasattr(content_obj, 'duration_secs') and content_obj.duration_secs:
duration_secs = content_obj.duration_secs
for movie in movies:
profile_param = f"?profile={m3u_profile.id}" if m3u_profile else ""
lines.append(f'#EXTINF:-1 tvg-id="{movie.tmdb_id}" group-title="Movies",{movie.title}')
lines.append(f'/proxy/vod/movie/{movie.uuid}/{profile_param}')
# If we don't have duration_secs, try to calculate it from file size and position data
if not duration_secs:
file_size_bytes = int(combined_data.get('total_content_size', 0))
last_seek_byte = int(combined_data.get('last_seek_byte', 0))
last_seek_percentage = float(combined_data.get('last_seek_percentage', 0.0))
# Add series
series_list = Series.objects.filter(is_active=True)
if m3u_profile:
series_list = series_list.filter(m3u_account=m3u_profile.m3u_account)
for series in series_list:
for episode in series.episodes.all():
profile_param = f"?profile={m3u_profile.id}" if m3u_profile else ""
episode_title = f"{series.title} - S{episode.season_number:02d}E{episode.episode_number:02d}"
lines.append(f'#EXTINF:-1 tvg-id="{series.tmdb_id}" group-title="Series",{episode_title}')
lines.append(f'/proxy/vod/episode/{episode.uuid}/{profile_param}')
return '\n'.join(lines)
@method_decorator(csrf_exempt, name='dispatch')
class VODPositionView(View):
"""Handle VOD position updates"""
def post(self, request, content_id):
"""Update playback position for VOD content"""
try:
import json
data = json.loads(request.body)
client_id = data.get('client_id')
position = data.get('position', 0)
# Find the content object
content_obj = None
try:
content_obj = Movie.objects.get(uuid=content_id)
except Movie.DoesNotExist:
try:
content_obj = Episode.objects.get(uuid=content_id)
except Episode.DoesNotExist:
return JsonResponse({'error': 'Content not found'}, status=404)
# Here you could store the position in a model or cache
# For now, just return success
logger.info(f"Position update for {content_obj.__class__.__name__} {content_id}: {position}s")
return JsonResponse({
'success': True,
'content_id': str(content_id),
'position': position
})
except Exception as e:
logger.error(f"Error updating VOD position: {e}")
return JsonResponse({'error': str(e)}, status=500)
@method_decorator(csrf_exempt, name='dispatch')
class VODStatsView(View):
"""Get VOD connection statistics"""
def get(self, request):
"""Get current VOD connection statistics"""
try:
connection_manager = MultiWorkerVODConnectionManager.get_instance()
redis_client = connection_manager.redis_client
if not redis_client:
return JsonResponse({'error': 'Redis not available'}, status=500)
# Get all VOD persistent connections (consolidated data)
pattern = "vod_persistent_connection:*"
cursor = 0
connections = []
current_time = time.time()
while True:
cursor, keys = redis_client.scan(cursor, match=pattern, count=100)
for key in keys:
try:
connection_data = redis_client.hgetall(key)
if connection_data:
# Extract session ID from key
session_id = key.replace('vod_persistent_connection:', '')
# Decode Redis hash data
combined_data = {}
for k, v in connection_data.items():
combined_data[k] = v
# Get content info from the connection data (using correct field names)
content_type = combined_data.get('content_obj_type', 'unknown')
content_uuid = combined_data.get('content_uuid', 'unknown')
client_id = session_id
# Get content info with enhanced metadata
content_name = "Unknown"
content_metadata = {}
try:
if content_type == 'movie':
content_obj = Movie.objects.select_related('logo').get(uuid=content_uuid)
content_name = content_obj.name
# Get duration from content object
duration_secs = None
if hasattr(content_obj, 'duration_secs') and content_obj.duration_secs:
duration_secs = content_obj.duration_secs
# If we don't have duration_secs, try to calculate it from file size and position data
if not duration_secs:
file_size_bytes = int(combined_data.get('total_content_size', 0))
last_seek_byte = int(combined_data.get('last_seek_byte', 0))
last_seek_percentage = float(combined_data.get('last_seek_percentage', 0.0))
# Calculate position if we have the required data
if file_size_bytes and file_size_bytes > 0 and last_seek_percentage > 0:
# If we know the seek percentage and current time position, we can estimate duration
# But we need to know the current time position in seconds first
# For now, let's use a rough estimate based on file size and typical bitrates
# This is a fallback - ideally duration should be in the database
estimated_duration = 6000 # 100 minutes as default for movies
duration_secs = estimated_duration
content_metadata = {
'year': content_obj.year,
'rating': content_obj.rating,
'genre': content_obj.genre,
'duration_secs': duration_secs,
'description': content_obj.description,
'logo_url': content_obj.logo.url if content_obj.logo else None,
'tmdb_id': content_obj.tmdb_id,
'imdb_id': content_obj.imdb_id
}
elif content_type == 'episode':
content_obj = Episode.objects.select_related('series', 'series__logo').get(uuid=content_uuid)
content_name = f"{content_obj.series.name} - {content_obj.name}"
# Get duration from content object
duration_secs = None
if hasattr(content_obj, 'duration_secs') and content_obj.duration_secs:
duration_secs = content_obj.duration_secs
# If we don't have duration_secs, estimate for episodes
if not duration_secs:
estimated_duration = 2400 # 40 minutes as default for episodes
# Calculate position if we have the required data
if file_size_bytes and file_size_bytes > 0 and last_seek_percentage > 0:
# If we know the seek percentage and current time position, we can estimate duration
# But we need to know the current time position in seconds first
# For now, let's use a rough estimate based on file size and typical bitrates
# This is a fallback - ideally duration should be in the database
estimated_duration = 6000 # 100 minutes as default for movies
duration_secs = estimated_duration
content_metadata = {
'series_name': content_obj.series.name,
'episode_name': content_obj.name,
'season_number': content_obj.season_number,
'episode_number': content_obj.episode_number,
'air_date': content_obj.air_date.isoformat() if content_obj.air_date else None,
'rating': content_obj.rating,
'duration_secs': duration_secs,
'description': content_obj.description,
'logo_url': content_obj.series.logo.url if content_obj.series.logo else None,
'series_year': content_obj.series.year,
'series_genre': content_obj.series.genre,
'tmdb_id': content_obj.tmdb_id,
'imdb_id': content_obj.imdb_id
}
content_metadata = {
'year': content_obj.year,
'rating': content_obj.rating,
'genre': content_obj.genre,
'duration_secs': duration_secs,
'description': content_obj.description,
'logo_url': content_obj.logo.url if content_obj.logo else None,
'tmdb_id': content_obj.tmdb_id,
'imdb_id': content_obj.imdb_id
}
elif content_type == 'episode':
content_obj = Episode.objects.select_related('series', 'series__logo').get(uuid=content_uuid)
content_name = f"{content_obj.series.name} - {content_obj.name}"
# Get duration from content object
duration_secs = None
if hasattr(content_obj, 'duration_secs') and content_obj.duration_secs:
duration_secs = content_obj.duration_secs
# If we don't have duration_secs, estimate for episodes
if not duration_secs:
estimated_duration = 2400 # 40 minutes as default for episodes
duration_secs = estimated_duration
content_metadata = {
'series_name': content_obj.series.name,
'episode_name': content_obj.name,
'season_number': content_obj.season_number,
'episode_number': content_obj.episode_number,
'air_date': content_obj.air_date.isoformat() if content_obj.air_date else None,
'rating': content_obj.rating,
'duration_secs': duration_secs,
'description': content_obj.description,
'logo_url': content_obj.series.logo.url if content_obj.series.logo else None,
'series_year': content_obj.series.year,
'series_genre': content_obj.series.genre,
'tmdb_id': content_obj.tmdb_id,
'imdb_id': content_obj.imdb_id
}
except:
pass
# Get M3U profile information
m3u_profile_info = {}
m3u_profile_id = combined_data.get('m3u_profile_id')
if m3u_profile_id:
try:
from apps.m3u.models import M3UAccountProfile
profile = M3UAccountProfile.objects.select_related('m3u_account').get(id=m3u_profile_id)
m3u_profile_info = {
'profile_name': profile.name,
'account_name': profile.m3u_account.name,
'account_id': profile.m3u_account.id,
'max_streams': profile.m3u_account.max_streams,
'm3u_profile_id': int(m3u_profile_id)
}
except Exception as e:
logger.warning(f"Could not fetch M3U profile {m3u_profile_id}: {e}")
# Also try to get profile info from stored data if database lookup fails
if not m3u_profile_info and combined_data.get('m3u_profile_name'):
m3u_profile_info = {
'profile_name': combined_data.get('m3u_profile_name', 'Unknown Profile'),
'm3u_profile_id': combined_data.get('m3u_profile_id'),
'account_name': 'Unknown Account' # We don't store account name directly
}
# Calculate estimated current position based on seek percentage or last known position
last_known_position = int(combined_data.get('position_seconds', 0))
last_position_update = combined_data.get('last_position_update')
last_seek_percentage = float(combined_data.get('last_seek_percentage', 0.0))
last_seek_timestamp = float(combined_data.get('last_seek_timestamp', 0.0))
estimated_position = last_known_position
# If we have seek percentage and content duration, calculate position from that
if last_seek_percentage > 0 and content_metadata.get('duration_secs'):
try:
duration_secs = int(content_metadata['duration_secs'])
# Calculate position from seek percentage
seek_position = int((last_seek_percentage / 100) * duration_secs)
# If we have a recent seek timestamp, add elapsed time since seek
if last_seek_timestamp > 0:
elapsed_since_seek = current_time - last_seek_timestamp
# Add elapsed time but don't exceed content duration
estimated_position = min(
seek_position + int(elapsed_since_seek),
duration_secs
)
else:
estimated_position = seek_position
except (ValueError, TypeError):
pass
elif last_position_update and content_metadata.get('duration_secs'):
# Fallback: use time-based estimation from position_seconds
try:
update_timestamp = float(last_position_update)
elapsed_since_update = current_time - update_timestamp
# Add elapsed time to last known position, but don't exceed content duration
estimated_position = min(
last_known_position + int(elapsed_since_update),
int(content_metadata['duration_secs'])
)
except (ValueError, TypeError):
# If timestamp parsing fails, fall back to last known position
estimated_position = last_known_position
connection_info = {
'content_type': content_type,
'content_uuid': content_uuid,
'content_name': content_name,
'content_metadata': content_metadata,
'm3u_profile': m3u_profile_info,
'client_id': client_id,
'client_ip': combined_data.get('client_ip', 'Unknown'),
'user_id': combined_data.get('user_id', '0'),
'user_agent': combined_data.get('client_user_agent', 'Unknown'),
'connected_at': combined_data.get('created_at'),
'last_activity': combined_data.get('last_activity'),
'm3u_profile_id': m3u_profile_id,
'position_seconds': estimated_position, # Use estimated position
'last_known_position': last_known_position, # Include raw position for debugging
'last_position_update': last_position_update, # Include timestamp for frontend use
'bytes_sent': int(combined_data.get('bytes_sent', 0)),
# Seek/range information for position calculation and frontend display
'last_seek_byte': int(combined_data.get('last_seek_byte', 0)),
'last_seek_percentage': float(combined_data.get('last_seek_percentage', 0.0)),
'total_content_size': int(combined_data.get('total_content_size', 0)),
'last_seek_timestamp': float(combined_data.get('last_seek_timestamp', 0.0))
}
# Calculate connection duration
duration_calculated = False
if connection_info['connected_at']:
try:
connected_time = float(connection_info['connected_at'])
duration = current_time - connected_time
connection_info['duration'] = int(duration)
duration_calculated = True
except:
pass
# Get M3U profile information
m3u_profile_info = {}
m3u_profile_id = combined_data.get('m3u_profile_id')
if m3u_profile_id:
try:
from apps.m3u.models import M3UAccountProfile
profile = M3UAccountProfile.objects.select_related('m3u_account').get(id=m3u_profile_id)
m3u_profile_info = {
'profile_name': profile.name,
'account_name': profile.m3u_account.name,
'account_id': profile.m3u_account.id,
'max_streams': profile.m3u_account.max_streams,
'm3u_profile_id': int(m3u_profile_id)
}
except Exception as e:
logger.warning(f"Could not fetch M3U profile {m3u_profile_id}: {e}")
# Fallback: use last_activity if connected_at is not available
if not duration_calculated and connection_info['last_activity']:
try:
last_activity_time = float(connection_info['last_activity'])
# Estimate connection duration using client_id timestamp if available
if connection_info['client_id'].startswith('vod_'):
# Extract timestamp from client_id (format: vod_timestamp_random)
parts = connection_info['client_id'].split('_')
if len(parts) >= 2:
client_start_time = float(parts[1]) / 1000.0 # Convert ms to seconds
duration = current_time - client_start_time
connection_info['duration'] = int(duration)
duration_calculated = True
except:
pass
# Also try to get profile info from stored data if database lookup fails
if not m3u_profile_info and combined_data.get('m3u_profile_name'):
m3u_profile_info = {
'profile_name': combined_data.get('m3u_profile_name', 'Unknown Profile'),
'm3u_profile_id': combined_data.get('m3u_profile_id'),
'account_name': 'Unknown Account' # We don't store account name directly
}
# Final fallback
if not duration_calculated:
connection_info['duration'] = 0
# Calculate estimated current position based on seek percentage or last known position
last_known_position = int(combined_data.get('position_seconds', 0))
last_position_update = combined_data.get('last_position_update')
last_seek_percentage = float(combined_data.get('last_seek_percentage', 0.0))
last_seek_timestamp = float(combined_data.get('last_seek_timestamp', 0.0))
estimated_position = last_known_position
connections.append(connection_info)
# If we have seek percentage and content duration, calculate position from that
if last_seek_percentage > 0 and content_metadata.get('duration_secs'):
try:
duration_secs = int(content_metadata['duration_secs'])
# Calculate position from seek percentage
seek_position = int((last_seek_percentage / 100) * duration_secs)
except Exception as e:
logger.error(f"Error processing connection key {key}: {e}")
# If we have a recent seek timestamp, add elapsed time since seek
if last_seek_timestamp > 0:
elapsed_since_seek = current_time - last_seek_timestamp
# Add elapsed time but don't exceed content duration
estimated_position = min(
seek_position + int(elapsed_since_seek),
duration_secs
)
else:
estimated_position = seek_position
except (ValueError, TypeError):
pass
elif last_position_update and content_metadata.get('duration_secs'):
# Fallback: use time-based estimation from position_seconds
try:
update_timestamp = float(last_position_update)
elapsed_since_update = current_time - update_timestamp
# Add elapsed time to last known position, but don't exceed content duration
estimated_position = min(
last_known_position + int(elapsed_since_update),
int(content_metadata['duration_secs'])
)
except (ValueError, TypeError):
# If timestamp parsing fails, fall back to last known position
estimated_position = last_known_position
if cursor == 0:
break
connection_info = {
'content_type': content_type,
'content_uuid': content_uuid,
'content_name': content_name,
'content_metadata': content_metadata,
'm3u_profile': m3u_profile_info,
'client_id': client_id,
'client_ip': combined_data.get('client_ip', 'Unknown'),
'user_id': combined_data.get('user_id', '0'),
'user_agent': combined_data.get('client_user_agent', 'Unknown'),
'connected_at': combined_data.get('created_at'),
'last_activity': combined_data.get('last_activity'),
'm3u_profile_id': m3u_profile_id,
'position_seconds': estimated_position, # Use estimated position
'last_known_position': last_known_position, # Include raw position for debugging
'last_position_update': last_position_update, # Include timestamp for frontend use
'bytes_sent': int(combined_data.get('bytes_sent', 0)),
# Seek/range information for position calculation and frontend display
'last_seek_byte': int(combined_data.get('last_seek_byte', 0)),
'last_seek_percentage': float(combined_data.get('last_seek_percentage', 0.0)),
'total_content_size': int(combined_data.get('total_content_size', 0)),
'last_seek_timestamp': float(combined_data.get('last_seek_timestamp', 0.0))
}
# Group connections by content
content_stats = {}
for conn in connections:
content_key = f"{conn['content_type']}:{conn['content_uuid']}"
if content_key not in content_stats:
content_stats[content_key] = {
'content_type': conn['content_type'],
'content_name': conn['content_name'],
'content_uuid': conn['content_uuid'],
'content_metadata': conn['content_metadata'],
'connection_count': 0,
'connections': []
}
content_stats[content_key]['connection_count'] += 1
content_stats[content_key]['connections'].append(conn)
# Calculate connection duration
duration_calculated = False
if connection_info['connected_at']:
try:
connected_time = float(connection_info['connected_at'])
duration = current_time - connected_time
connection_info['duration'] = int(duration)
duration_calculated = True
except:
pass
return JsonResponse({
'vod_connections': list(content_stats.values()),
'total_connections': len(connections),
'timestamp': current_time
})
# Fallback: use last_activity if connected_at is not available
if not duration_calculated and connection_info['last_activity']:
try:
last_activity_time = float(connection_info['last_activity'])
# Estimate connection duration using client_id timestamp if available
if connection_info['client_id'].startswith('vod_'):
# Extract timestamp from client_id (format: vod_timestamp_random)
parts = connection_info['client_id'].split('_')
if len(parts) >= 2:
client_start_time = float(parts[1]) / 1000.0 # Convert ms to seconds
duration = current_time - client_start_time
connection_info['duration'] = int(duration)
duration_calculated = True
except:
pass
# Final fallback
if not duration_calculated:
connection_info['duration'] = 0
connections.append(connection_info)
except Exception as e:
logger.error(f"Error processing connection key {key}: {e}")
if cursor == 0:
break
# Group connections by content
content_stats = {}
for conn in connections:
content_key = f"{conn['content_type']}:{conn['content_uuid']}"
if content_key not in content_stats:
content_stats[content_key] = {
'content_type': conn['content_type'],
'content_name': conn['content_name'],
'content_uuid': conn['content_uuid'],
'content_metadata': conn['content_metadata'],
'connection_count': 0,
'connections': []
}
content_stats[content_key]['connection_count'] += 1
content_stats[content_key]['connections'].append(conn)
return JsonResponse({
'vod_connections': list(content_stats.values()),
'total_connections': len(connections),
'timestamp': current_time
})
except Exception as e:
logger.error(f"Error getting VOD stats: {e}")
return JsonResponse({'error': str(e)}, status=500)
from rest_framework.decorators import api_view, permission_classes
from apps.accounts.permissions import IsAdmin
except Exception as e:
logger.error(f"Error getting VOD stats: {e}")
return JsonResponse({'error': str(e)}, status=500)
@csrf_exempt

View file

@ -13,7 +13,7 @@ from apps.epg.models import EPGSource
from apps.m3u.tasks import refresh_single_m3u_account
from apps.epg.tasks import refresh_epg_data
from .models import CoreSettings
from apps.channels.models import Stream, ChannelStream
from apps.channels.models import ChannelStream
from django.db import transaction
logger = logging.getLogger(__name__)
@ -753,20 +753,6 @@ def _determine_stream_to_keep(stream_a, stream_b):
return (stream_b, stream_a)
@shared_task
def cleanup_vod_persistent_connections():
"""Clean up stale VOD persistent connections"""
try:
from apps.proxy.vod_proxy.connection_manager import VODConnectionManager
# Clean up connections older than 30 minutes
VODConnectionManager.cleanup_stale_persistent_connections(max_age_seconds=1800)
logger.info("VOD persistent connection cleanup completed")
except Exception as e:
logger.error(f"Error during VOD persistent connection cleanup: {e}")
@shared_task
def check_for_version_update():
"""

View file

@ -3212,21 +3212,6 @@ export default class API {
}
}
static async updateVODPosition(vodUuid, clientId, position) {
try {
const response = await request(
`${host}/proxy/vod/stream/${vodUuid}/position/`,
{
method: 'POST',
body: { client_id: clientId, position },
}
);
return response;
} catch (e) {
errorNotification('Failed to update playback position', e);
}
}
static async getSystemEvents(limit = 100, offset = 0, eventType = null) {
try {
const params = new URLSearchParams();