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
+
+
+ )}
)}