Refactor: PHASE 2: Core Module Extraction
This commit is contained in:
parent
96bedae80a
commit
4e9ae6bcc4
7 changed files with 3684 additions and 0 deletions
572
detector_worker/streams/stream_manager.py
Normal file
572
detector_worker/streams/stream_manager.py
Normal file
|
@ -0,0 +1,572 @@
|
|||
"""
|
||||
Stream lifecycle management and coordination.
|
||||
|
||||
This module provides centralized management of camera streams including
|
||||
lifecycle management, resource allocation, and stream coordination.
|
||||
"""
|
||||
|
||||
import time
|
||||
import queue
|
||||
import logging
|
||||
import threading
|
||||
from typing import Dict, List, Any, Optional, Tuple, Set
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
|
||||
from ..core.constants import (
|
||||
DEFAULT_MAX_STREAMS,
|
||||
SHARED_STREAM_BUFFER_SIZE,
|
||||
DEFAULT_RECONNECT_INTERVAL_SEC,
|
||||
DEFAULT_MAX_RETRIES
|
||||
)
|
||||
from ..core.exceptions import StreamError, create_stream_error
|
||||
from ..streams.frame_reader import create_frame_reader_thread
|
||||
from ..streams.camera_monitor import set_camera_connected
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class StreamInfo:
|
||||
"""Information about a single camera stream."""
|
||||
camera_id: str
|
||||
stream_url: str
|
||||
stream_type: str # "rtsp" or "snapshot"
|
||||
snapshot_interval: Optional[int] = None
|
||||
buffer: Optional[queue.Queue] = None
|
||||
stop_event: Optional[threading.Event] = None
|
||||
thread: Optional[threading.Thread] = None
|
||||
subscribers: Set[str] = field(default_factory=set)
|
||||
created_at: float = field(default_factory=time.time)
|
||||
last_frame_time: Optional[float] = None
|
||||
frame_count: int = 0
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert to dictionary format."""
|
||||
return {
|
||||
"camera_id": self.camera_id,
|
||||
"stream_url": self.stream_url,
|
||||
"stream_type": self.stream_type,
|
||||
"snapshot_interval": self.snapshot_interval,
|
||||
"subscriber_count": len(self.subscribers),
|
||||
"subscribers": list(self.subscribers),
|
||||
"created_at": self.created_at,
|
||||
"last_frame_time": self.last_frame_time,
|
||||
"frame_count": self.frame_count,
|
||||
"is_active": self.thread is not None and self.thread.is_alive()
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class StreamSubscription:
|
||||
"""Information about a stream subscription."""
|
||||
subscription_id: str
|
||||
camera_id: str
|
||||
subscriber_id: str
|
||||
created_at: float = field(default_factory=time.time)
|
||||
last_access: float = field(default_factory=time.time)
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert to dictionary format."""
|
||||
return {
|
||||
"subscription_id": self.subscription_id,
|
||||
"camera_id": self.camera_id,
|
||||
"subscriber_id": self.subscriber_id,
|
||||
"created_at": self.created_at,
|
||||
"last_access": self.last_access
|
||||
}
|
||||
|
||||
|
||||
class StreamManager:
|
||||
"""
|
||||
Manages camera stream lifecycle and resource allocation.
|
||||
|
||||
This class provides centralized management of camera streams including:
|
||||
- Stream lifecycle management (start/stop/restart)
|
||||
- Resource allocation and sharing
|
||||
- Subscriber management
|
||||
- Connection state monitoring
|
||||
"""
|
||||
|
||||
def __init__(self, max_streams: int = DEFAULT_MAX_STREAMS):
|
||||
"""
|
||||
Initialize stream manager.
|
||||
|
||||
Args:
|
||||
max_streams: Maximum number of concurrent streams
|
||||
"""
|
||||
self.max_streams = max_streams
|
||||
self._streams: Dict[str, StreamInfo] = {}
|
||||
self._subscriptions: Dict[str, StreamSubscription] = {}
|
||||
self._lock = None
|
||||
|
||||
def _ensure_thread_safety(self):
|
||||
"""Initialize thread safety if not already done."""
|
||||
if self._lock is None:
|
||||
import threading
|
||||
self._lock = threading.RLock()
|
||||
|
||||
def _connection_state_callback(self, camera_id: str, connected: bool, error_msg: Optional[str] = None):
|
||||
"""Callback for connection state changes."""
|
||||
set_camera_connected(camera_id, connected, error_msg)
|
||||
|
||||
def _create_stream_info(self,
|
||||
camera_id: str,
|
||||
rtsp_url: Optional[str] = None,
|
||||
snapshot_url: Optional[str] = None,
|
||||
snapshot_interval: Optional[int] = None) -> StreamInfo:
|
||||
"""Create StreamInfo object based on stream type."""
|
||||
if snapshot_url and snapshot_interval:
|
||||
return StreamInfo(
|
||||
camera_id=camera_id,
|
||||
stream_url=snapshot_url,
|
||||
stream_type="snapshot",
|
||||
snapshot_interval=snapshot_interval,
|
||||
buffer=queue.Queue(maxsize=SHARED_STREAM_BUFFER_SIZE),
|
||||
stop_event=threading.Event()
|
||||
)
|
||||
elif rtsp_url:
|
||||
return StreamInfo(
|
||||
camera_id=camera_id,
|
||||
stream_url=rtsp_url,
|
||||
stream_type="rtsp",
|
||||
buffer=queue.Queue(maxsize=SHARED_STREAM_BUFFER_SIZE),
|
||||
stop_event=threading.Event()
|
||||
)
|
||||
else:
|
||||
raise ValueError("Must provide either RTSP URL or snapshot URL with interval")
|
||||
|
||||
def create_subscription(self,
|
||||
subscription_id: str,
|
||||
camera_id: str,
|
||||
subscriber_id: str,
|
||||
rtsp_url: Optional[str] = None,
|
||||
snapshot_url: Optional[str] = None,
|
||||
snapshot_interval: Optional[int] = None) -> bool:
|
||||
"""
|
||||
Create a stream subscription.
|
||||
|
||||
Args:
|
||||
subscription_id: Unique subscription identifier
|
||||
camera_id: Camera identifier
|
||||
subscriber_id: Subscriber identifier
|
||||
rtsp_url: RTSP stream URL (for RTSP streams)
|
||||
snapshot_url: HTTP snapshot URL (for snapshot streams)
|
||||
snapshot_interval: Snapshot interval in milliseconds
|
||||
|
||||
Returns:
|
||||
True if subscription was created successfully
|
||||
"""
|
||||
self._ensure_thread_safety()
|
||||
|
||||
with self._lock:
|
||||
try:
|
||||
# Check if subscription already exists
|
||||
if subscription_id in self._subscriptions:
|
||||
logger.warning(f"Subscription {subscription_id} already exists")
|
||||
return False
|
||||
|
||||
# Check stream limit
|
||||
if len(self._streams) >= self.max_streams and camera_id not in self._streams:
|
||||
logger.error(f"Maximum streams ({self.max_streams}) reached, cannot create new stream for camera {camera_id}")
|
||||
return False
|
||||
|
||||
# Create or get existing stream
|
||||
if camera_id not in self._streams:
|
||||
stream_info = self._create_stream_info(
|
||||
camera_id, rtsp_url, snapshot_url, snapshot_interval
|
||||
)
|
||||
self._streams[camera_id] = stream_info
|
||||
|
||||
# Create and start frame reader thread
|
||||
thread = create_frame_reader_thread(
|
||||
camera_id=camera_id,
|
||||
rtsp_url=rtsp_url,
|
||||
snapshot_url=snapshot_url,
|
||||
snapshot_interval=snapshot_interval,
|
||||
buffer=stream_info.buffer,
|
||||
stop_event=stream_info.stop_event,
|
||||
connection_callback=self._connection_state_callback
|
||||
)
|
||||
|
||||
if thread:
|
||||
stream_info.thread = thread
|
||||
thread.start()
|
||||
logger.info(f"Created new {stream_info.stream_type} stream for camera {camera_id}")
|
||||
else:
|
||||
# Clean up failed stream
|
||||
del self._streams[camera_id]
|
||||
return False
|
||||
|
||||
# Add subscriber to stream
|
||||
stream_info = self._streams[camera_id]
|
||||
stream_info.subscribers.add(subscription_id)
|
||||
|
||||
# Create subscription record
|
||||
subscription = StreamSubscription(
|
||||
subscription_id=subscription_id,
|
||||
camera_id=camera_id,
|
||||
subscriber_id=subscriber_id
|
||||
)
|
||||
self._subscriptions[subscription_id] = subscription
|
||||
|
||||
logger.info(f"Created subscription {subscription_id} for camera {camera_id}, subscribers: {len(stream_info.subscribers)}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error creating subscription {subscription_id}: {e}")
|
||||
return False
|
||||
|
||||
def remove_subscription(self, subscription_id: str) -> bool:
|
||||
"""
|
||||
Remove a stream subscription.
|
||||
|
||||
Args:
|
||||
subscription_id: Unique subscription identifier
|
||||
|
||||
Returns:
|
||||
True if subscription was removed successfully
|
||||
"""
|
||||
self._ensure_thread_safety()
|
||||
|
||||
with self._lock:
|
||||
if subscription_id not in self._subscriptions:
|
||||
logger.warning(f"Subscription {subscription_id} not found")
|
||||
return False
|
||||
|
||||
subscription = self._subscriptions[subscription_id]
|
||||
camera_id = subscription.camera_id
|
||||
|
||||
# Remove subscription
|
||||
del self._subscriptions[subscription_id]
|
||||
|
||||
# Remove subscriber from stream if stream exists
|
||||
if camera_id in self._streams:
|
||||
stream_info = self._streams[camera_id]
|
||||
stream_info.subscribers.discard(subscription_id)
|
||||
|
||||
logger.info(f"Removed subscription {subscription_id} for camera {camera_id}, remaining subscribers: {len(stream_info.subscribers)}")
|
||||
|
||||
# Stop stream if no more subscribers
|
||||
if not stream_info.subscribers:
|
||||
self._stop_stream(camera_id)
|
||||
|
||||
return True
|
||||
|
||||
def _stop_stream(self, camera_id: str) -> None:
|
||||
"""Stop a stream and clean up resources."""
|
||||
if camera_id not in self._streams:
|
||||
return
|
||||
|
||||
stream_info = self._streams[camera_id]
|
||||
|
||||
# Signal thread to stop
|
||||
if stream_info.stop_event:
|
||||
stream_info.stop_event.set()
|
||||
|
||||
# Wait for thread to finish
|
||||
if stream_info.thread and stream_info.thread.is_alive():
|
||||
stream_info.thread.join(timeout=5)
|
||||
if stream_info.thread.is_alive():
|
||||
logger.warning(f"Stream thread for camera {camera_id} did not stop gracefully")
|
||||
|
||||
# Clean up
|
||||
del self._streams[camera_id]
|
||||
logger.info(f"Stopped {stream_info.stream_type} stream for camera {camera_id}")
|
||||
|
||||
def get_frame(self, subscription_id: str, timeout: float = 0.1) -> Optional[Any]:
|
||||
"""
|
||||
Get the latest frame for a subscription.
|
||||
|
||||
Args:
|
||||
subscription_id: Unique subscription identifier
|
||||
timeout: Timeout for frame retrieval in seconds
|
||||
|
||||
Returns:
|
||||
Latest frame or None if not available
|
||||
"""
|
||||
self._ensure_thread_safety()
|
||||
|
||||
with self._lock:
|
||||
if subscription_id not in self._subscriptions:
|
||||
return None
|
||||
|
||||
subscription = self._subscriptions[subscription_id]
|
||||
camera_id = subscription.camera_id
|
||||
|
||||
if camera_id not in self._streams:
|
||||
return None
|
||||
|
||||
stream_info = self._streams[camera_id]
|
||||
subscription.last_access = time.time()
|
||||
|
||||
try:
|
||||
frame = stream_info.buffer.get(timeout=timeout)
|
||||
stream_info.last_frame_time = time.time()
|
||||
stream_info.frame_count += 1
|
||||
return frame
|
||||
except queue.Empty:
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting frame for subscription {subscription_id}: {e}")
|
||||
return None
|
||||
|
||||
def is_stream_active(self, camera_id: str) -> bool:
|
||||
"""
|
||||
Check if a stream is active.
|
||||
|
||||
Args:
|
||||
camera_id: Camera identifier
|
||||
|
||||
Returns:
|
||||
True if stream is active
|
||||
"""
|
||||
self._ensure_thread_safety()
|
||||
|
||||
with self._lock:
|
||||
if camera_id not in self._streams:
|
||||
return False
|
||||
|
||||
stream_info = self._streams[camera_id]
|
||||
return stream_info.thread is not None and stream_info.thread.is_alive()
|
||||
|
||||
def get_stream_stats(self, camera_id: str) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Get statistics for a stream.
|
||||
|
||||
Args:
|
||||
camera_id: Camera identifier
|
||||
|
||||
Returns:
|
||||
Stream statistics or None if stream not found
|
||||
"""
|
||||
self._ensure_thread_safety()
|
||||
|
||||
with self._lock:
|
||||
if camera_id not in self._streams:
|
||||
return None
|
||||
|
||||
stream_info = self._streams[camera_id]
|
||||
current_time = time.time()
|
||||
|
||||
stats = stream_info.to_dict()
|
||||
stats["uptime_seconds"] = current_time - stream_info.created_at
|
||||
|
||||
if stream_info.last_frame_time:
|
||||
stats["seconds_since_last_frame"] = current_time - stream_info.last_frame_time
|
||||
|
||||
return stats
|
||||
|
||||
def get_subscription_info(self, subscription_id: str) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Get information about a subscription.
|
||||
|
||||
Args:
|
||||
subscription_id: Unique subscription identifier
|
||||
|
||||
Returns:
|
||||
Subscription information or None if not found
|
||||
"""
|
||||
self._ensure_thread_safety()
|
||||
|
||||
with self._lock:
|
||||
if subscription_id not in self._subscriptions:
|
||||
return None
|
||||
|
||||
return self._subscriptions[subscription_id].to_dict()
|
||||
|
||||
def get_all_streams(self) -> Dict[str, Dict[str, Any]]:
|
||||
"""
|
||||
Get information about all active streams.
|
||||
|
||||
Returns:
|
||||
Dictionary mapping camera IDs to stream information
|
||||
"""
|
||||
self._ensure_thread_safety()
|
||||
|
||||
with self._lock:
|
||||
return {
|
||||
camera_id: stream_info.to_dict()
|
||||
for camera_id, stream_info in self._streams.items()
|
||||
}
|
||||
|
||||
def get_all_subscriptions(self) -> Dict[str, Dict[str, Any]]:
|
||||
"""
|
||||
Get information about all active subscriptions.
|
||||
|
||||
Returns:
|
||||
Dictionary mapping subscription IDs to subscription information
|
||||
"""
|
||||
self._ensure_thread_safety()
|
||||
|
||||
with self._lock:
|
||||
return {
|
||||
sub_id: subscription.to_dict()
|
||||
for sub_id, subscription in self._subscriptions.items()
|
||||
}
|
||||
|
||||
def cleanup_inactive_streams(self, inactive_threshold_seconds: int = 3600) -> int:
|
||||
"""
|
||||
Clean up streams that have been inactive for too long.
|
||||
|
||||
Args:
|
||||
inactive_threshold_seconds: Seconds of inactivity before cleanup
|
||||
|
||||
Returns:
|
||||
Number of streams cleaned up
|
||||
"""
|
||||
self._ensure_thread_safety()
|
||||
|
||||
current_time = time.time()
|
||||
cleanup_count = 0
|
||||
|
||||
with self._lock:
|
||||
streams_to_remove = []
|
||||
|
||||
for camera_id, stream_info in self._streams.items():
|
||||
# Check if stream has subscribers
|
||||
if stream_info.subscribers:
|
||||
continue
|
||||
|
||||
# Check if stream has been inactive
|
||||
last_activity = max(
|
||||
stream_info.created_at,
|
||||
stream_info.last_frame_time or 0
|
||||
)
|
||||
|
||||
if current_time - last_activity > inactive_threshold_seconds:
|
||||
streams_to_remove.append(camera_id)
|
||||
|
||||
for camera_id in streams_to_remove:
|
||||
self._stop_stream(camera_id)
|
||||
cleanup_count += 1
|
||||
logger.info(f"Cleaned up inactive stream for camera {camera_id}")
|
||||
|
||||
if cleanup_count > 0:
|
||||
logger.info(f"Cleaned up {cleanup_count} inactive streams")
|
||||
|
||||
return cleanup_count
|
||||
|
||||
def restart_stream(self, camera_id: str) -> bool:
|
||||
"""
|
||||
Restart a stream.
|
||||
|
||||
Args:
|
||||
camera_id: Camera identifier
|
||||
|
||||
Returns:
|
||||
True if stream was restarted successfully
|
||||
"""
|
||||
self._ensure_thread_safety()
|
||||
|
||||
with self._lock:
|
||||
if camera_id not in self._streams:
|
||||
logger.warning(f"Cannot restart stream for camera {camera_id}: stream not found")
|
||||
return False
|
||||
|
||||
stream_info = self._streams[camera_id]
|
||||
subscribers = stream_info.subscribers.copy()
|
||||
stream_url = stream_info.stream_url
|
||||
stream_type = stream_info.stream_type
|
||||
snapshot_interval = stream_info.snapshot_interval
|
||||
|
||||
# Stop current stream
|
||||
self._stop_stream(camera_id)
|
||||
|
||||
# Recreate stream
|
||||
try:
|
||||
new_stream_info = self._create_stream_info(
|
||||
camera_id,
|
||||
rtsp_url=stream_url if stream_type == "rtsp" else None,
|
||||
snapshot_url=stream_url if stream_type == "snapshot" else None,
|
||||
snapshot_interval=snapshot_interval
|
||||
)
|
||||
new_stream_info.subscribers = subscribers
|
||||
self._streams[camera_id] = new_stream_info
|
||||
|
||||
# Create and start new frame reader thread
|
||||
thread = create_frame_reader_thread(
|
||||
camera_id=camera_id,
|
||||
rtsp_url=stream_url if stream_type == "rtsp" else None,
|
||||
snapshot_url=stream_url if stream_type == "snapshot" else None,
|
||||
snapshot_interval=snapshot_interval,
|
||||
buffer=new_stream_info.buffer,
|
||||
stop_event=new_stream_info.stop_event,
|
||||
connection_callback=self._connection_state_callback
|
||||
)
|
||||
|
||||
if thread:
|
||||
new_stream_info.thread = thread
|
||||
thread.start()
|
||||
logger.info(f"Restarted {stream_type} stream for camera {camera_id}")
|
||||
return True
|
||||
else:
|
||||
# Clean up failed restart
|
||||
del self._streams[camera_id]
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error restarting stream for camera {camera_id}: {e}")
|
||||
return False
|
||||
|
||||
def shutdown_all(self) -> None:
|
||||
"""Shutdown all streams and clean up resources."""
|
||||
self._ensure_thread_safety()
|
||||
|
||||
with self._lock:
|
||||
logger.info("Shutting down all streams...")
|
||||
|
||||
# Stop all streams
|
||||
camera_ids = list(self._streams.keys())
|
||||
for camera_id in camera_ids:
|
||||
self._stop_stream(camera_id)
|
||||
|
||||
# Clear all subscriptions
|
||||
self._subscriptions.clear()
|
||||
|
||||
logger.info("All streams shut down successfully")
|
||||
|
||||
|
||||
# Global stream manager instance
|
||||
stream_manager = StreamManager()
|
||||
|
||||
|
||||
# ===== CONVENIENCE FUNCTIONS =====
|
||||
# These provide a simplified interface for common operations
|
||||
|
||||
def create_stream_subscription(subscription_id: str,
|
||||
camera_id: str,
|
||||
subscriber_id: str,
|
||||
rtsp_url: Optional[str] = None,
|
||||
snapshot_url: Optional[str] = None,
|
||||
snapshot_interval: Optional[int] = None) -> bool:
|
||||
"""Create a stream subscription using global stream manager."""
|
||||
return stream_manager.create_subscription(
|
||||
subscription_id, camera_id, subscriber_id, rtsp_url, snapshot_url, snapshot_interval
|
||||
)
|
||||
|
||||
|
||||
def remove_stream_subscription(subscription_id: str) -> bool:
|
||||
"""Remove a stream subscription using global stream manager."""
|
||||
return stream_manager.remove_subscription(subscription_id)
|
||||
|
||||
|
||||
def get_stream_frame(subscription_id: str, timeout: float = 0.1) -> Optional[Any]:
|
||||
"""Get the latest frame for a subscription using global stream manager."""
|
||||
return stream_manager.get_frame(subscription_id, timeout)
|
||||
|
||||
|
||||
def is_stream_active(camera_id: str) -> bool:
|
||||
"""Check if a stream is active using global stream manager."""
|
||||
return stream_manager.is_stream_active(camera_id)
|
||||
|
||||
|
||||
def get_stream_statistics() -> Dict[str, Any]:
|
||||
"""Get comprehensive stream statistics."""
|
||||
return {
|
||||
"streams": stream_manager.get_all_streams(),
|
||||
"subscriptions": stream_manager.get_all_subscriptions(),
|
||||
"total_streams": len(stream_manager._streams),
|
||||
"total_subscriptions": len(stream_manager._subscriptions),
|
||||
"max_streams": stream_manager.max_streams
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue