docs: Add 42 files
All checks were successful
Test and Publish Templates / test-and-publish (push) Successful in 59s

This commit is contained in:
Your Name
2025-09-01 14:04:58 +12:00
parent 014104df35
commit 11ddc264eb
21 changed files with 1284 additions and 0 deletions

14
squashkiwi-streaming/_paths.sh Executable file
View File

@@ -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}"
}

11
squashkiwi-streaming/backup.sh Executable file
View File

@@ -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"

View File

@@ -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"

View File

@@ -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

View File

@@ -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

View File

@@ -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;
}
}
}

View File

@@ -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"]

View File

@@ -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)

View File

@@ -0,0 +1,3 @@
aiohttp==3.9.1
python-dateutil==2.8.2
tenacity==8.2.3

View File

@@ -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

View File

@@ -0,0 +1,329 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>SquashKiwi Court Stream</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
color: white;
}
.container {
max-width: 1400px;
margin: 0 auto;
padding: 20px;
}
header {
text-align: center;
padding: 30px 0;
}
h1 {
font-size: 2.5rem;
margin-bottom: 10px;
text-shadow: 2px 2px 4px rgba(0,0,0,0.3);
}
.video-section {
background: rgba(255, 255, 255, 0.1);
border-radius: 12px;
overflow: hidden;
backdrop-filter: blur(10px);
margin-bottom: 20px;
}
.video-container {
position: relative;
padding-bottom: 56.25%;
height: 0;
background: black;
}
video {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
}
.video-controls {
padding: 15px;
background: rgba(0, 0, 0, 0.3);
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 10px;
}
.status-badge {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 8px 16px;
border-radius: 20px;
font-weight: 600;
font-size: 0.9rem;
}
.status-badge.live {
background: #10b981;
animation: pulse 2s infinite;
}
.status-badge.offline {
background: #ef4444;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.8; }
}
.btn {
padding: 8px 16px;
border: none;
border-radius: 6px;
background: rgba(255, 255, 255, 0.2);
color: white;
cursor: pointer;
font-size: 0.9rem;
transition: all 0.3s;
}
.btn:hover {
background: rgba(255, 255, 255, 0.3);
transform: translateY(-2px);
}
.quality-selector {
display: flex;
gap: 5px;
}
.quality-btn {
padding: 5px 10px;
border: 1px solid rgba(255, 255, 255, 0.3);
background: transparent;
color: white;
border-radius: 4px;
cursor: pointer;
font-size: 0.85rem;
}
.quality-btn.active {
background: rgba(255, 255, 255, 0.3);
}
.info-section {
background: rgba(255, 255, 255, 0.1);
border-radius: 12px;
padding: 20px;
backdrop-filter: blur(10px);
margin-bottom: 20px;
}
.recordings-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
gap: 15px;
margin-top: 20px;
}
.recording-card {
background: rgba(255, 255, 255, 0.1);
border-radius: 8px;
padding: 15px;
transition: all 0.3s;
cursor: pointer;
}
.recording-card:hover {
background: rgba(255, 255, 255, 0.2);
transform: translateY(-2px);
}
</style>
<script src="https://cdn.jsdelivr.net/npm/hls.js@latest"></script>
</head>
<body>
<div class="container">
<header>
<h1>🎾 SquashKiwi Court Stream</h1>
</header>
<div class="video-section">
<div class="video-container">
<video id="video" controls autoplay muted></video>
</div>
<div class="video-controls">
<div class="status-badge offline" id="status">
<span>Connecting...</span>
</div>
<div class="quality-selector">
<button class="quality-btn active" data-quality="auto">Auto</button>
<button class="quality-btn" data-quality="high">HD</button>
<button class="quality-btn" data-quality="low">SD</button>
</div>
<div>
<button class="btn" onclick="toggleFullscreen()">📺 Fullscreen</button>
<button class="btn" onclick="refreshStream()">🔄 Refresh</button>
</div>
</div>
</div>
<div class="info-section">
<h2>Recent Recordings</h2>
<div class="recordings-grid" id="recordings">
<div class="recording-card">
<div>Loading recordings...</div>
</div>
</div>
</div>
</div>
<script>
const config = {
courtId: new URLSearchParams(window.location.search).get('court') || 'court_main',
hlsUrl: '/hls/'
};
let hls = null;
function initPlayer() {
const video = document.getElementById('video');
const streamUrl = `${config.hlsUrl}${config.courtId}/index.m3u8`;
if (Hls.isSupported()) {
hls = new Hls({
liveSyncDurationCount: 3,
liveMaxLatencyDurationCount: 10,
enableWorker: true,
lowLatencyMode: true
});
hls.loadSource(streamUrl);
hls.attachMedia(video);
hls.on(Hls.Events.MANIFEST_PARSED, function() {
video.play().catch(e => console.log('Autoplay blocked:', e));
updateStatus(true);
});
hls.on(Hls.Events.ERROR, function(event, data) {
console.error('HLS error:', data);
if (data.fatal) {
updateStatus(false);
switch(data.type) {
case Hls.ErrorTypes.NETWORK_ERROR:
setTimeout(() => hls.startLoad(), 3000);
break;
case Hls.ErrorTypes.MEDIA_ERROR:
hls.recoverMediaError();
break;
default:
setTimeout(initPlayer, 5000);
break;
}
}
});
} else if (video.canPlayType('application/vnd.apple.mpegurl')) {
video.src = streamUrl;
video.addEventListener('loadedmetadata', function() {
video.play().catch(e => console.log('Autoplay blocked:', e));
updateStatus(true);
});
video.addEventListener('error', function() {
updateStatus(false);
setTimeout(initPlayer, 5000);
});
}
}
function updateStatus(live) {
const statusEl = document.getElementById('status');
if (live) {
statusEl.className = 'status-badge live';
statusEl.innerHTML = '<span>🔴 LIVE</span>';
} else {
statusEl.className = 'status-badge offline';
statusEl.innerHTML = '<span>⚫ OFFLINE</span>';
}
}
document.querySelector('.quality-selector').addEventListener('click', (e) => {
if (e.target.classList.contains('quality-btn')) {
document.querySelectorAll('.quality-btn').forEach(btn => {
btn.classList.remove('active');
});
e.target.classList.add('active');
const quality = e.target.dataset.quality;
if (hls) {
switch(quality) {
case 'auto':
hls.currentLevel = -1;
break;
case 'high':
hls.currentLevel = hls.levels.length - 1;
break;
case 'low':
hls.currentLevel = 0;
break;
}
}
}
});
function toggleFullscreen() {
const video = document.getElementById('video');
if (!document.fullscreenElement) {
video.requestFullscreen().catch(err => {
console.error('Fullscreen error:', err);
});
} else {
document.exitFullscreen();
}
}
function refreshStream() {
if (hls) {
hls.destroy();
}
initPlayer();
}
async function loadRecordings() {
try {
const response = await fetch(`/api/recordings?court=${config.courtId}`);
const data = await response.json();
const container = document.getElementById('recordings');
if (!data.recordings || data.recordings.length === 0) {
container.innerHTML = '<div class="recording-card"><div>No recordings available</div></div>';
return;
}
container.innerHTML = data.recordings.map(rec => `
<div class="recording-card" onclick="playRecording('${rec.file}')">
<div>${rec.date}</div>
<div style="margin-top: 10px; font-size: 0.85rem;">
${rec.size} • Click to play
</div>
</div>
`).join('');
} catch (e) {
console.error('Failed to load recordings:', e);
}
}
function playRecording(file) {
const video = document.getElementById('video');
if (hls) {
hls.destroy();
}
video.src = `/recordings/${config.courtId}/${file}`;
video.play();
updateStatus(false);
}
document.addEventListener('DOMContentLoaded', () => {
initPlayer();
loadRecordings();
setInterval(loadRecordings, 30000);
});
window.addEventListener('beforeunload', () => {
if (hls) {
hls.destroy();
}
});
</script>
</body>
</html>

