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

This commit is contained in:
Your Name
2025-09-02 22:51:24 +12:00
parent ddeb37a636
commit 20a3790834
42 changed files with 0 additions and 2171 deletions

View File

@@ -1,7 +0,0 @@
# Playwright
node_modules/
/test-results/
/playwright-report/
/blob-report/
/playwright/.cache/

View File

@@ -1,14 +0,0 @@
#!/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}"
}

View File

@@ -1,11 +0,0 @@
#!/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" "BACKUP_FILE" "TEMP_DIR"
# shellcheck disable=SC2046
backup_items $(get_squashkiwi_streaming_paths) || _die "Failed to create backup"
echo "Backup created successfully (recordings excluded)"

View File

@@ -1,16 +0,0 @@
# 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

View File

@@ -1,150 +0,0 @@
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_FOLDER}:/recordings
environment:
MTX_PROTOCOLS: tcp
# Main stream configuration
MTX_PATHS_COURT_MAIN_SOURCE: ${MTX_PATHS_COURT_MAIN_SOURCE}
MTX_PATHS_COURT_MAIN_SOURCEPROTOCOL: tcp
MTX_PATHS_COURT_MAIN_RECORD: "yes"
MTX_PATHS_COURT_MAIN_RECORDPATH: "/recordings/%path/%Y-%m-%d_%H-%M-%S-%f.mp4"
MTX_PATHS_COURT_MAIN_RECORDFORMAT: fmp4
MTX_PATHS_COURT_MAIN_RECORDSEGMENTDURATION: 1h
MTX_PATHS_COURT_MAIN_RECORDDELETEAFTER: 24h
# Sub stream configuration (usually H264)
MTX_PATHS_COURT_SUB_SOURCE: ${MTX_PATHS_COURT_SUB_SOURCE}
MTX_PATHS_COURT_SUB_SOURCEPROTOCOL: tcp
# Legacy court path - original H265 stream
MTX_PATHS_COURT_SOURCE: ${MTX_PATHS_COURT_SOURCE}
MTX_PATHS_COURT_SOURCEPROTOCOL: tcp
# Force all paths to start immediately
MTX_PATHDEFAULTS_SOURCEONDEMAND: "no"
# Authentication disabled for testing
# MTX_PATHDEFAULTS_PUBLISHUSER: ${MEDIAMTX_USER}
# MTX_PATHDEFAULTS_PUBLISHPASS: ${MEDIAMTX_PASS}
# MTX_PATHDEFAULTS_READUSER: ${MEDIAMTX_USER}
# MTX_PATHDEFAULTS_READPASS: ${MEDIAMTX_PASS}
healthcheck:
test: ["CMD", "wget", "-q", "-O", "-", "http://localhost:9997/v2/paths/list"]
interval: 30s
timeout: 10s
retries: 3
# FFmpeg transcoder for H265 to H264 with score overlay
transcoder:
build: ./transcoder
image: ${PROJECT_NAME}-transcoder:latest
container_name: ${PROJECT_NAME}-transcoder
restart: unless-stopped
network_mode: host
volumes:
- score-data:/tmp:rw
depends_on:
- mediamtx
# Score overlay and recording service
overlay-service:
build: ./overlay
image: ${PROJECT_NAME}-overlay:latest
container_name: ${PROJECT_NAME}-overlay
restart: unless-stopped
network_mode: host
depends_on:
- mediamtx
volumes:
- ${RECORDINGS_FOLDER}:/recordings
- score-data:/tmp:rw
environment:
- SQUASHKIWI_API=${SQUASHKIWI_API}
- CLUB_CODE=${CLUB_CODE}
- COURT_NUMBER=${COURT_NUMBER}
- COURT_NAME=${COURT_NAME}
- 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
network_mode: host
volumes:
- ./nginx.conf:/etc/nginx/nginx.conf:ro
- ./web:/usr/share/nginx/html:ro
- ${RECORDINGS_FOLDER}:/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:
score-data:
networks:
default:
name: ${PROJECT_NAME}
driver: bridge

View File

@@ -1,47 +0,0 @@
# MediaMTX Configuration for SquashKiwi Streaming
# General Settings
logLevel: info
logDestinations: [stdout]
# API Configuration
api: yes
apiAddress: :9997
apiAllowOrigin: '*'
# Metrics
metrics: yes
metricsAddress: :9998
# RTSP Server
rtsp: yes
rtspAddress: :8554
rtspTransports: [tcp, udp]
# HLS Server
hls: yes
hlsAddress: :8888
hlsAllowOrigin: '*'
hlsSegmentCount: 3
hlsSegmentDuration: 1s
hlsPartDuration: 200ms
hlsAlwaysRemux: yes
# WebRTC Server
webrtc: yes
webrtcAddress: :8889
webrtcAllowOrigin: '*'
# Path defaults
pathDefaults:
# Authentication disabled for testing
# readUser: stream
# readPass: squashkiwi
# publishUser: stream
# publishPass: squashkiwi
# Path Configuration
paths:
# Transcoded H264 stream
court_h264:
# This path will receive the transcoded stream from ffmpeg

