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

841
backend/routes/m3u-files.js Normal file
View file

@ -0,0 +1,841 @@
const express = require('express');
const router = express.Router();
const path = require('path');
const fs = require('fs').promises;
const fsSync = require('fs');
const { authenticate } = require('../middleware/auth');
const { db } = require('../database/db');
const m3uParser = require('iptv-playlist-parser');
const axios = require('axios');
const crypto = require('crypto');
// Cache for IPTV-org logos database (in memory)
let logoDbCache = null;
let logoDbCacheTime = null;
const CACHE_DURATION = 24 * 60 * 60 * 1000; // 24 hours
// Local logo cache directory
const LOGO_CACHE_DIR = path.join('/app', 'data', 'logo-cache');
// Ensure logo cache directory exists
async function ensureLogoCacheDir() {
try {
await fs.mkdir(LOGO_CACHE_DIR, { recursive: true });
} catch (error) {
console.error('Failed to create logo cache directory:', error);
}
}
// Initialize cache directory
ensureLogoCacheDir();
// Download and cache a logo locally
async function downloadAndCacheLogo(logoUrl) {
try {
// Generate a hash-based filename
const hash = crypto.createHash('md5').update(logoUrl).digest('hex');
const ext = path.extname(new URL(logoUrl).pathname) || '.png';
const filename = `${hash}${ext}`;
const localPath = path.join(LOGO_CACHE_DIR, filename);
const publicPath = `/logos/${filename}`;
// Check if already cached
try {
await fs.access(localPath);
return publicPath; // Already cached
} catch {
// Not cached, download it
}
console.log(`Downloading logo: ${logoUrl}`);
const response = await axios({
method: 'GET',
url: logoUrl,
responseType: 'arraybuffer',
timeout: 10000,
headers: {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'
}
});
// Save to cache
await fs.writeFile(localPath, response.data);
console.log(`Cached logo: ${publicPath}`);
return publicPath;
} catch (error) {
console.error(`Failed to download logo ${logoUrl}:`, error.message);
// Return null instead of the failed URL to avoid overwriting existing logos
// with geo-blocked/inaccessible URLs
return null;
}
}
// Fetch and cache IPTV-org logos database
async function getLogoDatabase() {
const now = Date.now();
// Return cached data if still valid
if (logoDbCache && logoDbCacheTime && (now - logoDbCacheTime) < CACHE_DURATION) {
return logoDbCache;
}
try {
console.log('Fetching logos from tv-logo/tv-logos (Romania)...');
// Fetch Romanian logos from tv-logos repository
const response = await axios.get('https://api.github.com/repos/tv-logo/tv-logos/contents/countries/romania', {
timeout: 10000,
headers: {
'Accept': 'application/vnd.github.v3+json',
'User-Agent': 'StreamFlow-IPTV'
}
});
// Transform GitHub API response to match our logo database format
const logos = response.data
.filter(item => item.type === 'file' && (item.name.endsWith('.png') || item.name.endsWith('.svg')))
.map(item => ({
name: item.name.replace(/\.(png|svg)$/i, '').toLowerCase(),
url: item.download_url,
guides: []
}));
logoDbCache = logos;
logoDbCacheTime = now;
console.log(`Loaded ${logoDbCache.length} logos from tv-logos (Romania)`);
// Also try to fetch IPTV-org as fallback
try {
const iptvOrgResponse = await axios.get('https://iptv-org.github.io/api/logos.json', {
timeout: 5000
});
// Merge both sources, tv-logos takes priority
const combinedLogos = [...logoDbCache];
const existingNames = new Set(logoDbCache.map(l => l.name));
iptvOrgResponse.data.forEach(logo => {
if (!existingNames.has(logo.name.toLowerCase())) {
combinedLogos.push(logo);
}
});
logoDbCache = combinedLogos;
console.log(`Total logos after merging with IPTV-org: ${logoDbCache.length}`);
} catch (iptvError) {
console.log('IPTV-org fallback not available, using only tv-logos');
}
return logoDbCache;
} catch (error) {
console.error('Failed to fetch logos from tv-logos:', error.message);
// Try IPTV-org as fallback
try {
console.log('Trying IPTV-org as fallback...');
const response = await axios.get('https://iptv-org.github.io/api/logos.json', {
timeout: 10000
});
logoDbCache = response.data;
logoDbCacheTime = now;
console.log(`Loaded ${logoDbCache.length} logos from IPTV-org (fallback)`);
return logoDbCache;
} catch (fallbackError) {
console.error('Fallback to IPTV-org also failed:', fallbackError.message);
return logoDbCache || [];
}
}
}
// Find logo for a channel
async function findChannelLogo(channelName, channelUrl, logoDb) {
if (!logoDb || logoDb.length === 0) return null;
const cleanName = channelName.toLowerCase()
.replace(/\s*\([^)]*\)/g, '') // Remove parentheses content
.replace(/\s*\[[^\]]*\]/g, '') // Remove brackets content
.replace(/\s+/g, ' ')
.trim();
// Try exact match first
let match = logoDb.find(logo =>
logo.name.toLowerCase() === cleanName
);
if (match) return await downloadAndCacheLogo(match.url);
// Try partial match
match = logoDb.find(logo =>
cleanName.includes(logo.name.toLowerCase()) ||
logo.name.toLowerCase().includes(cleanName)
);
if (match) return await downloadAndCacheLogo(match.url);
// Try matching by domain in URL
if (channelUrl) {
try {
const urlObj = new URL(channelUrl);
const domain = urlObj.hostname.replace('www.', '');
match = logoDb.find(logo =>
logo.name.toLowerCase().includes(domain.split('.')[0])
);
if (match) return await downloadAndCacheLogo(match.url);
} catch (e) {
// Invalid URL, skip
}
}
return null;
}
// Helper function to detect if a channel is likely radio or TV
function detectChannelType(item, userSelectedType) {
const url = (item.url || '').toLowerCase();
const name = (item.name || '').toLowerCase();
const group = (item.group?.title || '').toLowerCase();
// Strong TV indicators - if any match, it's definitely TV
const strongTvIndicators = [
'.m3u8', // HLS video streams
'/playlist.m3u8', // Video playlists
'video',
'tv',
'1080p', '720p', '480p', '360p', '4k', 'hd', 'fhd', 'uhd', // Video quality markers
'tvsat', 'livestream'
];
// Strong radio indicators
const strongRadioIndicators = [
':8000/', ':8001/', ':8080/', ':8443/', // Common radio streaming ports
'/radio',
'radiostream',
'.mp3', '.aac', '.ogg', // Audio file extensions
'icecast', 'shoutcast' // Radio streaming platforms
];
// Radio name patterns
const radioNamePatterns = [
/^radio\s/i, // Starts with "Radio "
/\sradio$/i, // Ends with " Radio"
/\sradio\s/i, // Contains " Radio "
/\sfm$/i, // Ends with " FM"
/^fm\s/i, // Starts with "FM "
/\d+\.?\d*\s?fm/i, // Frequency like "101.5 FM" or "101FM"
/\sam\s/i, // Contains " AM "
];
// Check for strong TV indicators
for (const indicator of strongTvIndicators) {
if (url.includes(indicator) || name.includes(indicator)) {
return 0; // Definitely TV
}
}
// Check for strong radio indicators
for (const indicator of strongRadioIndicators) {
if (url.includes(indicator)) {
return 1; // Definitely radio
}
}
// Check radio name patterns
for (const pattern of radioNamePatterns) {
if (pattern.test(name) || pattern.test(group)) {
return 1; // Likely radio based on name
}
}
// Check group names
if (group.includes('radio') || group.includes('fm') || group.includes('am')) {
return 1;
}
// If no strong indicators, use user selection
return userSelectedType === 'radio' ? 1 : 0;
}
// Ensure M3U upload directory exists
const M3U_UPLOAD_DIR = path.join('/app', 'data', 'm3u-files');
try {
if (!fsSync.existsSync(M3U_UPLOAD_DIR)) {
fsSync.mkdirSync(M3U_UPLOAD_DIR, { recursive: true });
}
} catch (error) {
console.error('Failed to create M3U upload directory:', error);
}
// Get all M3U files for current user
router.get('/', authenticate, (req, res) => {
db.all(
`SELECT id, user_id, name, original_filename, size, created_at, updated_at
FROM m3u_files
WHERE user_id = ?
ORDER BY created_at DESC`,
[req.user.userId],
(err, files) => {
if (err) {
console.error('Error fetching M3U files:', err);
return res.status(500).json({ error: 'Failed to fetch M3U files' });
}
res.json(files);
}
);
});
// Download M3U file
router.get('/:id/download', authenticate, async (req, res) => {
const { id } = req.params;
db.get(
'SELECT * FROM m3u_files WHERE id = ? AND user_id = ?',
[id, req.user.userId],
async (err, file) => {
if (err) {
console.error('Error fetching M3U file:', err);
return res.status(500).json({ error: 'Failed to fetch M3U file' });
}
if (!file) {
return res.status(404).json({ error: 'M3U file not found' });
}
try {
const filePath = file.file_path;
// Sanitize filename to prevent path traversal attacks
const fileName = (file.original_filename || `${file.name}.m3u`).replace(/[^a-zA-Z0-9._-]/g, '_');
// Check if file exists
await fs.access(filePath);
// Set headers for download
res.setHeader('Content-Type', 'audio/x-mpegurl');
res.setHeader('Content-Disposition', `attachment; filename="${fileName}"`);
// Stream the file
const fileStream = fsSync.createReadStream(filePath);
fileStream.pipe(res);
} catch (error) {
console.error('Error downloading M3U file:', error);
res.status(500).json({ error: 'Failed to download M3U file' });
}
}
);
});
// Upload M3U file
router.post('/upload', authenticate, async (req, res) => {
try {
if (!req.files || !req.files.m3u) {
return res.status(400).json({ error: 'No file uploaded' });
}
const uploadedFile = req.files.m3u;
const { name } = req.body;
if (!name || !name.trim()) {
return res.status(400).json({ error: 'File name is required' });
}
// Validate file extension
const ext = path.extname(uploadedFile.name).toLowerCase();
if (ext !== '.m3u' && ext !== '.m3u8') {
return res.status(400).json({ error: 'Only .m3u and .m3u8 files are allowed' });
}
// Generate unique filename
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9);
const filename = uniqueSuffix + ext;
const filePath = path.join(M3U_UPLOAD_DIR, filename);
// Move file from temp location to M3U directory
await uploadedFile.mv(filePath);
db.run(
`INSERT INTO m3u_files (user_id, name, original_filename, file_path, size)
VALUES (?, ?, ?, ?, ?)`,
[req.user.userId, name.trim(), uploadedFile.name, filePath, uploadedFile.size],
function(err) {
if (err) {
console.error('Error uploading M3U file:', err);
fs.unlink(filePath).catch(console.error);
return res.status(500).json({ error: 'Failed to upload M3U file' });
}
db.get(
`SELECT id, user_id, name, original_filename, size, created_at, updated_at
FROM m3u_files WHERE id = ?`,
[this.lastID],
(err, file) => {
if (err) {
return res.status(500).json({ error: 'Failed to fetch uploaded file' });
}
res.status(201).json(file);
}
);
}
);
} catch (error) {
console.error('Error uploading M3U file:', error);
res.status(500).json({ error: 'Failed to upload M3U file' });
}
});
// Rename M3U file
router.patch('/:id', authenticate, (req, res) => {
const { id } = req.params;
const { name } = req.body;
if (!name || !name.trim()) {
return res.status(400).json({ error: 'File name is required' });
}
// Verify ownership
db.get(
'SELECT * FROM m3u_files WHERE id = ? AND user_id = ?',
[id, req.user.userId],
(err, file) => {
if (err) {
console.error('Error fetching M3U file:', err);
return res.status(500).json({ error: 'Failed to fetch M3U file' });
}
if (!file) {
return res.status(404).json({ error: 'M3U file not found' });
}
db.run(
'UPDATE m3u_files SET name = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?',
[name.trim(), id],
(err) => {
if (err) {
console.error('Error renaming M3U file:', err);
return res.status(500).json({ error: 'Failed to rename M3U file' });
}
db.get(
`SELECT id, user_id, name, original_filename, size, created_at, updated_at
FROM m3u_files WHERE id = ?`,
[id],
(err, updatedFile) => {
if (err) {
return res.status(500).json({ error: 'Failed to fetch updated file' });
}
res.json(updatedFile);
}
);
}
);
}
);
});
// Delete M3U file
router.delete('/:id', authenticate, async (req, res) => {
const { id } = req.params;
// Get file info and verify ownership
db.get(
'SELECT * FROM m3u_files WHERE id = ? AND user_id = ?',
[id, req.user.userId],
async (err, file) => {
if (err) {
console.error('Error fetching M3U file:', err);
return res.status(500).json({ error: 'Failed to fetch M3U file' });
}
if (!file) {
return res.status(404).json({ error: 'M3U file not found' });
}
// Delete from database
db.run('DELETE FROM m3u_files WHERE id = ?', [id], async (err) => {
if (err) {
console.error('Error deleting M3U file:', err);
return res.status(500).json({ error: 'Failed to delete M3U file' });
}
// Delete physical file
try {
await fs.unlink(file.file_path);
} catch (fsError) {
console.error('Error deleting physical file:', fsError);
}
res.json({ message: 'M3U file deleted successfully' });
});
}
);
});
// Import M3U file to playlist
router.post('/:id/import', authenticate, async (req, res) => {
try {
const { id } = req.params;
const { type } = req.body; // 'tv' or 'radio'
if (!type || !['tv', 'radio'].includes(type)) {
return res.status(400).json({ error: 'Invalid type. Must be "tv" or "radio"' });
}
// Fetch logo database in parallel with file processing
const logoDbPromise = getLogoDatabase();
// Get file info and verify ownership
db.get(
'SELECT * FROM m3u_files WHERE id = ? AND user_id = ?',
[id, req.user.userId],
async (err, file) => {
if (err) {
console.error('Error fetching M3U file:', err);
return res.status(500).json({ error: 'Failed to fetch M3U file' });
}
if (!file) {
return res.status(404).json({ error: 'M3U file not found' });
}
try {
// Read and parse M3U file
console.log('Reading M3U file:', file.file_path);
const m3uContent = await fs.readFile(file.file_path, 'utf-8');
console.log('M3U content length:', m3uContent.length);
const parsed = m3uParser.parse(m3uContent);
console.log('Parsed items count:', parsed.items.length);
// Wait for logo database
const logoDb = await logoDbPromise;
console.log(`Logo database ready with ${logoDb.length} entries`);
// Create or get playlist
db.get(
`SELECT * FROM playlists WHERE user_id = ? AND name = ?`,
[req.user.userId, file.name],
(err, playlist) => {
if (err) {
console.error('Error checking playlist:', err);
return res.status(500).json({ error: 'Failed to check playlist' });
}
const createOrUsePlaylist = async (playlistId) => {
// Insert channels
let channelsAdded = 0;
let channelsProcessed = 0;
const totalChannels = parsed.items.length;
if (totalChannels === 0) {
return res.json({
message: 'No channels found in M3U file',
playlist_id: playlistId,
channels_added: 0,
type,
});
}
// Process channels sequentially to handle async logo downloads
for (const item of parsed.items) {
try {
// Detect actual channel type using heuristics
const isRadio = detectChannelType(item, type);
// Try to find logo from IPTV-org if not provided, or cache existing logo
let logo = item.tvg?.logo;
if (!logo || logo.trim() === '') {
// No logo in M3U, try to find one from IPTV-org
const foundLogo = await findChannelLogo(item.name, item.url, logoDb);
if (foundLogo) {
logo = foundLogo;
}
} else {
// Logo exists in M3U, try to cache it
const cachedLogo = await downloadAndCacheLogo(logo);
// Only use cached logo if download succeeded
if (cachedLogo) {
logo = cachedLogo;
}
// Otherwise keep original logo URL from M3U
}
await new Promise((resolve, reject) => {
db.run(
`INSERT OR IGNORE INTO channels (
playlist_id, name, url, logo, group_name,
tvg_id, tvg_name, language, country, is_radio
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[
playlistId,
item.name || 'Unknown Channel',
item.url,
logo,
item.group?.title || null,
item.tvg?.id || null,
item.tvg?.name || null,
item.tvg?.language || null,
item.tvg?.country || null,
isRadio,
],
function(insertErr) {
if (!insertErr && this.changes > 0) {
channelsAdded++;
}
if (insertErr) {
console.error('Error inserting channel:', insertErr);
}
resolve();
}
);
});
channelsProcessed++;
} catch (error) {
console.error('Error processing channel:', error);
channelsProcessed++;
}
}
// Update complete
console.log(`Import complete: ${channelsAdded} channels added out of ${totalChannels}`);
// Update channel count - count all channels in this playlist regardless of type
db.run(
'UPDATE playlists SET channel_count = (SELECT COUNT(*) FROM channels WHERE playlist_id = ?) WHERE id = ?',
[playlistId, playlistId],
(updateErr) => {
if (updateErr) {
console.error('Error updating playlist count:', updateErr);
}
// Get actual counts of radio vs TV
db.get(
'SELECT SUM(is_radio = 1) as radio_count, SUM(is_radio = 0) as tv_count FROM channels WHERE playlist_id = ?',
[playlistId],
(err, counts) => {
const radioCount = counts?.radio_count || 0;
const tvCount = counts?.tv_count || 0;
res.json({
message: 'M3U file imported successfully',
playlist_id: playlistId,
channels_added: channelsAdded,
radio_channels: radioCount,
tv_channels: tvCount,
type,
});
}
);
}
);
};
if (!playlist) {
// Create new playlist
db.run(
`INSERT INTO playlists (user_id, name, type, url)
VALUES (?, ?, ?, ?)`,
[req.user.userId, file.name, type, file.file_path],
function(insertErr) {
if (insertErr) {
console.error('Error creating playlist:', insertErr);
return res.status(500).json({ error: 'Failed to create playlist' });
}
createOrUsePlaylist(this.lastID);
}
);
} else {
createOrUsePlaylist(playlist.id);
}
}
);
} catch (parseError) {
console.error('Error parsing/importing M3U file:', parseError);
console.error('Error stack:', parseError.stack);
res.status(500).json({ error: 'Failed to parse M3U file: ' + parseError.message });
}
}
);
} catch (error) {
console.error('Error importing M3U file:', error);
res.status(500).json({ error: 'Failed to import M3U file' });
}
});
// Fix channel types for existing channels (utility endpoint)
router.post('/fix-channel-types', authenticate, async (req, res) => {
try {
console.log('Starting channel type fix and deduplication...');
// First, remove duplicates (same URL in same playlist)
db.run(
`DELETE FROM channels WHERE id NOT IN (
SELECT MIN(id) FROM channels
GROUP BY playlist_id, url
)`,
(delErr, delResult) => {
if (delErr) {
console.error('Error removing duplicates:', delErr);
} else {
console.log('Duplicates removed');
}
// Now fix channel types
db.all(
`SELECT c.id, c.name, c.url, c.group_name, c.is_radio
FROM channels c
JOIN playlists p ON c.playlist_id = p.id
WHERE p.user_id = ?`,
[req.user.userId],
(err, channels) => {
if (err) {
console.error('Error fetching channels:', err);
return res.status(500).json({ error: 'Failed to fetch channels' });
}
let updated = 0;
let processed = 0;
const total = channels.length;
console.log(`Found ${total} channels to analyze`);
if (total === 0) {
return res.json({
message: 'No channels found',
total_channels: 0,
updated_channels: 0
});
}
channels.forEach(channel => {
// Detect correct type
const item = {
name: channel.name,
url: channel.url,
group: { title: channel.group_name }
};
// Determine if it should be radio or TV based on current type
const currentType = channel.is_radio === 1 ? 'radio' : 'tv';
const detectedIsRadio = detectChannelType(item, currentType);
// Only update if detection differs from current value
if (detectedIsRadio !== channel.is_radio) {
db.run(
'UPDATE channels SET is_radio = ? WHERE id = ?',
[detectedIsRadio, channel.id],
function(updateErr) {
processed++;
if (!updateErr && this.changes > 0) {
updated++;
console.log(`Updated channel ${channel.id} (${channel.name}): ${channel.is_radio} -> ${detectedIsRadio}`);
}
if (processed === total) {
console.log(`Fix complete: ${updated} channels updated out of ${total}`);
res.json({
message: 'Channel types fixed and duplicates removed',
total_channels: total,
updated_channels: updated
});
}
}
);
} else {
processed++;
if (processed === total) {
console.log(`Fix complete: ${updated} channels updated out of ${total}`);
res.json({
message: 'Channel types fixed and duplicates removed',
total_channels: total,
updated_channels: updated
});
}
}
});
}
);
}
);
} catch (error) {
console.error('Error fixing channel types:', error);
res.status(500).json({ error: 'Failed to fix channel types' });
}
});
// Update missing logos from IPTV-org database
router.post('/update-logos', authenticate, async (req, res) => {
try {
console.log('Fetching logo database...');
const logoDb = await getLogoDatabase();
if (!logoDb || logoDb.length === 0) {
return res.status(503).json({ error: 'Logo database unavailable' });
}
console.log('Fetching channels with missing logos...');
// Get channels without logos
db.all(
`SELECT c.id, c.name, c.url, c.logo
FROM channels c
JOIN playlists p ON c.playlist_id = p.id
WHERE p.user_id = ? AND (c.logo IS NULL OR c.logo = '')`,
[req.user.userId],
async (err, channels) => {
if (err) {
console.error('Error fetching channels:', err);
return res.status(500).json({ error: 'Failed to fetch channels' });
}
const total = channels.length;
console.log(`Found ${total} channels without logos`);
if (total === 0) {
return res.json({
message: 'All channels already have logos',
total_channels: 0,
updated_channels: 0
});
}
let updated = 0;
// Process sequentially to handle async logo downloads
for (const channel of channels) {
try {
const foundLogo = await findChannelLogo(channel.name, channel.url, logoDb);
if (foundLogo) {
await new Promise((resolve) => {
db.run(
'UPDATE channels SET logo = ? WHERE id = ?',
[foundLogo, channel.id],
function(updateErr) {
if (!updateErr && this.changes > 0) {
updated++;
console.log(`Updated logo for ${channel.name}: ${foundLogo}`);
}
resolve();
}
);
});
}
} catch (error) {
console.error(`Error updating logo for ${channel.name}:`, error);
}
}
console.log(`Logo update complete: ${updated} logos added`);
res.json({
message: 'Channel logos updated',
total_channels: total,
updated_channels: updated
});
}
);
} catch (error) {
console.error('Error updating logos:', error);
res.status(500).json({ error: 'Failed to update logos' });
}
});
module.exports = router;