Updated cacheing to use persistent json cache
This commit is contained in:
@@ -106,6 +106,11 @@ const API = {
|
|||||||
epg: {
|
epg: {
|
||||||
get: (sourceId) => API.request('GET', `/proxy/epg/${sourceId}`),
|
get: (sourceId) => API.request('GET', `/proxy/epg/${sourceId}`),
|
||||||
getForChannels: (sourceId, channelIds) => API.request('POST', `/proxy/epg/${sourceId}/channels`, { channelIds })
|
getForChannels: (sourceId, channelIds) => API.request('POST', `/proxy/epg/${sourceId}/channels`, { channelIds })
|
||||||
|
},
|
||||||
|
|
||||||
|
// Cache management
|
||||||
|
cache: {
|
||||||
|
clear: (sourceId) => API.request('DELETE', `/proxy/cache/${sourceId}`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -294,10 +294,13 @@ class SourceManager {
|
|||||||
btn.textContent = '⏳';
|
btn.textContent = '⏳';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Clear cache for this source first
|
||||||
|
await API.proxy.cache.clear(id);
|
||||||
|
|
||||||
if (type === 'epg') {
|
if (type === 'epg') {
|
||||||
// Force refresh EPG data (server-side cache will be bypassed)
|
// Force refresh EPG data
|
||||||
if (window.app?.epgGuide) {
|
if (window.app?.epgGuide) {
|
||||||
await window.app.epgGuide.loadEpg(true); // Force refresh
|
await window.app.epgGuide.loadEpg(true);
|
||||||
}
|
}
|
||||||
alert('EPG data refreshed!');
|
alert('EPG data refreshed!');
|
||||||
} else if (type === 'xtream') {
|
} else if (type === 'xtream') {
|
||||||
|
|||||||
@@ -12,8 +12,13 @@ class GuidePage {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async show() {
|
async show() {
|
||||||
// Always reload EPG data when page is shown to ensure fresh data
|
// Only load EPG data if not already loaded
|
||||||
await this.app.epgGuide.loadEpg();
|
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() {
|
hide() {
|
||||||
|
|||||||
+77
-27
@@ -4,6 +4,10 @@ const { sources } = require('../db');
|
|||||||
const xtreamApi = require('../services/xtreamApi');
|
const xtreamApi = require('../services/xtreamApi');
|
||||||
const m3uParser = require('../services/m3uParser');
|
const m3uParser = require('../services/m3uParser');
|
||||||
const epgParser = require('../services/epgParser');
|
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
|
* Proxy Xtream API calls
|
||||||
@@ -11,15 +15,38 @@ const epgParser = require('../services/epgParser');
|
|||||||
*/
|
*/
|
||||||
router.get('/xtream/:sourceId/:action', async (req, res) => {
|
router.get('/xtream/:sourceId/:action', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const source = sources.getById(req.params.sourceId);
|
const sourceId = req.params.sourceId;
|
||||||
|
const source = sources.getById(sourceId);
|
||||||
if (!source || source.type !== 'xtream') {
|
if (!source || source.type !== 'xtream') {
|
||||||
return res.status(404).json({ error: 'Xtream source not found' });
|
return res.status(404).json({ error: 'Xtream source not found' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const api = xtreamApi.createFromSource(source);
|
|
||||||
const { action } = req.params;
|
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;
|
let data;
|
||||||
switch (action) {
|
switch (action) {
|
||||||
case 'auth':
|
case 'auth':
|
||||||
@@ -56,6 +83,11 @@ router.get('/xtream/:sourceId/:action', async (req, res) => {
|
|||||||
return res.status(400).json({ error: 'Unknown action' });
|
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);
|
res.json(data);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Xtream proxy error:', 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) => {
|
router.get('/m3u/:sourceId', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const source = sources.getById(req.params.sourceId);
|
const sourceId = req.params.sourceId;
|
||||||
|
const source = sources.getById(sourceId);
|
||||||
if (!source || source.type !== 'm3u') {
|
if (!source || source.type !== 'm3u') {
|
||||||
return res.status(404).json({ error: 'M3U source not found' });
|
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);
|
const data = await m3uParser.fetchAndParse(source.url);
|
||||||
|
|
||||||
|
// Store in cache
|
||||||
|
cache.set('m3u', sourceId, 'playlist', data);
|
||||||
|
|
||||||
res.json(data);
|
res.json(data);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('M3U proxy error:', err);
|
console.error('M3U proxy error:', err);
|
||||||
@@ -106,13 +155,7 @@ router.get('/m3u/:sourceId', async (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* EPG cache - in-memory for performance
|
* Fetch and parse EPG (with file-based caching)
|
||||||
* Structure: { [sourceId]: { data, fetchedAt } }
|
|
||||||
*/
|
|
||||||
const epgMemoryCache = {};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetch and parse EPG (with caching)
|
|
||||||
* GET /api/proxy/epg/:sourceId
|
* GET /api/proxy/epg/:sourceId
|
||||||
* Query params:
|
* Query params:
|
||||||
* - refresh=1 Force refresh, bypass cache
|
* - refresh=1 Force refresh, bypass cache
|
||||||
@@ -127,15 +170,14 @@ router.get('/epg/:sourceId', async (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const forceRefresh = req.query.refresh === '1';
|
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;
|
const maxAgeMs = maxAgeHours * 60 * 60 * 1000;
|
||||||
|
|
||||||
// Check cache (unless force refresh)
|
// Check file cache (unless force refresh)
|
||||||
if (!forceRefresh && epgMemoryCache[sourceId]) {
|
if (!forceRefresh) {
|
||||||
const cached = epgMemoryCache[sourceId];
|
const cached = cache.get('epg', sourceId, 'data', maxAgeMs);
|
||||||
const age = Date.now() - cached.fetchedAt;
|
if (cached) {
|
||||||
if (age < maxAgeMs) {
|
return res.json(cached);
|
||||||
return res.json(cached.data);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -148,11 +190,8 @@ router.get('/epg/:sourceId', async (req, res) => {
|
|||||||
|
|
||||||
const data = await epgParser.fetchAndParse(url);
|
const data = await epgParser.fetchAndParse(url);
|
||||||
|
|
||||||
// Store in cache
|
// Store in file cache
|
||||||
epgMemoryCache[sourceId] = {
|
cache.set('epg', sourceId, 'data', data);
|
||||||
data,
|
|
||||||
fetchedAt: Date.now()
|
|
||||||
};
|
|
||||||
|
|
||||||
res.json(data);
|
res.json(data);
|
||||||
} catch (err) {
|
} 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
|
* DELETE /api/proxy/epg/:sourceId/cache
|
||||||
*/
|
*/
|
||||||
router.delete('/epg/:sourceId/cache', (req, res) => {
|
router.delete('/epg/:sourceId/cache', (req, res) => {
|
||||||
const sourceId = req.params.sourceId;
|
const sourceId = req.params.sourceId;
|
||||||
delete epgMemoryCache[sourceId];
|
cache.clear('epg', sourceId, 'data');
|
||||||
res.json({ success: true });
|
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)}`;
|
return `${req.protocol}://${req.get('host')}${req.baseUrl}/stream?url=${encodeURIComponent(absoluteUrl)}`;
|
||||||
} catch (e) { return line; }
|
} catch (e) { return line; }
|
||||||
}).join('\n');
|
}).join('\n');
|
||||||
|
|
||||||
return res.send(manifest);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Return manifest (whether rewritten or not)
|
||||||
|
return res.send(manifest);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Binary content (segments)
|
// Binary content (segments)
|
||||||
|
|||||||
@@ -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
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user