diff --git a/squashkiwi-streaming/_paths.sh b/squashkiwi-streaming/_paths.sh new file mode 100755 index 0000000..ac173ae --- /dev/null +++ b/squashkiwi-streaming/_paths.sh @@ -0,0 +1,14 @@ +#!/bin/bash +# Path configuration for squashkiwi-streaming + +# get_squashkiwi_streaming_paths() { +# echo "${LOCAL_DATA_FOLDER}/config" +# echo "${LOCAL_DATA_FOLDER}/recordings" +# echo "${LOCAL_DATA_FOLDER}/config/overlay" +# echo "${LOCAL_DATA_FOLDER}/config/web" +# } + + +get_squashkiwi_streaming_paths() { + echo "path:localpath:${LOCAL_DATA_FOLDER}" +} \ No newline at end of file diff --git a/squashkiwi-streaming/backup.sh b/squashkiwi-streaming/backup.sh new file mode 100755 index 0000000..72e92a8 --- /dev/null +++ b/squashkiwi-streaming/backup.sh @@ -0,0 +1,11 @@ +#!/bin/bash +# shellcheck disable=SC1091 +source "${AGENT_PATH}/common.sh" +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "${SCRIPT_DIR}/_paths.sh" +_check_required_env_vars "CONTAINER_NAME" "LOCAL_DATA_FOLDER" "BACKUP_FILE" "TEMP_DIR" + +# shellcheck disable=SC2046 +backup_items $(get_squashkiwi_streaming_paths) || _die "Failed to create backup" + +echo "Backup created successfully" diff --git a/squashkiwi-streaming/config/.template_info.env b/squashkiwi-streaming/config/.template_info.env new file mode 100644 index 0000000..b5f58a4 --- /dev/null +++ b/squashkiwi-streaming/config/.template_info.env @@ -0,0 +1,18 @@ +# DO NOT EDIT THIS FILE FOR YOUR SERVICE! +# This file is replaced from the template whenever there is an update. +# Edit the service.env file to make changes. + +# Template to use - always required! +TEMPLATE=squashkiwi-streaming +REQUIRES_HOST_ROOT=false +REQUIRES_DOCKER=true +REQUIRES_DOCKER_ROOT=false + +# Application settings +MEDIAMTX_PORT=8554 +HLS_PORT=8888 +WEBRTC_PORT=8889 +WEB_PORT=80 + +# Deployment settings +PROJECT_NAME="squashkiwi-streaming" \ No newline at end of file diff --git a/squashkiwi-streaming/config/docker-compose.yml b/squashkiwi-streaming/config/docker-compose.yml new file mode 100644 index 0000000..3a3573e --- /dev/null +++ b/squashkiwi-streaming/config/docker-compose.yml @@ -0,0 +1,122 @@ +version: '3.8' + +services: + # MediaMTX - RTSP/HLS/WebRTC streaming server + mediamtx: + image: bluenviron/mediamtx:latest + container_name: ${PROJECT_NAME}-mediamtx + restart: unless-stopped + network_mode: host + volumes: + - ./mediamtx.yml:/mediamtx.yml + - ${RECORDINGS_PATH}:/recordings + environment: + - MTX_PROTOCOLS=tcp + - CAMERA_USER=${CAMERA_USER} + - CAMERA_PASSWORD=${CAMERA_PASSWORD} + - CAMERA_IP=${CAMERA_IP} + - CAMERA_RTSP_PORT=${CAMERA_RTSP_PORT} + - COURT_ID=${COURT_ID} + - COURT_NAME=${COURT_NAME} + - PUBLISH_PASSWORD=${PUBLISH_PASSWORD:-admin} + healthcheck: + test: ["CMD", "wget", "-q", "-O", "-", "http://localhost:9997/v2/paths/list"] + interval: 30s + timeout: 10s + retries: 3 + + # Score overlay and recording service + overlay-service: + build: ./overlay + image: ${PROJECT_NAME}-overlay:latest + container_name: ${PROJECT_NAME}-overlay + restart: unless-stopped + depends_on: + - mediamtx + volumes: + - ${RECORDINGS_PATH}:/recordings + environment: + - SQUASHKIWI_API=${SQUASHKIWI_API} + - COURT_ID=${COURT_ID} + - MEDIAMTX_API=http://localhost:9997 + - RECORDING_PATH=/recordings + - IDLE_TIMEOUT=${IDLE_TIMEOUT} + - RECORDING_RETENTION_DAYS=${RECORDING_RETENTION_DAYS} + healthcheck: + test: ["CMD", "pgrep", "-f", "overlay_service.py"] + interval: 30s + timeout: 10s + retries: 3 + + # Nginx web server + nginx: + image: nginx:alpine + container_name: ${PROJECT_NAME}-nginx + restart: unless-stopped + ports: + - "${HOST_PORT}:80" + volumes: + - ./nginx.conf:/etc/nginx/nginx.conf:ro + - ./web:/usr/share/nginx/html:ro + - ${RECORDINGS_PATH}:/recordings:ro + - nginx-cache:/var/cache/nginx + depends_on: + - mediamtx + healthcheck: + test: ["CMD", "wget", "-q", "-O", "-", "http://localhost/health"] + interval: 30s + timeout: 10s + retries: 3 + + # Optional: Cloudflare tunnel + cloudflared: + image: cloudflare/cloudflared:latest + container_name: ${PROJECT_NAME}-tunnel + restart: unless-stopped + command: tunnel run + environment: + - TUNNEL_TOKEN=${CLOUDFLARE_TUNNEL_TOKEN} + profiles: + - tunnel + + # Optional: Prometheus monitoring + prometheus: + image: prom/prometheus:latest + container_name: ${PROJECT_NAME}-prometheus + restart: unless-stopped + ports: + - "9090:9090" + volumes: + - ./prometheus.yml:/etc/prometheus/prometheus.yml:ro + - prometheus-data:/prometheus + command: + - '--config.file=/etc/prometheus/prometheus.yml' + - '--storage.tsdb.path=/prometheus' + profiles: + - monitoring + + # Optional: Grafana + grafana: + image: grafana/grafana:latest + container_name: ${PROJECT_NAME}-grafana + restart: unless-stopped + ports: + - "3000:3000" + volumes: + - grafana-data:/var/lib/grafana + environment: + - GF_SECURITY_ADMIN_PASSWORD=${GRAFANA_PASSWORD} + depends_on: + - prometheus + profiles: + - monitoring + +volumes: + nginx-cache: + prometheus-data: + grafana-data: + +networks: + default: + name: ${PROJECT_NAME} + driver: bridge \ No newline at end of file diff --git a/squashkiwi-streaming/config/mediamtx.yml b/squashkiwi-streaming/config/mediamtx.yml new file mode 100644 index 0000000..f019e11 --- /dev/null +++ b/squashkiwi-streaming/config/mediamtx.yml @@ -0,0 +1,66 @@ +# MediaMTX Configuration for SquashKiwi Streaming + +# General Settings +logLevel: info +logDestinations: [stdout] + +# API Configuration +api: yes +apiAddress: :9997 + +# Metrics +metrics: yes +metricsAddress: :9998 + +# RTSP Server +rtsp: yes +rtspAddress: :8554 +protocols: [tcp, udp] +readTimeout: 10s +writeTimeout: 10s + +# HLS Server +hls: yes +hlsAddress: :8888 +hlsAllowOrigin: '*' +hlsSegmentCount: 10 +hlsSegmentDuration: 2s +hlsPartDuration: 200ms + +# WebRTC Server +webrtc: yes +webrtcAddress: :8889 +webrtcAllowOrigin: '*' + +# Path Configuration +paths: + # Main court camera stream + court_main: + source: rtsp://${CAMERA_USER}:${CAMERA_PASSWORD}@${CAMERA_IP}:${CAMERA_RTSP_PORT}/cam/realmonitor?channel=1&subtype=0 + sourceProtocol: tcp + sourceOnDemand: no + + # Authentication + publishUser: admin + publishPass: ${PUBLISH_PASSWORD} + + # Recording + record: yes + recordPath: /recordings/${COURT_ID}_%Y%m%d_%H%M%S.mp4 + recordFormat: mp4 + recordSegmentDuration: 1h + recordDeleteAfter: 720h + + # Generate HLS + sourceGenerateHLS: yes + + # Generate WebRTC + sourceGenerateWebRTC: yes + + # Sub-stream for lower bandwidth + court_sub: + source: rtsp://${CAMERA_USER}:${CAMERA_PASSWORD}@${CAMERA_IP}:${CAMERA_RTSP_PORT}/cam/realmonitor?channel=1&subtype=1 + sourceProtocol: tcp + sourceOnDemand: yes + sourceGenerateHLS: yes + hlsSegmentDuration: 4s \ No newline at end of file diff --git a/squashkiwi-streaming/config/nginx.conf b/squashkiwi-streaming/config/nginx.conf new file mode 100644 index 0000000..6bf8dbd --- /dev/null +++ b/squashkiwi-streaming/config/nginx.conf @@ -0,0 +1,102 @@ +events { + worker_connections 1024; +} + +http { + include /etc/nginx/mime.types; + default_type application/octet-stream; + + sendfile on; + tcp_nopush on; + tcp_nodelay on; + keepalive_timeout 65; + gzip on; + + # Cache settings for HLS + proxy_cache_path /var/cache/nginx levels=1:2 keys_zone=hls_cache:10m max_size=1g inactive=60m use_temp_path=off; + + server { + listen 80; + server_name _; + + root /usr/share/nginx/html; + index index.html; + + # Health check + location /health { + access_log off; + return 200 "OK\n"; + add_header Content-Type text/plain; + } + + # Main web interface + location / { + try_files $uri $uri/ /index.html; + add_header Cache-Control "no-cache, no-store, must-revalidate"; + } + + # HLS streams proxy + location /hls/ { + proxy_pass http://localhost:8888/; + proxy_http_version 1.1; + + # CORS headers + add_header Access-Control-Allow-Origin *; + add_header Access-Control-Allow-Methods "GET, OPTIONS"; + add_header Access-Control-Allow-Headers "Range"; + + # Cache HLS segments + proxy_cache hls_cache; + proxy_cache_valid 200 1m; + proxy_cache_use_stale error timeout updating http_500 http_502 http_503 http_504; + + proxy_buffering off; + proxy_request_buffering off; + } + + # WebRTC signaling + location /webrtc/ { + proxy_pass http://localhost:8889/; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + + proxy_read_timeout 86400; + proxy_send_timeout 86400; + } + + # MediaMTX API + location /api/mediamtx/ { + proxy_pass http://localhost:9997/; + allow 127.0.0.1; + allow 172.16.0.0/12; + deny all; + } + + # Recordings + location /recordings/ { + alias /recordings/; + autoindex on; + autoindex_exact_size off; + autoindex_localtime on; + add_header Accept-Ranges bytes; + add_header Content-Disposition "inline"; + } + + # API endpoint for recordings list + location /api/recordings { + default_type application/json; + return 200 '{"recordings": []}'; + } + + # Metrics + location /metrics { + proxy_pass http://localhost:9998/metrics; + allow 127.0.0.1; + allow 172.16.0.0/12; + deny all; + } + } +} \ No newline at end of file diff --git a/squashkiwi-streaming/config/overlay/Dockerfile b/squashkiwi-streaming/config/overlay/Dockerfile new file mode 100644 index 0000000..91af08c --- /dev/null +++ b/squashkiwi-streaming/config/overlay/Dockerfile @@ -0,0 +1,32 @@ +FROM python:3.11-slim + +# Install FFmpeg and fonts +RUN apt-get update && \ + apt-get install -y --no-install-recommends \ + ffmpeg \ + fonts-dejavu-core \ + curl \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app + +# Copy requirements and install +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +# Copy application +COPY overlay_service.py . + +# Create recordings directory +RUN mkdir -p /recordings + +# Run as non-root user +RUN useradd -m -s /bin/bash overlay && \ + chown -R overlay:overlay /app /recordings +USER overlay + +# Health check +HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ + CMD python -c "import sys; sys.exit(0)" + +CMD ["python", "-u", "overlay_service.py"] \ No newline at end of file diff --git a/squashkiwi-streaming/config/overlay/overlay_service.py b/squashkiwi-streaming/config/overlay/overlay_service.py new file mode 100644 index 0000000..1e8ab44 --- /dev/null +++ b/squashkiwi-streaming/config/overlay/overlay_service.py @@ -0,0 +1,298 @@ +#!/usr/bin/env python3 +""" +SquashKiwi Score Overlay Service +Fetches scores from SquashKiwi API and manages recordings +""" + +import asyncio +import aiohttp +import json +import subprocess +import os +import time +import signal +import sys +from datetime import datetime, timedelta +from typing import Optional, Dict, Any +import logging + +logging.basicConfig( + level=logging.INFO, + format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' +) +logger = logging.getLogger(__name__) + +class ScoreOverlayService: + def __init__(self): + # Load configuration from environment + self.api_url = os.getenv('SQUASHKIWI_API', 'https://squash.kiwi/api') + self.court_id = os.getenv('COURT_ID', 'court1') + self.mediamtx_api = os.getenv('MEDIAMTX_API', 'http://localhost:9997') + self.recording_path = os.getenv('RECORDING_PATH', '/recordings') + + # State management + self.current_score = {"player1": 0, "player2": 0, "games": "0-0", "serving": None} + self.recording_process = None + self.recording_filename = None + self.last_score_change = time.time() + self.match_start_time = None + self.idle_timeout = int(os.getenv('IDLE_TIMEOUT', '300')) + + # Ensure recording directory exists + os.makedirs(self.recording_path, exist_ok=True) + + # Setup signal handlers + signal.signal(signal.SIGINT, self.signal_handler) + signal.signal(signal.SIGTERM, self.signal_handler) + + logger.info(f"Score overlay service initialized for court {self.court_id}") + + def signal_handler(self, signum, frame): + """Handle shutdown signals gracefully""" + logger.info(f"Received signal {signum}, shutting down...") + if self.recording_process: + self.stop_recording_sync() + sys.exit(0) + + async def fetch_score(self) -> Optional[Dict[str, Any]]: + """Fetch current score from SquashKiwi API""" + try: + timeout = aiohttp.ClientTimeout(total=5) + async with aiohttp.ClientSession(timeout=timeout) as session: + url = f"{self.api_url}/court/{self.court_id}/score" + async with session.get(url) as resp: + if resp.status == 200: + return await resp.json() + elif resp.status == 404: + logger.debug(f"No active match on court {self.court_id}") + return None + else: + logger.warning(f"API returned status {resp.status}") + return None + except Exception as e: + logger.error(f"Error fetching score: {e}") + return None + + async def check_stream_health(self) -> bool: + """Check if the stream is healthy""" + try: + timeout = aiohttp.ClientTimeout(total=2) + async with aiohttp.ClientSession(timeout=timeout) as session: + url = f"{self.mediamtx_api}/v2/paths/list" + async with session.get(url) as resp: + if resp.status == 200: + data = await resp.json() + paths = data.get('items', []) + return any(p.get('name') == 'court_main' for p in paths) + return False + except Exception as e: + logger.error(f"Failed to check stream health: {e}") + return False + + def format_score_text(self) -> str: + """Format the score for overlay display""" + games = self.current_score.get('games', '0-0') + p1_score = self.current_score.get('player1', 0) + p2_score = self.current_score.get('player2', 0) + p1_name = self.current_score.get('player1_name', 'Player 1')[:15] + p2_name = self.current_score.get('player2_name', 'Player 2')[:15] + + serving = self.current_score.get('serving') + serve1 = ' •' if serving == 1 else '' + serve2 = ' •' if serving == 2 else '' + + return f"{games} | {p1_name}: {p1_score}{serve1} - {p2_name}: {p2_score}{serve2}" + + async def start_recording(self): + """Start recording the match""" + if self.recording_process and self.recording_process.poll() is None: + logger.debug("Recording already in progress") + return + + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + court_folder = os.path.join(self.recording_path, self.court_id) + os.makedirs(court_folder, exist_ok=True) + + self.recording_filename = os.path.join(court_folder, f"match_{timestamp}.mp4") + self.match_start_time = datetime.now() + + logger.info(f"Starting recording: {self.recording_filename}") + + score_text = self.format_score_text() + + cmd = [ + 'ffmpeg', + '-y', + '-i', 'rtsp://localhost:8554/court_main', + '-vf', f"drawtext=fontfile=/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf:" + f"text='{score_text}':fontcolor=white:fontsize=30:" + f"box=1:boxcolor=black@0.5:boxborderw=5:x=10:y=10:" + f"reload=1:textfile=/tmp/score.txt", + '-c:v', 'libx264', + '-preset', 'fast', + '-crf', '23', + '-c:a', 'aac', + '-b:a', '128k', + '-movflags', '+faststart', + self.recording_filename + ] + + with open('/tmp/score.txt', 'w') as f: + f.write(score_text) + + try: + self.recording_process = subprocess.Popen( + cmd, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE + ) + logger.info(f"Recording process started with PID {self.recording_process.pid}") + except Exception as e: + logger.error(f"Failed to start recording: {e}") + self.recording_process = None + + async def stop_recording(self): + """Stop recording the match""" + if not self.recording_process or self.recording_process.poll() is not None: + logger.debug("No active recording to stop") + return + + logger.info("Stopping recording due to inactivity") + + try: + self.recording_process.send_signal(signal.SIGINT) + + try: + await asyncio.wait_for( + asyncio.create_subprocess_exec('wait', str(self.recording_process.pid)), + timeout=5.0 + ) + except asyncio.TimeoutError: + self.recording_process.kill() + logger.warning("Had to force kill recording process") + + if self.match_start_time: + duration = datetime.now() - self.match_start_time + logger.info(f"Recording duration: {duration}") + + metadata_file = self.recording_filename.replace('.mp4', '.json') + metadata = { + 'court_id': self.court_id, + 'start_time': self.match_start_time.isoformat(), + 'end_time': datetime.now().isoformat(), + 'duration': str(duration), + 'final_score': self.current_score + } + + try: + with open(metadata_file, 'w') as f: + json.dump(metadata, f, indent=2) + logger.info(f"Metadata saved to {metadata_file}") + except Exception as e: + logger.error(f"Failed to save metadata: {e}") + + except Exception as e: + logger.error(f"Error stopping recording: {e}") + finally: + self.recording_process = None + self.recording_filename = None + self.match_start_time = None + + def stop_recording_sync(self): + """Synchronous version of stop_recording for signal handler""" + if self.recording_process and self.recording_process.poll() is None: + logger.info("Stopping recording (sync)") + self.recording_process.terminate() + self.recording_process.wait(timeout=5) + + async def update_overlay_text(self): + """Update the overlay text file""" + score_text = self.format_score_text() + try: + with open('/tmp/score.txt', 'w') as f: + f.write(score_text) + except Exception as e: + logger.error(f"Failed to update overlay text: {e}") + + async def cleanup_old_recordings(self): + """Remove recordings older than retention period""" + retention_days = int(os.getenv('RECORDING_RETENTION_DAYS', '30')) + cutoff_date = datetime.now() - timedelta(days=retention_days) + + try: + for root, dirs, files in os.walk(self.recording_path): + for file in files: + if file.endswith('.mp4'): + file_path = os.path.join(root, file) + file_time = datetime.fromtimestamp(os.path.getmtime(file_path)) + if file_time < cutoff_date: + os.remove(file_path) + logger.info(f"Deleted old recording: {file}") + metadata_file = file_path.replace('.mp4', '.json') + if os.path.exists(metadata_file): + os.remove(metadata_file) + except Exception as e: + logger.error(f"Error during cleanup: {e}") + + async def main_loop(self): + """Main service loop""" + logger.info("Starting main service loop") + + await self.cleanup_old_recordings() + last_cleanup = datetime.now() + + while True: + try: + stream_healthy = await self.check_stream_health() + if not stream_healthy: + logger.warning("Stream not healthy, waiting...") + await asyncio.sleep(10) + continue + + score = await self.fetch_score() + + if score: + if score != self.current_score: + self.current_score = score + self.last_score_change = time.time() + + logger.info(f"Score updated: {self.format_score_text()}") + await self.update_overlay_text() + + if not self.recording_process or self.recording_process.poll() is not None: + await self.start_recording() + + idle_time = time.time() - self.last_score_change + if self.recording_process and idle_time > self.idle_timeout: + logger.info(f"Match idle for {idle_time:.0f} seconds") + await self.stop_recording() + + else: + if self.recording_process: + logger.info("No active match detected") + await self.stop_recording() + + if datetime.now() - last_cleanup > timedelta(days=1): + await self.cleanup_old_recordings() + last_cleanup = datetime.now() + + await asyncio.sleep(1) + + except Exception as e: + logger.error(f"Error in main loop: {e}") + await asyncio.sleep(5) + + async def run(self): + """Run the service""" + logger.info("SquashKiwi Score Overlay Service starting...") + await self.main_loop() + +if __name__ == "__main__": + service = ScoreOverlayService() + try: + asyncio.run(service.run()) + except KeyboardInterrupt: + logger.info("Service stopped by user") + except Exception as e: + logger.error(f"Service crashed: {e}") + sys.exit(1) \ No newline at end of file diff --git a/squashkiwi-streaming/config/overlay/requirements.txt b/squashkiwi-streaming/config/overlay/requirements.txt new file mode 100644 index 0000000..5486541 --- /dev/null +++ b/squashkiwi-streaming/config/overlay/requirements.txt @@ -0,0 +1,3 @@ +aiohttp==3.9.1 +python-dateutil==2.8.2 +tenacity==8.2.3 \ No newline at end of file diff --git a/squashkiwi-streaming/config/service.env b/squashkiwi-streaming/config/service.env new file mode 100644 index 0000000..71d9e53 --- /dev/null +++ b/squashkiwi-streaming/config/service.env @@ -0,0 +1,35 @@ +# SquashKiwi Streaming Configuration +# Edit this file to configure your streaming service + +LOCAL_DATA_FOLDER="/home/dropshell/example-squashkiwi-streaming" + + +# Camera Configuration +CAMERA_IP=192.168.1.100 +CAMERA_USER=admin +CAMERA_PASSWORD=changeme +CAMERA_RTSP_PORT=554 + +# Court Configuration +COURT_ID=court1 +COURT_NAME=Court 1 + +# SquashKiwi API +SQUASHKIWI_API=https://squash.kiwi/api + +# Recording Settings +RECORDINGS_PATH="/home/dropshell/example-squashkiwi-streaming/recordings" +RECORDING_RETENTION_DAYS=30 +RECORDING_QUALITY=high +IDLE_TIMEOUT=300 + +# Network Settings +HOST_PORT=8080 +PUBLIC_IP= + +# Optional: Cloudflare Tunnel +CLOUDFLARE_TUNNEL_TOKEN= + +# Optional: Monitoring +ENABLE_MONITORING=false +GRAFANA_PASSWORD=admin \ No newline at end of file diff --git a/squashkiwi-streaming/config/web/index.html b/squashkiwi-streaming/config/web/index.html new file mode 100644 index 0000000..c368d15 --- /dev/null +++ b/squashkiwi-streaming/config/web/index.html @@ -0,0 +1,329 @@ + + +
+ + +