hackerhouse/blink-recorder/stream_recorder.py

221 lines
8.1 KiB
Python
Raw Normal View History

2025-01-17 15:32:32 +00:00
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)")
2025-01-18 16:25:08 +00:00
recordings_path = Path("/media/lumhq/Seagate Portable Drive/blink_recordings")
2025-01-17 15:32:32 +00:00
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())