diff --git a/apps/proxy/vod_proxy/connection_manager.py b/apps/proxy/vod_proxy/connection_manager.py
index b12996cf..dea5759b 100644
--- a/apps/proxy/vod_proxy/connection_manager.py
+++ b/apps/proxy/vod_proxy/connection_manager.py
@@ -475,7 +475,8 @@ class VODConnectionManager:
"connected_at": str(time.time()),
"last_activity": str(time.time()),
"bytes_sent": "0",
- "position_seconds": "0"
+ "position_seconds": "0",
+ "last_position_update": str(time.time())
}
# Use pipeline for atomic operations
@@ -1079,7 +1080,7 @@ class VODConnectionManager:
if range_header:
logger.info(f"[{client_id}] *** CLIENT RANGE REQUEST: {range_header} ***")
- # Parse range for logging
+ # Parse range for seeking detection
try:
if 'bytes=' in range_header:
range_part = range_header.replace('bytes=', '')
diff --git a/apps/proxy/vod_proxy/views.py b/apps/proxy/vod_proxy/views.py
index 8f17120e..204dca7d 100644
--- a/apps/proxy/vod_proxy/views.py
+++ b/apps/proxy/vod_proxy/views.py
@@ -199,6 +199,55 @@ class VODStreamView(View):
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(
@@ -760,6 +809,24 @@ 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
+ last_known_position = int(combined_data.get('position_seconds', 0))
+ last_position_update = combined_data.get('last_position_update')
+ estimated_position = last_known_position
+
+ if last_position_update and content_metadata.get('duration_secs'):
+ 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,
@@ -771,7 +838,11 @@ class VODStatsView(View):
'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
+ '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))
}
# Calculate connection duration
diff --git a/frontend/src/pages/Stats.jsx b/frontend/src/pages/Stats.jsx
index 160ca132..ea790761 100644
--- a/frontend/src/pages/Stats.jsx
+++ b/frontend/src/pages/Stats.jsx
@@ -8,6 +8,7 @@ import {
Container,
Flex,
Group,
+ Progress,
SimpleGrid,
Stack,
Text,
@@ -152,6 +153,43 @@ const VODCard = ({ vodContent }) => {
);
};
+ // Calculate progress percentage and time
+ const calculateProgress = useCallback(() => {
+ if (!connection || !metadata.duration_secs) {
+ return {
+ percentage: 0,
+ currentTime: 0,
+ totalTime: metadata.duration_secs || 0,
+ };
+ }
+
+ const positionSeconds = connection.position_seconds || 0;
+ const totalSeconds = metadata.duration_secs;
+ const percentage =
+ totalSeconds > 0 ? (positionSeconds / totalSeconds) * 100 : 0;
+
+ return {
+ percentage: Math.min(percentage, 100), // Cap at 100%
+ currentTime: positionSeconds,
+ totalTime: totalSeconds,
+ };
+ }, [connection, metadata.duration_secs]);
+
+ // Format time for display (e.g., "1:23:45" or "23:45")
+ const formatTime = (seconds) => {
+ if (!seconds || seconds === 0) return '0:00';
+
+ const hours = Math.floor(seconds / 3600);
+ const minutes = Math.floor((seconds % 3600) / 60);
+ const secs = seconds % 60;
+
+ if (hours > 0) {
+ return `${hours}:${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
+ } else {
+ return `${minutes}:${secs.toString().padStart(2, '0')}`;
+ }
+ };
+
// Calculate duration for connection
const calculateConnectionDuration = useCallback((connection) => {
// If duration is provided by API, use it
@@ -326,6 +364,37 @@ const VODCard = ({ vodContent }) => {
)}
+ {/* Progress bar - show current position in content */}
+ {connection &&
+ metadata.duration_secs &&
+ (() => {
+ const progress = calculateProgress();
+ return progress.totalTime > 0 ? (
+
+
+
+ Progress
+
+
+ {formatTime(progress.currentTime)} /{' '}
+ {formatTime(progress.totalTime)}
+
+
+
+
+ {progress.percentage.toFixed(1)}% watched
+
+
+ ) : null;
+ })()}
+
{/* Client information section - collapsible like channel cards */}
{connection && (