sfu-signaling: real Janus signaling service implementation

- JanusClient.php: HTTP REST client for Janus Gateway API
- SfuRoomRepository.php: room + participant + stats persistence
- bootstrap.php: PDO, auth, Janus client factory
- public/index.php: front-controller routing
- public/health.php: Janus online check
- public/api/rooms.php: list/create/destroy rooms
- public/api/join.php: SDP offer relay → Janus VideoRoom
- public/api/stats.php: bitrate/packet-loss analytics ingest
- public/dashboard.php: SFU rooms UI (common BE dark-theme styles)

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Anton Afanasyeu
2026-06-28 22:27:59 +02:00
parent ece49a035a
commit 0658e26ae4
9 changed files with 723 additions and 0 deletions

168
src/JanusClient.php Normal file
View File

@@ -0,0 +1,168 @@
<?php
declare(strict_types=1);
namespace AndroidCast\Sfu;
/**
* Thin HTTP client for Janus Gateway REST API.
* Base URL: http://cast02.intra.raptor.org:8088/janus
*/
final class JanusClient
{
private string $base;
private int $timeout;
public function __construct(string $baseUrl = '', int $timeoutSec = 10)
{
$this->base = rtrim($baseUrl ?: 'http://10.7.16.237:8088/janus', '/');
$this->timeout = $timeoutSec;
}
/** GET /janus/info — returns server info or null. */
public function info(): ?array
{
return $this->get('');
}
/** Create a new Janus session. Returns session_id or null. */
public function createSession(): ?int
{
$body = ['janus' => 'create', 'transaction' => $this->txid()];
$resp = $this->post('', $body);
return isset($resp['data']['id']) ? (int) $resp['data']['id'] : null;
}
/** Attach VideoRoom plugin to a session. Returns handle_id or null. */
public function attachVideoRoom(int $sessionId): ?int
{
$body = [
'janus' => 'attach',
'plugin' => 'janus.plugin.videoroom',
'transaction' => $this->txid(),
];
$resp = $this->post("/{$sessionId}", $body);
return isset($resp['data']['id']) ? (int) $resp['data']['id'] : null;
}
/** List all VideoRoom rooms. */
public function listRooms(int $sessionId, int $handleId): array
{
$body = [
'janus' => 'message',
'transaction' => $this->txid(),
'body' => ['request' => 'list'],
];
$resp = $this->post("/{$sessionId}/{$handleId}", $body);
return $resp['plugindata']['data']['list'] ?? [];
}
/**
* Create a VideoRoom room.
* @param array $opts e.g. ['description'=>'My room','publishers'=>6,'videocodec'=>'vp8,h264']
*/
public function createRoom(int $sessionId, int $handleId, int $roomId, array $opts = []): bool
{
$body = [
'janus' => 'message',
'transaction' => $this->txid(),
'body' => array_merge([
'request' => 'create',
'room' => $roomId,
'publishers' => 6,
'bitrate' => 1024000,
'videocodec' => 'vp8,h264',
'audiocodec' => 'opus',
'record' => false,
], $opts),
];
$resp = $this->post("/{$sessionId}/{$handleId}", $body);
$ack = $resp['plugindata']['data']['videoroom'] ?? '';
return $ack === 'created' || $ack === 'ok';
}
/** Destroy a VideoRoom room. */
public function destroyRoom(int $sessionId, int $handleId, int $roomId): bool
{
$body = [
'janus' => 'message',
'transaction' => $this->txid(),
'body' => ['request' => 'destroy', 'room' => $roomId],
];
$resp = $this->post("/{$sessionId}/{$handleId}", $body);
$ack = $resp['plugindata']['data']['videoroom'] ?? '';
return $ack === 'destroyed';
}
/**
* Forward a client SDP offer to Janus and get an SDP answer.
* Used for the join/publish flow.
*/
public function sendMessage(int $sessionId, int $handleId, array $body): array
{
return $this->post("/{$sessionId}/{$handleId}", [
'janus' => 'message',
'transaction' => $this->txid(),
'body' => $body,
]) ?? [];
}
public function sendJsep(int $sessionId, int $handleId, array $body, array $jsep): array
{
return $this->post("/{$sessionId}/{$handleId}", [
'janus' => 'message',
'transaction' => $this->txid(),
'body' => $body,
'jsep' => $jsep,
]) ?? [];
}
/** Long-poll for Janus events on a session. */
public function poll(int $sessionId, int $maxev = 4): array
{
return $this->get("/{$sessionId}?maxev={$maxev}") ?? [];
}
public function keepalive(int $sessionId): void
{
$this->post("/{$sessionId}", ['janus' => 'keepalive', 'transaction' => $this->txid()]);
}
public function destroySession(int $sessionId): void
{
$this->post("/{$sessionId}", ['janus' => 'destroy', 'transaction' => $this->txid()]);
}
/* ── HTTP helpers ────────────────────────────────────────────────── */
private function get(string $path): ?array
{
$url = $this->base . $path;
$ctx = stream_context_create(['http' => [
'method' => 'GET',
'timeout' => $this->timeout,
'header' => "Accept: application/json\r\n",
'ignore_errors' => true,
]]);
$raw = @file_get_contents($url, false, $ctx);
return $raw !== false ? json_decode($raw, true) : null;
}
private function post(string $path, array $payload): ?array
{
$url = $this->base . $path;
$json = json_encode($payload);
$ctx = stream_context_create(['http' => [
'method' => 'POST',
'header' => "Content-Type: application/json\r\nAccept: application/json\r\n",
'content' => $json,
'timeout' => $this->timeout,
'ignore_errors' => true,
]]);
$raw = @file_get_contents($url, false, $ctx);
return $raw !== false ? json_decode($raw, true) : null;
}
private function txid(): string
{
return bin2hex(random_bytes(8));
}
}

