Better progress tracking for clients that start a new session on every seek instead of reusing existing session.

This commit is contained in:
SergeantPanda 2025-09-06 15:36:14 -05:00
parent 4ca6bf763e
commit c239f0300f
3 changed files with 241 additions and 66 deletions

View file

@ -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}")

View file

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

View file

@ -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 }) => {
</Text>
</Group>
)}
{/* Seek/Position Information */}
{(connection.last_seek_percentage > 0 ||
connection.last_seek_byte > 0) && (
<>
<Group gap={8}>
<Text
size="xs"
fw={500}
color="dimmed"
style={{ minWidth: '80px' }}
>
Last Seek:
</Text>
<Text size="xs">
{connection.last_seek_percentage?.toFixed(1)}%
{connection.total_content_size > 0 && (
<span
style={{ color: 'var(--mantine-color-dimmed)' }}
>
{' '}
(
{Math.round(
connection.last_seek_byte / (1024 * 1024)
)}
MB /{' '}
{Math.round(
connection.total_content_size / (1024 * 1024)
)}
MB)
</span>
)}
</Text>
</Group>
{Number(connection.last_seek_timestamp) > 0 && (
<Group gap={8}>
<Text
size="xs"
fw={500}
color="dimmed"
style={{ minWidth: '80px' }}
>
Seek Time:
</Text>
<Text size="xs">
{dayjs
.unix(Number(connection.last_seek_timestamp))
.fromNow()}
</Text>
</Group>
)}
</>
)}
{connection.bytes_sent > 0 && (
<Group gap={8}>
<Text
size="xs"
fw={500}
color="dimmed"
style={{ minWidth: '80px' }}
>
Data Sent:
</Text>
<Text size="xs">
{(connection.bytes_sent / (1024 * 1024)).toFixed(1)} MB
</Text>
</Group>
)}
</Stack>
)}
</Stack>