const { db } = require('../database/db'); const axios = require('axios'); const fs = require('fs').promises; const path = require('path'); const crypto = require('crypto'); const logger = require('../utils/logger'); const LOGO_CACHE_DIR = path.join(__dirname, '../../data/logo-cache'); const CACHE_REFRESH_INTERVAL = 7 * 24 * 60 * 60 * 1000; // 7 days const BATCH_SIZE = 5; // Process 5 logos at a time const BATCH_DELAY = 2000; // 2 seconds between batches // Ensure cache directory exists async function ensureCacheDir() { try { await fs.mkdir(LOGO_CACHE_DIR, { recursive: true }); } catch (err) { logger.error('Failed to create logo cache directory:', err); } } // Download and cache a single logo async function cacheLogo(channelName, logoUrl) { if (!logoUrl || !logoUrl.startsWith('http')) { return null; } try { // Generate filename from URL hash 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 relativeLocalPath = path.join('/app/data/logo-cache', filename); console.log(`[LogoCacher] Path for ${channelName}: ${relativeLocalPath}`); // Check if already cached in database and file exists const existingEntry = await new Promise((resolve) => { db.get( 'SELECT local_path, last_updated FROM logo_cache WHERE logo_url = ? LIMIT 1', [logoUrl], (err, row) => resolve(row) ); }); if (existingEntry) { try { const stats = await fs.stat(localPath); const age = Date.now() - new Date(existingEntry.last_updated).getTime(); if (age < CACHE_REFRESH_INTERVAL) { logger.debug(`Logo already cached for ${channelName}`); return relativeLocalPath; } } catch (err) { // File doesn't exist anymore, re-download } } // Download logo const response = await axios.get(logoUrl, { responseType: 'arraybuffer', timeout: 45000, // Increased for VPN connection headers: { 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', 'Accept': 'image/*', }, maxRedirects: 5 }); if (!response.data || response.data.length === 0) { console.error(`[LogoCacher] Empty response for ${channelName}`); return null; } // Save to disk await fs.writeFile(localPath, response.data); console.log(`[LogoCacher] Cached logo for ${channelName} (${response.data.length} bytes)`); logger.info(`Cached logo for ${channelName}: ${logoUrl}`); // Update database return new Promise((resolve, reject) => { console.log(`[LogoCacher] Inserting DB: name="${channelName}", url="${logoUrl}", path="${relativeLocalPath}"`); db.run( `INSERT OR REPLACE INTO logo_cache (channel_name, logo_url, local_path, last_updated) VALUES (?, ?, ?, CURRENT_TIMESTAMP)`, [channelName, logoUrl, relativeLocalPath], function(err) { if (err) { console.error(`[LogoCacher] DB INSERT FAILED for ${channelName}:`, err); logger.error(`Failed to update logo cache DB for ${channelName}:`, err); reject(err); } else { console.log(`[LogoCacher] DB INSERT SUCCESS for ${channelName}, rowID: ${this.lastID}`); resolve(relativeLocalPath); } } ); }); } catch (error) { logger.error(`Failed to cache logo for ${channelName}:`, error.message); return null; } } // Get all channels with logos that need caching function getChannelsNeedingCache() { return new Promise((resolve, reject) => { db.all( `SELECT DISTINCT c.name, COALESCE(c.custom_logo, c.logo) as logo FROM channels c WHERE (c.logo IS NOT NULL AND c.logo LIKE 'http%') OR (c.custom_logo IS NOT NULL AND c.custom_logo LIKE 'http%') ORDER BY c.name`, [], (err, rows) => { if (err) { logger.error('Error fetching channels for logo caching:', err); reject(err); } else { resolve(rows || []); } } ); }); } // Process logos in batches async function cacheAllLogos() { try { console.log('[LogoCacher] Starting logo caching...'); await ensureCacheDir(); const channels = await getChannelsNeedingCache(); console.log(`[LogoCacher] Found ${channels.length} channels with logos`); if (channels.length === 0) { logger.info('All channel logos are already cached'); return; } logger.info(`Caching logos for ${channels.length} channels...`); let cached = 0; // Process in batches for (let i = 0; i < channels.length; i += BATCH_SIZE) { const batch = channels.slice(i, i + BATCH_SIZE); console.log(`[LogoCacher] Processing batch ${Math.floor(i/BATCH_SIZE) + 1}/${Math.ceil(channels.length/BATCH_SIZE)}`); const results = await Promise.all( batch.map(channel => cacheLogo(channel.name, channel.logo)) ); cached += results.filter(r => r !== null).length; // Wait between batches to avoid overwhelming servers if (i + BATCH_SIZE < channels.length) { await new Promise(resolve => setTimeout(resolve, BATCH_DELAY)); } } logger.info(`Logo caching complete. Cached ${cached}/${channels.length} logos.`); console.log(`[LogoCacher] Completed: ${cached}/${channels.length} logos cached`); } catch (error) { logger.error('Error in logo caching job:', error); console.error('[LogoCacher] Error:', error.message); } } // Clean up old cached logos async function cleanupOldLogos() { try { // First, delete all database entries await new Promise((resolve, reject) => { db.run('DELETE FROM logo_cache', (err) => { if (err) reject(err); else resolve(); }); }); console.log('[LogoCacher] Cleared all logo cache database entries'); // Then delete old files const files = await fs.readdir(LOGO_CACHE_DIR); const cutoffTime = Date.now() - (30 * 24 * 60 * 60 * 1000); // 30 days let deleted = 0; for (const file of files) { const filePath = path.join(LOGO_CACHE_DIR, file); try { const stats = await fs.stat(filePath); if (stats.mtime.getTime() < cutoffTime) { await fs.unlink(filePath); deleted++; } } catch (err) { // Ignore errors for individual files } } if (deleted > 0) { logger.info(`Cleaned up ${deleted} old cached logos`); } } catch (error) { logger.error('Error cleaning up old logos:', error); } } // Initialize cache directory on startup but don't auto-cache // (VPN must be connected first for external logo downloads) ensureCacheDir().then(() => { logger.info('Logo caching job initialized (manual trigger required)'); }); module.exports = { cacheAllLogos, cacheLogo, cleanupOldLogos };