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;