11
squashkiwi-streaming/destroy.sh Executable file
View File

@@ -0,0 +1,11 @@
#!/bin/bash
# shellcheck disable=SC1091
source "${AGENT_PATH}/common.sh"
_check_required_env_vars "PROJECT_NAME" "LOCAL_DATA_FOLDER"
cd "${LOCAL_DATA_FOLDER}/config" || _die "Failed to change to config directory"
echo "Destroying ${PROJECT_NAME} containers and networks..."
docker compose down -v || _die "Failed to destroy Docker Compose services"
echo "${PROJECT_NAME} destroyed (configuration and recordings preserved)"

45
squashkiwi-streaming/install.sh Executable file
View File

@@ -0,0 +1,45 @@
#!/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 "PROJECT_NAME" "LOCAL_DATA_FOLDER" "CAMERA_IP" "COURT_ID"
# Create directories
# shellcheck disable=SC2046
create_items $(get_squashkiwi_streaming_paths) || _die "Failed to create directories"
# Copy configuration files
cp -r "${SCRIPT_DIR}/config/"* "${LOCAL_DATA_FOLDER}/config/" || _die "Failed to copy config files"
# Update recordings path in service.env
sed -i "s|RECORDINGS_PATH=.*|RECORDINGS_PATH=${LOCAL_DATA_FOLDER}/recordings|" "${LOCAL_DATA_FOLDER}/config/service.env"
# Test Docker
_check_docker_installed || _die "Docker test failed, aborting installation..."
# Test camera connection (optional check)
echo "Testing camera connection to ${CAMERA_IP}..."
if ping -c 1 -W 2 "${CAMERA_IP}" > /dev/null 2>&1; then
echo "✓ Camera IP is reachable"
else
echo "⚠ Warning: Camera IP ${CAMERA_IP} is not reachable. Please check camera connection."
fi
# Build overlay service
cd "${LOCAL_DATA_FOLDER}/config" || _die "Failed to change to config directory"
docker compose build overlay-service || _die "Failed to build overlay service"
# Stop any existing containers
bash "${SCRIPT_DIR}/stop.sh" 2>/dev/null || true
# Start services
bash "${SCRIPT_DIR}/start.sh" || _die "Failed to start services"
echo ""
echo "Installation of ${PROJECT_NAME} complete!"
echo "Access the stream at: http://$(hostname -I | awk '{print $1}'):${HOST_PORT}"
echo ""
echo "To enable optional features:"
echo " - Cloudflare tunnel: Set CLOUDFLARE_TUNNEL_TOKEN in service.env and run: docker compose --profile tunnel up -d"
echo " - Monitoring: docker compose --profile monitoring up -d"

