mirror of
https://github.com/Dispatcharr/Dispatcharr.git
synced 2026-01-23 02:35:14 +00:00
Better progress tracking for clients that start a new session on every seek instead of reusing existing session.
This commit is contained in:
parent
4ca6bf763e
commit
c239f0300f
3 changed files with 241 additions and 66 deletions
|
|
@ -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}")
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue