From 0658e26ae48e5f85ce0d4d605cb46ebab48da20b Mon Sep 17 00:00:00 2001 From: Anton Afanasyeu Date: Sun, 28 Jun 2026 22:27:59 +0200 Subject: [PATCH] sfu-signaling: real Janus signaling service implementation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- public/api/join.php | 68 +++++++++++++++ public/api/rooms.php | 60 ++++++++++++++ public/api/stats.php | 25 ++++++ public/dashboard.php | 124 ++++++++++++++++++++++++++++ public/health.php | 22 +++++ public/index.php | 28 +++++++ src/JanusClient.php | 168 ++++++++++++++++++++++++++++++++++++++ src/SfuRoomRepository.php | 109 +++++++++++++++++++++++++ src/bootstrap.php | 119 +++++++++++++++++++++++++++ 9 files changed, 723 insertions(+) create mode 100644 public/api/join.php create mode 100644 public/api/rooms.php create mode 100644 public/api/stats.php create mode 100644 public/dashboard.php create mode 100644 public/health.php create mode 100644 public/index.php create mode 100644 src/JanusClient.php create mode 100644 src/SfuRoomRepository.php create mode 100644 src/bootstrap.php diff --git a/public/api/join.php b/public/api/join.php new file mode 100644 index 0000000..69a07b2 --- /dev/null +++ b/public/api/join.php @@ -0,0 +1,68 @@ +getByJanusId($janusRoomId); +if (!$room) { sfuJsonErr('room not found', 404); } + +/* Create a fresh Janus session + handle for this participant */ +$sid = $janus->createSession(); +if (!$sid) { sfuJsonErr('Janus session failed'); } +$hid = $janus->attachVideoRoom($sid); +if (!$hid) { sfuJsonErr('Janus attach failed'); } + +/* Send join + offer to Janus */ +$joinBody = [ + 'request' => 'join', + 'room' => $janusRoomId, + 'ptype' => $role === 'publisher' ? 'publisher' : 'subscriber', + 'display' => $user['email'] ?? 'anon', +]; +$resp = $janus->sendJsep($sid, $hid, $joinBody, [ + 'type' => $jsep['type'], + 'sdp' => $jsep['sdp'], +]); + +/* Record participant in DB */ +$participantId = $repo->joinRoom( + (int) $room['id'], + (int) $user['id'], + $hid, + $role +); + +/* Return negotiated JSEP answer + WS connection details */ +$answer = $resp['jsep'] ?? null; +sfuJsonOk([ + 'session_id' => $sid, + 'handle_id' => $hid, + 'participant_id' => $participantId, + 'jsep' => $answer, + 'ws_url' => '/app/androidcast_project/sfu/ws', +]); diff --git a/public/api/rooms.php b/public/api/rooms.php new file mode 100644 index 0000000..2f4361a --- /dev/null +++ b/public/api/rooms.php @@ -0,0 +1,60 @@ + $repo->listActive()]); +} + +if ($method === 'POST') { + $input = json_decode(file_get_contents('php://input'), true) ?? []; + $name = trim((string) ($input['name'] ?? 'Room ' . date('H:i'))); + $purp = trim((string) ($input['purpose'] ?? 'screen_cast')); + + /* Allocate a Janus session + handle to create the VideoRoom */ + $sid = $janus->createSession(); + if (!$sid) { sfuJsonErr('Janus unreachable'); } + $hid = $janus->attachVideoRoom($sid); + if (!$hid) { sfuJsonErr('Janus attach failed'); } + + $janusRoomId = mt_rand(1_000_000, 9_999_999); + $ok = $janus->createRoom($sid, $hid, $janusRoomId, [ + 'description' => $name, + 'videocodec' => 'vp8,h264', + 'audiocodec' => 'opus', + 'bitrate' => 1024000, + ]); + if (!$ok) { sfuJsonErr('Janus room creation failed'); } + + $id = $repo->createRoom($janusRoomId, (int) $user['id'], $name, $purp); + sfuJsonOk(['room' => ['id' => $id, 'janus_room_id' => $janusRoomId, 'name' => $name]]); +} + +if ($method === 'DELETE' && $roomId > 0) { + $row = $repo->getByJanusId($roomId) ?? sfuJsonErr('room not found', 404); + /* Best-effort Janus destroy */ + $sid = $janus->createSession(); + if ($sid) { + $hid = $janus->attachVideoRoom($sid); + if ($hid) { $janus->destroyRoom($sid, $hid, $roomId); } + } + $repo->closeRoom((int) $row['id']); + sfuJsonOk(['destroyed' => true]); +} + +sfuJsonErr('method not allowed', 405); diff --git a/public/api/stats.php b/public/api/stats.php new file mode 100644 index 0000000..c1f3433 --- /dev/null +++ b/public/api/stats.php @@ -0,0 +1,25 @@ +recordStats($pid, $bw, $pl); +sfuJsonOk(['recorded' => true]); diff --git a/public/dashboard.php b/public/dashboard.php new file mode 100644 index 0000000..275f506 --- /dev/null +++ b/public/dashboard.php @@ -0,0 +1,124 @@ +info()['janus']); +$rooms = $repo->listActive(); +?> + + + + + +SFU Rooms — Android Cast + + + + +
+
+
+

