feat(phase8): Add PeerTube federated video platform

- LXC-based PeerTube container with PostgreSQL, Redis, Node.js, ffmpeg
- FastAPI backend for install, user, video management
- Web UI with stats grid, videos/users/logs tabs
- Systemd service on Unix socket

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
CyberMind-FR 2026-03-28 10:21:03 +01:00
parent 14b786d7b3
commit 491b2aaea1
9 changed files with 735 additions and 0 deletions

View File

@ -0,0 +1,479 @@
"""
SecuBox PeerTube API
LXC-based federated video platform management
"""
from fastapi import FastAPI, Depends, HTTPException, Query, UploadFile, File
from pydantic import BaseModel, Field
from typing import Optional, List
from pathlib import Path
import subprocess
import json
import os
try:
from secubox_core.auth import require_jwt
from secubox_core.logger import get_logger
except ImportError:
def require_jwt(): return {"sub": "admin"}
class Logger:
def info(self, msg): print(f"INFO: {msg}")
def error(self, msg): print(f"ERROR: {msg}")
def get_logger(name): return Logger()
app = FastAPI(title="secubox-peertube", root_path="/api/v1/peertube")
log = get_logger("peertube")
LXC_NAME = "secubox-peertube"
CONFIG_DIR = Path("/var/lib/secubox/peertube")
DATA_DIR = Path("/var/lib/secubox/peertube/data")
PEERTUBE_PORT = 9000
def lxc_exists() -> bool:
return subprocess.run(["lxc-info", "-n", LXC_NAME], capture_output=True).returncode == 0
def lxc_running() -> bool:
try:
r = subprocess.run(["lxc-info", "-n", LXC_NAME, "-s"], capture_output=True, text=True, timeout=10)
return "RUNNING" in r.stdout
except: return False
def lxc_get_ip() -> Optional[str]:
try:
r = subprocess.run(["lxc-info", "-n", LXC_NAME, "-iH"], capture_output=True, text=True, timeout=10)
if r.returncode == 0 and r.stdout.strip():
return r.stdout.strip().split("\n")[0]
except: pass
return None
def lxc_exec(cmd: List[str], timeout: int = 60) -> subprocess.CompletedProcess:
return subprocess.run(["lxc-attach", "-n", LXC_NAME, "--"] + cmd, capture_output=True, text=True, timeout=timeout)
def lxc_start() -> bool:
return subprocess.run(["lxc-start", "-n", LXC_NAME], capture_output=True).returncode == 0
def lxc_stop() -> bool:
return subprocess.run(["lxc-stop", "-n", LXC_NAME], capture_output=True).returncode == 0
class UserCreate(BaseModel):
username: str = Field(..., min_length=1, max_length=50)
email: str
password: str = Field(..., min_length=8)
role: str = Field(default="user", pattern="^(admin|moderator|user)$")
class ConfigUpdate(BaseModel):
instance_name: Optional[str] = None
instance_description: Optional[str] = None
signup_enabled: Optional[bool] = None
signup_requires_approval: Optional[bool] = None
@app.get("/health")
async def health():
return {"status": "ok", "module": "peertube"}
@app.get("/status")
async def status(user=Depends(require_jwt)):
container_exists = lxc_exists()
running = lxc_running() if container_exists else False
container_ip = lxc_get_ip() if running else None
version = None
domain = None
users_count = 0
videos_count = 0
storage_used = None
if running:
# Get version
r = lxc_exec(["cat", "/var/www/peertube/versions/peertube-latest/package.json"])
if r.returncode == 0:
try:
pkg = json.loads(r.stdout)
version = pkg.get("version")
except: pass
# Get stats from PostgreSQL
r = lxc_exec(["sudo", "-u", "postgres", "psql", "-t", "-c",
"SELECT COUNT(*) FROM \"user\";", "peertube"])
if r.returncode == 0:
try: users_count = int(r.stdout.strip())
except: pass
r = lxc_exec(["sudo", "-u", "postgres", "psql", "-t", "-c",
"SELECT COUNT(*) FROM video;", "peertube"])
if r.returncode == 0:
try: videos_count = int(r.stdout.strip())
except: pass
# Storage
r = lxc_exec(["du", "-sh", "/var/www/peertube/storage"])
if r.returncode == 0:
storage_used = r.stdout.split()[0]
disk_usage = None
if DATA_DIR.exists():
try:
r = subprocess.run(["du", "-sh", str(DATA_DIR)], capture_output=True, text=True)
if r.returncode == 0: disk_usage = r.stdout.split()[0]
except: pass
return {
"container_exists": container_exists,
"running": running,
"container_ip": container_ip,
"version": version,
"domain": domain,
"users_count": users_count,
"videos_count": videos_count,
"storage_used": storage_used,
"disk_usage": disk_usage,
"port": PEERTUBE_PORT,
}
@app.post("/install")
async def install(
domain: str = Query(..., description="PeerTube domain"),
admin_email: str = Query(..., description="Admin email"),
user=Depends(require_jwt)
):
if lxc_exists():
raise HTTPException(400, "Container already exists")
log.info(f"Installing PeerTube for {domain}")
DATA_DIR.mkdir(parents=True, exist_ok=True)
# Create container
r = subprocess.run([
"lxc-create", "-n", LXC_NAME, "-t", "download", "--",
"-d", "debian", "-r", "bookworm", "-a", "amd64"
], capture_output=True, text=True, timeout=600)
if r.returncode != 0:
raise HTTPException(500, f"Failed to create container: {r.stderr}")
# Configure LXC
lxc_config = f"/var/lib/lxc/{LXC_NAME}/config"
with open(lxc_config, "a") as f:
f.write(f"\n# SecuBox PeerTube\n")
f.write(f"lxc.mount.entry = {DATA_DIR} data none bind,create=dir 0 0\n")
f.write("lxc.start.auto = 1\n")
if not lxc_start():
raise HTTPException(500, "Failed to start container")
import time
for _ in range(30):
if lxc_get_ip(): break
time.sleep(1)
install_script = f'''#!/bin/bash
set -e
export DEBIAN_FRONTEND=noninteractive
# Install dependencies
apt-get update
apt-get install -y curl sudo gnupg ca-certificates
# Add NodeSource repo (Node.js 18)
curl -fsSL https://deb.nodesource.com/setup_18.x | bash -
# Install packages
apt-get install -y nodejs postgresql postgresql-contrib redis-server \
ffmpeg python3 python3-dev g++ make openssl certbot nginx
# Create peertube user
useradd -m -d /var/www/peertube -s /bin/bash peertube || true
# Setup PostgreSQL
sudo -u postgres psql -c "CREATE USER peertube WITH PASSWORD 'peertube';" 2>/dev/null || true
sudo -u postgres psql -c "CREATE DATABASE peertube OWNER peertube;" 2>/dev/null || true
sudo -u postgres psql -c "CREATE EXTENSION IF NOT EXISTS pg_trgm;" peertube 2>/dev/null || true
sudo -u postgres psql -c "CREATE EXTENSION IF NOT EXISTS unaccent;" peertube 2>/dev/null || true
# Download PeerTube
cd /var/www/peertube
sudo -u peertube mkdir -p versions config storage
cd versions
VERSION=$(curl -s https://api.github.com/repos/Chocobozzz/PeerTube/releases/latest | grep tag_name | cut -d'"' -f4)
sudo -u peertube wget -q "https://github.com/Chocobozzz/PeerTube/releases/download/${{VERSION}}/peertube-${{VERSION}}.zip"
sudo -u peertube unzip -q "peertube-${{VERSION}}.zip"
sudo -u peertube rm "peertube-${{VERSION}}.zip"
cd /var/www/peertube
sudo -u peertube ln -sf "versions/peertube-${{VERSION}}" peertube-latest
# Install dependencies
cd /var/www/peertube/peertube-latest
sudo -u peertube yarn install --production --pure-lockfile
# Create config
cat > /var/www/peertube/config/production.yaml << 'EOFCONFIG'
listen:
hostname: '0.0.0.0'
port: {PEERTUBE_PORT}
webserver:
https: true
hostname: '{domain}'
port: 443
database:
hostname: 'localhost'
port: 5432
name: 'peertube'
username: 'peertube'
password: 'peertube'
redis:
hostname: 'localhost'
port: 6379
smtp:
transport: 'sendmail'
sendmail: '/usr/sbin/sendmail'
storage:
tmp: '/var/www/peertube/storage/tmp/'
bin: '/var/www/peertube/storage/bin/'
avatars: '/var/www/peertube/storage/avatars/'
videos: '/var/www/peertube/storage/videos/'
streaming_playlists: '/var/www/peertube/storage/streaming-playlists/'
redundancy: '/var/www/peertube/storage/redundancy/'
logs: '/var/www/peertube/storage/logs/'
previews: '/var/www/peertube/storage/previews/'
thumbnails: '/var/www/peertube/storage/thumbnails/'
torrents: '/var/www/peertube/storage/torrents/'
captions: '/var/www/peertube/storage/captions/'
cache: '/var/www/peertube/storage/cache/'
plugins: '/var/www/peertube/storage/plugins/'
client_overrides: '/var/www/peertube/storage/client-overrides/'
admin:
email: '{admin_email}'
signup:
enabled: false
requires_approval: true
instance:
name: 'SecuBox PeerTube'
short_description: 'Federated video platform'
description: 'PeerTube instance powered by SecuBox'
EOFCONFIG
chown peertube:peertube /var/www/peertube/config/production.yaml
# Create storage directories
for dir in tmp bin avatars videos streaming-playlists redundancy logs previews thumbnails torrents captions cache plugins client-overrides; do
sudo -u peertube mkdir -p "/var/www/peertube/storage/$dir"
done
# Systemd service
cat > /etc/systemd/system/peertube.service << 'EOFSVC'
[Unit]
Description=PeerTube daemon
After=network.target postgresql.service redis.service
[Service]
Type=simple
User=peertube
Group=peertube
WorkingDirectory=/var/www/peertube/peertube-latest
ExecStart=/usr/bin/node dist/server
Environment=NODE_ENV=production
Environment=NODE_CONFIG_DIR=/var/www/peertube/config
Restart=on-failure
RestartSec=10
[Install]
WantedBy=multi-user.target
EOFSVC
systemctl daemon-reload
systemctl enable postgresql redis-server peertube
systemctl start postgresql redis-server
sleep 3
systemctl start peertube
echo "PeerTube installed"
'''
r = lxc_exec(["bash", "-c", install_script], timeout=900)
if r.returncode != 0:
log.error(f"Install failed: {r.stderr}")
raise HTTPException(500, f"Installation failed: {r.stderr[:500]}")
return {"success": True, "message": f"PeerTube installed for {domain}"}
@app.post("/start")
async def start(user=Depends(require_jwt)):
if not lxc_exists(): raise HTTPException(400, "Container not installed")
if lxc_running(): return {"success": True, "message": "Already running"}
if lxc_start():
lxc_exec(["systemctl", "start", "postgresql", "redis-server", "peertube"])
return {"success": True}
raise HTTPException(500, "Failed to start")
@app.post("/stop")
async def stop(user=Depends(require_jwt)):
if not lxc_running(): return {"success": True, "message": "Already stopped"}
lxc_exec(["systemctl", "stop", "peertube", "redis-server", "postgresql"])
if lxc_stop(): return {"success": True}
raise HTTPException(500, "Failed to stop")
@app.post("/restart")
async def restart(user=Depends(require_jwt)):
if not lxc_running(): raise HTTPException(400, "Container not running")
lxc_exec(["systemctl", "restart", "peertube"])
return {"success": True}
@app.delete("/uninstall")
async def uninstall(user=Depends(require_jwt)):
if lxc_running(): lxc_stop()
if lxc_exists():
subprocess.run(["lxc-destroy", "-n", LXC_NAME], capture_output=True)
return {"success": True, "message": "Container removed, data preserved"}
@app.get("/users")
async def list_users(user=Depends(require_jwt)):
if not lxc_running(): raise HTTPException(400, "Container not running")
r = lxc_exec(["sudo", "-u", "postgres", "psql", "-t", "-A", "-F", "|", "-c",
'SELECT username, email, role, "createdAt" FROM "user" ORDER BY "createdAt" DESC LIMIT 50;',
"peertube"])
users = []
if r.returncode == 0 and r.stdout.strip():
for line in r.stdout.strip().splitlines():
parts = line.split("|")
if len(parts) >= 3:
users.append({
"username": parts[0],
"email": parts[1] if len(parts) > 1 else "",
"role": parts[2] if len(parts) > 2 else "user",
})
return {"users": users}
@app.post("/users")
async def create_user(req: UserCreate, user=Depends(require_jwt)):
if not lxc_running(): raise HTTPException(400, "Container not running")
cmd = f'''cd /var/www/peertube/peertube-latest && \
NODE_ENV=production NODE_CONFIG_DIR=/var/www/peertube/config \
npx ts-node --transpile-only ./scripts/create-user.ts \
--username "{req.username}" --email "{req.email}" --password "{req.password}" --role "{req.role}"'''
r = lxc_exec(["bash", "-c", cmd], timeout=60)
return {"success": r.returncode == 0, "username": req.username}
@app.get("/videos")
async def list_videos(
limit: int = Query(20, ge=1, le=100),
user=Depends(require_jwt)
):
if not lxc_running(): raise HTTPException(400, "Container not running")
r = lxc_exec(["sudo", "-u", "postgres", "psql", "-t", "-A", "-F", "|", "-c",
f'SELECT uuid, name, views, likes, duration, "createdAt" FROM video ORDER BY "createdAt" DESC LIMIT {limit};',
"peertube"])
videos = []
if r.returncode == 0 and r.stdout.strip():
for line in r.stdout.strip().splitlines():
parts = line.split("|")
if len(parts) >= 4:
videos.append({
"uuid": parts[0],
"name": parts[1],
"views": int(parts[2]) if parts[2].isdigit() else 0,
"likes": int(parts[3]) if len(parts) > 3 and parts[3].isdigit() else 0,
})
return {"videos": videos}
@app.get("/config")
async def get_config(user=Depends(require_jwt)):
if not lxc_running(): return {"error": "Container not running"}
r = lxc_exec(["cat", "/var/www/peertube/config/production.yaml"])
if r.returncode != 0: raise HTTPException(500, "Failed to read config")
# Parse YAML simply
config = {}
for line in r.stdout.splitlines():
if ":" in line and not line.strip().startswith("#"):
key = line.split(":")[0].strip()
value = ":".join(line.split(":")[1:]).strip().strip("'\"")
config[key] = value
return config
@app.get("/logs")
async def get_logs(lines: int = Query(100, ge=10, le=1000), user=Depends(require_jwt)):
if not lxc_running(): raise HTTPException(400, "Container not running")
r = lxc_exec(["journalctl", "-u", "peertube", "-n", str(lines), "--no-pager"])
return {"logs": r.stdout.splitlines() if r.returncode == 0 else []}
@app.get("/stats")
async def get_stats(user=Depends(require_jwt)):
if not lxc_running(): raise HTTPException(400, "Container not running")
stats = {"users": 0, "videos": 0, "views": 0, "storage": "0"}
r = lxc_exec(["sudo", "-u", "postgres", "psql", "-t", "-c", 'SELECT COUNT(*) FROM "user";', "peertube"])
if r.returncode == 0: stats["users"] = int(r.stdout.strip()) if r.stdout.strip().isdigit() else 0
r = lxc_exec(["sudo", "-u", "postgres", "psql", "-t", "-c", "SELECT COUNT(*) FROM video;", "peertube"])
if r.returncode == 0: stats["videos"] = int(r.stdout.strip()) if r.stdout.strip().isdigit() else 0
r = lxc_exec(["sudo", "-u", "postgres", "psql", "-t", "-c", "SELECT COALESCE(SUM(views), 0) FROM video;", "peertube"])
if r.returncode == 0: stats["views"] = int(r.stdout.strip()) if r.stdout.strip().isdigit() else 0
r = lxc_exec(["du", "-sh", "/var/www/peertube/storage/videos"])
if r.returncode == 0: stats["storage"] = r.stdout.split()[0]
return stats
@app.post("/backup")
async def create_backup(user=Depends(require_jwt)):
backup_dir = Path("/var/lib/secubox/backups/peertube")
backup_dir.mkdir(parents=True, exist_ok=True)
from datetime import datetime
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
backup_file = backup_dir / f"peertube-backup-{timestamp}.tar.gz"
was_running = lxc_running()
if was_running:
lxc_exec(["systemctl", "stop", "peertube"])
# Dump database
lxc_exec(["sudo", "-u", "postgres", "pg_dump", "-Fc", "peertube", "-f", "/tmp/peertube.dump"])
try:
r = subprocess.run([
"tar", "-czf", str(backup_file),
"-C", str(DATA_DIR.parent), DATA_DIR.name
], capture_output=True, text=True, timeout=600)
if r.returncode != 0:
raise HTTPException(500, f"Backup failed: {r.stderr}")
return {
"success": True,
"file": str(backup_file),
"size": f"{backup_file.stat().st_size / 1024 / 1024:.1f} MB"
}
finally:
if was_running:
lxc_exec(["systemctl", "start", "peertube"])

