nedo-vision-worker-core 0.2.0__py3-none-any.whl → 0.3.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of nedo-vision-worker-core might be problematic. Click here for more details.

Files changed (33) hide show
  1. nedo_vision_worker_core/__init__.py +47 -12
  2. nedo_vision_worker_core/callbacks/DetectionCallbackManager.py +306 -0
  3. nedo_vision_worker_core/callbacks/DetectionCallbackTypes.py +150 -0
  4. nedo_vision_worker_core/callbacks/__init__.py +27 -0
  5. nedo_vision_worker_core/cli.py +24 -34
  6. nedo_vision_worker_core/core_service.py +121 -55
  7. nedo_vision_worker_core/database/DatabaseManager.py +2 -2
  8. nedo_vision_worker_core/detection/BaseDetector.py +2 -1
  9. nedo_vision_worker_core/detection/DetectionManager.py +2 -2
  10. nedo_vision_worker_core/detection/RFDETRDetector.py +23 -5
  11. nedo_vision_worker_core/detection/YOLODetector.py +18 -5
  12. nedo_vision_worker_core/detection/detection_processing/DetectionProcessor.py +1 -1
  13. nedo_vision_worker_core/detection/detection_processing/HumanDetectionProcessor.py +57 -3
  14. nedo_vision_worker_core/detection/detection_processing/PPEDetectionProcessor.py +173 -10
  15. nedo_vision_worker_core/models/ai_model.py +23 -2
  16. nedo_vision_worker_core/pipeline/PipelineProcessor.py +299 -14
  17. nedo_vision_worker_core/pipeline/PipelineSyncThread.py +32 -0
  18. nedo_vision_worker_core/repositories/PPEDetectionRepository.py +18 -15
  19. nedo_vision_worker_core/repositories/RestrictedAreaRepository.py +17 -13
  20. nedo_vision_worker_core/services/SharedVideoStreamServer.py +276 -0
  21. nedo_vision_worker_core/services/VideoSharingDaemon.py +808 -0
  22. nedo_vision_worker_core/services/VideoSharingDaemonManager.py +257 -0
  23. nedo_vision_worker_core/streams/SharedVideoDeviceManager.py +383 -0
  24. nedo_vision_worker_core/streams/StreamSyncThread.py +16 -2
  25. nedo_vision_worker_core/streams/VideoStream.py +267 -246
  26. nedo_vision_worker_core/streams/VideoStreamManager.py +158 -6
  27. nedo_vision_worker_core/tracker/TrackerManager.py +25 -31
  28. nedo_vision_worker_core-0.3.1.dist-info/METADATA +444 -0
  29. {nedo_vision_worker_core-0.2.0.dist-info → nedo_vision_worker_core-0.3.1.dist-info}/RECORD +32 -25
  30. nedo_vision_worker_core-0.2.0.dist-info/METADATA +0 -347
  31. {nedo_vision_worker_core-0.2.0.dist-info → nedo_vision_worker_core-0.3.1.dist-info}/WHEEL +0 -0
  32. {nedo_vision_worker_core-0.2.0.dist-info → nedo_vision_worker_core-0.3.1.dist-info}/entry_points.txt +0 -0
  33. {nedo_vision_worker_core-0.2.0.dist-info → nedo_vision_worker_core-0.3.1.dist-info}/top_level.txt +0 -0
@@ -3,322 +3,343 @@ import cv2
3
3
  import threading
4
4
  import time
5
5
  import logging
6
- from typing import Optional
6
+ from typing import Optional, Union
7
+ from enum import Enum
8
+
9
+
10
+ class StreamState(Enum):
11
+ DISCONNECTED = "disconnected"
12
+ CONNECTING = "connecting"
13
+ CONNECTED = "connected"
14
+ RECONNECTING = "reconnecting"
15
+ STOPPED = "stopped"
7
16
 
8
17
 
9
18
  class VideoStream(threading.Thread):
