221 lines
8.0 KiB
Python
221 lines
8.0 KiB
Python
|
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())
|