109
src/SfuRoomRepository.php Normal file
View File

@@ -0,0 +1,109 @@
<?php
declare(strict_types=1);
namespace AndroidCast\Sfu;
use PDO;
/**
* Persistent store for SFU rooms + participant sessions (MariaDB / SQLite).
* Schema managed by ac-platform-db sql/sfu/*.sql migrations.
*/
final class SfuRoomRepository
{
private PDO $pdo;
public function __construct(PDO $pdo)
{
$this->pdo = $pdo;
}
/* ── room lifecycle ─────────────────────────────────────────────── */
public function createRoom(int $janusRoomId, int $ownerId, string $name, string $purpose = 'screen_cast'): int
{
$stmt = $this->pdo->prepare(
'INSERT INTO sfu_rooms (janus_room_id, owner_id, name, purpose, created_at, updated_at)
VALUES (:jrId, :oid, :name, :purpose, NOW(), NOW())'
);
$stmt->execute([
':jrId' => $janusRoomId,
':oid' => $ownerId,
':name' => $name,
':purpose' => $purpose,
]);
return (int) $this->pdo->lastInsertId();
}
public function getByJanusId(int $janusRoomId): ?array
{
$stmt = $this->pdo->prepare('SELECT * FROM sfu_rooms WHERE janus_room_id = :id LIMIT 1');
$stmt->execute([':id' => $janusRoomId]);
$row = $stmt->fetch(PDO::FETCH_ASSOC);
return $row ?: null;
}
public function listActive(): array
{
$stmt = $this->pdo->query(
"SELECT r.*, COUNT(p.id) AS participant_count
FROM sfu_rooms r
LEFT JOIN sfu_participants p ON p.room_id = r.id AND p.left_at IS NULL
WHERE r.closed_at IS NULL
GROUP BY r.id
ORDER BY r.created_at DESC"
);
return $stmt->fetchAll(PDO::FETCH_ASSOC);
}
public function closeRoom(int $roomId): void
{
$stmt = $this->pdo->prepare('UPDATE sfu_rooms SET closed_at = NOW() WHERE id = :id');
$stmt->execute([':id' => $roomId]);
}
/* ── participant tracking ───────────────────────────────────────── */
public function joinRoom(int $roomId, int $userId, int $janusHandleId, string $role = 'publisher'): int
{
$stmt = $this->pdo->prepare(
'INSERT INTO sfu_participants (room_id, user_id, janus_handle_id, role, joined_at)
VALUES (:room, :user, :handle, :role, NOW())'
);
$stmt->execute([
':room' => $roomId,
':user' => $userId,
':handle' => $janusHandleId,
':role' => $role,
]);
return (int) $this->pdo->lastInsertId();
}
public function leaveRoom(int $participantId): void
{
$stmt = $this->pdo->prepare('UPDATE sfu_participants SET left_at = NOW() WHERE id = :id');
$stmt->execute([':id' => $participantId]);
}
public function listParticipants(int $roomId): array
{
$stmt = $this->pdo->prepare(
'SELECT p.*, u.email FROM sfu_participants p
JOIN users u ON u.id = p.user_id
WHERE p.room_id = :room AND p.left_at IS NULL'
);
$stmt->execute([':room' => $roomId]);
return $stmt->fetchAll(PDO::FETCH_ASSOC);
}
/* ── stats hook ─────────────────────────────────────────────────── */
/** Record a bitrate/packet-loss sample for analytics. */
public function recordStats(int $participantId, float $bitrateKbps, float $packetLoss): void
{
$stmt = $this->pdo->prepare(
'INSERT INTO sfu_stats (participant_id, bitrate_kbps, packet_loss, sampled_at)
VALUES (:pid, :bw, :pl, NOW())'
);
$stmt->execute([':pid' => $participantId, ':bw' => $bitrateKbps, ':pl' => $packetLoss]);
}
}

