Files
cam_control/server.py
2026-01-07 05:26:56 +09:00

3988 lines
144 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
KazicCAM - Серверная часть с веб-интерфейсом
FastAPI + OpenCV + WebSocket + Multiprocessing
Версия: 3.1.1, 35 пересборка
Для Windows
"""
import os
import sys
import uuid
import json
import time
import asyncio
import hashlib
import secrets
import string
from contextlib import asynccontextmanager
from datetime import datetime
from typing import Dict, List, Optional, Set, Tuple, Any
from collections import defaultdict
from multiprocessing import Process, Manager, Queue, cpu_count, freeze_support
from concurrent.futures import ProcessPoolExecutor
from dotenv import load_dotenv
import cv2
import numpy as np
import uvicorn
from fastapi import FastAPI, WebSocket, WebSocketDisconnect, HTTPException, Depends, status, Request, Form
from fastapi.security import HTTPBasic, HTTPBasicCredentials
from fastapi.responses import HTMLResponse, JSONResponse, StreamingResponse, RedirectResponse
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from pydantic import BaseModel
import psutil
load_dotenv()
# ========== КОНФИГУРАЦИЯ ==========
SERVER_CONFIG = {
# считываем переменную окружения HOST (или legacy 'host'), чтобы можно было управлять через .env / docker-compose
"host": os.getenv("HOST", os.getenv("host", "0.0.0.0")), # сюда IP смотрящий наружу или 0.0.0.0 для всех интерфейсов
"port": int(os.getenv("PORT", 8000)),
"ssl_certfile": os.getenv("SSL_CERTFILE", "/etc/videostream/ssl/cert.pem"),
"ssl_keyfile": os.getenv("SSL_KEYFILE", "/etc/videostream/ssl/key.pem"),
"ssl_enabled": os.getenv("SSL_ENABLED", "false").lower() == "true",
"debug": False,
"max_clients_per_room": 50,
"max_rooms": 100,
"video_width": 640,
"video_height": 480,
"frame_rate": 30,
"jpeg_quality": 85,
"websocket_ping_interval": 30,
"websocket_ping_timeout": 10,
"admin_session_timeout": 3600,
}
# ========== МОДЕЛИ ДАННЫХ ==========
class RoomCreate(BaseModel):
name: str
password: str
max_connections: int
class RoomUpdate(BaseModel):
name: Optional[str] = None
password: Optional[str] = None
max_connections: Optional[int] = None
# ========== ГЛОБАЛЬНЫЕ ПЕРЕМЕННЫЕ ==========
rooms = None
clients = None
admin_sessions = None
client_websockets = None
admin_websockets = None
video_queues = None
command_queues = None
room_stats = None
server_stats = None
stats_lock = None
cleanup_task = None
templates = None
processed_video_queues = {}
# Администраторы
ADMINS = [
["admin", "admin123"],
["administrator", "securepass"],
["supervisor", "superpass"]
]
# ========== ВСПОМОГАТЕЛЬНЫЕ ФУНКЦИИ ==========
def safe_json_serializer(obj: Any) -> Any:
"""Безопасный сериализатор JSON для объектов Python"""
if isinstance(obj, set):
return list(obj)
elif isinstance(obj, datetime):
return obj.isoformat()
elif hasattr(obj, '__dict__'):
return obj.__dict__
return str(obj)
def get_server_host():
"""Получение IP адреса сервера"""
import socket
# 0) Если явно задан адрес, который должен отображаться в UI (рекомендуется при запуске в Docker), используем его
public_host = os.getenv("PUBLIC_HOST") or os.getenv("ADVERTISED_HOST")
if public_host and public_host not in ("", "0.0.0.0", "127.0.0.1", "localhost"):
return public_host
# 1) Если в конфиге явно указан хост (и это не 0.0.0.0 / localhost), используем его
cfg_host = SERVER_CONFIG.get("host")
if cfg_host and cfg_host not in ("0.0.0.0", "127.0.0.1", "localhost", ""):
return cfg_host
# 2) Попытка определить внешний IP без отправки данных — UDP socket к публичному адресу
try:
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
# Не устанавливаем реального соединения, просто используем маршрутную информацию
s.connect(("8.8.8.8", 80))
ip_address = s.getsockname()[0]
s.close()
if ip_address and not ip_address.startswith("127."):
return ip_address
except Exception:
pass
# 3) Попытка через hostname (может вернуть 127.0.1.1 на некоторых системах)
try:
hostname = socket.gethostname()
ip_address = socket.gethostbyname(hostname)
if ip_address and not ip_address.startswith("127."):
return ip_address
except Exception:
pass
# 4) Фолбэк — вернуть значение из конфига (возможно 0.0.0.0 или что задано в ENV)
return cfg_host or "0.0.0.0"
# ========== КЛАССЫ ДЛЯ УПРАВЛЕНИЯ ==========
class RoomManager:
@staticmethod
def generate_room_id(length: int = 11) -> str:
"""Генерация ID комнаты из букв и цифр"""
alphabet = string.ascii_letters + string.digits
return ''.join(secrets.choice(alphabet) for _ in range(length))
@staticmethod
def create_room(name: str, password: str, max_connections: int, created_by: str = None) -> Dict:
"""Создание новой комнаты"""
global rooms, room_stats, server_stats, stats_lock
room_id = RoomManager.generate_room_id()
room = {
"id": room_id,
"name": name,
"password": password,
"max_connections": max_connections,
"created_at": datetime.now().isoformat(),
"created_by": created_by,
"clients": [], # Используем список вместо set
"is_active": True
}
with stats_lock:
rooms[room_id] = room
room_stats[room_id] = {
"total_clients": 0,
"active_streams": 0,
"bytes_transferred": 0,
"last_activity": datetime.now().isoformat()
}
server_stats["total_rooms"] = len(rooms)
print(f"[RoomManager] Created room: {room_id} - {name}")
return room
@staticmethod
def delete_room(room_id: str) -> bool:
"""Удаление комнаты и отключение всех клиентов"""
global rooms, room_stats, server_stats, stats_lock
if room_id in rooms:
# Отключаем всех клиентов в комнате
for client_id in list(rooms[room_id]["clients"]):
ClientManager.disconnect_client(client_id)
with stats_lock:
del rooms[room_id]
if room_id in room_stats:
del room_stats[room_id]
server_stats["total_rooms"] = len(rooms)
print(f"[RoomManager] Deleted room: {room_id}")
return True
return False
@staticmethod
def update_room(room_id: str, updates: dict) -> Optional[Dict]:
"""Обновление информации о комнате"""
global rooms, stats_lock
if room_id in rooms:
with stats_lock:
for key, value in updates.items():
if value is not None and key in rooms[room_id]:
rooms[room_id][key] = value
return rooms[room_id]
return None
class ClientManager:
@staticmethod
def generate_client_id() -> str:
"""Генерация уникального ID клиента"""
return str(uuid.uuid4())
@staticmethod
def add_client(room_id: str, password: str, ip_address: str) -> Optional[str]:
"""Добавление нового клиента"""
global rooms, clients, room_stats, server_stats, stats_lock
print(f"[ClientManager] add_client called: room={room_id}, ip={ip_address}")
if room_id not in rooms:
print(f"[ClientManager] ❌ Room not found: {room_id}")
return None
room = rooms[room_id]
print(f"[ClientManager] Found room: {room['name']}")
if room["password"] != password:
print(f"[ClientManager] ❌ Invalid password for room: {room_id} (expected: {room['password']}, got: {password})")
return None
if len(room["clients"]) >= room["max_connections"]:
print(f"[ClientManager] ❌ Room {room_id} is full: {len(room['clients'])}/{room['max_connections']}")
return None
client_id = ClientManager.generate_client_id()
print(f"[ClientManager] Generated client_id: {client_id}")
client = {
"id": client_id,
"room_id": room_id,
"ip_address": ip_address,
"connected_at": datetime.now().isoformat(),
"last_activity": datetime.now().isoformat(),
"is_streaming": False,
"video_settings": {
"quality": 85,
"frame_rate": 30,
"resolution": "640x480"
},
"commands": []
}
with stats_lock:
clients[client_id] = client
# Используем список вместо set
if client_id not in room["clients"]:
room["clients"].append(client_id)
# Обновление статистики
if room_id in room_stats:
room_stats[room_id]["total_clients"] = len(room["clients"])
room_stats[room_id]["last_activity"] = datetime.now().isoformat()
server_stats["total_clients"] = len(clients)
print(f"[ClientManager] ✓ Added client: {client_id} to room: {room_id} (total in room: {len(room['clients'])})")
return client_id
@staticmethod
def disconnect_client(client_id: str) -> bool:
"""Отключение клиента"""
global rooms, clients, client_websockets, video_queues, command_queues, room_stats, server_stats, stats_lock
if client_id in clients:
room_id = clients[client_id]["room_id"]
with stats_lock:
# Удаляем из комнаты
if room_id in rooms and client_id in rooms[room_id]["clients"]:
rooms[room_id]["clients"].remove(client_id)
if room_id in room_stats:
room_stats[room_id]["total_clients"] = len(rooms[room_id]["clients"])
# Удаляем клиента
del clients[client_id]
server_stats["total_clients"] = len(clients)
# Закрываем WebSocket соединение
if client_id in client_websockets:
try:
asyncio.create_task(client_websockets[client_id].close())
except:
pass
del client_websockets[client_id]
# Очищаем очереди
if client_id in video_queues:
del video_queues[client_id]
if client_id in command_queues:
del command_queues[client_id]
print(f"[ClientManager] Disconnected client: {client_id}")
return True
return False
class VideoProcessor:
"""Обработчик видео в отдельном процессе"""
def __init__(self, client_id: str):
self.client_id = client_id
self.video_queue = Queue(maxsize=10)
self.command_queue = Queue()
self.process = None
self.is_running = False
video_queues[client_id] = self.video_queue
command_queues[client_id] = self.command_queue
def start(self):
"""Запуск процесса обработки видео"""
self.is_running = True
self.process = Process(
target=self._process_video_stream,
args=(self.client_id,)
)
self.process.daemon = True
self.process.start()
print(f"[VideoProcessor] ✓ Started process for client: {self.client_id} (PID: {self.process.pid})")
def stop(self):
"""Остановка процесса"""
self.is_running = False
if self.process:
self.process.terminate()
self.process.join(timeout=5)
if self.process.is_alive():
self.process.kill()
print(f"[VideoProcessor] Stopped for client: {self.client_id}")
@staticmethod
def _process_video_stream(client_id: str):
"""Основной цикл обработки видео с пересылкой в общую очередь"""
print(f"[VideoProcessor] Process started for client {client_id}")
video_queue = None
command_queue = None
# Ждем инициализации очередей
for _ in range(100):
try:
if client_id in video_queues and client_id in command_queues:
video_queue = video_queues[client_id]
command_queue = command_queues[client_id]
break
except:
pass
time.sleep(0.1)
if not video_queue or not command_queue:
print(f"[VideoProcessor] Queues not found for client {client_id}")
return
# Создаем очередь для обработанных кадров (для администраторов)
processed_queue = Queue(maxsize=10)
# Добавляем эту очередь в глобальный словарь для доступа администраторов
processed_video_queues[client_id] = processed_queue
frame_count = 0
last_log_time = time.time()
while True:
try:
if not video_queue.empty():
frame_data = video_queue.get(timeout=1)
frame_count += 1
nparr = np.frombuffer(frame_data, np.uint8)
frame = cv2.imdecode(nparr, cv2.IMREAD_COLOR)
if frame is not None:
# Применяем команды из очереди
while not command_queue.empty():
try:
command = command_queue.get_nowait()
frame = VideoProcessor._apply_command(frame, command)
except:
break
# Кодируем обратно в JPEG
encode_param = [int(cv2.IMWRITE_JPEG_QUALITY), 85]
success, encoded_frame = cv2.imencode('.jpg', frame, encode_param)
if success:
# Отправляем в очередь для администраторов
if not processed_queue.full():
processed_queue.put(encoded_frame.tobytes())
# Логируем каждые 5 секунд
current_time = time.time()
if current_time - last_log_time > 5:
print(f"[VideoProcessor] Client {client_id}: Processed {frame_count} frames")
last_log_time = current_time
time.sleep(0.001)
except Exception as e:
print(f"[VideoProcessor] Error: {e}")
time.sleep(0.1)
@staticmethod
def _apply_command(frame: np.ndarray, command: Dict) -> np.ndarray:
"""Применение команды к кадру"""
try:
if not isinstance(command, dict):
return frame
cmd_type = command.get("type")
if cmd_type == "adjust_quality":
pass
elif cmd_type == "rotate":
angle = command.get("angle", 0)
if angle == 90:
frame = cv2.rotate(frame, cv2.ROTATE_90_CLOCKWISE)
elif angle == 180:
frame = cv2.rotate(frame, cv2.ROTATE_180)
elif angle == 270:
frame = cv2.rotate(frame, cv2.ROTATE_90_COUNTERCLOCKWISE)
elif cmd_type == "flip":
flip_code = command.get("direction", 0)
if flip_code in [0, 1, -1]:
frame = cv2.flip(frame, flip_code)
elif cmd_type == "grayscale":
frame = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
frame = cv2.cvtColor(frame, cv2.COLOR_GRAY2BGR)
elif cmd_type == "brightness":
value = command.get("value", 0)
frame = cv2.convertScaleAbs(frame, alpha=1, beta=value)
elif cmd_type == "contrast":
value = command.get("value", 1.0)
frame = cv2.convertScaleAbs(frame, alpha=value, beta=0)
return frame
except Exception as e:
print(f"[VideoProcessor] Error applying command: {e}")
return frame
class AdminAuth:
security = HTTPBasic()
@staticmethod
def authenticate_admin(credentials: HTTPBasicCredentials = Depends(security)):
"""Аутентификация администратора"""
global admin_sessions, stats_lock
for admin in ADMINS:
if credentials.username == admin[0] and credentials.password == admin[1]:
session_id = hashlib.sha256(
f"{credentials.username}{time.time()}".encode()
).hexdigest()[:32]
with stats_lock:
admin_sessions[session_id] = {
"username": credentials.username,
"login_time": datetime.now().isoformat(),
"last_activity": datetime.now().isoformat(),
"is_authenticated": True
}
print(f"[AdminAuth] User authenticated: {credentials.username}")
return {"session_id": session_id, "username": credentials.username}
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid credentials",
headers={"WWW-Authenticate": "Basic"},
)
@staticmethod
def verify_session(session_id: str) -> bool:
"""Проверка сессии администратора"""
global admin_sessions, stats_lock
if session_id in admin_sessions:
with stats_lock:
admin_sessions[session_id]["last_activity"] = datetime.now().isoformat()
return True
return False
# ========== ФОНОВЫЕ ЗАДАЧИ ==========
async def cleanup_inactive_sessions():
"""Очистка неактивных сессий"""
global admin_sessions, admin_websockets, rooms, room_stats, stats_lock
while True:
try:
current_time = datetime.now()
inactive_sessions = []
with stats_lock:
for session_id, session_data in admin_sessions.items():
last_activity = datetime.fromisoformat(session_data["last_activity"])
timeout = SERVER_CONFIG["admin_session_timeout"]
if (current_time - last_activity).seconds > timeout:
inactive_sessions.append(session_id)
for session_id in inactive_sessions:
if session_id in admin_sessions:
del admin_sessions[session_id]
for session_id in inactive_sessions:
if session_id in admin_websockets:
try:
await admin_websockets[session_id].close()
except:
pass
del admin_websockets[session_id]
inactive_rooms = []
with stats_lock:
for room_id, room in rooms.items():
if not room["clients"]:
stats = room_stats.get(room_id, {})
last_activity_str = stats.get("last_activity", current_time.isoformat())
last_activity = datetime.fromisoformat(last_activity_str)
if (current_time - last_activity).seconds > 86400:
inactive_rooms.append(room_id)
for room_id in inactive_rooms:
RoomManager.delete_room(room_id)
await asyncio.sleep(60)
except Exception as e:
print(f"[Cleanup] Error: {e}")
await asyncio.sleep(60)
# ========== LIFESPAN MANAGER ==========
@asynccontextmanager
async def lifespan(app: FastAPI):
"""Управление жизненным циклом приложения"""
global rooms, clients, admin_sessions, client_websockets, admin_websockets
global video_queues, command_queues, room_stats, server_stats, stats_lock, cleanup_task, templates
import threading
stats_lock = threading.Lock()
rooms = {}
clients = {}
admin_sessions = {}
client_websockets = {}
admin_websockets = {}
video_queues = {}
command_queues = {}
room_stats = {}
server_stats = {
"total_rooms": 0,
"total_clients": 0,
"total_streams": 0,
"start_time": datetime.now().isoformat(),
"cpu_usage": 0,
"memory_usage": 0,
"uptime": 0
}
# Создаем папку для шаблонов если её нет
os.makedirs("templates", exist_ok=True)
templates = Jinja2Templates(directory="templates")
print("=" * 60)
print("🎥 Video Streaming Server with Web Interface")
print(f"📡 Server running on: {SERVER_CONFIG['host']}:{SERVER_CONFIG['port']}")
print(f"🌐 Web Interface: http://{SERVER_CONFIG['host']}:{SERVER_CONFIG['port']}/")
print(f"👥 Admin accounts: {len(ADMINS)}")
print("=" * 60)
cleanup_task = asyncio.create_task(cleanup_inactive_sessions())
yield
print("Shutting down server...")
if cleanup_task:
cleanup_task.cancel()
try:
await cleanup_task
except asyncio.CancelledError:
pass
for client_id in list(clients.keys()):
ClientManager.disconnect_client(client_id)
print("Server shutdown complete.")
# ========== FASTAPI ПРИЛОЖЕНИЕ ==========
app = FastAPI(
title="Video Streaming Server",
version="2.1.0",
lifespan=lifespan
)
# CORS middleware
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Статические файлы
os.makedirs("static", exist_ok=True)
app.mount("/static", StaticFiles(directory="static"), name="static")
# ========== ВЕБ-ИНТЕРФЕЙС ==========
@app.get("/", response_class=HTMLResponse)
async def login_page(request: Request):
"""Страница входа"""
server_host = get_server_host()
return templates.TemplateResponse("login.html", {
"request": request,
"server_host": server_host,
"server_port": SERVER_CONFIG["port"]
})
@app.post("/login")
async def login_form(request: Request, username: str = Form(...), password: str = Form(...)):
"""Обработка формы входа"""
for admin in ADMINS:
if username == admin[0] and password == admin[1]:
session_id = hashlib.sha256(
f"{username}{time.time()}".encode()
).hexdigest()[:32]
with stats_lock:
admin_sessions[session_id] = {
"username": username,
"login_time": datetime.now().isoformat(),
"last_activity": datetime.now().isoformat(),
"is_authenticated": True
}
print(f"[Auth] ✓ Admin logged in: {username}, session_id={session_id[:16]}..., total sessions: {len(admin_sessions)}")
response = RedirectResponse(url="/dashboard", status_code=303)
response.set_cookie(key="session_id", value=session_id)
return response
print(f"[Auth] ❌ Failed login attempt: {username}")
server_host = get_server_host()
return templates.TemplateResponse("login.html", {
"request": request,
"error": "Invalid username or password",
"server_host": server_host,
"server_port": SERVER_CONFIG["port"]
})
@app.get("/dashboard", response_class=HTMLResponse)
async def dashboard_page(request: Request):
"""Панель управления"""
session_id = request.cookies.get("session_id")
if not session_id or not AdminAuth.verify_session(session_id):
return RedirectResponse(url="/", status_code=303)
with stats_lock:
stats = server_stats.copy()
# Преобразуем данные для JSON сериализации
room_list = []
for room_id, room in rooms.items():
room_stats_data = room_stats.get(room_id, {})
room_list.append({
"id": room["id"],
"name": room["name"],
"clients_count": len(room["clients"]),
"max_connections": room["max_connections"],
"created_at": room["created_at"],
"created_by": room.get("created_by", "Unknown"),
"active_streams": room_stats_data.get("active_streams", 0),
"clients": room["clients"] # Уже список
})
server_host = get_server_host()
return templates.TemplateResponse("dashboard.html", {
"request": request,
"username": admin_sessions[session_id]["username"],
"stats": stats,
"rooms": room_list,
"total_rooms": len(rooms),
"total_clients": len(clients),
"server_host": server_host,
"server_port": SERVER_CONFIG["port"]
})
@app.get("/room/{room_id}", response_class=HTMLResponse)
async def room_page(request: Request, room_id: str):
"""Страница управления комнатой"""
session_id = request.cookies.get("session_id")
if not session_id or not AdminAuth.verify_session(session_id):
return RedirectResponse(url="/", status_code=303)
if room_id not in rooms:
return RedirectResponse(url="/dashboard", status_code=303)
room = rooms[room_id]
room_stats_data = room_stats.get(room_id, {})
clients_list = []
for client_id in room["clients"]:
if client_id in clients:
client = clients[client_id]
clients_list.append({
"id": client_id,
"ip_address": client["ip_address"],
"connected_at": client["connected_at"],
"is_streaming": client["is_streaming"],
"video_settings": client["video_settings"]
})
server_host = get_server_host()
return templates.TemplateResponse("room.html", {
"request": request,
"room": room,
"room_stats": room_stats_data,
"clients": clients_list,
"username": admin_sessions[session_id]["username"],
"server_host": server_host,
"server_port": SERVER_CONFIG["port"],
"ssl_enabled": SERVER_CONFIG.get("ssl_enabled", False)
})
@app.get("/stream/{client_id}", response_class=HTMLResponse)
async def stream_page(request: Request, client_id: str):
"""Страница просмотра потока клиента"""
session_id = request.cookies.get("session_id")
if not session_id or not AdminAuth.verify_session(session_id):
return RedirectResponse(url="/", status_code=303)
if client_id not in clients:
return RedirectResponse(url="/dashboard", status_code=303)
client = clients[client_id]
room = rooms[client["room_id"]] if client["room_id"] in rooms else None
server_host = get_server_host()
return templates.TemplateResponse("stream.html", {
"request": request,
"client": client,
"room": room,
"session_id": session_id,
"username": admin_sessions[session_id]["username"],
"server_host": server_host,
"server_port": SERVER_CONFIG["port"]
})
@app.get("/create-room", response_class=HTMLResponse)
async def create_room_page(request: Request):
"""Страница создания комнаты"""
session_id = request.cookies.get("session_id")
if not session_id or not AdminAuth.verify_session(session_id):
return RedirectResponse(url="/", status_code=303)
server_host = get_server_host()
return templates.TemplateResponse("create_room.html", {
"request": request,
"username": admin_sessions[session_id]["username"],
"server_host": server_host,
"server_port": SERVER_CONFIG["port"]
})
@app.post("/api/create-room")
async def create_room_api(request: Request, name: str = Form(...), password: str = Form(...), max_connections: int = Form(...)):
"""API для создания комнаты через веб-интерфейс"""
session_id = request.cookies.get("session_id")
if not session_id or not AdminAuth.verify_session(session_id):
return JSONResponse({"success": False, "error": "Unauthorized"})
created_by = admin_sessions[session_id]["username"] if session_id in admin_sessions else "Unknown"
try:
room = RoomManager.create_room(name, password, max_connections, created_by)
# Используем safe_json_serializer для преобразования данных
room_serializable = {
"id": room["id"],
"name": room["name"],
"password": room["password"],
"max_connections": room["max_connections"],
"created_at": room["created_at"],
"created_by": room["created_by"],
"is_active": room["is_active"],
"clients": room["clients"] # Уже список
}
return JSONResponse({"success": True, "room": room_serializable})
except Exception as e:
print(f"[API] Error creating room: {e}")
return JSONResponse({"success": False, "error": str(e)})
@app.post("/api/delete-room/{room_id}")
async def delete_room_api(request: Request, room_id: str):
"""API для удаления комнаты"""
session_id = request.cookies.get("session_id")
if not session_id or not AdminAuth.verify_session(session_id):
return JSONResponse({"success": False, "error": "Unauthorized"})
success = RoomManager.delete_room(room_id)
return JSONResponse({"success": success})
@app.post("/api/disconnect-client/{client_id}")
async def disconnect_client_api(request: Request, client_id: str):
"""API для отключения клиента"""
session_id = request.cookies.get("session_id")
if not session_id or not AdminAuth.verify_session(session_id):
return JSONResponse({"success": False, "error": "Unauthorized"})
success = ClientManager.disconnect_client(client_id)
return JSONResponse({"success": success})
@app.get("/logout")
async def logout(request: Request):
"""Выход из системы"""
session_id = request.cookies.get("session_id")
if session_id and session_id in admin_sessions:
with stats_lock:
del admin_sessions[session_id]
response = RedirectResponse(url="/", status_code=303)
response.delete_cookie(key="session_id")
return response
# ========== API РОУТЫ ==========
@app.post("/api/auth/login")
async def login_api(auth_data: dict):
"""Вход администратора (API)"""
username = auth_data.get("username")
password = auth_data.get("password")
for admin in ADMINS:
if username == admin[0] and password == admin[1]:
session_id = hashlib.sha256(
f"{username}{time.time()}".encode()
).hexdigest()[:32]
with stats_lock:
admin_sessions[session_id] = {
"username": username,
"login_time": datetime.now().isoformat(),
"last_activity": datetime.now().isoformat(),
"is_authenticated": True
}
return {"success": True, "session_id": session_id, "username": username}
return {"success": False, "error": "Invalid credentials"}
@app.get("/api/auth/verify")
async def verify_session_api(session_id: str):
"""Проверка сессии (API)"""
if AdminAuth.verify_session(session_id):
return {"success": True, "username": admin_sessions[session_id]["username"]}
return {"success": False}
@app.post("/api/rooms/create")
async def create_room_api_endpoint(room_data: RoomCreate, session_id: str):
"""Создание новой комнаты (API)"""
if not AdminAuth.verify_session(session_id):
raise HTTPException(status_code=401, detail="Unauthorized")
created_by = admin_sessions[session_id]["username"] if session_id in admin_sessions else None
room = RoomManager.create_room(
name=room_data.name,
password=room_data.password,
max_connections=room_data.max_connections,
created_by=created_by
)
# Возвращаем сериализуемые данные
room_response = {
"id": room["id"],
"name": room["name"],
"password": room["password"],
"max_connections": room["max_connections"],
"created_at": room["created_at"],
"created_by": room["created_by"],
"is_active": room["is_active"],
"clients": room["clients"]
}
return {"success": True, "room": room_response}
@app.get("/api/rooms")
async def get_rooms_api(session_id: str):
"""Получение списка комнат (API)"""
if not AdminAuth.verify_session(session_id):
raise HTTPException(status_code=401, detail="Unauthorized")
with stats_lock:
room_list = []
for room_id, room in rooms.items():
stats = room_stats.get(room_id, {})
room_list.append({
"id": room["id"],
"name": room["name"],
"created_at": room["created_at"],
"created_by": room.get("created_by"),
"max_connections": room["max_connections"],
"clients_count": len(room["clients"]),
"is_active": room["is_active"],
"stats": stats,
"clients": room["clients"] # Уже список
})
return {"success": True, "rooms": room_list}
@app.delete("/api/rooms/{room_id}")
async def delete_room_api_endpoint(room_id: str, session_id: str):
"""Удаление комнаты (API)"""
if not AdminAuth.verify_session(session_id):
raise HTTPException(status_code=401, detail="Unauthorized")
success = RoomManager.delete_room(room_id)
return {"success": success}
@app.get("/api/rooms/{room_id}/clients")
async def get_room_clients_api(room_id: str, session_id: str):
"""Получение клиентов комнаты (API)"""
if not AdminAuth.verify_session(session_id):
raise HTTPException(status_code=401, detail="Unauthorized")
if room_id not in rooms:
return {"success": False, "error": "Room not found"}
with stats_lock:
clients_list = []
for client_id in rooms[room_id]["clients"]:
if client_id in clients:
client = clients[client_id]
clients_list.append({
"id": client_id,
"ip_address": client["ip_address"],
"connected_at": client["connected_at"],
"is_streaming": client["is_streaming"],
"video_settings": client["video_settings"]
})
return {"success": True, "clients": clients_list}
@app.get("/api/stats")
async def get_server_stats_api(session_id: str):
"""Получение статистики сервера (API)"""
if not AdminAuth.verify_session(session_id):
raise HTTPException(status_code=401, detail="Unauthorized")
cpu_usage = psutil.cpu_percent()
memory_usage = psutil.virtual_memory().percent
with stats_lock:
server_stats["cpu_usage"] = cpu_usage
server_stats["memory_usage"] = memory_usage
server_stats["total_rooms"] = len(rooms)
server_stats["total_clients"] = len(clients)
active_streams = sum(1 for client in clients.values() if client.get("is_streaming", False))
server_stats["total_streams"] = active_streams
# Расчет времени работы
start_time = datetime.fromisoformat(server_stats["start_time"])
uptime = (datetime.now() - start_time).total_seconds()
server_stats["uptime"] = int(uptime)
stats_copy = server_stats.copy()
return {
"success": True,
"stats": stats_copy,
"system": {
"cpu_count": cpu_count(),
"memory_total": psutil.virtual_memory().total,
"memory_available": psutil.virtual_memory().available,
}
}
# ========== WEBSOCKET РОУТЫ ==========
@app.websocket("/ws/client/{room_id}/{password}")
async def client_websocket_endpoint(websocket: WebSocket, room_id: str, password: str):
"""WebSocket для клиентов (трансляция видео)"""
await websocket.accept()
client_id = None
video_processor = None
try:
client_ip = websocket.client.host if websocket.client else "unknown"
print(f"\n[WebSocket Client] ===== NEW CONNECTION =====")
print(f"[WebSocket Client] IP: {client_ip}, Room: {room_id}, Password: {password}")
client_id = ClientManager.add_client(room_id, password, client_ip)
if not client_id:
print(f"[WebSocket Client] ❌ ClientManager.add_client returned None")
await websocket.send_text(json.dumps({"error": "Invalid room or password"}))
await websocket.close()
return
print(f"[WebSocket Client] ✓ Client added: {client_id}")
client_websockets[client_id] = websocket
print(f"[WebSocket Client] ✓ WebSocket registered for {client_id}")
video_processor = VideoProcessor(client_id)
print(f"[WebSocket Client] ✓ VideoProcessor created for {client_id}")
video_processor.start()
print(f"[WebSocket Client] ✓ VideoProcessor started for {client_id}")
with stats_lock:
if client_id in clients:
clients[client_id]["is_streaming"] = True
print(f"[WebSocket Client] ✓ is_streaming set to True for {client_id}")
success_msg = {
"success": True,
"client_id": client_id,
"room_id": room_id
}
await websocket.send_text(json.dumps(success_msg))
print(f"[WebSocket Client] ✓ Sent success message to {client_id}")
print(f"[WebSocket Client] ===== WAITING FOR DATA =====\n")
frame_count = 0
command_count = 0
while True:
try:
data = await websocket.receive()
if data["type"] == "websocket.receive":
message = data.get("text") or data.get("bytes")
if isinstance(message, bytes):
if frame_count == 0:
print(f"[WebSocket Client] ✓ FIRST FRAME RECEIVED from {client_id}! Size: {len(message)} bytes")
if client_id in video_queues:
if not video_queues[client_id].full():
video_queues[client_id].put(message)
frame_count += 1
if frame_count % 30 == 0: # log every 30 frames
print(f"[WebSocket Client] {client_id}: received {frame_count} frames")
else:
print(f"[WebSocket Client] ⚠️ {client_id}: video queue is FULL, dropping frame")
else:
print(f"[WebSocket Client] ❌ {client_id}: video_queue not found!")
elif isinstance(message, str):
try:
command = json.loads(message)
if "type" in command:
with stats_lock:
if client_id in clients:
clients[client_id]["commands"].append(command)
if client_id in command_queues:
command_queues[client_id].put(command)
command_count += 1
print(f"[WebSocket Client] {client_id}: command received: {command.get('type')}")
except json.JSONDecodeError as e:
print(f"[WebSocket Client] ⚠️ {client_id}: invalid JSON: {message[:50]}")
elif data["type"] == "websocket.disconnect":
print(f"[WebSocket Client] {client_id}: CLIENT DISCONNECT (frames: {frame_count}, commands: {command_count})")
break
except WebSocketDisconnect:
print(f"[WebSocket Client] {client_id}: WEBSOCKET DISCONNECT (frames: {frame_count})")
break
except Exception as e:
print(f"[WebSocket Client] ❌ {client_id}: Error in receive loop: {type(e).__name__}: {e}")
break
except Exception as e:
print(f"[WebSocket Client] ❌ Connection error: {type(e).__name__}: {e}")
finally:
print(f"[WebSocket Client] ===== CLEANUP =====")
if client_id:
with stats_lock:
if client_id in clients:
clients[client_id]["is_streaming"] = False
print(f"[WebSocket Client] ✓ is_streaming set to False for {client_id}")
ClientManager.disconnect_client(client_id)
print(f"[WebSocket Client] ✓ Disconnected client: {client_id}")
if video_processor:
video_processor.stop()
# Очищаем очередь для администраторов
try:
if client_id in processed_video_queues:
del processed_video_queues[client_id]
except Exception:
pass
print(f"[WebSocket Client] ✓ VideoProcessor stopped for {client_id}")
print(f"[WebSocket Client] ===== END OF CONNECTION =====\n")
@app.websocket("/ws/admin/{session_id}")
async def admin_websocket_endpoint(websocket: WebSocket, session_id: str):
"""WebSocket для администраторов (просмотр и управление)"""
if not AdminAuth.verify_session(session_id):
await websocket.close(code=1008)
return
await websocket.accept()
admin_websockets[session_id] = websocket
# Словарь для хранения активных стримов
active_streams = {}
try:
await websocket.send_text(json.dumps({
"type": "connected",
"message": "Admin WebSocket connected",
"session_id": session_id
}))
print(f"[WebSocket] Admin connected: {session_id}")
while True:
try:
data = await websocket.receive()
if data["type"] == "websocket.receive":
message = data.get("text")
if message:
try:
command = json.loads(message)
cmd_type = command.get("type")
if cmd_type == "watch_client":
client_id = command.get("client_id")
# Останавливаем предыдущий стрим если есть
if session_id in active_streams:
active_streams[session_id].cancel()
# Запускаем новый стрим
task = asyncio.create_task(
_stream_client_to_admin(client_id, session_id)
)
active_streams[session_id] = task
await websocket.send_text(json.dumps({
"type": "watching_started",
"client_id": client_id,
"message": f"Started watching client {client_id}"
}))
elif cmd_type == "stop_watching":
# Останавливаем просмотр
if session_id in active_streams:
active_streams[session_id].cancel()
del active_streams[session_id]
await websocket.send_text(json.dumps({
"type": "watching_stopped",
"message": "Stopped watching"
}))
elif cmd_type == "control_client":
client_id = command.get("client_id")
control_cmd = command.get("command")
if client_id in clients and client_id in command_queues:
command_queues[client_id].put(control_cmd)
await websocket.send_text(json.dumps({
"type": "control_response",
"success": True,
"command": control_cmd
}))
elif cmd_type == "get_stats":
with stats_lock:
stats = server_stats.copy()
await websocket.send_text(json.dumps({
"type": "stats_update",
"stats": stats
}))
except json.JSONDecodeError:
pass
elif data["type"] == "websocket.disconnect":
print(f"[WebSocket] Admin disconnected: {session_id}")
break
except WebSocketDisconnect:
print(f"[WebSocket] Admin WebSocket disconnected: {session_id}")
break
except Exception as e:
print(f"[WebSocket] Error in admin WebSocket {session_id}: {e}")
break
except Exception as e:
print(f"[WebSocket] Admin connection error: {e}")
finally:
# Останавливаем все активные стримы
if session_id in active_streams:
active_streams[session_id].cancel()
if session_id in admin_websockets:
del admin_websockets[session_id]
async def _stream_client_to_admin(client_id: str, admin_session_id: str):
"""Потоковая передача видео от клиента к администратору"""
if client_id not in clients or admin_session_id not in admin_websockets:
return
try:
admin_ws = admin_websockets[admin_session_id]
await admin_ws.send_text(json.dumps({
"type": "stream_started",
"client_id": client_id,
"timestamp": datetime.now().isoformat()
}))
# Получаем информацию о клиенте
with stats_lock:
if client_id in clients:
client = clients[client_id]
await admin_ws.send_text(json.dumps({
"type": "stream_info",
"message": f"Streaming from client {client_id}",
"client_id": client_id,
"quality": client["video_settings"]["quality"],
"frame_rate": client["video_settings"]["frame_rate"],
"resolution": client["video_settings"]["resolution"],
"ip": client["ip_address"],
"connected_at": client["connected_at"],
"status": "streaming"
}))
print(f"[WebSocket] Started streaming from client {client_id} to admin {admin_session_id}")
# Ждем пока появится очередь обработанных кадров
start_time = time.time()
while client_id not in processed_video_queues and time.time() - start_time < 10:
await asyncio.sleep(0.1)
if client_id not in processed_video_queues:
await admin_ws.send_text(json.dumps({
"type": "stream_error",
"message": "No video stream available from client"
}))
return
processed_queue = processed_video_queues[client_id]
frame_count = 0
last_update_time = time.time()
# Основной цикл пересылки видео
while (client_id in clients and
admin_session_id in admin_websockets and
clients[client_id].get("is_streaming", False)):
try:
# Получаем обработанный кадр
if not processed_queue.empty():
frame_data = processed_queue.get_nowait()
frame_count += 1
# Отправляем кадр администратору как бинарные данные
await admin_ws.send_bytes(frame_data)
# Отправляем статистику каждую секунду
current_time = time.time()
if current_time - last_update_time > 1:
await admin_ws.send_text(json.dumps({
"type": "stream_stats",
"frames_sent": frame_count,
"timestamp": datetime.now().isoformat()
}))
last_update_time = current_time
# Небольшая задержка для CPU
await asyncio.sleep(0.01)
except asyncio.CancelledError:
# Стрим был отменен администратором
break
except Exception as e:
print(f"[WebSocket] Error streaming to admin: {e}")
break
print(f"[WebSocket] Stopped streaming from client {client_id} to admin {admin_session_id}")
except Exception as e:
print(f"[WebSocket] Error in stream to admin: {e}")
# ========== КЛИЕНТСКИЙ API ==========
@app.post("/api/client/connect")
async def client_connect_api(connection_data: dict):
"""Подключение клиента к комнате (API)"""
room_id = connection_data.get("room_id")
password = connection_data.get("password")
client_ip = connection_data.get("ip", "unknown")
client_id = ClientManager.add_client(room_id, password, client_ip)
if client_id:
server_host = get_server_host()
ws_protocol = "wss" if SERVER_CONFIG.get("ssl_enabled", False) else "ws"
return {
"success": True,
"client_id": client_id,
"room_id": room_id,
"ws_url": f"{ws_protocol}://{server_host}:{SERVER_CONFIG['port']}/ws/client/{room_id}/{password}"
}
return {"success": False, "error": "Connection failed"}
# ========== СОЗДАНИЕ HTML ШАБЛОНОВ ==========
def create_html_templates():
"""Создание HTML шаблонов для веб-интерфейса"""
# Шаблон login.html
login_html = """
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Video Streaming Server - Login</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
height: 100vh;
display: flex;
justify-content: center;
align-items: center;
}
.login-container {
background: white;
padding: 40px;
border-radius: 10px;
box-shadow: 0 10px 40px rgba(0,0,0,0.2);
width: 100%;
max-width: 400px;
}
.logo {
text-align: center;
margin-bottom: 30px;
}
.logo h1 {
color: #333;
font-size: 28px;
margin-bottom: 5px;
}
.logo p {
color: #666;
font-size: 14px;
}
.form-group {
margin-bottom: 20px;
}
.form-group label {
display: block;
margin-bottom: 5px;
color: #555;
font-weight: 500;
}
.form-group input {
width: 100%;
padding: 12px;
border: 1px solid #ddd;
border-radius: 5px;
font-size: 16px;
transition: border-color 0.3s;
}
.form-group input:focus {
outline: none;
border-color: #667eea;
}
.btn {
width: 100%;
padding: 12px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border: none;
border-radius: 5px;
font-size: 16px;
font-weight: 600;
cursor: pointer;
transition: transform 0.3s;
}
.btn:hover {
transform: translateY(-2px);
}
.error {
background: #ffebee;
color: #c62828;
padding: 10px;
border-radius: 5px;
margin-bottom: 20px;
font-size: 14px;
}
.demo-accounts {
margin-top: 20px;
padding: 15px;
background: #f5f5f5;
border-radius: 5px;
font-size: 13px;
}
.demo-accounts h3 {
margin-bottom: 10px;
color: #333;
}
.demo-accounts ul {
list-style: none;
}
.demo-accounts li {
margin-bottom: 5px;
color: #666;
}
footer {
text-align: center;
margin-top: 20px;
color: #666;
font-size: 12px;
}
</style>
</head>
<body>
<div class="login-container">
<div class="logo">
<h1>🎥 Video Streaming Server</h1>
<p>Admin Panel</p>
</div>
{% if error %}
<div class="error">
{{ error }}
</div>
{% endif %}
<form method="POST" action="/login">
<div class="form-group">
<label for="username">Username</label>
<input type="text" id="username" name="username" required placeholder="Enter username">
</div>
<div class="form-group">
<label for="password">Password</label>
<input type="password" id="password" name="password" required placeholder="Enter password">
</div>
<button type="submit" class="btn">Login</button>
</form>
<div class="demo-accounts">
<h3>Demo Accounts:</h3>
<ul>
<li><strong>admin</strong> / admin123</li>
<li><strong>administrator</strong> / securepass</li>
<li><strong>supervisor</strong> / superpass</li>
</ul>
</div>
<footer>
Version 2.1.0 • Server: {{ server_host }}:{{ server_port }}
</footer>
</div>
</body>
</html>
"""
# Шаблон dashboard.html
dashboard_html = """
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Dashboard - Video Streaming Server</title>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
:root {
--primary: #667eea;
--secondary: #764ba2;
--success: #10b981;
--danger: #ef4444;
--warning: #f59e0b;
--info: #3b82f6;
--dark: #1f2937;
--light: #f9fafb;
--gray: #6b7280;
}
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
background: #f3f4f6;
color: var(--dark);
}
/* Sidebar */
.sidebar {
position: fixed;
top: 0;
left: 0;
height: 100vh;
width: 250px;
background: linear-gradient(135deg, var(--primary) 0%, var(--secondary) 100%);
color: white;
padding: 20px 0;
box-shadow: 0 0 20px rgba(0,0,0,0.1);
z-index: 1000;
}
.logo {
padding: 0 20px 30px;
border-bottom: 1px solid rgba(255,255,255,0.1);
margin-bottom: 30px;
}
.logo h1 {
font-size: 24px;
margin-bottom: 5px;
}
.logo p {
font-size: 12px;
opacity: 0.8;
}
.nav-links {
list-style: none;
padding: 0 20px;
}
.nav-links li {
margin-bottom: 10px;
}
.nav-links a {
display: flex;
align-items: center;
padding: 12px 15px;
color: white;
text-decoration: none;
border-radius: 5px;
transition: background 0.3s;
}
.nav-links a:hover {
background: rgba(255,255,255,0.1);
}
.nav-links a.active {
background: rgba(255,255,255,0.2);
}
.nav-links i {
margin-right: 10px;
width: 20px;
text-align: center;
}
.user-info {
position: absolute;
bottom: 20px;
left: 0;
right: 0;
padding: 20px;
border-top: 1px solid rgba(255,255,255,0.1);
}
.user-info p {
margin-bottom: 10px;
font-size: 14px;
}
.logout-btn {
display: block;
width: 100%;
padding: 8px;
background: rgba(255,255,255,0.1);
color: white;
border: none;
border-radius: 5px;
cursor: pointer;
font-size: 14px;
transition: background 0.3s;
}
.logout-btn:hover {
background: rgba(255,255,255,0.2);
}
/* Main Content */
.main-content {
margin-left: 250px;
padding: 30px;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 30px;
}
.header h2 {
font-size: 28px;
color: var(--dark);
}
.header-actions {
display: flex;
gap: 15px;
}
.btn {
padding: 10px 20px;
border: none;
border-radius: 5px;
cursor: pointer;
font-weight: 600;
display: inline-flex;
align-items: center;
gap: 8px;
transition: all 0.3s;
}
.btn-primary {
background: linear-gradient(135deg, var(--primary) 0%, var(--secondary) 100%);
color: white;
}
.btn-success {
background: var(--success);
color: white;
}
.btn-danger {
background: var(--danger);
color: white;
}
.btn:hover {
transform: translateY(-2px);
box-shadow: 0 5px 15px rgba(0,0,0,0.1);
}
/* Stats Cards */
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 20px;
margin-bottom: 30px;
}
.stat-card {
background: white;
padding: 20px;
border-radius: 10px;
box-shadow: 0 5px 15px rgba(0,0,0,0.05);
border-left: 4px solid var(--primary);
}
.stat-card h3 {
font-size: 14px;
color: var(--gray);
margin-bottom: 10px;
text-transform: uppercase;
letter-spacing: 1px;
}
.stat-card .value {
font-size: 32px;
font-weight: bold;
color: var(--dark);
margin-bottom: 10px;
}
.stat-card .change {
font-size: 12px;
color: var(--success);
}
/* Rooms Table */
.section {
background: white;
border-radius: 10px;
padding: 25px;
margin-bottom: 30px;
box-shadow: 0 5px 15px rgba(0,0,0,0.05);
}
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.section-title {
font-size: 20px;
color: var(--dark);
}
table {
width: 100%;
border-collapse: collapse;
}
thead {
background: var(--light);
}
th {
padding: 15px;
text-align: left;
font-weight: 600;
color: var(--gray);
border-bottom: 2px solid #e5e7eb;
}
td {
padding: 15px;
border-bottom: 1px solid #e5e7eb;
}
tr:hover {
background: var(--light);
}
.status-badge {
display: inline-block;
padding: 5px 10px;
border-radius: 20px;
font-size: 12px;
font-weight: 600;
}
.status-active {
background: #d1fae5;
color: var(--success);
}
.status-inactive {
background: #fee2e2;
color: var(--danger);
}
.action-buttons {
display: flex;
gap: 8px;
}
.btn-sm {
padding: 5px 10px;
font-size: 12px;
border-radius: 3px;
}
.btn-icon {
padding: 8px;
width: 35px;
height: 35px;
justify-content: center;
}
/* Loading */
.loading {
text-align: center;
padding: 50px;
color: var(--gray);
}
/* Responsive */
@media (max-width: 768px) {
.sidebar {
width: 70px;
}
.sidebar .logo h1,
.sidebar .logo p,
.sidebar .nav-links span,
.sidebar .user-info p {
display: none;
}
.sidebar .nav-links a {
justify-content: center;
}
.sidebar .nav-links i {
margin-right: 0;
font-size: 20px;
}
.main-content {
margin-left: 70px;
}
.stats-grid {
grid-template-columns: repeat(2, 1fr);
}
}
</style>
</head>
<body>
<!-- Sidebar -->
<div class="sidebar">
<div class="logo">
<h1>🎥 VSS</h1>
<p>Video Streaming Server</p>
</div>
<ul class="nav-links">
<li>
<a href="/dashboard" class="active">
<i class="fas fa-tachometer-alt"></i>
<span>Dashboard</span>
</a>
</li>
<li>
<a href="/create-room">
<i class="fas fa-plus-circle"></i>
<span>Create Room</span>
</a>
</li>
<li>
<a href="#">
<i class="fas fa-cog"></i>
<span>Settings</span>
</a>
</li>
</ul>
<div class="user-info">
<p>Logged in as: <strong>{{ username }}</strong></p>
<a href="/logout">
<button class="logout-btn">
<i class="fas fa-sign-out-alt"></i> Logout
</button>
</a>
</div>
</div>
<!-- Main Content -->
<div class="main-content">
<!-- Header -->
<div class="header">
<h2>Dashboard</h2>
<div class="header-actions">
<button class="btn btn-success" onclick="refreshStats()">
<i class="fas fa-sync-alt"></i> Refresh
</button>
</div>
</div>
<!-- Stats Cards -->
<div class="stats-grid" id="statsGrid">
<div class="stat-card">
<h3>Total Rooms</h3>
<div class="value">{{ stats.total_rooms }}</div>
<div class="change">Active: {{ total_rooms }}</div>
</div>
<div class="stat-card">
<h3>Connected Clients</h3>
<div class="value">{{ stats.total_clients }}</div>
<div class="change">Streaming: {{ stats.total_streams }}</div>
</div>
<div class="stat-card">
<h3>CPU Usage</h3>
<div class="value">{{ stats.cpu_usage }}%</div>
<div class="change">Cores: {{ stats.system.cpu_count if stats.system else 'N/A' }}</div>
</div>
<div class="stat-card">
<h3>Memory Usage</h3>
<div class="value">{{ stats.memory_usage }}%</div>
<div class="change">
{% if stats.system %}
{{ ((stats.system.memory_total - stats.system.memory_available) / 1024 / 1024 / 1024)|round(1) }} GB used
{% else %}
0 GB used
{% endif %}
</div>
</div>
</div>
<!-- Rooms Section -->
<div class="section">
<div class="section-header">
<h3 class="section-title">Rooms</h3>
<a href="/create-room">
<button class="btn btn-primary">
<i class="fas fa-plus"></i> Create Room
</button>
</a>
</div>
{% if rooms %}
<div class="table-responsive">
<table>
<thead>
<tr>
<th>ID</th>
<th>Name</th>
<th>Clients</th>
<th>Max Connections</th>
<th>Created By</th>
<th>Status</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for room in rooms %}
<tr>
<td><code>{{ room.id }}</code></td>
<td>{{ room.name }}</td>
<td>
<span class="value">{{ room.clients_count }}</span>
{% if room.active_streams > 0 %}
<span class="change">({{ room.active_streams }} streaming)</span>
{% endif %}
</td>
<td>{{ room.max_connections }}</td>
<td>{{ room.created_by }}</td>
<td>
<span class="status-badge status-active">Active</span>
</td>
<td>
<div class="action-buttons">
<a href="/room/{{ room.id }}">
<button class="btn btn-primary btn-sm">
<i class="fas fa-eye"></i> View
</button>
</a>
<button class="btn btn-danger btn-sm" onclick="deleteRoom('{{ room.id }}')">
<i class="fas fa-trash"></i>
</button>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="loading">
<p>No rooms created yet. Create your first room!</p>
</div>
{% endif %}
</div>
<!-- System Info -->
<div class="section">
<h3 class="section-title">System Information</h3>
<div class="stats-grid">
<div class="stat-card">
<h3>Server Uptime</h3>
<div class="value" id="uptime">Loading...</div>
<div class="change">Since {{ stats.start_time }}</div>
</div>
<div class="stat-card">
<h3>Server Address</h3>
<div class="value">{{ server_host }}:{{ server_port }}</div>
<div class="change">WebSocket: ws://{{ server_host }}:{{ server_port }}</div>
</div>
<div class="stat-card">
<h3>API Status</h3>
<div class="value">Online</div>
<div class="change change-up">All systems operational</div>
</div>
</div>
</div>
</div>
<script>
function formatUptime(seconds) {
const days = Math.floor(seconds / 86400);
const hours = Math.floor((seconds % 86400) / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
let result = [];
if (days > 0) result.push(`${days}d`);
if (hours > 0) result.push(`${hours}h`);
if (minutes > 0) result.push(`${minutes}m`);
return result.join(' ') || 'Just started';
}
function updateUptime() {
const uptimeElement = document.getElementById('uptime');
if (uptimeElement && {{ stats.uptime }}) {
uptimeElement.textContent = formatUptime({{ stats.uptime }});
}
}
async function deleteRoom(roomId) {
if (confirm('Are you sure you want to delete this room? All clients will be disconnected.')) {
try {
const response = await fetch(`/api/delete-room/${roomId}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
}
});
const result = await response.json();
if (result.success) {
alert('Room deleted successfully!');
location.reload();
} else {
alert('Error: ' + (result.error || 'Failed to delete room'));
}
} catch (error) {
alert('Error: ' + error.message);
}
}
}
async function refreshStats() {
try {
const sessionId = getCookie('session_id');
if (!sessionId) {
window.location.href = '/';
return;
}
const response = await fetch(`/api/stats?session_id=${sessionId}`);
const result = await response.json();
if (result.success) {
location.reload();
}
} catch (error) {
console.error('Error refreshing stats:', error);
}
}
function getCookie(name) {
const value = `; ${document.cookie}`;
const parts = value.split(`; ${name}=`);
if (parts.length === 2) return parts.pop().split(';').shift();
return null;
}
// Update uptime every minute
updateUptime();
setInterval(updateUptime, 60000);
// Auto-refresh every 30 seconds
setInterval(refreshStats, 30000);
</script>
</body>
</html>
"""
# Шаблон room.html
room_html = """
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Room: {{ room.name }} - Video Streaming Server</title>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
:root {
--primary: #667eea;
--secondary: #764ba2;
--success: #10b981;
--danger: #ef4444;
--warning: #f59e0b;
--info: #3b82f6;
--dark: #1f2937;
--light: #f9fafb;
--gray: #6b7280;
}
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
background: #f3f4f6;
color: var(--dark);
}
/* Sidebar */
.sidebar {
position: fixed;
top: 0;
left: 0;
height: 100vh;
width: 250px;
background: linear-gradient(135deg, var(--primary) 0%, var(--secondary) 100%);
color: white;
padding: 20px 0;
box-shadow: 0 0 20px rgba(0,0,0,0.1);
z-index: 1000;
}
.logo {
padding: 0 20px 30px;
border-bottom: 1px solid rgba(255,255,255,0.1);
margin-bottom: 30px;
}
.logo h1 {
font-size: 24px;
margin-bottom: 5px;
}
.logo p {
font-size: 12px;
opacity: 0.8;
}
.nav-links {
list-style: none;
padding: 0 20px;
}
.nav-links li {
margin-bottom: 10px;
}
.nav-links a {
display: flex;
align-items: center;
padding: 12px 15px;
color: white;
text-decoration: none;
border-radius: 5px;
transition: background 0.3s;
}
.nav-links a:hover {
background: rgba(255,255,255,0.1);
}
.nav-links a.active {
background: rgba(255,255,255,0.2);
}
.nav-links i {
margin-right: 10px;
width: 20px;
text-align: center;
}
.back-btn {
display: flex;
align-items: center;
padding: 12px 15px;
color: white;
text-decoration: none;
border-radius: 5px;
transition: background 0.3s;
background: rgba(255,255,255,0.1);
margin: 20px;
cursor: pointer;
}
.back-btn:hover {
background: rgba(255,255,255,0.2);
}
.back-btn i {
margin-right: 10px;
}
/* Main Content */
.main-content {
margin-left: 250px;
padding: 30px;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 30px;
}
.header h2 {
font-size: 28px;
color: var(--dark);
}
.room-info {
background: white;
border-radius: 10px;
padding: 25px;
margin-bottom: 30px;
box-shadow: 0 5px 15px rgba(0,0,0,0.05);
}
.room-info-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 20px;
margin-top: 20px;
}
.info-item {
padding: 15px;
background: var(--light);
border-radius: 5px;
}
.info-item label {
display: block;
font-size: 12px;
color: var(--gray);
margin-bottom: 5px;
text-transform: uppercase;
letter-spacing: 1px;
}
.info-item .value {
font-size: 18px;
font-weight: 600;
color: var(--dark);
}
/* Clients Table */
.clients-section {
background: white;
border-radius: 10px;
padding: 25px;
margin-bottom: 30px;
box-shadow: 0 5px 15px rgba(0,0,0,0.05);
}
.section-title {
font-size: 20px;
color: var(--dark);
margin-bottom: 20px;
}
table {
width: 100%;
border-collapse: collapse;
}
thead {
background: var(--light);
}
th {
padding: 15px;
text-align: left;
font-weight: 600;
color: var(--gray);
border-bottom: 2px solid #e5e7eb;
}
td {
padding: 15px;
border-bottom: 1px solid #e5e7eb;
}
tr:hover {
background: var(--light);
}
.status-badge {
display: inline-block;
padding: 5px 10px;
border-radius: 20px;
font-size: 12px;
font-weight: 600;
}
.status-streaming {
background: #d1fae5;
color: var(--success);
}
.status-idle {
background: #fef3c7;
color: var(--warning);
}
.action-buttons {
display: flex;
gap: 8px;
}
.btn {
padding: 8px 16px;
border: none;
border-radius: 5px;
cursor: pointer;
font-weight: 600;
display: inline-flex;
align-items: center;
gap: 8px;
transition: all 0.3s;
text-decoration: none;
font-size: 14px;
}
.btn-primary {
background: linear-gradient(135deg, var(--primary) 0%, var(--secondary) 100%);
color: white;
}
.btn-danger {
background: var(--danger);
color: white;
}
.btn-success {
background: var(--success);
color: white;
}
.btn:hover {
transform: translateY(-2px);
box-shadow: 0 5px 15px rgba(0,0,0,0.1);
}
.btn-sm {
padding: 5px 10px;
font-size: 12px;
}
/* Empty State */
.empty-state {
text-align: center;
padding: 50px;
color: var(--gray);
}
/* Connection Info */
.connection-info {
background: #f0f9ff;
border: 1px solid #bae6fd;
border-radius: 5px;
padding: 15px;
margin-top: 20px;
}
.connection-info h4 {
color: #0369a1;
margin-bottom: 10px;
}
.connection-info code {
background: #e0f2fe;
padding: 2px 6px;
border-radius: 3px;
font-family: monospace;
}
/* Responsive */
@media (max-width: 768px) {
.sidebar {
width: 70px;
}
.sidebar .logo h1,
.sidebar .logo p,
.sidebar .nav-links span,
.sidebar .back-btn span {
display: none;
}
.sidebar .nav-links a {
justify-content: center;
}
.sidebar .nav-links i {
margin-right: 0;
font-size: 20px;
}
.sidebar .back-btn {
justify-content: center;
}
.main-content {
margin-left: 70px;
}
}
</style>
</head>
<body>
<!-- Sidebar -->
<div class="sidebar">
<div class="logo">
<h1>🎥 VSS</h1>
<p>Video Streaming Server</p>
</div>
<ul class="nav-links">
<li>
<a href="/dashboard">
<i class="fas fa-tachometer-alt"></i>
<span>Dashboard</span>
</a>
</li>
<li>
<a href="/create-room">
<i class="fas fa-plus-circle"></i>
<span>Create Room</span>
</a>
</li>
</ul>
<a href="/dashboard" class="back-btn">
<i class="fas fa-arrow-left"></i>
<span>Back to Dashboard</span>
</a>
</div>
<!-- Main Content -->
<div class="main-content">
<!-- Header -->
<div class="header">
<h2>Room: {{ room.name }}</h2>
<div class="action-buttons">
<button class="btn btn-danger" onclick="deleteRoom('{{ room.id }}')">
<i class="fas fa-trash"></i> Delete Room
</button>
</div>
</div>
<!-- Room Information -->
<div class="room-info">
<h3 class="section-title">Room Information</h3>
<div class="room-info-grid">
<div class="info-item">
<label>Room ID</label>
<div class="value"><code>{{ room.id }}</code></div>
</div>
<div class="info-item">
<label>Password</label>
<div class="value">{{ room.password }}</div>
</div>
<div class="info-item">
<label>Max Connections</label>
<div class="value">{{ room.max_connections }}</div>
</div>
<div class="info-item">
<label>Created</label>
<div class="value">{{ room.created_at }}</div>
</div>
<div class="info-item">
<label>Created By</label>
<div class="value">{{ room.created_by }}</div>
</div>
<div class="info-item">
<label>Active Clients</label>
<div class="value">{{ room_stats.total_clients }} / {{ room.max_connections }}</div>
</div>
<div class="info-item">
<label>Active Streams</label>
<div class="value">{{ room_stats.active_streams }}</div>
</div>
<div class="info-item">
<label>Data Transferred</label>
<div class="value">{{ (room_stats.bytes_transferred / 1024 / 1024)|round(2) }} MB</div>
</div>
</div>
<!-- Connection Info for Clients -->
<div class="connection-info">
<h4>Client Connection Information</h4>
<p>Clients can connect to this room using:</p>
<p><strong>WebSocket URL:</strong> <code>{{ 'wss' if ssl_enabled else 'ws' }}://{{ server_host }}:{{ server_port }}/ws/client/{{ room.id }}/{{ room.password }}</code></p>
<p><strong>Room ID:</strong> <code>{{ room.id }}</code></p>
<p><strong>Password:</strong> <code>{{ room.password }}</code></p>
</div>
</div>
<!-- Connected Clients -->
<div class="clients-section">
<div class="section-header">
<h3 class="section-title">Connected Clients ({{ clients|length }})</h3>
<button class="btn btn-primary" onclick="refreshClients()">
<i class="fas fa-sync-alt"></i> Refresh
</button>
</div>
{% if clients %}
<div class="table-responsive">
<table>
<thead>
<tr>
<th>Client ID</th>
<th>IP Address</th>
<th>Connected At</th>
<th>Status</th>
<th>Video Settings</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for client in clients %}
<tr>
<td><code>{{ client.id[:8] }}...</code></td>
<td>{{ client.ip_address }}</td>
<td>{{ client.connected_at }}</td>
<td>
{% if client.is_streaming %}
<span class="status-badge status-streaming">Streaming</span>
{% else %}
<span class="status-badge status-idle">Idle</span>
{% endif %}
</td>
<td>
{{ client.video_settings.quality }}% Quality<br>
{{ client.video_settings.frame_rate }} FPS
</td>
<td>
<div class="action-buttons">
<a href="/stream/{{ client.id }}">
<button class="btn btn-primary btn-sm">
<i class="fas fa-play"></i> Stream
</button>
</a>
<button class="btn btn-danger btn-sm" onclick="disconnectClient('{{ client.id }}')">
<i class="fas fa-sign-out-alt"></i>
</button>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="empty-state">
<p>No clients connected to this room yet.</p>
<p>Share the connection information above with clients.</p>
</div>
{% endif %}
</div>
</div>
<script>
async function deleteRoom(roomId) {
if (confirm('Are you sure you want to delete this room? All clients will be disconnected.')) {
try {
const response = await fetch(`/api/delete-room/${roomId}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
}
});
const result = await response.json();
if (result.success) {
alert('Room deleted successfully!');
window.location.href = '/dashboard';
} else {
alert('Error: ' + (result.error || 'Failed to delete room'));
}
} catch (error) {
alert('Error: ' + error.message);
}
}
}
async function disconnectClient(clientId) {
if (confirm('Are you sure you want to disconnect this client?')) {
try {
const response = await fetch(`/api/disconnect-client/${clientId}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
}
});
const result = await response.json();
if (result.success) {
alert('Client disconnected successfully!');
location.reload();
} else {
alert('Error: ' + (result.error || 'Failed to disconnect client'));
}
} catch (error) {
alert('Error: ' + error.message);
}
}
}
function refreshClients() {
location.reload();
}
// Auto-refresh every 10 seconds
setInterval(refreshClients, 10000);
</script>
</body>
</html>
"""
# Шаблон create_room.html
create_room_html = """
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Create Room - Video Streaming Server</title>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
:root {
--primary: #667eea;
--secondary: #764ba2;
--success: #10b981;
--danger: #ef4444;
--warning: #f59e0b;
--info: #3b82f6;
--dark: #1f2937;
--light: #f9fafb;
--gray: #6b7280;
}
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
background: #f3f4f6;
color: var(--dark);
}
/* Sidebar */
.sidebar {
position: fixed;
top: 0;
left: 0;
height: 100vh;
width: 250px;
background: linear-gradient(135deg, var(--primary) 0%, var(--secondary) 100%);
color: white;
padding: 20px 0;
box-shadow: 0 0 20px rgba(0,0,0,0.1);
z-index: 1000;
}
.logo {
padding: 0 20px 30px;
border-bottom: 1px solid rgba(255,255,255,0.1);
margin-bottom: 30px;
}
.logo h1 {
font-size: 24px;
margin-bottom: 5px;
}
.logo p {
font-size: 12px;
opacity: 0.8;
}
.nav-links {
list-style: none;
padding: 0 20px;
}
.nav-links li {
margin-bottom: 10px;
}
.nav-links a {
display: flex;
align-items: center;
padding: 12px 15px;
color: white;
text-decoration: none;
border-radius: 5px;
transition: background 0.3s;
}
.nav-links a:hover {
background: rgba(255,255,255,0.1);
}
.nav-links a.active {
background: rgba(255,255,255,0.2);
}
.nav-links i {
margin-right: 10px;
width: 20px;
text-align: center;
}
.back-btn {
display: flex;
align-items: center;
padding: 12px 15px;
color: white;
text-decoration: none;
border-radius: 5px;
transition: background 0.3s;
background: rgba(255,255,255,0.1);
margin: 20px;
cursor: pointer;
}
.back-btn:hover {
background: rgba(255,255,255,0.2);
}
.back-btn i {
margin-right: 10px;
}
/* Main Content */
.main-content {
margin-left: 250px;
padding: 30px;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 30px;
}
.header h2 {
font-size: 28px;
color: var(--dark);
}
/* Form */
.form-container {
background: white;
border-radius: 10px;
padding: 30px;
box-shadow: 0 5px 15px rgba(0,0,0,0.05);
max-width: 600px;
margin: 0 auto;
}
.form-group {
margin-bottom: 25px;
}
.form-group label {
display: block;
margin-bottom: 8px;
color: var(--gray);
font-weight: 600;
}
.form-group input,
.form-group select {
width: 100%;
padding: 12px;
border: 1px solid #ddd;
border-radius: 5px;
font-size: 16px;
transition: border-color 0.3s;
}
.form-group input:focus,
.form-group select:focus {
outline: none;
border-color: var(--primary);
}
.form-group .help-text {
font-size: 12px;
color: var(--gray);
margin-top: 5px;
}
.btn {
padding: 12px 24px;
border: none;
border-radius: 5px;
cursor: pointer;
font-weight: 600;
display: inline-flex;
align-items: center;
gap: 8px;
transition: all 0.3s;
text-decoration: none;
font-size: 16px;
}
.btn-primary {
background: linear-gradient(135deg, var(--primary) 0%, var(--secondary) 100%);
color: white;
}
.btn-secondary {
background: var(--gray);
color: white;
}
.btn:hover {
transform: translateY(-2px);
box-shadow: 0 5px 15px rgba(0,0,0,0.1);
}
.form-actions {
display: flex;
gap: 15px;
margin-top: 30px;
}
/* Alert */
.alert {
padding: 15px;
border-radius: 5px;
margin-bottom: 20px;
display: none;
}
.alert-success {
background: #d1fae5;
color: var(--success);
border: 1px solid #a7f3d0;
}
.alert-error {
background: #fee2e2;
color: var(--danger);
border: 1px solid #fecaca;
}
/* Room Preview */
.room-preview {
background: var(--light);
border-radius: 5px;
padding: 20px;
margin-top: 30px;
display: none;
}
.room-preview h4 {
margin-bottom: 15px;
color: var(--dark);
}
.preview-item {
margin-bottom: 10px;
}
.preview-item label {
display: inline-block;
width: 120px;
color: var(--gray);
}
/* Responsive */
@media (max-width: 768px) {
.sidebar {
width: 70px;
}
.sidebar .logo h1,
.sidebar .logo p,
.sidebar .nav-links span,
.sidebar .back-btn span {
display: none;
}
.sidebar .nav-links a {
justify-content: center;
}
.sidebar .nav-links i {
margin-right: 0;
font-size: 20px;
}
.sidebar .back-btn {
justify-content: center;
}
.main-content {
margin-left: 70px;
}
.form-container {
padding: 20px;
}
}
</style>
</head>
<body>
<!-- Sidebar -->
<div class="sidebar">
<div class="logo">
<h1>🎥 VSS</h1>
<p>Video Streaming Server</p>
</div>
<ul class="nav-links">
<li>
<a href="/dashboard">
<i class="fas fa-tachometer-alt"></i>
<span>Dashboard</span>
</a>
</li>
<li>
<a href="/create-room" class="active">
<i class="fas fa-plus-circle"></i>
<span>Create Room</span>
</a>
</li>
</ul>
<a href="/dashboard" class="back-btn">
<i class="fas fa-arrow-left"></i>
<span>Back to Dashboard</span>
</a>
</div>
<!-- Main Content -->
<div class="main-content">
<!-- Header -->
<div class="header">
<h2>Create New Room</h2>
</div>
<!-- Alert Messages -->
<div id="alert" class="alert"></div>
<!-- Form -->
<div class="form-container">
<form id="createRoomForm" method="POST" action="/api/create-room">
<div class="form-group">
<label for="name">Room Name *</label>
<input type="text" id="name" name="name" required
placeholder="e.g., Conference Room, Surveillance Camera">
<div class="help-text">A descriptive name for the room</div>
</div>
<div class="form-group">
<label for="password">Room Password *</label>
<input type="text" id="password" name="password" required
placeholder="Enter a secure password">
<div class="help-text">Clients will need this password to connect</div>
</div>
<div class="form-group">
<label for="max_connections">Max Connections *</label>
<select id="max_connections" name="max_connections" required>
<option value="1">1 client</option>
<option value="5" selected>5 clients</option>
<option value="10">10 clients</option>
<option value="20">20 clients</option>
<option value="50">50 clients</option>
</select>
<div class="help-text">Maximum number of clients that can connect simultaneously</div>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary">
<i class="fas fa-plus-circle"></i> Create Room
</button>
<a href="/dashboard" class="btn btn-secondary">
<i class="fas fa-times"></i> Cancel
</a>
</div>
</form>
<!-- Room Preview -->
<div id="roomPreview" class="room-preview">
<h4>Room Information</h4>
<div class="preview-item">
<label>Room ID:</label>
<span id="previewId">Generating...</span>
</div>
<div class="preview-item">
<label>WebSocket URL:</label>
<code id="previewWsUrl">ws://{{ server_host }}:{{ server_port }}/ws/client/...</code>
</div>
</div>
</div>
</div>
<script>
document.getElementById('createRoomForm').addEventListener('submit', async function(e) {
e.preventDefault();
const formData = new FormData(this);
const alertDiv = document.getElementById('alert');
const previewDiv = document.getElementById('roomPreview');
// Show loading
alertDiv.className = 'alert alert-success';
alertDiv.style.display = 'block';
alertDiv.textContent = 'Creating room...';
try {
const response = await fetch('/api/create-room', {
method: 'POST',
body: formData
});
const result = await response.json();
if (result.success) {
alertDiv.className = 'alert alert-success';
alertDiv.textContent = 'Room created successfully!';
// Show room preview
previewDiv.style.display = 'block';
document.getElementById('previewId').textContent = result.room.id;
const wsProto = window.location.protocol === 'https:' ? 'wss' : 'ws';
document.getElementById('previewWsUrl').textContent =
`${wsProto}://${window.location.hostname}:{{ server_port }}/ws/client/${result.room.id}/${result.room.password}`;
// Clear form
this.reset();
// Redirect to room page after 3 seconds
setTimeout(() => {
window.location.href = `/room/${result.room.id}`;
}, 3000);
} else {
alertDiv.className = 'alert alert-error';
alertDiv.textContent = 'Error: ' + (result.error || 'Failed to create room');
}
} catch (error) {
alertDiv.className = 'alert alert-error';
alertDiv.textContent = 'Error: ' + error.message;
}
});
// Generate a random password suggestion
function generatePassword() {
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
let password = '';
for (let i = 0; i < 8; i++) {
password += chars.charAt(Math.floor(Math.random() * chars.length));
}
document.getElementById('password').value = password;
}
// Auto-generate password on page load
window.addEventListener('load', generatePassword);
</script>
</body>
</html>
"""
# Шаблон stream.html
stream_html = """
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Stream: {{ client.id }} - Video Streaming Server</title>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
:root {
--primary: #667eea;
--secondary: #764ba2;
--success: #10b981;
--danger: #ef4444;
--warning: #f59e0b;
--info: #3b82f6;
--dark: #1f2937;
--light: #f9fafb;
--gray: #6b7280;
}
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
background: #1a1a1a;
color: white;
height: 100vh;
overflow: hidden;
}
/* Header */
.header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 15px 30px;
background: rgba(0, 0, 0, 0.8);
border-bottom: 1px solid rgba(255,255,255,0.1);
}
.header-left {
display: flex;
align-items: center;
gap: 20px;
}
.back-btn {
display: flex;
align-items: center;
gap: 8px;
color: white;
text-decoration: none;
padding: 8px 16px;
background: rgba(255,255,255,0.1);
border-radius: 5px;
transition: background 0.3s;
}
.back-btn:hover {
background: rgba(255,255,255,0.2);
}
.client-info h2 {
font-size: 18px;
margin-bottom: 5px;
}
.client-info p {
font-size: 12px;
color: #aaa;
}
.header-actions {
display: flex;
gap: 10px;
}
.btn {
padding: 8px 16px;
border: none;
border-radius: 5px;
cursor: pointer;
font-weight: 600;
display: inline-flex;
align-items: center;
gap: 8px;
transition: all 0.3s;
background: rgba(255,255,255,0.1);
color: white;
}
.btn:hover {
background: rgba(255,255,255,0.2);
transform: translateY(-2px);
}
.btn-primary {
background: var(--primary);
}
.btn-danger {
background: var(--danger);
}
/* Main Content */
.main-content {
display: flex;
height: calc(100vh - 70px);
}
/* Video Container */
.video-container {
flex: 3;
display: flex;
flex-direction: column;
background: #000;
position: relative;
}
.video-placeholder {
flex: 1;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
color: #666;
}
.video-placeholder i {
font-size: 100px;
margin-bottom: 20px;
opacity: 0.3;
}
.video-placeholder h3 {
font-size: 24px;
margin-bottom: 10px;
}
.video-placeholder p {
font-size: 14px;
text-align: center;
max-width: 400px;
}
.video-stats {
position: absolute;
bottom: 20px;
left: 20px;
background: rgba(0, 0, 0, 0.7);
padding: 10px 15px;
border-radius: 5px;
font-size: 12px;
}
/* Controls Panel */
.controls-panel {
flex: 1;
max-width: 300px;
background: rgba(30, 30, 30, 0.95);
padding: 20px;
overflow-y: auto;
border-left: 1px solid rgba(255,255,255,0.1);
}
.controls-section {
margin-bottom: 30px;
}
.controls-section h3 {
font-size: 16px;
margin-bottom: 15px;
color: #ddd;
border-bottom: 1px solid rgba(255,255,255,0.1);
padding-bottom: 10px;
}
.control-group {
margin-bottom: 15px;
}
.control-group label {
display: block;
margin-bottom: 5px;
color: #aaa;
font-size: 12px;
}
.slider-container {
display: flex;
align-items: center;
gap: 10px;
}
input[type="range"] {
flex: 1;
height: 5px;
-webkit-appearance: none;
background: rgba(255,255,255,0.1);
border-radius: 5px;
outline: none;
}
input[type="range"]::-webkit-slider-thumb {
-webkit-appearance: none;
width: 15px;
height: 15px;
border-radius: 50%;
background: var(--primary);
cursor: pointer;
}
.slider-value {
min-width: 30px;
text-align: right;
font-size: 12px;
color: #ddd;
}
.btn-group {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 10px;
margin-top: 10px;
}
.btn-control {
padding: 10px;
text-align: center;
}
.btn-control i {
font-size: 20px;
margin-bottom: 5px;
}
.btn-control span {
display: block;
font-size: 11px;
}
/* Connection Info */
.info-box {
background: rgba(0, 0, 0, 0.3);
border-radius: 5px;
padding: 15px;
margin-bottom: 15px;
}
.info-item {
margin-bottom: 8px;
font-size: 12px;
}
.info-item label {
display: inline-block;
width: 100px;
color: #aaa;
}
/* Status Indicator */
.status-indicator {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
margin-right: 5px;
}
.status-connected {
background: var(--success);
box-shadow: 0 0 10px var(--success);
}
.status-disconnected {
background: var(--danger);
box-shadow: 0 0 10px var(--danger);
}
/* WebSocket Status */
.ws-status {
position: fixed;
bottom: 10px;
right: 10px;
padding: 5px 10px;
border-radius: 3px;
font-size: 11px;
background: rgba(0, 0, 0, 0.7);
}
.ws-connected {
color: var(--success);
}
.ws-disconnected {
color: var(--danger);
}
/* Loading */
.loading {
text-align: center;
padding: 50px;
color: #666;
}
/* Responsive */
@media (max-width: 768px) {
.main-content {
flex-direction: column;
}
.controls-panel {
max-width: none;
height: 300px;
}
}
</style>
</head>
<body>
<!-- Header -->
<div class="header">
<div class="header-left">
<a href="/room/{{ client.room_id }}" class="back-btn">
<i class="fas fa-arrow-left"></i>
Back to Room
</a>
<div class="client-info">
<h2>Streaming: {{ client.id[:8] }}...</h2>
<p>
<span class="status-indicator status-connected"></span>
Connected from {{ client.ip_address }}
</p>
</div>
</div>
<div class="header-actions">
<button class="btn btn-danger" onclick="disconnectClient()">
<i class="fas fa-sign-out-alt"></i> Disconnect
</button>
</div>
</div>
<!-- Main Content -->
<div class="main-content">
<!-- Video Container -->
<div class="video-container">
<!-- Canvas для отображения видеокадров -->
<canvas id="videoCanvas" style="width: 100%; height: auto; display: none; background: #000;"></canvas>
<div class="video-placeholder" id="videoPlaceholder">
<i class="fas fa-video"></i>
<h3>Waiting for Video Stream</h3>
<p>Video stream will appear here once the client starts streaming.</p>
<p>Use the controls on the right to adjust video settings.</p>
</div>
<div class="video-stats" id="videoStats" style="display: none;">
<div>Resolution: <span id="statsResolution">640x480</span></div>
<div>Frame Rate: <span id="statsFps">30</span> FPS</div>
<div>Quality: <span id="statsQuality">85</span>%</div>
</div>
</div>
<!-- Controls Panel -->
<div class="controls-panel">
<!-- Connection Info -->
<div class="info-box">
<div class="info-item">
<label>Client ID:</label>
<span>{{ client.id }}</span>
</div>
<div class="info-item">
<label>Room:</label>
<span>{{ room.name if room else 'Unknown' }}</span>
</div>
<div class="info-item">
<label>Connected:</label>
<span>{{ client.connected_at }}</span>
</div>
<div class="info-item">
<label>Streaming:</label>
<span id="streamingStatus">{{ "Yes" if client.is_streaming else "No" }}</span>
</div>
</div>
<!-- Video Controls -->
<div class="controls-section">
<h3>Video Controls</h3>
<div class="control-group">
<label>Quality</label>
<div class="slider-container">
<input type="range" id="qualitySlider" min="10" max="100" value="{{ client.video_settings.quality }}">
<span class="slider-value" id="qualityValue">{{ client.video_settings.quality }}%</span>
</div>
</div>
<div class="control-group">
<label>Brightness</label>
<div class="slider-container">
<input type="range" id="brightnessSlider" min="-100" max="100" value="0">
<span class="slider-value" id="brightnessValue">0</span>
</div>
</div>
<div class="control-group">
<label>Contrast</label>
<div class="slider-container">
<input type="range" id="contrastSlider" min="0.5" max="2" step="0.1" value="1">
<span class="slider-value" id="contrastValue">1.0</span>
</div>
</div>
<div class="btn-group">
<button class="btn btn-control" onclick="sendCommand('rotate', {angle: 90})">
<i class="fas fa-redo"></i>
<span>Rotate 90°</span>
</button>
<button class="btn btn-control" onclick="sendCommand('rotate', {angle: 180})">
<i class="fas fa-sync-alt"></i>
<span>Rotate 180°</span>
</button>
<button class="btn btn-control" onclick="sendCommand('flip', {direction: 1})">
<i class="fas fa-arrows-alt-v"></i>
<span>Flip Vertical</span>
</button>
<button class="btn btn-control" onclick="sendCommand('flip', {direction: 0})">
<i class="fas fa-arrows-alt-h"></i>
<span>Flip Horizontal</span>
</button>
<button class="btn btn-control" onclick="sendCommand('grayscale', {})">
<i class="fas fa-adjust"></i>
<span>Grayscale</span>
</button>
<button class="btn btn-control" onclick="sendCommand('reset', {})">
<i class="fas fa-undo"></i>
<span>Reset All</span>
</button>
</div>
</div>
<!-- Stream Information -->
<div class="controls-section">
<h3>Stream Information</h3>
<div class="info-box">
<p>This client is connected via WebSocket and streaming video data.</p>
<p>Use the controls above to adjust the video stream in real-time.</p>
<p>Changes are applied to the video processor on the server side.</p>
</div>
</div>
</div>
</div>
<!-- WebSocket Status -->
<div class="ws-status" id="wsStatus">
<span class="status-indicator status-disconnected"></span>
<span id="wsStatusText">Connecting...</span>
</div>
<script>
let ws = null;
let clientId = '{{ client.id }}';
let sessionId = getCookie('session_id');
let reconnectAttempts = 0;
const maxReconnectAttempts = 5;
function getCookie(name) {
const value = `; ${document.cookie}`;
const parts = value.split(`; ${name}=`);
if (parts.length === 2) return parts.pop().split(';').shift();
return null;
}
// Initialize WebSocket connection
function connectWebSocket() {
if (!sessionId) {
console.error('No session ID found');
return;
}
const wsProto = window.location.protocol === 'https:' ? 'wss' : 'ws';
const wsUrl = `${wsProto}://${window.location.hostname}:{{ server_port }}/ws/admin/${sessionId}`;
try {
ws = new WebSocket(wsUrl);
ws.onopen = function() {
console.log('WebSocket connected');
updateWsStatus(true);
reconnectAttempts = 0;
// Request to watch this client
sendCommand('watch_client', { client_id: clientId });
};
ws.onmessage = function(event) {
try {
// Проверяем, это бинарные данные (видеокадр) или текст (JSON)
if (event.data instanceof Blob) {
// Это видеокадр - отображаем на canvas
handleVideoFrame(event.data);
} else if (typeof event.data === 'string') {
// Это текстовое сообщение JSON
const data = JSON.parse(event.data);
handleWebSocketMessage(data);
}
} catch (error) {
console.error('Error processing WebSocket message:', error);
}
};
ws.onclose = function() {
console.log('WebSocket disconnected');
updateWsStatus(false);
// Try to reconnect
if (reconnectAttempts < maxReconnectAttempts) {
reconnectAttempts++;
setTimeout(connectWebSocket, 1000 * reconnectAttempts);
}
};
ws.onerror = function(error) {
console.error('WebSocket error:', error);
updateWsStatus(false);
};
} catch (error) {
console.error('Error creating WebSocket:', error);
updateWsStatus(false);
}
}
// Update WebSocket status display
function updateWsStatus(connected) {
const wsStatus = document.getElementById('wsStatus');
const wsStatusText = document.getElementById('wsStatusText');
if (connected) {
wsStatus.className = 'ws-status';
wsStatusText.innerHTML = '<span class="status-indicator status-connected"></span> Connected';
} else {
wsStatus.className = 'ws-status';
wsStatusText.innerHTML = `<span class="status-indicator status-disconnected"></span> Disconnected (${reconnectAttempts}/${maxReconnectAttempts})`;
}
}
// Handle incoming WebSocket messages
function handleWebSocketMessage(data) {
console.log('Received:', data);
switch (data.type) {
case 'stream_started':
showNotification('Stream started successfully');
updateStreamingStatus(true);
break;
case 'stream_info':
showNotification(data.message);
break;
case 'control_response':
if (data.success) {
showNotification('Command executed successfully');
} else {
showNotification('Command failed', 'error');
}
break;
case 'stats_update':
// Update any stats if needed
break;
}
}
// Send command to WebSocket
function sendCommand(type, data) {
if (!ws || ws.readyState !== WebSocket.OPEN) {
showNotification('WebSocket not connected', 'error');
return;
}
const command = {
type: 'control_client',
client_id: clientId,
command: {
type: type,
...data
}
};
ws.send(JSON.stringify(command));
console.log('Sent command:', command);
}
// Update streaming status
function updateStreamingStatus(streaming) {
document.getElementById('streamingStatus').textContent = streaming ? 'Yes' : 'No';
if (streaming) {
document.getElementById('videoStats').style.display = 'block';
document.getElementById('videoPlaceholder').innerHTML = `
<i class="fas fa-sync-alt fa-spin"></i>
<h3>Streaming Live Video</h3>
<p>Video is being streamed from the client.</p>
<p>Use controls to adjust the stream.</p>
`;
}
}
// Show notification
function showNotification(message, type = 'success') {
// Create notification element
const notification = document.createElement('div');
notification.style.cssText = `
position: fixed;
top: 20px;
right: 20px;
padding: 15px 20px;
background: ${type === 'error' ? '#ef4444' : '#10b981'};
color: white;
border-radius: 5px;
box-shadow: 0 5px 15px rgba(0,0,0,0.2);
z-index: 10000;
animation: slideIn 0.3s ease-out;
`;
notification.textContent = message;
document.body.appendChild(notification);
// Remove after 3 seconds
setTimeout(() => {
notification.style.animation = 'slideOut 0.3s ease-in';
setTimeout(() => {
document.body.removeChild(notification);
}, 300);
}, 3000);
}
// Disconnect client
async function disconnectClient() {
if (confirm('Are you sure you want to disconnect this client?')) {
try {
const response = await fetch(`/api/disconnect-client/${clientId}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
}
});
const result = await response.json();
if (result.success) {
showNotification('Client disconnected successfully');
setTimeout(() => {
window.location.href = `/room/{{ client.room_id }}`;
}, 1000);
} else {
showNotification('Error: ' + (result.error || 'Failed to disconnect client'), 'error');
}
} catch (error) {
showNotification('Error: ' + error.message, 'error');
}
}
}
// Handle video frame from admin WebSocket
function handleVideoFrame(blob) {
const canvas = document.getElementById('videoCanvas');
const placeholder = document.getElementById('videoPlaceholder');
// Show canvas, hide placeholder
if (canvas.style.display === 'none') {
canvas.style.display = 'block';
placeholder.style.display = 'none';
}
// Create image from blob and draw on canvas
const img = new Image();
img.onload = function() {
const ctx = canvas.getContext('2d');
// Set canvas size to match image
canvas.width = img.width;
canvas.height = img.height;
// Draw image
ctx.drawImage(img, 0, 0);
// Revoke the object URL to free memory
URL.revokeObjectURL(img.src);
};
img.onerror = function() {
console.error('Failed to load image from blob');
URL.revokeObjectURL(img.src);
};
// Create object URL from blob and load as image
img.src = URL.createObjectURL(blob);
}
// Initialize sliders
function initializeSliders() {
// Quality slider
const qualitySlider = document.getElementById('qualitySlider');
const qualityValue = document.getElementById('qualityValue');
qualitySlider.addEventListener('input', function() {
qualityValue.textContent = this.value + '%';
document.getElementById('statsQuality').textContent = this.value;
});
qualitySlider.addEventListener('change', function() {
sendCommand('adjust_quality', { quality: parseInt(this.value) });
});
// Brightness slider
const brightnessSlider = document.getElementById('brightnessSlider');
const brightnessValue = document.getElementById('brightnessValue');
brightnessSlider.addEventListener('input', function() {
brightnessValue.textContent = this.value;
});
brightnessSlider.addEventListener('change', function() {
sendCommand('brightness', { value: parseInt(this.value) });
});
// Contrast slider
const contrastSlider = document.getElementById('contrastSlider');
const contrastValue = document.getElementById('contrastValue');
contrastSlider.addEventListener('input', function() {
contrastValue.textContent = parseFloat(this.value).toFixed(1);
});
contrastSlider.addEventListener('change', function() {
sendCommand('contrast', { value: parseFloat(this.value) });
});
}
// Initialize
window.addEventListener('load', function() {
if (!sessionId) {
window.location.href = '/';
return;
}
connectWebSocket();
initializeSliders();
// Add CSS animations
const style = document.createElement('style');
style.textContent = `
@keyframes slideIn {
from { transform: translateX(100%); opacity: 0; }
to { transform: translateX(0); opacity: 1; }
}
@keyframes slideOut {
from { transform: translateX(0); opacity: 1; }
to { transform: translateX(100%); opacity: 0; }
}
`;
document.head.appendChild(style);
});
</script>
</body>
</html>
"""
# Сохраняем шаблоны в файлы
templates_dir = "templates"
os.makedirs(templates_dir, exist_ok=True)
with open(os.path.join(templates_dir, "login.html"), "w", encoding="utf-8") as f:
f.write(login_html)
with open(os.path.join(templates_dir, "dashboard.html"), "w", encoding="utf-8") as f:
f.write(dashboard_html)
with open(os.path.join(templates_dir, "room.html"), "w", encoding="utf-8") as f:
f.write(room_html)
with open(os.path.join(templates_dir, "create_room.html"), "w", encoding="utf-8") as f:
f.write(create_room_html)
with open(os.path.join(templates_dir, "stream.html"), "w", encoding="utf-8") as f:
f.write(stream_html)
print(f"[Templates] Created HTML templates in '{templates_dir}' directory")
# ========== ЗАПУСК СЕРВЕРА ==========
def main():
"""Основная функция для запуска сервера"""
# Создаем HTML шаблоны
create_html_templates()
server_host = get_server_host()
ssl_enabled = SERVER_CONFIG.get("ssl_enabled", False)
protocol = "https" if ssl_enabled else "http"
ws_protocol = "wss" if ssl_enabled else "ws"
print("=" * 60)
print("🎥 Video Streaming Server with Web Interface")
print("=" * 60)
print(f"🌐 Web Interface: {protocol}://{server_host}:{SERVER_CONFIG['port']}")
print(f"🔌 WebSocket: {ws_protocol}://{server_host}:{SERVER_CONFIG['port']}")
print(f"👤 Admin Login: {protocol}://{server_host}:{SERVER_CONFIG['port']}/")
print("=" * 60)
print("Default Admin Accounts:")
print(" • admin / admin123")
print(" • administrator / securepass")
print(" • supervisor / superpass")
print("=" * 60)
if ssl_enabled:
print("🔒 SSL/TLS enabled")
print(f" Certificate: {SERVER_CONFIG.get('ssl_certfile')}")
print(f" Key: {SERVER_CONFIG.get('ssl_keyfile')}")
print("=" * 60)
print("Press Ctrl+C to stop the server")
try:
uvicorn_kwargs = {
"host": SERVER_CONFIG["host"],
"port": SERVER_CONFIG["port"],
"log_level": "info",
"ws_ping_interval": SERVER_CONFIG["websocket_ping_interval"],
"ws_ping_timeout": SERVER_CONFIG["websocket_ping_timeout"],
}
# Добавляем SSL если включено
if ssl_enabled:
ssl_certfile = SERVER_CONFIG.get("ssl_certfile")
ssl_keyfile = SERVER_CONFIG.get("ssl_keyfile")
if ssl_certfile and ssl_keyfile:
import os
if os.path.exists(ssl_certfile) and os.path.exists(ssl_keyfile):
uvicorn_kwargs["ssl_certfile"] = ssl_certfile
uvicorn_kwargs["ssl_keyfile"] = ssl_keyfile
else:
print(f"⚠️ SSL files not found, running without SSL")
uvicorn.run("server:app", **uvicorn_kwargs)
except KeyboardInterrupt:
print("\nServer stopped by user")
except Exception as e:
print(f"Error starting server: {e}")
if __name__ == "__main__":
freeze_support()
main()