View File

@ -0,0 +1,5 @@
secubox-peertube (1.0.0-1) bookworm; urgency=medium
* Initial release - PeerTube federated video platform
-- Gandalf / CyberMind <gk@cybermind.fr> Fri, 28 Mar 2026 10:30:00 +0100

View File

@ -0,0 +1,13 @@
Source: secubox-peertube
Section: net
Priority: optional
Maintainer: Gandalf / CyberMind <gk@cybermind.fr>
Build-Depends: debhelper-compat (= 13)
Standards-Version: 4.6.2
Package: secubox-peertube
Architecture: all
Depends: ${misc:Depends}, secubox-core (>= 1.0), python3-uvicorn, lxc, lxc-templates
Description: SecuBox PeerTube - Federated Video Platform
LXC-based PeerTube video platform for SecuBox.
ActivityPub-compatible decentralized YouTube alternative.

View File

@ -0,0 +1,12 @@
#!/bin/bash
set -e
case "$1" in
configure)
install -d -o secubox -g secubox -m 750 /var/lib/secubox/peertube/data
systemctl daemon-reload
systemctl enable secubox-peertube.service
systemctl start secubox-peertube.service || true
systemctl reload nginx 2>/dev/null || true
;;
esac
#DEBHELPER#

View File

@ -0,0 +1,16 @@
#!/usr/bin/make -f
%:
dh $@
override_dh_auto_install:
install -d $(CURDIR)/debian/secubox-peertube/usr/lib/secubox/peertube/api
install -m 644 api/main.py $(CURDIR)/debian/secubox-peertube/usr/lib/secubox/peertube/api/
touch $(CURDIR)/debian/secubox-peertube/usr/lib/secubox/peertube/api/__init__.py
install -d $(CURDIR)/debian/secubox-peertube/usr/share/secubox/www/peertube
install -m 644 www/peertube/index.html $(CURDIR)/debian/secubox-peertube/usr/share/secubox/www/peertube/
install -d $(CURDIR)/debian/secubox-peertube/etc/nginx/secubox.d
install -m 644 nginx/peertube.conf $(CURDIR)/debian/secubox-peertube/etc/nginx/secubox.d/
install -d $(CURDIR)/debian/secubox-peertube/usr/share/secubox/menu.d
install -m 644 menu.d/811-peertube.json $(CURDIR)/debian/secubox-peertube/usr/share/secubox/menu.d/
install -d $(CURDIR)/debian/secubox-peertube/usr/lib/systemd/system
install -m 644 debian/secubox-peertube.service $(CURDIR)/debian/secubox-peertube/usr/lib/systemd/system/

