657 lines
27 KiB
JavaScript
657 lines
27 KiB
JavaScript
// server.js - VERSÃO 6 (ACK + Clock Sync + Logs Dinâmicos + Persistência Autoritativa) - HTTPS ENABLED
|
|
|
|
// =========================
|
|
// IMPORTS PRINCIPAIS
|
|
// =========================
|
|
const express = require("express");
|
|
const https = require("https");
|
|
const { Server } = require("socket.io");
|
|
const fs = require("fs");
|
|
const path = require("path");
|
|
const pino = require("pino");
|
|
|
|
const DEFAULT_PROJECT_XML = `<?xml version="1.0"?>
|
|
<!DOCTYPE lmms-project>
|
|
<lmms-project version="1.0" type="song" creatorversion="1.2.2" creator="LMMS">
|
|
<head mastervol="100" masterpitch="0" timesig_denominator="4" timesig_numerator="4" bpm="140"/>
|
|
<song>
|
|
<trackcontainer visible="1" y="5" minimized="0" height="300" type="song" x="5" maximized="0" width="600">
|
|
<track muted="0" name="TripleOscillator" type="0" solo="0">
|
|
<instrumenttrack pitchrange="1" fxch="0" usemasterpitch="1" vol="100" pitch="0" pan="0" basenote="57">
|
|
<instrument name="tripleoscillator">
|
|
<tripleoscillator wavetype1="0" pan2="0" finer1="0" finel1="0" userwavefile0="" wavetype0="0" userwavefile1="" finer0="0" stphdetun1="0" vol0="33" wavetype2="0" coarse0="0" finel2="0" finer2="0" modalgo2="2" finel0="0" coarse1="-12" phoffset1="0" pan0="0" modalgo3="2" coarse2="-24" phoffset2="0" pan1="0" vol1="33" phoffset0="0" stphdetun0="0" modalgo1="2" vol2="33" stphdetun2="0" userwavefile2=""/>
|
|
</instrument>
|
|
<eldata fres="0.5" fwet="0" ftype="0" fcut="14000">
|
|
<elvol ctlenvamt="0" lspd_syncmode="0" x100="0" lpdel="0" latt="0" pdel="0" lspd="0.1" lshp="0" hold="0.5" lspd_numerator="4" userwavefile="" att="0" lamt="0" dec="0.5" lspd_denominator="4" sustain="0.5" rel="0.1" amt="0"/>
|
|
<elcut ctlenvamt="0" lspd_syncmode="0" x100="0" lpdel="0" latt="0" pdel="0" lspd="0.1" lshp="0" hold="0.5" lspd_numerator="4" userwavefile="" att="0" lamt="0" dec="0.5" lspd_denominator="4" sustain="0.5" rel="0.1" amt="0"/>
|
|
<elres ctlenvamt="0" lspd_syncmode="0" x100="0" lpdel="0" latt="0" pdel="0" lspd="0.1" lshp="0" hold="0.5" lspd_numerator="4" userwavefile="" att="0" lamt="0" dec="0.5" lspd_denominator="4" sustain="0.5" rel="0.1" amt="0"/>
|
|
</eldata>
|
|
<chordcreator chordrange="1" chord-enabled="0" chord="0"/>
|
|
<arpeggiator arpcycle="0" arptime_denominator="4" arpgate="100" arpdir="0" arpmode="0" arptime_syncmode="0" arp="0" arpmiss="0" arp-enabled="0" arptime="100" arprange="1" arpskip="0" arptime_numerator="4"/>
|
|
<midiport inputchannel="0" outputcontroller="0" fixedoutputvelocity="-1" outputchannel="1" fixedinputvelocity="-1" outputprogram="1" inputcontroller="0" readable="0" fixedoutputnote="-1" basevelocity="63" writable="0"/>
|
|
<fxchain enabled="0" numofeffects="0"/>
|
|
</instrumenttrack>
|
|
</track>
|
|
<track muted="0" name="Sample track" type="2" solo="0">
|
|
<sampletrack vol="100" pan="0">
|
|
<fxchain enabled="0" numofeffects="0"/>
|
|
</sampletrack>
|
|
</track>
|
|
<track muted="0" name="Beat/Bassline 0" type="1" solo="0">
|
|
<bbtrack>
|
|
<trackcontainer visible="0" y="5" minimized="0" height="400" type="bbtrackcontainer" x="610" maximized="0" width="700">
|
|
<track muted="0" name="Kicker" type="0" solo="0">
|
|
<instrumenttrack pitchrange="1" fxch="0" usemasterpitch="1" vol="100" pitch="0" pan="0" basenote="57">
|
|
<instrument name="kicker">
|
|
<kicker decay="440" version="1" noise="0" endfreq="40" decay_syncmode="0" decay_denominator="4" startfreq="150" env="0.163" startnote="1" dist="0.8" slope="0.06" decay_numerator="4" click="0.4" distend="0.8" gain="1" endnote="0"/>
|
|
</instrument>
|
|
<eldata fres="0.5" fwet="0" ftype="0" fcut="14000">
|
|
<elvol ctlenvamt="0" lspd_syncmode="0" x100="0" lpdel="0" latt="0" pdel="0" lspd="0.1" lshp="0" hold="0.5" lspd_numerator="4" userwavefile="" att="0" lamt="0" dec="0.5" lspd_denominator="4" sustain="0.5" rel="0.1" amt="0"/>
|
|
<elcut ctlenvamt="0" lspd_syncmode="0" x100="0" lpdel="0" latt="0" pdel="0" lspd="0.1" lshp="0" hold="0.5" lspd_numerator="4" userwavefile="" att="0" lamt="0" dec="0.5" lspd_denominator="4" sustain="0.5" rel="0.1" amt="0"/>
|
|
<elres ctlenvamt="0" lspd_syncmode="0" x100="0" lpdel="0" latt="0" pdel="0" lspd="0.1" lshp="0" hold="0.5" lspd_numerator="4" userwavefile="" att="0" lamt="0" dec="0.5" lspd_denominator="4" sustain="0.5" rel="0.1" amt="0"/>
|
|
</eldata>
|
|
<chordcreator chordrange="1" chord-enabled="0" chord="0"/>
|
|
<arpeggiator arpcycle="0" arptime_denominator="4" arpgate="100" arpdir="0" arpmode="0" arptime_syncmode="0" arp="0" arpmiss="0" arp-enabled="0" arptime="100" arprange="1" arpskip="0" arptime_numerator="4"/>
|
|
<midiport inputchannel="0" outputcontroller="0" fixedoutputvelocity="-1" outputchannel="1" fixedinputvelocity="-1" outputprogram="1" inputcontroller="0" readable="0" fixedoutputnote="-1" basevelocity="63" writable="0"/>
|
|
<fxchain enabled="0" numofeffects="0"/>
|
|
</instrumenttrack>
|
|
<pattern pos="0" muted="0" name="Kicker" steps="16" type="0"/>
|
|
</track>
|
|
</trackcontainer>
|
|
</bbtrack>
|
|
</track>
|
|
<track muted="0" name="Automation track" type="5" solo="0">
|
|
<automationtrack/>
|
|
</track>
|
|
</trackcontainer>
|
|
<track muted="0" name="Automation track" type="6" solo="0">
|
|
<automationtrack/>
|
|
<automationpattern pos="0" len="192" name="Numerator" prog="0" tens="1" mute="0"/>
|
|
<automationpattern pos="0" len="192" name="Denominator" prog="0" tens="1" mute="0"/>
|
|
<automationpattern pos="0" len="192" name="Tempo" prog="0" tens="1" mute="0"/>
|
|
<automationpattern pos="0" len="192" name="Master volume" prog="0" tens="1" mute="0"/>
|
|
<automationpattern pos="0" len="192" name="Master pitch" prog="0" tens="1" mute="0"/>
|
|
</track>
|
|
<fxmixer visible="1" y="310" minimized="0" height="333" x="5" maximized="0" width="543">
|
|
<fxchannel volume="1" muted="0" num="0" name="Master" soloed="0">
|
|
<fxchain enabled="0" numofeffects="0"/>
|
|
</fxchannel>
|
|
</fxmixer>
|
|
<ControllerRackView visible="1" y="310" minimized="0" height="200" x="680" maximized="0" width="350"/>
|
|
<pianoroll visible="0" y="5" minimized="0" height="480" x="5" maximized="0" width="860"/>
|
|
<automationeditor visible="0" y="1" minimized="0" height="400" x="1" maximized="0" width="860"/>
|
|
<projectnotes visible="0" y="10" minimized="0" height="400" x="700" maximized="0" width="679"><![CDATA[<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.0//EN" "http://www.w3.org/TR/REC-html40/strict.dtd">
|
|
<html><head><meta name="qrichtext" content="1" /><style type="text/css">
|
|
p, li { white-space: pre-wrap; }
|
|
</style></head><body style=" font-family:'MS Shell Dlg 2'; font-size:7.5pt; font-weight:400; font-style:normal;">
|
|
<p style="-qt-paragraph-type:empty; margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;"><br /></p></body></html>]]></projectnotes>
|
|
<timeline lp1pos="192" lpstate="0" lp0pos="0"/>
|
|
<controllers/>
|
|
</song>
|
|
</lmms-project>`;
|
|
|
|
|
|
// -------------------------
|
|
// LOGGER DINÂMICO (GERENCIADOR)
|
|
// -------------------------
|
|
|
|
// Vamos manter um mapa de loggers ativos, um para cada sala
|
|
const loggersByRoom = new Map();
|
|
|
|
// Função para gerar um timestamp formatado para nome de arquivo
|
|
function getTimestampForFile() {
|
|
const d = new Date();
|
|
const date = d.toISOString().split("T")[0]; // YYYY-MM-DD
|
|
// HH-MM-SS (formato 24h, fuso local)
|
|
const time = d.toTimeString().split(" ")[0].replace(/:/g, "-");
|
|
return `${date}_${time}`; // ex: 2025-10-26_12-30-05
|
|
}
|
|
|
|
// Função principal: Pega o logger de uma sala ou cria um novo
|
|
function getActionLoggerForRoom(roomName) {
|
|
// 1. Se já criamos um logger para esta sala, reutilize-o
|
|
if (loggersByRoom.has(roomName)) {
|
|
return loggersByRoom.get(roomName);
|
|
}
|
|
|
|
// 2. Se for a primeira vez, crie um novo logger
|
|
const timestamp = getTimestampForFile();
|
|
|
|
// Limpa o nome da sala para evitar caracteres inválidos no nome do arquivo
|
|
const safeRoomName = roomName.replace(/[^a-z0-9_-]/gi, "_");
|
|
|
|
const fileName = `${timestamp}_${safeRoomName}.log`;
|
|
const filePath = path.join(process.cwd(), "data", fileName);
|
|
|
|
console.log(`[Logger] Novo log de sessão iniciado: ${filePath}`);
|
|
|
|
// Garante que a pasta 'data' exista
|
|
fs.mkdirSync(path.join(process.cwd(), "data"), { recursive: true });
|
|
|
|
// Cria a instância do pino para este arquivo específico
|
|
const newLogger = pino(pino.destination(filePath));
|
|
|
|
// Armazena no mapa para reutilização
|
|
loggersByRoom.set(roomName, newLogger);
|
|
|
|
return newLogger;
|
|
}
|
|
|
|
// -------------------------
|
|
// Configuração do Servidor (HTTPS)
|
|
// -------------------------
|
|
const app = express();
|
|
const PORT = process.env.PORT || 33001;
|
|
|
|
// ====== HTTPS (Opção B) ======
|
|
const CERT_FULLCHAIN = process.env.SSL_FULLCHAIN || "/etc/letsencrypt/live/alice.ufsj.edu.br/fullchain.pem";
|
|
const CERT_PRIVKEY = process.env.SSL_PRIVKEY || "/etc/letsencrypt/live/alice.ufsj.edu.br/privkey.pem";
|
|
|
|
if (!fs.existsSync(CERT_FULLCHAIN) || !fs.existsSync(CERT_PRIVKEY)) {
|
|
console.error("[HTTPS] Certificados não encontrados.\n" +
|
|
` fullchain: ${CERT_FULLCHAIN}\n` +
|
|
` privkey : ${CERT_PRIVKEY}\n` +
|
|
"Defina SSL_FULLCHAIN/SSL_PRIVKEY ou instale os certificados no caminho padrão.");
|
|
process.exit(1);
|
|
}
|
|
|
|
const httpsOptions = {
|
|
cert: fs.readFileSync(CERT_FULLCHAIN),
|
|
key : fs.readFileSync(CERT_PRIVKEY),
|
|
};
|
|
|
|
const httpsServer = https.createServer(httpsOptions, app);
|
|
|
|
// -------------------------
|
|
// Persistência por sala
|
|
// -------------------------
|
|
/*
|
|
Estrutura na memória:
|
|
roomStates = {
|
|
[roomName]: {
|
|
projectXml: '<xml...>' | undefined,
|
|
audio: { tracks: [], clips: [] },
|
|
seq: number,
|
|
tokensSeen: Set<string> // idempotência
|
|
}
|
|
}
|
|
*/
|
|
const roomStates = {}; // compat com V4 (já existia), ampliado para incluir audio/seq
|
|
|
|
function dataFile(roomName) {
|
|
return path.join(process.cwd(), "data", `${roomName}.json`);
|
|
}
|
|
|
|
function ensureRoom(roomName) {
|
|
if (roomStates[roomName]) return roomStates[roomName];
|
|
|
|
// tenta carregar do disco
|
|
try {
|
|
const p = dataFile(roomName);
|
|
if (fs.existsSync(p)) {
|
|
const j = JSON.parse(fs.readFileSync(p, "utf8"));
|
|
roomStates[roomName] = {
|
|
// 🔥 CORREÇÃO: Fallback para DEFAULT_PROJECT_XML se o JSON salvo estiver sem XML
|
|
projectXml: j.projectXml || DEFAULT_PROJECT_XML,
|
|
audio: j.audio || { tracks: [], clips: [] },
|
|
seq: j.seq || 0,
|
|
tokensSeen: new Set(),
|
|
};
|
|
return roomStates[roomName];
|
|
}
|
|
} catch (e) {
|
|
console.warn(`[persist] falha ao carregar estado da sala ${roomName}:`, e);
|
|
}
|
|
|
|
// Cria sala NOVA na memória
|
|
roomStates[roomName] = {
|
|
// 🔥 CORREÇÃO: Inicia com o XML do LMMS completo, não mais null
|
|
projectXml: DEFAULT_PROJECT_XML,
|
|
audio: { tracks: [], clips: [] },
|
|
seq: 0,
|
|
tokensSeen: new Set(),
|
|
};
|
|
return roomStates[roomName];
|
|
}
|
|
|
|
function saveRoom(roomName) {
|
|
const r = ensureRoom(roomName);
|
|
try {
|
|
fs.mkdirSync(path.join(process.cwd(), "data"), { recursive: true });
|
|
fs.writeFileSync(
|
|
dataFile(roomName),
|
|
JSON.stringify({ projectXml: r.projectXml, audio: r.audio, seq: r.seq })
|
|
);
|
|
} catch (e) {
|
|
console.warn(`[persist] falha ao salvar estado da sala ${roomName}:`, e);
|
|
}
|
|
}
|
|
|
|
// -------------------------
|
|
// Server / IO
|
|
// -------------------------
|
|
const io = new Server(httpsServer, {
|
|
cors: {
|
|
// CORREÇÃO: Simplificado para a origem real do cliente, que é onde o HTML é servido.
|
|
// O cliente provavelmente é servido a partir de https://alice.ufsj.edu.br (porta 443).
|
|
origin: "https://alice.ufsj.edu.br",
|
|
methods: ["GET", "POST"],
|
|
credentials: true,
|
|
},
|
|
});
|
|
|
|
console.log("Backend V6 (Logs Dinâmicos) iniciado. Aguardando conexões...");
|
|
|
|
// util de log das salas deste socket
|
|
function logMyRooms(socket, prefix = "SALAS") {
|
|
const myRooms = Array.from(socket.rooms || []);
|
|
console.log(`[${prefix}] socket ${socket.id} está em:`, myRooms);
|
|
}
|
|
|
|
// -------------------------
|
|
// REDUCER autoritativo do editor de áudio
|
|
// -------------------------
|
|
function basenameNoExt(p) {
|
|
if (!p) return "";
|
|
const base = String(p).split(/[\\/]/).pop();
|
|
return base.replace(/\.[^/.]+$/, "");
|
|
}
|
|
|
|
function applyAuthoritativeAction(roomName, action) {
|
|
const room = ensureRoom(roomName);
|
|
const state = room.audio;
|
|
|
|
// idempotência por token (se fornecido)
|
|
if (action.__token && room.tokensSeen.has(action.__token)) {
|
|
return null;
|
|
}
|
|
if (action.__token) room.tokensSeen.add(action.__token);
|
|
|
|
let mutated = false;
|
|
|
|
switch (action.type) {
|
|
case "ADD_AUDIO_LANE": {
|
|
// Usa o trackId enviado pelo cliente, se existir
|
|
const id = action.trackId || generateUniqueId("track_server");
|
|
if (!state.tracks.find((t) => t.id === id)) {
|
|
state.tracks.push({
|
|
id,
|
|
name: `Pista de Áudio ${state.tracks.length + 1}`,
|
|
});
|
|
mutated = true;
|
|
}
|
|
break;
|
|
}
|
|
|
|
case "ADD_AUDIO_CLIP": {
|
|
const { clipId, filePath, trackId, startTimeInSeconds, name } = action;
|
|
|
|
// Blindagem do Servidor (Evitar faixas fantasma)
|
|
if (
|
|
!trackId ||
|
|
clipId == null ||
|
|
startTimeInSeconds == null ||
|
|
isNaN(startTimeInSeconds)
|
|
) {
|
|
console.warn(
|
|
`[Server] Ação ADD_AUDIO_CLIP rejeitada (dados inválidos):`,
|
|
action
|
|
);
|
|
return null; // Rejeita a ação, não faz broadcast
|
|
}
|
|
|
|
if (!state.tracks.find((t) => t.id === trackId)) {
|
|
state.tracks.push({
|
|
id: trackId,
|
|
name: `Pista de Áudio ${state.tracks.length + 1}`,
|
|
});
|
|
mutated = true;
|
|
}
|
|
|
|
if (!state.clips.find((c) => String(c.id) === String(clipId))) {
|
|
state.clips.push({
|
|
id: clipId,
|
|
trackId,
|
|
name: name || basenameNoExt(filePath) || "sample",
|
|
sourcePath: filePath || null, // ideal: URL pública
|
|
startTimeInSeconds,
|
|
durationInSeconds: action.durationInSeconds || 0,
|
|
offset: action.offset || 0,
|
|
pitch: action.pitch || 0,
|
|
volume: action.volume ?? 1,
|
|
pan: action.pan ?? 0,
|
|
originalDuration: action.originalDuration || 0,
|
|
});
|
|
mutated = true;
|
|
}
|
|
break;
|
|
}
|
|
|
|
case "UPDATE_AUDIO_CLIP": {
|
|
if (!action.clipId || !action.props) {
|
|
console.warn(
|
|
`[Server] Ação UPDATE_AUDIO_CLIP rejeitada (dados base inválidos):`,
|
|
action
|
|
);
|
|
return null;
|
|
}
|
|
|
|
const { trackId, startTimeInSeconds } = action.props;
|
|
if (
|
|
trackId !== undefined &&
|
|
(trackId == null || (typeof trackId === "number" && isNaN(trackId)))
|
|
) {
|
|
console.warn(
|
|
`[Server] Ação UPDATE_AUDIO_CLIP rejeitada (trackId inválido):`,
|
|
action
|
|
);
|
|
return null;
|
|
}
|
|
if (
|
|
startTimeInSeconds !== undefined &&
|
|
(startTimeInSeconds == null || isNaN(startTimeInSeconds))
|
|
) {
|
|
console.warn(
|
|
`[Server] Ação UPDATE_AUDIO_CLIP rejeitada (startTimeInSeconds inválido):`,
|
|
action
|
|
);
|
|
return null;
|
|
}
|
|
|
|
const c = state.clips.find((x) => String(x.id) === String(action.clipId));
|
|
if (c && action.props && typeof action.props === "object") {
|
|
Object.assign(c, action.props);
|
|
mutated = true;
|
|
}
|
|
break;
|
|
}
|
|
|
|
case "REMOVE_AUDIO_CLIP": {
|
|
const i = state.clips.findIndex(
|
|
(x) => String(x.id) === String(action.clipId)
|
|
);
|
|
if (i >= 0) {
|
|
state.clips.splice(i, 1);
|
|
mutated = true;
|
|
}
|
|
break;
|
|
}
|
|
|
|
case "REMOVE_AUDIO_LANE_BY_ID": {
|
|
// 1. Acha e remove a Track
|
|
const idx = state.tracks.findIndex((t) => t.id === action.trackId);
|
|
if (idx !== -1) {
|
|
state.tracks.splice(idx, 1);
|
|
mutated = true;
|
|
}
|
|
|
|
// 2. LIMPEZA PROFUNDA: Remove clips dessa track E clips órfãos (trackId null)
|
|
// Se removemos a track, os clips dela têm que sumir.
|
|
const prevCount = state.clips.length;
|
|
state.clips = state.clips.filter((c) => {
|
|
// Mantém apenas se tiver trackId E se esse trackId ainda existir na lista de tracks
|
|
const parentTrackExists = state.tracks.some(t => t.id === c.trackId);
|
|
return c.trackId && parentTrackExists;
|
|
});
|
|
|
|
if (state.clips.length !== prevCount) {
|
|
mutated = true;
|
|
}
|
|
break;
|
|
}
|
|
|
|
default:
|
|
// outras ações não são da persistência do editor de áudio
|
|
mutated = false;
|
|
}
|
|
|
|
if (!mutated) return null;
|
|
|
|
room.seq += 1; // numeração autoritativa
|
|
saveRoom(roomName); // persistência em disco
|
|
return { ...action, __seq: room.seq }; // devolve ação com seq para broadcast
|
|
}
|
|
|
|
io.on("connection", (socket) => {
|
|
console.log(`Novo usuário conectado: ${socket.id}`);
|
|
|
|
// =======================================
|
|
// JOIN ROOM
|
|
// =======================================
|
|
socket.on("join_room", async (data) => {
|
|
const { roomName, userName } = data || {};
|
|
if (!roomName) {
|
|
console.warn(`join_room inválido de ${socket.id} (sem roomName)`);
|
|
return;
|
|
}
|
|
|
|
await socket.join(roomName);
|
|
console.log(
|
|
`Usuário ${userName || "(sem nome)"} (${
|
|
socket.id
|
|
}) entrou na sala: ${roomName}`
|
|
);
|
|
logMyRooms(socket, "JOIN");
|
|
|
|
// envia estado salvo do projeto (XML) — compat V4
|
|
const room = ensureRoom(roomName);
|
|
if (room.projectXml) {
|
|
socket.emit("load_project_state", room.projectXml);
|
|
console.log(
|
|
`Estado XML enviado para ${userName || socket.id} na sala ${roomName}`
|
|
);
|
|
} else {
|
|
console.log(`Sala ${roomName} sem XML salvo ainda.`);
|
|
}
|
|
|
|
// envia snapshot autoritativo do editor de áudio
|
|
socket.emit("action_broadcast", {
|
|
action: {
|
|
type: "AUDIO_SNAPSHOT",
|
|
snapshot: room.audio,
|
|
__seq: room.seq,
|
|
__target: socket.id,
|
|
},
|
|
});
|
|
|
|
socket
|
|
.to(roomName)
|
|
.emit("feedback", `Usuário ${userName || socket.id} entrou na sala.`);
|
|
});
|
|
|
|
// =======================================
|
|
// CLOCK SYNC
|
|
// =======================================
|
|
socket.on("what_time_is_it", (_data, cb) => {
|
|
cb && cb({ serverNowMs: Date.now() });
|
|
});
|
|
|
|
// =======================================
|
|
// BROADCAST COM ACK + PERSISTÊNCIA
|
|
// =======================================
|
|
socket.on("broadcast_action", async (payload, cb) => {
|
|
const { roomName, action } = payload || {};
|
|
if (!roomName || !action) {
|
|
cb && cb({ ok: false, error: "invalid_payload" });
|
|
console.warn(
|
|
`[broadcast_action] payload inválido de ${socket.id}:`,
|
|
payload
|
|
);
|
|
return;
|
|
}
|
|
|
|
// 👇 *** NOSSO NOVO LOG DINÂMICO *** 👇
|
|
try {
|
|
// 1. Pega o logger específico para ESTA sala
|
|
const roomLogger = getActionLoggerForRoom(roomName);
|
|
|
|
// 2. Loga a ação (não precisamos mais salvar roomName, já está no nome do arq.)
|
|
roomLogger.info(
|
|
{
|
|
timestamp: Date.now(),
|
|
socketId: socket.id,
|
|
action: action,
|
|
},
|
|
"action_received"
|
|
);
|
|
} catch (e) {
|
|
console.warn(`[Logger] Falha ao logar ação para sala ${roomName}:`, e);
|
|
}
|
|
// *** FIM DO LOG ***
|
|
|
|
// Confirma recebimento imediatamente
|
|
cb && cb({ ok: true, token: action.__token });
|
|
|
|
// Garante que o emissor está na sala (caso o join tenha falhado/atrasado)
|
|
if (!socket.rooms.has(roomName)) {
|
|
console.warn(
|
|
`[broadcast_action] ${socket.id} NÃO estava na sala ${roomName}. Forçando join...`
|
|
);
|
|
await socket.join(roomName);
|
|
logMyRooms(socket, "FORCED_JOIN");
|
|
}
|
|
|
|
// Carrega estado da sala
|
|
const room = ensureRoom(roomName);
|
|
|
|
// Persiste estado do Pattern (Notas/Sequenciador)
|
|
// Aceita tanto carregamento total quanto atualização de notas
|
|
if ((action.type === "LOAD_PROJECT" || action.type === "SYNC_PATTERN_STATE") && action.xml) {
|
|
// Proteção: Não salva se o XML for vazio (evita corromper a sala com o bug antigo)
|
|
if (action.xml.trim().length > 0) {
|
|
room.projectXml = action.xml;
|
|
saveRoom(roomName);
|
|
console.log(
|
|
`[broadcast_action] XML da sala ${roomName} atualizado via ${action.type}.`
|
|
);
|
|
} else {
|
|
console.warn(`[Server] Ignorando XML vazio vindo de ${action.type}`);
|
|
}
|
|
}
|
|
|
|
// Aplica ações autoritativas do editor de áudio
|
|
const maybeApplied = applyAuthoritativeAction(roomName, action);
|
|
|
|
// Debug: quem está na sala neste momento?
|
|
const socketsInRoom = await io.in(roomName).fetchSockets();
|
|
const idsInRoom = socketsInRoom.map((s) => s.id);
|
|
console.log(
|
|
`[broadcast_action] ${action.type} (token=${action.__token}) para sala "${roomName}" com ${idsInRoom.length} cliente(s):`,
|
|
idsInRoom
|
|
);
|
|
|
|
// Se foi uma ação do editor de áudio e foi aplicada, rebroadcast com __seq
|
|
if (maybeApplied) {
|
|
io.to(roomName).emit("action_broadcast", { action: maybeApplied });
|
|
return;
|
|
}
|
|
|
|
// Caso contrário, apenas repercute a ação como antes (sem __seq)
|
|
io.to(roomName).emit("action_broadcast", { action });
|
|
});
|
|
|
|
// =======================================
|
|
// Retrocompat (daw_action)
|
|
// =======================================
|
|
socket.on("daw_action", async (data) => {
|
|
const { room, action } = data || {};
|
|
if (!room || !action) return;
|
|
|
|
console.warn(
|
|
"[DEPRECATION] Recebido daw_action. Migre para broadcast_action com ACK."
|
|
);
|
|
|
|
// (Opcional, mas recomendado) Adicionando log dinâmico aqui também
|
|
try {
|
|
const roomLogger = getActionLoggerForRoom(room);
|
|
roomLogger.info(
|
|
{
|
|
timestamp: Date.now(),
|
|
socketId: socket.id,
|
|
action: action,
|
|
},
|
|
"action_received (deprecated daw_action)"
|
|
);
|
|
} catch (e) {
|
|
console.warn(`[Logger] Falha ao logar daw_action para sala ${room}:`, e);
|
|
}
|
|
|
|
if (!socket.rooms.has(room)) {
|
|
console.warn(
|
|
`[daw_action] ${socket.id} NÃO estava na sala ${room}. Forçando join...`
|
|
);
|
|
await socket.join(room);
|
|
logMyRooms(socket, "FORCED_JOIN(daw)");
|
|
}
|
|
|
|
const r = ensureRoom(room);
|
|
if (action.type === "LOAD_PROJECT" && action.xml) {
|
|
r.projectXml = action.xml;
|
|
saveRoom(room);
|
|
console.log(
|
|
`[daw_action] Estado da sala ${room} atualizado (LOAD_PROJECT).`
|
|
);
|
|
}
|
|
|
|
const socketsInRoom = await io.in(room).fetchSockets();
|
|
const idsInRoom = socketsInRoom.map((s) => s.id);
|
|
console.log(
|
|
`[daw_action] ${action.type} para sala "${room}" (${idsInRoom.length} cliente(s)):`,
|
|
idsInRoom
|
|
);
|
|
|
|
io.in(room).emit("action_broadcast", { action });
|
|
});
|
|
|
|
// =======================================
|
|
// Re-sync explícito (quando cliente detectar buraco de seq)
|
|
// =======================================
|
|
socket.on("audio_resync", ({ roomName, lastSeq }) => {
|
|
const room = ensureRoom(roomName);
|
|
// caminho simples: envia snapshot atual (estado autoritativo)
|
|
socket.emit("action_broadcast", {
|
|
action: {
|
|
type: "AUDIO_SNAPSHOT",
|
|
snapshot: room.audio,
|
|
__seq: room.seq,
|
|
__target: socket.id,
|
|
},
|
|
});
|
|
});
|
|
|
|
socket.on("disconnect", () => {
|
|
console.log(`Usuário desconectado: ${socket.id}`);
|
|
});
|
|
});
|
|
|
|
// --- ENDPOINT DE NOTIFICAÇÃO EXTERNA ---
|
|
app.post("/notify-update", express.json(), (req, res) => {
|
|
const { updateType } = req.body;
|
|
|
|
if (updateType === "samples") {
|
|
// 1. Emitir o evento para TODAS as salas/clientes.
|
|
// O evento deve ser algo que seu ui.js entenda.
|
|
io.emit("system_update", {
|
|
type: "RELOAD_SAMPLES",
|
|
message: "Novo Sample/Project adicionado. Recarregando o navegador de arquivos...",
|
|
});
|
|
console.log(
|
|
"[Notificação] Evento 'RELOAD_SAMPLES' emitido para todos os clientes."
|
|
);
|
|
|
|
return res.status(200).send({ success: true, message: "Notificação de Samples/Projetos enviada." });
|
|
}
|
|
|
|
res.status(400).send({ success: false, message: "updateType inválido." });
|
|
});
|
|
|
|
app.get("/", (req, res) => {
|
|
res.send(
|
|
"Servidor Backend V6 (Logs Dinâmicos) da DAW colaborativa está no ar!"
|
|
);
|
|
});
|
|
|
|
// ====== START ======
|
|
httpsServer.listen(PORT, () => {
|
|
console.log(`Servidor escutando na porta https://localhost:${PORT}`);
|
|
}); |