10
- """Threaded class for capturing video from a source, with automatic reconnection.
19
+ """Thread-safe video capture with automatic reconnection and error handling."""
11
20
 
12
- This class provides a thread-safe way to capture video frames from various sources
13
- (cameras, files, network streams) with automatic reconnection on failure.
14
-
15
- Attributes:
16
- source: The video source (int for webcams, string for files/URLs)
17
- reconnect_interval: Time in seconds to wait before reconnecting on failure
18
- retry_limit: Maximum number of consecutive retries before giving up
19
- """
20
21
  def __init__(
21
22
  self,
22
- source: str,
23
- reconnect_interval: int = 5,
24
- retry_limit: int = 5,
23
+ source: Union[str, int],
24
+ reconnect_interval: float = 5.0,
25
+ max_failures: int = 5,
25
26
  max_reconnect_attempts: int = 10,
26
- reconnect_backoff_factor: float = 1.5
27
+ backoff_factor: float = 1.5,
28
+ target_fps: Optional[float] = None
27
29
  ):
28
- super().__init__()
30
+ super().__init__(daemon=True)
29
31
 
30
- # Stream configuration
31
32
  self.source = source
32
33
  self.reconnect_interval = reconnect_interval
33
- self.retry_limit = retry_limit
34
+ self.max_failures = max_failures
35
+ self.max_reconnect_attempts = max_reconnect_attempts
36
+ self.backoff_factor = backoff_factor
37
+ self.target_fps = target_fps
34
38
 
35
- # Stream state
36
39
  self.capture = None
37
- self.running = True
38
- self.connected = False
39
- self.start_time = time.time()
40
- self.is_file = isinstance(source, (int, str, bytes, os.PathLike)) and os.path.isfile(source)
41
- self.fps = 30 # Default FPS until determined
40
+ self.state = StreamState.DISCONNECTED
41
+ self.fps = 30.0
42
42
  self.frame_count = 0
43
+ self.start_time = time.time()
43
44
 
44
- # Reconnection control
45
- self.max_reconnect_attempts = max_reconnect_attempts
46
- self.reconnect_attempts = 0
47
- self.reconnect_backoff_factor = reconnect_backoff_factor
48
- self.current_reconnect_interval = reconnect_interval
49
-
50
- # Thread synchronization
51
- self.lock = threading.Lock()
45
+ self._running = True
46
+ self._lock = threading.Lock()
52
47
  self._latest_frame = None
48
+ self._reconnect_attempts = 0
49
+ self._current_interval = reconnect_interval
53
50
 
54
- # Start the capture thread
55
- self.start()
56
-
57
- def _initialize_capture(self) -> bool:
58
- """Initialize or reinitialize the capture device.
51
+ self.is_file = self._is_file_source()
52
+
53
+ # Error tracking for diagnostics
54
+ self._recent_errors = []
55
+ self._max_error_history = 50
56
+ self._codec_info = None
59
57
 
60
- Returns:
61
- True if successful, False otherwise
62
- """
58
+ def _is_file_source(self) -> bool:
59
+ """Check if source is a file path."""
60
+ if isinstance(self.source, int):
61
+ return False
62
+ return isinstance(self.source, (str, bytes, os.PathLike)) and os.path.isfile(str(self.source))
63
+
64
+ def _get_source_for_cv2(self) -> Union[str, int]:
65
+ """Convert source to format suitable for cv2.VideoCapture."""
66
+ if isinstance(self.source, str) and self.source.isdigit():
67
+ return int(self.source)
68
+ return self.source
69
+
70
+ def _initialize_capture(self) -> bool:
71
+ """Initialize video capture device."""
63
72
  try:
64
- logging.info(f"🔄 Attempting to connect to stream: {self.source} (attempt {self.reconnect_attempts + 1}/{self.max_reconnect_attempts})")
73
+ self.state = StreamState.CONNECTING
74
+ logging.info(f"Connecting to {self.source} (attempt {self._reconnect_attempts + 1})")
65
75
 
