Updated cacheing to use persistent json cache

This commit is contained in:
Trevor Mears
2025-12-27 21:16:17 -08:00
parent 52a7cd53b4
commit bb5dc89311
5 changed files with 245 additions and 31 deletions
+5
View File
@@ -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}`)
}
}
};
+5 -2
View File
@@ -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') {
+7 -2
View File
@@ -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() {
+77 -27
View File
@@ -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)
+151
View File
@@ -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
};