feat: backend web-sockets solution

This commit is contained in:
Ante Brähler
2024-11-19 18:10:57 +00:00
parent d431e032f0
commit 45781aaff0
14 changed files with 424 additions and 96 deletions

View File

@@ -18,3 +18,15 @@ Before running, make sure you have `ffmpeg` installed on your system.
node index.js
```
Be aware, that this application is designed for Linux systems!
## Architecture
### API
- Endpoints to add a channel, get all channels, get selected channel and set selected channel
### WebSockets
- `channel-added` and `channel-selected` events will be send to all connected clients
- chat messages: `send-chat-message` and `chat-message`
- users: `user-connected` and `user-disconnected`

View File

@@ -1,66 +1,11 @@
const ffmpegService = require('../services/FFmpegService');
const storageService = require('../services/StorageService');
const Channel = require('../models/Channel');
let channels = [new Channel('DEFAULT_CHANNEL', process.env.DEFAULT_CHANNEL_URL)];
let currentChannel = channels[0];
function setCurrent(req, res) {
const { channelName } = req.body || {};
if (!channelName) {
return res.status(400).json({ status: 'error', message: 'channelName is required' });
}
const nextChannel = channels.find(channel => channel.name === channelName);
if (!nextChannel) {
res.status(400).json({ status: 'error', message: 'Channel does not exist' });
}
if (currentChannel !== nextChannel) {
const segmentNumber = storageService.getNextSegmentNumber();
storageService.clearStorage();
currentChannel = nextChannel;
ffmpegService.startFFmpeg(nextChannel.url, segmentNumber);
}
res.status(200).json({ status: 'success', channelUrl: currentChannel.url });
}
function getCurrent(_, res) {
if (currentChannel) {
res.status(200).json({ channelName: currentChannel.name, channelUrl: currentChannel.url });
} else {
res.status(404).json({ status: 'error', message: 'No channel active' });
}
}
function getChannels(_, res) {
res.status(200).json({ channels });
}
function addChannel(req, res) {
const { name, url } = req.body;
if (!name || !url) {
return res.status(400).json({ status: 'error', message: 'Channel name and URL are required' });
}
const channelExists = channels.some(channel => channel.url === url);
if (channelExists) {
return res.status(409).json({ status: 'error', message: 'Channel already exists' });
}
const newChannel = new Channel(name, url);
channels.push(newChannel);
res.status(201).json({ status: 'success', message: 'Channel added', channel: newChannel });
}
const ChannelService = require('../services/ChannelService');
module.exports = {
setCurrent,
getCurrent,
getChannels,
addChannel
};
getChannels(req, res) {
res.json(ChannelService.getChannels());
},
getCurrentChannel(req, res) {
res.json(ChannelService.getCurrentChannel());
},
};

View File

@@ -1,5 +1,5 @@
const ffmpegService = require('../services/FFmpegService');
const storageService = require('../services/StorageService');
const ffmpegService = require('../services/streaming/FFmpegService');
const storageService = require('../services/streaming/StorageService');
function start() {
if (!ffmpegService.isFFmpegRunning()) {

View File

@@ -1,27 +0,0 @@
const express = require('express');
const dotenv = require('dotenv');
const channelController = require('./controllers/ChannelController');
const streamController = require('./controllers/StreamController');
dotenv.config();
const app = express();
app.use(express.json());
const apiRouter = express.Router();
apiRouter.post('/current', channelController.setCurrent);
apiRouter.get('/current', channelController.getCurrent);
apiRouter.get('/', channelController.getChannels);
apiRouter.post('/add', channelController.addChannel);
app.use('/channels', apiRouter);
const PORT = 5000;
app.listen(PORT, () => {
console.log(`Server listening on Port ${PORT}`);
streamController.start();
});

View File

@@ -11,7 +11,34 @@
"dependencies": {
"child_process": "^1.0.2",
"dotenv": "^16.4.5",
"express": "^4.21.1"
"express": "^4.21.1",
"socket.io": "^4.8.1"
}
},
"node_modules/@socket.io/component-emitter": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz",
"integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA=="
},
"node_modules/@types/cookie": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.4.1.tgz",
"integrity": "sha512-XW/Aa8APYr6jSVVA1y/DEIZX0/GMKLEVekNG727R8cs56ahETkRAy/3DR7+fJyh7oUgGwNQaRfXCun0+KbWY7Q=="
},
"node_modules/@types/cors": {
"version": "2.8.17",
"resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.17.tgz",
"integrity": "sha512-8CGDvrBj1zgo2qE+oS3pOCyYNqCPryMWY2bGfwA0dcfopWGgxs+78df0Rs3rc9THP4JkOhLsAa+15VdpAqkcUA==",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/node": {
"version": "22.9.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.9.0.tgz",
"integrity": "sha512-vuyHg81vvWA1Z1ELfvLko2c8f34gyA0zaic0+Rllc5lbCnbSyuvb2Oxpm6TAUAC/2xZN3QGqxBNggD1nNR2AfQ==",
"dependencies": {
"undici-types": "~6.19.8"
}
},
"node_modules/accepts": {
@@ -31,6 +58,14 @@
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg=="
},
"node_modules/base64id": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/base64id/-/base64id-2.0.0.tgz",
"integrity": "sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog==",
"engines": {
"node": "^4.5.0 || >= 5.9"
}
},
"node_modules/body-parser": {
"version": "1.20.3",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.3.tgz",
@@ -117,6 +152,18 @@
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz",
"integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ=="
},
"node_modules/cors": {
"version": "2.8.5",
"resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz",
"integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==",
"dependencies": {
"object-assign": "^4",
"vary": "^1"
},
"engines": {
"node": ">= 0.10"
}
},
"node_modules/debug": {
"version": "2.6.9",
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
@@ -182,6 +229,63 @@
"node": ">= 0.8"
}
},
"node_modules/engine.io": {
"version": "6.6.2",
"resolved": "https://registry.npmjs.org/engine.io/-/engine.io-6.6.2.tgz",
"integrity": "sha512-gmNvsYi9C8iErnZdVcJnvCpSKbWTt1E8+JZo8b+daLninywUWi5NQ5STSHZ9rFjFO7imNcvb8Pc5pe/wMR5xEw==",
"dependencies": {
"@types/cookie": "^0.4.1",
"@types/cors": "^2.8.12",
"@types/node": ">=10.0.0",
"accepts": "~1.3.4",
"base64id": "2.0.0",
"cookie": "~0.7.2",
"cors": "~2.8.5",
"debug": "~4.3.1",
"engine.io-parser": "~5.2.1",
"ws": "~8.17.1"
},
"engines": {
"node": ">=10.2.0"
}
},
"node_modules/engine.io-parser": {
"version": "5.2.3",
"resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.3.tgz",
"integrity": "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==",
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/engine.io/node_modules/cookie": {
"version": "0.7.2",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
"integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/engine.io/node_modules/debug": {
"version": "4.3.7",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz",
"integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/engine.io/node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="
},
"node_modules/es-define-property": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.0.tgz",
@@ -475,6 +579,14 @@
"node": ">= 0.6"
}
},
"node_modules/object-assign": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/object-inspect": {
"version": "1.13.2",
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.2.tgz",
@@ -670,6 +782,107 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/socket.io": {
"version": "4.8.1",
"resolved": "https://registry.npmjs.org/socket.io/-/socket.io-4.8.1.tgz",
"integrity": "sha512-oZ7iUCxph8WYRHHcjBEc9unw3adt5CmSNlppj/5Q4k2RIrhl8Z5yY2Xr4j9zj0+wzVZ0bxmYoGSzKJnRl6A4yg==",
"dependencies": {
"accepts": "~1.3.4",
"base64id": "~2.0.0",
"cors": "~2.8.5",
"debug": "~4.3.2",
"engine.io": "~6.6.0",
"socket.io-adapter": "~2.5.2",
"socket.io-parser": "~4.2.4"
},
"engines": {
"node": ">=10.2.0"
}
},
"node_modules/socket.io-adapter": {
"version": "2.5.5",
"resolved": "https://registry.npmjs.org/socket.io-adapter/-/socket.io-adapter-2.5.5.tgz",
"integrity": "sha512-eLDQas5dzPgOWCk9GuuJC2lBqItuhKI4uxGgo9aIV7MYbk2h9Q6uULEh8WBzThoI7l+qU9Ast9fVUmkqPP9wYg==",
"dependencies": {
"debug": "~4.3.4",
"ws": "~8.17.1"
}
},
"node_modules/socket.io-adapter/node_modules/debug": {
"version": "4.3.7",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz",
"integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/socket.io-adapter/node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="
},
"node_modules/socket.io-parser": {
"version": "4.2.4",
"resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.4.tgz",
"integrity": "sha512-/GbIKmo8ioc+NIWIhwdecY0ge+qVBSMdgxGygevmdHj24bsfgtCmcUUcQ5ZzcylGFHsN3k4HB4Cgkl96KVnuew==",
"dependencies": {
"@socket.io/component-emitter": "~3.1.0",
"debug": "~4.3.1"
},
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/socket.io-parser/node_modules/debug": {
"version": "4.3.7",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz",
"integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/socket.io-parser/node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="
},
"node_modules/socket.io/node_modules/debug": {
"version": "4.3.7",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz",
"integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/socket.io/node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="
},
"node_modules/statuses": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz",
@@ -698,6 +911,11 @@
"node": ">= 0.6"
}
},
"node_modules/undici-types": {
"version": "6.19.8",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz",
"integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw=="
},
"node_modules/unpipe": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
@@ -721,6 +939,26 @@
"engines": {
"node": ">= 0.8"
}
},
"node_modules/ws": {
"version": "8.17.1",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz",
"integrity": "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
}
}
}

View File

@@ -2,8 +2,9 @@
"name": "iptv-restream",
"version": "1.0.0",
"description": "",
"main": "index.js",
"main": "server.js",
"scripts": {
"start": "node server.js",
"test": "echo \"Error: no test specified\" && exit 1"
},
"repository": {
@@ -20,6 +21,7 @@
"dependencies": {
"child_process": "^1.0.2",
"dotenv": "^16.4.5",
"express": "^4.21.1"
"express": "^4.21.1",
"socket.io": "^4.8.1"
}
}

47
backend/server.js Normal file
View File

@@ -0,0 +1,47 @@
const express = require('express');
const dotenv = require('dotenv');
const channelController = require('./controllers/ChannelController');
const streamController = require('./controllers/StreamController');
dotenv.config();
const app = express();
app.use(express.json());
const apiRouter = express.Router();
apiRouter.get('/', channelController.getChannels);
apiRouter.get('/current', channelController.getCurrentChannel);
app.use('/channels', apiRouter);
const PORT = 5000;
const server = app.listen(PORT, () => {
console.log(`Server listening on Port ${PORT}`);
streamController.start();
});
// Web Sockets
const io = require('socket.io')(server)
const connectedUsers = {}
io.on('connection', socket => {
socket.on('new-user', name => {
connectedUsers[socket.id] = name
socket.broadcast.emit('user-connected', name)
})
socket.on('disconnect', () => {
socket.broadcast.emit('user-disconnected', connectedUsers[socket.id])
delete connectedUsers[socket.id]
})
ChannelSocketHandler(io, socket);
ChatSocketHandler(io, socket);
})

View File

@@ -0,0 +1,45 @@
const ffmpegService = require('./streaming/FFmpegService');
const storageService = require('./streaming/StorageService');
const Channel = require('../models/Channel');
class ChannelService {
constructor() {
this.channels = [new Channel('DEFAULT_CHANNEL', process.env.DEFAULT_CHANNEL_URL)];
this.currentChannel = this.channels[0];
}
getChannels() {
return this.channels;
}
addChannel(name, url) {
const existing = this.channels.some(channel => channel.url === url);
if (existing) {
throw new Error('Channel already exists');
}
const newChannel = new Channel(name, url);
this.channels.push(newChannel);
return newChannel;
}
setCurrentChannel(name) {
const nextChannel = this.channels.find(channel => channel.name === name);
if (!nextChannel) {
throw new Error('Channel does not exist');
}
if (currentChannel !== nextChannel) {
const segmentNumber = storageService.getNextSegmentNumber();
storageService.clearStorage();
currentChannel = nextChannel;
ffmpegService.startFFmpeg(nextChannel.url, segmentNumber);
}
return nextChannel;
}
getCurrentChannel() {
return this.currentChannel;
}
}
module.exports = new ChannelService();

View File

@@ -0,0 +1,21 @@
const ChatMessage = require('../models/ChatMessage');
// At the moment, this service is not used! It is only a placeholder for future development for a persistent chat.
class ChatService {
constructor() {
this.messages = [];
}
addMessage(user, message) {
const newMessage = new ChatMessage(user, message);
this.messages.push(newMessage);
return newMessage;
}
getMessages() {
return this.messages;
}
}
module.exports = new ChatService();

View File

@@ -0,0 +1,14 @@
let io;
function setSocketIO(socketIOInstance) {
io = socketIOInstance;
}
function getSocketIO() {
if (!io) {
throw new Error('Socket.IO instance not initialized');
}
return io;
}
module.exports = { setSocketIO, getSocketIO };

View File

@@ -0,0 +1,23 @@
const ChannelService = require('../services/ChannelService');
module.exports = (io, socket) => {
socket.on('add-channel', ({ name, url }) => {
try {
const newChannel = ChannelService.addChannel(name, url);
io.emit('channel-added', newChannel); // Broadcast to all clients
} catch (err) {
socket.emit('error', { message: err.message });
}
});
socket.on('set-current-channel', (name) => {
try {
const currentChannel = ChannelService.setCurrentChannel(name);
io.emit('channel-selected', currentChannel); // Broadcast to all clients
} catch (err) {
socket.emit('error', { message: err.message });
}
});
};

View File

@@ -0,0 +1,8 @@
const ChatService = require('../services/ChatService');
module.exports = (io, socket) => {
socket.on('send-message', ({ user, message }) => {
ChatService.addMessage(user, message);
socket.broadcast.emit('chat-message', { message: message, name: user }) // Broadcast to all clients except sender
});
};