From 8973d9bd5da5d4cdb0d65dda3d669333b1a8a797 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ante=20Br=C3=A4hler?= Date: Fri, 20 Dec 2024 00:48:14 +0100 Subject: [PATCH 1/2] docs: update and add faq --- README.md | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 77a7daf..4ae3ea5 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,6 @@ # IPTV StreamHub -A simple IPTV `restream` and `synchronization` application with web frontend. Share your iptv playlist and watch it together with your friends. - +A simple IPTV `restream` and `synchronization` (watch2gether) application with web frontend. Share your iptv playlist and watch it together with your friends. ## ✨ Features **Restreaming** - Proxy your iptv streams through the backend. @@ -19,6 +18,7 @@ A simple IPTV `restream` and `synchronization` application with web frontend. Sh - Helps with CORS issues. - Synchronize IPTV streaming with multiple devices: Synchronized playback and channel selection. - Share your iptv and watch together with your friends. + - The actual iptv stream-url is unvisible to them if you restream [upcomming feature] ## 🛠️ Architecture @@ -45,7 +45,8 @@ docker compose up -d ``` Open http://localhost -⚠️ Be aware that a restreamed synchonized channel (also some of the preview channels) may take some time to load (20-25s) ⚠️ -> To reduce loading time deactivate synchronization in the ⚙️ or edit the delay in the [config]((docker-compose.yml))! +> [!IMPORTANT] +> If a channel/playlist won't work, please try with `restream through backend` option enabled. This fixes most of the problems! It leads to longer initial loading times. If you don't need synchronization, turn it off in the ⚙️ or set the delay in the [config](docker-compose.yml). ### Run components seperately @@ -61,6 +62,12 @@ Be aware, that this'll require additional configuration/adaption and won't be of ![Frontend Preview](/frontend/ressources/frontend-preview.png) ![Add channel](/frontend/ressources/add-channel.png) +## FAQ & Common Mistakes + +> Error: `Bind for 0.0.0.0:80 failed: port is already allocated` + +To fix this, change the [port mapping in the docker-compose](docker-compose.yml#L40) to `X:80` e.g. `8080:80`. Make also sure that port X is open in the firewall configuration if you want to expose the application. + ## Contribute & Contact Feel free to open discussions and issues for any type of requests. Don't hesitate to contact me, if you have any problems with the setup. From f727477d47bc4ad0f17a2b4dffadf29b91e43586 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ante=20Br=C3=A4hler?= Date: Fri, 20 Dec 2024 00:49:41 +0100 Subject: [PATCH 2/2] feat: add playlist update and delete functionality --- backend/controllers/ChannelController.js | 4 +- backend/models/Channel.js | 3 +- backend/server.js | 2 + backend/services/ChannelService.js | 43 +++---------- backend/services/PlaylistService.js | 62 +++++++++++++++++++ backend/socket/ChannelSocketHandler.js | 22 +------ backend/socket/PlaylistSocketHandler.js | 47 ++++++++++++++ .../components/add_channel/ChannelModal.tsx | 58 +++++++++++------ frontend/src/services/SocketService.ts | 33 +++++++--- frontend/src/types.ts | 2 + 10 files changed, 193 insertions(+), 83 deletions(-) create mode 100644 backend/services/PlaylistService.js create mode 100644 backend/socket/PlaylistSocketHandler.js diff --git a/backend/controllers/ChannelController.js b/backend/controllers/ChannelController.js index 3a99f45..f2327c0 100644 --- a/backend/controllers/ChannelController.js +++ b/backend/controllers/ChannelController.js @@ -31,8 +31,8 @@ module.exports = { addChannel(req, res) { try { - const { name, url, avatar, restream, headersJson, group } = req.body; - const newChannel = ChannelService.addChannel(name, url, avatar, restream, headersJson, group); + //const { name, url, avatar, restream, headersJson, group, playlist } = req.body; + const newChannel = ChannelService.addChannel(req.body); res.status(201).json(newChannel); } catch (error) { res.status(500).json({ error: error.message }); diff --git a/backend/models/Channel.js b/backend/models/Channel.js index 4aa00d7..c93c7f9 100644 --- a/backend/models/Channel.js +++ b/backend/models/Channel.js @@ -1,6 +1,6 @@ class Channel { static nextId = 0; - constructor(name, url, avatar, restream, headers, group) { + constructor(name, url, avatar, restream, headers = [], group = null, playlist = null) { this.id = Channel.nextId++; this.name = name; this.url = url; @@ -8,6 +8,7 @@ class Channel { this.restream = restream; this.headers = headers; this.group = group; + this.playlist = playlist; } } diff --git a/backend/server.js b/backend/server.js index ab68336..41af5b3 100644 --- a/backend/server.js +++ b/backend/server.js @@ -8,6 +8,7 @@ const ChannelSocketHandler = require('./socket/ChannelSocketHandler'); const channelController = require('./controllers/ChannelController'); const streamController = require('./services/streaming/StreamController'); const ChannelService = require('./services/ChannelService'); +const PlaylistSocketHandler = require('./socket/PlaylistSocketHandler'); dotenv.config(); @@ -52,6 +53,7 @@ io.on('connection', socket => { }) ChannelSocketHandler(io, socket); + PlaylistSocketHandler(io, socket); ChatSocketHandler(io, socket); }) diff --git a/backend/services/ChannelService.js b/backend/services/ChannelService.js index b31a692..1ba95e2 100644 --- a/backend/services/ChannelService.js +++ b/backend/services/ChannelService.js @@ -1,6 +1,5 @@ const streamController = require('./streaming/StreamController'); const Channel = require('../models/Channel'); -const m3uParser = require('iptv-playlist-parser'); class ChannelService { constructor() { @@ -19,14 +18,14 @@ class ChannelService { this.channels = [ //Some Test-channels to get started, remove this when using your own playlist - new Channel('Das Erste', process.env.DEFAULT_CHANNEL_URL, "https://upload.wikimedia.org/wikipedia/commons/thumb/5/56/Das_Erste-Logo_klein.svg/768px-Das_Erste-Logo_klein.svg.png", false, [], null), - new Channel('DAZN 1 DE', "https://xyzdddd.mizhls.ru/lb/premium426/index.m3u8", "https://upload.wikimedia.org/wikipedia/commons/4/49/DAZN_1.svg", true, daddyHeaders, null), - new Channel('beIN Sports 1', "https://xyzdddd.mizhls.ru/lb/premium61/index.m3u8","https://www.thesportsdb.com/images/media/channel/logo/BeIn_Sports_1_Australia.png", true, daddyHeaders, null), - new Channel('beIN Sports 2', "https://xyzdddd.mizhls.ru/lb/premium92/index.m3u8", "https://www.thesportsdb.com/images/media/channel/logo/BeIn_Sports_HD_2_France.png", true, daddyHeaders, null), - new Channel('Sky Sport Football', "https://xyzdddd.mizhls.ru/lb/premium35/index.m3u8", "https://raw.githubusercontent.com/tv-logo/tv-logos/main/countries/united-kingdom/sky-sports-football-uk.png", true, daddyHeaders, null), - new Channel('Sky Sports Premier League', "https://xyzdddd.mizhls.ru/lb/premium130/index.m3u8", "https://github.com/tv-logo/tv-logos/blob/main/countries/united-kingdom/sky-sports-premier-league-uk.png?raw=true", true, daddyHeaders, null), - new Channel('SuperSport Premier League', 'https://xyzdddd.mizhls.ru/lb/premium414/index.m3u8', "https://github.com/tv-logo/tv-logos/blob/8d25ddd79ca2f9cd033b808c45cccd2b3da563ee/countries/south-africa/supersport-premier-league-za.png?raw=true", true, daddyHeaders, null), - new Channel('NBA', "https://v14.thetvapp.to/hls/NBA28/index.m3u8?token=bFFITmZCbllna21WRUJra0xjN0JPN0w1VlBmSkNUcTl4Zml3a2tQSg==", "https://raw.githubusercontent.com/tv-logo/tv-logos/635e715cb2f2c6d28e9691861d3d331dd040285b/countries/united-states/nba-tv-icon-us.png", false, [], null), + new Channel('Das Erste', process.env.DEFAULT_CHANNEL_URL, "https://upload.wikimedia.org/wikipedia/commons/thumb/5/56/Das_Erste-Logo_klein.svg/768px-Das_Erste-Logo_klein.svg.png", false), + new Channel('DAZN 1 DE', "https://xyzdddd.mizhls.ru/lb/premium426/index.m3u8", "https://upload.wikimedia.org/wikipedia/commons/4/49/DAZN_1.svg", true, daddyHeaders), + new Channel('beIN Sports 1', "https://xyzdddd.mizhls.ru/lb/premium61/index.m3u8","https://www.thesportsdb.com/images/media/channel/logo/BeIn_Sports_1_Australia.png", true, daddyHeaders), + new Channel('beIN Sports 2', "https://xyzdddd.mizhls.ru/lb/premium92/index.m3u8", "https://www.thesportsdb.com/images/media/channel/logo/BeIn_Sports_HD_2_France.png", true, daddyHeaders), + new Channel('Sky Sport Football', "https://xyzdddd.mizhls.ru/lb/premium35/index.m3u8", "https://raw.githubusercontent.com/tv-logo/tv-logos/main/countries/united-kingdom/sky-sports-football-uk.png", true, daddyHeaders), + new Channel('Sky Sports Premier League', "https://xyzdddd.mizhls.ru/lb/premium130/index.m3u8", "https://github.com/tv-logo/tv-logos/blob/main/countries/united-kingdom/sky-sports-premier-league-uk.png?raw=true", true, daddyHeaders), + new Channel('SuperSport Premier League', 'https://xyzdddd.mizhls.ru/lb/premium414/index.m3u8', "https://github.com/tv-logo/tv-logos/blob/8d25ddd79ca2f9cd033b808c45cccd2b3da563ee/countries/south-africa/supersport-premier-league-za.png?raw=true", true, daddyHeaders), + new Channel('NBA', "https://v14.thetvapp.to/hls/NBA28/index.m3u8?token=bFFITmZCbllna21WRUJra0xjN0JPN0w1VlBmSkNUcTl4Zml3a2tQSg==", "https://raw.githubusercontent.com/tv-logo/tv-logos/635e715cb2f2c6d28e9691861d3d331dd040285b/countries/united-states/nba-tv-icon-us.png", false), ]; this.currentChannel = this.channels[0]; } @@ -35,7 +34,7 @@ class ChannelService { return this.channels; } - addChannel(name, url, avatar, restream, headersJson, group) { + addChannel({name, url, avatar, restream, headersJson, group = false, playlist = false}) { const existing = this.channels.find(channel => channel.url === url); if (existing) { @@ -43,7 +42,7 @@ class ChannelService { } const headers = JSON.parse(headersJson); - const newChannel = new Channel(name, url, avatar, restream, headers, group); + const newChannel = new Channel(name, url, avatar, restream, headers, group, playlist); this.channels.push(newChannel); return newChannel; @@ -119,28 +118,6 @@ class ChannelService { return channel; } - - async addChannelsFromPlaylist(playlistUrl, restream, headersJson) { - - const response = await fetch(playlistUrl); - const content = await response.text(); - - const parsedPlaylist = m3uParser.parse(content); - - // list of added channels - const channels = parsedPlaylist.items.map(channel => { - //TODO: add channel.http if not '' to headers - try { - return this.addChannel(channel.name, channel.url, channel.tvg.logo, restream, headersJson, channel.group.title); - } catch (error) { - console.error(error); - return null; - } - }) - .filter(result => result !== null); - - return channels; - } } module.exports = new ChannelService(); diff --git a/backend/services/PlaylistService.js b/backend/services/PlaylistService.js new file mode 100644 index 0000000..7e8acae --- /dev/null +++ b/backend/services/PlaylistService.js @@ -0,0 +1,62 @@ +const m3uParser = require('iptv-playlist-parser'); +const ChannelService = require('./ChannelService'); + +class PlaylistService { + + async addPlaylist(playlistUrl, restream, headersJson) { + + const response = await fetch(playlistUrl); + const content = await response.text(); + + const parsedPlaylist = m3uParser.parse(content); + + // list of added channels + const channels = parsedPlaylist.items.map(channel => { + //TODO: add channel.http if not '' to headers + try { + return ChannelService.addChannel({ + name: channel.name, + url: channel.url, + avatar: channel.tvg.logo, + restream: restream, + headersJson: headersJson, + group: channel.group.title, + playlist: playlistUrl + }); + } catch (error) { + console.error(error); + return null; + } + }) + .filter(result => result !== null); + + return channels; + } + + + updatePlaylist(playlistUrl, updatedAttributes) { + const channels = ChannelService + .getChannels() + .filter(channel => channel.playlist === playlistUrl); + + for(var channel of channels) { + channel = ChannelService.updateChannel(channel.id, updatedAttributes); + } + + return channels; + } + + deletePlaylist(playlistUrl) { + const channels = ChannelService + .getChannels() + .filter(channel => channel.playlist === playlistUrl); + + for(const channel of channels) { + ChannelService.deleteChannel(channel.id); + } + + return channels; + } +} + +module.exports = new PlaylistService(); diff --git a/backend/socket/ChannelSocketHandler.js b/backend/socket/ChannelSocketHandler.js index 4402389..3e22999 100644 --- a/backend/socket/ChannelSocketHandler.js +++ b/backend/socket/ChannelSocketHandler.js @@ -2,16 +2,15 @@ const ChannelService = require('../services/ChannelService'); module.exports = (io, socket) => { - socket.on('add-channel', ({ name, url, avatar, restream, headersJson}) => { + socket.on('add-channel', ({ name, url, avatar, restream, headersJson }) => { try { - const newChannel = ChannelService.addChannel(name, url, avatar, restream, headersJson, null); + const newChannel = ChannelService.addChannel({ name: name, url: url, avatar: avatar, restream: restream, headersJson: headersJson }); io.emit('channel-added', newChannel); // Broadcast to all clients } catch (err) { socket.emit('app-error', { message: err.message }); } }); - socket.on('set-current-channel', (id) => { try { const nextChannel = ChannelService.setCurrentChannel(id); @@ -26,7 +25,7 @@ module.exports = (io, socket) => { try { const current = ChannelService.deleteChannel(id); io.emit('channel-deleted', id); // Broadcast to all clients - io.emit('channel-selected', current); + io.emit('channel-selected', current); } catch (err) { console.error(err); socket.emit('app-error', { message: err.message }); @@ -42,19 +41,4 @@ module.exports = (io, socket) => { socket.emit('app-error', { message: err.message }); } }); - - socket.on('upload-playlist', async ({ playlistUrl, restream, headersJson }) => { - try { - - channels = await ChannelService.addChannelsFromPlaylist(playlistUrl, restream, headersJson); - if (channels) { - channels.forEach(channel => { - io.emit('channel-added', channel); - }); - } - } catch (err) { - console.error(err); - socket.emit('app-error', { message: err.message }); - } - }); }; diff --git a/backend/socket/PlaylistSocketHandler.js b/backend/socket/PlaylistSocketHandler.js new file mode 100644 index 0000000..0178fac --- /dev/null +++ b/backend/socket/PlaylistSocketHandler.js @@ -0,0 +1,47 @@ +const PlaylistService = require('../services/PlaylistService'); +const ChannelService = require('../services/ChannelService'); +const Channel = require('../models/Channel'); + +module.exports = (io, socket) => { + + socket.on('add-playlist', async ({ playlist, restream, headersJson }) => { + try { + const channels = await PlaylistService.addPlaylist(playlist, restream, headersJson); + if (channels) { + channels.forEach(channel => { + io.emit('channel-added', channel); + }); + } + } catch (err) { + console.error(err); + socket.emit('app-error', { message: err.message }); + } + }); + + + socket.on('update-playlist', ({ playlist, updatedAttributes }) => { + try { + const channels = PlaylistService.updatePlaylist(playlist, updatedAttributes); + channels.forEach(channel => { + io.emit('channel-updated', channel); + }); + } catch (err) { + console.error(err); + socket.emit('app-error', { message: err.message }); + } + }); + + + socket.on('delete-playlist', (playlist) => { + try { + const channels = PlaylistService.deletePlaylist(playlist); + channels.forEach(channel => { + io.emit('channel-deleted', channel.id); + }); + io.emit('channel-selected', ChannelService.getCurrentChannel()); + } catch (err) { + console.error(err); + socket.emit('app-error', { message: err.message }); + } + }); +}; diff --git a/frontend/src/components/add_channel/ChannelModal.tsx b/frontend/src/components/add_channel/ChannelModal.tsx index c90f646..78a1038 100644 --- a/frontend/src/components/add_channel/ChannelModal.tsx +++ b/frontend/src/components/add_channel/ChannelModal.tsx @@ -18,7 +18,7 @@ function ChannelModal({ isOpen, onClose, channel }: ChannelModalProps) { const [name, setName] = useState(''); const [url, setUrl] = useState(''); const [avatar, setAvatar] = useState(''); - const [restream, setRestream] = useState(false); + const [restream, setRestream] = useState(true); const [headers, setHeaders] = useState([]); const [playlistUrl, setPlaylistUrl] = useState(''); @@ -32,6 +32,7 @@ function ChannelModal({ isOpen, onClose, channel }: ChannelModalProps) { setAvatar(channel.avatar); setRestream(channel.restream); setHeaders(channel.headers); + setPlaylistUrl(channel.playlist); setIsEditMode(true); setMode('channel'); // Default to "channel" if a channel object exists } else { @@ -40,6 +41,7 @@ function ChannelModal({ isOpen, onClose, channel }: ChannelModalProps) { setAvatar(''); setRestream(false); setHeaders([]); + setPlaylistUrl(''); setIsEditMode(false); setMode('channel'); // Default to "channel" if a channel object exists } @@ -78,7 +80,7 @@ function ChannelModal({ isOpen, onClose, channel }: ChannelModalProps) { ); } else if (mode === 'playlist') { if (!playlistUrl.trim()) return; - socketService.uploadPlaylist(playlistUrl.trim(), restream, JSON.stringify(headers)); + socketService.addPlaylist(playlistUrl.trim(), restream, JSON.stringify(headers)); } addToast({ @@ -91,16 +93,32 @@ function ChannelModal({ isOpen, onClose, channel }: ChannelModalProps) { }; const handleUpdate = (id: number) => { - socketService.updateChannel(id, { - name: name.trim(), - url: url.trim(), - avatar: avatar.trim() || 'https://via.placeholder.com/64', - restream, - headers: headers, - }); + if (mode === 'channel') { + socketService.updateChannel(id, { + name: name.trim(), + url: url.trim(), + avatar: avatar.trim() || 'https://via.placeholder.com/64', + restream, + headers: headers, + }); + + } else if (mode === 'playlist') { + if(channel!.playlist !== playlistUrl.trim()) { + // If the playlist URL has changed, we need to reload the playlist (delete old channels and fetch again) + socketService.deletePlaylist(channel!.playlist); + socketService.addPlaylist(playlistUrl.trim(), restream, JSON.stringify(headers)); + } else { + socketService.updatePlaylist(playlistUrl.trim(), { + playlist: playlistUrl.trim(), + restream, + headers: headers, + }); + } + } + addToast({ type: 'success', - title: 'Channel updated', + title: `${mode} updated`, duration: 3000, }); @@ -109,11 +127,15 @@ function ChannelModal({ isOpen, onClose, channel }: ChannelModalProps) { const handleDelete = () => { if (channel) { - socketService.deleteChannel(channel.id); + if (mode === 'channel') { + socketService.deleteChannel(channel.id); + } else if (mode === 'playlist') { + socketService.deletePlaylist(channel.playlist); + } } addToast({ type: 'error', - title: 'Channel deleted', + title: `${mode} deleted`, duration: 3000, }); onClose(); @@ -138,22 +160,20 @@ function ChannelModal({ isOpen, onClose, channel }: ChannelModalProps) { {/* Slider */} - {!isEditMode && ( + {(!isEditMode || channel?.playlist) && (
diff --git a/frontend/src/services/SocketService.ts b/frontend/src/services/SocketService.ts index 7b8690d..2412440 100644 --- a/frontend/src/services/SocketService.ts +++ b/frontend/src/services/SocketService.ts @@ -49,7 +49,7 @@ class SocketService { this.listeners.get(event)?.push(listener); } - // Event abbestellen + // Unsubscribe from event unsubscribeFromEvent(event: string, listener: (data: T) => void) { const eventListeners = this.listeners.get(event); if (eventListeners) { @@ -61,47 +61,62 @@ class SocketService { } - // Nachricht senden + // Send chat message sendMessage(userName: string, userAvatar: string, message: string, timestamp: string) { if (!this.socket) throw new Error('Socket is not connected.'); this.socket.emit('send-message', { userName, userAvatar, message, timestamp }); } - // Channel hinzufügen + // Add channel addChannel(name: string, url: string, avatar: string, restream: boolean, headersJson: string) { if (!this.socket) throw new Error('Socket is not connected.'); this.socket.emit('add-channel', { name, url, avatar, restream, headersJson }); } - // Aktuellen Channel setzen + // Set current channel setCurrentChannel(id: number) { if (!this.socket) throw new Error('Socket is not connected.'); this.socket.emit('set-current-channel', id); } - // Channel löschen + // Delete channel deleteChannel(id: number) { if (!this.socket) throw new Error('Socket is not connected.'); this.socket.emit('delete-channel', id); } - // Channel aktualisieren + // Update channel updateChannel(id: number, updatedAttributes: any) { if (!this.socket) throw new Error('Socket is not connected.'); this.socket.emit('update-channel', { id, updatedAttributes }); } - // Playlist hochladen - uploadPlaylist(playlistUrl: string, restream: boolean, headersJson: string ) { + // Add playlist + addPlaylist(playlist: string, restream: boolean, headersJson: string) { if (!this.socket) throw new Error('Socket is not connected.'); - this.socket.emit('upload-playlist', { playlistUrl, restream, headersJson }); + this.socket.emit('add-playlist', { playlist, restream, headersJson }); } + + // Update playlist + updatePlaylist(playlist: string, updatedAttributes: any) { + if (!this.socket) throw new Error('Socket is not connected.'); + + this.socket.emit('update-playlist', { playlist, updatedAttributes }); + } + + // Delete playlist + deletePlaylist(playlist: string) { + if (!this.socket) throw new Error('Socket is not connected.'); + + this.socket.emit('delete-playlist', playlist); + } + } const socketService = new SocketService(); diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 656df0e..d8776f0 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -25,6 +25,8 @@ export interface Channel { avatar: string; restream: boolean; headers: CustomHeader[]; + group: string; + playlist: string; } export interface ChatMessage {