import asyncio import os from datetime import datetime from pathlib import Path import cv2 from aiohttp import ClientSession from blinkpy.blinkpy import Blink from blinkpy.auth import Auth import subprocess import logging from typing import List, Dict, Optional import threading import json import re logging.basicConfig(level=logging.INFO) class StreamHealth: def __init__(self): self.bitrate = 0 self.fps = 0 self.dropped_frames = 0 self.last_error: Optional[str] = None self.is_healthy = True self.last_update = datetime.now() class BlinkStreamer: def __init__(self, save_path: str, stream_keys: Dict[str, str]): self.save_path = Path(save_path) self.blink: Blink | None = None self.recording = False self.stream_keys = stream_keys self.stream_health: Dict[str, Dict[str, StreamHealth]] = {} # camera -> platform -> health self.processes: Dict[str, subprocess.Popen] = {} def get_stream_urls(self) -> List[str]: urls = [] if 'youtube' in self.stream_keys: urls.append(f'rtmp://a.rtmp.youtube.com/live2/{self.stream_keys["youtube"]}') if 'twitch' in self.stream_keys: urls.append(f'rtmp://live.twitch.tv/app/{self.stream_keys["twitch"]}') return urls def monitor_ffmpeg_output(self, process: subprocess.Popen, camera_name: str): while process.poll() is None: line = process.stderr.readline().decode() if not line: continue # Update health metrics for each platform for platform in self.stream_keys.keys(): health = self.stream_health[camera_name][platform] # Parse FFMPEG output for health metrics if 'bitrate=' in line: match = re.search(r'bitrate=\s*([\d.]+)', line) if match: health.bitrate = float(match.group(1)) if 'fps=' in line: match = re.search(r'fps=\s*([\d.]+)', line) if match: health.fps = float(match.group(1)) if 'drop=' in line: match = re.search(r'drop=\s*(\d+)', line) if match: health.dropped_frames = int(match.group(1)) # Check for errors if 'Error' in line or 'error' in line: health.last_error = line.strip() health.is_healthy = False logging.error(f"Stream error for {camera_name} on {platform}: {line.strip()}") health.last_update = datetime.now() # Log health status every 60 seconds if health.last_update.second == 0: self.log_health_status(camera_name, platform) def log_health_status(self, camera_name: str, platform: str): health = self.stream_health[camera_name][platform] status = "HEALTHY" if health.is_healthy else "UNHEALTHY" logging.info( f"Stream Health [{camera_name}][{platform}] - Status: {status}\n" f" Bitrate: {health.bitrate:.2f}kbps\n" f" FPS: {health.fps:.1f}\n" f" Dropped Frames: {health.dropped_frames}\n" f" Last Error: {health.last_error or 'None'}" ) async def setup(self) -> None: self.blink = Blink(session=ClientSession()) auth = Auth({ "username": os.getenv("BLINK_USERNAME"), "password": os.getenv("BLINK_PASSWORD") }) self.blink.auth = auth await self.blink.start() async def stream_camera(self, camera, save_path: Path) -> None: # Initialize health monitoring for this camera self.stream_health[camera.name] = { platform: StreamHealth() for platform in self.stream_keys.keys() } while self.recording: timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") video_path = save_path / f"{timestamp}.mp4" stream_url = await camera.get_rtsp_stream() output_urls = self.get_stream_urls() ffmpeg_cmd = [ 'ffmpeg', '-i', stream_url, '-c', 'copy', '-f', 'mp4', str(video_path) ] for url in output_urls: ffmpeg_cmd.extend([ '-c:v', 'libx264', '-preset', 'veryfast', '-maxrate', '2500k', '-bufsize', '5000k', '-framerate', '30', '-g', '60', '-c:a', 'aac', '-b:a', '128k', '-f', 'flv', url ]) try: logging.info(f"Starting stream for camera {camera.name}") process = subprocess.Popen( ffmpeg_cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, universal_newlines=True, bufsize=1 ) self.processes[camera.name] = process # Start monitoring in a separate thread monitor_thread = threading.Thread( target=self.monitor_ffmpeg_output, args=(process, camera.name) ) monitor_thread.daemon = True monitor_thread.start() await asyncio.sleep(3600) # 1 hour segments process.terminate() await asyncio.sleep(2) except Exception as e: logging.error(f"Streaming error for {camera.name}: {e}") # Mark all platforms as unhealthy for this camera for platform in self.stream_keys.keys(): self.stream_health[camera.name][platform].is_healthy = False self.stream_health[camera.name][platform].last_error = str(e) await asyncio.sleep(5) async def start_streaming(self) -> None: if not self.blink: return self.recording = True tasks = [] for name, camera in self.blink.cameras.items(): camera_folder = self.save_path / name camera_folder.mkdir(parents=True, exist_ok=True) tasks.append(asyncio.create_task( self.stream_camera(camera, camera_folder) )) await asyncio.gather(*tasks) async def main(): stream_keys = {} youtube_key = os.getenv("YOUTUBE_STREAM_KEY") twitch_key = os.getenv("TWITCH_STREAM_KEY") if youtube_key: stream_keys['youtube'] = youtube_key if twitch_key: stream_keys['twitch'] = twitch_key if not stream_keys: raise ValueError("At least one stream key must be set (YOUTUBE_STREAM_KEY or TWITCH_STREAM_KEY)") recordings_path = Path("/mnt/external_hd/blink_recordings") streamer = BlinkStreamer( save_path=str(recordings_path), stream_keys=stream_keys ) try: await streamer.setup() platforms = ', '.join(stream_keys.keys()) logging.info(f"Starting stream to: {platforms}") await streamer.start_streaming() except Exception as err: logging.error(f"Error occurred: {err}") finally: streamer.recording = False # Cleanup all processes for process in streamer.processes.values(): process.terminate() if __name__ == "__main__": asyncio.run(main())