66
- # Clean up existing capture if needed
67
76
  if self.capture:
68
77
  self.capture.release()
69
-
70
- # Create new capture object
71
- self.capture = cv2.VideoCapture(self.source)
78
+
79
+ self.capture = cv2.VideoCapture(self._get_source_for_cv2())
72
80
 
73
81
  if not self.capture.isOpened():
74
- logging.error(f"Failed to open video source: {self.source}")
82
+ logging.error(f"Failed to open video source: {self.source}")
75
83
  return False
76
-
77
- # Get FPS if available or estimate it
78
- self.fps = self.capture.get(cv2.CAP_PROP_FPS)
79
84
 
80
- # Check FPS validity and estimate if needed
81
- if not self.fps or self.fps <= 0 or self.fps > 240:
82
- if self.reconnect_attempts:
83
- return False
84
-
85
- logging.warning(f"⚠️ Invalid FPS reported ({self.fps}). Using default 30 FPS.")
86
- self.fps = 30
87
-
88
- if self.is_file:
89
- total_frames = self.capture.get(cv2.CAP_PROP_FRAME_COUNT)
90
- duration = (total_frames / self.fps) if self.fps > 0 and total_frames > 0 else 0
91
- logging.info(f"✅ Connected to video file {self.source} at {self.fps:.2f} FPS, {total_frames} frames, {duration:.2f}s duration")
92
- else:
93
- logging.info(f"✅ Connected to stream {self.source} at {self.fps:.2f} FPS")
94
-
95
- self.connected = True
85
+ self._configure_capture()
86
+ self.state = StreamState.CONNECTED
96
87
  return True
97
88
 
98
89
  except Exception as e:
99
- logging.error(f"Error initializing capture for {self.source}: {e}")
100
- self.connected = False
101
- if self.capture:
102
- try:
103
- self.capture.release()
104
- except:
105
- pass
106
- self.capture = None
90
+ logging.error(f"Error initializing capture: {e}")
91
+ self._cleanup_capture()
107
92
  return False
108
-
109
- def run(self):
110
- """Main thread loop that continuously captures frames with automatic reconnection."""
111
- retry_count = 0
112
- frame_interval = 0 # Will be calculated once we know the FPS
93
+
94
+ def _configure_capture(self):
95
+ """Configure capture properties and determine FPS."""
96
+ detected_fps = self.capture.get(cv2.CAP_PROP_FPS)
113
97
 
114
- while self.running:
98
+ # Configure error-resilient settings for video streams
99
+ if not self.is_file:
115
100
  try:
116
- # Check if we should exit
117
- if not self.running:
118
- break
101
+ # Set buffer size to reduce latency and handle corrupted frames better
102
+ self.capture.set(cv2.CAP_PROP_BUFFERSIZE, 1)
103
+
104
+ # Set codec-specific properties for better error handling
105
+ # These help with HEVC streams that have QP delta and POC reference errors
106
+ if hasattr(cv2, 'CAP_PROP_FOURCC'):
107
+ # Try to get current codec
108
+ fourcc = self.capture.get(cv2.CAP_PROP_FOURCC)
109
+ codec_str = "".join([chr((int(fourcc) >> 8 * i) & 0xFF) for i in range(4)])
119
110
 
120
- # (Re)connect if needed
121
- if self.capture is None or not self.capture.isOpened():
122
- # Check if we've exceeded the maximum reconnection attempts
123
- if self.reconnect_attempts >= self.max_reconnect_attempts:
124
- logging.error(f"❌ Exceeded maximum reconnection attempts ({self.max_reconnect_attempts}) for {self.source}. Giving up.")
125
- self.running = False
126
- break
111
+ # Log codec information for debugging
112
+ logging.info(f"Stream codec detected: {codec_str} (fourcc: {int(fourcc)})")
113
+ self._codec_info = codec_str
127
114
 