9
squashkiwi-streaming/logs.sh Executable file
View File

@@ -0,0 +1,9 @@
#!/bin/bash
# shellcheck disable=SC1091
source "${AGENT_PATH}/common.sh"
_check_required_env_vars "LOCAL_DATA_FOLDER"
cd "${LOCAL_DATA_FOLDER}/config" || _die "Failed to change to config directory"
# Follow logs for all services
docker compose logs -f

16
squashkiwi-streaming/ports.sh Executable file
View File

@@ -0,0 +1,16 @@
#!/bin/bash
# shellcheck disable=SC1091
source "${AGENT_PATH}/common.sh"
echo "=== Port Configuration ==="
echo "Web Interface: ${HOST_PORT:-8080}"
echo "RTSP: 8554"
echo "HLS: 8888"
echo "WebRTC: 8889"
echo "MediaMTX API: 9997"
echo "Metrics: 9998"
if [[ "${ENABLE_MONITORING}" == "true" ]]; then
echo "Prometheus: 9090"
echo "Grafana: 3000"
fi

55
squashkiwi-streaming/restore.sh Executable file
View File

@@ -0,0 +1,55 @@
#!/bin/bash
# shellcheck disable=SC1091
source "${AGENT_PATH}/common.sh"
_check_required_env_vars "PROJECT_NAME" "LOCAL_DATA_FOLDER"
BACKUP_DIR="${HOME}/backups/${PROJECT_NAME}"
# List available backups
echo "Available backups:"
ls -1t "${BACKUP_DIR}"/*.tar.gz 2>/dev/null | head -10 | nl
if [[ $? -ne 0 ]]; then
echo "No backups found in ${BACKUP_DIR}"
exit 1
fi
# Select backup
read -p "Enter backup number to restore (or path to backup file): " selection
if [[ -f "$selection" ]]; then
BACKUP_FILE="$selection"
else
BACKUP_FILE=$(ls -1t "${BACKUP_DIR}"/*.tar.gz 2>/dev/null | sed -n "${selection}p")
fi
if [[ ! -f "$BACKUP_FILE" ]]; then
echo "Backup file not found: ${BACKUP_FILE}"
exit 1
fi
echo "Restoring from: ${BACKUP_FILE}"
read -p "This will overwrite current configuration. Continue? (yes/no): " confirmation
if [[ "$confirmation" != "yes" ]]; then
echo "Restore cancelled"
exit 0
fi
# Stop services
bash "${SCRIPT_DIR}/stop.sh" || true
# Backup current config
if [[ -d "${LOCAL_DATA_FOLDER}/config" ]]; then
mv "${LOCAL_DATA_FOLDER}/config" "${LOCAL_DATA_FOLDER}/config.bak.$(date +%Y%m%d_%H%M%S)"
fi
# Extract backup
tar -xzf "${BACKUP_FILE}" -C "${LOCAL_DATA_FOLDER}" || _die "Failed to extract backup"
echo "✓ Backup restored"
# Restart services
bash "${SCRIPT_DIR}/start.sh"
echo "Restore complete!"

18
squashkiwi-streaming/ssh.sh Executable file
View File

@@ -0,0 +1,18 @@
#!/bin/bash
# shellcheck disable=SC1091
source "${AGENT_PATH}/common.sh"
_check_required_env_vars "PROJECT_NAME"
# List running containers
echo "Available containers:"
docker ps --filter "name=${PROJECT_NAME}" --format "table {{.Names}}\t{{.Status}}"
echo ""
read -p "Enter container name to SSH into (or press Enter for mediamtx): " container
if [[ -z "$container" ]]; then
container="${PROJECT_NAME}-mediamtx"
fi
echo "Connecting to ${container}..."
docker exec -it "${container}" /bin/sh

32
squashkiwi-streaming/start.sh Executable file
View File

@@ -0,0 +1,32 @@
#!/bin/bash
# shellcheck disable=SC1091
source "${AGENT_PATH}/common.sh"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
_check_required_env_vars "PROJECT_NAME" "LOCAL_DATA_FOLDER"
cd "${LOCAL_DATA_FOLDER}/config" || _die "Failed to change to config directory"
# Load environment variables
set -a
source "${LOCAL_DATA_FOLDER}/config/service.env"
set +a
# Start docker compose services
echo "Starting ${PROJECT_NAME} services..."
docker compose up -d || _die "Failed to start Docker Compose services"
# Wait for services to be healthy
echo "Waiting for services to be healthy..."
sleep 5
# Check service health
if docker compose ps | grep -q "healthy"; then
echo "✓ Services are healthy"
else
echo "⚠ Some services may not be healthy yet. Check with: docker compose ps"
fi
echo ""
echo "${PROJECT_NAME} started successfully!"
echo "Stream URL: http://$(hostname -I | awk '{print $1}'):${HOST_PORT}"
echo "View logs: docker compose logs -f"

33
squashkiwi-streaming/status.sh Executable file
View File

@@ -0,0 +1,33 @@
#!/bin/bash
# shellcheck disable=SC1091
source "${AGENT_PATH}/common.sh"
_check_required_env_vars "PROJECT_NAME" "LOCAL_DATA_FOLDER"
cd "${LOCAL_DATA_FOLDER}/config" || _die "Failed to change to config directory"
echo "=== ${PROJECT_NAME} Status ==="
echo ""
# Show container status
docker compose ps
echo ""
echo "=== Stream Health ==="
# Check MediaMTX stream
if curl -s "http://localhost:9997/v2/paths/list" | grep -q "court_main"; then
echo "✓ MediaMTX stream is active"
else
echo "✗ MediaMTX stream is not active"
fi
# Check web interface
if curl -s "http://localhost:${HOST_PORT:-8080}/health" > /dev/null 2>&1; then
echo "✓ Web interface is accessible"
else
echo "✗ Web interface is not accessible"
fi
echo ""
echo "=== Recent Logs ==="
docker compose logs --tail=10

11
squashkiwi-streaming/stop.sh Executable file
View File

@@ -0,0 +1,11 @@
#!/bin/bash
# shellcheck disable=SC1091
source "${AGENT_PATH}/common.sh"
_check_required_env_vars "PROJECT_NAME" "LOCAL_DATA_FOLDER"
cd "${LOCAL_DATA_FOLDER}/config" || _die "Failed to change to config directory"
echo "Stopping ${PROJECT_NAME} services..."
docker compose down || _die "Failed to stop Docker Compose services"
echo "${PROJECT_NAME} stopped"

View File

@@ -0,0 +1,24 @@
#!/bin/bash
# shellcheck disable=SC1091
source "${AGENT_PATH}/common.sh"
_check_required_env_vars "PROJECT_NAME" "LOCAL_DATA_FOLDER"
echo "WARNING: This will remove all ${PROJECT_NAME} containers, images, and data!"
echo "Recordings will be permanently deleted."
read -p "Are you sure? (yes/no): " confirmation
if [[ "$confirmation" != "yes" ]]; then
echo "Uninstall cancelled"
exit 0
fi
cd "${LOCAL_DATA_FOLDER}/config" || _die "Failed to change to config directory"
# Stop and remove containers
docker compose down -v --rmi all || true
# Remove local data
echo "Removing local data folder: ${LOCAL_DATA_FOLDER}"
rm -rf "${LOCAL_DATA_FOLDER}"
echo "${PROJECT_NAME} has been completely uninstalled"