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:
@@ -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!
|
||||
|
||||
@@ -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.
|
||||
|
||||
148
backend/controllers/CentralChannelController.js
Normal file
148
backend/controllers/CentralChannelController.js
Normal 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);
|
||||
}
|
||||
};
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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;
|
||||
75
frontend/src/components/TvPlaylistModal.tsx
Normal file
75
frontend/src/components/TvPlaylistModal.tsx
Normal 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;
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user