bzl

self-hosted ephemeral community engine
Log | Files | Refs | README | LICENSE

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