added watchparties

This commit is contained in:
2026-01-04 16:49:59 +01:00
parent cde09c6ffa
commit d9c1ba3d27
48 changed files with 7585 additions and 167 deletions

View File

@@ -0,0 +1,141 @@
import { FastifyReply, FastifyRequest } from 'fastify';
import * as roomService from './rooms.service';
import { getUserById } from '../user/user.service';
interface CreateRoomBody {
name: string;
password?: string;
}
interface JoinRoomBody {
password?: string;
guestName?: string;
}
export async function createRoom(req: any, reply: FastifyReply) {
try {
const { name, password } = req.body as CreateRoomBody;
const userId = req.user?.id;
if (!userId) {
return reply.code(401).send({ error: "Authentication required to create room" });
}
if (!name || name.trim().length === 0) {
return reply.code(400).send({ error: "Room name is required" });
}
const user = await getUserById(userId);
if (!user) {
return reply.code(404).send({ error: "User not found" });
}
const host = {
id: `user_${userId}`,
username: user.username,
avatar: user.profile_picture_url || undefined,
isHost: true,
isGuest: false,
userId
};
const room = roomService.createRoom(name, host, password);
return reply.send({
success: true,
room: {
id: room.id,
name: room.name,
hasPassword: !!room.password,
userCount: room.users.size
}
});
} catch (err) {
console.error("Create Room Error:", err);
return reply.code(500).send({ error: "Failed to create room" });
}
}
export async function getRooms(req: FastifyRequest, reply: FastifyReply) {
try {
const rooms = roomService.getAllRooms();
const roomList = rooms.map((room) => ({
id: room.id,
name: room.name,
host: room.host.username,
userCount: room.users.size,
hasPassword: !!room.password,
currentlyWatching: room.currentVideo ? {
animeId: room.currentVideo.animeId,
episode: room.currentVideo.episode
} : null
}));
return reply.send({ rooms: roomList });
} catch (err) {
console.error("Get Rooms Error:", err);
return reply.code(500).send({ error: "Failed to retrieve rooms" });
}
}
export async function getRoom(req: FastifyRequest, reply: FastifyReply) {
try {
const { id } = req.params as { id: string };
const room = roomService.getRoom(id);
if (!room) {
return reply.code(404).send({ error: "Room not found" });
}
return reply.send({
room: {
id: room.id,
name: room.name,
host: {
username: room.host.username,
avatar: room.host.avatar
},
users: Array.from(room.users.values()).map(u => ({
id: u.id,
username: u.username,
avatar: u.avatar,
isHost: u.isHost,
isGuest: u.isGuest
})),
hasPassword: !!room.password,
currentVideo: room.currentVideo
}
});
} catch (err) {
console.error("Get Room Error:", err);
return reply.code(500).send({ error: "Failed to retrieve room" });
}
}
export async function deleteRoom(req: any, reply: FastifyReply) {
try {
const { id } = req.params as { id: string };
const userId = req.user?.id;
if (!userId) {
return reply.code(401).send({ error: "Authentication required" });
}
const room = roomService.getRoom(id);
if (!room) {
return reply.code(404).send({ error: "Room not found" });
}
if (room.host.userId !== userId) {
return reply.code(403).send({ error: "Only the host can delete the room" });
}
roomService.deleteRoom(id);
return reply.send({ success: true });
} catch (err) {
console.error("Delete Room Error:", err);
return reply.code(500).send({ error: "Failed to delete room" });
}
}

View File

@@ -0,0 +1,11 @@
import { FastifyInstance } from 'fastify';
import * as controller from './rooms.controller';
async function roomRoutes(fastify: FastifyInstance) {
fastify.post('/rooms', controller.createRoom);
fastify.get('/rooms', controller.getRooms);
fastify.get('/rooms/:id', controller.getRoom);
fastify.delete('/rooms/:id', controller.deleteRoom);
}
export default roomRoutes;

View File

