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

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]);
}
}