View File

@ -0,0 +1,17 @@
[Unit]
Description=SecuBox PeerTube API
After=network.target secubox-core.service
[Service]
Type=simple
User=root
WorkingDirectory=/usr/lib/secubox/peertube
ExecStart=/usr/bin/uvicorn api.main:app --uds /run/secubox/peertube.sock --log-level warning
UMask=0117
Restart=on-failure
PrivateTmp=true
ProtectSystem=strict
ReadWritePaths=/run/secubox /var/lib/secubox /var/lib/lxc
[Install]
WantedBy=multi-user.target

View File

@ -0,0 +1 @@
{"id":"peertube","name":"PeerTube","icon":"📺","path":"/peertube/","category":"apps","order":811,"description":"Federated video platform"}

View File

@ -0,0 +1,2 @@
location /api/v1/peertube/ { proxy_pass http://unix:/run/secubox/peertube.sock; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; }
location /peertube/ { alias /usr/share/secubox/www/peertube/; try_files $uri $uri/ /peertube/index.html; }

View File

@ -0,0 +1,190 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>PeerTube - SecuBox</title>
<link rel="stylesheet" href="/shared/crt-light.css">
<link rel="stylesheet" href="/shared/sidebar-light.css">
<style>
:root { --pt-orange: #f2690d; --pt-light: #ff8533; --pt-dark: #cc5500; }
body.dark { --pt-orange: #ff8533; --pt-light: #ffa366; --pt-dark: #f2690d; }
.accent { color: var(--pt-orange); }
.accent-bg { background-color: var(--pt-orange); }
.header { display: flex; justify-content: space-between; align-items: center; padding: 1rem; border-bottom: 1px solid var(--border); margin-bottom: 1rem; }
.header h1 { margin: 0; font-size: 1.5rem; }
.status-badge { padding: 0.25rem 0.75rem; border-radius: 1rem; font-size: 0.875rem; }
.status-badge.running { background: var(--success-bg); color: var(--success); }
.status-badge.stopped { background: var(--error-bg); color: var(--error); }
.stats-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); gap: 1rem; margin-bottom: 1.5rem; }
.stat-card { background: var(--surface); border: 1px solid var(--border); border-radius: 0.5rem; padding: 1rem; text-align: center; }
.stat-value { font-size: 1.75rem; font-weight: bold; color: var(--pt-orange); }
.stat-label { font-size: 0.75rem; color: var(--text-secondary); text-transform: uppercase; }
.action-bar { display: flex; gap: 0.5rem; margin-bottom: 1rem; flex-wrap: wrap; }
.btn { padding: 0.5rem 1rem; border: 1px solid var(--border); border-radius: 0.25rem; background: var(--surface); color: var(--text); cursor: pointer; font-size: 0.875rem; }
.btn:hover { background: var(--hover); }
.btn.primary { background: var(--pt-orange); color: white; border-color: var(--pt-orange); }
.btn.primary:hover { background: var(--pt-dark); }
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
.tabs { display: flex; gap: 0.5rem; border-bottom: 1px solid var(--border); margin-bottom: 1rem; }
.tab { padding: 0.75rem 1rem; border: none; background: none; color: var(--text-secondary); cursor: pointer; border-bottom: 2px solid transparent; }
.tab:hover { color: var(--text); }
.tab.active { color: var(--pt-orange); border-bottom-color: var(--pt-orange); }
.tab-content { display: none; }
.tab-content.active { display: block; }
.list-item { display: flex; align-items: center; gap: 1rem; padding: 0.75rem; background: var(--surface); border: 1px solid var(--border); border-radius: 0.5rem; margin-bottom: 0.5rem; }
.item-icon { font-size: 1.5rem; }
.item-info { flex: 1; }
.item-name { font-weight: 500; }
.item-meta { font-size: 0.875rem; color: var(--text-secondary); }
.log-viewer { background: var(--bg-dark, #1a1a2e); color: var(--text-light, #e0e0e0); font-family: monospace; font-size: 0.75rem; padding: 1rem; border-radius: 0.5rem; height: 400px; overflow-y: auto; white-space: pre-wrap; }
.settings-form { display: grid; gap: 1rem; max-width: 500px; }
.form-group label { display: block; margin-bottom: 0.25rem; font-weight: 500; }
.form-group input, .form-group select { width: 100%; padding: 0.5rem; border: 1px solid var(--border); border-radius: 0.25rem; background: var(--surface); color: var(--text); }
.open-app-btn { position: fixed; bottom: 1rem; right: 1rem; padding: 1rem 1.5rem; background: var(--pt-orange); color: white; border: none; border-radius: 2rem; cursor: pointer; font-size: 1rem; box-shadow: 0 4px 12px rgba(242, 105, 13, 0.4); }
.hidden { display: none !important; }
.install-form { max-width: 400px; margin: 2rem auto; padding: 2rem; background: var(--surface); border: 1px solid var(--border); border-radius: 0.5rem; }
.install-form h2 { margin-top: 0; text-align: center; }
</style>
</head>
<body class="crt-light">
<nav class="sidebar" id="sidebar"></nav>
<main class="main-content">
<div class="container">
<div class="header">
<h1><span class="accent">PeerTube</span></h1>
<span id="status-badge" class="status-badge stopped">Stopped</span>
</div>
<div id="install-section" class="install-form hidden">
<h2>Install PeerTube</h2>
<form onsubmit="install(event)">
<div class="form-group"><label>Domain</label><input type="text" id="install-domain" placeholder="videos.example.com" required></div>
<div class="form-group"><label>Admin Email</label><input type="email" id="install-email" required></div>
<button type="submit" class="btn primary" style="width: 100%; margin-top: 1rem;">Install</button>
</form>
</div>
<div id="main-section" class="hidden">
<div class="stats-grid">
<div class="stat-card"><div class="stat-value" id="users-count">0</div><div class="stat-label">Users</div></div>
<div class="stat-card"><div class="stat-value" id="videos-count">0</div><div class="stat-label">Videos</div></div>
<div class="stat-card"><div class="stat-value" id="views-count">0</div><div class="stat-label">Views</div></div>
<div class="stat-card"><div class="stat-value" id="storage-used">-</div><div class="stat-label">Storage</div></div>
</div>
<div class="action-bar">
<button class="btn primary" id="btn-start" onclick="startService()">Start</button>
<button class="btn" id="btn-stop" onclick="stopService()" disabled>Stop</button>
<button class="btn" id="btn-restart" onclick="restartService()" disabled>Restart</button>
<button class="btn" onclick="createBackup()">Backup</button>
</div>
<div class="tabs">
<button class="tab active" data-tab="videos">Videos</button>
<button class="tab" data-tab="users">Users</button>
<button class="tab" data-tab="logs">Logs</button>
</div>
<div id="tab-videos" class="tab-content active">
<div id="videos-list"></div>
</div>
<div id="tab-users" class="tab-content">
<div class="action-bar"><button class="btn primary" onclick="showCreateUser()">Create User</button></div>
<div id="users-list"></div>
</div>
<div id="tab-logs" class="tab-content">
<div class="action-bar"><button class="btn" onclick="loadLogs()">Refresh</button></div>
<div id="log-viewer" class="log-viewer">Loading...</div>
</div>
</div>
</div>
<button id="open-app-btn" class="open-app-btn hidden" onclick="openApp()">Open PeerTube</button>
</main>
<script>
const API = '/api/v1/peertube';
let containerIP = null;
async function api(endpoint, options = {}) {
const r = await fetch(API + endpoint, { ...options, headers: { 'Authorization': 'Bearer ' + localStorage.getItem('sbx_token'), 'Content-Type': 'application/json', ...options.headers } });
if (!r.ok) throw new Error(`HTTP ${r.status}`);
return r.json();
}
async function loadStatus() {
try {
const d = await api('/status');
const badge = document.getElementById('status-badge');
if (!d.container_exists) {
document.getElementById('install-section').classList.remove('hidden');
document.getElementById('main-section').classList.add('hidden');
badge.textContent = 'Not Installed'; badge.className = 'status-badge stopped';
return;
}
document.getElementById('install-section').classList.add('hidden');
document.getElementById('main-section').classList.remove('hidden');
if (d.running) {
badge.textContent = 'Running'; badge.className = 'status-badge running';
document.getElementById('btn-start').disabled = true;
document.getElementById('btn-stop').disabled = false;
document.getElementById('btn-restart').disabled = false;
containerIP = d.container_ip;
document.getElementById('open-app-btn').classList.remove('hidden');
loadStats(); loadVideos(); loadUsers();
} else {
badge.textContent = 'Stopped'; badge.className = 'status-badge stopped';
document.getElementById('btn-start').disabled = false;
document.getElementById('btn-stop').disabled = true;
document.getElementById('btn-restart').disabled = true;
document.getElementById('open-app-btn').classList.add('hidden');
}
document.getElementById('storage-used').textContent = d.storage_used || '-';
} catch (e) { console.error(e); }
}
async function loadStats() {
try {
const s = await api('/stats');
document.getElementById('users-count').textContent = s.users || 0;
document.getElementById('videos-count').textContent = s.videos || 0;
document.getElementById('views-count').textContent = s.views || 0;
document.getElementById('storage-used').textContent = s.storage || '-';
} catch (e) { console.error(e); }
}
async function loadVideos() {
try {
const d = await api('/videos?limit=20');
document.getElementById('videos-list').innerHTML = (d.videos || []).map(v => `<div class="list-item"><div class="item-icon">🎬</div><div class="item-info"><div class="item-name">${v.name}</div><div class="item-meta">${v.views} views · ${v.likes} likes</div></div></div>`).join('') || '<p>No videos</p>';
} catch (e) { console.error(e); }
}
async function loadUsers() {
try {
const d = await api('/users');
document.getElementById('users-list').innerHTML = (d.users || []).map(u => `<div class="list-item"><div class="item-icon">👤</div><div class="item-info"><div class="item-name">${u.username}</div><div class="item-meta">${u.email} · ${u.role}</div></div></div>`).join('') || '<p>No users</p>';
} catch (e) { console.error(e); }
}
async function install(e) { e.preventDefault(); const domain = document.getElementById('install-domain').value; const email = document.getElementById('install-email').value; const btn = e.target.querySelector('button'); btn.disabled = true; btn.textContent = 'Installing...'; try { await api(`/install?domain=${encodeURIComponent(domain)}&admin_email=${encodeURIComponent(email)}`, { method: 'POST' }); await loadStatus(); } catch (err) { alert('Failed: ' + err.message); } finally { btn.disabled = false; btn.textContent = 'Install'; } }
async function startService() { document.getElementById('btn-start').disabled = true; try { await api('/start', { method: 'POST' }); await loadStatus(); } catch (e) { alert('Failed: ' + e.message); } }
async def stopService() { document.getElementById('btn-stop').disabled = true; try { await api('/stop', { method: 'POST' }); await loadStatus(); } catch (e) { alert('Failed: ' + e.message); } }
async function restartService() { document.getElementById('btn-restart').disabled = true; try { await api('/restart', { method: 'POST' }); await loadStatus(); } catch (e) { alert('Failed: ' + e.message); } }
function showCreateUser() { const u = prompt('Username:'); if (!u) return; const e = prompt('Email:'); if (!e) return; const p = prompt('Password (8+ chars):'); if (!p || p.length < 8) { alert('Password too short'); return; } createUser(u, e, p); }
async function createUser(u, e, p) { try { await api('/users', { method: 'POST', body: JSON.stringify({ username: u, email: e, password: p }) }); loadUsers(); } catch (err) { alert('Failed: ' + err.message); } }
async function loadLogs() { try { const d = await api('/logs?lines=200'); document.getElementById('log-viewer').textContent = d.logs?.join('\n') || 'No logs'; } catch (e) { document.getElementById('log-viewer').textContent = 'Error: ' + e.message; } }
async function createBackup() { try { const r = await api('/backup', { method: 'POST' }); alert(`Backup: ${r.file} (${r.size})`); } catch (e) { alert('Failed: ' + e.message); } }
function openApp() { if (containerIP) window.open(`http://${containerIP}:9000/`, '_blank'); }
document.querySelectorAll('.tab').forEach(tab => { tab.addEventListener('click', () => { document.querySelectorAll('.tab').forEach(t => t.classList.remove('active')); document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active')); tab.classList.add('active'); document.getElementById('tab-' + tab.dataset.tab).classList.add('active'); if (tab.dataset.tab === 'logs') loadLogs(); }); });
if (window.matchMedia?.('(prefers-color-scheme: dark)').matches) document.body.classList.add('dark');
loadStatus(); setInterval(loadStatus, 30000);
</script>
<script src="/shared/sidebar.js"></script>
</body>
</html>