mirror of
git://f0xx.org/ac/ac-ms-sfu-signaling
synced 2026-07-29 04:18:07 +03:00
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:
68
public/api/join.php
Normal file
68
public/api/join.php
Normal 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
60
public/api/rooms.php
Normal 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
25
public/api/stats.php
Normal 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
124
public/dashboard.php
Normal 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
22
public/health.php
Normal 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
28
public/index.php
Normal 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']);
|
||||||
168
src/JanusClient.php
Normal file
168
src/JanusClient.php
Normal 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
109
src/SfuRoomRepository.php
Normal 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
119
src/bootstrap.php
Normal 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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user