Merge pull request #56 from antebrl/24-central-redirect-m3u8-link-for-other-iptv-players

24 central redirect m3u8 link for other iptv players
This commit is contained in:
Ante Brähler
2025-01-19 15:21:54 +01:00
committed by GitHub
12 changed files with 284 additions and 19 deletions

View File

@@ -15,6 +15,7 @@
- [x] **Share your iptv access** without revealing your actual stream-url (privacy-mode) and watch together with your friends.
## ✨ Features
**IPTV Player** - IPTV web player with support for any other iptv players by exposing the playlist.
**Restream / Proxy** - Proxy your iptv streams through the backend. <br>
**Synchronization** - The selection and playback of the stream is perfectly synchronized for all viewers. <br>
**Channels** - Add multiple iptv streams and playlists, you can switch between. <br>
@@ -84,6 +85,13 @@ Which streaming mode should I choose for the channel?
> Proxy mode is most likely the mode, you will use! You will need restream mode especially when your iptv playlist has no programDateTime set and you want to have playback synchronization.
---
How can I use the channels on any other iptv player (e.g. on TV)?
> Please click on the 📺 (TV-button) in the top-right in the frontend. There you'll find the playlist you have to use in any other iptv player.
> This playlist contains all your channels and one **CURRENT_CHANNEL**, which forwards the content of the currently played channel.
> If this playlist does not work, please check if the base-url of the channels in the playlist is correct and set the `BACKEND_URL` in the `docker-compose.yml` if not.
---
My playlist only supports xtream codes api!
> [IPTV playlist browser](https://github.com/PhunkyBob/iptv_playlist_browser) allows you to export a m3u playlist from your xtream codes account, and let's you select single channels or the whole playlist. Official xstreams-code integration is planned!

View File

@@ -72,15 +72,12 @@ To use together with the frontend, [run with docker](../README.md#run-with-docke
- users: `user-connected` and `user-disconnected`
## Usage without the frontend (with other iptv player)
If you want to watch the current stream in any other IPTV player e.g. on tv, there will be a central m3u link provided in the near future. Watch the [progress of this feature](https://github.com/antebrl/IPTV-Restream/issues/24).
You can use all the channels with any other IPTV player. The backend exposes a **M3U Playlist** on `http://your-domain/api/channels/playlist`. You can also find it by clicking on the TV-button on the top right in the frontend!
If this playlist does not work, please check if the base-url of the channels in the playlist is correct and set the `BACKEND_URL` in the `docker-compose.yml` if not.
This playlist contains all your channels and one **CURRENT_CHANNEL**, which forwards the content of the currently played channel.
If you can't wait, these are your options to watch from other IPTV players right now:
- You can use the [ProxyController](#ProxyController) standalone. Just use `{baseUrl}/proxy/channel` in your iptv player. It is also possible to watch different channels simultaneously by setting the expected query parameters: `url` (and `headers` if needed).
- Use `/streams/{currentChannelId}/{currentChannelId}.m3u` to watch the current playing restream!
- [ChannelController](#ChannelController) endpoints should be used to manage the channels. If you just want to have static channels, you can also set them directly in the [ChannelService.js](./services/ChannelService.js).
- [WebSocket](#WebSocket) is only used by the frontend for chat and channel updates. You likely won't need it!
To modify the channel list, you can use the frontend or the [api](#channelcontroller).
> [!NOTE]
> These options are only tested with VLC media player as other iptv player. Use them at your own risk. Only for the usage together with the frontend will be support provided. <br>
> Support for a central m3u link for other players will be provided soon!
> These options are only tested with VLC media player as other iptv player. Use them at your own risk. Only for the usage together with the frontend will be support provided.

View File

@@ -0,0 +1,148 @@
const request = require('request');
const ChannelService = require('../services/ChannelService');
const ProxyHelperService = require('../services/proxy/ProxyHelperService');
const SessionFactory = require('../services/session/SessionFactory');
const Path = require('path');
const fs = require('fs');
const STORAGE_PATH = process.env.STORAGE_PATH;
const BACKEND_URL = process.env.BACKEND_URL;
function fetchM3u8(res, targetUrl, headers) {
console.log('Proxy playlist request to:', targetUrl);
try {
request(ProxyHelperService.getRequestOptions(targetUrl, headers), (error, response, body) => {
if (error) {
console.error('Request error:', error);
if (!res.headersSent) {
return res.status(500).json({ error: 'Failed to fetch m3u8 file' });
}
return;
}
try {
const proxyBaseUrl = '/proxy/';
const rewrittenBody = ProxyHelperService.rewriteUrls(body, proxyBaseUrl, headers, targetUrl).join('\n');
if(rewrittenBody.indexOf('channel?url=') !== -1) {
const regex = /channel\?url=([^&\s]+)/;
const match = rewrittenBody.match(regex);
const channelUrl = decodeURIComponent(match[1]);
return fetchM3u8(res, channelUrl, headers);
}
const updatedM3u8 = rewrittenBody.replace(/(#EXTINF.*)/, '#EXT-X-DISCONTINUITY\n$1');
return res.send(updatedM3u8);
} catch (e) {
console.error('Failed to rewrite URLs:', e);
return res.status(500).json({ error: 'Failed to parse m3uo file. Not a valid HLS stream.' });
}
//res.set('Content-Type', 'application/vnd.apple.mpegurl');
}).on('error', (err) => {
console.error('Unhandled error:', err);
if (!res.headersSent) {
res.status(500).json({ error: 'Proxy request failed' });
}
});
} catch (e) {
console.error('Failed to proxy request:', e);
if (!res.headersSent) {
res.status(500).json({ error: 'Proxy request failed' });
}
}
}
module.exports = {
async currentChannel(req, res) {
const channel = ChannelService.getCurrentChannel();
res.set('Access-Control-Allow-Origin', '*');
if(channel.restream()) {
const path = Path.resolve(`${STORAGE_PATH}${channel.id}/${channel.id}.m3u8`);
if (fs.existsSync(path)) {
try {
const m3u8Data = fs.readFileSync(path, 'utf-8');
let discontinuityAdded = false;
const updatedM3u8 = m3u8Data
.split('\n')
.map((line, index, lines) => {
// Füge #EXT-X-DISCONTINUITY vor der ersten #EXTINF hinzu
if (!discontinuityAdded && line.startsWith('#EXTINF')) {
discontinuityAdded = true;
return `#EXT-X-DISCONTINUITY\n${line}`;
}
// Passe die .ts-Dateipfade an
if (line.endsWith('.ts')) {
return `${STORAGE_PATH}${channel.id}/${line}`;
}
return line;
})
.join('\n');
return res.send(updatedM3u8);
} catch (err) {
console.error('Error loading m3u8 data from fs:', err);
res.status(500).json({ error: 'Failed to load m3u8 data from filesystem.' });
}
}
//add platzhalter
return res.send('No m3u8 data found.');
} else {
// Direct/Proxy Mode
// -> Fetch the m3u8 file from the channel URL
let targetUrl = channel.url;
const sessionProvider = SessionFactory.getSessionProvider(channel);
if(sessionProvider) {
await sessionProvider.createSession();
targetUrl = channel.sessionUrl;
}
let headers = undefined;
if(channel.headers && channel.headers.length > 0) {
headers = Buffer.from(JSON.stringify(channel.headers)).toString('base64');
}
fetchM3u8(res, targetUrl, headers);
}
},
playlist(req, res) {
const backendBaseUrl = BACKEND_URL ? BACKEND_URL : `${req.headers['x-forwarded-proto'] ?? 'http'}://${req.get('Host')}:${req.headers['x-forwarded-port'] ?? ''}`;
let playlistStr = `#EXTM3U
#EXTINF:-1 tvg-name="CURRENT RESTREAM" tvg-logo="https://cdn-icons-png.freepik.com/512/9294/9294560.png" group-title="DE",CURRENT RESTREAM
${backendBaseUrl}/proxy/current`;
//TODO: dynamically add channels from ChannelService
const channels = ChannelService.getChannels();
for(const channel of channels) {
let restreamMode = undefined;
if(channel.restream()) {
restreamMode = channel.headers && channel.headers.length > 0 ? 'proxy' : 'direct';
}
playlistStr += `\n#EXTINF:-1 tvg-name="${channel.name}" tvg-logo="${channel.avatar}" group-title="${channel.group ?? ''}",${channel.name}\n`;
if(channel.mode === 'direct' || restreamMode === 'direct') {
playlistStr += channel.url;
} else {
let headers = undefined;
if(channel.headers && channel.headers.length > 0) {
headers = Buffer.from(JSON.stringify(channel.headers)).toString('base64');
}
playlistStr += `${backendBaseUrl}/proxy/channel?url=${encodeURIComponent(channel.url)}${headers ? `&headers=${headers}` : ''}`;
}
}
res.send(playlistStr);
}
};

View File

@@ -32,6 +32,8 @@ module.exports = {
console.log('Proxy playlist request to:', targetUrl);
res.set('Access-Control-Allow-Origin', '*');
try {
request(ProxyHelperService.getRequestOptions(targetUrl, headers), (error, response, body) => {
if (error) {
@@ -76,6 +78,8 @@ module.exports = {
console.log('Proxy request to:', targetUrl);
res.set('Access-Control-Allow-Origin', '*');
req.pipe(
request(ProxyHelperService.getRequestOptions(targetUrl, headers))
.on('error', (err) => {

View File

@@ -6,6 +6,7 @@ const ChatSocketHandler = require('./socket/ChatSocketHandler');
const ChannelSocketHandler = require('./socket/ChannelSocketHandler');
const proxyController = require('./controllers/ProxyController');
const centralChannelController = require('./controllers/CentralChannelController');
const channelController = require('./controllers/ChannelController');
const streamController = require('./services/restream/StreamController');
const ChannelService = require('./services/ChannelService');
@@ -18,24 +19,23 @@ const app = express();
app.use(express.json());
const apiRouter = express.Router();
apiRouter.get('/', channelController.getChannels);
apiRouter.get('/current', channelController.getCurrentChannel);
apiRouter.get('/playlist', centralChannelController.playlist);
apiRouter.get('/:channelId', channelController.getChannel);
apiRouter.delete('/:channelId', channelController.deleteChannel);
apiRouter.put('/:channelId', channelController.updateChannel);
apiRouter.post('/', channelController.addChannel);
app.use('/api/channels', apiRouter);
const proxyRouter = express.Router();
proxyRouter.get('/channel', proxyController.channel);
proxyRouter.get('/segment', proxyController.segment);
proxyRouter.get('/key', proxyController.key);
proxyRouter.get('/current', centralChannelController.currentChannel);
app.use('/proxy', proxyRouter);
const PORT = 5000;
const server = app.listen(PORT, async () => {
console.log(`Server listening on Port ${PORT}`);

View File

@@ -26,6 +26,8 @@ services:
- channels:/channels
environment:
STORAGE_PATH: /streams/
# If you have problems with the playlist, set the backend url manually here
#BACKEND_URL: http://localhost:5000
networks:
- app-network

View File

@@ -12,6 +12,8 @@ services:
- channels:/channels
environment:
STORAGE_PATH: /streams/
# If you have problems with the playlist, set the backend url manually here
#BACKEND_URL: http://localhost:5000
networks:
- app-network

View File

@@ -8,6 +8,16 @@ http {
include mime.types;
default_type application/octet-stream;
#Forward header if there is a proxy in front otherwise set the headers
map $http_x_forwarded_proto $x_forwarded_proto {
default $http_x_forwarded_proto;
"" $scheme;
}
map $http_x_forwarded_port $x_forwarded_port {
default $http_x_forwarded_port;
"" $server_port;
}
server {
listen 80;
@@ -17,6 +27,10 @@ http {
location /api/ {
proxy_pass http://iptv_restream_backend:5000;
proxy_set_header X-Forwarded-Proto $x_forwarded_proto;
proxy_set_header X-Forwarded-Port $x_forwarded_port;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
@@ -36,9 +50,9 @@ http {
proxy_pass http://iptv_restream_backend:5000;
proxy_set_header Host $host;
add_header 'Access-Control-Allow-Origin' '*';
add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS';
add_header 'Access-Control-Allow-Headers' 'Content-Type, Authorization';
# add_header 'Access-Control-Allow-Origin' '*';
# add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS';
# add_header 'Access-Control-Allow-Headers' 'Content-Type, Authorization';
proxy_hide_header Content-Type;
add_header Content-Type 'application/vnd.apple.mpegurl' always;
@@ -49,6 +63,7 @@ http {
location /streams/ {
root /;
add_header 'Access-Control-Allow-Origin' '*';
add_header 'Access-Control-Allow-Methods' 'GET, OPTIONS';
add_header 'Access-Control-Allow-Headers' 'Range';

View File

@@ -27,6 +27,8 @@ services:
- channels:/channels
environment:
STORAGE_PATH: /streams/
# If you have problems with the playlist, set the backend url manually here
#BACKEND_URL: http://localhost:5000
networks:
- app-network

View File

@@ -8,6 +8,7 @@ import { Channel } from './types';
import socketService from './services/SocketService';
import apiService from './services/ApiService';
import SettingsModal from './components/SettingsModal';
import TvPlaylistModal from './components/TvPlaylistModal';
import { ToastProvider } from './components/notifications/ToastContext';
import ToastContainer from './components/notifications/ToastContainer';
@@ -16,6 +17,7 @@ function App() {
const [selectedChannel, setSelectedChannel] = useState<Channel | null>(null);
const [isModalOpen, setIsModalOpen] = useState(false);
const [isSettingsOpen, setIsSettingsOpen] = useState(false);
const [isTvPlaylistOpen, setIsTvPlaylistOpen] = useState(false);
const [syncEnabled, setSyncEnabled] = useState(() => {
const savedValue = localStorage.getItem('syncEnabled');
return savedValue !== null ? JSON.parse(savedValue) : true;
@@ -23,7 +25,6 @@ function App() {
const [searchQuery, setSearchQuery] = useState('');
const [editChannel, setEditChannel] = useState<Channel | null>(null);
const [selectedPlaylist, setSelectedPlaylist] = useState<string>('All Channels');
const [selectedGroup, setSelectedGroup] = useState<string>('Category');
const [isPlaylistDropdownOpen, setIsPlaylistDropdownOpen] = useState(false);
@@ -130,7 +131,6 @@ function App() {
};
}, []);
const handleEditChannel = (channel: Channel) => {
setEditChannel(channel);
setIsModalOpen(true);
@@ -157,6 +157,12 @@ function App() {
</div>
<div className="flex items-center space-x-4">
<Users className="w-6 h-6 text-blue-500" />
<button
onClick={() => setIsTvPlaylistOpen(true)}
className="p-2 hover:bg-gray-800 rounded-lg transition-colors"
>
<Tv2 className="w-6 h-6 text-blue-500" />
</button>
<button
onClick={() => setIsSettingsOpen(true)}
className="p-2 hover:bg-gray-800 rounded-lg transition-colors"
@@ -310,10 +316,15 @@ function App() {
}}
/>
<TvPlaylistModal
isOpen={isTvPlaylistOpen}
onClose={() => setIsTvPlaylistOpen(false)}
/>
<ToastContainer />
</div>
</ToastProvider>
);
}
export default App;
export default App;

View File

@@ -0,0 +1,75 @@
import { X, Copy, Tv2 } from 'lucide-react';
import { useContext } from 'react';
import { ToastContext } from './notifications/ToastContext';
interface TvPlaylistModalProps {
isOpen: boolean;
onClose: () => void;
}
function TvPlaylistModal({ isOpen, onClose }: TvPlaylistModalProps) {
const { addToast } = useContext(ToastContext);
const playlistUrl = `${import.meta.env.VITE_BACKEND_URL || window.location.origin}/api/channels/playlist`;
if (!isOpen) return null;
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(playlistUrl);
addToast({
type: 'success',
title: 'Playlist URL copied to clipboard',
duration: 2500,
});
} catch (err) {
addToast({
type: 'error',
title: 'Failed to copy URL',
message: 'Please copy the URL manually',
duration: 2500,
});
}
};
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50">
<div className="bg-gray-800 rounded-lg w-full max-w-2xl">
<div className="flex items-center justify-between p-4 border-b border-gray-700">
<div className="flex items-center space-x-2">
<Tv2 className="w-5 h-5 text-blue-500" />
<h2 className="text-xl font-semibold">TV Playlist</h2>
</div>
<button
onClick={onClose}
className="p-1 hover:bg-gray-700 rounded-full transition-colors"
>
<X className="w-5 h-5" />
</button>
</div>
<div className="p-6 space-y-4">
<div className="flex items-center space-x-2">
<input
type="text"
value={playlistUrl}
readOnly
className="flex-1 bg-gray-700 rounded-lg px-4 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
<button
onClick={handleCopy}
className="p-2 bg-blue-600 rounded-lg hover:bg-blue-700 transition-colors"
>
<Copy className="w-5 h-5" />
</button>
</div>
<p className="text-sm text-gray-400">
Use this playlist in any other IPTV player. If you have problems, check if the base-url in the playlist is correctly pointing to the backend. If not, please set BACKEND_URL in the docker-compose.yml
</p>
</div>
</div>
</div>
);
}
export default TvPlaylistModal;

View File

@@ -366,9 +366,10 @@ function ChannelModal({ onClose, channel }: ChannelModalProps) {
id="playlistText"
value={playlistText}
onChange={(e) => setPlaylistText(e.target.value)}
className="w-full bg-gray-700 rounded-lg px-4 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500 min-h-[200px]"
className="w-full bg-gray-700 rounded-lg px-4 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500 min-h-[200px] scroll-container overflow-y-auto"
placeholder="#EXTM3U..."
required={inputMethod === 'text'}
style={{ resize: 'none' }}
/>
</div>
)}