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

68
public/api/join.php Normal file
View File

@@ -0,0 +1,68 @@
<?php
declare(strict_types=1);
/**
* SFU join API — POST /api/join
*
* Client sends: { room_id, jsep: { type: "offer", sdp: "..." }, role: "publisher"|"subscriber" }
* Server relays to Janus VideoRoom, returns: { jsep: { type: "answer", sdp: "..." }, handle_id, session_id }
*
* Client subsequently maintains a WebSocket to Janus directly (proxied via nginx /sfu/ws).
*/
require_once dirname(__DIR__, 2) . '/src/bootstrap.php';
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
sfuJsonErr('method not allowed', 405);
}
$user = sfuRequireAuth();
$input = json_decode(file_get_contents('php://input'), true) ?? [];
$janusRoomId = (int) ($input['room_id'] ?? 0);
$jsep = $input['jsep'] ?? null;
$role = in_array($input['role'] ?? '', ['publisher', 'subscriber'], true)
? $input['role'] : 'publisher';
if (!$janusRoomId) { sfuJsonErr('room_id required'); }
if (!$jsep || empty($jsep['sdp'])) { sfuJsonErr('jsep.sdp required'); }
$repo = new \AndroidCast\Sfu\SfuRoomRepository(sfuPdo());
$janus = janusClient();
$room = $repo->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',
]);

60
public/api/rooms.php Normal file
View File

