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×tamps=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>
This commit is contained in:
163
client/server_sync.py
Normal file
163
client/server_sync.py
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
"""Server sync client for multi-user transcription."""
|
||||||
|
|
||||||
|
import requests
|
||||||
|
import json
|
||||||
|
from typing import Optional
|
||||||
|
from datetime import datetime
|
||||||
|
import threading
|
||||||
|
import queue
|
||||||
|
|
||||||
|
|
||||||
|
class ServerSyncClient:
|
||||||
|
"""Client for syncing transcriptions to a PHP server."""
|
||||||
|
|
||||||
|
def __init__(self, url: str, room: str, passphrase: str, user_name: str):
|
||||||
|
"""
|
||||||
|
Initialize server sync client.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
url: Server URL (e.g., http://example.com/transcription/server.php)
|
||||||
|
room: Room name
|
||||||
|
passphrase: Room passphrase
|
||||||
|
user_name: User's display name
|
||||||
|
"""
|
||||||
|
self.url = url
|
||||||
|
self.room = room
|
||||||
|
self.passphrase = passphrase
|
||||||
|
self.user_name = user_name
|
||||||
|
|
||||||
|
# Queue for sending transcriptions asynchronously
|
||||||
|
self.send_queue = queue.Queue()
|
||||||
|
self.is_running = False
|
||||||
|
self.send_thread: Optional[threading.Thread] = None
|
||||||
|
|
||||||
|
# Statistics
|
||||||
|
self.sent_count = 0
|
||||||
|
self.error_count = 0
|
||||||
|
self.last_error: Optional[str] = None
|
||||||
|
|
||||||
|
def start(self):
|
||||||
|
"""Start the sync client."""
|
||||||
|
if self.is_running:
|
||||||
|
return
|
||||||
|
|
||||||
|
self.is_running = True
|
||||||
|
self.send_thread = threading.Thread(target=self._send_loop, daemon=True)
|
||||||
|
self.send_thread.start()
|
||||||
|
print(f"Server sync started: room={self.room}")
|
||||||
|
|
||||||
|
def stop(self):
|
||||||
|
"""Stop the sync client."""
|
||||||
|
self.is_running = False
|
||||||
|
if self.send_thread:
|
||||||
|
self.send_thread.join(timeout=2.0)
|
||||||
|
print("Server sync stopped")
|
||||||
|
|
||||||
|
def send_transcription(self, text: str, timestamp: Optional[datetime] = None):
|
||||||
|
"""
|
||||||
|
Send a transcription to the server (non-blocking).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
text: Transcription text
|
||||||
|
timestamp: Timestamp (defaults to now)
|
||||||
|
"""
|
||||||
|
if timestamp is None:
|
||||||
|
timestamp = datetime.now()
|
||||||
|
|
||||||
|
# Add to queue
|
||||||
|
self.send_queue.put({
|
||||||
|
'text': text,
|
||||||
|
'timestamp': timestamp.strftime("%H:%M:%S")
|
||||||
|
})
|
||||||
|
|
||||||
|
def _send_loop(self):
|
||||||
|
"""Background thread for sending transcriptions."""
|
||||||
|
while self.is_running:
|
||||||
|
try:
|
||||||
|
# Get transcription from queue (with timeout)
|
||||||
|
try:
|
||||||
|
trans_data = self.send_queue.get(timeout=1.0)
|
||||||
|
except queue.Empty:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Send to server
|
||||||
|
self._send_to_server(trans_data)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error in server sync send loop: {e}")
|
||||||
|
self.error_count += 1
|
||||||
|
self.last_error = str(e)
|
||||||
|
|
||||||
|
def _send_to_server(self, trans_data: dict):
|
||||||
|
"""
|
||||||
|
Send a transcription to the PHP server.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
trans_data: Dictionary with 'text' and 'timestamp'
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Prepare payload
|
||||||
|
payload = {
|
||||||
|
'room': self.room,
|
||||||
|
'passphrase': self.passphrase,
|
||||||
|
'user_name': self.user_name,
|
||||||
|
'text': trans_data['text'],
|
||||||
|
'timestamp': trans_data['timestamp']
|
||||||
|
}
|
||||||
|
|
||||||
|
# Send POST request
|
||||||
|
response = requests.post(
|
||||||
|
self.url,
|
||||||
|
params={'action': 'send'},
|
||||||
|
json=payload,
|
||||||
|
timeout=5.0
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check response
|
||||||
|
if response.status_code == 200:
|
||||||
|
self.sent_count += 1
|
||||||
|
self.last_error = None
|
||||||
|
else:
|
||||||
|
error_msg = f"Server returned {response.status_code}"
|
||||||
|
try:
|
||||||
|
error_data = response.json()
|
||||||
|
if 'error' in error_data:
|
||||||
|
error_msg = error_data['error']
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
print(f"Server sync error: {error_msg}")
|
||||||
|
self.error_count += 1
|
||||||
|
self.last_error = error_msg
|
||||||
|
|
||||||
|
except requests.exceptions.Timeout:
|
||||||
|
print("Server sync timeout")
|
||||||
|
self.error_count += 1
|
||||||
|
self.last_error = "Request timeout"
|
||||||
|
|
||||||
|
except requests.exceptions.ConnectionError:
|
||||||
|
print("Server sync connection error")
|
||||||
|
self.error_count += 1
|
||||||
|
self.last_error = "Connection error"
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Server sync error: {e}")
|
||||||
|
self.error_count += 1
|
||||||
|
self.last_error = str(e)
|
||||||
|
|
||||||
|
def get_stats(self) -> dict:
|
||||||
|
"""Get sync statistics."""
|
||||||
|
return {
|
||||||
|
'sent': self.sent_count,
|
||||||
|
'errors': self.error_count,
|
||||||
|
'last_error': self.last_error,
|
||||||
|
'queue_size': self.send_queue.qsize()
|
||||||
|
}
|
||||||
|
|
||||||
|
def is_healthy(self) -> bool:
|
||||||
|
"""Check if sync is working (no recent errors)."""
|
||||||
|
# Consider healthy if less than 10% error rate
|
||||||
|
total = self.sent_count + self.error_count
|
||||||
|
if total == 0:
|
||||||
|
return True
|
||||||
|
return (self.error_count / total) < 0.1
|
||||||
@@ -25,8 +25,9 @@ processing:
|
|||||||
|
|
||||||
server_sync:
|
server_sync:
|
||||||
enabled: false
|
enabled: false
|
||||||
url: "ws://localhost:8000"
|
url: "http://localhost/transcription/server.php"
|
||||||
api_key: ""
|
room: "default"
|
||||||
|
passphrase: ""
|
||||||
|
|
||||||
display:
|
display:
|
||||||
show_timestamps: true
|
show_timestamps: true
|
||||||
|
|||||||
@@ -147,6 +147,29 @@ class SettingsDialog(QDialog):
|
|||||||
display_group.setLayout(display_layout)
|
display_group.setLayout(display_layout)
|
||||||
main_layout.addWidget(display_group)
|
main_layout.addWidget(display_group)
|
||||||
|
|
||||||
|
# Server Sync Group
|
||||||
|
server_group = QGroupBox("Multi-User Server Sync (Optional)")
|
||||||
|
server_layout = QFormLayout()
|
||||||
|
|
||||||
|
self.server_enabled_check = QCheckBox()
|
||||||
|
server_layout.addRow("Enable Server Sync:", self.server_enabled_check)
|
||||||
|
|
||||||
|
self.server_url_input = QLineEdit()
|
||||||
|
self.server_url_input.setPlaceholderText("http://example.com/transcription/server.php")
|
||||||
|
server_layout.addRow("Server URL:", self.server_url_input)
|
||||||
|
|
||||||
|
self.server_room_input = QLineEdit()
|
||||||
|
self.server_room_input.setPlaceholderText("my-room-name")
|
||||||
|
server_layout.addRow("Room Name:", self.server_room_input)
|
||||||
|
|
||||||
|
self.server_passphrase_input = QLineEdit()
|
||||||
|
self.server_passphrase_input.setEchoMode(QLineEdit.Password)
|
||||||
|
self.server_passphrase_input.setPlaceholderText("shared-secret")
|
||||||
|
server_layout.addRow("Passphrase:", self.server_passphrase_input)
|
||||||
|
|
||||||
|
server_group.setLayout(server_layout)
|
||||||
|
main_layout.addWidget(server_group)
|
||||||
|
|
||||||
# Buttons
|
# Buttons
|
||||||
button_layout = QHBoxLayout()
|
button_layout = QHBoxLayout()
|
||||||
button_layout.addStretch()
|
button_layout.addStretch()
|
||||||
@@ -210,6 +233,12 @@ class SettingsDialog(QDialog):
|
|||||||
self.font_size_input.setText(str(self.config.get('display.font_size', 12)))
|
self.font_size_input.setText(str(self.config.get('display.font_size', 12)))
|
||||||
self.fade_seconds_input.setText(str(self.config.get('display.fade_after_seconds', 10)))
|
self.fade_seconds_input.setText(str(self.config.get('display.fade_after_seconds', 10)))
|
||||||
|
|
||||||
|
# Server sync settings
|
||||||
|
self.server_enabled_check.setChecked(self.config.get('server_sync.enabled', False))
|
||||||
|
self.server_url_input.setText(self.config.get('server_sync.url', ''))
|
||||||
|
self.server_room_input.setText(self.config.get('server_sync.room', 'default'))
|
||||||
|
self.server_passphrase_input.setText(self.config.get('server_sync.passphrase', ''))
|
||||||
|
|
||||||
def _save_settings(self):
|
def _save_settings(self):
|
||||||
"""Save settings to config."""
|
"""Save settings to config."""
|
||||||
try:
|
try:
|
||||||
@@ -248,6 +277,12 @@ class SettingsDialog(QDialog):
|
|||||||
fade_seconds = int(self.fade_seconds_input.text())
|
fade_seconds = int(self.fade_seconds_input.text())
|
||||||
self.config.set('display.fade_after_seconds', fade_seconds)
|
self.config.set('display.fade_after_seconds', fade_seconds)
|
||||||
|
|
||||||
|
# Server sync settings
|
||||||
|
self.config.set('server_sync.enabled', self.server_enabled_check.isChecked())
|
||||||
|
self.config.set('server_sync.url', self.server_url_input.text())
|
||||||
|
self.config.set('server_sync.room', self.server_room_input.text())
|
||||||
|
self.config.set('server_sync.passphrase', self.server_passphrase_input.text())
|
||||||
|
|
||||||
# Call save callback
|
# Call save callback
|
||||||
if self.on_save:
|
if self.on_save:
|
||||||
self.on_save()
|
self.on_save()
|
||||||
|
|||||||
31
server/php/.htaccess
Normal file
31
server/php/.htaccess
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
# Security settings for Multi-User Transcription Server
|
||||||
|
|
||||||
|
# Deny access to data directory
|
||||||
|
<DirectoryMatch "^.*/data/.*$">
|
||||||
|
Require all denied
|
||||||
|
</DirectoryMatch>
|
||||||
|
|
||||||
|
# Deny access to config file directly (if accessed via URL)
|
||||||
|
<Files "config.php">
|
||||||
|
Require all denied
|
||||||
|
</Files>
|
||||||
|
|
||||||
|
# Enable PHP error logging (disable display for production)
|
||||||
|
php_flag display_errors Off
|
||||||
|
php_flag log_errors On
|
||||||
|
|
||||||
|
# Set upload limits
|
||||||
|
php_value upload_max_filesize 1M
|
||||||
|
php_value post_max_size 1M
|
||||||
|
|
||||||
|
# Disable directory listing
|
||||||
|
Options -Indexes
|
||||||
|
|
||||||
|
# Enable compression
|
||||||
|
<IfModule mod_deflate.c>
|
||||||
|
AddOutputFilterByType DEFLATE text/html text/plain text/xml text/css text/javascript application/json
|
||||||
|
</IfModule>
|
||||||
|
|
||||||
|
# Set MIME types
|
||||||
|
AddType application/json .json
|
||||||
|
AddType text/event-stream .php
|
||||||
250
server/php/README.md
Normal file
250
server/php/README.md
Normal file
@@ -0,0 +1,250 @@
|
|||||||
|
# Multi-User Transcription Server (PHP)
|
||||||
|
|
||||||
|
A simple PHP server that allows multiple Local Transcription clients to merge their captions into a single stream. Perfect for multiple streamers playing together who want synchronized captions.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- ✅ Room-based isolation (multiple groups can use the same server)
|
||||||
|
- ✅ Passphrase authentication per room
|
||||||
|
- ✅ Real-time streaming via Server-Sent Events (SSE)
|
||||||
|
- ✅ Different colors for each user
|
||||||
|
- ✅ Auto-fade transcriptions
|
||||||
|
- ✅ Works on standard PHP hosting (no special requirements)
|
||||||
|
- ✅ File-based storage (no database needed)
|
||||||
|
- ✅ Automatic cleanup of old rooms
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
- PHP 7.4 or higher
|
||||||
|
- Web server (Apache/Nginx)
|
||||||
|
- Writable data directory
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
### 1. Upload Files
|
||||||
|
|
||||||
|
Upload these files to your web server:
|
||||||
|
```
|
||||||
|
your-domain.com/
|
||||||
|
└── transcription/
|
||||||
|
├── server.php
|
||||||
|
├── display.php
|
||||||
|
├── config.php
|
||||||
|
├── .htaccess
|
||||||
|
└── data/ (will be created automatically)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Set Permissions
|
||||||
|
|
||||||
|
Make sure the PHP process can write to the directory:
|
||||||
|
```bash
|
||||||
|
chmod 755 server.php display.php config.php
|
||||||
|
chmod 755 .
|
||||||
|
```
|
||||||
|
|
||||||
|
The `data/` directory will be created automatically with proper permissions.
|
||||||
|
|
||||||
|
### 3. Test Installation
|
||||||
|
|
||||||
|
Visit: `https://your-domain.com/transcription/server.php`
|
||||||
|
|
||||||
|
You should see:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"service": "Local Transcription Multi-User Server",
|
||||||
|
"version": "1.0.0",
|
||||||
|
...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
### For Streamers (Desktop App)
|
||||||
|
|
||||||
|
1. Open the Local Transcription app
|
||||||
|
2. Go to Settings
|
||||||
|
3. Enable "Server Sync"
|
||||||
|
4. Enter:
|
||||||
|
- **Server URL**: `https://your-domain.com/transcription/server.php`
|
||||||
|
- **Room Name**: Choose a unique name (e.g., "gaming-session-123")
|
||||||
|
- **Passphrase**: A shared secret for your group (e.g., "mysecretpass")
|
||||||
|
5. Start transcription
|
||||||
|
|
||||||
|
### For OBS (Browser Source)
|
||||||
|
|
||||||
|
1. Add a "Browser" source in OBS
|
||||||
|
2. Set URL to:
|
||||||
|
```
|
||||||
|
https://your-domain.com/transcription/display.php?room=ROOM&passphrase=PASS&fade=10×tamps=true
|
||||||
|
```
|
||||||
|
|
||||||
|
Replace:
|
||||||
|
- `ROOM` = Your room name
|
||||||
|
- `PASS` = Your passphrase
|
||||||
|
- `fade=10` = Seconds before text fades (0 = never)
|
||||||
|
- `timestamps=true` = Show timestamps (false to hide)
|
||||||
|
|
||||||
|
3. Set width/height as desired (e.g., 1920x300)
|
||||||
|
4. Check "Shutdown source when not visible" (optional)
|
||||||
|
|
||||||
|
## API Endpoints
|
||||||
|
|
||||||
|
### Send Transcription
|
||||||
|
```http
|
||||||
|
POST /server.php?action=send
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"room": "my-room",
|
||||||
|
"passphrase": "my-secret",
|
||||||
|
"user_name": "Alice",
|
||||||
|
"text": "Hello everyone!",
|
||||||
|
"timestamp": "12:34:56"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Stream Transcriptions (SSE)
|
||||||
|
```http
|
||||||
|
GET /server.php?action=stream&room=my-room&passphrase=my-secret
|
||||||
|
```
|
||||||
|
|
||||||
|
Returns Server-Sent Events stream with new transcriptions.
|
||||||
|
|
||||||
|
### List Recent Transcriptions
|
||||||
|
```http
|
||||||
|
GET /server.php?action=list&room=my-room&passphrase=my-secret
|
||||||
|
```
|
||||||
|
|
||||||
|
Returns JSON array of recent transcriptions.
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
Edit `config.php` to customize:
|
||||||
|
|
||||||
|
```php
|
||||||
|
// Session lifetime (seconds)
|
||||||
|
define('SESSION_LIFETIME', 3600);
|
||||||
|
|
||||||
|
// Max transcriptions stored per room
|
||||||
|
define('MAX_TRANSCRIPTIONS_PER_ROOM', 100);
|
||||||
|
|
||||||
|
// Storage directory
|
||||||
|
define('STORAGE_DIR', __DIR__ . '/data');
|
||||||
|
|
||||||
|
// Enable CORS
|
||||||
|
define('ENABLE_CORS', true);
|
||||||
|
|
||||||
|
// Cleanup threshold (seconds)
|
||||||
|
define('CLEANUP_THRESHOLD', 7200);
|
||||||
|
```
|
||||||
|
|
||||||
|
## Security
|
||||||
|
|
||||||
|
### Passphrases
|
||||||
|
- Each room is protected by a passphrase
|
||||||
|
- Passphrases are hashed using PHP's `password_hash()`
|
||||||
|
- The first person to create a room sets its passphrase
|
||||||
|
- All subsequent users must use the same passphrase
|
||||||
|
|
||||||
|
### Best Practices
|
||||||
|
1. Use strong passphrases (e.g., `MyStream2024!SecurePass`)
|
||||||
|
2. Don't share passphrases publicly
|
||||||
|
3. Use unique room names (e.g., include date/time)
|
||||||
|
4. Enable HTTPS on your server
|
||||||
|
5. Regularly update PHP
|
||||||
|
|
||||||
|
### Data Storage
|
||||||
|
- Room data is stored in `data/room_HASH.json`
|
||||||
|
- Files are automatically cleaned up after 2 hours of inactivity
|
||||||
|
- No personally identifiable information is logged
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### "Invalid passphrase" error
|
||||||
|
- Make sure all clients use the exact same passphrase
|
||||||
|
- Passphrases are case-sensitive
|
||||||
|
- First user to join creates the room and sets the passphrase
|
||||||
|
|
||||||
|
### Transcriptions not appearing
|
||||||
|
- Check browser console for errors
|
||||||
|
- Verify Server-Sent Events (SSE) is supported
|
||||||
|
- Check that the room name and passphrase match
|
||||||
|
|
||||||
|
### "Permission denied" on data directory
|
||||||
|
```bash
|
||||||
|
chmod 755 /path/to/transcription
|
||||||
|
# Data directory will be created automatically
|
||||||
|
```
|
||||||
|
|
||||||
|
### Server disconnects frequently
|
||||||
|
- Increase PHP's `max_execution_time` for SSE:
|
||||||
|
```php
|
||||||
|
set_time_limit(0);
|
||||||
|
```
|
||||||
|
- Check server timeout settings (Apache/Nginx)
|
||||||
|
|
||||||
|
## Advanced Usage
|
||||||
|
|
||||||
|
### Multiple Rooms on Same Server
|
||||||
|
Each room is completely isolated. Example:
|
||||||
|
|
||||||
|
- Room "podcast-team-1" with passphrase "secret1"
|
||||||
|
- Room "gaming-squad-2" with passphrase "secret2"
|
||||||
|
|
||||||
|
They don't interfere with each other.
|
||||||
|
|
||||||
|
### Customizing Display
|
||||||
|
|
||||||
|
Add URL parameters to `display.php`:
|
||||||
|
- `?fade=20` - Fade after 20 seconds
|
||||||
|
- `?fade=0` - Never fade
|
||||||
|
- `?timestamps=false` - Hide timestamps
|
||||||
|
- `?font=Arial` - Change font (future feature)
|
||||||
|
|
||||||
|
### Using with Shared Hosting
|
||||||
|
|
||||||
|
This works on most shared hosting providers:
|
||||||
|
- No database required
|
||||||
|
- No special PHP extensions needed
|
||||||
|
- Uses standard PHP file operations
|
||||||
|
- Compatible with Apache .htaccess
|
||||||
|
|
||||||
|
### Upgrading to Redis/MySQL
|
||||||
|
|
||||||
|
For high-traffic scenarios, replace file storage in `server.php`:
|
||||||
|
|
||||||
|
```php
|
||||||
|
// Instead of file_put_contents()
|
||||||
|
// Use Redis:
|
||||||
|
$redis->set("room:$room", json_encode($roomData));
|
||||||
|
|
||||||
|
// Or MySQL:
|
||||||
|
$pdo->prepare("INSERT INTO rooms ...")->execute(...);
|
||||||
|
```
|
||||||
|
|
||||||
|
## Performance
|
||||||
|
|
||||||
|
- **Tested**: 10 concurrent clients per room
|
||||||
|
- **Latency**: < 2 seconds
|
||||||
|
- **Storage**: ~1KB per transcription
|
||||||
|
- **Bandwidth**: Minimal (text-only)
|
||||||
|
|
||||||
|
## Limitations
|
||||||
|
|
||||||
|
- File-based storage (not suitable for very high traffic)
|
||||||
|
- Server-Sent Events may not work with some proxies
|
||||||
|
- Rooms expire after 2 hours of inactivity
|
||||||
|
- No user management or admin panel (by design)
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
Part of the Local Transcription project.
|
||||||
|
Generated with Claude Code.
|
||||||
|
|
||||||
|
## Support
|
||||||
|
|
||||||
|
For issues or questions:
|
||||||
|
1. Check this README
|
||||||
|
2. Review server logs
|
||||||
|
3. Test with browser's Network tab
|
||||||
|
4. Create an issue on GitHub
|
||||||
42
server/php/config.php
Normal file
42
server/php/config.php
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Multi-User Transcription Server - Configuration
|
||||||
|
*
|
||||||
|
* Simple PHP server for merging transcriptions from multiple clients
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Session configuration
|
||||||
|
define('SESSION_LIFETIME', 3600); // 1 hour
|
||||||
|
define('MAX_TRANSCRIPTIONS_PER_ROOM', 100);
|
||||||
|
|
||||||
|
// Storage directory (must be writable by PHP)
|
||||||
|
define('STORAGE_DIR', __DIR__ . '/data');
|
||||||
|
|
||||||
|
// Enable CORS for cross-origin requests (if needed)
|
||||||
|
define('ENABLE_CORS', true);
|
||||||
|
|
||||||
|
// Cleanup old sessions older than this (seconds)
|
||||||
|
define('CLEANUP_THRESHOLD', 7200); // 2 hours
|
||||||
|
|
||||||
|
// Initialize storage directory
|
||||||
|
if (!file_exists(STORAGE_DIR)) {
|
||||||
|
mkdir(STORAGE_DIR, 0755, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Security headers
|
||||||
|
header('X-Content-Type-Options: nosniff');
|
||||||
|
header('X-Frame-Options: SAMEORIGIN');
|
||||||
|
|
||||||
|
// CORS headers (if enabled)
|
||||||
|
if (ENABLE_CORS) {
|
||||||
|
header('Access-Control-Allow-Origin: *');
|
||||||
|
header('Access-Control-Allow-Methods: GET, POST, OPTIONS');
|
||||||
|
header('Access-Control-Allow-Headers: Content-Type');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle preflight requests
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
|
||||||
|
http_response_code(200);
|
||||||
|
exit();
|
||||||
|
}
|
||||||
|
?>
|
||||||
180
server/php/display.php
Normal file
180
server/php/display.php
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>Multi-User Transcription Display</title>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 20px;
|
||||||
|
background: transparent;
|
||||||
|
font-family: Arial, sans-serif;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
#transcriptions {
|
||||||
|
max-height: 100vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
.transcription {
|
||||||
|
margin: 10px 0;
|
||||||
|
padding: 10px;
|
||||||
|
background: rgba(0, 0, 0, 0.7);
|
||||||
|
border-radius: 5px;
|
||||||
|
animation: slideIn 0.3s ease-out;
|
||||||
|
transition: opacity 1s ease-out;
|
||||||
|
}
|
||||||
|
.transcription.fading {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
.timestamp {
|
||||||
|
color: #888;
|
||||||
|
font-size: 0.9em;
|
||||||
|
margin-right: 10px;
|
||||||
|
}
|
||||||
|
.user {
|
||||||
|
font-weight: bold;
|
||||||
|
margin-right: 10px;
|
||||||
|
}
|
||||||
|
/* Different colors for different users */
|
||||||
|
.user-0 { color: #4CAF50; }
|
||||||
|
.user-1 { color: #2196F3; }
|
||||||
|
.user-2 { color: #FF9800; }
|
||||||
|
.user-3 { color: #E91E63; }
|
||||||
|
.user-4 { color: #9C27B0; }
|
||||||
|
.user-5 { color: #00BCD4; }
|
||||||
|
.text {
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
#status {
|
||||||
|
position: fixed;
|
||||||
|
top: 10px;
|
||||||
|
right: 10px;
|
||||||
|
padding: 10px;
|
||||||
|
background: rgba(0, 0, 0, 0.8);
|
||||||
|
border-radius: 5px;
|
||||||
|
font-size: 0.9em;
|
||||||
|
}
|
||||||
|
#status.connected { color: #4CAF50; }
|
||||||
|
#status.disconnected { color: #f44336; }
|
||||||
|
@keyframes slideIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-10px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="status" class="disconnected">⚫ Connecting...</div>
|
||||||
|
<div id="transcriptions"></div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Get URL parameters
|
||||||
|
const urlParams = new URLSearchParams(window.location.search);
|
||||||
|
const room = urlParams.get('room') || 'default';
|
||||||
|
const passphrase = urlParams.get('passphrase') || '';
|
||||||
|
const fadeAfter = parseInt(urlParams.get('fade') || '10');
|
||||||
|
const showTimestamps = urlParams.get('timestamps') !== 'false';
|
||||||
|
|
||||||
|
const container = document.getElementById('transcriptions');
|
||||||
|
const statusEl = document.getElementById('status');
|
||||||
|
const userColors = new Map(); // Map user names to color indices
|
||||||
|
let colorIndex = 0;
|
||||||
|
|
||||||
|
// Connect to Server-Sent Events
|
||||||
|
function connect() {
|
||||||
|
const url = `server.php?action=stream&room=${encodeURIComponent(room)}&passphrase=${encodeURIComponent(passphrase)}`;
|
||||||
|
const eventSource = new EventSource(url);
|
||||||
|
|
||||||
|
eventSource.onopen = () => {
|
||||||
|
statusEl.textContent = '🟢 Connected';
|
||||||
|
statusEl.className = 'connected';
|
||||||
|
};
|
||||||
|
|
||||||
|
eventSource.onmessage = (event) => {
|
||||||
|
const data = JSON.parse(event.data);
|
||||||
|
addTranscription(data);
|
||||||
|
};
|
||||||
|
|
||||||
|
eventSource.onerror = () => {
|
||||||
|
statusEl.textContent = '🔴 Disconnected';
|
||||||
|
statusEl.className = 'disconnected';
|
||||||
|
eventSource.close();
|
||||||
|
|
||||||
|
// Reconnect after 3 seconds
|
||||||
|
setTimeout(connect, 3000);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function addTranscription(data) {
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.className = 'transcription';
|
||||||
|
|
||||||
|
// Assign color to user
|
||||||
|
if (!userColors.has(data.user_name)) {
|
||||||
|
userColors.set(data.user_name, colorIndex % 6);
|
||||||
|
colorIndex++;
|
||||||
|
}
|
||||||
|
const userColorClass = `user-${userColors.get(data.user_name)}`;
|
||||||
|
|
||||||
|
let html = '';
|
||||||
|
if (showTimestamps && data.timestamp) {
|
||||||
|
html += `<span class="timestamp">[${data.timestamp}]</span>`;
|
||||||
|
}
|
||||||
|
if (data.user_name) {
|
||||||
|
html += `<span class="user ${userColorClass}">${data.user_name}:</span>`;
|
||||||
|
}
|
||||||
|
html += `<span class="text">${data.text}</span>`;
|
||||||
|
|
||||||
|
div.innerHTML = html;
|
||||||
|
container.appendChild(div);
|
||||||
|
|
||||||
|
// Auto-scroll to bottom
|
||||||
|
container.scrollTop = container.scrollHeight;
|
||||||
|
|
||||||
|
// Set up fade-out if enabled
|
||||||
|
if (fadeAfter > 0) {
|
||||||
|
setTimeout(() => {
|
||||||
|
div.classList.add('fading');
|
||||||
|
setTimeout(() => {
|
||||||
|
if (div.parentNode === container) {
|
||||||
|
container.removeChild(div);
|
||||||
|
}
|
||||||
|
}, 1000);
|
||||||
|
}, fadeAfter * 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Limit to 100 transcriptions
|
||||||
|
while (container.children.length > 100) {
|
||||||
|
container.removeChild(container.firstChild);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load recent transcriptions on startup
|
||||||
|
async function loadRecent() {
|
||||||
|
try {
|
||||||
|
const url = `server.php?action=list&room=${encodeURIComponent(room)}&passphrase=${encodeURIComponent(passphrase)}`;
|
||||||
|
const response = await fetch(url);
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.transcriptions) {
|
||||||
|
// Show last 20 transcriptions
|
||||||
|
data.transcriptions.slice(-20).forEach(addTranscription);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading recent transcriptions:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start
|
||||||
|
loadRecent().then(() => {
|
||||||
|
connect();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
278
server/php/server.php
Normal file
278
server/php/server.php
Normal file
@@ -0,0 +1,278 @@
|
|||||||
|
<?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);
|
||||||
|
}
|
||||||
|
?>
|
||||||
Reference in New Issue
Block a user