diff --git a/public/js/api.js b/public/js/api.js index c85d4b9..ba0871d 100644 --- a/public/js/api.js +++ b/public/js/api.js @@ -106,6 +106,11 @@ const API = { epg: { get: (sourceId) => API.request('GET', `/proxy/epg/${sourceId}`), getForChannels: (sourceId, channelIds) => API.request('POST', `/proxy/epg/${sourceId}/channels`, { channelIds }) + }, + + // Cache management + cache: { + clear: (sourceId) => API.request('DELETE', `/proxy/cache/${sourceId}`) } } }; diff --git a/public/js/components/SourceManager.js b/public/js/components/SourceManager.js index 3e07e50..9161693 100644 --- a/public/js/components/SourceManager.js +++ b/public/js/components/SourceManager.js @@ -294,10 +294,13 @@ class SourceManager { btn.textContent = '⏳'; } + // Clear cache for this source first + await API.proxy.cache.clear(id); + if (type === 'epg') { - // Force refresh EPG data (server-side cache will be bypassed) + // Force refresh EPG data if (window.app?.epgGuide) { - await window.app.epgGuide.loadEpg(true); // Force refresh + await window.app.epgGuide.loadEpg(true); } alert('EPG data refreshed!'); } else if (type === 'xtream') { diff --git a/public/js/pages/Guide.js b/public/js/pages/Guide.js index 99b64b8..adcf37e 100644 --- a/public/js/pages/Guide.js +++ b/public/js/pages/Guide.js @@ -12,8 +12,13 @@ class GuidePage { } async show() { - // Always reload EPG data when page is shown to ensure fresh data - await this.app.epgGuide.loadEpg(); + // Only load EPG data if not already loaded + if (!this.app.epgGuide.programmes || this.app.epgGuide.programmes.length === 0) { + await this.app.epgGuide.loadEpg(); + } else { + // Just re-render with existing data (updates time position) + this.app.epgGuide.render(); + } } hide() { diff --git a/server/routes/proxy.js b/server/routes/proxy.js index d25bda1..a214da9 100644 --- a/server/routes/proxy.js +++ b/server/routes/proxy.js @@ -4,6 +4,10 @@ const { sources } = require('../db'); const xtreamApi = require('../services/xtreamApi'); const m3uParser = require('../services/m3uParser'); const epgParser = require('../services/epgParser'); +const cache = require('../services/cache'); + +// Default cache TTL: 24 hours +const DEFAULT_MAX_AGE_HOURS = 24; /** * Proxy Xtream API calls @@ -11,15 +15,38 @@ const epgParser = require('../services/epgParser'); */ router.get('/xtream/:sourceId/:action', async (req, res) => { try { - const source = sources.getById(req.params.sourceId); + const sourceId = req.params.sourceId; + const source = sources.getById(sourceId); if (!source || source.type !== 'xtream') { return res.status(404).json({ error: 'Xtream source not found' }); } - const api = xtreamApi.createFromSource(source); const { action } = req.params; - const { category_id, stream_id, vod_id, series_id, limit } = req.query; + const { category_id, stream_id, vod_id, series_id, limit, refresh, maxAge } = req.query; + const forceRefresh = refresh === '1'; + const maxAgeHours = parseInt(maxAge) || DEFAULT_MAX_AGE_HOURS; + const maxAgeMs = maxAgeHours * 60 * 60 * 1000; + // Actions that should be cached + const cacheableActions = [ + 'live_categories', 'live_streams', + 'vod_categories', 'vod_streams', + 'series_categories', 'series' + ]; + + // Build cache key (include category_id if present) + const cacheKey = category_id ? `${action}_${category_id}` : action; + + // Check cache for cacheable actions + if (!forceRefresh && cacheableActions.includes(action)) { + const cached = cache.get('xtream', sourceId, cacheKey, maxAgeMs); + if (cached) { + return res.json(cached); + } + } + + // Fetch fresh data + const api = xtreamApi.createFromSource(source); let data; switch (action) { case 'auth': @@ -56,6 +83,11 @@ router.get('/xtream/:sourceId/:action', async (req, res) => { return res.status(400).json({ error: 'Unknown action' }); } + // Cache the result for cacheable actions + if (cacheableActions.includes(action)) { + cache.set('xtream', sourceId, cacheKey, data); + } + res.json(data); } catch (err) { console.error('Xtream proxy error:', err); @@ -92,12 +124,29 @@ router.get('/xtream/:sourceId/stream/:streamId/:type?', (req, res) => { */ router.get('/m3u/:sourceId', async (req, res) => { try { - const source = sources.getById(req.params.sourceId); + const sourceId = req.params.sourceId; + const source = sources.getById(sourceId); if (!source || source.type !== 'm3u') { return res.status(404).json({ error: 'M3U source not found' }); } + const forceRefresh = req.query.refresh === '1'; + const maxAgeHours = parseInt(req.query.maxAge) || DEFAULT_MAX_AGE_HOURS; + const maxAgeMs = maxAgeHours * 60 * 60 * 1000; + + // Check cache + if (!forceRefresh) { + const cached = cache.get('m3u', sourceId, 'playlist', maxAgeMs); + if (cached) { + return res.json(cached); + } + } + const data = await m3uParser.fetchAndParse(source.url); + + // Store in cache + cache.set('m3u', sourceId, 'playlist', data); + res.json(data); } catch (err) { console.error('M3U proxy error:', err); @@ -106,13 +155,7 @@ router.get('/m3u/:sourceId', async (req, res) => { }); /** - * EPG cache - in-memory for performance - * Structure: { [sourceId]: { data, fetchedAt } } - */ -const epgMemoryCache = {}; - -/** - * Fetch and parse EPG (with caching) + * Fetch and parse EPG (with file-based caching) * GET /api/proxy/epg/:sourceId * Query params: * - refresh=1 Force refresh, bypass cache @@ -127,15 +170,14 @@ router.get('/epg/:sourceId', async (req, res) => { } const forceRefresh = req.query.refresh === '1'; - const maxAgeHours = parseInt(req.query.maxAge) || 24; + const maxAgeHours = parseInt(req.query.maxAge) || DEFAULT_MAX_AGE_HOURS; const maxAgeMs = maxAgeHours * 60 * 60 * 1000; - // Check cache (unless force refresh) - if (!forceRefresh && epgMemoryCache[sourceId]) { - const cached = epgMemoryCache[sourceId]; - const age = Date.now() - cached.fetchedAt; - if (age < maxAgeMs) { - return res.json(cached.data); + // Check file cache (unless force refresh) + if (!forceRefresh) { + const cached = cache.get('epg', sourceId, 'data', maxAgeMs); + if (cached) { + return res.json(cached); } } @@ -148,11 +190,8 @@ router.get('/epg/:sourceId', async (req, res) => { const data = await epgParser.fetchAndParse(url); - // Store in cache - epgMemoryCache[sourceId] = { - data, - fetchedAt: Date.now() - }; + // Store in file cache + cache.set('epg', sourceId, 'data', data); res.json(data); } catch (err) { @@ -162,12 +201,22 @@ router.get('/epg/:sourceId', async (req, res) => { }); /** - * Clear EPG cache for a source + * Clear cache for a source + * DELETE /api/proxy/cache/:sourceId + */ +router.delete('/cache/:sourceId', (req, res) => { + const sourceId = req.params.sourceId; + cache.clearSource(sourceId); + res.json({ success: true }); +}); + +/** + * Clear EPG cache for a source (legacy endpoint, calls clearSource) * DELETE /api/proxy/epg/:sourceId/cache */ router.delete('/epg/:sourceId/cache', (req, res) => { const sourceId = req.params.sourceId; - delete epgMemoryCache[sourceId]; + cache.clear('epg', sourceId, 'data'); res.json({ success: true }); }); @@ -266,9 +315,10 @@ router.get('/stream', async (req, res) => { return `${req.protocol}://${req.get('host')}${req.baseUrl}/stream?url=${encodeURIComponent(absoluteUrl)}`; } catch (e) { return line; } }).join('\n'); - - return res.send(manifest); } + + // Return manifest (whether rewritten or not) + return res.send(manifest); } // Binary content (segments) diff --git a/server/services/cache.js b/server/services/cache.js new file mode 100644 index 0000000..4b09a76 --- /dev/null +++ b/server/services/cache.js @@ -0,0 +1,151 @@ +/** + * File-based Cache Service + * Stores cached data as JSON files in data/cache/ + */ + +const fs = require('fs'); +const path = require('path'); + +// Cache directory +const cacheDir = path.join(__dirname, '..', '..', 'data', 'cache'); + +// Ensure cache directories exist +function ensureCacheDir(type, sourceId) { + const dir = path.join(cacheDir, type, String(sourceId)); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } + return dir; +} + +// Get cache file path +function getCachePath(type, sourceId, key) { + const dir = ensureCacheDir(type, sourceId); + // Sanitize key for filename + const safeKey = String(key || 'default').replace(/[^a-zA-Z0-9_-]/g, '_'); + return path.join(dir, `${safeKey}.json`); +} + +/** + * Get cached data if not expired + * @param {string} type - Cache type (epg, m3u, xtream) + * @param {number|string} sourceId - Source ID + * @param {string} key - Cache key (e.g., action name) + * @param {number} maxAgeMs - Maximum age in milliseconds + * @returns {any|null} - Cached data or null if expired/missing + */ +function get(type, sourceId, key, maxAgeMs) { + try { + const cachePath = getCachePath(type, sourceId, key); + + if (!fs.existsSync(cachePath)) { + return null; + } + + const cached = JSON.parse(fs.readFileSync(cachePath, 'utf-8')); + const age = Date.now() - cached.timestamp; + + if (age > maxAgeMs) { + return null; // Expired + } + + return cached.data; + } catch (err) { + console.warn(`Cache read error for ${type}/${sourceId}/${key}:`, err.message); + return null; + } +} + +/** + * Store data in cache + * @param {string} type - Cache type + * @param {number|string} sourceId - Source ID + * @param {string} key - Cache key + * @param {any} data - Data to cache + */ +function set(type, sourceId, key, data) { + try { + const cachePath = getCachePath(type, sourceId, key); + const cached = { + timestamp: Date.now(), + data: data + }; + fs.writeFileSync(cachePath, JSON.stringify(cached)); + } catch (err) { + console.error(`Cache write error for ${type}/${sourceId}/${key}:`, err.message); + } +} + +/** + * Clear specific cache entry + */ +function clear(type, sourceId, key) { + try { + const cachePath = getCachePath(type, sourceId, key); + if (fs.existsSync(cachePath)) { + fs.unlinkSync(cachePath); + } + } catch (err) { + console.warn(`Cache clear error:`, err.message); + } +} + +/** + * Clear all cache for a source + */ +function clearSource(sourceId) { + try { + const types = ['epg', 'm3u', 'xtream']; + for (const type of types) { + const dir = path.join(cacheDir, type, String(sourceId)); + if (fs.existsSync(dir)) { + fs.rmSync(dir, { recursive: true }); + } + } + } catch (err) { + console.warn(`Cache clear source error:`, err.message); + } +} + +/** + * Clear all cache + */ +function clearAll() { + try { + if (fs.existsSync(cacheDir)) { + fs.rmSync(cacheDir, { recursive: true }); + } + } catch (err) { + console.warn(`Cache clear all error:`, err.message); + } +} + +/** + * Get cache info for debugging + */ +function getInfo(type, sourceId, key) { + try { + const cachePath = getCachePath(type, sourceId, key); + if (!fs.existsSync(cachePath)) { + return null; + } + const cached = JSON.parse(fs.readFileSync(cachePath, 'utf-8')); + const stats = fs.statSync(cachePath); + return { + timestamp: cached.timestamp, + age: Date.now() - cached.timestamp, + size: stats.size + }; + } catch (err) { + return null; + } +} + +module.exports = { + get, + set, + clear, + clearSource, + clearAll, + getInfo +};