@@ -0,0 +1,60 @@
<?php
declare(strict_types=1);
/**
* SFU rooms API
* GET /api/rooms — list active rooms
* POST /api/rooms — create room (body: {name, purpose})
* DELETE /api/rooms/{id} — close room
*/
require_once dirname(__DIR__, 2) . '/src/bootstrap.php';
$user = sfuRequireAuth();
$method = $_SERVER['REQUEST_METHOD'] ?? 'GET';
$path = trim(parse_url($_SERVER['REQUEST_URI'] ?? '', PHP_URL_PATH) ?? '', '/');
$parts = explode('/', $path);
$roomId = (int) ($parts[array_key_last($parts)] ?? 0);
$repo = new \AndroidCast\Sfu\SfuRoomRepository(sfuPdo());
$janus = janusClient();
if ($method === 'GET') {
sfuJsonOk(['rooms' => $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);

25
public/api/stats.php Normal file
View File

@@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
/**
* SFU stats ingest — POST /api/stats
* Body: { participant_id, bitrate_kbps, packet_loss }
* Called periodically by Android client (every 5s) to feed analytics.
*/
require_once dirname(__DIR__, 2) . '/src/bootstrap.php';
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
sfuJsonErr('method not allowed', 405);
}
sfuRequireAuth();
$input = json_decode(file_get_contents('php://input'), true) ?? [];
$pid = (int) ($input['participant_id'] ?? 0);
$bw = (float) ($input['bitrate_kbps'] ?? 0.0);
$pl = (float) ($input['packet_loss'] ?? 0.0);
if ($pid <= 0) { sfuJsonErr('participant_id required'); }
$repo = new \AndroidCast\Sfu\SfuRoomRepository(sfuPdo());
$repo->recordStats($pid, $bw, $pl);
sfuJsonOk(['recorded' => true]);

124
public/dashboard.php Normal file
View File

@@ -0,0 +1,124 @@
<?php
declare(strict_types=1);
require_once dirname(__DIR__) . '/src/bootstrap.php';
$user = sfuRequireAuth();
$repo = new \AndroidCast\Sfu\SfuRoomRepository(sfuPdo());
$janus = janusClient();
$janusOnline = isset($janus->info()['janus']);
$rooms = $repo->listActive();
?>
<!doctype html>
<html lang="en" data-theme="dark">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>SFU Rooms — Android Cast</title>
<link rel="stylesheet" href="/app/androidcast_project/issues/assets/css/app.css">
<style>
.sfu-badge { display:inline-flex; align-items:center; gap:6px; padding:3px 10px; border-radius:20px; font-size:.78rem; font-weight:600; }
.sfu-badge.online { background:rgba(16,185,129,.15); color:#34d399; border:1px solid rgba(16,185,129,.3); }
.sfu-badge.offline { background:rgba(248,113,113,.12); color:#f87171; border:1px solid rgba(248,113,113,.25); }
.dot { width:7px; height:7px; border-radius:50%; background:currentColor; }
.room-card { background:var(--surface); border:1px solid var(--border); border-radius:8px; padding:16px 20px; margin-bottom:12px; display:flex; align-items:center; justify-content:space-between; gap:12px; }
.room-card h3 { margin:0 0 4px; font-size:1rem; }
.room-meta { color:var(--muted); font-size:.82rem; }
.btn-sm { padding:5px 14px; border-radius:6px; border:none; cursor:pointer; font-size:.82rem; font-weight:600; }
.btn-join { background:var(--accent); color:#fff; }
.btn-close { background:rgba(248,113,113,.18); color:var(--danger); border:1px solid rgba(248,113,113,.3); }
#create-form { background:var(--surface); border:1px solid var(--border); border-radius:8px; padding:18px 20px; margin-bottom:20px; display:none; }
#create-form.open { display:block; }
.form-row { display:flex; gap:10px; align-items:flex-end; flex-wrap:wrap; }
.form-row input, .form-row select { background:var(--surface2); border:1px solid var(--border); color:var(--text); border-radius:6px; padding:7px 12px; font-size:.9rem; }
</style>
</head>
<body>
<div class="layout" style="max-width:860px;margin:0 auto;padding:24px 16px">
<div style="display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:12px;margin-bottom:20px">
<div>
<h1 style="margin:0;font-size:1.4rem">SFU Rooms</h1>
<a href="/app/androidcast_project/issues/" style="color:var(--muted);font-size:.82rem">← Back to dashboard</a>
</div>
<div style="display:flex;align-items:center;gap:12px">
<span class="sfu-badge <?= $janusOnline ? 'online' : 'offline' ?>">
<span class="dot"></span>
Janus <?= $janusOnline ? 'online' : 'offline' ?>
</span>
<button class="btn-sm btn-join" onclick="toggleCreate()">+ New Room</button>
</div>
</div>
<div id="create-form">
<h3 style="margin:0 0 14px;font-size:1rem">Create Room</h3>
<div class="form-row">
<div>
<label style="font-size:.82rem;color:var(--muted);display:block;margin-bottom:4px">Room name</label>
<input id="room-name" type="text" placeholder="My screen cast" style="min-width:200px">
</div>
<div>
<label style="font-size:.82rem;color:var(--muted);display:block;margin-bottom:4px">Purpose</label>
<select id="room-purpose">
<option value="screen_cast">Screen Cast</option>
<option value="education">Education</option>
<option value="conference">Conference</option>
</select>
</div>
<button class="btn-sm btn-join" onclick="createRoom()">Create</button>
<button class="btn-sm btn-close" onclick="toggleCreate()">Cancel</button>
</div>
<p id="create-err" style="color:var(--danger);font-size:.82rem;margin:8px 0 0;display:none"></p>
</div>
<div id="rooms-list">
<?php if (empty($rooms)): ?>
<p style="color:var(--muted);text-align:center;padding:40px 0">No active rooms. Create one above.</p>
<?php else: foreach ($rooms as $r): ?>
<div class="room-card" id="room-<?= (int)$r['janus_room_id'] ?>">
<div>
<h3><?= htmlspecialchars($r['name']) ?></h3>
<span class="room-meta">
<?= htmlspecialchars($r['purpose']) ?> ·
<?= (int)$r['participant_count'] ?> participant(s) ·
Janus #<?= (int)$r['janus_room_id'] ?>
</span>
</div>
<div style="display:flex;gap:8px">
<button class="btn-sm btn-close" onclick="closeRoom(<?= (int)$r['janus_room_id'] ?>)">Close</button>
</div>
</div>
<?php endforeach; endif; ?>
</div>
</div>
<script>
const API = '/app/androidcast_project/sfu/api';
function toggleCreate() {
document.getElementById('create-form').classList.toggle('open');
}
async function createRoom() {
const name = document.getElementById('room-name').value.trim() || 'Room';
const purpose = document.getElementById('room-purpose').value;
const errEl = document.getElementById('create-err');
errEl.style.display = 'none';
try {
const r = await fetch(`${API}/rooms`, {
method: 'POST',
headers: {'Content-Type':'application/json'},
body: JSON.stringify({name, purpose})
});
const j = await r.json();
if (!j.ok) throw new Error(j.error || 'failed');
location.reload();
} catch (e) {
errEl.textContent = e.message;
errEl.style.display = 'block';
}
}
async function closeRoom(janusId) {
if (!confirm('Close this room?')) return;
await fetch(`${API}/rooms/${janusId}`, {method:'DELETE'});
document.getElementById(`room-${janusId}`)?.remove();
}
</script>
</body>
</html>

22
public/health.php Normal file
View File

@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
require_once dirname(__DIR__) . '/src/bootstrap.php';
header('Content-Type: application/json; charset=utf-8');
$janus = janusClient();
$info = $janus->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);

28
public/index.php Normal file
View File

@@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
require_once dirname(__DIR__) . '/src/bootstrap.php';
/* ── Simple front-controller ─────────────────────────────────────── */
$uri = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH);
$script = basename($uri);
/* Strip project prefix /app/androidcast_project/sfu */
$uri = preg_replace('#^/app/androidcast_project/sfu#', '', $uri);
$routes = [
'/' => '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']);