128
- if retry_count:
129
- self.reconnect_attempts += 1
130
-
131
- if not self._initialize_capture():
132
- # Apply exponential backoff to reconnection interval
133
- self.current_reconnect_interval *= self.reconnect_backoff_factor
134
-
135
- logging.warning(f"⚠️ Reconnection attempt {self.reconnect_attempts}/{self.max_reconnect_attempts} "
136
- f"failed. Next attempt in {self.current_reconnect_interval:.1f}s...")
115
+ # For HEVC streams, we can't change the codec but we can optimize buffering
116
+ if 'hevc' in codec_str.lower() or 'h265' in codec_str.lower():
117
+ logging.info("HEVC stream detected - applying error-resilient settings")
118
+ # Reduce buffer to minimize impact of corrupted frames
119
+ self.capture.set(cv2.CAP_PROP_BUFFERSIZE, 1)
137
120
 
138
- # Check for thread stop before sleeping
139
- if not self.running:
121
+ except Exception as e:
122
+ logging.warning(f"Could not configure capture properties: {e}")
123
+
124
+ if self.target_fps:
125
+ self.fps = self.target_fps
126
+ if hasattr(cv2, 'CAP_PROP_FPS'):
127
+ self.capture.set(cv2.CAP_PROP_FPS, self.target_fps)
128
+ elif detected_fps and 0 < detected_fps <= 240:
129
+ self.fps = detected_fps
130
+ else:
131
+ self.fps = 30.0
132
+ logging.warning(f"Invalid FPS detected ({detected_fps}), using {self.fps}")
133
+
134
+ if self.is_file:
135
+ total_frames = self.capture.get(cv2.CAP_PROP_FRAME_COUNT)
136
+ duration = total_frames / self.fps if self.fps > 0 else 0
137
+ logging.info(f"Video file: {self.fps:.1f} FPS, {int(total_frames)} frames, {duration:.1f}s")
138
+ else:
139
+ logging.info(f"Stream connected: {self.fps:.1f} FPS")
140
+
141
+ def _cleanup_capture(self):
142
+ """Clean up capture resources."""
143
+ if self.capture:
144
+ try:
145
+ self.capture.release()
146
+ except Exception as e:
147
+ logging.error(f"Error releasing capture: {e}")
148
+ finally:
149
+ self.capture = None
150
+ self.state = StreamState.DISCONNECTED
151
+
152
+ def _handle_reconnection(self) -> bool:
153
+ """Handle reconnection logic with backoff."""
154
+ if self._reconnect_attempts >= self.max_reconnect_attempts:
155
+ logging.error(f"Max reconnection attempts reached for {self.source}")
156
+ return False
157
+
158
+ self._reconnect_attempts += 1
159
+ self.state = StreamState.RECONNECTING
160
+ self._current_interval = min(self._current_interval * self.backoff_factor, 60.0)
161
+
162
+ logging.warning(f"Reconnecting in {self._current_interval:.1f}s...")
163
+ return self._sleep_interruptible(self._current_interval)
164
+
165
+ def _sleep_interruptible(self, duration: float) -> bool:
166
+ """Sleep with ability to interrupt on stop."""
167
+ end_time = time.time() + duration
168
+ while time.time() < end_time and self._running:
169
+ time.sleep(0.1)
170
+ return self._running
171
+
172
+ def _handle_file_end(self) -> bool:
173
+ """Handle video file reaching end."""
174
+ if not self.is_file:
175
+ return False
176
+
177
+ try:
178
+ current_pos = self.capture.get(cv2.CAP_PROP_POS_FRAMES)
179
+ total_frames = self.capture.get(cv2.CAP_PROP_FRAME_COUNT)
180
+
181
+ if current_pos >= total_frames - 1:
182
+ logging.info(f"Video file ended, restarting: {self.source}")
183
+ self.capture.set(cv2.CAP_PROP_POS_FRAMES, 0)
184
+ return True
185
+ except Exception as e:
186
+ logging.error(f"Error handling file end: {e}")
187
+
188
+ return False
189
+
190
+ def run(self):
191
+ """Main capture loop."""
192
+ failure_count = 0
193
+ frame_interval = 1.0 / self.fps
194
+
195
+ while self._running:
196
+ try:
197
+ if not self.capture or not self.capture.isOpened():
198
+ if not self._initialize_capture():
199
+ if not self._handle_reconnection():
140
200
  break
