initial commit - vibe coded with claude
This commit is contained in:
commit
7b826e9f38
|
|
@ -0,0 +1,44 @@
|
||||||
|
FROM python:3.11-slim
|
||||||
|
|
||||||
|
# Install system dependencies
|
||||||
|
RUN apt-get update && apt-get install -y \
|
||||||
|
ffmpeg \
|
||||||
|
wget \
|
||||||
|
git \
|
||||||
|
build-essential \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Install whisper.cpp
|
||||||
|
WORKDIR /tmp
|
||||||
|
RUN git clone https://github.com/ggerganov/whisper.cpp.git && \
|
||||||
|
cd whisper.cpp && \
|
||||||
|
make && \
|
||||||
|
cp main /usr/local/bin/whisper.cpp && \
|
||||||
|
chmod +x /usr/local/bin/whisper.cpp
|
||||||
|
|
||||||
|
# Download whisper model
|
||||||
|
RUN mkdir -p /models && \
|
||||||
|
cd /models && \
|
||||||
|
wget -q https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-base.en.bin && \
|
||||||
|
mv ggml-base.en.bin ggml-base.bin
|
||||||
|
|
||||||
|
# Set up application directory
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Copy requirements and install Python dependencies
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
# Copy application code
|
||||||
|
COPY agent.py .
|
||||||
|
|
||||||
|
# Create directories for videos
|
||||||
|
RUN mkdir -p /raw_movies /final_movies
|
||||||
|
|
||||||
|
# Set environment variables with defaults
|
||||||
|
ENV RAW_DIR=/raw_movies \
|
||||||
|
FINAL_DIR=/final_movies \
|
||||||
|
WHISPER_MODEL=/models/ggml-base.bin \
|
||||||
|
VAAPI_DEVICE=/dev/dri/renderD128
|
||||||
|
|
||||||
|
CMD ["python", "-u", "agent.py"]
|
||||||
|
|
@ -0,0 +1,16 @@
|
||||||
|
FROM python:3.11-slim
|
||||||
|
|
||||||
|
# Install curl, wget and other utilities
|
||||||
|
RUN apt-get update && apt-get install -y \
|
||||||
|
curl \
|
||||||
|
wget \
|
||||||
|
jq \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Install Python requests library
|
||||||
|
RUN pip install --no-cache-dir requests
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# The init script will be mounted
|
||||||
|
CMD ["/bin/bash"]
|
||||||
|
|
@ -0,0 +1,875 @@
|
||||||
|
# Production Deployment Guide
|
||||||
|
|
||||||
|
Complete guide for running the Movie Scheduler in production environments.
|
||||||
|
|
||||||
|
## Table of Contents
|
||||||
|
1. [Prerequisites](#prerequisites)
|
||||||
|
2. [Deployment Options](#deployment-options)
|
||||||
|
3. [Installation Methods](#installation-methods)
|
||||||
|
4. [Configuration](#configuration)
|
||||||
|
5. [Security](#security)
|
||||||
|
6. [Monitoring](#monitoring)
|
||||||
|
7. [Backup & Recovery](#backup--recovery)
|
||||||
|
8. [Maintenance](#maintenance)
|
||||||
|
9. [Troubleshooting](#troubleshooting)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
### Hardware Requirements
|
||||||
|
|
||||||
|
**Minimum:**
|
||||||
|
- CPU: 4 cores (for whisper.cpp and encoding)
|
||||||
|
- RAM: 4GB
|
||||||
|
- Storage: 100GB+ (depends on video library size)
|
||||||
|
- GPU: Intel/AMD with VAAPI support (optional but recommended)
|
||||||
|
|
||||||
|
**Recommended:**
|
||||||
|
- CPU: 8+ cores
|
||||||
|
- RAM: 8GB+
|
||||||
|
- Storage: 500GB+ SSD
|
||||||
|
- GPU: Modern Intel/AMD GPU with VAAPI
|
||||||
|
|
||||||
|
### Software Requirements
|
||||||
|
|
||||||
|
- **OS**: Linux (Ubuntu 20.04+, Debian 11+, RHEL 8+, or compatible)
|
||||||
|
- **Python**: 3.7+
|
||||||
|
- **FFmpeg**: With VAAPI support
|
||||||
|
- **whisper.cpp**: Compiled and in PATH
|
||||||
|
- **Network**: Stable connection to NocoDB and RTMP server
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Deployment Options
|
||||||
|
|
||||||
|
### Option 1: Systemd Service (Recommended for bare metal)
|
||||||
|
✅ Direct hardware access (best VAAPI performance)
|
||||||
|
✅ Low overhead
|
||||||
|
✅ System integration
|
||||||
|
❌ Manual dependency management
|
||||||
|
|
||||||
|
### Option 2: Docker Container (Recommended for most users)
|
||||||
|
✅ Isolated environment
|
||||||
|
✅ Easy updates
|
||||||
|
✅ Portable configuration
|
||||||
|
⚠️ Slight performance overhead
|
||||||
|
⚠️ Requires GPU passthrough for VAAPI
|
||||||
|
|
||||||
|
### Option 3: Kubernetes/Orchestration
|
||||||
|
✅ High availability
|
||||||
|
✅ Auto-scaling
|
||||||
|
✅ Cloud-native
|
||||||
|
❌ Complex setup
|
||||||
|
❌ Overkill for single-instance deployment
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Installation Methods
|
||||||
|
|
||||||
|
### Method 1: Systemd Service Installation
|
||||||
|
|
||||||
|
#### 1. Create Scheduler User
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Create dedicated user for security
|
||||||
|
sudo useradd -r -s /bin/bash -d /opt/scheduler -m scheduler
|
||||||
|
|
||||||
|
# Add to video group for GPU access
|
||||||
|
sudo usermod -aG video,render scheduler
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 2. Install Dependencies
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Install system packages
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install -y python3 python3-pip python3-venv ffmpeg git build-essential
|
||||||
|
|
||||||
|
# Install whisper.cpp
|
||||||
|
sudo -u scheduler git clone https://github.com/ggerganov/whisper.cpp.git /tmp/whisper.cpp
|
||||||
|
cd /tmp/whisper.cpp
|
||||||
|
make
|
||||||
|
sudo cp main /usr/local/bin/whisper.cpp
|
||||||
|
sudo chmod +x /usr/local/bin/whisper.cpp
|
||||||
|
|
||||||
|
# Download whisper model
|
||||||
|
sudo mkdir -p /opt/models
|
||||||
|
cd /opt/models
|
||||||
|
sudo wget https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-base.en.bin
|
||||||
|
sudo mv ggml-base.en.bin ggml-base.bin
|
||||||
|
sudo chown -R scheduler:scheduler /opt/models
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3. Deploy Application
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Create application directory
|
||||||
|
sudo mkdir -p /opt/scheduler
|
||||||
|
sudo chown scheduler:scheduler /opt/scheduler
|
||||||
|
|
||||||
|
# Copy application files
|
||||||
|
sudo -u scheduler cp agent.py /opt/scheduler/
|
||||||
|
sudo -u scheduler cp requirements.txt /opt/scheduler/
|
||||||
|
|
||||||
|
# Create Python virtual environment
|
||||||
|
sudo -u scheduler python3 -m venv /opt/scheduler/venv
|
||||||
|
sudo -u scheduler /opt/scheduler/venv/bin/pip install -r /opt/scheduler/requirements.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 4. Configure Storage
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Create storage directories (adjust paths as needed)
|
||||||
|
sudo mkdir -p /mnt/storage/raw_movies
|
||||||
|
sudo mkdir -p /mnt/storage/final_movies
|
||||||
|
sudo chown -R scheduler:scheduler /mnt/storage
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 5. Configure Service
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Copy service file
|
||||||
|
sudo cp scheduler.service /etc/systemd/system/
|
||||||
|
|
||||||
|
# Create environment file with secrets
|
||||||
|
sudo mkdir -p /etc/scheduler
|
||||||
|
sudo nano /etc/scheduler/scheduler.env
|
||||||
|
```
|
||||||
|
|
||||||
|
Edit `/etc/scheduler/scheduler.env`:
|
||||||
|
```bash
|
||||||
|
NOCODB_URL=https://your-nocodb.com/api/v2/tables/YOUR_TABLE_ID/records
|
||||||
|
NOCODB_TOKEN=your_production_token
|
||||||
|
RTMP_SERVER=rtmp://your-rtmp-server.com/live/stream
|
||||||
|
RAW_DIR=/mnt/storage/raw_movies
|
||||||
|
FINAL_DIR=/mnt/storage/final_movies
|
||||||
|
WHISPER_MODEL=/opt/models/ggml-base.bin
|
||||||
|
```
|
||||||
|
|
||||||
|
Update `scheduler.service` to use the environment file:
|
||||||
|
```ini
|
||||||
|
# Replace Environment= lines with:
|
||||||
|
EnvironmentFile=/etc/scheduler/scheduler.env
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 6. Enable and Start Service
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Reload systemd
|
||||||
|
sudo systemctl daemon-reload
|
||||||
|
|
||||||
|
# Enable service (start on boot)
|
||||||
|
sudo systemctl enable scheduler
|
||||||
|
|
||||||
|
# Start service
|
||||||
|
sudo systemctl start scheduler
|
||||||
|
|
||||||
|
# Check status
|
||||||
|
sudo systemctl status scheduler
|
||||||
|
|
||||||
|
# View logs
|
||||||
|
sudo journalctl -u scheduler -f
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Method 2: Docker Deployment
|
||||||
|
|
||||||
|
#### 1. Prepare Environment
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Create project directory
|
||||||
|
mkdir -p /opt/scheduler
|
||||||
|
cd /opt/scheduler
|
||||||
|
|
||||||
|
# Copy application files
|
||||||
|
cp agent.py requirements.txt Dockerfile docker-compose.prod.yml ./
|
||||||
|
|
||||||
|
# Create production environment file
|
||||||
|
cp .env.production.example .env.production
|
||||||
|
nano .env.production # Fill in your values
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 2. Configure Storage
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Ensure storage directories exist
|
||||||
|
mkdir -p /mnt/storage/raw_movies
|
||||||
|
mkdir -p /mnt/storage/final_movies
|
||||||
|
|
||||||
|
# Download whisper model
|
||||||
|
mkdir -p /opt/models
|
||||||
|
cd /opt/models
|
||||||
|
wget https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-base.en.bin
|
||||||
|
mv ggml-base.en.bin ggml-base.bin
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3. Deploy Container
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /opt/scheduler
|
||||||
|
|
||||||
|
# Build image
|
||||||
|
docker compose -f docker-compose.prod.yml build
|
||||||
|
|
||||||
|
# Start service
|
||||||
|
docker compose -f docker-compose.prod.yml up -d
|
||||||
|
|
||||||
|
# Check logs
|
||||||
|
docker compose -f docker-compose.prod.yml logs -f
|
||||||
|
|
||||||
|
# Check status
|
||||||
|
docker compose -f docker-compose.prod.yml ps
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 4. Enable Auto-Start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Create systemd service for docker compose
|
||||||
|
sudo nano /etc/systemd/system/scheduler-docker.service
|
||||||
|
```
|
||||||
|
|
||||||
|
```ini
|
||||||
|
[Unit]
|
||||||
|
Description=Movie Scheduler (Docker)
|
||||||
|
Requires=docker.service
|
||||||
|
After=docker.service
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=oneshot
|
||||||
|
RemainAfterExit=yes
|
||||||
|
WorkingDirectory=/opt/scheduler
|
||||||
|
ExecStart=/usr/bin/docker compose -f docker-compose.prod.yml up -d
|
||||||
|
ExecStop=/usr/bin/docker compose -f docker-compose.prod.yml down
|
||||||
|
TimeoutStartSec=0
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo systemctl daemon-reload
|
||||||
|
sudo systemctl enable scheduler-docker
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
### Essential Configuration
|
||||||
|
|
||||||
|
#### NocoDB Connection
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Get your table ID from NocoDB URL
|
||||||
|
# https://nocodb.com/nc/YOUR_BASE_ID/table_NAME
|
||||||
|
# API endpoint: https://nocodb.com/api/v2/tables/TABLE_ID/records
|
||||||
|
|
||||||
|
# Generate API token in NocoDB:
|
||||||
|
# Account Settings → Tokens → Create Token
|
||||||
|
```
|
||||||
|
|
||||||
|
#### RTMP Server
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# For nginx-rtmp:
|
||||||
|
RTMP_SERVER=rtmp://your-server.com:1935/live/stream
|
||||||
|
|
||||||
|
# For other RTMP servers, use their endpoint format
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Storage Paths
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Use separate fast storage for final videos (streaming)
|
||||||
|
RAW_DIR=/mnt/storage/raw_movies # Can be slower storage
|
||||||
|
FINAL_DIR=/mnt/fast-storage/final # Should be fast SSD
|
||||||
|
|
||||||
|
# Ensure proper permissions
|
||||||
|
chown -R scheduler:scheduler /mnt/storage
|
||||||
|
chmod 755 /mnt/storage/raw_movies
|
||||||
|
chmod 755 /mnt/fast-storage/final
|
||||||
|
```
|
||||||
|
|
||||||
|
### Performance Tuning
|
||||||
|
|
||||||
|
#### Sync Intervals
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# High-load scenario (many jobs, frequent updates)
|
||||||
|
NOCODB_SYNC_INTERVAL_SECONDS=120 # Check less often
|
||||||
|
WATCHDOG_CHECK_INTERVAL_SECONDS=15 # Check streams less often
|
||||||
|
|
||||||
|
# Low-latency scenario (need fast response)
|
||||||
|
NOCODB_SYNC_INTERVAL_SECONDS=30
|
||||||
|
WATCHDOG_CHECK_INTERVAL_SECONDS=5
|
||||||
|
|
||||||
|
# Default (balanced)
|
||||||
|
NOCODB_SYNC_INTERVAL_SECONDS=60
|
||||||
|
WATCHDOG_CHECK_INTERVAL_SECONDS=10
|
||||||
|
```
|
||||||
|
|
||||||
|
#### FFmpeg VAAPI
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Find your VAAPI device
|
||||||
|
ls -la /dev/dri/
|
||||||
|
|
||||||
|
# Common devices:
|
||||||
|
# renderD128 - Primary GPU
|
||||||
|
# renderD129 - Secondary GPU
|
||||||
|
|
||||||
|
# Test VAAPI
|
||||||
|
ffmpeg -hwaccel vaapi -vaapi_device /dev/dri/renderD128 -i test.mp4 -f null -
|
||||||
|
|
||||||
|
# Set in config
|
||||||
|
VAAPI_DEVICE=/dev/dri/renderD128
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Security
|
||||||
|
|
||||||
|
### Secrets Management
|
||||||
|
|
||||||
|
**DO NOT hardcode secrets in files tracked by git!**
|
||||||
|
|
||||||
|
#### Option 1: Environment Files (Simple)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Store secrets in protected file
|
||||||
|
sudo nano /etc/scheduler/scheduler.env
|
||||||
|
sudo chmod 600 /etc/scheduler/scheduler.env
|
||||||
|
sudo chown scheduler:scheduler /etc/scheduler/scheduler.env
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Option 2: Secrets Management Tools
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Using Vault
|
||||||
|
export NOCODB_TOKEN=$(vault kv get -field=token secret/scheduler/nocodb)
|
||||||
|
|
||||||
|
# Using AWS Secrets Manager
|
||||||
|
export NOCODB_TOKEN=$(aws secretsmanager get-secret-value --secret-id scheduler/nocodb --query SecretString --output text)
|
||||||
|
|
||||||
|
# Using Docker Secrets (Swarm/Kubernetes)
|
||||||
|
# Mount secrets as files and read in application
|
||||||
|
```
|
||||||
|
|
||||||
|
### Filesystem Permissions
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Application directory
|
||||||
|
chown -R scheduler:scheduler /opt/scheduler
|
||||||
|
chmod 750 /opt/scheduler
|
||||||
|
|
||||||
|
# Storage directories
|
||||||
|
chown -R scheduler:scheduler /mnt/storage
|
||||||
|
chmod 755 /mnt/storage/raw_movies # Read-only for scheduler
|
||||||
|
chmod 755 /mnt/storage/final_movies # Read-write for scheduler
|
||||||
|
|
||||||
|
# Database file
|
||||||
|
chmod 600 /opt/scheduler/scheduler.db
|
||||||
|
chown scheduler:scheduler /opt/scheduler/scheduler.db
|
||||||
|
```
|
||||||
|
|
||||||
|
### Network Security
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Firewall rules (if scheduler runs on separate server)
|
||||||
|
# Only allow outbound connections to NocoDB and RTMP
|
||||||
|
|
||||||
|
sudo ufw allow out to YOUR_NOCODB_IP port 443 # HTTPS
|
||||||
|
sudo ufw allow out to YOUR_RTMP_IP port 1935 # RTMP
|
||||||
|
sudo ufw default deny outgoing # Deny all other outbound (optional)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Regular Updates
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Update system packages weekly
|
||||||
|
sudo apt-get update && sudo apt-get upgrade
|
||||||
|
|
||||||
|
# Update Python dependencies
|
||||||
|
sudo -u scheduler /opt/scheduler/venv/bin/pip install --upgrade requests
|
||||||
|
|
||||||
|
# Rebuild whisper.cpp quarterly (for performance improvements)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Monitoring
|
||||||
|
|
||||||
|
### Service Health
|
||||||
|
|
||||||
|
#### Systemd Monitoring
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Check service status
|
||||||
|
systemctl status scheduler
|
||||||
|
|
||||||
|
# View recent logs
|
||||||
|
journalctl -u scheduler -n 100
|
||||||
|
|
||||||
|
# Follow logs in real-time
|
||||||
|
journalctl -u scheduler -f
|
||||||
|
|
||||||
|
# Check for errors in last hour
|
||||||
|
journalctl -u scheduler --since "1 hour ago" | grep ERROR
|
||||||
|
|
||||||
|
# Service restart count
|
||||||
|
systemctl show scheduler | grep NRestarts
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Docker Monitoring
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Container status
|
||||||
|
docker compose -f docker-compose.prod.yml ps
|
||||||
|
|
||||||
|
# Resource usage
|
||||||
|
docker stats movie_scheduler
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
docker compose -f docker-compose.prod.yml logs --tail=100 -f
|
||||||
|
|
||||||
|
# Health check status
|
||||||
|
docker inspect movie_scheduler | jq '.[0].State.Health'
|
||||||
|
```
|
||||||
|
|
||||||
|
### Database Monitoring
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Check job status
|
||||||
|
sqlite3 /opt/scheduler/scheduler.db "SELECT prep_status, play_status, COUNT(*) FROM jobs GROUP BY prep_status, play_status;"
|
||||||
|
|
||||||
|
# Active streams
|
||||||
|
sqlite3 /opt/scheduler/scheduler.db "SELECT nocodb_id, title, play_status, stream_retry_count FROM jobs WHERE play_status='streaming';"
|
||||||
|
|
||||||
|
# Failed jobs
|
||||||
|
sqlite3 /opt/scheduler/scheduler.db "SELECT nocodb_id, title, prep_status, play_status, log FROM jobs WHERE prep_status='failed' OR play_status='failed';"
|
||||||
|
|
||||||
|
# Database size
|
||||||
|
ls -lh /opt/scheduler/scheduler.db
|
||||||
|
```
|
||||||
|
|
||||||
|
### Automated Monitoring Script
|
||||||
|
|
||||||
|
Create `/opt/scheduler/monitor.sh`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
LOG_FILE="/var/log/scheduler-monitor.log"
|
||||||
|
DB_PATH="/opt/scheduler/scheduler.db"
|
||||||
|
|
||||||
|
echo "=== Scheduler Monitor - $(date) ===" >> "$LOG_FILE"
|
||||||
|
|
||||||
|
# Check if service is running
|
||||||
|
if systemctl is-active --quiet scheduler; then
|
||||||
|
echo "✓ Service is running" >> "$LOG_FILE"
|
||||||
|
else
|
||||||
|
echo "✗ Service is DOWN" >> "$LOG_FILE"
|
||||||
|
# Send alert (email, Slack, etc.)
|
||||||
|
systemctl start scheduler
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check for failed jobs
|
||||||
|
FAILED=$(sqlite3 "$DB_PATH" "SELECT COUNT(*) FROM jobs WHERE prep_status='failed' OR play_status='failed';")
|
||||||
|
if [ "$FAILED" -gt 0 ]; then
|
||||||
|
echo "⚠ Found $FAILED failed jobs" >> "$LOG_FILE"
|
||||||
|
# Send alert
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check active streams
|
||||||
|
STREAMING=$(sqlite3 "$DB_PATH" "SELECT COUNT(*) FROM jobs WHERE play_status='streaming';")
|
||||||
|
echo "Active streams: $STREAMING" >> "$LOG_FILE"
|
||||||
|
|
||||||
|
# Check disk space
|
||||||
|
DISK_USAGE=$(df -h /mnt/storage | tail -1 | awk '{print $5}' | sed 's/%//')
|
||||||
|
if [ "$DISK_USAGE" -gt 90 ]; then
|
||||||
|
echo "⚠ Disk usage is ${DISK_USAGE}%" >> "$LOG_FILE"
|
||||||
|
# Send alert
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "" >> "$LOG_FILE"
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Make executable
|
||||||
|
chmod +x /opt/scheduler/monitor.sh
|
||||||
|
|
||||||
|
# Add to crontab (check every 5 minutes)
|
||||||
|
(crontab -l 2>/dev/null; echo "*/5 * * * * /opt/scheduler/monitor.sh") | crontab -
|
||||||
|
```
|
||||||
|
|
||||||
|
### External Monitoring
|
||||||
|
|
||||||
|
#### Prometheus + Grafana
|
||||||
|
|
||||||
|
Export metrics using node_exporter or custom exporter:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Install node_exporter for system metrics
|
||||||
|
# Create custom exporter for job metrics from database
|
||||||
|
# Set up Grafana dashboard
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Uptime Monitoring
|
||||||
|
|
||||||
|
Use services like:
|
||||||
|
- UptimeRobot
|
||||||
|
- Pingdom
|
||||||
|
- Datadog
|
||||||
|
|
||||||
|
Monitor:
|
||||||
|
- Service availability
|
||||||
|
- RTMP server connectivity
|
||||||
|
- NocoDB API accessibility
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Backup & Recovery
|
||||||
|
|
||||||
|
### What to Backup
|
||||||
|
|
||||||
|
1. **Database** (scheduler.db) - Critical
|
||||||
|
2. **Configuration** (.env.production or /etc/scheduler/scheduler.env) - Critical
|
||||||
|
3. **Final videos** (if you want to keep processed videos)
|
||||||
|
4. **Logs** (optional, for forensics)
|
||||||
|
|
||||||
|
### Backup Script
|
||||||
|
|
||||||
|
Create `/opt/scheduler/backup.sh`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
BACKUP_DIR="/backup/scheduler"
|
||||||
|
DATE=$(date +%Y%m%d_%H%M%S)
|
||||||
|
DB_PATH="/opt/scheduler/scheduler.db"
|
||||||
|
CONFIG_PATH="/etc/scheduler/scheduler.env"
|
||||||
|
|
||||||
|
# Create backup directory
|
||||||
|
mkdir -p "$BACKUP_DIR"
|
||||||
|
|
||||||
|
# Backup database
|
||||||
|
cp "$DB_PATH" "$BACKUP_DIR/scheduler_${DATE}.db"
|
||||||
|
|
||||||
|
# Backup config (careful with secrets!)
|
||||||
|
cp "$CONFIG_PATH" "$BACKUP_DIR/config_${DATE}.env"
|
||||||
|
|
||||||
|
# Compress old backups
|
||||||
|
find "$BACKUP_DIR" -name "*.db" -mtime +7 -exec gzip {} \;
|
||||||
|
|
||||||
|
# Delete backups older than 30 days
|
||||||
|
find "$BACKUP_DIR" -name "*.db.gz" -mtime +30 -delete
|
||||||
|
find "$BACKUP_DIR" -name "*.env" -mtime +30 -delete
|
||||||
|
|
||||||
|
# Optional: Upload to S3/cloud storage
|
||||||
|
# aws s3 sync "$BACKUP_DIR" s3://your-bucket/scheduler-backups/
|
||||||
|
|
||||||
|
echo "Backup completed: $BACKUP_DIR/scheduler_${DATE}.db"
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Make executable
|
||||||
|
chmod +x /opt/scheduler/backup.sh
|
||||||
|
|
||||||
|
# Run daily at 2 AM
|
||||||
|
(crontab -l 2>/dev/null; echo "0 2 * * * /opt/scheduler/backup.sh") | crontab -
|
||||||
|
```
|
||||||
|
|
||||||
|
### Recovery Procedure
|
||||||
|
|
||||||
|
#### 1. Restore from Backup
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Stop service
|
||||||
|
sudo systemctl stop scheduler
|
||||||
|
|
||||||
|
# Restore database
|
||||||
|
cp /backup/scheduler/scheduler_YYYYMMDD_HHMMSS.db /opt/scheduler/scheduler.db
|
||||||
|
chown scheduler:scheduler /opt/scheduler/scheduler.db
|
||||||
|
|
||||||
|
# Restore config if needed
|
||||||
|
cp /backup/scheduler/config_YYYYMMDD_HHMMSS.env /etc/scheduler/scheduler.env
|
||||||
|
|
||||||
|
# Start service
|
||||||
|
sudo systemctl start scheduler
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 2. Disaster Recovery (Full Rebuild)
|
||||||
|
|
||||||
|
If server is completely lost:
|
||||||
|
|
||||||
|
1. Provision new server
|
||||||
|
2. Follow installation steps above
|
||||||
|
3. Restore database and config from backup
|
||||||
|
4. Restart service
|
||||||
|
5. Verify jobs are picked up
|
||||||
|
|
||||||
|
**Recovery Time Objective (RTO):** 30-60 minutes
|
||||||
|
**Recovery Point Objective (RPO):** Up to 24 hours (with daily backups)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Maintenance
|
||||||
|
|
||||||
|
### Routine Tasks
|
||||||
|
|
||||||
|
#### Daily
|
||||||
|
- ✓ Check service status
|
||||||
|
- ✓ Review error logs
|
||||||
|
- ✓ Check failed jobs
|
||||||
|
|
||||||
|
#### Weekly
|
||||||
|
- ✓ Review disk space
|
||||||
|
- ✓ Check database size
|
||||||
|
- ✓ Clean up old processed videos (if not needed)
|
||||||
|
|
||||||
|
#### Monthly
|
||||||
|
- ✓ Update system packages
|
||||||
|
- ✓ Review and optimize database
|
||||||
|
- ✓ Test backup restoration
|
||||||
|
- ✓ Review and rotate logs
|
||||||
|
|
||||||
|
### Database Maintenance
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Vacuum database (reclaim space, optimize)
|
||||||
|
sqlite3 /opt/scheduler/scheduler.db "VACUUM;"
|
||||||
|
|
||||||
|
# Analyze database (update statistics)
|
||||||
|
sqlite3 /opt/scheduler/scheduler.db "ANALYZE;"
|
||||||
|
|
||||||
|
# Clean up old completed jobs (optional)
|
||||||
|
sqlite3 /opt/scheduler/scheduler.db "DELETE FROM jobs WHERE play_status='done' AND datetime(run_at) < datetime('now', '-30 days');"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Log Rotation
|
||||||
|
|
||||||
|
For systemd (automatic via journald):
|
||||||
|
```bash
|
||||||
|
# Configure in /etc/systemd/journald.conf
|
||||||
|
SystemMaxUse=1G
|
||||||
|
RuntimeMaxUse=100M
|
||||||
|
```
|
||||||
|
|
||||||
|
For Docker:
|
||||||
|
```yaml
|
||||||
|
# Already configured in docker-compose.prod.yml
|
||||||
|
logging:
|
||||||
|
driver: "json-file"
|
||||||
|
options:
|
||||||
|
max-size: "10m"
|
||||||
|
max-file: "5"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Video Cleanup
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Clean up old final videos (adjust retention as needed)
|
||||||
|
find /mnt/storage/final_movies -name "*.mp4" -mtime +7 -delete
|
||||||
|
|
||||||
|
# Or move to archive
|
||||||
|
find /mnt/storage/final_movies -name "*.mp4" -mtime +7 -exec mv {} /mnt/archive/ \;
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Service Won't Start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Check service status
|
||||||
|
systemctl status scheduler
|
||||||
|
|
||||||
|
# Check logs for errors
|
||||||
|
journalctl -u scheduler -n 50
|
||||||
|
|
||||||
|
# Common issues:
|
||||||
|
# 1. Missing environment variables
|
||||||
|
grep -i "error" /var/log/syslog | grep scheduler
|
||||||
|
|
||||||
|
# 2. Permission issues
|
||||||
|
ls -la /opt/scheduler
|
||||||
|
ls -la /mnt/storage
|
||||||
|
|
||||||
|
# 3. GPU access issues
|
||||||
|
ls -la /dev/dri/
|
||||||
|
groups scheduler # Should include 'video' and 'render'
|
||||||
|
```
|
||||||
|
|
||||||
|
### Streams Keep Failing
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Test RTMP server manually
|
||||||
|
ffmpeg -re -i test.mp4 -c copy -f flv rtmp://your-server/live/stream
|
||||||
|
|
||||||
|
# Check network connectivity
|
||||||
|
ping your-rtmp-server.com
|
||||||
|
telnet your-rtmp-server.com 1935
|
||||||
|
|
||||||
|
# Review stream logs
|
||||||
|
journalctl -u scheduler | grep -A 10 "Stream crashed"
|
||||||
|
|
||||||
|
# Check retry count
|
||||||
|
sqlite3 /opt/scheduler/scheduler.db "SELECT nocodb_id, title, stream_retry_count FROM jobs WHERE play_status='streaming';"
|
||||||
|
```
|
||||||
|
|
||||||
|
### High CPU/Memory Usage
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Check resource usage
|
||||||
|
top -u scheduler
|
||||||
|
|
||||||
|
# Or for Docker
|
||||||
|
docker stats movie_scheduler
|
||||||
|
|
||||||
|
# Common causes:
|
||||||
|
# 1. Large video file encoding - normal, wait for completion
|
||||||
|
# 2. whisper.cpp using all cores - normal
|
||||||
|
# 3. Multiple prep jobs running - adjust or wait
|
||||||
|
|
||||||
|
# Limit resources if needed (systemd)
|
||||||
|
systemctl edit scheduler
|
||||||
|
# Add:
|
||||||
|
[Service]
|
||||||
|
CPUQuota=200%
|
||||||
|
MemoryMax=4G
|
||||||
|
```
|
||||||
|
|
||||||
|
### Database Locked Errors
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Check for stale locks
|
||||||
|
lsof /opt/scheduler/scheduler.db
|
||||||
|
|
||||||
|
# Kill stale processes if needed
|
||||||
|
# Restart service
|
||||||
|
systemctl restart scheduler
|
||||||
|
```
|
||||||
|
|
||||||
|
### VAAPI Not Working
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Verify VAAPI support
|
||||||
|
vainfo
|
||||||
|
|
||||||
|
# Test FFmpeg VAAPI
|
||||||
|
ffmpeg -hwaccels
|
||||||
|
|
||||||
|
# Check permissions
|
||||||
|
ls -la /dev/dri/renderD128
|
||||||
|
groups scheduler # Should include 'video' or 'render'
|
||||||
|
|
||||||
|
# Fallback to software encoding
|
||||||
|
# Comment out VAAPI_DEVICE in config
|
||||||
|
# Encoding will use CPU (slower but works)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Performance Optimization
|
||||||
|
|
||||||
|
### Hardware Acceleration
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Verify GPU usage during encoding
|
||||||
|
intel_gpu_top # For Intel GPUs
|
||||||
|
radeontop # For AMD GPUs
|
||||||
|
|
||||||
|
# If GPU not being used, check:
|
||||||
|
# 1. VAAPI device path correct
|
||||||
|
# 2. User has GPU permissions
|
||||||
|
# 3. FFmpeg compiled with VAAPI support
|
||||||
|
```
|
||||||
|
|
||||||
|
### Storage Performance
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Use SSD for final videos (they're streamed frequently)
|
||||||
|
# Use HDD for raw videos (accessed once for processing)
|
||||||
|
|
||||||
|
# Test disk performance
|
||||||
|
dd if=/dev/zero of=/mnt/storage/test bs=1M count=1024 oflag=direct
|
||||||
|
rm /mnt/storage/test
|
||||||
|
```
|
||||||
|
|
||||||
|
### Network Optimization
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# For better streaming reliability
|
||||||
|
# 1. Use dedicated network for RTMP
|
||||||
|
# 2. Enable QoS for streaming traffic
|
||||||
|
# 3. Consider local RTMP relay
|
||||||
|
|
||||||
|
# Test network throughput
|
||||||
|
iperf3 -c your-rtmp-server.com
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Production Checklist
|
||||||
|
|
||||||
|
Before going live:
|
||||||
|
|
||||||
|
- [ ] Secrets stored securely (not in git)
|
||||||
|
- [ ] Service auto-starts on boot
|
||||||
|
- [ ] Backups configured and tested
|
||||||
|
- [ ] Monitoring configured
|
||||||
|
- [ ] Logs being rotated
|
||||||
|
- [ ] Disk space alerts configured
|
||||||
|
- [ ] Test recovery procedure
|
||||||
|
- [ ] Document runbook for on-call
|
||||||
|
- [ ] GPU permissions verified
|
||||||
|
- [ ] RTMP connectivity tested
|
||||||
|
- [ ] NocoDB API tested
|
||||||
|
- [ ] Process one test video end-to-end
|
||||||
|
- [ ] Verify streaming watchdog works
|
||||||
|
- [ ] Test service restart during streaming
|
||||||
|
- [ ] Configure alerting for failures
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Support & Updates
|
||||||
|
|
||||||
|
### Getting Updates
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Git-based deployment
|
||||||
|
cd /opt/scheduler
|
||||||
|
git pull origin main
|
||||||
|
systemctl restart scheduler
|
||||||
|
|
||||||
|
# Docker-based deployment
|
||||||
|
cd /opt/scheduler
|
||||||
|
docker compose -f docker-compose.prod.yml pull
|
||||||
|
docker compose -f docker-compose.prod.yml up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
### Reporting Issues
|
||||||
|
|
||||||
|
Include in bug reports:
|
||||||
|
- Service logs: `journalctl -u scheduler -n 100`
|
||||||
|
- Database state: `sqlite3 scheduler.db ".dump jobs"`
|
||||||
|
- System info: `uname -a`, `python3 --version`, `ffmpeg -version`
|
||||||
|
- Configuration (redact secrets!)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Additional Resources
|
||||||
|
|
||||||
|
- FFmpeg VAAPI Guide: https://trac.ffmpeg.org/wiki/Hardware/VAAPI
|
||||||
|
- whisper.cpp: https://github.com/ggerganov/whisper.cpp
|
||||||
|
- NocoDB API: https://docs.nocodb.com
|
||||||
|
- Systemd Documentation: https://www.freedesktop.org/software/systemd/man/
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
This production guide is provided as-is. Test thoroughly in staging before production deployment.
|
||||||
|
|
@ -0,0 +1,314 @@
|
||||||
|
# Movie Scheduler
|
||||||
|
|
||||||
|
Automated movie scheduler that reads from NocoDB, generates subtitles with whisper.cpp, burns them into videos with VAAPI encoding, and streams at scheduled times.
|
||||||
|
|
||||||
|
## Quick Links
|
||||||
|
|
||||||
|
- **[Production Deployment Guide](PRODUCTION.md)** - Complete guide for production setup
|
||||||
|
- **[Testing Guide](TESTING.md)** - Docker-based test environment
|
||||||
|
- **[Development Setup](#installation)** - Local development setup
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- **NocoDB Integration**: Syncs job schedules from NocoDB database
|
||||||
|
- **Automatic Subtitle Generation**: Uses whisper.cpp to generate SRT subtitles
|
||||||
|
- **Hardware-Accelerated Encoding**: Burns subtitles into videos using VAAPI (h264_vaapi)
|
||||||
|
- **Scheduled Streaming**: Streams videos to RTMP server at scheduled times
|
||||||
|
- **Robust Error Handling**: Automatic retries with exponential backoff
|
||||||
|
- **Comprehensive Logging**: Tracks all operations in database log column
|
||||||
|
- **Process Management**: Properly tracks and manages streaming processes
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
- Python 3.7+
|
||||||
|
- `requests` library
|
||||||
|
- `whisper.cpp` installed and available in PATH
|
||||||
|
- `ffmpeg` with VAAPI support
|
||||||
|
- VAAPI-capable hardware (AMD/Intel GPU)
|
||||||
|
- NocoDB instance with scheduled jobs table
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
1. Clone or download this repository
|
||||||
|
|
||||||
|
2. Install Python dependencies:
|
||||||
|
```bash
|
||||||
|
pip install requests
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Ensure whisper.cpp is installed:
|
||||||
|
```bash
|
||||||
|
# Follow whisper.cpp installation instructions
|
||||||
|
# Make sure the binary is in your PATH
|
||||||
|
which whisper.cpp
|
||||||
|
```
|
||||||
|
|
||||||
|
4. Verify FFmpeg has VAAPI support:
|
||||||
|
```bash
|
||||||
|
ffmpeg -hwaccels | grep vaapi
|
||||||
|
```
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
1. Copy the example environment file:
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Edit `.env` and set the required variables:
|
||||||
|
```bash
|
||||||
|
# Required
|
||||||
|
export NOCODB_URL="https://nocodb/api/v2/tables/XXXX/records"
|
||||||
|
export NOCODB_TOKEN="your_token_here"
|
||||||
|
export RTMP_SERVER="rtmp://your_server/live"
|
||||||
|
|
||||||
|
# Optional (defaults provided)
|
||||||
|
export RAW_DIR="/root/surowe_filmy"
|
||||||
|
export FINAL_DIR="/root/przygotowane_filmy"
|
||||||
|
export WHISPER_MODEL="/root/models/ggml-base.bin"
|
||||||
|
export VAAPI_DEVICE="/dev/dri/renderD128"
|
||||||
|
export STREAM_GRACE_PERIOD_MINUTES="15"
|
||||||
|
export NOCODB_SYNC_INTERVAL_SECONDS="60"
|
||||||
|
export WATCHDOG_CHECK_INTERVAL_SECONDS="10"
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Load the environment variables:
|
||||||
|
```bash
|
||||||
|
source .env
|
||||||
|
```
|
||||||
|
|
||||||
|
## NocoDB Table Structure
|
||||||
|
|
||||||
|
Your NocoDB table should have the following fields:
|
||||||
|
|
||||||
|
- `Id` - Unique identifier (Text/Primary Key)
|
||||||
|
- `Title` - Movie title matching filename (Text)
|
||||||
|
- `Date` - Scheduled run time (DateTime in ISO format)
|
||||||
|
|
||||||
|
The scheduler will automatically create additional columns in the local SQLite database:
|
||||||
|
- `prep_at` - Preparation time (6 hours before run_at)
|
||||||
|
- `prep_status` - Preparation status (pending/done/failed)
|
||||||
|
- `play_status` - Streaming status (pending/done/failed)
|
||||||
|
- `raw_path` - Path to source video file
|
||||||
|
- `final_path` - Path to converted video with subtitles
|
||||||
|
- `log` - Detailed operation log
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
### Running the Scheduler
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Ensure environment variables are set
|
||||||
|
source .env
|
||||||
|
|
||||||
|
# Run the scheduler
|
||||||
|
python agent.py
|
||||||
|
```
|
||||||
|
|
||||||
|
The scheduler will:
|
||||||
|
1. Sync jobs from NocoDB every 60 seconds (configurable)
|
||||||
|
2. Check stream health every 10 seconds (configurable)
|
||||||
|
3. Prepare videos 6 hours before their scheduled time:
|
||||||
|
- Find matching video file in RAW_DIR
|
||||||
|
- Generate subtitles with whisper.cpp
|
||||||
|
- Encode video with burned subtitles using VAAPI
|
||||||
|
4. Stream videos at their scheduled time to RTMP server
|
||||||
|
|
||||||
|
### Stopping the Scheduler
|
||||||
|
|
||||||
|
Press `Ctrl+C` to gracefully stop the scheduler. It will:
|
||||||
|
- Stop any active streaming process
|
||||||
|
- Close the database connection
|
||||||
|
- Exit cleanly
|
||||||
|
|
||||||
|
### Restart & Recovery Behavior
|
||||||
|
|
||||||
|
The scheduler handles restarts, power outages, and downtime gracefully:
|
||||||
|
|
||||||
|
**On Startup:**
|
||||||
|
- ✅ Checks for overdue prep jobs and processes them immediately
|
||||||
|
- ⚠️ Skips jobs where the streaming time has already passed
|
||||||
|
- 📊 Logs recovery status (overdue/skipped jobs found)
|
||||||
|
|
||||||
|
**Grace Period for Late Streaming:**
|
||||||
|
- ⏰ If prep is done and streaming time is overdue by **up to 15 minutes**, will still start streaming
|
||||||
|
- ⚠️ Jobs more than 15 minutes late are marked as 'skipped'
|
||||||
|
- 📝 Late starts are logged with exact delay time
|
||||||
|
|
||||||
|
**Recovery Scenarios:**
|
||||||
|
|
||||||
|
1. **Short Outage (prep time missed, but streaming time not reached)**
|
||||||
|
- Movie scheduled for 21:00 (prep at 15:00)
|
||||||
|
- System down from 14:00-16:00
|
||||||
|
- ✅ Restarts at 16:00: immediately preps the movie
|
||||||
|
- ✅ Streams normally at 21:00
|
||||||
|
|
||||||
|
2. **Late Start Within Grace Period (< 15 minutes)**
|
||||||
|
- Movie scheduled for 21:00 (prep completed at 15:00)
|
||||||
|
- System down from 20:00-21:10
|
||||||
|
- ✅ Restarts at 21:10: starts streaming immediately (10 minutes late)
|
||||||
|
- 📝 Logged: "Starting stream 10.0 minutes late"
|
||||||
|
|
||||||
|
3. **Too Late to Stream (> 15 minutes)**
|
||||||
|
- Movie scheduled for 21:00 (prep completed)
|
||||||
|
- System down from 20:00-21:20
|
||||||
|
- ⚠️ Restarts at 21:20: marks streaming as 'skipped' (>15 min late)
|
||||||
|
- 📝 Logged: "Streaming skipped - more than 15 minutes late"
|
||||||
|
|
||||||
|
4. **Long Outage (both prep and streaming times passed, >15 min)**
|
||||||
|
- Movie scheduled for 21:00 (prep at 15:00)
|
||||||
|
- System down from 14:00-22:00
|
||||||
|
- ⚠️ Restarts at 22:00: marks entire job as 'skipped'
|
||||||
|
- 📝 Logged: "Job skipped - streaming time already passed"
|
||||||
|
|
||||||
|
5. **Crash During Processing**
|
||||||
|
- System crashes during subtitle generation or encoding
|
||||||
|
- ✅ On restart: retries from the beginning with full retry logic
|
||||||
|
- All operations are idempotent (safe to re-run)
|
||||||
|
|
||||||
|
**Database Persistence:**
|
||||||
|
- Job status stored in `scheduler.db` survives restarts
|
||||||
|
- Completed preps are never re-done
|
||||||
|
- Failed jobs stay marked as 'failed' (can be reset manually to 'pending' if needed)
|
||||||
|
|
||||||
|
## File Naming
|
||||||
|
|
||||||
|
Place your raw video files in `RAW_DIR` with names that contain the title from NocoDB.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
- NocoDB Title: `"The Matrix"`
|
||||||
|
- Valid filenames: `The Matrix.mkv`, `the-matrix-1999.mp4`, `[1080p] The Matrix.avi`
|
||||||
|
|
||||||
|
The scheduler uses glob matching to find files containing the title.
|
||||||
|
|
||||||
|
## Streaming Watchdog
|
||||||
|
|
||||||
|
The scheduler includes an active watchdog that monitors streaming:
|
||||||
|
|
||||||
|
**Stream Monitoring (checked every 10 seconds):**
|
||||||
|
- ✅ Detects if ffmpeg stream crashes or exits unexpectedly
|
||||||
|
- ✅ Automatically restarts stream at the correct playback position
|
||||||
|
- ✅ Calculates elapsed time since stream start
|
||||||
|
- ✅ Seeks to correct position using `ffmpeg -ss` flag
|
||||||
|
- ✅ Limits restarts to 10 attempts to prevent infinite loops
|
||||||
|
- ✅ Marks stream as 'done' when video completes normally
|
||||||
|
- ✅ Marks stream as 'failed' after 10 failed restart attempts
|
||||||
|
|
||||||
|
**How It Works:**
|
||||||
|
1. Stream starts and status set to 'streaming' (not 'done')
|
||||||
|
2. Watchdog checks every 10 seconds if stream process is running
|
||||||
|
3. If crashed: calculates elapsed time and restarts with seek
|
||||||
|
4. If completed normally (exit code 0): marks as 'done'
|
||||||
|
5. If video duration exceeded: marks as 'done'
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
```
|
||||||
|
Stream starts at 21:00:00
|
||||||
|
Stream crashes at 21:05:30 (5.5 minutes in)
|
||||||
|
Watchdog detects crash within 10 seconds
|
||||||
|
Restarts stream with: ffmpeg -ss 330 -i video.mp4
|
||||||
|
Stream resumes from 5:30 mark
|
||||||
|
```
|
||||||
|
|
||||||
|
## Error Handling
|
||||||
|
|
||||||
|
The scheduler includes robust error handling:
|
||||||
|
|
||||||
|
- **Automatic Retries**: Failed operations are retried up to 3 times with exponential backoff
|
||||||
|
- **Database Logging**: All operations and errors are logged to the `log` column
|
||||||
|
- **Status Tracking**: Jobs are marked as 'failed' if all retries are exhausted
|
||||||
|
- **File Verification**: Checks that subtitle files and encoded videos were created successfully
|
||||||
|
|
||||||
|
## Monitoring
|
||||||
|
|
||||||
|
### Check Job Status
|
||||||
|
|
||||||
|
Query the SQLite database:
|
||||||
|
```bash
|
||||||
|
sqlite3 scheduler.db "SELECT nocodb_id, title, prep_status, play_status FROM jobs;"
|
||||||
|
```
|
||||||
|
|
||||||
|
### View Job Logs
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sqlite3 scheduler.db "SELECT nocodb_id, title, log FROM jobs WHERE nocodb_id='YOUR_JOB_ID';"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Console Output
|
||||||
|
|
||||||
|
The scheduler logs to stdout with timestamps:
|
||||||
|
- INFO: Normal operations
|
||||||
|
- WARNING: Retry attempts
|
||||||
|
- ERROR: Failures
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Configuration Validation Failed
|
||||||
|
- Ensure all required environment variables are set
|
||||||
|
- Check that NOCODB_URL is accessible
|
||||||
|
- Verify NOCODB_TOKEN has proper permissions
|
||||||
|
|
||||||
|
### No Files Found Matching Title
|
||||||
|
- Check that video files exist in RAW_DIR
|
||||||
|
- Ensure filenames contain the NocoDB title (case-insensitive glob matching)
|
||||||
|
- Review the log column in the database for exact error messages
|
||||||
|
|
||||||
|
### Subtitle Generation Failed
|
||||||
|
- Verify whisper.cpp is installed: `which whisper.cpp`
|
||||||
|
- Check WHISPER_MODEL path is correct
|
||||||
|
- Ensure the model file exists and is readable
|
||||||
|
|
||||||
|
### Video Encoding Failed
|
||||||
|
- Confirm VAAPI is available: `ffmpeg -hwaccels | grep vaapi`
|
||||||
|
- Check VAAPI device exists: `ls -l /dev/dri/renderD128`
|
||||||
|
- Verify subtitle file was created successfully
|
||||||
|
- Review FFmpeg error output in the log column
|
||||||
|
|
||||||
|
### Streaming Failed
|
||||||
|
- Test RTMP server connectivity: `ffmpeg -re -i test.mp4 -c copy -f flv rtmp://your_server/live`
|
||||||
|
- Ensure final video file exists
|
||||||
|
- Check network connectivity to RTMP server
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
### Database Schema
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE jobs (
|
||||||
|
nocodb_id TEXT PRIMARY KEY,
|
||||||
|
title TEXT,
|
||||||
|
run_at TIMESTAMP,
|
||||||
|
prep_at TIMESTAMP,
|
||||||
|
raw_path TEXT,
|
||||||
|
final_path TEXT,
|
||||||
|
prep_status TEXT, -- pending/done/failed/skipped
|
||||||
|
play_status TEXT, -- pending/streaming/done/failed/skipped
|
||||||
|
log TEXT,
|
||||||
|
stream_start_time TIMESTAMP, -- When streaming started (for restart seek)
|
||||||
|
stream_retry_count INTEGER -- Number of stream restart attempts
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
**Status Values:**
|
||||||
|
- `play_status='streaming'`: Stream is actively running
|
||||||
|
- `play_status='done'`: Stream completed successfully
|
||||||
|
- `play_status='failed'`: Stream failed after 10 restart attempts
|
||||||
|
- `play_status='skipped'`: Too late to stream (>15 min past scheduled time)
|
||||||
|
|
||||||
|
### Customization
|
||||||
|
|
||||||
|
Use environment variables to customize:
|
||||||
|
- `NOCODB_SYNC_INTERVAL_SECONDS` - How often to check NocoDB for new jobs (default: 60)
|
||||||
|
- `WATCHDOG_CHECK_INTERVAL_SECONDS` - How often to monitor streams (default: 10)
|
||||||
|
- `STREAM_GRACE_PERIOD_MINUTES` - Late start tolerance (default: 15)
|
||||||
|
|
||||||
|
Edit `agent.py` to customize:
|
||||||
|
- Retry attempts and delay (agent.py:99, 203, 232)
|
||||||
|
- Preparation time offset (currently 6 hours, see sync function)
|
||||||
|
- Stream restart limit (agent.py:572)
|
||||||
|
- Subtitle styling (agent.py:246)
|
||||||
|
- Video encoding parameters (agent.py:248-250)
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
This project is provided as-is without warranty.
|
||||||
|
|
@ -0,0 +1,486 @@
|
||||||
|
# Movie Scheduler - Testing Environment
|
||||||
|
|
||||||
|
Complete Docker-based testing environment for the movie scheduler. Fire and forget - just run one command and everything is set up automatically!
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./start-test-environment.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
That's it! The script will:
|
||||||
|
1. Build all Docker containers
|
||||||
|
2. Start PostgreSQL, NocoDB, and RTMP server
|
||||||
|
3. Initialize NocoDB with test data
|
||||||
|
4. Download a test video (Big Buck Bunny)
|
||||||
|
5. Start the scheduler
|
||||||
|
|
||||||
|
## Services
|
||||||
|
|
||||||
|
After running the startup script, you'll have:
|
||||||
|
|
||||||
|
### NocoDB Dashboard
|
||||||
|
- **URL**: http://localhost:8080
|
||||||
|
- **Email**: admin@test.com
|
||||||
|
- **Password**: admin123
|
||||||
|
|
||||||
|
Login and view the "Movies" table to see scheduled movies and their status.
|
||||||
|
|
||||||
|
### RTMP Streaming Server
|
||||||
|
- **Stream URL**: rtmp://localhost:1935/live/stream
|
||||||
|
- **Statistics**: http://localhost:8081/stat
|
||||||
|
|
||||||
|
This is where processed movies will be streamed.
|
||||||
|
|
||||||
|
### Movie Scheduler
|
||||||
|
Runs in the background, automatically:
|
||||||
|
- Syncs movies from NocoDB every 60 seconds (configurable)
|
||||||
|
- Monitors stream health every 10 seconds (configurable)
|
||||||
|
- Prepares videos 6 hours before scheduled time
|
||||||
|
- Streams at the scheduled time
|
||||||
|
|
||||||
|
## Testing the Workflow
|
||||||
|
|
||||||
|
### 1. View Initial Data
|
||||||
|
|
||||||
|
The test environment comes with a pre-loaded movie:
|
||||||
|
- **Title**: BigBuckBunny
|
||||||
|
- **Scheduled**: 5 minutes from startup
|
||||||
|
|
||||||
|
Open NocoDB to see this entry.
|
||||||
|
|
||||||
|
### 2. Monitor the Scheduler
|
||||||
|
|
||||||
|
Watch the scheduler logs:
|
||||||
|
```bash
|
||||||
|
docker compose logs -f scheduler
|
||||||
|
```
|
||||||
|
|
||||||
|
You'll see:
|
||||||
|
- Syncing from NocoDB
|
||||||
|
- Preparation jobs (if within 6 hours of scheduled time)
|
||||||
|
- Subtitle generation
|
||||||
|
- Video encoding
|
||||||
|
- Streaming start
|
||||||
|
|
||||||
|
### 3. Check the Database
|
||||||
|
|
||||||
|
Query the local SQLite database:
|
||||||
|
```bash
|
||||||
|
sqlite3 scheduler.db "SELECT nocodb_id, title, prep_status, play_status FROM jobs;"
|
||||||
|
```
|
||||||
|
|
||||||
|
View detailed logs:
|
||||||
|
```bash
|
||||||
|
sqlite3 scheduler.db "SELECT title, log FROM jobs WHERE title='BigBuckBunny';"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Watch the Stream
|
||||||
|
|
||||||
|
When a movie starts streaming, watch it with VLC:
|
||||||
|
```bash
|
||||||
|
vlc rtmp://localhost:1935/live/stream
|
||||||
|
```
|
||||||
|
|
||||||
|
Or use ffplay:
|
||||||
|
```bash
|
||||||
|
ffplay rtmp://localhost:1935/live/stream
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. Add Your Own Movies
|
||||||
|
|
||||||
|
#### Option A: Through NocoDB UI
|
||||||
|
|
||||||
|
1. Open http://localhost:8080
|
||||||
|
2. Navigate to the Movies table
|
||||||
|
3. Click "+ Add New Row"
|
||||||
|
4. Fill in:
|
||||||
|
- **Title**: Name matching your video file
|
||||||
|
- **Date**: Schedule time (ISO format: 2024-01-21T20:00:00)
|
||||||
|
- Other fields are optional
|
||||||
|
5. Place video file in `raw_movies` volume
|
||||||
|
|
||||||
|
#### Option B: Add Video File Manually
|
||||||
|
|
||||||
|
Copy a video to the raw movies volume:
|
||||||
|
```bash
|
||||||
|
# Find the volume name
|
||||||
|
docker volume ls | grep raw_movies
|
||||||
|
|
||||||
|
# Copy your video
|
||||||
|
docker run --rm -v scheduler_raw_movies:/data -v $(pwd):/host alpine \
|
||||||
|
cp /host/your-movie.mp4 /data/
|
||||||
|
```
|
||||||
|
|
||||||
|
Then add the entry in NocoDB with matching Title.
|
||||||
|
|
||||||
|
## Testing Restart & Recovery
|
||||||
|
|
||||||
|
### Test Overdue Job Processing
|
||||||
|
|
||||||
|
1. Add a movie scheduled for 15 minutes from now
|
||||||
|
2. Stop the scheduler:
|
||||||
|
```bash
|
||||||
|
docker compose stop scheduler
|
||||||
|
```
|
||||||
|
3. Wait 20 minutes (past the prep time)
|
||||||
|
4. Restart the scheduler:
|
||||||
|
```bash
|
||||||
|
docker compose start scheduler
|
||||||
|
docker compose logs -f scheduler
|
||||||
|
```
|
||||||
|
5. Watch it immediately process the overdue prep job
|
||||||
|
|
||||||
|
### Test 15-Minute Grace Period for Streaming
|
||||||
|
|
||||||
|
1. Add a movie scheduled for 5 minutes from now (with prep already done)
|
||||||
|
2. Stop the scheduler after prep completes:
|
||||||
|
```bash
|
||||||
|
docker compose stop scheduler
|
||||||
|
```
|
||||||
|
3. Wait 10 minutes (past streaming time but within 15-min grace period)
|
||||||
|
4. Restart and watch logs:
|
||||||
|
```bash
|
||||||
|
docker compose start scheduler
|
||||||
|
docker compose logs -f scheduler
|
||||||
|
```
|
||||||
|
5. Stream should start immediately with "Starting stream X.X minutes late" message
|
||||||
|
|
||||||
|
### Test Skipping Late Streams (>15 minutes)
|
||||||
|
|
||||||
|
1. Add a movie scheduled for 5 minutes from now
|
||||||
|
2. Let prep complete, then stop the scheduler:
|
||||||
|
```bash
|
||||||
|
docker compose stop scheduler
|
||||||
|
```
|
||||||
|
3. Wait 20 minutes (past the 15-minute grace period)
|
||||||
|
4. Restart and watch logs:
|
||||||
|
```bash
|
||||||
|
docker compose start scheduler
|
||||||
|
docker compose logs -f scheduler
|
||||||
|
```
|
||||||
|
5. Job should be marked as 'skipped' with "more than 15 minutes late"
|
||||||
|
|
||||||
|
### Test Skipping Unprepared Expired Jobs
|
||||||
|
|
||||||
|
1. Add a movie scheduled for 5 minutes from now
|
||||||
|
2. Stop the scheduler before prep starts:
|
||||||
|
```bash
|
||||||
|
docker compose stop scheduler
|
||||||
|
```
|
||||||
|
3. Wait 10 minutes (past both prep and streaming time)
|
||||||
|
4. Restart and watch logs:
|
||||||
|
```bash
|
||||||
|
docker compose start scheduler
|
||||||
|
docker compose logs -f scheduler
|
||||||
|
```
|
||||||
|
5. Job should be marked as 'skipped' (too late to prep)
|
||||||
|
|
||||||
|
### Test Recovery from Crash During Processing
|
||||||
|
|
||||||
|
1. Start processing a large video file
|
||||||
|
2. Kill the scheduler during encoding:
|
||||||
|
```bash
|
||||||
|
docker compose kill scheduler
|
||||||
|
```
|
||||||
|
3. Restart:
|
||||||
|
```bash
|
||||||
|
docker compose start scheduler
|
||||||
|
```
|
||||||
|
4. Watch it retry the entire operation from the beginning
|
||||||
|
|
||||||
|
## Testing Stream Watchdog
|
||||||
|
|
||||||
|
### Test Stream Crash and Auto-Restart
|
||||||
|
|
||||||
|
1. Start a stream:
|
||||||
|
```bash
|
||||||
|
docker compose logs -f scheduler
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Find the streaming ffmpeg process and kill it:
|
||||||
|
```bash
|
||||||
|
# In another terminal
|
||||||
|
docker compose exec scheduler ps aux | grep ffmpeg
|
||||||
|
docker compose exec scheduler kill -9 <PID>
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Watch the scheduler logs - within 10 seconds it should:
|
||||||
|
- Detect the crash
|
||||||
|
- Calculate elapsed playback time
|
||||||
|
- Restart with seek to correct position
|
||||||
|
- Log: "Restarting stream at position X.Xs"
|
||||||
|
|
||||||
|
### Test Stream Restart with Correct Position
|
||||||
|
|
||||||
|
1. Start a test stream with a longer video
|
||||||
|
2. Let it run for 2-3 minutes
|
||||||
|
3. Kill the ffmpeg process
|
||||||
|
4. Watch logs confirm restart with seek:
|
||||||
|
```
|
||||||
|
Restarting stream for job XXX at position 180.0s (attempt 2)
|
||||||
|
```
|
||||||
|
5. Use VLC to confirm playback resumed at correct position
|
||||||
|
|
||||||
|
### Test Stream Completion Detection
|
||||||
|
|
||||||
|
1. Use a short test video (1-2 minutes)
|
||||||
|
2. Watch stream until completion
|
||||||
|
3. Check logs - should show:
|
||||||
|
```
|
||||||
|
Stream completed successfully for job XXX
|
||||||
|
```
|
||||||
|
4. Check database - play_status should be 'done'
|
||||||
|
|
||||||
|
### Test Retry Limit
|
||||||
|
|
||||||
|
1. Break the RTMP server or use invalid RTMP URL
|
||||||
|
2. Start a stream - it will crash immediately
|
||||||
|
3. Watch the scheduler attempt 10 restarts
|
||||||
|
4. After 10 attempts, should mark as 'failed':
|
||||||
|
```
|
||||||
|
Stream retry limit exceeded for job XXX
|
||||||
|
ERROR: Stream failed after 10 restart attempts
|
||||||
|
```
|
||||||
|
|
||||||
|
### Test Network Interruption Recovery
|
||||||
|
|
||||||
|
1. Start a stream
|
||||||
|
2. Stop the RTMP server:
|
||||||
|
```bash
|
||||||
|
docker compose stop rtmp
|
||||||
|
```
|
||||||
|
3. Watch stream fail and retry
|
||||||
|
4. Restart RTMP:
|
||||||
|
```bash
|
||||||
|
docker compose start rtmp
|
||||||
|
```
|
||||||
|
5. Stream should resume at correct position
|
||||||
|
|
||||||
|
## Testing Error Handling
|
||||||
|
|
||||||
|
### Test Retry Logic
|
||||||
|
|
||||||
|
1. Stop the RTMP server:
|
||||||
|
```bash
|
||||||
|
docker compose stop rtmp
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Watch the scheduler handle the failure and retry:
|
||||||
|
```bash
|
||||||
|
docker compose logs -f scheduler
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Restart RTMP:
|
||||||
|
```bash
|
||||||
|
docker compose start rtmp
|
||||||
|
```
|
||||||
|
|
||||||
|
### Test Missing File
|
||||||
|
|
||||||
|
1. Add a movie entry in NocoDB with a title that doesn't match any file
|
||||||
|
2. Set the Date to trigger immediately
|
||||||
|
3. Watch the scheduler log the error and mark it as failed
|
||||||
|
|
||||||
|
### Test Subtitle Generation Failure
|
||||||
|
|
||||||
|
1. Add an invalid video file (corrupted or wrong format)
|
||||||
|
2. Watch the scheduler attempt retries with exponential backoff
|
||||||
|
|
||||||
|
## Viewing Results
|
||||||
|
|
||||||
|
### Processed Videos
|
||||||
|
|
||||||
|
List final videos with burned-in subtitles:
|
||||||
|
```bash
|
||||||
|
docker run --rm -v scheduler_final_movies:/data alpine ls -lh /data
|
||||||
|
```
|
||||||
|
|
||||||
|
### Raw Videos
|
||||||
|
|
||||||
|
List source videos:
|
||||||
|
```bash
|
||||||
|
docker run --rm -v scheduler_raw_movies:/data alpine ls -lh /data
|
||||||
|
```
|
||||||
|
|
||||||
|
### RTMP Recordings (if enabled)
|
||||||
|
|
||||||
|
Recordings are saved to the rtmp_recordings volume:
|
||||||
|
```bash
|
||||||
|
docker run --rm -v scheduler_rtmp_recordings:/data alpine ls -lh /data/recordings
|
||||||
|
```
|
||||||
|
|
||||||
|
## Customization
|
||||||
|
|
||||||
|
### Change Test Video Scheduling
|
||||||
|
|
||||||
|
Edit `init-data.sh` before starting, around line 130:
|
||||||
|
```python
|
||||||
|
"Date": (datetime.now() + timedelta(minutes=5)).isoformat(),
|
||||||
|
```
|
||||||
|
|
||||||
|
Change `minutes=5` to adjust when the test movie is scheduled.
|
||||||
|
|
||||||
|
### Use Different NocoDB Credentials
|
||||||
|
|
||||||
|
Edit `docker-compose.yml`:
|
||||||
|
```yaml
|
||||||
|
environment:
|
||||||
|
NOCODB_EMAIL: "your@email.com"
|
||||||
|
NOCODB_PASSWORD: "yourpassword"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Test with Real NocoDB Instance
|
||||||
|
|
||||||
|
Comment out the `nocodb` and `postgres` services in `docker-compose.yml` and update the `init` service environment:
|
||||||
|
```yaml
|
||||||
|
environment:
|
||||||
|
NOCODB_URL: "https://your-nocodb-instance.com"
|
||||||
|
NOCODB_EMAIL: "your@email.com"
|
||||||
|
NOCODB_PASSWORD: "yourpassword"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Services Won't Start
|
||||||
|
|
||||||
|
Check if ports are already in use:
|
||||||
|
```bash
|
||||||
|
lsof -i :8080 # NocoDB
|
||||||
|
lsof -i :1935 # RTMP
|
||||||
|
lsof -i :8081 # RTMP stats
|
||||||
|
```
|
||||||
|
|
||||||
|
### Initialization Fails
|
||||||
|
|
||||||
|
View init logs:
|
||||||
|
```bash
|
||||||
|
docker compose logs init
|
||||||
|
```
|
||||||
|
|
||||||
|
Common issues:
|
||||||
|
- NocoDB not ready yet (increase wait time in init-data.sh)
|
||||||
|
- Network connectivity issues
|
||||||
|
- Insufficient disk space
|
||||||
|
|
||||||
|
### Scheduler Not Processing
|
||||||
|
|
||||||
|
Check if it's running:
|
||||||
|
```bash
|
||||||
|
docker compose ps scheduler
|
||||||
|
```
|
||||||
|
|
||||||
|
View logs for errors:
|
||||||
|
```bash
|
||||||
|
docker compose logs scheduler
|
||||||
|
```
|
||||||
|
|
||||||
|
Restart if needed:
|
||||||
|
```bash
|
||||||
|
docker compose restart scheduler
|
||||||
|
```
|
||||||
|
|
||||||
|
### Video Download Fails
|
||||||
|
|
||||||
|
The init script downloads Big Buck Bunny from Google's test video bucket. If this fails:
|
||||||
|
1. Check internet connectivity
|
||||||
|
2. Manually download and copy to raw_movies volume
|
||||||
|
3. Or use your own test video
|
||||||
|
|
||||||
|
### VAAPI Not Available
|
||||||
|
|
||||||
|
The Docker container runs without GPU access by default (CPU encoding fallback). To enable VAAPI:
|
||||||
|
|
||||||
|
Edit `docker-compose.yml` scheduler service:
|
||||||
|
```yaml
|
||||||
|
scheduler:
|
||||||
|
devices:
|
||||||
|
- /dev/dri:/dev/dri
|
||||||
|
group_add:
|
||||||
|
- video
|
||||||
|
```
|
||||||
|
|
||||||
|
## Cleanup
|
||||||
|
|
||||||
|
### Stop Everything
|
||||||
|
```bash
|
||||||
|
docker compose down
|
||||||
|
```
|
||||||
|
|
||||||
|
### Remove All Data (including volumes)
|
||||||
|
```bash
|
||||||
|
docker compose down -v
|
||||||
|
```
|
||||||
|
|
||||||
|
This removes:
|
||||||
|
- All containers
|
||||||
|
- All volumes (database, videos, recordings)
|
||||||
|
- Network
|
||||||
|
|
||||||
|
### Keep Database, Remove Containers
|
||||||
|
```bash
|
||||||
|
docker compose down
|
||||||
|
# Volumes persist - next startup will reuse existing data
|
||||||
|
```
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────┐
|
||||||
|
│ PostgreSQL │
|
||||||
|
└──────┬──────┘
|
||||||
|
│
|
||||||
|
┌──────▼──────┐ ┌─────────────┐
|
||||||
|
│ NocoDB │◄─────┤ Init │ Downloads test video
|
||||||
|
└──────┬──────┘ │ Container │ Creates test data
|
||||||
|
│ └─────────────┘
|
||||||
|
│
|
||||||
|
┌──────▼──────┐
|
||||||
|
│ Scheduler │ Reads from NocoDB
|
||||||
|
└──────┬──────┘ Generates subtitles
|
||||||
|
│ Encodes videos
|
||||||
|
│ Manages streaming
|
||||||
|
│
|
||||||
|
┌──────▼──────┐
|
||||||
|
│ RTMP Server │ Streams processed videos
|
||||||
|
└─────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
## Production Considerations
|
||||||
|
|
||||||
|
This test environment uses:
|
||||||
|
- Embedded PostgreSQL (fine for testing)
|
||||||
|
- Local volumes (fine for testing)
|
||||||
|
- Simple authentication (change for production)
|
||||||
|
- No SSL/TLS (add for production)
|
||||||
|
- CPU-only encoding (enable VAAPI for production)
|
||||||
|
|
||||||
|
For production deployment:
|
||||||
|
1. Use external PostgreSQL database
|
||||||
|
2. Configure proper authentication and secrets
|
||||||
|
3. Enable SSL/TLS for NocoDB
|
||||||
|
4. Mount persistent storage for videos
|
||||||
|
5. Enable GPU acceleration (VAAPI)
|
||||||
|
6. Set up proper monitoring and logging
|
||||||
|
7. Configure backup for NocoDB database
|
||||||
|
|
||||||
|
## Next Steps
|
||||||
|
|
||||||
|
Once you've tested the environment:
|
||||||
|
1. Review the logs to understand the workflow
|
||||||
|
2. Try adding your own movies
|
||||||
|
3. Test error scenarios
|
||||||
|
4. Adjust timing and configuration
|
||||||
|
5. Deploy to production with proper setup
|
||||||
|
|
||||||
|
## Support
|
||||||
|
|
||||||
|
For issues with:
|
||||||
|
- **Scheduler code**: Check `agent.py` and logs
|
||||||
|
- **Docker setup**: Check `docker-compose.yml` and service logs
|
||||||
|
- **NocoDB**: Visit https://docs.nocodb.com
|
||||||
|
- **RTMP streaming**: Check nginx-rtmp documentation
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
This test environment is provided as-is for development and testing purposes.
|
||||||
|
|
@ -0,0 +1,684 @@
|
||||||
|
import requests
|
||||||
|
import sqlite3
|
||||||
|
import time
|
||||||
|
import subprocess
|
||||||
|
import hashlib
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import logging
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from pathlib import Path
|
||||||
|
from functools import wraps
|
||||||
|
|
||||||
|
# Configure logging
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.INFO,
|
||||||
|
format='%(asctime)s - %(levelname)s - %(message)s'
|
||||||
|
)
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Load configuration from environment variables
|
||||||
|
NOCODB_URL = os.getenv("NOCODB_URL")
|
||||||
|
NOCODB_TOKEN = os.getenv("NOCODB_TOKEN")
|
||||||
|
RTMP_SERVER = os.getenv("RTMP_SERVER")
|
||||||
|
RAW_DIR = Path(os.getenv("RAW_DIR", "/root/surowe_filmy"))
|
||||||
|
FINAL_DIR = Path(os.getenv("FINAL_DIR", "/root/przygotowane_filmy"))
|
||||||
|
WHISPER_MODEL = os.getenv("WHISPER_MODEL", "/root/models/ggml-base.bin")
|
||||||
|
VAAPI_DEVICE = os.getenv("VAAPI_DEVICE", "/dev/dri/renderD128")
|
||||||
|
STREAM_GRACE_PERIOD_MINUTES = int(os.getenv("STREAM_GRACE_PERIOD_MINUTES", "15"))
|
||||||
|
|
||||||
|
# Timing configuration
|
||||||
|
NOCODB_SYNC_INTERVAL_SECONDS = int(os.getenv("NOCODB_SYNC_INTERVAL_SECONDS", "60"))
|
||||||
|
WATCHDOG_CHECK_INTERVAL_SECONDS = int(os.getenv("WATCHDOG_CHECK_INTERVAL_SECONDS", "10"))
|
||||||
|
|
||||||
|
# Configuration validation
|
||||||
|
def validate_config():
|
||||||
|
"""Validate required environment variables on startup."""
|
||||||
|
required_vars = {
|
||||||
|
"NOCODB_URL": NOCODB_URL,
|
||||||
|
"NOCODB_TOKEN": NOCODB_TOKEN,
|
||||||
|
"RTMP_SERVER": RTMP_SERVER
|
||||||
|
}
|
||||||
|
|
||||||
|
missing = [name for name, value in required_vars.items() if not value]
|
||||||
|
|
||||||
|
if missing:
|
||||||
|
logger.error(f"Missing required environment variables: {', '.join(missing)}")
|
||||||
|
logger.error("Please set the following environment variables:")
|
||||||
|
logger.error(" NOCODB_URL - NocoDB API endpoint")
|
||||||
|
logger.error(" NOCODB_TOKEN - Authentication token")
|
||||||
|
logger.error(" RTMP_SERVER - Streaming destination")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
# Validate directories exist or can be created
|
||||||
|
for dir_path in [RAW_DIR, FINAL_DIR]:
|
||||||
|
try:
|
||||||
|
dir_path.mkdir(parents=True, exist_ok=True)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Cannot create directory {dir_path}: {e}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
logger.info("Configuration validated successfully")
|
||||||
|
logger.info(f"RAW_DIR: {RAW_DIR}")
|
||||||
|
logger.info(f"FINAL_DIR: {FINAL_DIR}")
|
||||||
|
logger.info(f"WHISPER_MODEL: {WHISPER_MODEL}")
|
||||||
|
logger.info(f"VAAPI_DEVICE: {VAAPI_DEVICE}")
|
||||||
|
logger.info(f"STREAM_GRACE_PERIOD: {STREAM_GRACE_PERIOD_MINUTES} minutes")
|
||||||
|
logger.info(f"NOCODB_SYNC_INTERVAL: {NOCODB_SYNC_INTERVAL_SECONDS} seconds")
|
||||||
|
logger.info(f"WATCHDOG_CHECK_INTERVAL: {WATCHDOG_CHECK_INTERVAL_SECONDS} seconds")
|
||||||
|
|
||||||
|
# Database setup
|
||||||
|
db = sqlite3.connect("scheduler.db", check_same_thread=False)
|
||||||
|
db.row_factory = sqlite3.Row
|
||||||
|
|
||||||
|
# Ensure table exists with log column and streaming metadata
|
||||||
|
db.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS jobs (
|
||||||
|
nocodb_id TEXT PRIMARY KEY,
|
||||||
|
title TEXT,
|
||||||
|
run_at TIMESTAMP,
|
||||||
|
prep_at TIMESTAMP,
|
||||||
|
raw_path TEXT,
|
||||||
|
final_path TEXT,
|
||||||
|
prep_status TEXT,
|
||||||
|
play_status TEXT,
|
||||||
|
log TEXT,
|
||||||
|
stream_start_time TIMESTAMP,
|
||||||
|
stream_retry_count INTEGER DEFAULT 0
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
# Add new columns if they don't exist (for existing databases)
|
||||||
|
try:
|
||||||
|
db.execute("ALTER TABLE jobs ADD COLUMN stream_start_time TIMESTAMP")
|
||||||
|
db.commit()
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
try:
|
||||||
|
db.execute("ALTER TABLE jobs ADD COLUMN stream_retry_count INTEGER DEFAULT 0")
|
||||||
|
db.commit()
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Track streaming process
|
||||||
|
streaming_process = None
|
||||||
|
current_streaming_job = None
|
||||||
|
|
||||||
|
def log_to_db(nocodb_id, message):
|
||||||
|
"""Append a timestamped log message to the database log column."""
|
||||||
|
timestamp = datetime.now().isoformat()
|
||||||
|
log_entry = f"[{timestamp}] {message}\n"
|
||||||
|
|
||||||
|
try:
|
||||||
|
db.execute("""
|
||||||
|
UPDATE jobs SET log = log || ? WHERE nocodb_id = ?
|
||||||
|
""", (log_entry, nocodb_id))
|
||||||
|
db.commit()
|
||||||
|
logger.info(f"Job {nocodb_id}: {message}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to log to database: {e}")
|
||||||
|
|
||||||
|
def retry_with_backoff(max_attempts=3, base_delay=1):
|
||||||
|
"""Decorator to retry a function with exponential backoff."""
|
||||||
|
def decorator(func):
|
||||||
|
@wraps(func)
|
||||||
|
def wrapper(*args, **kwargs):
|
||||||
|
for attempt in range(1, max_attempts + 1):
|
||||||
|
try:
|
||||||
|
return func(*args, **kwargs)
|
||||||
|
except Exception as e:
|
||||||
|
if attempt == max_attempts:
|
||||||
|
logger.error(f"{func.__name__} failed after {max_attempts} attempts: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
delay = base_delay * (2 ** (attempt - 1))
|
||||||
|
logger.warning(f"{func.__name__} attempt {attempt} failed: {e}. Retrying in {delay}s...")
|
||||||
|
time.sleep(delay)
|
||||||
|
|
||||||
|
return wrapper
|
||||||
|
return decorator
|
||||||
|
|
||||||
|
@retry_with_backoff(max_attempts=3)
|
||||||
|
def sync():
|
||||||
|
"""Sync jobs from NocoDB."""
|
||||||
|
try:
|
||||||
|
response = requests.get(
|
||||||
|
NOCODB_URL,
|
||||||
|
headers={"xc-token": NOCODB_TOKEN},
|
||||||
|
timeout=30
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
|
||||||
|
rows = response.json().get("list", [])
|
||||||
|
logger.info(f"Fetched {len(rows)} jobs from NocoDB")
|
||||||
|
|
||||||
|
for r in rows:
|
||||||
|
try:
|
||||||
|
run_at = datetime.fromisoformat(r["Date"])
|
||||||
|
prep_at = run_at - timedelta(hours=6)
|
||||||
|
|
||||||
|
# Preserve existing status and streaming data on sync
|
||||||
|
existing = db.execute("SELECT * FROM jobs WHERE nocodb_id=?", (r["Id"],)).fetchone()
|
||||||
|
if existing:
|
||||||
|
db.execute("""
|
||||||
|
UPDATE jobs SET title=?, run_at=?, prep_at=?
|
||||||
|
WHERE nocodb_id=?
|
||||||
|
""", (r["Title"], run_at, prep_at, r["Id"]))
|
||||||
|
else:
|
||||||
|
db.execute("""
|
||||||
|
INSERT INTO jobs (nocodb_id, title, run_at, prep_at, raw_path, final_path,
|
||||||
|
prep_status, play_status, log, stream_start_time, stream_retry_count)
|
||||||
|
VALUES (?,?,?,?,?,?,?,?,?,?,?)
|
||||||
|
""", (r["Id"], r["Title"], run_at, prep_at, None, None, 'pending', 'pending', '', None, 0))
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to process row {r.get('Id', 'unknown')}: {e}")
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
except requests.exceptions.RequestException as e:
|
||||||
|
logger.error(f"Failed to sync from NocoDB: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
def take_prep():
|
||||||
|
"""Get the next job ready for preparation."""
|
||||||
|
try:
|
||||||
|
# First, mark jobs as skipped if both prep and run times have passed
|
||||||
|
db.execute("""
|
||||||
|
UPDATE jobs
|
||||||
|
SET prep_status='skipped', play_status='skipped',
|
||||||
|
log = log || ?
|
||||||
|
WHERE prep_status='pending'
|
||||||
|
AND run_at <= CURRENT_TIMESTAMP
|
||||||
|
""", (f"[{datetime.now().isoformat()}] Job skipped - streaming time already passed\n",))
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
# Get next overdue or upcoming prep job
|
||||||
|
c = db.execute("""
|
||||||
|
SELECT * FROM jobs WHERE prep_status='pending' AND prep_at <= CURRENT_TIMESTAMP LIMIT 1
|
||||||
|
""")
|
||||||
|
job = c.fetchone()
|
||||||
|
|
||||||
|
if job:
|
||||||
|
# Check if this is an overdue job
|
||||||
|
prep_at = datetime.fromisoformat(job["prep_at"])
|
||||||
|
if prep_at < datetime.now() - timedelta(minutes=5):
|
||||||
|
logger.warning(f"Processing overdue prep job: {job['nocodb_id']} - {job['title']} (was due {prep_at})")
|
||||||
|
|
||||||
|
return job
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to query prep jobs: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def take_play():
|
||||||
|
"""Get the next job ready for streaming with configurable grace period."""
|
||||||
|
try:
|
||||||
|
# Calculate grace period cutoff (default 15 minutes ago)
|
||||||
|
grace_period_cutoff = datetime.now() - timedelta(minutes=STREAM_GRACE_PERIOD_MINUTES)
|
||||||
|
|
||||||
|
# Mark jobs more than STREAM_GRACE_PERIOD_MINUTES overdue as skipped
|
||||||
|
db.execute("""
|
||||||
|
UPDATE jobs
|
||||||
|
SET play_status='skipped',
|
||||||
|
log = log || ?
|
||||||
|
WHERE prep_status='done'
|
||||||
|
AND play_status='pending'
|
||||||
|
AND run_at < ?
|
||||||
|
""", (
|
||||||
|
f"[{datetime.now().isoformat()}] Streaming skipped - more than {STREAM_GRACE_PERIOD_MINUTES} minutes late\n",
|
||||||
|
grace_period_cutoff.isoformat()
|
||||||
|
))
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
# Get jobs ready to stream (on time or within 15-minute grace period)
|
||||||
|
c = db.execute("""
|
||||||
|
SELECT * FROM jobs
|
||||||
|
WHERE prep_status='done'
|
||||||
|
AND play_status='pending'
|
||||||
|
AND run_at <= CURRENT_TIMESTAMP
|
||||||
|
LIMIT 1
|
||||||
|
""")
|
||||||
|
job = c.fetchone()
|
||||||
|
|
||||||
|
if job:
|
||||||
|
# Check if this is a late start
|
||||||
|
run_at = datetime.fromisoformat(job["run_at"])
|
||||||
|
delay = datetime.now() - run_at
|
||||||
|
if delay > timedelta(seconds=30):
|
||||||
|
minutes_late = delay.total_seconds() / 60
|
||||||
|
logger.warning(f"Starting stream late: {job['nocodb_id']} - {job['title']} ({minutes_late:.1f} minutes after scheduled time)")
|
||||||
|
|
||||||
|
return job
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to query play jobs: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def run_subprocess(cmd, job_id, description, timeout=None):
|
||||||
|
"""Run a subprocess with error handling and logging."""
|
||||||
|
log_to_db(job_id, f"Starting: {description}")
|
||||||
|
logger.info(f"Running command: {' '.join(str(c) for c in cmd)}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
cmd,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=timeout,
|
||||||
|
check=True
|
||||||
|
)
|
||||||
|
|
||||||
|
if result.stdout:
|
||||||
|
logger.debug(f"stdout: {result.stdout[:500]}")
|
||||||
|
|
||||||
|
log_to_db(job_id, f"Completed: {description}")
|
||||||
|
return result
|
||||||
|
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
error_msg = f"Timeout: {description} exceeded {timeout}s"
|
||||||
|
log_to_db(job_id, f"ERROR: {error_msg}")
|
||||||
|
raise Exception(error_msg)
|
||||||
|
|
||||||
|
except subprocess.CalledProcessError as e:
|
||||||
|
error_msg = f"Command failed with code {e.returncode}: {e.stderr[:500] if e.stderr else 'no error output'}"
|
||||||
|
log_to_db(job_id, f"ERROR: {description} - {error_msg}")
|
||||||
|
raise Exception(error_msg)
|
||||||
|
|
||||||
|
@retry_with_backoff(max_attempts=3, base_delay=2)
|
||||||
|
def generate_subtitles(raw_file, job_id):
|
||||||
|
"""Generate subtitles using whisper.cpp."""
|
||||||
|
srt_file = raw_file.with_suffix(".srt")
|
||||||
|
|
||||||
|
# Remove existing subtitle file if present
|
||||||
|
if srt_file.exists():
|
||||||
|
srt_file.unlink()
|
||||||
|
log_to_db(job_id, f"Removed existing subtitle file: {srt_file}")
|
||||||
|
|
||||||
|
# Run whisper.cpp with correct format: whisper.cpp -m <model> -f <file> -osrt
|
||||||
|
cmd = [
|
||||||
|
"whisper.cpp",
|
||||||
|
"-m", str(WHISPER_MODEL),
|
||||||
|
"-f", str(raw_file),
|
||||||
|
"-osrt"
|
||||||
|
]
|
||||||
|
|
||||||
|
run_subprocess(cmd, job_id, "Subtitle generation with whisper.cpp", timeout=3600)
|
||||||
|
|
||||||
|
# Verify subtitle file was created
|
||||||
|
if not srt_file.exists():
|
||||||
|
error_msg = f"Subtitle file not created: {srt_file}"
|
||||||
|
log_to_db(job_id, f"ERROR: {error_msg}")
|
||||||
|
raise Exception(error_msg)
|
||||||
|
|
||||||
|
log_to_db(job_id, f"Subtitle file created successfully: {srt_file}")
|
||||||
|
return srt_file
|
||||||
|
|
||||||
|
@retry_with_backoff(max_attempts=3, base_delay=2)
|
||||||
|
def encode_video_with_subtitles(raw_file, srt_file, final_file, job_id):
|
||||||
|
"""Encode video with burned-in subtitles using VAAPI."""
|
||||||
|
# Remove existing output file if present
|
||||||
|
if final_file.exists():
|
||||||
|
final_file.unlink()
|
||||||
|
log_to_db(job_id, f"Removed existing output file: {final_file}")
|
||||||
|
|
||||||
|
# FFmpeg command with VAAPI encoding (h264_vaapi instead of h264_qsv)
|
||||||
|
cmd = [
|
||||||
|
"ffmpeg",
|
||||||
|
"-hwaccel", "vaapi",
|
||||||
|
"-vaapi_device", VAAPI_DEVICE,
|
||||||
|
"-i", str(raw_file),
|
||||||
|
"-vf", f"subtitles={srt_file}:force_style=Fontname=Consolas,BackColour=&H80000000,Spacing=0.2,Outline=0,Shadow=0.75,format=yuv420p",
|
||||||
|
"-c:v", "h264_vaapi", # Changed from h264_qsv to h264_vaapi
|
||||||
|
"-qp", "23", # Quality parameter for VAAPI (similar to CRF)
|
||||||
|
"-c:a", "aac",
|
||||||
|
"-b:a", "192k",
|
||||||
|
"-movflags", "faststart",
|
||||||
|
"-y", # Overwrite output file
|
||||||
|
str(final_file)
|
||||||
|
]
|
||||||
|
|
||||||
|
run_subprocess(cmd, job_id, "Video encoding with VAAPI", timeout=7200)
|
||||||
|
|
||||||
|
# Verify output file was created
|
||||||
|
if not final_file.exists():
|
||||||
|
error_msg = f"Output video file not created: {final_file}"
|
||||||
|
log_to_db(job_id, f"ERROR: {error_msg}")
|
||||||
|
raise Exception(error_msg)
|
||||||
|
|
||||||
|
file_size = final_file.stat().st_size / (1024 * 1024) # Size in MB
|
||||||
|
log_to_db(job_id, f"Video encoded successfully: {final_file} ({file_size:.2f} MB)")
|
||||||
|
return final_file
|
||||||
|
|
||||||
|
def prepare_job(job):
|
||||||
|
"""Prepare a job: generate subtitles and encode video."""
|
||||||
|
job_id = job["nocodb_id"]
|
||||||
|
title = job["title"]
|
||||||
|
|
||||||
|
try:
|
||||||
|
log_to_db(job_id, f"Starting preparation for: {title}")
|
||||||
|
|
||||||
|
# Find raw video file
|
||||||
|
matching_files = list(RAW_DIR.glob(f"*{title}*"))
|
||||||
|
|
||||||
|
if not matching_files:
|
||||||
|
error_msg = f"No files found matching title: {title}"
|
||||||
|
log_to_db(job_id, f"ERROR: {error_msg}")
|
||||||
|
db.execute("UPDATE jobs SET prep_status='failed' WHERE nocodb_id=?", (job_id,))
|
||||||
|
db.commit()
|
||||||
|
return
|
||||||
|
|
||||||
|
raw_file = max(matching_files, key=lambda x: x.stat().st_mtime)
|
||||||
|
log_to_db(job_id, f"Found raw file: {raw_file}")
|
||||||
|
|
||||||
|
# Generate subtitles
|
||||||
|
srt_file = generate_subtitles(raw_file, job_id)
|
||||||
|
|
||||||
|
# Prepare output filename
|
||||||
|
final_file = FINAL_DIR / (raw_file.stem + ".converted.mp4")
|
||||||
|
|
||||||
|
# Encode video with subtitles
|
||||||
|
encode_video_with_subtitles(raw_file, srt_file, final_file, job_id)
|
||||||
|
|
||||||
|
# Update database
|
||||||
|
db.execute("""
|
||||||
|
UPDATE jobs SET prep_status='done', raw_path=?, final_path=? WHERE nocodb_id=?
|
||||||
|
""", (str(raw_file), str(final_file), job_id))
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
log_to_db(job_id, "Preparation completed successfully")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
error_msg = f"Preparation failed: {e}"
|
||||||
|
log_to_db(job_id, f"ERROR: {error_msg}")
|
||||||
|
logger.error(f"Job {job_id} preparation failed: {e}")
|
||||||
|
|
||||||
|
db.execute("UPDATE jobs SET prep_status='failed' WHERE nocodb_id=?", (job_id,))
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
def get_video_duration(video_path):
|
||||||
|
"""Get video duration in seconds using ffprobe."""
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
[
|
||||||
|
"ffprobe",
|
||||||
|
"-v", "error",
|
||||||
|
"-show_entries", "format=duration",
|
||||||
|
"-of", "default=noprint_wrappers=1:nokey=1",
|
||||||
|
str(video_path)
|
||||||
|
],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=10
|
||||||
|
)
|
||||||
|
if result.returncode == 0 and result.stdout.strip():
|
||||||
|
return float(result.stdout.strip())
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to get video duration: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def stream_job(job, seek_seconds=0):
|
||||||
|
"""Start streaming a prepared job with optional seek position."""
|
||||||
|
global streaming_process, current_streaming_job
|
||||||
|
|
||||||
|
job_id = job["nocodb_id"]
|
||||||
|
final_path = job["final_path"]
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Check if starting late or restarting
|
||||||
|
run_at = datetime.fromisoformat(job["run_at"])
|
||||||
|
delay = datetime.now() - run_at
|
||||||
|
if seek_seconds > 0:
|
||||||
|
log_to_db(job_id, f"Restarting stream at position {seek_seconds:.1f}s")
|
||||||
|
elif delay > timedelta(seconds=30):
|
||||||
|
minutes_late = delay.total_seconds() / 60
|
||||||
|
log_to_db(job_id, f"Starting stream {minutes_late:.1f} minutes late")
|
||||||
|
|
||||||
|
log_to_db(job_id, f"Starting stream to: {RTMP_SERVER}")
|
||||||
|
|
||||||
|
# Verify file exists
|
||||||
|
if not Path(final_path).exists():
|
||||||
|
error_msg = f"Final video file not found: {final_path}"
|
||||||
|
log_to_db(job_id, f"ERROR: {error_msg}")
|
||||||
|
db.execute("UPDATE jobs SET play_status='failed' WHERE nocodb_id=?", (job_id,))
|
||||||
|
db.commit()
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Get video duration to know when it should finish
|
||||||
|
video_duration = get_video_duration(final_path)
|
||||||
|
if not video_duration:
|
||||||
|
logger.warning(f"Could not determine video duration for {final_path}")
|
||||||
|
|
||||||
|
# Stop previous stream if running
|
||||||
|
if streaming_process and streaming_process.poll() is None:
|
||||||
|
logger.info(f"Stopping previous stream (PID: {streaming_process.pid})")
|
||||||
|
streaming_process.terminate()
|
||||||
|
try:
|
||||||
|
streaming_process.wait(timeout=5)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
streaming_process.kill()
|
||||||
|
log_to_db(job_id, "Stopped previous stream")
|
||||||
|
|
||||||
|
# Build ffmpeg command with optional seek
|
||||||
|
cmd = ["ffmpeg"]
|
||||||
|
|
||||||
|
if seek_seconds > 0:
|
||||||
|
# Seek to position (input seeking is faster)
|
||||||
|
cmd.extend(["-ss", str(seek_seconds)])
|
||||||
|
|
||||||
|
cmd.extend([
|
||||||
|
"-re", # Read input at native frame rate
|
||||||
|
"-i", final_path,
|
||||||
|
"-c", "copy", # Copy streams without re-encoding
|
||||||
|
"-f", "flv",
|
||||||
|
RTMP_SERVER
|
||||||
|
])
|
||||||
|
|
||||||
|
logger.info(f"Starting stream: {' '.join(cmd)}")
|
||||||
|
streaming_process = subprocess.Popen(
|
||||||
|
cmd,
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.PIPE
|
||||||
|
)
|
||||||
|
|
||||||
|
# Record when this stream started (for calculating seek position on restart)
|
||||||
|
stream_start_time = datetime.now()
|
||||||
|
current_streaming_job = job_id
|
||||||
|
|
||||||
|
log_to_db(job_id, f"Stream started (PID: {streaming_process.pid})")
|
||||||
|
|
||||||
|
# Update database - set status to 'streaming', not 'done'
|
||||||
|
db.execute("""
|
||||||
|
UPDATE jobs
|
||||||
|
SET play_status='streaming',
|
||||||
|
stream_start_time=?,
|
||||||
|
stream_retry_count=stream_retry_count+1
|
||||||
|
WHERE nocodb_id=?
|
||||||
|
""", (stream_start_time.isoformat(), job_id))
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
error_msg = f"Streaming failed: {e}"
|
||||||
|
log_to_db(job_id, f"ERROR: {error_msg}")
|
||||||
|
logger.error(f"Job {job_id} streaming failed: {e}")
|
||||||
|
|
||||||
|
db.execute("UPDATE jobs SET play_status='failed' WHERE nocodb_id=?", (job_id,))
|
||||||
|
db.commit()
|
||||||
|
return False
|
||||||
|
|
||||||
|
def monitor_and_restart_stream():
|
||||||
|
"""Monitor active stream and restart if it fails."""
|
||||||
|
global streaming_process, current_streaming_job
|
||||||
|
|
||||||
|
# Check if there's supposed to be an active stream
|
||||||
|
try:
|
||||||
|
active_stream = db.execute("""
|
||||||
|
SELECT * FROM jobs WHERE play_status='streaming' LIMIT 1
|
||||||
|
""").fetchone()
|
||||||
|
|
||||||
|
if not active_stream:
|
||||||
|
# No active stream expected
|
||||||
|
streaming_process = None
|
||||||
|
current_streaming_job = None
|
||||||
|
return
|
||||||
|
|
||||||
|
job_id = active_stream["nocodb_id"]
|
||||||
|
final_path = active_stream["final_path"]
|
||||||
|
stream_start_time = active_stream["stream_start_time"]
|
||||||
|
retry_count = active_stream["stream_retry_count"] or 0
|
||||||
|
|
||||||
|
# Check if process is still running
|
||||||
|
if streaming_process and streaming_process.poll() is None:
|
||||||
|
# Stream is running fine
|
||||||
|
return
|
||||||
|
|
||||||
|
# Stream is not running but should be
|
||||||
|
if streaming_process:
|
||||||
|
# Process exited - check if it was normal completion or error
|
||||||
|
return_code = streaming_process.returncode
|
||||||
|
if return_code == 0:
|
||||||
|
# Normal completion - video finished
|
||||||
|
logger.info(f"Stream completed successfully for job {job_id}")
|
||||||
|
log_to_db(job_id, "Stream completed successfully")
|
||||||
|
db.execute("UPDATE jobs SET play_status='done' WHERE nocodb_id=?", (job_id,))
|
||||||
|
db.commit()
|
||||||
|
streaming_process = None
|
||||||
|
current_streaming_job = None
|
||||||
|
return
|
||||||
|
else:
|
||||||
|
# Error exit
|
||||||
|
stderr = streaming_process.stderr.read().decode('utf-8')[-500:] if streaming_process.stderr else ""
|
||||||
|
logger.error(f"Stream crashed for job {job_id} with code {return_code}: {stderr}")
|
||||||
|
log_to_db(job_id, f"Stream crashed (exit code {return_code})")
|
||||||
|
|
||||||
|
# Calculate how much time has elapsed since stream started
|
||||||
|
if not stream_start_time:
|
||||||
|
logger.error(f"No stream start time recorded for job {job_id}")
|
||||||
|
db.execute("UPDATE jobs SET play_status='failed' WHERE nocodb_id=?", (job_id,))
|
||||||
|
db.commit()
|
||||||
|
return
|
||||||
|
|
||||||
|
start_time = datetime.fromisoformat(stream_start_time)
|
||||||
|
elapsed_seconds = (datetime.now() - start_time).total_seconds()
|
||||||
|
|
||||||
|
# Get video duration to check if we should still be streaming
|
||||||
|
video_duration = get_video_duration(final_path)
|
||||||
|
if video_duration and elapsed_seconds >= video_duration:
|
||||||
|
# Video should have finished by now
|
||||||
|
logger.info(f"Stream duration exceeded for job {job_id} - marking as done")
|
||||||
|
log_to_db(job_id, "Stream duration completed")
|
||||||
|
db.execute("UPDATE jobs SET play_status='done' WHERE nocodb_id=?", (job_id,))
|
||||||
|
db.commit()
|
||||||
|
streaming_process = None
|
||||||
|
current_streaming_job = None
|
||||||
|
return
|
||||||
|
|
||||||
|
# Check retry limit (max 10 restarts)
|
||||||
|
if retry_count >= 10:
|
||||||
|
logger.error(f"Stream retry limit exceeded for job {job_id}")
|
||||||
|
log_to_db(job_id, "ERROR: Stream failed after 10 restart attempts")
|
||||||
|
db.execute("UPDATE jobs SET play_status='failed' WHERE nocodb_id=?", (job_id,))
|
||||||
|
db.commit()
|
||||||
|
streaming_process = None
|
||||||
|
current_streaming_job = None
|
||||||
|
return
|
||||||
|
|
||||||
|
# Restart stream at correct position
|
||||||
|
logger.warning(f"Restarting stream for job {job_id} at position {elapsed_seconds:.1f}s (attempt {retry_count + 1})")
|
||||||
|
stream_job(active_stream, seek_seconds=elapsed_seconds)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error in stream monitor: {e}")
|
||||||
|
|
||||||
|
def main():
|
||||||
|
"""Main scheduler loop."""
|
||||||
|
logger.info("Starting scheduler...")
|
||||||
|
validate_config()
|
||||||
|
|
||||||
|
# Check for overdue jobs on startup
|
||||||
|
try:
|
||||||
|
grace_period_cutoff = datetime.now() - timedelta(minutes=STREAM_GRACE_PERIOD_MINUTES)
|
||||||
|
|
||||||
|
overdue_prep = db.execute("""
|
||||||
|
SELECT COUNT(*) as count FROM jobs
|
||||||
|
WHERE prep_status='pending' AND prep_at <= CURRENT_TIMESTAMP AND run_at > CURRENT_TIMESTAMP
|
||||||
|
""").fetchone()
|
||||||
|
|
||||||
|
skipped_prep = db.execute("""
|
||||||
|
SELECT COUNT(*) as count FROM jobs
|
||||||
|
WHERE prep_status='pending' AND run_at <= CURRENT_TIMESTAMP
|
||||||
|
""").fetchone()
|
||||||
|
|
||||||
|
overdue_stream = db.execute("""
|
||||||
|
SELECT COUNT(*) as count FROM jobs
|
||||||
|
WHERE prep_status='done' AND play_status='pending' AND run_at <= CURRENT_TIMESTAMP AND run_at >= ?
|
||||||
|
""", (grace_period_cutoff.isoformat(),)).fetchone()
|
||||||
|
|
||||||
|
skipped_stream = db.execute("""
|
||||||
|
SELECT COUNT(*) as count FROM jobs
|
||||||
|
WHERE prep_status='done' AND play_status='pending' AND run_at < ?
|
||||||
|
""", (grace_period_cutoff.isoformat(),)).fetchone()
|
||||||
|
|
||||||
|
if overdue_prep and overdue_prep["count"] > 0:
|
||||||
|
logger.warning(f"Found {overdue_prep['count']} overdue prep job(s) - will process immediately")
|
||||||
|
|
||||||
|
if skipped_prep and skipped_prep["count"] > 0:
|
||||||
|
logger.warning(f"Found {skipped_prep['count']} unprepared job(s) past streaming time - will be marked as skipped")
|
||||||
|
|
||||||
|
if overdue_stream and overdue_stream["count"] > 0:
|
||||||
|
logger.warning(f"Found {overdue_stream['count']} overdue streaming job(s) - will start within grace period ({STREAM_GRACE_PERIOD_MINUTES}min)")
|
||||||
|
|
||||||
|
if skipped_stream and skipped_stream["count"] > 0:
|
||||||
|
logger.warning(f"Found {skipped_stream['count']} streaming job(s) more than {STREAM_GRACE_PERIOD_MINUTES}min late - will be marked as skipped")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to check for overdue jobs: {e}")
|
||||||
|
|
||||||
|
logger.info("Scheduler is running. Press Ctrl+C to stop.")
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Calculate how many iterations between syncs
|
||||||
|
sync_iterations = max(1, NOCODB_SYNC_INTERVAL_SECONDS // WATCHDOG_CHECK_INTERVAL_SECONDS)
|
||||||
|
logger.info(f"Main loop: checking every {WATCHDOG_CHECK_INTERVAL_SECONDS}s, syncing every {sync_iterations} iterations ({NOCODB_SYNC_INTERVAL_SECONDS}s)")
|
||||||
|
|
||||||
|
iteration = 0
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
# Monitor and restart stream if needed (check every iteration)
|
||||||
|
monitor_and_restart_stream()
|
||||||
|
|
||||||
|
# Sync jobs from NocoDB at configured interval
|
||||||
|
if iteration % sync_iterations == 0:
|
||||||
|
sync()
|
||||||
|
|
||||||
|
# Process preparation jobs
|
||||||
|
job = take_prep()
|
||||||
|
if job:
|
||||||
|
logger.info(f"Processing prep job: {job['nocodb_id']} - {job['title']}")
|
||||||
|
prepare_job(job)
|
||||||
|
|
||||||
|
# Process streaming jobs
|
||||||
|
job = take_play()
|
||||||
|
if job:
|
||||||
|
logger.info(f"Processing play job: {job['nocodb_id']} - {job['title']}")
|
||||||
|
stream_job(job)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error in main loop: {e}")
|
||||||
|
|
||||||
|
# Sleep between iterations
|
||||||
|
time.sleep(WATCHDOG_CHECK_INTERVAL_SECONDS)
|
||||||
|
iteration += 1
|
||||||
|
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
logger.info("Scheduler stopped by user")
|
||||||
|
if streaming_process and streaming_process.poll() is None:
|
||||||
|
logger.info("Stopping active stream...")
|
||||||
|
streaming_process.terminate()
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
|
|
@ -0,0 +1,74 @@
|
||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
scheduler:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
container_name: movie_scheduler
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
# Environment variables - USE SECRETS IN PRODUCTION
|
||||||
|
env_file:
|
||||||
|
- .env.production
|
||||||
|
|
||||||
|
# GPU access for VAAPI encoding
|
||||||
|
devices:
|
||||||
|
- /dev/dri:/dev/dri
|
||||||
|
group_add:
|
||||||
|
- video
|
||||||
|
- render
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
# Persistent database
|
||||||
|
- ./scheduler.db:/app/scheduler.db
|
||||||
|
|
||||||
|
# Video storage - mount your actual storage paths
|
||||||
|
- /mnt/storage/raw_movies:/raw_movies:ro
|
||||||
|
- /mnt/storage/final_movies:/final_movies
|
||||||
|
|
||||||
|
# Whisper model (download once, reuse)
|
||||||
|
- /opt/models:/models:ro
|
||||||
|
|
||||||
|
# Logs (optional - use if not using external logging)
|
||||||
|
- ./logs:/app/logs
|
||||||
|
|
||||||
|
# Logging configuration
|
||||||
|
logging:
|
||||||
|
driver: "json-file"
|
||||||
|
options:
|
||||||
|
max-size: "10m"
|
||||||
|
max-file: "5"
|
||||||
|
|
||||||
|
# Resource limits
|
||||||
|
deploy:
|
||||||
|
resources:
|
||||||
|
limits:
|
||||||
|
cpus: '4.0'
|
||||||
|
memory: 4G
|
||||||
|
reservations:
|
||||||
|
cpus: '1.0'
|
||||||
|
memory: 1G
|
||||||
|
|
||||||
|
# Health check
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pgrep -f agent.py || exit 1"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
start_period: 10s
|
||||||
|
|
||||||
|
# Network
|
||||||
|
networks:
|
||||||
|
- scheduler_network
|
||||||
|
|
||||||
|
# Security
|
||||||
|
security_opt:
|
||||||
|
- no-new-privileges:true
|
||||||
|
read_only: false
|
||||||
|
tmpfs:
|
||||||
|
- /tmp
|
||||||
|
|
||||||
|
networks:
|
||||||
|
scheduler_network:
|
||||||
|
driver: bridge
|
||||||
|
|
@ -0,0 +1,112 @@
|
||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
# PostgreSQL database for NocoDB
|
||||||
|
postgres:
|
||||||
|
image: postgres:15-alpine
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: nocodb
|
||||||
|
POSTGRES_USER: nocodb
|
||||||
|
POSTGRES_PASSWORD: nocodb_password
|
||||||
|
volumes:
|
||||||
|
- postgres_data:/var/lib/postgresql/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U nocodb"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
networks:
|
||||||
|
- scheduler_network
|
||||||
|
|
||||||
|
# NocoDB service
|
||||||
|
nocodb:
|
||||||
|
image: nocodb/nocodb:latest
|
||||||
|
ports:
|
||||||
|
- "8080:8080"
|
||||||
|
environment:
|
||||||
|
NC_DB: "pg://postgres:5432?u=nocodb&p=nocodb_password&d=nocodb"
|
||||||
|
NC_AUTH_JWT_SECRET: "test_jwt_secret_key_12345"
|
||||||
|
NC_PUBLIC_URL: "http://localhost:8080"
|
||||||
|
depends_on:
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
volumes:
|
||||||
|
- nocodb_data:/usr/app/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "curl -f http://localhost:8080/api/v1/health || exit 1"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 10
|
||||||
|
networks:
|
||||||
|
- scheduler_network
|
||||||
|
|
||||||
|
# RTMP Server for streaming
|
||||||
|
rtmp:
|
||||||
|
image: tiangolo/nginx-rtmp
|
||||||
|
ports:
|
||||||
|
- "1935:1935"
|
||||||
|
- "8081:80"
|
||||||
|
volumes:
|
||||||
|
- ./nginx-rtmp.conf:/etc/nginx/nginx.conf:ro
|
||||||
|
- rtmp_recordings:/tmp/recordings
|
||||||
|
networks:
|
||||||
|
- scheduler_network
|
||||||
|
|
||||||
|
# Initialization service - sets up test data
|
||||||
|
init:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile.init
|
||||||
|
depends_on:
|
||||||
|
nocodb:
|
||||||
|
condition: service_healthy
|
||||||
|
environment:
|
||||||
|
NOCODB_URL: "http://nocodb:8080"
|
||||||
|
NOCODB_EMAIL: "admin@test.com"
|
||||||
|
NOCODB_PASSWORD: "admin123"
|
||||||
|
volumes:
|
||||||
|
- raw_movies:/raw_movies
|
||||||
|
- config_data:/tmp
|
||||||
|
- ./init-data.sh:/init-data.sh:ro
|
||||||
|
networks:
|
||||||
|
- scheduler_network
|
||||||
|
command: ["/bin/bash", "/init-data.sh"]
|
||||||
|
|
||||||
|
# Movie scheduler service
|
||||||
|
scheduler:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
depends_on:
|
||||||
|
nocodb:
|
||||||
|
condition: service_healthy
|
||||||
|
rtmp:
|
||||||
|
condition: service_started
|
||||||
|
init:
|
||||||
|
condition: service_completed_successfully
|
||||||
|
environment:
|
||||||
|
RAW_DIR: "/raw_movies"
|
||||||
|
FINAL_DIR: "/final_movies"
|
||||||
|
WHISPER_MODEL: "/models/ggml-base.bin"
|
||||||
|
volumes:
|
||||||
|
- raw_movies:/raw_movies
|
||||||
|
- final_movies:/final_movies
|
||||||
|
- config_data:/config:ro
|
||||||
|
- ./scheduler.db:/app/scheduler.db
|
||||||
|
- ./start-scheduler.sh:/start-scheduler.sh:ro
|
||||||
|
networks:
|
||||||
|
- scheduler_network
|
||||||
|
restart: unless-stopped
|
||||||
|
command: ["/bin/bash", "/start-scheduler.sh"]
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
postgres_data:
|
||||||
|
nocodb_data:
|
||||||
|
raw_movies:
|
||||||
|
final_movies:
|
||||||
|
rtmp_recordings:
|
||||||
|
config_data:
|
||||||
|
|
||||||
|
networks:
|
||||||
|
scheduler_network:
|
||||||
|
driver: bridge
|
||||||
|
|
@ -0,0 +1,113 @@
|
||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# Docker Compose command detection
|
||||||
|
if docker compose version &> /dev/null; then
|
||||||
|
COMPOSE="docker compose"
|
||||||
|
elif docker-compose --version &> /dev/null; then
|
||||||
|
COMPOSE="docker-compose"
|
||||||
|
else
|
||||||
|
echo "Error: Docker Compose not found!"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
case "$1" in
|
||||||
|
start)
|
||||||
|
echo "Starting all services..."
|
||||||
|
$COMPOSE up -d
|
||||||
|
;;
|
||||||
|
stop)
|
||||||
|
echo "Stopping all services..."
|
||||||
|
$COMPOSE down
|
||||||
|
;;
|
||||||
|
restart)
|
||||||
|
echo "Restarting all services..."
|
||||||
|
$COMPOSE restart
|
||||||
|
;;
|
||||||
|
logs)
|
||||||
|
if [ -z "$2" ]; then
|
||||||
|
$COMPOSE logs -f
|
||||||
|
else
|
||||||
|
$COMPOSE logs -f "$2"
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
status)
|
||||||
|
$COMPOSE ps
|
||||||
|
;;
|
||||||
|
clean)
|
||||||
|
echo "Stopping and removing all containers and volumes..."
|
||||||
|
read -p "This will delete all data. Continue? (y/N) " -n 1 -r
|
||||||
|
echo
|
||||||
|
if [[ $REPLY =~ ^[Yy]$ ]]; then
|
||||||
|
$COMPOSE down -v
|
||||||
|
echo "Cleanup complete!"
|
||||||
|
else
|
||||||
|
echo "Cancelled."
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
db)
|
||||||
|
echo "Opening scheduler database..."
|
||||||
|
if [ -f "scheduler.db" ]; then
|
||||||
|
sqlite3 scheduler.db
|
||||||
|
else
|
||||||
|
echo "Database file not found. Is the scheduler running?"
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
jobs)
|
||||||
|
echo "Current jobs:"
|
||||||
|
if [ -f "scheduler.db" ]; then
|
||||||
|
sqlite3 scheduler.db "SELECT nocodb_id, title, prep_status, play_status, datetime(run_at) as scheduled FROM jobs ORDER BY run_at;"
|
||||||
|
else
|
||||||
|
echo "Database file not found."
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
add-video)
|
||||||
|
if [ -z "$2" ]; then
|
||||||
|
echo "Usage: $0 add-video <path-to-video-file>"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [ ! -f "$2" ]; then
|
||||||
|
echo "File not found: $2"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "Adding video to raw_movies volume..."
|
||||||
|
docker run --rm -v scheduler_raw_movies:/data -v "$(pwd):/host" alpine \
|
||||||
|
cp "/host/$2" /data/
|
||||||
|
echo "✓ Video added: $(basename "$2")"
|
||||||
|
echo "Now add an entry in NocoDB with Title matching: $(basename "$2" | cut -d. -f1)"
|
||||||
|
;;
|
||||||
|
stream)
|
||||||
|
echo "To watch the stream, use one of these commands:"
|
||||||
|
echo ""
|
||||||
|
echo "VLC:"
|
||||||
|
echo " vlc rtmp://localhost:1935/live/stream"
|
||||||
|
echo ""
|
||||||
|
echo "ffplay:"
|
||||||
|
echo " ffplay rtmp://localhost:1935/live/stream"
|
||||||
|
echo ""
|
||||||
|
echo "MPV:"
|
||||||
|
echo " mpv rtmp://localhost:1935/live/stream"
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "Movie Scheduler - Docker Helper"
|
||||||
|
echo ""
|
||||||
|
echo "Usage: $0 <command> [arguments]"
|
||||||
|
echo ""
|
||||||
|
echo "Commands:"
|
||||||
|
echo " start Start all services"
|
||||||
|
echo " stop Stop all services"
|
||||||
|
echo " restart Restart all services"
|
||||||
|
echo " logs [service] View logs (all or specific service)"
|
||||||
|
echo " status Show service status"
|
||||||
|
echo " clean Stop and remove all data"
|
||||||
|
echo " db Open scheduler database (SQLite)"
|
||||||
|
echo " jobs List all jobs and their status"
|
||||||
|
echo " add-video <file> Add video to raw_movies volume"
|
||||||
|
echo " stream Show commands to watch the stream"
|
||||||
|
echo ""
|
||||||
|
echo "Examples:"
|
||||||
|
echo " $0 start"
|
||||||
|
echo " $0 logs scheduler"
|
||||||
|
echo " $0 add-video my-movie.mp4"
|
||||||
|
echo " $0 jobs"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
@ -0,0 +1,261 @@
|
||||||
|
#!/bin/bash
|
||||||
|
set -e
|
||||||
|
|
||||||
|
echo "========================================"
|
||||||
|
echo "NocoDB & Test Data Initialization"
|
||||||
|
echo "========================================"
|
||||||
|
|
||||||
|
NOCODB_URL=${NOCODB_URL:-"http://nocodb:8080"}
|
||||||
|
NOCODB_EMAIL=${NOCODB_EMAIL:-"admin@test.com"}
|
||||||
|
NOCODB_PASSWORD=${NOCODB_PASSWORD:-"admin123"}
|
||||||
|
|
||||||
|
# Wait for NocoDB to be fully ready
|
||||||
|
echo "Waiting for NocoDB to be ready..."
|
||||||
|
for i in {1..30}; do
|
||||||
|
if curl -sf "${NOCODB_URL}/api/v1/health" > /dev/null 2>&1; then
|
||||||
|
echo "NocoDB is ready!"
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
echo "Attempt $i/30: NocoDB not ready yet, waiting..."
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
|
||||||
|
# Download test video (Big Buck Bunny - open source test video)
|
||||||
|
echo ""
|
||||||
|
echo "Downloading test video..."
|
||||||
|
if [ ! -f "/raw_movies/BigBuckBunny.mp4" ]; then
|
||||||
|
wget -q -O /raw_movies/BigBuckBunny.mp4 \
|
||||||
|
"http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4" \
|
||||||
|
|| echo "Failed to download test video, but continuing..."
|
||||||
|
|
||||||
|
if [ -f "/raw_movies/BigBuckBunny.mp4" ]; then
|
||||||
|
echo "✓ Test video downloaded: BigBuckBunny.mp4"
|
||||||
|
else
|
||||||
|
echo "⚠ Could not download test video - you'll need to provide one manually"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo "✓ Test video already exists"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Run Python script to setup NocoDB
|
||||||
|
echo ""
|
||||||
|
echo "Setting up NocoDB database and table..."
|
||||||
|
python3 << 'PYTHON_SCRIPT'
|
||||||
|
import requests
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
NOCODB_URL = os.getenv("NOCODB_URL", "http://nocodb:8080")
|
||||||
|
EMAIL = os.getenv("NOCODB_EMAIL", "admin@test.com")
|
||||||
|
PASSWORD = os.getenv("NOCODB_PASSWORD", "admin123")
|
||||||
|
|
||||||
|
print(f"Connecting to NocoDB at {NOCODB_URL}")
|
||||||
|
|
||||||
|
# Step 1: Sign up (first time) or sign in
|
||||||
|
try:
|
||||||
|
# Try to sign up first
|
||||||
|
response = requests.post(
|
||||||
|
f"{NOCODB_URL}/api/v1/auth/user/signup",
|
||||||
|
json={
|
||||||
|
"email": EMAIL,
|
||||||
|
"password": PASSWORD
|
||||||
|
},
|
||||||
|
timeout=10
|
||||||
|
)
|
||||||
|
|
||||||
|
if response.status_code == 200:
|
||||||
|
print("✓ New user created successfully")
|
||||||
|
auth_data = response.json()
|
||||||
|
else:
|
||||||
|
# If signup fails, try signin
|
||||||
|
response = requests.post(
|
||||||
|
f"{NOCODB_URL}/api/v1/auth/user/signin",
|
||||||
|
json={
|
||||||
|
"email": EMAIL,
|
||||||
|
"password": PASSWORD
|
||||||
|
},
|
||||||
|
timeout=10
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
print("✓ Signed in successfully")
|
||||||
|
auth_data = response.json()
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"✗ Authentication failed: {e}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
token = auth_data.get("token")
|
||||||
|
if not token:
|
||||||
|
print("✗ No token received from NocoDB")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
print(f"✓ Got authentication token")
|
||||||
|
|
||||||
|
headers = {
|
||||||
|
"xc-auth": token,
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Step 2: Create a new base (project)
|
||||||
|
try:
|
||||||
|
response = requests.post(
|
||||||
|
f"{NOCODB_URL}/api/v1/db/meta/projects/",
|
||||||
|
headers=headers,
|
||||||
|
json={
|
||||||
|
"title": "MovieScheduler",
|
||||||
|
"type": "database"
|
||||||
|
},
|
||||||
|
timeout=10
|
||||||
|
)
|
||||||
|
|
||||||
|
if response.status_code in [200, 201]:
|
||||||
|
base = response.json()
|
||||||
|
print(f"✓ Created base: {base.get('title')}")
|
||||||
|
else:
|
||||||
|
# Base might already exist, try to get it
|
||||||
|
response = requests.get(
|
||||||
|
f"{NOCODB_URL}/api/v1/db/meta/projects/",
|
||||||
|
headers=headers,
|
||||||
|
timeout=10
|
||||||
|
)
|
||||||
|
bases = response.json().get("list", [])
|
||||||
|
base = next((b for b in bases if b.get("title") == "MovieScheduler"), bases[0] if bases else None)
|
||||||
|
|
||||||
|
if not base:
|
||||||
|
print("✗ Could not create or find base")
|
||||||
|
sys.exit(1)
|
||||||
|
print(f"✓ Using existing base: {base.get('title')}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"✗ Base creation failed: {e}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
base_id = base.get("id")
|
||||||
|
print(f"Base ID: {base_id}")
|
||||||
|
|
||||||
|
# Step 3: Get or create table
|
||||||
|
try:
|
||||||
|
# List existing tables
|
||||||
|
response = requests.get(
|
||||||
|
f"{NOCODB_URL}/api/v1/db/meta/projects/{base_id}/tables",
|
||||||
|
headers=headers,
|
||||||
|
timeout=10
|
||||||
|
)
|
||||||
|
tables = response.json().get("list", [])
|
||||||
|
|
||||||
|
# Check if Movies table exists
|
||||||
|
movies_table = next((t for t in tables if t.get("title") == "Movies"), None)
|
||||||
|
|
||||||
|
if not movies_table:
|
||||||
|
# Create Movies table
|
||||||
|
response = requests.post(
|
||||||
|
f"{NOCODB_URL}/api/v1/db/meta/projects/{base_id}/tables",
|
||||||
|
headers=headers,
|
||||||
|
json={
|
||||||
|
"table_name": "Movies",
|
||||||
|
"title": "Movies",
|
||||||
|
"columns": [
|
||||||
|
{"column_name": "Id", "title": "Id", "uidt": "ID"},
|
||||||
|
{"column_name": "Title", "title": "Title", "uidt": "SingleLineText"},
|
||||||
|
{"column_name": "Language", "title": "Language", "uidt": "SingleLineText"},
|
||||||
|
{"column_name": "Year", "title": "Year", "uidt": "Year"},
|
||||||
|
{"column_name": "Date", "title": "Date", "uidt": "DateTime"},
|
||||||
|
{"column_name": "Image", "title": "Image", "uidt": "URL"},
|
||||||
|
{"column_name": "IMDB", "title": "IMDB", "uidt": "URL"},
|
||||||
|
{"column_name": "Time", "title": "Time", "uidt": "Duration"},
|
||||||
|
{"column_name": "Kanal", "title": "Kanal", "uidt": "SingleLineText"},
|
||||||
|
{"column_name": "Obejrzane", "title": "Obejrzane", "uidt": "Checkbox"}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
timeout=10
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
movies_table = response.json()
|
||||||
|
print(f"✓ Created table: Movies")
|
||||||
|
else:
|
||||||
|
print(f"✓ Using existing table: Movies")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"✗ Table creation failed: {e}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
table_id = movies_table.get("id")
|
||||||
|
print(f"Table ID: {table_id}")
|
||||||
|
|
||||||
|
# Step 4: Add test movie entries
|
||||||
|
test_movies = [
|
||||||
|
{
|
||||||
|
"Title": "BigBuckBunny",
|
||||||
|
"Language": "po angielsku; z napisami",
|
||||||
|
"Year": 2008,
|
||||||
|
"Date": (datetime.now() + timedelta(minutes=5)).isoformat(),
|
||||||
|
"Image": "https://peach.blender.org/wp-content/uploads/title_anouncement.jpg",
|
||||||
|
"IMDB": "https://www.imdb.com/title/tt1254207/",
|
||||||
|
"Time": "9:56",
|
||||||
|
"Kanal": "TestTV",
|
||||||
|
"Obejrzane": False
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Title": "TestMovie",
|
||||||
|
"Language": "po angielsku; z napisami",
|
||||||
|
"Year": 2024,
|
||||||
|
"Date": (datetime.now() + timedelta(hours=1)).isoformat(),
|
||||||
|
"Image": "https://example.com/test.jpg",
|
||||||
|
"IMDB": "https://www.imdb.com/",
|
||||||
|
"Time": "1:30:00",
|
||||||
|
"Kanal": "TestTV",
|
||||||
|
"Obejrzane": False
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
try:
|
||||||
|
for movie in test_movies:
|
||||||
|
response = requests.post(
|
||||||
|
f"{NOCODB_URL}/api/v2/tables/{table_id}/records",
|
||||||
|
headers={"xc-token": token, "Content-Type": "application/json"},
|
||||||
|
json=movie,
|
||||||
|
timeout=10
|
||||||
|
)
|
||||||
|
if response.status_code in [200, 201]:
|
||||||
|
print(f"✓ Added movie: {movie['Title']}")
|
||||||
|
else:
|
||||||
|
print(f"⚠ Could not add movie {movie['Title']}: {response.text}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"⚠ Some movies could not be added: {e}")
|
||||||
|
|
||||||
|
# Step 5: Save configuration for scheduler
|
||||||
|
print("\n" + "="*50)
|
||||||
|
print("CONFIGURATION FOR SCHEDULER")
|
||||||
|
print("="*50)
|
||||||
|
print(f"NOCODB_URL: http://nocodb:8080/api/v2/tables/{table_id}/records")
|
||||||
|
print(f"NOCODB_TOKEN: {token}")
|
||||||
|
print(f"RTMP_SERVER: rtmp://rtmp:1935/live/stream")
|
||||||
|
print("="*50)
|
||||||
|
|
||||||
|
# Save to file that docker-compose can read
|
||||||
|
with open("/tmp/nocodb_config.env", "w") as f:
|
||||||
|
f.write(f"NOCODB_TABLE_ID={table_id}\n")
|
||||||
|
f.write(f"NOCODB_TOKEN={token}\n")
|
||||||
|
|
||||||
|
print("\n✓ Configuration saved to /tmp/nocodb_config.env")
|
||||||
|
print("\n✓ Initialization complete!")
|
||||||
|
|
||||||
|
PYTHON_SCRIPT
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "========================================"
|
||||||
|
echo "✓ All initialization steps completed!"
|
||||||
|
echo "========================================"
|
||||||
|
echo ""
|
||||||
|
echo "NocoDB Dashboard: http://localhost:8080"
|
||||||
|
echo " Email: ${NOCODB_EMAIL}"
|
||||||
|
echo " Password: ${NOCODB_PASSWORD}"
|
||||||
|
echo ""
|
||||||
|
echo "RTMP Server: rtmp://localhost:1935/live/stream"
|
||||||
|
echo "RTMP Stats: http://localhost:8081/stat"
|
||||||
|
echo ""
|
||||||
|
echo "The scheduler will start automatically."
|
||||||
|
echo "========================================"
|
||||||
|
|
@ -0,0 +1,66 @@
|
||||||
|
worker_processes auto;
|
||||||
|
events {
|
||||||
|
worker_connections 1024;
|
||||||
|
}
|
||||||
|
|
||||||
|
# RTMP configuration
|
||||||
|
rtmp {
|
||||||
|
server {
|
||||||
|
listen 1935;
|
||||||
|
chunk_size 4096;
|
||||||
|
allow publish all;
|
||||||
|
|
||||||
|
application live {
|
||||||
|
live on;
|
||||||
|
record off;
|
||||||
|
|
||||||
|
# Allow playback from anywhere
|
||||||
|
allow play all;
|
||||||
|
|
||||||
|
# HLS settings (optional, for web playback)
|
||||||
|
hls on;
|
||||||
|
hls_path /tmp/hls;
|
||||||
|
hls_fragment 3;
|
||||||
|
hls_playlist_length 60;
|
||||||
|
|
||||||
|
# Record streams (optional)
|
||||||
|
record all;
|
||||||
|
record_path /tmp/recordings;
|
||||||
|
record_suffix -%Y%m%d-%H%M%S.flv;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# HTTP server for HLS playback
|
||||||
|
http {
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
|
||||||
|
# HLS streaming endpoint
|
||||||
|
location /hls {
|
||||||
|
types {
|
||||||
|
application/vnd.apple.mpegurl m3u8;
|
||||||
|
video/mp2t ts;
|
||||||
|
}
|
||||||
|
root /tmp;
|
||||||
|
add_header Cache-Control no-cache;
|
||||||
|
add_header Access-Control-Allow-Origin *;
|
||||||
|
}
|
||||||
|
|
||||||
|
# RTMP statistics
|
||||||
|
location /stat {
|
||||||
|
rtmp_stat all;
|
||||||
|
rtmp_stat_stylesheet stat.xsl;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /stat.xsl {
|
||||||
|
root /usr/local/nginx/html;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Status page
|
||||||
|
location / {
|
||||||
|
root /usr/local/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
requests>=2.25.0
|
||||||
Binary file not shown.
|
|
@ -0,0 +1,56 @@
|
||||||
|
[Unit]
|
||||||
|
Description=Movie Scheduler - Automated video processing and streaming
|
||||||
|
After=network-online.target
|
||||||
|
Wants=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=scheduler
|
||||||
|
Group=scheduler
|
||||||
|
WorkingDirectory=/opt/scheduler
|
||||||
|
|
||||||
|
# Environment variables (or use EnvironmentFile)
|
||||||
|
Environment="NOCODB_URL=https://your-nocodb.com/api/v2/tables/YOUR_TABLE_ID/records"
|
||||||
|
Environment="NOCODB_TOKEN=YOUR_TOKEN_HERE"
|
||||||
|
Environment="RTMP_SERVER=rtmp://your-rtmp-server.com/live/stream"
|
||||||
|
Environment="RAW_DIR=/mnt/storage/raw_movies"
|
||||||
|
Environment="FINAL_DIR=/mnt/storage/final_movies"
|
||||||
|
Environment="WHISPER_MODEL=/opt/models/ggml-base.bin"
|
||||||
|
Environment="VAAPI_DEVICE=/dev/dri/renderD128"
|
||||||
|
Environment="STREAM_GRACE_PERIOD_MINUTES=15"
|
||||||
|
Environment="NOCODB_SYNC_INTERVAL_SECONDS=60"
|
||||||
|
Environment="WATCHDOG_CHECK_INTERVAL_SECONDS=10"
|
||||||
|
|
||||||
|
# Or use external environment file (recommended for secrets)
|
||||||
|
# EnvironmentFile=/etc/scheduler/scheduler.env
|
||||||
|
|
||||||
|
# Python virtual environment
|
||||||
|
ExecStart=/opt/scheduler/venv/bin/python /opt/scheduler/agent.py
|
||||||
|
|
||||||
|
# Restart policy
|
||||||
|
Restart=always
|
||||||
|
RestartSec=10
|
||||||
|
|
||||||
|
# Security hardening
|
||||||
|
NoNewPrivileges=true
|
||||||
|
PrivateTmp=true
|
||||||
|
ProtectSystem=strict
|
||||||
|
ProtectHome=true
|
||||||
|
ReadWritePaths=/mnt/storage /opt/scheduler
|
||||||
|
|
||||||
|
# Allow access to GPU for VAAPI
|
||||||
|
DeviceAllow=/dev/dri/renderD128 rw
|
||||||
|
SupplementaryGroups=video render
|
||||||
|
|
||||||
|
# Logging
|
||||||
|
StandardOutput=journal
|
||||||
|
StandardError=journal
|
||||||
|
SyslogIdentifier=scheduler
|
||||||
|
|
||||||
|
# Resource limits (adjust based on your needs)
|
||||||
|
LimitNOFILE=65536
|
||||||
|
MemoryMax=4G
|
||||||
|
CPUQuota=200%
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
|
@ -0,0 +1,92 @@
|
||||||
|
#!/bin/bash
|
||||||
|
#
|
||||||
|
# Scheduler Backup Script
|
||||||
|
# Backs up database and configuration with rotation
|
||||||
|
#
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# Configuration
|
||||||
|
BACKUP_DIR="${BACKUP_DIR:-/backup/scheduler}"
|
||||||
|
DB_PATH="${DB_PATH:-/opt/scheduler/scheduler.db}"
|
||||||
|
CONFIG_PATH="${CONFIG_PATH:-/etc/scheduler/scheduler.env}"
|
||||||
|
RETENTION_DAYS=30
|
||||||
|
COMPRESS_AFTER_DAYS=7
|
||||||
|
|
||||||
|
# Colors for output
|
||||||
|
RED='\033[0;31m'
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
YELLOW='\033[1;33m'
|
||||||
|
NC='\033[0m' # No Color
|
||||||
|
|
||||||
|
DATE=$(date +%Y%m%d_%H%M%S)
|
||||||
|
|
||||||
|
echo -e "${GREEN}=== Scheduler Backup - $(date) ===${NC}"
|
||||||
|
|
||||||
|
# Create backup directory
|
||||||
|
mkdir -p "$BACKUP_DIR"
|
||||||
|
|
||||||
|
# Check if database exists
|
||||||
|
if [ ! -f "$DB_PATH" ]; then
|
||||||
|
echo -e "${RED}✗ Database not found: $DB_PATH${NC}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Backup database
|
||||||
|
echo "Backing up database..."
|
||||||
|
cp "$DB_PATH" "$BACKUP_DIR/scheduler_${DATE}.db"
|
||||||
|
if [ $? -eq 0 ]; then
|
||||||
|
echo -e "${GREEN}✓ Database backed up: scheduler_${DATE}.db${NC}"
|
||||||
|
else
|
||||||
|
echo -e "${RED}✗ Database backup failed${NC}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Backup config if exists
|
||||||
|
if [ -f "$CONFIG_PATH" ]; then
|
||||||
|
echo "Backing up configuration..."
|
||||||
|
cp "$CONFIG_PATH" "$BACKUP_DIR/config_${DATE}.env"
|
||||||
|
if [ $? -eq 0 ]; then
|
||||||
|
echo -e "${GREEN}✓ Config backed up: config_${DATE}.env${NC}"
|
||||||
|
else
|
||||||
|
echo -e "${YELLOW}⚠ Config backup failed${NC}"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Get database stats
|
||||||
|
DB_SIZE=$(du -h "$BACKUP_DIR/scheduler_${DATE}.db" | cut -f1)
|
||||||
|
JOB_COUNT=$(sqlite3 "$BACKUP_DIR/scheduler_${DATE}.db" "SELECT COUNT(*) FROM jobs;" 2>/dev/null || echo "N/A")
|
||||||
|
echo -e "Database size: ${DB_SIZE}, Jobs: ${JOB_COUNT}"
|
||||||
|
|
||||||
|
# Compress old backups (older than COMPRESS_AFTER_DAYS)
|
||||||
|
echo "Compressing old backups..."
|
||||||
|
COMPRESSED=$(find "$BACKUP_DIR" -name "*.db" -mtime +${COMPRESS_AFTER_DAYS} -exec gzip {} \; -print | wc -l)
|
||||||
|
if [ $COMPRESSED -gt 0 ]; then
|
||||||
|
echo -e "${GREEN}✓ Compressed $COMPRESSED old backup(s)${NC}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Delete backups older than RETENTION_DAYS
|
||||||
|
echo "Cleaning up old backups..."
|
||||||
|
DELETED_DB=$(find "$BACKUP_DIR" -name "*.db.gz" -mtime +${RETENTION_DAYS} -delete -print | wc -l)
|
||||||
|
DELETED_CFG=$(find "$BACKUP_DIR" -name "*.env" -mtime +${RETENTION_DAYS} -delete -print | wc -l)
|
||||||
|
if [ $DELETED_DB -gt 0 ] || [ $DELETED_CFG -gt 0 ]; then
|
||||||
|
echo -e "${GREEN}✓ Deleted $DELETED_DB database(s) and $DELETED_CFG config(s)${NC}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# List recent backups
|
||||||
|
echo ""
|
||||||
|
echo "Recent backups:"
|
||||||
|
ls -lh "$BACKUP_DIR" | tail -5
|
||||||
|
|
||||||
|
# Optional: Upload to S3 or remote storage
|
||||||
|
# Uncomment and configure if needed
|
||||||
|
#
|
||||||
|
# if command -v aws &> /dev/null; then
|
||||||
|
# echo "Uploading to S3..."
|
||||||
|
# aws s3 cp "$BACKUP_DIR/scheduler_${DATE}.db" "s3://your-bucket/scheduler-backups/"
|
||||||
|
# echo -e "${GREEN}✓ Uploaded to S3${NC}"
|
||||||
|
# fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo -e "${GREEN}=== Backup completed ===${NC}"
|
||||||
|
echo "Backup location: $BACKUP_DIR/scheduler_${DATE}.db"
|
||||||
|
|
@ -0,0 +1,173 @@
|
||||||
|
#!/bin/bash
|
||||||
|
#
|
||||||
|
# Scheduler Monitoring Script
|
||||||
|
# Checks service health, job status, and system resources
|
||||||
|
#
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# Configuration
|
||||||
|
LOG_FILE="${LOG_FILE:-/var/log/scheduler-monitor.log}"
|
||||||
|
DB_PATH="${DB_PATH:-/opt/scheduler/scheduler.db}"
|
||||||
|
SERVICE_NAME="${SERVICE_NAME:-scheduler}"
|
||||||
|
ALERT_EMAIL="${ALERT_EMAIL:-}"
|
||||||
|
DISK_THRESHOLD=90
|
||||||
|
|
||||||
|
# Colors for output
|
||||||
|
RED='\033[0;31m'
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
YELLOW='\033[1;33m'
|
||||||
|
NC='\033[0m' # No Color
|
||||||
|
|
||||||
|
TIMESTAMP=$(date '+%Y-%m-%d %H:%M:%S')
|
||||||
|
ALERTS=()
|
||||||
|
|
||||||
|
echo "=== Scheduler Monitor - $TIMESTAMP ===" | tee -a "$LOG_FILE"
|
||||||
|
|
||||||
|
# Check if service is running
|
||||||
|
check_service() {
|
||||||
|
if systemctl is-active --quiet "$SERVICE_NAME" 2>/dev/null; then
|
||||||
|
echo -e "${GREEN}✓ Service is running${NC}" | tee -a "$LOG_FILE"
|
||||||
|
return 0
|
||||||
|
elif docker ps --filter "name=movie_scheduler" --format "{{.Status}}" | grep -q "Up"; then
|
||||||
|
echo -e "${GREEN}✓ Docker container is running${NC}" | tee -a "$LOG_FILE"
|
||||||
|
return 0
|
||||||
|
else
|
||||||
|
echo -e "${RED}✗ Service is DOWN${NC}" | tee -a "$LOG_FILE"
|
||||||
|
ALERTS+=("Service is DOWN")
|
||||||
|
|
||||||
|
# Try to restart
|
||||||
|
echo "Attempting to restart service..." | tee -a "$LOG_FILE"
|
||||||
|
systemctl start "$SERVICE_NAME" 2>/dev/null || \
|
||||||
|
docker compose -f /opt/scheduler/docker-compose.prod.yml restart 2>/dev/null || true
|
||||||
|
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Check database
|
||||||
|
check_database() {
|
||||||
|
if [ ! -f "$DB_PATH" ]; then
|
||||||
|
echo -e "${RED}✗ Database not found${NC}" | tee -a "$LOG_FILE"
|
||||||
|
ALERTS+=("Database file not found")
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Database size
|
||||||
|
DB_SIZE=$(du -h "$DB_PATH" | cut -f1)
|
||||||
|
echo "Database size: $DB_SIZE" | tee -a "$LOG_FILE"
|
||||||
|
|
||||||
|
# Job counts
|
||||||
|
TOTAL=$(sqlite3 "$DB_PATH" "SELECT COUNT(*) FROM jobs;" 2>/dev/null)
|
||||||
|
PENDING_PREP=$(sqlite3 "$DB_PATH" "SELECT COUNT(*) FROM jobs WHERE prep_status='pending';" 2>/dev/null)
|
||||||
|
PENDING_PLAY=$(sqlite3 "$DB_PATH" "SELECT COUNT(*) FROM jobs WHERE play_status='pending';" 2>/dev/null)
|
||||||
|
STREAMING=$(sqlite3 "$DB_PATH" "SELECT COUNT(*) FROM jobs WHERE play_status='streaming';" 2>/dev/null)
|
||||||
|
FAILED=$(sqlite3 "$DB_PATH" "SELECT COUNT(*) FROM jobs WHERE prep_status='failed' OR play_status='failed';" 2>/dev/null)
|
||||||
|
|
||||||
|
echo "Jobs - Total: $TOTAL, Pending prep: $PENDING_PREP, Pending play: $PENDING_PLAY, Streaming: $STREAMING, Failed: $FAILED" | tee -a "$LOG_FILE"
|
||||||
|
|
||||||
|
if [ "$FAILED" -gt 0 ]; then
|
||||||
|
echo -e "${YELLOW}⚠ Found $FAILED failed job(s)${NC}" | tee -a "$LOG_FILE"
|
||||||
|
ALERTS+=("$FAILED failed jobs")
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$STREAMING" -gt 0 ]; then
|
||||||
|
echo -e "${GREEN}✓ $STREAMING active stream(s)${NC}" | tee -a "$LOG_FILE"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Check disk space
|
||||||
|
check_disk() {
|
||||||
|
for PATH in "/mnt/storage" "/opt/scheduler" "/"; do
|
||||||
|
if [ -d "$PATH" ]; then
|
||||||
|
USAGE=$(df "$PATH" 2>/dev/null | tail -1 | awk '{print $5}' | sed 's/%//')
|
||||||
|
if [ -n "$USAGE" ]; then
|
||||||
|
if [ "$USAGE" -gt "$DISK_THRESHOLD" ]; then
|
||||||
|
echo -e "${RED}✗ Disk usage for $PATH: ${USAGE}%${NC}" | tee -a "$LOG_FILE"
|
||||||
|
ALERTS+=("Disk usage ${USAGE}% on $PATH")
|
||||||
|
else
|
||||||
|
echo -e "Disk usage for $PATH: ${USAGE}%" | tee -a "$LOG_FILE"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
# Check for stuck streams
|
||||||
|
check_stuck_streams() {
|
||||||
|
# Find streams that have been active for more than 4 hours
|
||||||
|
STUCK=$(sqlite3 "$DB_PATH" "
|
||||||
|
SELECT COUNT(*) FROM jobs
|
||||||
|
WHERE play_status='streaming'
|
||||||
|
AND datetime(stream_start_time) < datetime('now', '-4 hours')
|
||||||
|
" 2>/dev/null)
|
||||||
|
|
||||||
|
if [ "$STUCK" -gt 0 ]; then
|
||||||
|
echo -e "${YELLOW}⚠ Found $STUCK stream(s) active for >4 hours${NC}" | tee -a "$LOG_FILE"
|
||||||
|
ALERTS+=("$STUCK potentially stuck streams")
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Check recent errors in logs
|
||||||
|
check_logs() {
|
||||||
|
if systemctl is-active --quiet "$SERVICE_NAME" 2>/dev/null; then
|
||||||
|
ERROR_COUNT=$(journalctl -u "$SERVICE_NAME" --since "5 minutes ago" 2>/dev/null | grep -i "ERROR" | wc -l)
|
||||||
|
else
|
||||||
|
ERROR_COUNT=$(docker compose -f /opt/scheduler/docker-compose.prod.yml logs --since 5m 2>/dev/null | grep -i "ERROR" | wc -l || echo "0")
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$ERROR_COUNT" -gt 10 ]; then
|
||||||
|
echo -e "${YELLOW}⚠ Found $ERROR_COUNT errors in last 5 minutes${NC}" | tee -a "$LOG_FILE"
|
||||||
|
ALERTS+=("$ERROR_COUNT recent errors")
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Send alerts
|
||||||
|
send_alerts() {
|
||||||
|
if [ ${#ALERTS[@]} -gt 0 ]; then
|
||||||
|
echo "" | tee -a "$LOG_FILE"
|
||||||
|
echo -e "${RED}=== ALERTS ===${NC}" | tee -a "$LOG_FILE"
|
||||||
|
for alert in "${ALERTS[@]}"; do
|
||||||
|
echo "- $alert" | tee -a "$LOG_FILE"
|
||||||
|
done
|
||||||
|
|
||||||
|
# Send email if configured
|
||||||
|
if [ -n "$ALERT_EMAIL" ] && command -v mail &> /dev/null; then
|
||||||
|
{
|
||||||
|
echo "Scheduler Monitoring Alert - $TIMESTAMP"
|
||||||
|
echo ""
|
||||||
|
echo "The following issues were detected:"
|
||||||
|
for alert in "${ALERTS[@]}"; do
|
||||||
|
echo "- $alert"
|
||||||
|
done
|
||||||
|
echo ""
|
||||||
|
echo "Check $LOG_FILE for details"
|
||||||
|
} | mail -s "Scheduler Alert" "$ALERT_EMAIL"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Could also send to Slack, PagerDuty, etc.
|
||||||
|
# Example Slack webhook:
|
||||||
|
# if [ -n "$SLACK_WEBHOOK" ]; then
|
||||||
|
# curl -X POST -H 'Content-type: application/json' \
|
||||||
|
# --data "{\"text\":\"Scheduler Alert: ${ALERTS[*]}\"}" \
|
||||||
|
# "$SLACK_WEBHOOK"
|
||||||
|
# fi
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Run checks
|
||||||
|
check_service
|
||||||
|
check_database
|
||||||
|
check_disk
|
||||||
|
check_stuck_streams
|
||||||
|
check_logs
|
||||||
|
send_alerts
|
||||||
|
|
||||||
|
echo "" | tee -a "$LOG_FILE"
|
||||||
|
|
||||||
|
# Exit with error if there are alerts
|
||||||
|
if [ ${#ALERTS[@]} -gt 0 ]; then
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
exit 0
|
||||||
|
|
@ -0,0 +1,238 @@
|
||||||
|
#!/bin/bash
|
||||||
|
#
|
||||||
|
# Production Setup Script
|
||||||
|
# Quick setup for production deployment
|
||||||
|
#
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
RED='\033[0;31m'
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
YELLOW='\033[1;33m'
|
||||||
|
BLUE='\033[0;34m'
|
||||||
|
NC='\033[0m'
|
||||||
|
|
||||||
|
echo -e "${BLUE}"
|
||||||
|
echo "=========================================="
|
||||||
|
echo " Movie Scheduler - Production Setup"
|
||||||
|
echo "=========================================="
|
||||||
|
echo -e "${NC}"
|
||||||
|
|
||||||
|
# Check if running as root
|
||||||
|
if [ "$EUID" -eq 0 ]; then
|
||||||
|
echo -e "${RED}Please don't run as root. Run as your regular user.${NC}"
|
||||||
|
echo "The script will use sudo when needed."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Detect deployment method
|
||||||
|
echo "Choose deployment method:"
|
||||||
|
echo "1) Systemd service (bare metal)"
|
||||||
|
echo "2) Docker container"
|
||||||
|
read -p "Enter choice [1-2]: " DEPLOY_METHOD
|
||||||
|
|
||||||
|
if [ "$DEPLOY_METHOD" == "1" ]; then
|
||||||
|
echo -e "${GREEN}Setting up systemd service...${NC}"
|
||||||
|
|
||||||
|
# Check dependencies
|
||||||
|
echo "Checking dependencies..."
|
||||||
|
for cmd in python3 ffmpeg git sqlite3 systemctl; do
|
||||||
|
if ! command -v $cmd &> /dev/null; then
|
||||||
|
echo -e "${RED}✗ Missing: $cmd${NC}"
|
||||||
|
echo "Install it with: sudo apt-get install $cmd"
|
||||||
|
exit 1
|
||||||
|
else
|
||||||
|
echo -e "${GREEN}✓ Found: $cmd${NC}"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
# Check whisper.cpp
|
||||||
|
if ! command -v whisper.cpp &> /dev/null; then
|
||||||
|
echo -e "${YELLOW}⚠ whisper.cpp not found${NC}"
|
||||||
|
read -p "Install whisper.cpp now? [y/N]: " INSTALL_WHISPER
|
||||||
|
if [[ $INSTALL_WHISPER =~ ^[Yy]$ ]]; then
|
||||||
|
echo "Installing whisper.cpp..."
|
||||||
|
git clone https://github.com/ggerganov/whisper.cpp.git /tmp/whisper.cpp
|
||||||
|
cd /tmp/whisper.cpp
|
||||||
|
make
|
||||||
|
sudo cp main /usr/local/bin/whisper.cpp
|
||||||
|
sudo chmod +x /usr/local/bin/whisper.cpp
|
||||||
|
rm -rf /tmp/whisper.cpp
|
||||||
|
echo -e "${GREEN}✓ whisper.cpp installed${NC}"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Create scheduler user
|
||||||
|
if ! id -u scheduler &> /dev/null; then
|
||||||
|
echo "Creating scheduler user..."
|
||||||
|
sudo useradd -r -s /bin/bash -d /opt/scheduler -m scheduler
|
||||||
|
sudo usermod -aG video,render scheduler
|
||||||
|
echo -e "${GREEN}✓ User created${NC}"
|
||||||
|
else
|
||||||
|
echo -e "${GREEN}✓ User already exists${NC}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Create directories
|
||||||
|
echo "Creating directories..."
|
||||||
|
sudo mkdir -p /opt/scheduler /opt/models
|
||||||
|
sudo mkdir -p /mnt/storage/raw_movies /mnt/storage/final_movies
|
||||||
|
sudo chown scheduler:scheduler /opt/scheduler
|
||||||
|
sudo chown scheduler:scheduler /mnt/storage/raw_movies /mnt/storage/final_movies
|
||||||
|
|
||||||
|
# Copy application files
|
||||||
|
echo "Copying application files..."
|
||||||
|
SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
|
||||||
|
PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
|
||||||
|
|
||||||
|
sudo -u scheduler cp "$PROJECT_DIR/agent.py" /opt/scheduler/
|
||||||
|
sudo -u scheduler cp "$PROJECT_DIR/requirements.txt" /opt/scheduler/
|
||||||
|
|
||||||
|
# Create virtual environment
|
||||||
|
echo "Setting up Python virtual environment..."
|
||||||
|
sudo -u scheduler python3 -m venv /opt/scheduler/venv
|
||||||
|
sudo -u scheduler /opt/scheduler/venv/bin/pip install --upgrade pip
|
||||||
|
sudo -u scheduler /opt/scheduler/venv/bin/pip install -r /opt/scheduler/requirements.txt
|
||||||
|
|
||||||
|
# Download whisper model if needed
|
||||||
|
if [ ! -f "/opt/models/ggml-base.bin" ]; then
|
||||||
|
echo "Downloading whisper model..."
|
||||||
|
sudo wget -q -O /opt/models/ggml-base.bin \
|
||||||
|
https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-base.en.bin
|
||||||
|
echo -e "${GREEN}✓ Model downloaded${NC}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Create config directory
|
||||||
|
echo "Setting up configuration..."
|
||||||
|
sudo mkdir -p /etc/scheduler
|
||||||
|
|
||||||
|
# Create environment file
|
||||||
|
if [ ! -f "/etc/scheduler/scheduler.env" ]; then
|
||||||
|
cat << 'EOF' | sudo tee /etc/scheduler/scheduler.env > /dev/null
|
||||||
|
# NocoDB Configuration
|
||||||
|
NOCODB_URL=https://your-nocodb.com/api/v2/tables/YOUR_TABLE_ID/records
|
||||||
|
NOCODB_TOKEN=YOUR_TOKEN_HERE
|
||||||
|
|
||||||
|
# RTMP Server
|
||||||
|
RTMP_SERVER=rtmp://your-rtmp-server.com/live/stream
|
||||||
|
|
||||||
|
# Storage Paths
|
||||||
|
RAW_DIR=/mnt/storage/raw_movies
|
||||||
|
FINAL_DIR=/mnt/storage/final_movies
|
||||||
|
|
||||||
|
# Whisper Model
|
||||||
|
WHISPER_MODEL=/opt/models/ggml-base.bin
|
||||||
|
|
||||||
|
# VAAPI Device
|
||||||
|
VAAPI_DEVICE=/dev/dri/renderD128
|
||||||
|
|
||||||
|
# Timing
|
||||||
|
NOCODB_SYNC_INTERVAL_SECONDS=60
|
||||||
|
WATCHDOG_CHECK_INTERVAL_SECONDS=10
|
||||||
|
STREAM_GRACE_PERIOD_MINUTES=15
|
||||||
|
EOF
|
||||||
|
sudo chmod 600 /etc/scheduler/scheduler.env
|
||||||
|
sudo chown scheduler:scheduler /etc/scheduler/scheduler.env
|
||||||
|
|
||||||
|
echo -e "${YELLOW}⚠ IMPORTANT: Edit /etc/scheduler/scheduler.env with your actual values${NC}"
|
||||||
|
echo " sudo nano /etc/scheduler/scheduler.env"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Install systemd service
|
||||||
|
echo "Installing systemd service..."
|
||||||
|
sudo cp "$PROJECT_DIR/scheduler.service" /etc/systemd/system/
|
||||||
|
sudo sed -i 's|^Environment=|# Environment=|g' /etc/systemd/system/scheduler.service
|
||||||
|
sudo sed -i 's|^# EnvironmentFile=|EnvironmentFile=|g' /etc/systemd/system/scheduler.service
|
||||||
|
sudo systemctl daemon-reload
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo -e "${GREEN}=== Installation Complete ===${NC}"
|
||||||
|
echo ""
|
||||||
|
echo "Next steps:"
|
||||||
|
echo "1. Edit configuration:"
|
||||||
|
echo " sudo nano /etc/scheduler/scheduler.env"
|
||||||
|
echo ""
|
||||||
|
echo "2. Enable and start service:"
|
||||||
|
echo " sudo systemctl enable scheduler"
|
||||||
|
echo " sudo systemctl start scheduler"
|
||||||
|
echo ""
|
||||||
|
echo "3. Check status:"
|
||||||
|
echo " sudo systemctl status scheduler"
|
||||||
|
echo ""
|
||||||
|
echo "4. View logs:"
|
||||||
|
echo " sudo journalctl -u scheduler -f"
|
||||||
|
|
||||||
|
elif [ "$DEPLOY_METHOD" == "2" ]; then
|
||||||
|
echo -e "${GREEN}Setting up Docker deployment...${NC}"
|
||||||
|
|
||||||
|
# Check docker
|
||||||
|
if ! command -v docker &> /dev/null; then
|
||||||
|
echo -e "${RED}✗ Docker not found${NC}"
|
||||||
|
echo "Install Docker first: https://docs.docker.com/get-docker/"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check docker compose
|
||||||
|
if ! docker compose version &> /dev/null && ! docker-compose --version &> /dev/null; then
|
||||||
|
echo -e "${RED}✗ Docker Compose not found${NC}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
|
||||||
|
PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
|
||||||
|
|
||||||
|
# Create deployment directory
|
||||||
|
read -p "Installation directory [/opt/scheduler]: " INSTALL_DIR
|
||||||
|
INSTALL_DIR=${INSTALL_DIR:-/opt/scheduler}
|
||||||
|
|
||||||
|
sudo mkdir -p "$INSTALL_DIR"
|
||||||
|
sudo chown $USER:$USER "$INSTALL_DIR"
|
||||||
|
|
||||||
|
# Copy files
|
||||||
|
echo "Copying files..."
|
||||||
|
cp "$PROJECT_DIR/agent.py" "$INSTALL_DIR/"
|
||||||
|
cp "$PROJECT_DIR/requirements.txt" "$INSTALL_DIR/"
|
||||||
|
cp "$PROJECT_DIR/Dockerfile" "$INSTALL_DIR/"
|
||||||
|
cp "$PROJECT_DIR/docker-compose.prod.yml" "$INSTALL_DIR/"
|
||||||
|
|
||||||
|
# Create environment file
|
||||||
|
if [ ! -f "$INSTALL_DIR/.env.production" ]; then
|
||||||
|
cp "$PROJECT_DIR/.env.production.example" "$INSTALL_DIR/.env.production"
|
||||||
|
echo -e "${YELLOW}⚠ Edit $INSTALL_DIR/.env.production with your actual values${NC}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Create storage directories
|
||||||
|
sudo mkdir -p /mnt/storage/raw_movies /mnt/storage/final_movies
|
||||||
|
sudo mkdir -p /opt/models
|
||||||
|
|
||||||
|
# Download whisper model
|
||||||
|
if [ ! -f "/opt/models/ggml-base.bin" ]; then
|
||||||
|
echo "Downloading whisper model..."
|
||||||
|
sudo wget -q -O /opt/models/ggml-base.bin \
|
||||||
|
https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-base.en.bin
|
||||||
|
echo -e "${GREEN}✓ Model downloaded${NC}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo -e "${GREEN}=== Installation Complete ===${NC}"
|
||||||
|
echo ""
|
||||||
|
echo "Next steps:"
|
||||||
|
echo "1. Edit configuration:"
|
||||||
|
echo " nano $INSTALL_DIR/.env.production"
|
||||||
|
echo ""
|
||||||
|
echo "2. Build and start:"
|
||||||
|
echo " cd $INSTALL_DIR"
|
||||||
|
echo " docker compose -f docker-compose.prod.yml up -d"
|
||||||
|
echo ""
|
||||||
|
echo "3. View logs:"
|
||||||
|
echo " docker compose -f docker-compose.prod.yml logs -f"
|
||||||
|
echo ""
|
||||||
|
echo "4. Check status:"
|
||||||
|
echo " docker compose -f docker-compose.prod.yml ps"
|
||||||
|
|
||||||
|
else
|
||||||
|
echo -e "${RED}Invalid choice${NC}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo -e "${BLUE}See PRODUCTION.md for complete documentation${NC}"
|
||||||
|
|
@ -0,0 +1,37 @@
|
||||||
|
#!/bin/bash
|
||||||
|
set -e
|
||||||
|
|
||||||
|
echo "Starting Movie Scheduler..."
|
||||||
|
|
||||||
|
# Wait for config file from init container
|
||||||
|
CONFIG_FILE="/config/nocodb_config.env"
|
||||||
|
echo "Waiting for initialization to complete..."
|
||||||
|
|
||||||
|
for i in {1..60}; do
|
||||||
|
if [ -f "$CONFIG_FILE" ]; then
|
||||||
|
echo "✓ Configuration found!"
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
if [ $i -eq 60 ]; then
|
||||||
|
echo "✗ Timeout waiting for configuration"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
|
||||||
|
# Load configuration
|
||||||
|
source "$CONFIG_FILE"
|
||||||
|
|
||||||
|
# Set environment variables for the scheduler
|
||||||
|
export NOCODB_URL="http://nocodb:8080/api/v2/tables/${NOCODB_TABLE_ID}/records"
|
||||||
|
export NOCODB_TOKEN="${NOCODB_TOKEN}"
|
||||||
|
export RTMP_SERVER="${RTMP_SERVER:-rtmp://rtmp:1935/live/stream}"
|
||||||
|
|
||||||
|
echo "Configuration loaded:"
|
||||||
|
echo " NOCODB_URL: $NOCODB_URL"
|
||||||
|
echo " RTMP_SERVER: $RTMP_SERVER"
|
||||||
|
echo ""
|
||||||
|
echo "Starting scheduler agent..."
|
||||||
|
|
||||||
|
# Start the scheduler
|
||||||
|
exec python -u agent.py
|
||||||
|
|
@ -0,0 +1,119 @@
|
||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
echo "=========================================="
|
||||||
|
echo " Movie Scheduler - Test Environment"
|
||||||
|
echo "=========================================="
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Make scripts executable
|
||||||
|
chmod +x init-data.sh start-scheduler.sh 2>/dev/null || true
|
||||||
|
|
||||||
|
# Check if docker compose is available
|
||||||
|
if ! command -v docker &> /dev/null; then
|
||||||
|
echo "✗ Docker is not installed!"
|
||||||
|
echo "Please install Docker first: https://docs.docker.com/get-docker/"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if docker compose version &> /dev/null; then
|
||||||
|
COMPOSE_CMD="docker compose"
|
||||||
|
elif docker-compose --version &> /dev/null; then
|
||||||
|
COMPOSE_CMD="docker-compose"
|
||||||
|
else
|
||||||
|
echo "✗ Docker Compose is not installed!"
|
||||||
|
echo "Please install Docker Compose first"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "✓ Using: $COMPOSE_CMD"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Stop any existing containers
|
||||||
|
echo "Stopping any existing containers..."
|
||||||
|
$COMPOSE_CMD down 2>/dev/null || true
|
||||||
|
|
||||||
|
# Build and start all services
|
||||||
|
echo ""
|
||||||
|
echo "Building and starting all services..."
|
||||||
|
echo "This may take a few minutes on first run..."
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
$COMPOSE_CMD up --build -d
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=========================================="
|
||||||
|
echo " Waiting for services to initialize..."
|
||||||
|
echo "=========================================="
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Wait for init to complete
|
||||||
|
echo "Waiting for initialization to complete (this may take 30-60 seconds)..."
|
||||||
|
sleep 5
|
||||||
|
|
||||||
|
# Follow init logs
|
||||||
|
echo ""
|
||||||
|
echo "--- Initialization Progress ---"
|
||||||
|
$COMPOSE_CMD logs -f init &
|
||||||
|
LOGS_PID=$!
|
||||||
|
|
||||||
|
# Wait for init to complete
|
||||||
|
while true; do
|
||||||
|
STATUS=$($COMPOSE_CMD ps init --format json 2>/dev/null | jq -r '.[0].State' 2>/dev/null || echo "unknown")
|
||||||
|
|
||||||
|
if [ "$STATUS" = "exited" ]; then
|
||||||
|
kill $LOGS_PID 2>/dev/null || true
|
||||||
|
wait $LOGS_PID 2>/dev/null || true
|
||||||
|
echo ""
|
||||||
|
echo "✓ Initialization completed!"
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=========================================="
|
||||||
|
echo " Test Environment Ready!"
|
||||||
|
echo "=========================================="
|
||||||
|
echo ""
|
||||||
|
echo "📺 NocoDB Dashboard:"
|
||||||
|
echo " URL: http://localhost:8080"
|
||||||
|
echo " Email: admin@test.com"
|
||||||
|
echo " Password: admin123"
|
||||||
|
echo ""
|
||||||
|
echo "📡 RTMP Server:"
|
||||||
|
echo " Stream URL: rtmp://localhost:1935/live/stream"
|
||||||
|
echo " Statistics: http://localhost:8081/stat"
|
||||||
|
echo ""
|
||||||
|
echo "🎬 Scheduler:"
|
||||||
|
echo " Status: Running"
|
||||||
|
echo " Database: ./scheduler.db"
|
||||||
|
echo ""
|
||||||
|
echo "=========================================="
|
||||||
|
echo " Useful Commands:"
|
||||||
|
echo "=========================================="
|
||||||
|
echo ""
|
||||||
|
echo "View scheduler logs:"
|
||||||
|
echo " $COMPOSE_CMD logs -f scheduler"
|
||||||
|
echo ""
|
||||||
|
echo "View all logs:"
|
||||||
|
echo " $COMPOSE_CMD logs -f"
|
||||||
|
echo ""
|
||||||
|
echo "Check service status:"
|
||||||
|
echo " $COMPOSE_CMD ps"
|
||||||
|
echo ""
|
||||||
|
echo "Stop all services:"
|
||||||
|
echo " $COMPOSE_CMD down"
|
||||||
|
echo ""
|
||||||
|
echo "Stop and remove all data:"
|
||||||
|
echo " $COMPOSE_CMD down -v"
|
||||||
|
echo ""
|
||||||
|
echo "=========================================="
|
||||||
|
echo ""
|
||||||
|
echo "The scheduler is now monitoring NocoDB for movies to process."
|
||||||
|
echo "A test movie 'BigBuckBunny' has been added and will be"
|
||||||
|
echo "processed 6 hours before its scheduled time."
|
||||||
|
echo ""
|
||||||
|
echo "To watch the stream, use a player like VLC:"
|
||||||
|
echo " vlc rtmp://localhost:1935/live/stream"
|
||||||
|
echo ""
|
||||||
Loading…
Reference in New Issue