View File

@@ -1,120 +0,0 @@
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 8880;
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://[::1]:8888/;
proxy_http_version 1.1;
# Forward authentication headers
proxy_set_header Authorization $http_authorization;
# CORS headers
add_header Access-Control-Allow-Origin *;
add_header Access-Control-Allow-Methods "GET, OPTIONS";
add_header Access-Control-Allow-Headers "Range, Authorization";
# 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 (WHEP)
location /webrtc/ {
proxy_pass http://[::1]:8889/;
proxy_http_version 1.1;
# CORS headers for WebRTC
add_header Access-Control-Allow-Origin * always;
add_header Access-Control-Allow-Methods "GET, POST, OPTIONS, PATCH, DELETE" always;
add_header Access-Control-Allow-Headers "Authorization, Content-Type, If-Match" always;
add_header Access-Control-Expose-Headers "Link" always;
# Handle OPTIONS preflight
if ($request_method = OPTIONS) {
return 204;
}
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Authorization $http_authorization;
proxy_buffering off;
proxy_request_buffering off;
proxy_read_timeout 86400;
proxy_send_timeout 86400;
}
# MediaMTX API
location /api/mediamtx/ {
proxy_pass http://[::1]: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

@@ -1,33 +0,0 @@
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
# Create user but don't switch to it - need root for shared volume
RUN useradd -m -s /bin/bash overlay && \
chown -R overlay:overlay /app /recordings
# Running as root to access shared /tmp volume
# 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

@@ -1,327 +0,0 @@
#!/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.club_code = os.getenv('CLUB_CODE', 'OTOG')
self.court_number = os.getenv('COURT_NUMBER', '1')
self.court_id = f"{self.club_code.lower()}{self.court_number}"
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:
# Use format: https://squash.kiwi/api/otog2/score
url = f"{self.api_url}/{self.court_id}/score"
async with session.get(url) as resp:
if resp.status == 200:
data = await resp.json()
# Check if match is active
if data.get('match_active', False):
return data
else:
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}/v3/paths/list"
async with session.get(url) as resp:
if resp.status == 200:
data = await resp.json()
paths = data.get('items', [])
# Check if either court or court_h264 path exists (we're transcoding)
return any(p.get('name') in ['court', 'court_h264'] 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',
'-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")
# Initialize overlay text
court_name = os.getenv('COURT_NAME', f'Court {self.court_number}')
try:
# Create file with write permissions for all
with open('/tmp/score.txt', 'w') as f:
f.write(f"{court_name} - Waiting for match...")
os.chmod('/tmp/score.txt', 0o666)
except PermissionError:
# If file exists and we can't write, try to remove and recreate
try:
os.remove('/tmp/score.txt')
with open('/tmp/score.txt', 'w') as f:
f.write(f"{court_name} - Waiting for match...")
os.chmod('/tmp/score.txt', 0o666)
except:
logger.error("Cannot create score.txt file - check permissions")
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()
# Update overlay to show no active match
court_name = os.getenv('COURT_NAME', f'Court {self.court_number}')
with open('/tmp/score.txt', 'w') as f:
f.write(f"{court_name} - No active match")
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

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

View File

@@ -1,49 +0,0 @@
# SquashKiwi Streaming Configuration
# Edit this file to configure your streaming service
# Data folders
LOCAL_DATA_FOLDER="/home/dropshell/squashkiwi-streaming-data"
RECORDINGS_FOLDER="/home/dropshell/squashkiwi-streaming-recordings"
# Project Name used in docker-compose.yml, must be unique on the server.
PROJECT_NAME="sk-streaming"
# Camera Configuration
# Note: If password contains special characters (! @ # $ & %), they will be automatically URL-encoded
# Or you can manually encode them: ! = %21, @ = %40, # = %23, $ = %24, & = %26
CAMERA_IP=192.168.1.100
CAMERA_USER=admin
CAMERA_PASSWORD=changeme
CAMERA_RTSP_PORT=554
# Court Configuration
# CLUB_CODE: Your club's code (e.g., OTOG for Otago, WELL for Wellington)
# COURT_NUMBER: The court number (1, 2, 3, etc.)
CLUB_CODE=OTOG
COURT_NUMBER=1
COURT_NAME="Court 1"
# SquashKiwi API
SQUASHKIWI_API=https://squash.kiwi/api
# Recording Settings
RECORDING_RETENTION_DAYS=30
RECORDING_QUALITY=high
IDLE_TIMEOUT=300
# Network Settings
HOST_PORT=8880
PUBLIC_IP=
# MediaMTX Authentication (optional - remove values to disable auth)
MEDIAMTX_USER=
MEDIAMTX_PASS=
# SSH User automatically set on service creation.
SSH_USER="root"
# Optional: Cloudflare Tunnel
CLOUDFLARE_TUNNEL_TOKEN=
# Optional: Monitoring
GRAFANA_PASSWORD=admin

View File

@@ -1,15 +0,0 @@
FROM linuxserver/ffmpeg:latest
# Install fonts for text overlay
RUN apt-get update && \
apt-get install -y --no-install-recommends fonts-dejavu-core && \
rm -rf /var/lib/apt/lists/*
# Copy transcoder script
COPY transcoder.sh /transcoder.sh
RUN chmod +x /transcoder.sh
# Create score file directory
RUN mkdir -p /tmp
ENTRYPOINT ["/transcoder.sh"]

View File

@@ -1,34 +0,0 @@
#!/bin/bash
# Transcoder with live score overlay for SquashKiwi streaming
# Create initial score file if it doesn't exist
SCORE_FILE="/tmp/score.txt"
if [ ! -f "$SCORE_FILE" ]; then
echo "Waiting for match..." > "$SCORE_FILE"
fi
# Start ffmpeg with drawtext filter for score overlay
exec ffmpeg \
-rtsp_transport tcp \
-i rtsp://localhost:8554/court \
-vf "drawtext=fontfile=/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf:\
textfile='$SCORE_FILE':\
reload=1:\
fontcolor=white:\
fontsize=24:\
box=1:\
boxcolor=black@0.7:\
boxborderw=5:\
x=10:\
y=10" \
-c:v libx264 \
-preset ultrafast \
-tune zerolatency \
-g 30 \
-keyint_min 30 \
-b:v 2M \
-maxrate 2M \
-bufsize 1M \
-f rtsp \
-rtsp_transport tcp \
rtsp://localhost:8554/court_h264

View File

@@ -1,329 +0,0 @@
<!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 playsinline></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_h264',
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>

View File

@@ -1,165 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Stream Test</title>
<style>
body {
font-family: monospace;
padding: 20px;
background: #1a1a1a;
color: #0f0;
}
.test-section {
margin: 20px 0;
padding: 15px;
border: 1px solid #0f0;
background: #000;
}
button {
background: #0f0;
color: #000;
border: none;
padding: 10px 20px;
margin: 5px;
cursor: pointer;
font-family: monospace;
font-weight: bold;
}
button:hover {
background: #0a0;
}
.status {
margin: 10px 0;
padding: 10px;
background: #111;
}
.success { color: #0f0; }
.error { color: #f00; }
.info { color: #ff0; }
</style>
</head>
<body>
<h1>SquashKiwi Stream Test Page</h1>
<div class="test-section">
<h2>Available Paths</h2>
<button onclick="checkPaths()">Check MediaMTX Paths</button>
<div id="paths-status" class="status">Click to check available paths...</div>
</div>
<div class="test-section">
<h2>Test Streams</h2>
<button onclick="testStream('court')">Test 'court' (H265)</button>
<button onclick="testStream('court_h264')">Test 'court_h264'</button>
<button onclick="testStream('court_sub')">Test 'court_sub'</button>
<button onclick="testStream('court_main')">Test 'court_main'</button>
<div id="stream-status" class="status">Select a stream to test...</div>
</div>
<div class="test-section">
<h2>HLS Test</h2>
<video id="hls-video" controls style="width: 100%; max-width: 600px;"></video>
<div id="hls-status" class="status">HLS player ready...</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/hls.js@latest"></script>
<script>
async function checkPaths() {
const status = document.getElementById('paths-status');
status.innerHTML = '<span class="info">Checking paths...</span>';
try {
// Try direct access to MediaMTX API on port 9997
const response = await fetch('http://' + window.location.hostname + ':9997/v3/paths/list');
const data = await response.json();
let html = '<span class="success">Found ' + data.itemCount + ' path(s):</span><br>';
data.items.forEach(item => {
html += `<br>Path: <b>${item.name}</b>`;
html += `<br> Ready: ${item.ready ? '✓' : '✗'}`;
html += `<br> Tracks: ${item.tracks ? item.tracks.join(', ') : 'none'}`;
html += `<br> Readers: ${item.readers ? item.readers.length : 0}`;
html += '<br>';
});
status.innerHTML = html;
} catch (error) {
status.innerHTML = '<span class="error">Error: ' + error.message + '</span>';
}
}
async function testStream(streamName) {
const status = document.getElementById('stream-status');
const video = document.getElementById('hls-video');
status.innerHTML = `<span class="info">Testing stream: ${streamName}...</span>`;
// Test HLS
const hlsUrl = `/hls/${streamName}/index.m3u8`;
status.innerHTML += `<br>HLS URL: ${hlsUrl}`;
try {
const response = await fetch(hlsUrl, { method: 'HEAD' });
if (response.ok) {
status.innerHTML += '<br><span class="success">HLS playlist exists!</span>';
loadHLS(hlsUrl);
} else {
status.innerHTML += `<br><span class="error">HLS playlist not found (${response.status})</span>`;
}
} catch (error) {
status.innerHTML += '<br><span class="error">HLS test failed: ' + error.message + '</span>';
}
// Test WebRTC
const webrtcUrl = `/webrtc/${streamName}/whep`;
status.innerHTML += `<br><br>WebRTC URL: ${webrtcUrl}`;
try {
const response = await fetch(webrtcUrl, { method: 'OPTIONS' });
if (response.ok) {
status.innerHTML += '<br><span class="success">WebRTC endpoint exists!</span>';
} else {
status.innerHTML += `<br><span class="error">WebRTC endpoint issue (${response.status})</span>`;
}
} catch (error) {
status.innerHTML += '<br><span class="error">WebRTC test failed: ' + error.message + '</span>';
}
}
function loadHLS(url) {
const video = document.getElementById('hls-video');
const status = document.getElementById('hls-status');
if (Hls.isSupported()) {
const hls = new Hls();
hls.loadSource(url);
hls.attachMedia(video);
hls.on(Hls.Events.MANIFEST_PARSED, function() {
status.innerHTML = '<span class="success">HLS stream loaded! Press play to start.</span>';
video.play().catch(e => {
status.innerHTML += '<br><span class="info">Auto-play blocked. Click play button.</span>';
});
});
hls.on(Hls.Events.ERROR, function(event, data) {
if (data.fatal) {
status.innerHTML = '<span class="error">HLS Error: ' + data.details + '</span>';
}
});
} else if (video.canPlayType('application/vnd.apple.mpegurl')) {
video.src = url;
status.innerHTML = '<span class="info">Using native HLS support</span>';
} else {
status.innerHTML = '<span class="error">HLS not supported in this browser</span>';
}
}
// Check paths on load
window.onload = () => {
checkPaths();
};
</script>
</body>
</html>

View File

@@ -1,299 +0,0 @@
<!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 - WebRTC</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;
}
.play-overlay {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background: rgba(0, 0, 0, 0.7);
color: white;
padding: 20px 40px;
border-radius: 10px;
cursor: pointer;
font-size: 1.2rem;
z-index: 10;
display: none;
}
.play-overlay:hover {
background: rgba(0, 0, 0, 0.9);
}
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;
background: rgba(255, 255, 255, 0.2);
border-radius: 20px;
font-size: 0.9rem;
font-weight: 500;
}
.status-badge.live {
background: rgba(76, 175, 80, 0.3);
}
.status-badge.offline {
background: rgba(244, 67, 54, 0.3);
}
.btn {
padding: 8px 16px;
background: rgba(255, 255, 255, 0.2);
border: none;
color: white;
border-radius: 4px;
cursor: pointer;
font-size: 0.85rem;
}
.btn:hover {
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;
}
.switch-link {
text-align: center;
padding: 10px;
}
.switch-link a {
color: white;
text-decoration: none;
background: rgba(255, 255, 255, 0.2);
padding: 8px 16px;
border-radius: 4px;
display: inline-block;
}
.switch-link a:hover {
background: rgba(255, 255, 255, 0.3);
}
</style>
</head>
<body>
<div class="container">
<header>
<h1>🎾 SquashKiwi Court Stream (WebRTC)</h1>
</header>
<div class="video-section">
<div class="video-container">
<video id="video" controls autoplay muted playsinline></video>
<div id="playOverlay" class="play-overlay">▶️ Click to Play</div>
</div>
<div class="video-controls">
<div class="status-badge offline" id="status">
<span>Connecting...</span>
</div>
<div>
<button class="btn" onclick="toggleFullscreen()">📺 Fullscreen</button>
<button class="btn" onclick="refreshStream()">🔄 Refresh</button>
</div>
</div>
</div>
<div class="switch-link">
<a href="/">Switch to HLS Player</a>
</div>
<div class="info-section">
<h2>WebRTC Stream</h2>
<p>This player uses WebRTC for lower latency streaming. It should work with H265/HEVC streams.</p>
</div>
</div>
<script>
const config = {
courtId: new URLSearchParams(window.location.search).get('court') || 'court_h264',
webrtcUrl: '/webrtc/'
};
let pc = null;
async function initPlayer() {
const video = document.getElementById('video');
pc = new RTCPeerConnection({
iceServers: [{urls: 'stun:stun.l.google.com:19302'}]
});
// Add transceiver for receiving video
pc.addTransceiver('video', {direction: 'recvonly'});
pc.addTransceiver('audio', {direction: 'recvonly'});
pc.ontrack = (event) => {
console.log('Got track:', event.track.kind);
video.srcObject = event.streams[0];
const playOverlay = document.getElementById('playOverlay');
// Ensure autoplay works
video.play().then(() => {
console.log('Video playback started automatically');
playOverlay.style.display = 'none';
}).catch(e => {
console.log('Autoplay was blocked, user interaction may be required:', e);
// Show play overlay
playOverlay.style.display = 'block';
// Add click handlers to both overlay and video
const startPlayback = () => {
playOverlay.innerHTML = 'Loading...';
video.play().then(() => {
playOverlay.style.display = 'none';
}).catch(e => {
playOverlay.innerHTML = '▶️ Click to Play';
console.error('Failed to start playback:', e);
});
video.onclick = null;
playOverlay.onclick = null;
};
video.onclick = startPlayback;
playOverlay.onclick = startPlayback;
});
updateStatus(true);
};
pc.oniceconnectionstatechange = () => {
console.log('ICE connection state:', pc.iceConnectionState);
if (pc.iceConnectionState === 'disconnected' || pc.iceConnectionState === 'failed') {
updateStatus(false);
setTimeout(refreshStream, 5000);
}
};
// Create offer with ICE candidates
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
// Wait for ICE gathering to complete
await new Promise((resolve) => {
if (pc.iceGatheringState === 'complete') {
resolve();
} else {
pc.onicegatheringstatechange = () => {
if (pc.iceGatheringState === 'complete') {
resolve();
}
};
// Timeout after 500ms for faster connection
setTimeout(resolve, 500);
}
});
// Send complete offer with ICE candidates to MediaMTX
try {
const response = await fetch(`${config.webrtcUrl}${config.courtId}/whep`, {
method: 'POST',
headers: {
'Content-Type': 'application/sdp'
},
body: pc.localDescription.sdp
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const answer = await response.text();
await pc.setRemoteDescription({
type: 'answer',
sdp: answer
});
console.log('WebRTC connection established');
} catch (error) {
console.error('WebRTC error:', error);
updateStatus(false);
setTimeout(refreshStream, 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>';
}
}
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 (pc) {
pc.close();
}
initPlayer();
}
// Initialize player on load
initPlayer();
</script>
</body>
</html>

View File

@@ -1,15 +0,0 @@
#!/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 "LOCAL_DATA_FOLDER" "PROJECT_NAME" "RECORDINGS_FOLDER"
"${SCRIPT_DIR}/uninstall.sh"
# shellcheck disable=SC2046
destroy_items $(get_squashkiwi_streaming_paths) || _die "Failed to destroy ${LOCAL_DATA_FOLDER}"
rm -rf "${RECORDINGS_FOLDER}"
echo "Destroyed ${PROJECT_NAME}"

View File

@@ -1,76 +0,0 @@
#!/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" "CLUB_CODE" "COURT_NUMBER" "RECORDINGS_FOLDER"
# Create directories
# shellcheck disable=SC2046
create_items $(get_squashkiwi_streaming_paths) || _die "Failed to create directories"
mkdir -p "${RECORDINGS_FOLDER}/court_main"
mkdir -p "${RECORDINGS_FOLDER}/court_sub"
mkdir -p "${LOCAL_DATA_FOLDER}/config"
mkdir -p "${LOCAL_DATA_FOLDER}/config/overlay"
mkdir -p "${LOCAL_DATA_FOLDER}/config/web"
# Copy configuration files
cp -r "${SCRIPT_DIR}/config/"* "${LOCAL_DATA_FOLDER}/config/" || _die "Failed to copy config files"
# URL-encode the camera password if it contains special characters
# Only encode if not already encoded (check for % sign)
if [[ "${CAMERA_PASSWORD}" == *[!\@\#\$\&]* ]] && [[ "${CAMERA_PASSWORD}" != *%* ]]; then
echo "Note: Camera password contains special characters. Encoding for RTSP URL..."
CAMERA_PASSWORD=$(echo -n "${CAMERA_PASSWORD}" | sed 's/!/%21/g; s/@/%40/g; s/#/%23/g; s/\$/%24/g; s/&/%26/g')
fi
# Create .env file for docker-compose with all required variables
echo "Creating environment file for docker-compose..."
cat > "${LOCAL_DATA_FOLDER}/config/.env" <<EOF
# Auto-generated environment file for docker-compose
PROJECT_NAME=${PROJECT_NAME}
RECORDINGS_FOLDER=${RECORDINGS_FOLDER}
MTX_PATHS_COURT_SOURCE=rtsp://${CAMERA_USER}:${CAMERA_PASSWORD}@${CAMERA_IP}:${CAMERA_RTSP_PORT}/cam/realmonitor?channel=1&subtype=0
MTX_PATHS_COURT_MAIN_SOURCE=rtsp://${CAMERA_USER}:${CAMERA_PASSWORD}@${CAMERA_IP}:${CAMERA_RTSP_PORT}/cam/realmonitor?channel=1&subtype=0
MTX_PATHS_COURT_SUB_SOURCE=rtsp://${CAMERA_USER}:${CAMERA_PASSWORD}@${CAMERA_IP}:${CAMERA_RTSP_PORT}/cam/realmonitor?channel=1&subtype=1
MEDIAMTX_USER=${MEDIAMTX_USER}
MEDIAMTX_PASS=${MEDIAMTX_PASS}
SQUASHKIWI_API=${SQUASHKIWI_API}
CLUB_CODE=${CLUB_CODE}
COURT_NUMBER=${COURT_NUMBER}
COURT_NAME=${COURT_NAME}
IDLE_TIMEOUT=${IDLE_TIMEOUT}
RECORDING_RETENTION_DAYS=${RECORDING_RETENTION_DAYS}
HOST_PORT=${HOST_PORT}
CLOUDFLARE_TUNNEL_TOKEN=${CLOUDFLARE_TUNNEL_TOKEN}
GRAFANA_PASSWORD=${GRAFANA_PASSWORD}
EOF
# 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"

View File

@@ -1,9 +0,0 @@
#!/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

View File

@@ -1,23 +0,0 @@
#!/bin/bash
# shellcheck disable=SC1091
source "${AGENT_PATH}/common.sh"
echo "${HOST_PORT:-8880}"
echo "8554"
echo "8888"
echo "8889"
echo "9997"
echo "9998"
echo "9090"
echo "3000"
# Port Requirements:
# - 8880 - Web interface (changed from 8080)
# - 8554 - RTSP server
# - 8888 - HLS streaming
# - 8889 - WebRTC signaling
# - 9997 - MediaMTX API
# - 9998 - Metrics
# - 9090 - Prometheus (optional)
# - 3000 - Grafana (optional)

View File

@@ -1,22 +0,0 @@
#!/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" "BACKUP_FILE" "TEMP_DIR"
# RESTORE SCRIPT
# The restore script is OPTIONAL.
# It is used to restore the service on the server from a backup file.
# It is called with one argument: the path to the backup file.
# # Stop container before backup
"${SCRIPT_DIR}/uninstall.sh" || _die "Failed to uninstall service before restore"
# shellcheck disable=SC2046
restore_items $(get_squashkiwi_streaming_paths) || _die "Failed to restore data folder from backup"
# reinstall service
"${SCRIPT_DIR}/install.sh" || _die "Failed to reinstall service after restore"
echo "Restore complete! Service is running again on port $HOST_PORT with restored website."

View File

@@ -1,18 +0,0 @@
#!/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

View File

@@ -1,37 +0,0 @@
#!/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"
# Check that .env file exists and show configuration
if [[ ! -f "${LOCAL_DATA_FOLDER}/config/.env" ]]; then
_die ".env file not found at ${LOCAL_DATA_FOLDER}/config/.env - run install first"
fi
echo "Checking .env configuration..."
CONFIGURED_IP=$(grep "MTX_PATHS_COURT_MAIN_SOURCE" "${LOCAL_DATA_FOLDER}/config/.env" | sed -n 's/.*@\([0-9.]*\):.*/\1/p')
echo "Camera IP from .env: ${CONFIGURED_IP}"
echo "Camera IP from service.env: ${CAMERA_IP}"
# Start docker compose services (docker-compose will read .env file created by install.sh)
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"

View File

@@ -1,33 +0,0 @@
#!/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

View File

@@ -1,11 +0,0 @@
#!/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

@@ -1,89 +0,0 @@
#!/bin/bash
# Test camera RTSP connection
# shellcheck disable=SC1091
# Handle both dropshell execution and manual execution
if [ -z "${AGENT_PATH}" ]; then
# Manual execution - set paths relative to template directory
AGENT_PATH="../../../agent/"
# Load from the actual deployed config, not template config
source "../config/.template_info.env"
source "../config/service.env"
fi
source "${AGENT_PATH}/common.sh"
_check_required_env_vars "CAMERA_IP" "CAMERA_USER" "CAMERA_PASSWORD" "CAMERA_RTSP_PORT"
echo "Testing camera connection..."
echo "Camera IP: ${CAMERA_IP}"
echo "Camera User: ${CAMERA_USER}"
echo "RTSP Port: ${CAMERA_RTSP_PORT}"
# Test basic network connectivity first
echo ""
echo "Testing network connectivity to camera..."
if ping -c 1 -W 2 "${CAMERA_IP}" > /dev/null 2>&1; then
echo "✓ Camera IP is reachable"
else
echo "✗ Cannot reach camera at ${CAMERA_IP}"
echo " Please check:"
echo " - Camera IP address is correct"
echo " - Camera is powered on"
echo " - Network routing/firewall allows connection"
exit 1
fi
# Test RTSP port
echo ""
echo "Testing RTSP port ${CAMERA_RTSP_PORT}..."
if timeout 2 bash -c "echo > /dev/tcp/${CAMERA_IP}/${CAMERA_RTSP_PORT}" 2>/dev/null; then
echo "✓ RTSP port is open"
else
echo "✗ RTSP port ${CAMERA_RTSP_PORT} is not accessible"
echo " Please check:"
echo " - RTSP is enabled on the camera"
echo " - Port number is correct"
echo " - No firewall blocking port ${CAMERA_RTSP_PORT}"
exit 1
fi
# URL-encode the password if it contains special characters
ENCODED_PASSWORD=$(echo -n "${CAMERA_PASSWORD}" | sed 's/!/%21/g; s/@/%40/g; s/#/%23/g; s/\$/%24/g; s/&/%26/g')
RTSP_URL="rtsp://${CAMERA_USER}:${ENCODED_PASSWORD}@${CAMERA_IP}:${CAMERA_RTSP_PORT}/cam/realmonitor?channel=1&subtype=0"
echo ""
echo "Testing RTSP URL (password hidden): rtsp://${CAMERA_USER}:****@${CAMERA_IP}:${CAMERA_RTSP_PORT}/cam/realmonitor?channel=1&subtype=0"
# Test with ffprobe
echo ""
echo "Testing with ffprobe (10 second timeout)..."
timeout 10 docker run --rm --network host \
--entrypoint ffprobe \
linuxserver/ffmpeg:latest \
-v error \
-rtsp_transport tcp \
-timeout 5000000 \
"${RTSP_URL}" 2>&1 | head -20
if [ $? -eq 0 ]; then
echo "✓ Camera connection successful!"
else
echo "✗ Camera connection failed"
echo ""
echo "Trying alternative RTSP paths..."
# Try without subtype parameter
ALT_URL="rtsp://${CAMERA_USER}:${ENCODED_PASSWORD}@${CAMERA_IP}:${CAMERA_RTSP_PORT}/cam/realmonitor?channel=1"
docker run --rm --network host --entrypoint ffprobe linuxserver/ffmpeg:latest \
-v error "${ALT_URL}" 2>&1 | head -5
# Try simple path
SIMPLE_URL="rtsp://${CAMERA_USER}:${ENCODED_PASSWORD}@${CAMERA_IP}:${CAMERA_RTSP_PORT}/"
docker run --rm --network host --entrypoint ffprobe linuxserver/ffmpeg:latest \
-v error "${SIMPLE_URL}" 2>&1 | head -5
fi
echo ""
echo "Checking MediaMTX container logs..."
docker logs "${PROJECT_NAME}-mediamtx" --tail 20 2>&1 | grep -E "(ERR|WARN|connected|disconnected|error)"

View File

@@ -1,12 +0,0 @@
#!/bin/bash
# Test script to check substream codec
echo "Testing substream codec..."
echo "RTSP URL: rtsp://admin:Squashkiwiaynsley1!@10.10.1.12:554/cam/realmonitor?channel=1&subtype=1"
# Use curl to get stream info
timeout 5 curl -I "rtsp://admin:Squashkiwiaynsley1%21@10.10.1.12:554/cam/realmonitor?channel=1&subtype=1" 2>&1 | head -20
echo ""
echo "Note: The substream (subtype=1) on Dahua cameras is typically H264"
echo "while the main stream (subtype=0) is H265"

View File

@@ -1,11 +0,0 @@
#!/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"
# Stop and remove containers
docker compose down || true
echo "${PROJECT_NAME} has been uninstalled"

View File

@@ -1,7 +0,0 @@
#!/bin/bash
# Define path items for squashkiwi container
# These are used across backup, restore, create, and destroy operations
get_squashkiwi_paths() {
echo "path:localpath:${LOCAL_DATA_FOLDER}"
}

View File

@@ -1,17 +0,0 @@
#!/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"
# Stop container before backup
_stop_container "$CONTAINER_NAME"
# shellcheck disable=SC2046
backup_items $(get_squashkiwi_paths) || _die "Failed to create backup"
# Start container after backup
_start_container "$CONTAINER_NAME"
echo "Backup created successfully"

View File

@@ -1,21 +0,0 @@
# 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
REQUIRES_HOST_ROOT=false
REQUIRES_DOCKER=true
REQUIRES_DOCKER_ROOT=true
# Application settings
CONTAINER_PORT=8181
# Deployment settings
CONTAINER_NAME="squashkiwi"
# Image settings
IMAGE_REGISTRY="gitea.jde.nz"
IMAGE_REPO="squashkiwi/squashkiwi"
IMAGE_TAG="latest"

View File

@@ -1,9 +0,0 @@
# Service settings specific to this server
# (can also override anything in the .template_info.env file in the template to make it specific to this server)
HOST_PORT=8181
LOCAL_DATA_FOLDER="/home/dropshell/example-squashkiwi"
IMAGE_TAG="latest"
# Server Settings
SSH_USER="root"

View File

@@ -1,14 +0,0 @@
#!/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 "LOCAL_DATA_FOLDER"
./uninstall.sh
# shellcheck disable=SC2046
destroy_items $(get_squashkiwi_paths) || _die "Failed to destroy ${LOCAL_DATA_FOLDER}"
echo "Destroyed ${CONTAINER_NAME}"

View File

@@ -1,22 +0,0 @@
#!/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 "IMAGE_REGISTRY" "IMAGE_REPO" "IMAGE_TAG" "CONTAINER_NAME" "LOCAL_DATA_FOLDER"
# shellcheck disable=SC2046
create_items $(get_squashkiwi_paths) || _die "Failed to create local data folder"
# Test Docker
_check_docker_installed || _die "Docker test failed, aborting installation..."
# check can pull image on remote host and exit if fails
docker pull "$IMAGE_REGISTRY/$IMAGE_REPO:$IMAGE_TAG" || _die "Failed to pull image $IMAGE_REGISTRY/$IMAGE_REPO:$IMAGE_TAG"
# remove and restart, as the env may have changed.
bash ./stop.sh || _die "Failed to stop container ${CONTAINER_NAME}"
_remove_container "$CONTAINER_NAME" || _die "Failed to remove container ${CONTAINER_NAME}"
bash ./start.sh || _die "Failed to start container ${CONTAINER_NAME}"
echo "Installation of ${CONTAINER_NAME} complete"

View File

@@ -1,7 +0,0 @@
#!/bin/bash
source "${AGENT_PATH}/common.sh"
_check_required_env_vars "CONTAINER_NAME"
echo "Container ${CONTAINER_NAME} logs:"
docker logs "${CONTAINER_NAME}"

View File

@@ -1,5 +0,0 @@
#!/bin/bash
source "${AGENT_PATH}/common.sh"
_check_required_env_vars "HOST_PORT"
echo $HOST_PORT

View File

@@ -1,22 +0,0 @@
#!/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"
# RESTORE SCRIPT
# The restore script is OPTIONAL.
# It is used to restore the service on the server from a backup file.
# It is called with one argument: the path to the backup file.
# # Stop container before backup
bash ./uninstall.sh || _die "Failed to uninstall service before restore"
# shellcheck disable=SC2046
restore_items $(get_squashkiwi_paths) || _die "Failed to restore data folder from backup"
# reinstall service
bash ./install.sh || _die "Failed to reinstall service after restore"
echo "Restore complete! Service is running again on port $HOST_PORT with restored website."

View File

@@ -1,13 +0,0 @@
#!/bin/bash
source "${AGENT_PATH}/common.sh"
_check_required_env_vars "CONTAINER_NAME"
if ! _is_container_running "$CONTAINER_NAME"; then
_die "Container ${CONTAINER_NAME} is not running. Can't connect to it."
fi
echo "Connecting to ${CONTAINER_NAME}..."
docker exec -it ${CONTAINER_NAME} bash
echo "Disconnected from ${CONTAINER_NAME}"

View File

@@ -1,24 +0,0 @@
#!/bin/bash
source "${AGENT_PATH}/common.sh"
_check_required_env_vars "CONTAINER_NAME" "HOST_PORT" "CONTAINER_PORT" "LOCAL_DATA_FOLDER" "IMAGE_REGISTRY" "IMAGE_REPO" "IMAGE_TAG"
DOCKER_RUN_CMD="docker run -d \
--restart unless-stopped \
--name ${CONTAINER_NAME} \
-p ${HOST_PORT}:${CONTAINER_PORT} \
-v ${LOCAL_DATA_FOLDER}:/skdata \
${IMAGE_REGISTRY}/${IMAGE_REPO}:${IMAGE_TAG}"
if ! _create_and_start_container "$DOCKER_RUN_CMD" "$CONTAINER_NAME"; then
echo "${DOCKER_RUN_CMD}"
_die "Failed to start container ${CONTAINER_NAME}"
fi
# Check if the container is running
if ! _is_container_running "$CONTAINER_NAME"; then
_die "Container ${CONTAINER_NAME} is not running"
fi
echo "Container ${CONTAINER_NAME} started, on port ${HOST_PORT}"

View File

@@ -1,13 +0,0 @@
#!/bin/bash
source "${AGENT_PATH}/common.sh"
_check_required_env_vars "CONTAINER_NAME" "HOST_PORT"
# check if the service is running
_is_container_running $CONTAINER_NAME || _die "Service is not running - did not find container $CONTAINER_NAME."
# check if the service is healthy
curl -s -X GET http://localhost:${HOST_PORT}/health | grep -q "OK" \
|| _die "Service is not healthy - did not get OK response from /health endpoint."
echo "Service is healthy"
exit 0

View File

@@ -1,7 +0,0 @@
#!/bin/bash
source "${AGENT_PATH}/common.sh"
_check_required_env_vars "CONTAINER_NAME"
_stop_container $CONTAINER_NAME || _die "Failed to stop container ${CONTAINER_NAME}"
echo "Container ${CONTAINER_NAME} stopped"

View File

@@ -1,15 +0,0 @@
#!/bin/bash
source "${AGENT_PATH}/common.sh"
_check_required_env_vars "CONTAINER_NAME" "LOCAL_DATA_FOLDER"
# UNINSTALL SCRIPT
# The uninstall script is required for all templates.
# It is used to uninstall the service from the server.
# It is called with the path to the server specific env file as an argument.
_remove_container "$CONTAINER_NAME" || _die "Failed to remove container ${CONTAINER_NAME}"
_is_container_running "$CONTAINER_NAME" && _die "Couldn't stop existing container"
_is_container_exists "$CONTAINER_NAME" && _die "Couldn't remove existing container"
echo "Uninstallation of ${CONTAINER_NAME} complete."
echo "Local data folder ${LOCAL_DATA_FOLDER} still in place."