Initial commit: StreamFlow IPTV platform
This commit is contained in:
commit
73a8ae9ffd
1240 changed files with 278451 additions and 0 deletions
841
backend/routes/m3u-files.js
Normal file
841
backend/routes/m3u-files.js
Normal 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;
|
||||
Loading…
Add table
Add a link
Reference in a new issue