141
-
142
- time.sleep(self.current_reconnect_interval)
143
201
  continue
144
202
 
145
- # Reset counters on successful connection
146
- retry_count = 0
147
- # Only reset reconnect_attempts if we've been connected for a meaningful duration
148
- # This prevents rapid success/fail cycles from resetting the counter
149
- if self.connected:
150
- self.reconnect_attempts = 0
151
- self.current_reconnect_interval = self.reconnect_interval
152
-
153
- frame_interval = 1.0 / self.fps if self.fps > 0 else 0.033
154
-
155
- # Check if we should exit before reading frame
156
- if not self.running:
157
- break
203
+ failure_count = 0
204
+ self._reconnect_attempts = 0
205
+ self._current_interval = self.reconnect_interval
206
+ frame_interval = 1.0 / self.fps
158
207
 
159
- # Read the next frame
160
- read_start = time.time()
208
+ start_time = time.time()
161
209
  ret, frame = self.capture.read()
162
210
 
163
- # Handle frame read failure
164
211
  if not ret or frame is None or frame.size == 0:
165
- if self.is_file:
166
- # For video files, check if we've reached the end
167
- current_pos = self.capture.get(cv2.CAP_PROP_POS_FRAMES)
168
- total_frames = self.capture.get(cv2.CAP_PROP_FRAME_COUNT)
169
-
170
- # If we're at or beyond the end of the video, restart from beginning
171
- if current_pos >= total_frames - 1:
172
- logging.info(f"🔄 Video file {self.source} reached end. Restarting from beginning...")
173
- self.capture.set(cv2.CAP_PROP_POS_FRAMES, 0)
174
- # Reset retry count for video files when restarting
175
- retry_count = 0
176
- continue
177
- else:
178
- # We're not at the end, this might be a real error
179
- retry_count += 1
180
- logging.warning(f"⚠️ Failed to read frame from {self.source} at position {current_pos}/{total_frames} (attempt {retry_count}/{self.retry_limit})")
181
- else:
182
- # For non-file sources (cameras, streams), increment retry count
183
- retry_count += 1
184
- logging.warning(f"⚠️ Failed to read frame from {self.source} (attempt {retry_count}/{self.retry_limit})")
212
+ if self._handle_file_end():
213
+ continue
185
214
 
186
- if retry_count > self.retry_limit:
187
- logging.error(f"❌ Too many consecutive frame failures. Reconnecting...")
188
- self.connected = False
189
- if self.capture and self.running: # Only release if we're still running
190
- try:
191
- self.capture.release()
192
- self.capture = None
193
- except Exception as e:
194
- logging.error(f"Error releasing capture during failure: {e}")
215
+ failure_count += 1
216
+ if failure_count > self.max_failures:
217
+ logging.error("Too many consecutive failures, reconnecting...")
218
+ self._cleanup_capture()
219
+ failure_count = 0
195
220
  continue
196
221
 
197
- if not self.running:
222
+ if not self._sleep_interruptible(0.1):
198
223
  break
199
-
200
- time.sleep(0.1)
201
224
  continue
202
225
 
203
- # Reset retry count on successful frame
204
- retry_count = 0
226
+ failure_count = 0
205
227
  self.frame_count += 1
206
228
 
