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. 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 {