mirror of
git://f0xx.org/ac/ac-ms-sfu-signaling
synced 2026-07-29 05:59:14 +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:
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