@@ -0,0 +1,130 @@
import crypto from 'crypto';
interface RoomUser {
id: string;
username: string;
avatar?: string;
isHost: boolean;
isGuest: boolean;
userId?: number; // ID real del usuario si está logueado
}
interface RoomMetadata {
id: string;
title: string;
episode: number;
image?: string;
source?: string;
}
interface RoomData {
id: string;
name: string;
host: RoomUser;
users: Map<string, RoomUser>;
createdAt: number;
currentVideo: {
animeId?: number;
episode?: number;
source?: string;
videoData?: any;
currentTime: number;
isPlaying: boolean;
} | null;
password?: string;
metadata?: RoomMetadata | null;
}
const rooms = new Map<string, RoomData>();
export function generateRoomId(): string {
return crypto.randomBytes(8).toString('hex');
}
export function createRoom(name: string, host: RoomUser, password?: string): RoomData {
const roomId = generateRoomId();
const room: RoomData = {
id: roomId,
name,
host,
users: new Map([[host.id, host]]),
createdAt: Date.now(),
currentVideo: null,
password: password || undefined,
metadata: null,
};
rooms.set(roomId, room);
return room;
}
export function getRoom(roomId: string): RoomData | null {
return rooms.get(roomId) || null;
}
export function getAllRooms(): RoomData[] {
return Array.from(rooms.values()).map(room => ({
...room,
users: room.users
}));
}
export function addUserToRoom(roomId: string, user: RoomUser): boolean {
const room = rooms.get(roomId);
if (!room) return false;
room.users.set(user.id, user);
return true;
}
export function removeUserFromRoom(roomId: string, userId: string): boolean {
const room = rooms.get(roomId);
if (!room) return false;
room.users.delete(userId);
// Si no quedan usuarios, eliminar la sala
if (room.users.size === 0) {
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;
room.host = newHost;
}
return true;
}
export function updateRoomVideo(roomId: string, videoData: any): boolean {
const room = rooms.get(roomId);
if (!room) return false;
room.currentVideo = videoData;
return true;
}
export function deleteRoom(roomId: string): boolean {
return rooms.delete(roomId);
}
export function verifyRoomPassword(roomId: string, password?: string): boolean {
const room = rooms.get(roomId);
if (!room) return false;
if (!room.password) return true;
if (!password) return false;
return room.password === password;
}
export function updateRoomMetadata(roomId: string, metadata: any): boolean {
const room = rooms.get(roomId);
if (!room) return false;
room.metadata = metadata;
return true;
}

View File

