added public watch parties with cloudflared

This commit is contained in:
2026-01-04 19:59:37 +01:00
parent d9c1ba3d27
commit 5fe0e319b9
20 changed files with 1426 additions and 458 deletions

View File

@@ -1,20 +1,17 @@
import { FastifyReply, FastifyRequest } from 'fastify';
import * as roomService from './rooms.service';
import { getUserById } from '../user/user.service';
import { openTunnel } from "./tunnel.manager";
interface CreateRoomBody {
name: string;
password?: string;
}
interface JoinRoomBody {
password?: string;
guestName?: string;
expose?: boolean;
}
export async function createRoom(req: any, reply: FastifyReply) {
try {
const { name, password } = req.body as CreateRoomBody;
const { name, password, expose } = req.body as CreateRoomBody;
const userId = req.user?.id;
if (!userId) {
@@ -39,7 +36,23 @@ export async function createRoom(req: any, reply: FastifyReply) {
userId
};
const room = roomService.createRoom(name, host, password);
let publicUrl: string | undefined;
if (expose) {
publicUrl = await openTunnel();
}
const room = roomService.createRoom(
name,
host,
password,
!!expose,
publicUrl
);
if (expose && publicUrl) {
room.publicUrl = `${publicUrl}/room?id=${room.id}`;
}
return reply.send({
success: true,
@@ -47,7 +60,9 @@ export async function createRoom(req: any, reply: FastifyReply) {
id: room.id,
name: room.name,
hasPassword: !!room.password,
userCount: room.users.size
userCount: room.users.size,
exposed: room.exposed,
publicUrl: room.publicUrl
}
});
} catch (err) {
@@ -104,7 +119,9 @@ export async function getRoom(req: FastifyRequest, reply: FastifyReply) {
isGuest: u.isGuest
})),
hasPassword: !!room.password,
currentVideo: room.currentVideo
currentVideo: room.currentVideo,
exposed: room.exposed,
publicUrl: room.publicUrl
}
});
} catch (err) {

View File

@@ -1,4 +1,5 @@
import crypto from 'crypto';
import { closeTunnelIfUnused } from "./tunnel.manager";
interface RoomUser {
id: string;
@@ -33,6 +34,8 @@ interface RoomData {
} | null;
password?: string;
metadata?: RoomMetadata | null;
exposed: boolean;
publicUrl?: string;
}
const rooms = new Map<string, RoomData>();
@@ -41,7 +44,7 @@ export function generateRoomId(): string {
return crypto.randomBytes(8).toString('hex');
}
export function createRoom(name: string, host: RoomUser, password?: string): RoomData {
export function createRoom(name: string, host: RoomUser, password?: string, exposed = false, publicUrl?: string): RoomData {
const roomId = generateRoomId();
const room: RoomData = {
@@ -53,6 +56,8 @@ export function createRoom(name: string, host: RoomUser, password?: string): Roo
currentVideo: null,
password: password || undefined,
metadata: null,
exposed,
publicUrl
};
rooms.set(roomId, room);
@@ -84,13 +89,14 @@ export function removeUserFromRoom(roomId: string, userId: string): boolean {
room.users.delete(userId);
// Si no quedan usuarios, eliminar la sala
if (room.users.size === 0) {
if (room.exposed) {
closeTunnelIfUnused();
}
rooms.delete(roomId);
return true;
}
// Si era el host, asignar nuevo host
if (room.host.id === userId && room.users.size > 0) {
const newHost = Array.from(room.users.values())[0];
newHost.isHost = true;
@@ -109,6 +115,13 @@ export function updateRoomVideo(roomId: string, videoData: any): boolean {
}
export function deleteRoom(roomId: string): boolean {
const room = rooms.get(roomId);
if (!room) return false;
if (room.exposed) {
closeTunnelIfUnused();
}
return rooms.delete(roomId);
}

View File

@@ -226,6 +226,34 @@ function handleMessage(roomId: string, userId: string, data: any) {
});
break;
case 'request_sync':
// Cualquier usuario puede pedir sync
const host = clients.get(room.host.id);
if (host && host.socket && host.socket.readyState === 1) {
console.log(`[Sync Request] User ${userId} requested sync from host`);
host.socket.send(JSON.stringify({
type: 'sync_requested',
requestedBy: userId,
username: room.users.get(userId)?.username
}));
} else {
console.warn(`[Sync Request] Host not available for user ${userId}`);
if (room.currentVideo) {
const client = clients.get(userId);
if (client && client.socket && client.socket.readyState === 1) {
console.log(`[Sync Request] Sending cached video state to ${userId}`);
client.socket.send(JSON.stringify({
type: 'sync',
currentTime: room.currentVideo.currentTime || 0,
isPlaying: room.currentVideo.isPlaying || false
}));
}
}
}
break;
case 'video_update':
if (room.host.id !== userId) return;
@@ -288,11 +316,10 @@ function handleMessage(roomId: string, userId: string, data: any) {
type: 'play',
currentTime: data.currentTime,
timestamp: Date.now()
}, userId); // IMPORTANTE: excludeUserId para no enviar al host
}, userId);
break;
case 'pause':
// Solo el host puede controlar la reproducción
if (room.host.id !== userId) {
console.warn('Non-host attempted pause:', userId);
return;
@@ -303,11 +330,10 @@ function handleMessage(roomId: string, userId: string, data: any) {
type: 'pause',
currentTime: data.currentTime,
timestamp: Date.now()
}, userId); // IMPORTANTE: excludeUserId para no enviar al host
}, userId);
break;
case 'seek':
// Solo el host puede controlar la reproducción
if (room.host.id !== userId) {
console.warn('Non-host attempted seek:', userId);
return;
@@ -318,22 +344,10 @@ function handleMessage(roomId: string, userId: string, data: any) {
type: 'seek',
currentTime: data.currentTime,
timestamp: Date.now()
}, userId); // IMPORTANTE: excludeUserId para no enviar al host
break;
case 'request_sync':
// Cualquier usuario puede pedir sync
const host = clients.get(room.host.id);
if (host && host.socket && host.socket.readyState === 1) {
host.socket.send(JSON.stringify({
type: 'sync_requested',
requestedBy: userId
}));
}
}, userId);
break;
case 'request_state':
// Enviar estado actual de la sala al usuario que lo solicita
const client = clients.get(userId);
if (client && client.socket && client.socket.readyState === 1) {
const updatedRoom = roomService.getRoom(roomId);

View File

@@ -0,0 +1,111 @@
import { spawn, ChildProcess } from "child_process";
import { getConfig as loadConfig } from '../../shared/config';
const { values } = loadConfig();
const CLOUDFLARED_PATH = values.paths?.cloudflared || 'cloudflared';
let tunnelProcess: ChildProcess | null = null;
let exposedRooms = 0;
let publicUrl: string | null = null;
let tunnelPromise: Promise<string> | null = null;
export function openTunnel(): Promise<string> {
if (tunnelProcess && publicUrl) {
exposedRooms++;
return Promise.resolve(publicUrl);
}
if (tunnelPromise) {
return tunnelPromise;
}
tunnelPromise = new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
cleanup();
reject(new Error("Timeout esperando URL del túnel (30s)"));
}, 30000);
tunnelProcess = spawn(CLOUDFLARED_PATH, [
"tunnel",
"--url",
"http://localhost:54322",
"--no-autoupdate"
]);
const cleanup = () => {
clearTimeout(timeout);
tunnelPromise = null;
};
let outputBuffer = "";
const processOutput = (data: Buffer) => {
const text = data.toString();
outputBuffer += text;
const match = outputBuffer.match(/https:\/\/[a-z0-9-]+\.trycloudflare\.com/);
if (match && !publicUrl) {
publicUrl = match[0];
exposedRooms = 1;
cleanup();
resolve(publicUrl);
}
};
tunnelProcess.stdout?.on("data", (data) => {
processOutput(data);
});
tunnelProcess.stderr?.on("data", (data) => {
processOutput(data);
});
tunnelProcess.on("error", (error) => {
console.error("[Cloudflared Process Error]", error);
cleanup();
tunnelProcess = null;
reject(error);
});
tunnelProcess.on("exit", (code, signal) => {
tunnelProcess = null;
publicUrl = null;
exposedRooms = 0;
if (!publicUrl) {
cleanup();
reject(new Error(`Proceso cloudflared terminó antes de obtener URL (código: ${code})`));
}
});
});
return tunnelPromise;
}
export function closeTunnelIfUnused() {
exposedRooms--;
console.log(`[Tunnel Manager] Rooms expuestas: ${exposedRooms}`);
if (exposedRooms <= 0 && tunnelProcess) {
console.log("[Tunnel Manager] Cerrando túnel...");
tunnelProcess.kill();
tunnelProcess = null;
publicUrl = null;
exposedRooms = 0;
tunnelPromise = null;
}
}
export function getTunnelUrl(): string | null {
return publicUrl;
}
export function forceTunnelClose() {
if (tunnelProcess) {
console.log("[Tunnel Manager] Forzando cierre del túnel...");
tunnelProcess.kill();
tunnelProcess = null;
publicUrl = null;
exposedRooms = 0;
tunnelPromise = null;
}
}