Files
local-transcription/server/php/server.php
Josh Knapp 9c3a0d7678 Add multi-user server sync (PHP server + client)
Phase 2 implementation: Multiple streamers can now merge their captions
into a single stream using a PHP server.

PHP Server (server/php/):
- server.php: API endpoint for sending/streaming transcriptions
- display.php: Web page for viewing merged captions in OBS
- config.php: Server configuration
- .htaccess: Security settings
- README.md: Comprehensive deployment guide

Features:
- Room-based isolation (multiple groups on same server)
- Passphrase authentication per room
- Real-time streaming via Server-Sent Events (SSE)
- Different colors for each user
- File-based storage (no database required)
- Auto-cleanup of old rooms
- Works on standard PHP hosting

Client-Side:
- client/server_sync.py: HTTP client for sending to PHP server
- Settings dialog updated with server sync options
- Config updated with server_sync section

Server Configuration:
- URL: Server endpoint (e.g., http://example.com/transcription/server.php)
- Room: Unique room name for your group
- Passphrase: Shared secret for authentication

OBS Integration:
Display URL format:
http://example.com/transcription/display.php?room=ROOM&passphrase=PASS&fade=10&timestamps=true

NOTE: Main window integration pending (client sends transcriptions)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-26 10:09:12 -08:00

279 lines
6.6 KiB
PHP

<?php
/**
* Multi-User Transcription Server - API Endpoint
*
* Endpoints:
* - POST /server.php?action=send - Send a transcription
* - GET /server.php?action=stream - Stream transcriptions via SSE
* - GET /server.php?action=list - List recent transcriptions
*/
require_once 'config.php';
// Get action
$action = $_GET['action'] ?? 'info';
// Route to appropriate handler
switch ($action) {
case 'send':
handleSend();
break;
case 'stream':
handleStream();
break;
case 'list':
handleList();
break;
case 'info':
handleInfo();
break;
default:
sendError('Invalid action', 400);
}
/**
* Handle sending a transcription
*/
function handleSend() {
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
sendError('Method not allowed', 405);
}
// Get JSON body
$input = file_get_contents('php://input');
$data = json_decode($input, true);
if (!$data) {
sendError('Invalid JSON', 400);
}
// Validate required fields
$required = ['room', 'passphrase', 'user_name', 'text'];
foreach ($required as $field) {
if (empty($data[$field])) {
sendError("Missing required field: $field", 400);
}
}
// Verify passphrase
$room = sanitize($data['room']);
$passphrase = $data['passphrase'];
if (!verifyPassphrase($room, $passphrase)) {
sendError('Invalid passphrase', 401);
}
// Create transcription entry
$transcription = [
'user_name' => sanitize($data['user_name']),
'text' => sanitize($data['text']),
'timestamp' => $data['timestamp'] ?? date('H:i:s'),
'created_at' => time()
];
// Add to room
addTranscription($room, $transcription);
// Cleanup old sessions
cleanupOldSessions();
// Success response
sendJson(['status' => 'ok', 'message' => 'Transcription added']);
}
/**
* Handle streaming transcriptions via Server-Sent Events
*/
function handleStream() {
// Get parameters
$room = sanitize($_GET['room'] ?? '');
$passphrase = $_GET['passphrase'] ?? '';
if (empty($room) || empty($passphrase)) {
sendError('Missing room or passphrase', 400);
}
if (!verifyPassphrase($room, $passphrase)) {
sendError('Invalid passphrase', 401);
}
// Set SSE headers
header('Content-Type: text/event-stream');
header('Cache-Control: no-cache');
header('X-Accel-Buffering: no'); // Disable nginx buffering
// Track last known count
$lastCount = 0;
// Stream loop
while (true) {
$transcriptions = getTranscriptions($room);
$currentCount = count($transcriptions);
// If new transcriptions, send them
if ($currentCount > $lastCount) {
$newTranscriptions = array_slice($transcriptions, $lastCount);
foreach ($newTranscriptions as $trans) {
echo "data: " . json_encode($trans) . "\n\n";
flush();
}
$lastCount = $currentCount;
}
// Send keepalive comment every 15 seconds
echo ": keepalive\n\n";
flush();
// Check if client disconnected
if (connection_aborted()) {
break;
}
// Wait before next check
sleep(1);
}
}
/**
* Handle listing recent transcriptions
*/
function handleList() {
$room = sanitize($_GET['room'] ?? '');
$passphrase = $_GET['passphrase'] ?? '';
if (empty($room) || empty($passphrase)) {
sendError('Missing room or passphrase', 400);
}
if (!verifyPassphrase($room, $passphrase)) {
sendError('Invalid passphrase', 401);
}
$transcriptions = getTranscriptions($room);
sendJson(['transcriptions' => $transcriptions]);
}
/**
* Handle info request
*/
function handleInfo() {
sendJson([
'service' => 'Local Transcription Multi-User Server',
'version' => '1.0.0',
'endpoints' => [
'POST ?action=send' => 'Send a transcription',
'GET ?action=stream' => 'Stream transcriptions (SSE)',
'GET ?action=list' => 'List recent transcriptions'
]
]);
}
/**
* Verify passphrase for a room
*/
function verifyPassphrase($room, $passphrase) {
$file = getRoomFile($room);
// If room doesn't exist, create it with this passphrase
if (!file_exists($file)) {
$roomData = [
'passphrase_hash' => password_hash($passphrase, PASSWORD_DEFAULT),
'created_at' => time(),
'transcriptions' => []
];
file_put_contents($file, json_encode($roomData));
return true;
}
// Verify passphrase
$roomData = json_decode(file_get_contents($file), true);
return password_verify($passphrase, $roomData['passphrase_hash']);
}
/**
* Add transcription to room
*/
function addTranscription($room, $transcription) {
$file = getRoomFile($room);
$roomData = json_decode(file_get_contents($file), true);
// Add transcription
$roomData['transcriptions'][] = $transcription;
// Limit to max transcriptions
if (count($roomData['transcriptions']) > MAX_TRANSCRIPTIONS_PER_ROOM) {
$roomData['transcriptions'] = array_slice(
$roomData['transcriptions'],
-MAX_TRANSCRIPTIONS_PER_ROOM
);
}
// Update last activity
$roomData['last_activity'] = time();
// Save
file_put_contents($file, json_encode($roomData));
}
/**
* Get transcriptions for a room
*/
function getTranscriptions($room) {
$file = getRoomFile($room);
if (!file_exists($file)) {
return [];
}
$roomData = json_decode(file_get_contents($file), true);
return $roomData['transcriptions'] ?? [];
}
/**
* Get room data file path
*/
function getRoomFile($room) {
return STORAGE_DIR . '/room_' . md5($room) . '.json';
}
/**
* Cleanup old sessions
*/
function cleanupOldSessions() {
$files = glob(STORAGE_DIR . '/room_*.json');
$now = time();
foreach ($files as $file) {
$data = json_decode(file_get_contents($file), true);
$lastActivity = $data['last_activity'] ?? $data['created_at'];
if ($now - $lastActivity > CLEANUP_THRESHOLD) {
unlink($file);
}
}
}
/**
* Sanitize input
*/
function sanitize($input) {
return htmlspecialchars(strip_tags(trim($input)), ENT_QUOTES, 'UTF-8');
}
/**
* Send JSON response
*/
function sendJson($data, $code = 200) {
http_response_code($code);
header('Content-Type: application/json');
echo json_encode($data);
exit();
}
/**
* Send error response
*/
function sendError($message, $code = 400) {
sendJson(['error' => $message], $code);
}
?>