From c239f0300ff2f47946bba0a7f934e237e04f17a9 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Sat, 6 Sep 2025 15:36:14 -0500 Subject: [PATCH] Better progress tracking for clients that start a new session on every seek instead of reusing existing session. --- .../multi_worker_connection_manager.py | 74 +++++++++-- apps/proxy/vod_proxy/views.py | 119 ++++++++++-------- frontend/src/pages/Stats.jsx | 114 ++++++++++++++++- 3 files changed, 241 insertions(+), 66 deletions(-) diff --git a/apps/proxy/vod_proxy/multi_worker_connection_manager.py b/apps/proxy/vod_proxy/multi_worker_connection_manager.py index 23fe5222..d40ed152 100644 --- a/apps/proxy/vod_proxy/multi_worker_connection_manager.py +++ b/apps/proxy/vod_proxy/multi_worker_connection_manager.py @@ -60,6 +60,12 @@ class SerializableConnectionState: self.bytes_sent = 0 self.position_seconds = 0 + # Range/seek tracking for position calculation + self.last_seek_byte = 0 + self.last_seek_percentage = 0.0 + self.total_content_size = 0 + self.last_seek_timestamp = 0.0 + def to_dict(self): """Convert to dictionary for Redis storage""" return { @@ -87,7 +93,12 @@ class SerializableConnectionState: 'created_at': str(self.created_at), # Additional tracking fields 'bytes_sent': str(self.bytes_sent), - 'position_seconds': str(self.position_seconds) + 'position_seconds': str(self.position_seconds), + # Range/seek tracking + 'last_seek_byte': str(self.last_seek_byte), + 'last_seek_percentage': str(self.last_seek_percentage), + 'total_content_size': str(self.total_content_size), + 'last_seek_timestamp': str(self.last_seek_timestamp) } @classmethod @@ -120,6 +131,11 @@ class SerializableConnectionState: # Additional tracking fields obj.bytes_sent = int(data.get('bytes_sent', 0)) obj.position_seconds = int(data.get('position_seconds', 0)) + # Range/seek tracking + obj.last_seek_byte = int(data.get('last_seek_byte', 0)) + obj.last_seek_percentage = float(data.get('last_seek_percentage', 0.0)) + obj.total_content_size = int(data.get('total_content_size', 0)) + obj.last_seek_timestamp = float(data.get('last_seek_timestamp', 0.0)) return obj @@ -420,7 +436,12 @@ class RedisBackedVODConnection: 'bytes_sent': state.bytes_sent, 'position_seconds': state.position_seconds, 'active_streams': state.active_streams, - 'request_count': state.request_count + 'request_count': state.request_count, + # Range/seek tracking + 'last_seek_byte': state.last_seek_byte, + 'last_seek_percentage': state.last_seek_percentage, + 'total_content_size': state.total_content_size, + 'last_seek_timestamp': state.last_seek_timestamp } return {} @@ -751,12 +772,51 @@ class MultiWorkerVODConnectionManager: if '-' in range_part: start_byte, end_byte = range_part.split('-', 1) start = int(start_byte) if start_byte else 0 - end = int(end_byte) if end_byte else int(connection_headers['content_length']) - 1 - total_size = int(connection_headers['content_length']) - content_range = f"bytes {start}-{end}/{total_size}" - response['Content-Range'] = content_range - logger.info(f"[{client_id}] Worker {self.worker_id} - Set Content-Range: {content_range}") + # Get the FULL content size from the connection state (from initial request) + state = redis_connection._get_connection_state() + if state and state.content_length: + full_content_size = int(state.content_length) + end = int(end_byte) if end_byte else full_content_size - 1 + + # Content-Range should show full file size per HTTP standards + content_range = f"bytes {start}-{end}/{full_content_size}" + response['Content-Range'] = content_range + logger.info(f"[{client_id}] Worker {self.worker_id} - Set Content-Range: {content_range}") + + # Store range information for the VOD stats API to calculate position + if start > 0: + try: + position_percentage = (start / full_content_size) * 100 + current_timestamp = time.time() + + # Update the Redis connection state with seek information + if redis_connection._acquire_lock(): + try: + # Refresh state in case it changed + state = redis_connection._get_connection_state() + if state: + # Store range/seek information for stats API + state.last_seek_byte = start + state.last_seek_percentage = position_percentage + state.total_content_size = full_content_size + state.last_seek_timestamp = current_timestamp + state.last_activity = current_timestamp + redis_connection._save_connection_state(state) + logger.info(f"[{client_id}] *** SEEK INFO STORED *** {position_percentage:.1f}% at byte {start:,}/{full_content_size:,} (timestamp: {current_timestamp})") + finally: + redis_connection._release_lock() + else: + logger.warning(f"[{client_id}] Could not acquire lock to update seek info") + except Exception as pos_e: + logger.error(f"[{client_id}] Error storing seek info: {pos_e}") + else: + # Fallback to partial content size if full size not available + partial_size = int(connection_headers['content_length']) + end = int(end_byte) if end_byte else partial_size - 1 + content_range = f"bytes {start}-{end}/{partial_size}" + response['Content-Range'] = content_range + logger.warning(f"[{client_id}] Using partial content size for Content-Range (full size not available): {content_range}") except Exception as e: logger.warning(f"[{client_id}] Worker {self.worker_id} - Could not set Content-Range: {e}") diff --git a/apps/proxy/vod_proxy/views.py b/apps/proxy/vod_proxy/views.py index 204dca7d..79d540fd 100644 --- a/apps/proxy/vod_proxy/views.py +++ b/apps/proxy/vod_proxy/views.py @@ -198,56 +198,6 @@ class VODStreamView(View): # Get connection manager (Redis-backed for multi-worker support) connection_manager = MultiWorkerVODConnectionManager.get_instance() - - # Calculate and update position if this is a range request (seeking) - if range_header and session_id: - try: - if 'bytes=' in range_header: - range_part = range_header.replace('bytes=', '') - if '-' in range_part: - start_byte, end_byte = range_part.split('-', 1) - if start_byte and int(start_byte) > 0: - # Get file size and duration for position calculation - file_size_bytes = None - duration_secs = None - - # Try to get file size from Redis (persistent connection) - try: - import redis - redis_client = redis.StrictRedis(host='localhost', port=6379, db=0, decode_responses=True) - persistent_conn_key = f"vod_persistent_connection:{session_id}" - - if redis_client.exists(persistent_conn_key): - conn_data = redis_client.hgetall(persistent_conn_key) - if conn_data: - content_length = conn_data.get('content_length') - if content_length: - file_size_bytes = int(content_length) - except Exception as e: - logger.warning(f"[VOD-POSITION] Could not get file size from Redis: {e}") - - # Get duration from content object - if hasattr(content_obj, 'duration_secs') and content_obj.duration_secs: - duration_secs = content_obj.duration_secs - - # Calculate position if we have the required data - if file_size_bytes and file_size_bytes > 0 and duration_secs and duration_secs > 0: - position_percentage = (int(start_byte) / file_size_bytes) * 100 - position_seconds = int((position_percentage / 100) * duration_secs) - current_timestamp = time.time() - - # Update persistent connection position with timestamp - try: - redis_client.hset(persistent_conn_key, mapping={ - "position_seconds": str(position_seconds), - "last_position_update": str(current_timestamp) - }) - logger.info(f"[VOD-POSITION] Updated position: {position_seconds}s ({position_percentage:.1f}%)") - except Exception as redis_e: - logger.error(f"[VOD-POSITION] Failed to update position: {redis_e}") - except Exception as e: - logger.warning(f"[VOD-POSITION] Position calculation error: {e}") - # Stream the content with session-based connection reuse logger.info("[VOD-STREAM] Calling connection manager to stream content") response = connection_manager.stream_content_with_session( @@ -753,11 +703,32 @@ class VODStatsView(View): 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': content_obj.duration_secs, + '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, @@ -766,6 +737,17 @@ class VODStatsView(View): 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, @@ -773,7 +755,7 @@ class VODStatsView(View): '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': content_obj.duration_secs, + '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, @@ -809,12 +791,34 @@ class VODStatsView(View): 'account_name': 'Unknown Account' # We don't store account name directly } - # Calculate estimated current position based on last known position + elapsed time + # 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 last_position_update and content_metadata.get('duration_secs'): + # 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 @@ -842,7 +846,12 @@ class VODStatsView(View): '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)) + '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 diff --git a/frontend/src/pages/Stats.jsx b/frontend/src/pages/Stats.jsx index ea790761..52c31656 100644 --- a/frontend/src/pages/Stats.jsx +++ b/frontend/src/pages/Stats.jsx @@ -89,6 +89,7 @@ const VODCard = ({ vodContent }) => { const [dateFormatSetting] = useLocalStorage('date-format', 'mdy'); const dateFormat = dateFormatSetting === 'mdy' ? 'MM/DD' : 'DD/MM'; const [isClientExpanded, setIsClientExpanded] = useState(false); + const [, setUpdateTrigger] = useState(0); // Force re-renders for progress updates // Get metadata from the VOD content const metadata = vodContent.content_metadata || {}; @@ -96,6 +97,15 @@ const VODCard = ({ vodContent }) => { const isMovie = contentType === 'movie'; const isEpisode = contentType === 'episode'; + // Set up timer to update progress every second + useEffect(() => { + const interval = setInterval(() => { + setUpdateTrigger((prev) => prev + 1); + }, 1000); + + return () => clearInterval(interval); + }, []); + // Get the individual connection (since we now separate cards per connection) const connection = vodContent.individual_connection || @@ -163,14 +173,40 @@ const VODCard = ({ vodContent }) => { }; } - const positionSeconds = connection.position_seconds || 0; const totalSeconds = metadata.duration_secs; - const percentage = - totalSeconds > 0 ? (positionSeconds / totalSeconds) * 100 : 0; + let percentage = 0; + let currentTime = 0; + const now = Date.now() / 1000; // Current time in seconds + + // Priority 1: Use last_seek_percentage if available (most accurate from range requests) + if ( + connection.last_seek_percentage && + connection.last_seek_percentage > 0 && + connection.last_seek_timestamp + ) { + // Calculate the position at the time of seek + const seekPosition = Math.round( + (connection.last_seek_percentage / 100) * totalSeconds + ); + + // Add elapsed time since the seek + const elapsedSinceSeek = now - connection.last_seek_timestamp; + currentTime = seekPosition + Math.floor(elapsedSinceSeek); + + // Don't exceed the total duration + currentTime = Math.min(currentTime, totalSeconds); + + percentage = (currentTime / totalSeconds) * 100; + } + // Priority 2: Use position_seconds if available + else if (connection.position_seconds && connection.position_seconds > 0) { + currentTime = connection.position_seconds; + percentage = (currentTime / totalSeconds) * 100; + } return { percentage: Math.min(percentage, 100), // Cap at 100% - currentTime: positionSeconds, + currentTime: Math.max(0, currentTime), // Don't go negative totalTime: totalSeconds, }; }, [connection, metadata.duration_secs]); @@ -514,6 +550,76 @@ const VODCard = ({ vodContent }) => { )} + + {/* Seek/Position Information */} + {(connection.last_seek_percentage > 0 || + connection.last_seek_byte > 0) && ( + <> + + + Last Seek: + + + {connection.last_seek_percentage?.toFixed(1)}% + {connection.total_content_size > 0 && ( + + {' '} + ( + {Math.round( + connection.last_seek_byte / (1024 * 1024) + )} + MB /{' '} + {Math.round( + connection.total_content_size / (1024 * 1024) + )} + MB) + + )} + + + + {Number(connection.last_seek_timestamp) > 0 && ( + + + Seek Time: + + + {dayjs + .unix(Number(connection.last_seek_timestamp)) + .fromNow()} + + + )} + + )} + + {connection.bytes_sent > 0 && ( + + + Data Sent: + + + {(connection.bytes_sent / (1024 * 1024)).toFixed(1)} MB + + + )} )}