119
src/bootstrap.php Normal file
View File

@@ -0,0 +1,119 @@
<?php
declare(strict_types=1);
/*
* SFU signaling bootstrap — minimal standalone setup.
* Reuses the composed BE database and session if available,
* otherwise falls back to own PDO from environment.
*/
define('SFU_ROOT', dirname(__DIR__));
/* ── autoloader ───────────────────────────────────────────────────── */
spl_autoload_register(static function (string $class): void {
$prefix = 'AndroidCast\\Sfu\\';
if (str_starts_with($class, $prefix)) {
$rel = str_replace('\\', '/', substr($class, strlen($prefix)));
$file = SFU_ROOT . '/src/' . $rel . '.php';
if (is_file($file)) {
require_once $file;
}
}
});
/* ── configuration ────────────────────────────────────────────────── */
function sfuConfig(string $key, mixed $default = null): mixed
{
static $cfg = null;
if ($cfg === null) {
$cfgFile = SFU_ROOT . '/config/config.php';
$cfg = is_file($cfgFile) ? (require $cfgFile) : [];
/* Env overrides */
if (getenv('JANUS_URL') !== false) {
$cfg['janus_url'] = getenv('JANUS_URL');
}
if (getenv('DB_DSN') !== false) {
$cfg['db_dsn'] = getenv('DB_DSN');
}
}
return $cfg[$key] ?? $default;
}
/* ── PDO ────────────────────────────────────────────────────────────── */
function sfuPdo(): \PDO
{
static $pdo = null;
if ($pdo === null) {
/* Prefer composed BE PDO if already bootstrapped */
$composedBe = '/var/www/ac/composed/backend/src/bootstrap.php';
if (is_file($composedBe)) {
require_once $composedBe;
if (function_exists('db')) {
$pdo = db();
return $pdo;
}
}
/* Fall back to own DSN */
$dsn = sfuConfig('db_dsn', 'mysql:host=cast01;dbname=androidcast;charset=utf8mb4');
$user = sfuConfig('db_user', 'androidcast');
$pass = sfuConfig('db_pass', '');
$pdo = new \PDO($dsn, $user, $pass, [
\PDO::ATTR_ERRMODE => \PDO::ERRMODE_EXCEPTION,
\PDO::ATTR_DEFAULT_FETCH_MODE => \PDO::FETCH_ASSOC,
]);
}
return $pdo;
}
/* ── Janus client ────────────────────────────────────────────────── */
function janusClient(): \AndroidCast\Sfu\JanusClient
{
static $client = null;
if ($client === null) {
$url = sfuConfig('janus_url', 'http://10.7.16.237:8088/janus');
$client = new \AndroidCast\Sfu\JanusClient($url);
}
return $client;
}
/* ── Auth helper ─────────────────────────────────────────────────── */
function sfuRequireAuth(): array
{
/* Try composed BE session */
$composedBe = '/var/www/ac/composed/backend/src/bootstrap.php';
if (is_file($composedBe)) {
require_once $composedBe;
if (function_exists('requireAuth')) {
return requireAuth();
}
}
/* Minimal cookie/bearer check */
session_start();
if (empty($_SESSION['user_id'])) {
http_response_code(401);
echo json_encode(['ok' => false, 'error' => 'unauthorized']);
exit;
}
return ['id' => (int) $_SESSION['user_id'], 'email' => $_SESSION['email'] ?? ''];
}
/* ── JSON helpers ────────────────────────────────────────────────── */
function sfuJsonOk(array $data = []): void
{
header('Content-Type: application/json; charset=utf-8');
echo json_encode(['ok' => true] + $data, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
exit;
}
function sfuJsonErr(string $message, int $code = 400): void
{
http_response_code($code);
header('Content-Type: application/json; charset=utf-8');
echo json_encode(['ok' => false, 'error' => $message], JSON_UNESCAPED_SLASHES);
exit;
}