@@ -0,0 +1,397 @@
import { FastifyInstance, FastifyRequest } from 'fastify';
import * as roomService from './rooms.service';
import { getUserById } from '../user/user.service';
import jwt from 'jsonwebtoken';
interface WSClient {
socket: any;
userId: string;
username: string;
roomId: string;
isGuest: boolean;
}
const clients = new Map<string, WSClient>();
interface WSParams {
roomId: string;
}
interface WSQuery {
token?: string;
guestName?: string;
password?: string;
}
export function setupRoomWebSocket(fastify: FastifyInstance) {
// @ts-ignore
fastify.get('/ws/room/:roomId', { websocket: true }, (connection: any, req: any) => {
handleWebSocketConnection(connection, req).catch(err => {
console.error('WebSocket error:', err);
try {
connection.socket.close();
} catch (e) {
// Socket already closed
}
});
});
}
async function handleWebSocketConnection(connection: any, req: any) {
const socket = connection.socket || connection;
const roomId = req.params.roomId;
const token = req.query.token;
const guestName = req.query.guestName;
const password = req.query.password;
let userId: string;
let username: string;
let avatar: string | undefined;
let isGuest = false;
let realUserId: any;
// Verificar si la sala existe
const room = roomService.getRoom(roomId);
if (!room) {
socket.send(JSON.stringify({
type: 'error',
message: 'Room not found'
}));
socket.close();
return;
}
// Verificar contraseña si existe
if (room.password) {
if (!password || !roomService.verifyRoomPassword(roomId, password)) {
socket.send(JSON.stringify({
type: 'error',
message: 'Invalid password'
}));
socket.close();
return;
}
}
// Autenticar usuario o crear invitado
if (token) {
try {
const decoded: any = jwt.verify(token, process.env.JWT_SECRET!);
realUserId = decoded.id;
const user = await getUserById(realUserId);
if (user) {
userId = `user_${realUserId}`;
username = user.username;
avatar = user.profile_picture_url || undefined;
isGuest = false;
} else {
throw new Error('User not found');
}
} catch (err) {
socket.send(JSON.stringify({
type: 'error',
message: 'Invalid token'
}));
socket.close();
return;
}
} else if (guestName && guestName.trim()) {
const nameToCheck = guestName.trim();
const isNameTaken = Array.from(room.users.values()).some(
u => u.username.toLowerCase() === nameToCheck.toLowerCase()
);
if (isNameTaken) {
socket.send(JSON.stringify({
type: 'error',
message: 'Username is already taken'
}));
socket.close();
return;
}
userId = `guest_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
username = nameToCheck;
isGuest = true;
} else {
socket.send(JSON.stringify({
type: 'error',
message: 'Authentication required'
}));
socket.close();
return;
}
const isHost = room.host.userId === realUserId || room.host.id === userId;
console.log('WebSocket Connection:', {
userId,
realUserId,
roomHostId: room.host.id,
roomHostUserId: room.host.userId,
isHost
});
// Agregar usuario a la sala
const userInRoom = {
id: userId,
username,
avatar,
isHost: isHost, // ← CORREGIDO: Usar la verificación correcta
isGuest,
userId: realUserId
};
roomService.addUserToRoom(roomId, userInRoom);
// Registrar cliente
clients.set(userId, {
socket: socket,
userId,
username,
roomId,
isGuest
});
// Enviar estado inicial
socket.send(JSON.stringify({
type: 'init',
userId,
username,
isGuest,
room: {
id: room.id,
name: room.name,
users: Array.from(room.users.values()).map(u => ({
id: u.id,
username: u.username,
avatar: u.avatar,
isHost: u.isHost,
isGuest: u.isGuest
})),
currentVideo: room.currentVideo
}
}));
// Notificar a otros usuarios
broadcastToRoom(roomId, {
type: 'user_joined',
user: {
id: userId,
username,
avatar,
isGuest
}
}, userId);
// Manejar mensajes
socket.on('message', (message: Buffer) => {
try {
const data = JSON.parse(message.toString());
handleMessage(roomId, userId, data);
} catch (err) {
console.error('WebSocket message error:', err);
}
});
// Manejar desconexión
socket.on('close', () => {
clients.delete(userId);
roomService.removeUserFromRoom(roomId, userId);
broadcastToRoom(roomId, {
type: 'user_left',
user: { userId, username }
});
});
}
function handleMessage(roomId: string, userId: string, data: any) {
const room = roomService.getRoom(roomId);
if (!room) return;
console.log('Handling message:', data.type, 'from user:', userId, 'isHost:', room.host.id === userId);
switch (data.type) {
case 'chat':
broadcastToRoom(roomId, {
type: 'chat',
userId,
username: room.users.get(userId)?.username || 'Unknown',
avatar: room.users.get(userId)?.avatar,
message: data.message,
timestamp: Date.now()
});
break;
case 'video_update':
if (room.host.id !== userId) return;
roomService.updateRoomVideo(roomId, data.video);
roomService.updateRoomMetadata(roomId, data.metadata);
broadcastToRoom(roomId, {
type: 'video_update',
video: data.video,
metadata: data.metadata // ✅ CLAVE
});
break;
case 'sync':
// Solo el host puede hacer sync
if (room.host.id !== userId) return;
if (room.currentVideo) {
room.currentVideo.currentTime = data.currentTime;
room.currentVideo.isPlaying = data.isPlaying;
}
broadcastToRoom(roomId, {
type: 'sync',
currentTime: data.currentTime,
isPlaying: data.isPlaying
}, userId);
break;
case 'request_users':
const currentRoom = roomService.getRoom(roomId);
if (currentRoom) {
const client = clients.get(userId);
if (client && client.socket && client.socket.readyState === 1) {
client.socket.send(JSON.stringify({
type: 'users_update', // Nuevo tipo de respuesta
users: Array.from(currentRoom.users.values()).map(u => ({
id: u.id,
username: u.username,
avatar: u.avatar,
isHost: u.isHost,
isGuest: u.isGuest
}))
}));
}
}
break;
case 'play':
// Solo el host puede controlar la reproducción
if (room.host.id !== userId) {
console.warn('Non-host attempted play:', userId);
return;
}
console.log('Broadcasting play event to room:', roomId);
broadcastToRoom(roomId, {
type: 'play',
currentTime: data.currentTime,
timestamp: Date.now()
}, userId); // IMPORTANTE: excludeUserId para no enviar al host
break;
case 'pause':
// Solo el host puede controlar la reproducción
if (room.host.id !== userId) {
console.warn('Non-host attempted pause:', userId);
return;
}
console.log('Broadcasting pause event to room:', roomId);
broadcastToRoom(roomId, {
type: 'pause',
currentTime: data.currentTime,
timestamp: Date.now()
}, userId); // IMPORTANTE: excludeUserId para no enviar al host
break;
case 'seek':
// Solo el host puede controlar la reproducción
if (room.host.id !== userId) {
console.warn('Non-host attempted seek:', userId);
return;
}
console.log('Broadcasting seek event to room:', roomId);
broadcastToRoom(roomId, {
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
}));
}
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);
if (updatedRoom) {
client.socket.send(JSON.stringify({
type: 'init',
userId,
username: client.username,
isGuest: client.isGuest,
room: {
id: updatedRoom.id,
name: updatedRoom.name,
users: Array.from(updatedRoom.users.values()).map(u => ({
id: u.id,
username: u.username,
avatar: u.avatar,
isHost: u.isHost,
isGuest: u.isGuest
})),
currentVideo: room.currentVideo,
metadata: room.metadata
}
}));
}
}
break;
default:
console.warn('Unknown message type:', data.type);
break;
}
}
function broadcastToRoom(roomId: string, message: any, excludeUserId?: string) {
const room = roomService.getRoom(roomId);
if (!room) return;
const messageStr = JSON.stringify(message);
console.log('Broadcasting to room:', roomId, 'message type:', message.type, 'excluding:', excludeUserId);
let sentCount = 0;
room.users.forEach((user) => {
if (user.id !== excludeUserId) {
const client = clients.get(user.id);
if (client && client.socket && client.socket.readyState === 1) {
try {
client.socket.send(messageStr);
sentCount++;
console.log('Sent to user:', user.id, user.username);
} catch (err) {
console.error('Error sending message to user:', user.id, err);
}
} else {
console.warn('User socket not ready:', user.id, 'readyState:', client?.socket?.readyState);
}
}
});
console.log(`Broadcast complete: sent to ${sentCount} users`);
}