842 lines
28 KiB
JavaScript
842 lines
28 KiB
JavaScript
|
|
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;
|