207
- # Store the frame - but make sure we're still running first
208
- if not self.running:
209
- break
210
-
211
- with self.lock:
212
- self._latest_frame = frame.copy()
229
+ with self._lock:
230
+ if self._running:
231
+ self._latest_frame = frame.copy()
213
232
 
214
- # Regulate frame rate to match source FPS for efficiency
215
- # This helps prevent CPU overuse when reading from fast sources
216
- elapsed = time.time() - read_start
233
+ elapsed = time.time() - start_time
217
234
  sleep_time = max(0, frame_interval - elapsed)
218
- if sleep_time > 0 and self.running: # Check running before sleep
219
- time.sleep(sleep_time)
235
+ if sleep_time > 0 and not self._sleep_interruptible(sleep_time):
236
+ break
220
237
 
221
- except cv2.error as cv_err:
222
- logging.error(f"OpenCV error in frame processing: {cv_err}")
223
- self.connected = False
224
- if not self.running:
238
+ except cv2.error as e:
239
+ error_msg = f"OpenCV error: {e}"
240
+ logging.error(error_msg)
241
+ self._add_error_to_history(error_msg)
242
+ self._cleanup_capture()
243
+ if not self._sleep_interruptible(1.0):
225
244
  break
226
- time.sleep(1) # Brief pause before retry
227
-
245
+
228
246
  except Exception as e:
229
- logging.error(f" Error in VideoStream {self.source}: {e}", exc_info=True)
230
- self.connected = False
231
- if not self.running:
247
+ error_msg = f"Unexpected error: {e}"
248
+ logging.error(error_msg, exc_info=True)
249
+ self._add_error_to_history(error_msg)
250
+ if not self._sleep_interruptible(self.reconnect_interval):
232
251
  break
233
- time.sleep(self.reconnect_interval)
234
252
 
235
- # Final cleanup
236
- self._cleanup()
237
-
238
- def _cleanup(self):
239
- """Release resources and perform cleanup."""
240
- try:
241
- if self.capture:
242
- self.capture.release()
243
- self.capture = None
244
-
245
- with self.lock:
246
- self._latest_frame = None
247
-
248
- self.connected = False
249
- logging.info(f"🔴 Stream {self.source} stopped and cleaned up.")
250
-
251
- except Exception as e:
252
- logging.error(f"Error during final VideoStream cleanup: {e}")
253
-
254
- def get_frame(self) -> Optional[cv2.Mat]:
255
- """Returns the latest available frame (thread-safe).
253
+ self._final_cleanup()
254
+
255
+ def _final_cleanup(self):
256
+ """Final resource cleanup."""
257
+ self.state = StreamState.STOPPED
258
+ self._cleanup_capture()
259
+
260
+ with self._lock:
261
+ self._latest_frame = None
256
262
 
257
- Returns:
258
- A copy of the latest frame or None if no frames are available
259
- """
260
- if not self.running:
263
+ logging.info(f"VideoStream stopped: {self.source}")
264
+
265
+ def get_frame(self) -> Optional[cv2.Mat]:
266
+ """Get the latest frame (thread-safe)."""
267
+ if not self._running or self.state not in (StreamState.CONNECTED, StreamState.RECONNECTING):
261
268
  return None
262
269
 
263
- with self.lock:
264
- if self._latest_frame is not None:
265
- return self._latest_frame.copy()
266
- return None
267
-
270
+ with self._lock:
271
+ return self._latest_frame.copy() if self._latest_frame is not None else None
272
+
273
+ def is_connected(self) -> bool:
274
+ """Check if stream is currently connected."""
275
+ return self.state == StreamState.CONNECTED
276
+
277
+ @property
278
+ def running(self) -> bool:
279
+ """Check if stream is currently running."""
280
+ return self._running and self.state != StreamState.STOPPED
281
+
282
+ def get_state(self) -> StreamState:
283
+ """Get current stream state."""
284
+ return self.state
285
+
268
286
  def is_video_ended(self) -> bool:
269
- """Check if a video file has reached its end (only for video files).
270
-
271
- Returns:
272
- True if video file has ended, False otherwise
273
- """
274
- if not self.is_file or not self.capture or not self.capture.isOpened():
287
+ """Check if video file has ended."""
288
+ if not self.is_file or not self.capture:
275
289
  return False
276
-
290
+
277
291
  try:
278
292
  current_pos = self.capture.get(cv2.CAP_PROP_POS_FRAMES)
279
293
  total_frames = self.capture.get(cv2.CAP_PROP_FRAME_COUNT)
280
294
  return current_pos >= total_frames - 1
281
295
  except Exception:
282
296
  return False
283
-
284
- def stop(self):
285
- """Stops the video stream and releases resources safely."""
286
- if not self.running: # Prevent multiple stops
287
- return
288
297
 
289
- # Step 1: Signal the thread to stop
290
- self.running = False
298
+ def stop(self, timeout: float = 5.0):
299
+ """Stop the video stream gracefully."""
300
+ if not self._running:
301
+ return
291
302
 
292
- # Step 2: Wait for any ongoing OpenCV operations to complete
293
- time.sleep(0.2) # Short delay to let operations finish
303
+ logging.info(f"Stopping VideoStream: {self.source}")
304
+ self._running = False
294
305
 
295
- # Step 3: Explicitly release capture device before joining thread
296
- # This is critical to avoid segmentation faults in some OpenCV implementations
297
- if self.capture:
298
- try:
299
- logging.debug(f"Releasing capture device for {self.source}")
300
- with self.lock: # Use lock to ensure thread isn't accessing capture during release
301
- if self.capture:
302
- self.capture.release()
303
- self.capture = None
304
- except Exception as e:
305
- logging.error(f"Error releasing capture device: {e}")
306
-
307
- # Step 4: Clear any references to frames to help garbage collection
308
- with self.lock:
306
+ with self._lock:
309
307
  self._latest_frame = None
310
-
311
- # Step 5: Wait for thread to exit
312
- try:
313
- if self.is_alive():
314
- logging.debug(f"Waiting for thread to exit for stream {self.source}")
315
- self.join(timeout=30) # Reduced timeout since we already released resources
316
-
317
- # Check if thread is still alive
318
- if self.is_alive():
319
- logging.warning(f"⚠️ Stream {self.source} thread did not exit cleanly within timeout")
320
- except Exception as e:
321
- logging.error(f"Error joining thread: {e}")
322
308
 
323
- # Final status update
324
- self.connected = False
309
+ if self.is_alive():
310
+ self.join(timeout=timeout)
311
+ if self.is_alive():
312
+ logging.warning(f"Stream thread did not exit within {timeout}s")
313
+
314
+ def __enter__(self):
315
+ """Context manager entry."""
316
+ self.start()
317
+ return self
318
+
319
+ def __exit__(self, exc_type, exc_val, exc_tb):
320
+ """Context manager exit."""
321
+ self.stop()
322
+
323
+ def _add_error_to_history(self, error_msg: str):
324
+ """Add error to recent error history for diagnostics."""
325
+ with self._lock:
326
+ self._recent_errors.append({
327
+ 'timestamp': time.time(),
328
+ 'error': error_msg
329
+ })
330
+ # Keep only recent errors
331
+ if len(self._recent_errors) > self._max_error_history:
332
+ self._recent_errors = self._recent_errors[-self._max_error_history:]
333
+
334
+ def get_recent_errors(self, max_age_seconds: float = 300) -> list:
335
+ """Get recent errors within the specified time window."""
336
+ current_time = time.time()
337
+ with self._lock:
338
+ return [
339
+ err for err in self._recent_errors
340
+ if current_time - err['timestamp'] <= max_age_seconds
341
+ ]
342
+
343
+ def get_codec_info(self) -> Optional[str]:
344
+ """Get codec information for the stream."""
345
+ return self._codec_info