# Radio Monitor Recorder — Fullstack Prototype This repository is a runnable **full-stack prototype** that records multiple online radio streams simultaneously and provides a simple web UI to control and playback recordings. --- ## Contents (single-file layout for easy copy/paste) ### 1) README (this file) ### 2) Backend: `server.js` (Node.js + Express) ### 3) Recorder module: `recorder.js` (spawns `ffmpeg` to write rolling files) ### 4) Frontend: `src/App.jsx` (React single-file app) ### 5) `package.json` for running both server and frontend (uses Vite for React dev) --- ## Requirements - Node.js 18+ (or 16+) - `ffmpeg` installed & available in PATH (for recording streams) - Optional: PM2 or systemd for production process management --- ## Quick start 1. Create a new folder and paste the files below into their filenames. 2. Run `npm install`. 3. Run `npm run dev` to start both backend and frontend (concurrently script). In production, run `node server.js` and serve the built frontend. --- ################################################################ // server.js ```js // server.js — simple Node.js + Express backend const express = require('express'); const path = require('path'); const bodyParser = require('body-parser'); const Recorder = require('./recorder'); const app = express(); app.use(bodyParser.json()); // Simple in-memory store const recorders = {}; // Start recording a stream app.post('/api/start', async (req, res) => { const { id, url, segmentMinutes = 15, outDir = 'recordings' } = req.body; if (!id || !url) return res.status(400).json({ error: 'id and url required' }); if (recorders[id]) return res.status(400).json({ error: 'Already recording' }); const r = new Recorder(id, url, { segmentMinutes, outDir }); try { await r.start(); recorders[id] = r; return res.json({ ok: true }); } catch (err) { return res.status(500).json({ error: err.message }); } }); // Stop recording app.post('/api/stop', (req, res) => { const { id } = req.body; const r = recorders[id]; if (!r) return res.status(404).json({ error: 'Not found' }); r.stop(); delete recorders[id]; return res.json({ ok: true }); }); // List status app.get('/api/status', (req, res) => { const status = Object.entries(recorders).map(([id, r]) => ({ id, url: r.url, startedAt: r.startedAt })); res.json(status); }); // Serve recordings directory listing const fs = require('fs'); app.get('/api/recordings', (req, res) => { const dir = path.resolve(process.cwd(), 'recordings'); if (!fs.existsSync(dir)) return res.json([]); const files = fs.readdirSync(dir).filter(f => f.endsWith('.mp3') || f.endsWith('.aac') || f.endsWith('.m4a') || f.endsWith('.wav')); const list = files.map(f => ({ name: f, url: `/recordings/${encodeURIComponent(f)}` })); res.json(list); }); // Serve static recordings and frontend app.use('/recordings', express.static(path.join(process.cwd(), 'recordings'))); app.use('/', express.static(path.join(process.cwd(), 'frontend', 'dist'))); const PORT = process.env.PORT || 4000; app.listen(PORT, () => console.log('Server listening on', PORT)); ``` ################################################################ // recorder.js ```js // recorder.js — spawns ffmpeg to capture a live stream into rolling segment files const { spawn } = require('child_process'); const fs = require('fs'); const path = require('path'); class Recorder { constructor(id, url, opts = {}) { this.id = id; this.url = url; this.segmentMinutes = opts.segmentMinutes || 15; this.outDir = path.resolve(process.cwd(), opts.outDir || 'recordings'); this.proc = null; this.startedAt = null; if (!fs.existsSync(this.outDir)) fs.mkdirSync(this.outDir, { recursive: true }); } start() { return new Promise((resolve, reject) => { const timestamp = () => new Date().toISOString().replace(/[:.]/g, '-'); // output pattern: recordings/__%03d.mp3 const outPattern = path.join(this.outDir, `${this.id}_${timestamp()}_%03d.mp3`); // FFmpeg command (use segment muxer to roll files) // Example: ffmpeg -i -c:a libmp3lame -b:a 128k -f segment -segment_time 900 -reset_timestamps 1 out%03d.mp3 const args = [ '-y', '-i', this.url, '-vn', '-c:a', 'libmp3lame', '-b:a', '128k', '-f', 'segment', '-segment_time', String(this.segmentMinutes * 60), '-segment_atclocktime', '1', '-strftime', '0', outPattern ]; this.proc = spawn('ffmpeg', args, { stdio: ['ignore', 'pipe', 'pipe'] }); this.startedAt = new Date().toISOString(); this.proc.stdout.on('data', d => console.log(`[ffmpeg ${this.id}]`, d.toString())); this.proc.stderr.on('data', d => console.log(`[ffmpeg ${this.id} err]`, d.toString())); this.proc.on('error', err => reject(err)); this.proc.on('close', code => console.log(`ffmpeg ${this.id} exited (${code})`)); // slight delay to ensure process started setTimeout(() => resolve(), 500); }); } stop() { if (this.proc) { // send SIGINT to let ffmpeg finalize segment this.proc.kill('SIGINT'); this.proc = null; } } } module.exports = Recorder; ``` ################################################################ // frontend/src/App.jsx ```jsx import React, { useEffect, useState } from 'react'; export default function App(){ const [streamUrl, setStreamUrl] = useState(''); const [id, setId] = useState('station1'); const [status, setStatus] = useState([]); const [recordings, setRecordings] = useState([]); useEffect(()=>{ fetchStatus(); fetchRecordings(); }, []); async function fetchStatus(){ const res = await fetch('/api/status'); const j = await res.json(); setStatus(j); } async function fetchRecordings(){ const res = await fetch('/api/recordings'); const j = await res.json(); setRecordings(j); } async function start(){ if(!streamUrl) return alert('Enter stream URL'); await fetch('/api/start', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ id, url: streamUrl }) }); fetchStatus(); } async function stop(){ await fetch('/api/stop', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ id }) }); fetchStatus(); } return (

Radio Monitor Recorder — Prototype

Active Recordings

    {status.map(s=>
  • {s.id} — {s.url} — startedAt: {s.startedAt}
  • )}

Recorded Files

); } ``` ################################################################ // package.json ```json { "name": "radio-monitor-proto", "version": "0.1.0", "private": true, "scripts": { "start": "node server.js", "dev": "concurrently \"node server.js\" \"cd frontend && npm run dev -- --port 5173\"" }, "dependencies": { "body-parser": "^1.20.0", "express": "^4.18.2", "concurrently": "^7.6.0" } } ``` --- ## Notes & Next Steps - This prototype uses `ffmpeg` segment muxer to write rolling files. You can adjust codec/bitrate depending on source stream type. - For production: use a process manager (PM2), rotate/purge old files, and move recordings to object storage (S3-compatible). - If you want recognition (what track is playing), integrate an ACR provider (ACRCloud/AudD) or local fingerprinting; send short snippets from the running stream to the ACR API and store matches alongside recording timestamps. --- If you want, I can now: - Provide the exact commands to install `ffmpeg` on your OS, - Convert the frontend to a standalone Vite project with full scaffolding, - Add ACR integration code that captures 10‑second snippets and queries ACRCloud. Tell me which next step you want and I’ll add it.