Initial commit: StreamFlow IPTV platform

This commit is contained in:
aiulian25 2025-12-17 00:42:43 +00:00
commit 73a8ae9ffd
1240 changed files with 278451 additions and 0 deletions

418
backend/routes/stream.js Normal file
View file

@ -0,0 +1,418 @@
const express = require('express');
const router = express.Router();
const { spawn, exec } = require('child_process');
const axios = require('axios');
const https = require('https');
const { SocksProxyAgent } = require('socks-proxy-agent');
const { HttpsProxyAgent } = require('https-proxy-agent');
const { authenticate } = require('../middleware/auth');
const { heavyLimiter, readLimiter } = require('../middleware/rateLimiter');
const { db } = require('../database/db');
const logger = require('../utils/logger');
// HTTPS agent to bypass SSL certificate verification for IPTV streams
const httpsAgent = new https.Agent({
rejectUnauthorized: false
});
// Check if user has active VPN connection and return appropriate agent
const getVPNAgent = async (userId) => {
return new Promise((resolve) => {
db.get(
'SELECT connected FROM vpn_settings WHERE user_id = ? AND connected = 1',
[userId],
(err, row) => {
if (err || !row) {
// No VPN, use standard HTTPS agent
resolve(httpsAgent);
} else {
// VPN active - traffic will automatically route through VPN interface
// Use standard agent, OS routing will handle VPN
logger.info('VPN active for user, traffic will route through VPN');
resolve(httpsAgent);
}
}
);
});
};
// Check hardware acceleration availability
const checkHardwareAcceleration = () => {
const capabilities = {
quicksync: false,
nvenc: false,
vaapi: false,
videotoolbox: false
};
// Check for Intel Quick Sync (typically /dev/dri/renderD128)
try {
const fs = require('fs');
if (fs.existsSync('/dev/dri/renderD128')) {
capabilities.quicksync = true;
capabilities.vaapi = true;
}
} catch (err) {
logger.debug('Quick Sync not available');
}
// Check for NVIDIA
try {
const { execSync } = require('child_process');
execSync('nvidia-smi', { stdio: 'ignore' });
capabilities.nvenc = true;
} catch (err) {
logger.debug('NVENC not available');
}
return capabilities;
};
// Get hardware acceleration capabilities
router.get('/capabilities', authenticate, readLimiter, (req, res) => {
const capabilities = checkHardwareAcceleration();
res.json(capabilities);
});
// Get user's stream settings
const getStreamSettings = (userId, callback) => {
db.get(
'SELECT value FROM settings WHERE user_id = ? AND key = ?',
[userId, 'stream_settings'],
(err, result) => {
if (err || !result) {
// Default settings
return callback({
hwaccel: 'auto',
hwaccel_device: '/dev/dri/renderD128',
codec: 'h264',
preset: 'veryfast',
buffer_size: '2M',
max_bitrate: '8M'
});
}
try {
callback(JSON.parse(result.value));
} catch {
callback({
hwaccel: 'auto',
hwaccel_device: '/dev/dri/renderD128',
codec: 'h264',
preset: 'veryfast',
buffer_size: '2M',
max_bitrate: '8M'
});
}
}
);
};
// Universal proxy for all streams with geo-blocking bypass
router.get('/proxy/:channelId', authenticate, heavyLimiter, async (req, res) => {
const { channelId } = req.params;
console.log(`[STREAM] Proxy request for channel ${channelId}`);
try {
const channel = await new Promise((resolve, reject) => {
db.get('SELECT url, name, is_radio FROM channels WHERE id = ?', [channelId], (err, row) => {
if (err) {
console.error('[STREAM] Database error:', err);
reject(err);
} else if (!row) {
console.error('[STREAM] Channel not found:', channelId);
reject(new Error('Channel not found'));
} else {
console.log('[STREAM] Found channel:', row.name);
resolve(row);
}
});
});
if (!channel.url) {
return res.status(400).json({ error: 'Channel has no URL' });
}
logger.info(`Proxying ${channel.is_radio ? 'radio' : 'video'} stream: ${channel.name} - ${channel.url}`);
// Extract origin from URL for proper spoofing
const urlObj = new URL(channel.url);
const origin = `${urlObj.protocol}//${urlObj.hostname}`;
const referer = origin;
// Get VPN-aware agent for this user
const agent = await getVPNAgent(req.user.userId);
const requestConfig = {
method: 'GET',
url: channel.url,
responseType: channel.url.includes('.m3u8') ? 'text' : 'stream',
timeout: 30000,
validateStatus: (status) => status < 500,
httpsAgent: agent,
headers: {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
'Accept': '*/*',
'Accept-Language': 'en-US,en;q=0.9',
'Accept-Encoding': 'identity',
'Origin': origin,
'Referer': referer,
'Connection': 'keep-alive',
'Sec-Fetch-Dest': 'empty',
'Sec-Fetch-Mode': 'cors',
'Sec-Fetch-Site': 'cross-site'
}
};
// Proxy with origin spoofing to bypass geo-blocking
const response = await axios(requestConfig);
if (response.status >= 400) {
logger.error(`Stream returned status ${response.status}`);
return res.status(response.status).json({ error: 'Stream unavailable' });
}
// Handle HLS manifests - rewrite URLs to go through our proxy
if (channel.url.includes('.m3u8')) {
const m3u8Content = response.data;
const baseUrl = channel.url.substring(0, channel.url.lastIndexOf('/') + 1);
// Rewrite relative URLs in playlist to absolute URLs through our proxy
const rewrittenContent = m3u8Content.split('\n').map(line => {
if (line.startsWith('#') || line.trim() === '') {
return line;
}
// Convert relative to absolute URL
let absoluteUrl = line;
if (!line.startsWith('http')) {
absoluteUrl = baseUrl + line;
}
// Proxy the URL through our server
const proxyUrl = `/api/stream/hls-segment?url=${encodeURIComponent(absoluteUrl)}&token=${req.query.token}`;
return proxyUrl;
}).join('\n');
res.setHeader('Content-Type', 'application/vnd.apple.mpegurl');
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Access-Control-Allow-Headers', '*');
res.setHeader('Cache-Control', 'no-cache');
return res.send(rewrittenContent);
}
// For binary streams, pipe directly
res.setHeader('Content-Type', response.headers['content-type'] || 'application/octet-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Access-Control-Allow-Headers', '*');
res.setHeader('Access-Control-Allow-Methods', 'GET, HEAD, OPTIONS');
if (response.headers['content-length']) {
res.setHeader('Content-Length', response.headers['content-length']);
}
if (response.headers['content-range']) {
res.setHeader('Content-Range', response.headers['content-range']);
res.setHeader('Accept-Ranges', 'bytes');
}
response.data.pipe(res);
response.data.on('error', (error) => {
logger.error('Stream error:', error.message);
if (!res.headersSent) {
res.status(500).json({ error: 'Stream failed' });
}
});
req.on('close', () => {
logger.info('Client disconnected from stream');
if (response.data && !response.data.destroyed) {
response.data.destroy();
}
});
} catch (error) {
console.error('[STREAM] Proxy error:', error);
logger.error('Proxy error:', error.message);
if (!res.headersSent) {
res.status(500).json({ error: 'Failed to proxy stream', details: error.message });
}
}
});
// Proxy HLS segments (playlists and .ts chunks)
router.get('/hls-segment', authenticate, heavyLimiter, async (req, res) => {
const { url } = req.query;
if (!url) {
return res.status(400).json({ error: 'URL parameter required' });
}
try {
const urlObj = new URL(url);
const origin = `${urlObj.protocol}//${urlObj.hostname}`;
logger.info(`Proxying HLS segment: ${url}`);
const response = await axios({
method: 'GET',
url: url,
responseType: url.includes('.m3u8') ? 'text' : 'stream',
timeout: 15000,
httpsAgent: httpsAgent,
validateStatus: (status) => status < 500,
headers: {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
'Accept': '*/*',
'Origin': origin,
'Referer': origin,
'Connection': 'keep-alive'
}
});
if (response.status >= 400) {
return res.status(response.status).json({ error: 'Segment unavailable' });
}
// Handle nested m3u8 playlists
if (url.includes('.m3u8')) {
const m3u8Content = response.data;
const baseUrl = url.substring(0, url.lastIndexOf('/') + 1);
const rewrittenContent = m3u8Content.split('\n').map(line => {
if (line.startsWith('#') || line.trim() === '') {
return line;
}
let absoluteUrl = line;
if (!line.startsWith('http')) {
absoluteUrl = baseUrl + line;
}
return `/api/stream/hls-segment?url=${encodeURIComponent(absoluteUrl)}&token=${req.query.token}`;
}).join('\n');
res.setHeader('Content-Type', 'application/vnd.apple.mpegurl');
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Cache-Control', 'no-cache');
return res.send(rewrittenContent);
}
// Stream binary segments
res.setHeader('Content-Type', response.headers['content-type'] || 'video/mp2t');
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Cache-Control', 'public, max-age=300');
if (response.headers['content-length']) {
res.setHeader('Content-Length', response.headers['content-length']);
}
response.data.pipe(res);
response.data.on('error', (error) => {
logger.error('HLS segment error:', error.message);
if (!res.headersSent) {
res.status(500).end();
}
});
} catch (error) {
logger.error('HLS segment proxy error:', error.message);
if (!res.headersSent) {
res.status(500).json({ error: 'Failed to proxy segment' });
}
}
});
// Stream proxy with hardware acceleration (for transcoding if needed)
router.get('/proxy-ffmpeg/:channelId', authenticate, heavyLimiter, (req, res) => {
const { channelId } = req.params;
db.get(
'SELECT url FROM channels WHERE id = ?',
[channelId],
(err, channel) => {
if (err || !channel) {
return res.status(404).json({ error: 'Channel not found' });
}
getStreamSettings(req.user.userId, (settings) => {
const capabilities = checkHardwareAcceleration();
// Build FFmpeg command with hardware acceleration
const ffmpegArgs = [
'-re',
'-i', channel.url,
'-c:v', 'copy',
'-c:a', 'copy',
'-f', 'mpegts',
'pipe:1'
];
// Add hardware acceleration if enabled and available
if (settings.hwaccel !== 'none') {
if (settings.hwaccel === 'quicksync' && capabilities.quicksync) {
ffmpegArgs.unshift(
'-hwaccel', 'qsv',
'-hwaccel_device', settings.hwaccel_device || '/dev/dri/renderD128',
'-hwaccel_output_format', 'qsv'
);
} else if (settings.hwaccel === 'vaapi' && capabilities.vaapi) {
ffmpegArgs.unshift(
'-hwaccel', 'vaapi',
'-hwaccel_device', settings.hwaccel_device || '/dev/dri/renderD128',
'-hwaccel_output_format', 'vaapi'
);
} else if (settings.hwaccel === 'nvenc' && capabilities.nvenc) {
ffmpegArgs.unshift(
'-hwaccel', 'cuda',
'-hwaccel_output_format', 'cuda'
);
} else if (settings.hwaccel === 'auto') {
// Auto-detect best available
if (capabilities.quicksync) {
ffmpegArgs.unshift(
'-hwaccel', 'qsv',
'-hwaccel_device', '/dev/dri/renderD128',
'-hwaccel_output_format', 'qsv'
);
} else if (capabilities.nvenc) {
ffmpegArgs.unshift(
'-hwaccel', 'cuda',
'-hwaccel_output_format', 'cuda'
);
}
}
}
logger.info(`Streaming channel ${channelId} with args:`, ffmpegArgs);
const ffmpeg = spawn('ffmpeg', ffmpegArgs);
res.setHeader('Content-Type', 'video/mp2t');
res.setHeader('Cache-Control', 'no-cache');
ffmpeg.stdout.pipe(res);
ffmpeg.stderr.on('data', (data) => {
logger.debug(`FFmpeg: ${data}`);
});
ffmpeg.on('error', (error) => {
logger.error('FFmpeg error:', error);
if (!res.headersSent) {
res.status(500).json({ error: 'Stream failed' });
}
});
ffmpeg.on('close', (code) => {
logger.info(`FFmpeg closed with code ${code}`);
});
req.on('close', () => {
logger.info('Client disconnected, stopping stream');
ffmpeg.kill('SIGTERM');
});
});
}
);
});
module.exports = router;