server.js (6595B)
1 const crypto = require("crypto"); 2 const fs = require("fs"); 3 const path = require("path"); 4 5 const MAX_STATIONS = Number(process.env.RADIO_MAX_STATIONS || 500); 6 const MAX_TRACKS_PER_STATION = Number(process.env.RADIO_MAX_TRACKS_PER_STATION || 500); 7 8 function safeJsonParse(str) { 9 try { 10 return JSON.parse(str); 11 } catch { 12 return null; 13 } 14 } 15 16 function readJsonOrNull(filePath) { 17 try { 18 const raw = fs.readFileSync(filePath, "utf8"); 19 return safeJsonParse(raw); 20 } catch { 21 return null; 22 } 23 } 24 25 function writeFileAtomic(filePath, content) { 26 const dir = path.dirname(filePath); 27 fs.mkdirSync(dir, { recursive: true }); 28 const tmp = `${filePath}.tmp.${crypto.randomBytes(6).toString("hex")}`; 29 fs.writeFileSync(tmp, content, "utf8"); 30 fs.renameSync(tmp, filePath); 31 } 32 33 function nowMs() { 34 return Date.now(); 35 } 36 37 function toId(bytes = 6) { 38 return crypto.randomBytes(Math.max(3, Math.min(16, bytes))).toString("hex"); 39 } 40 41 function normUser(u) { 42 return String(u || "").trim().toLowerCase(); 43 } 44 45 function normTitle(t, fallback) { 46 const s = String(t || "").replace(/\s+/g, " ").trim().slice(0, 40); 47 return s || fallback || "Untitled"; 48 } 49 50 function normUrl(u) { 51 const s = String(u || "").trim(); 52 if (!s) return ""; 53 if (!s.startsWith("/uploads/")) return ""; 54 if (!/\.mp3(\?|#|$)/i.test(s)) return ""; 55 if (s.length > 280) return ""; 56 return s; 57 } 58 59 module.exports = function init(api) { 60 const dataFile = path.join(__dirname, "radio.json"); 61 62 function loadState() { 63 const parsed = readJsonOrNull(dataFile); 64 const stations = Array.isArray(parsed?.stations) ? parsed.stations : []; 65 const cleaned = stations 66 .map((s) => { 67 const id = String(s?.id || "").trim().toLowerCase(); 68 const name = normTitle(s?.name, "Station"); 69 const author = normUser(s?.author); 70 const createdAt = Number(s?.createdAt || 0) || 0; 71 const tracks = Array.isArray(s?.tracks) ? s.tracks : []; 72 const cleanTracks = tracks 73 .map((t) => { 74 const tid = String(t?.id || "").trim().toLowerCase(); 75 const title = normTitle(t?.title, "Track"); 76 const url = normUrl(t?.url); 77 const addedBy = normUser(t?.addedBy); 78 const addedAt = Number(t?.addedAt || 0) || 0; 79 if (!tid || !url) return null; 80 return { id: tid, title, url, addedBy, addedAt }; 81 }) 82 .filter(Boolean) 83 .slice(0, MAX_TRACKS_PER_STATION); 84 if (!id || !author) return null; 85 return { id, name, author, createdAt, tracks: cleanTracks }; 86 }) 87 .filter(Boolean) 88 .slice(0, MAX_STATIONS); 89 cleaned.sort((a, b) => Number(a.createdAt || 0) - Number(b.createdAt || 0)); 90 return { stations: cleaned }; 91 } 92 93 function saveState(state) { 94 const stations = Array.isArray(state?.stations) ? state.stations : []; 95 writeFileAtomic(dataFile, JSON.stringify({ version: 1, savedAt: nowMs(), stations }, null, 2) + "\n"); 96 } 97 98 function listForClient() { 99 const state = loadState(); 100 return state.stations.map((s) => ({ 101 id: s.id, 102 name: s.name, 103 author: s.author, 104 createdAt: s.createdAt, 105 trackCount: Array.isArray(s.tracks) ? s.tracks.length : 0, 106 tracks: (Array.isArray(s.tracks) ? s.tracks : []).map((t) => ({ 107 id: t.id, 108 title: t.title, 109 url: t.url, 110 addedBy: t.addedBy, 111 addedAt: t.addedAt 112 })) 113 })); 114 } 115 116 function send(ws, msg) { 117 try { 118 ws.send(JSON.stringify(msg)); 119 return true; 120 } catch { 121 return false; 122 } 123 } 124 125 function sendError(ws, message, data) { 126 send(ws, { type: "plugin:radio:error", message: String(message || "Error."), data: data || null }); 127 } 128 129 function broadcastStations() { 130 api.broadcast({ type: "plugin:radio:stations", stations: listForClient(), at: api.now() }); 131 } 132 133 api.registerWs("stateReq", (ws) => { 134 send(ws, { type: "plugin:radio:stations", stations: listForClient(), at: api.now() }); 135 }); 136 137 api.registerWs("createStation", (ws, msg) => { 138 const user = normUser(ws?.user?.username); 139 if (!user) { 140 sendError(ws, "Sign in required to create a station."); 141 return; 142 } 143 const name = normTitle(msg?.name, ""); 144 if (!name) { 145 sendError(ws, "Station name required."); 146 return; 147 } 148 const state = loadState(); 149 if (state.stations.length >= MAX_STATIONS) { 150 sendError(ws, "Station limit reached."); 151 return; 152 } 153 const station = { 154 id: toId(6), 155 name, 156 author: user, 157 createdAt: nowMs(), 158 tracks: [] 159 }; 160 state.stations.push(station); 161 saveState(state); 162 broadcastStations(); 163 send(ws, { type: "plugin:radio:createOk", stationId: station.id }); 164 }); 165 166 api.registerWs("addTracks", (ws, msg) => { 167 const user = normUser(ws?.user?.username); 168 if (!user) { 169 sendError(ws, "Sign in required to upload tracks."); 170 return; 171 } 172 const stationId = String(msg?.stationId || "").trim().toLowerCase(); 173 if (!stationId) { 174 sendError(ws, "Missing stationId."); 175 return; 176 } 177 const incoming = Array.isArray(msg?.tracks) ? msg.tracks : []; 178 if (!incoming.length) { 179 sendError(ws, "No tracks provided."); 180 return; 181 } 182 const state = loadState(); 183 const idx = state.stations.findIndex((s) => String(s?.id || "") === stationId); 184 if (idx < 0) { 185 sendError(ws, "Station not found."); 186 return; 187 } 188 const station = state.stations[idx]; 189 const existingUrls = new Set((station.tracks || []).map((t) => String(t.url || ""))); 190 const space = Math.max(0, MAX_TRACKS_PER_STATION - (Array.isArray(station.tracks) ? station.tracks.length : 0)); 191 if (space <= 0) { 192 sendError(ws, "Track limit reached for this station."); 193 return; 194 } 195 196 const clean = []; 197 for (const raw of incoming) { 198 if (clean.length >= space) break; 199 const url = normUrl(raw?.url); 200 if (!url || existingUrls.has(url)) continue; 201 const title = normTitle(raw?.title, "Track"); 202 clean.push({ 203 id: toId(6), 204 title, 205 url, 206 addedBy: user, 207 addedAt: nowMs() 208 }); 209 existingUrls.add(url); 210 } 211 if (!clean.length) { 212 sendError(ws, "No valid MP3 tracks to add."); 213 return; 214 } 215 station.tracks = [...(Array.isArray(station.tracks) ? station.tracks : []), ...clean].slice(0, MAX_TRACKS_PER_STATION); 216 state.stations[idx] = station; 217 saveState(state); 218 broadcastStations(); 219 send(ws, { type: "plugin:radio:addOk", stationId, added: clean.length }); 220 }); 221 }; 222