SFU Rooms

+ ← Back to dashboard +
+
+ + + Janus + + +
+
+ +
+

Create Room

+
+
+ + +
+
+ + +
+ + +
+ +
+ +
+ +

No active rooms. Create one above.

+ +
+
+

+ + · + participant(s) · + Janus # + +
+
+ +
+
+ +
+
+ + + + diff --git a/public/health.php b/public/health.php new file mode 100644 index 0000000..45cbfeb --- /dev/null +++ b/public/health.php @@ -0,0 +1,22 @@ +info(); +$online = isset($info['janus']) && $info['janus'] === 'server_info'; + +echo json_encode([ + 'ok' => true, + 'sfu' => [ + 'enabled' => true, + 'status' => $online ? 'online' : 'unreachable', + 'janus_version' => $info['version_string'] ?? null, + 'janus_url' => sfuConfig('janus_url', 'http://10.7.16.237:8088/janus'), + 'signaling_url' => sfuConfig('signaling_url', 'wss://apps.f0xx.org/app/androidcast_project/sfu/ws'), + 'ws_url' => '/app/androidcast_project/sfu/ws', + 'api_base' => '/app/androidcast_project/sfu/api', + ], +], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); diff --git a/public/index.php b/public/index.php new file mode 100644 index 0000000..ce28531 --- /dev/null +++ b/public/index.php @@ -0,0 +1,28 @@ + 'dashboard.php', + '/health' => 'health.php', + '/health.php' => 'health.php', + '/api/rooms' => 'api/rooms.php', + '/api/join' => 'api/join.php', + '/api/stats' => 'api/stats.php', +]; + +$target = $routes[$uri] ?? null; +if ($target && is_file(__DIR__ . '/' . $target)) { + require __DIR__ . '/' . $target; + exit; +} +http_response_code(404); +header('Content-Type: application/json'); +echo json_encode(['ok' => false, 'error' => 'not found']); diff --git a/src/JanusClient.php b/src/JanusClient.php new file mode 100644 index 0000000..0a0486a --- /dev/null +++ b/src/JanusClient.php @@ -0,0 +1,168 @@ +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)); + } +} diff --git a/src/SfuRoomRepository.php b/src/SfuRoomRepository.php new file mode 100644 index 0000000..a034196 --- /dev/null +++ b/src/SfuRoomRepository.php @@ -0,0 +1,109 @@ +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]); + } +} diff --git a/src/bootstrap.php b/src/bootstrap.php new file mode 100644 index 0000000..d59ff95 --- /dev/null +++ b/src/bootstrap.php @@ -0,0 +1,119 @@ + \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; +}