From cb13a175c7d756acc11ce8fd15bba7ece0826de8 Mon Sep 17 00:00:00 2001 From: Viren070 Date: Sun, 26 Jan 2025 23:22:11 +0000 Subject: [PATCH] feat: memory cache --- packages/addon/src/server.ts | 8 ++++ packages/cloudflare-worker/src/index.ts | 11 +++++ packages/types/src/types.ts | 1 + packages/utils/src/cache.ts | 58 +++++++++++++++++++++++++ packages/utils/src/crypto.ts | 13 +++++- packages/utils/src/index.ts | 1 + packages/utils/src/mediaflow.ts | 19 +++++++- packages/utils/src/settings.ts | 3 ++ packages/wrappers/src/base.ts | 34 +++++++-------- 9 files changed, 129 insertions(+), 19 deletions(-) create mode 100644 packages/utils/src/cache.ts diff --git a/packages/addon/src/server.ts b/packages/addon/src/server.ts index 48e71d6b..1390bc42 100644 --- a/packages/addon/src/server.ts +++ b/packages/addon/src/server.ts @@ -11,6 +11,7 @@ import { addonDetails, compressAndEncrypt, parseAndDecryptString, + Cache, } from '@aiostreams/utils'; const app = express(); @@ -55,6 +56,8 @@ if (Settings.CUSTOM_CONFIGS) { } } +const cache = new Cache(Settings.MAX_CACHE_SIZE); + // Built-in middleware for parsing JSON app.use(express.json()); // Built-in middleware for parsing URL-encoded data @@ -73,6 +76,10 @@ app.get('/', (req, res) => { res.redirect('/configure'); }); +app.get('/cache-stats', (req, res) => { + res.send(cache.stats()); +}); + app.get( ['/_next/*', '/assets/*', '/icon.ico', '/configure.txt'], (req, res) => { @@ -185,6 +192,7 @@ app.get('/:config/stream/:type/:id.json', (req, res: Response): void => { return; } configJson.requestingIp = req.get('CF-Connecting-IP') || req.ip; + configJson.instanceCache = cache; const aioStreams = new AIOStreams(configJson); aioStreams.getStreams(streamRequest).then((streams) => { res.json({ streams: streams }); diff --git a/packages/cloudflare-worker/src/index.ts b/packages/cloudflare-worker/src/index.ts index 8b63010a..dc76c7d8 100644 --- a/packages/cloudflare-worker/src/index.ts +++ b/packages/cloudflare-worker/src/index.ts @@ -1,6 +1,7 @@ import { AIOStreams, errorResponse, validateConfig } from '@aiostreams/addon'; import manifest from '@aiostreams/addon/src/manifest'; import { Config, StreamRequest } from '@aiostreams/types'; +import { Cache } from '@aiostreams/utils'; const HEADERS = { 'Access-Control-Allow-Origin': '*', @@ -20,6 +21,8 @@ function createResponse(message: string, status: number): Response { }); } +const cache = new Cache(1024); + export default { async fetch(request, env, ctx): Promise { try { @@ -116,6 +119,14 @@ export default { let streamRequest: StreamRequest = { id, type }; + decodedConfig.requestingIp = + request.headers.get('X-Forwarded-For') || + request.headers.get('CF-Connecting-IP') || + request.headers.get('X-Real-IP') || + request.headers.get('X-Client-IP') || + undefined; + decodedConfig.instanceCache = cache; + const aioStreams = new AIOStreams(decodedConfig); const streams = await aioStreams.getStreams(streamRequest); return createJsonResponse({ streams }); diff --git a/packages/types/src/types.ts b/packages/types/src/types.ts index 1c71229d..9feb53ed 100644 --- a/packages/types/src/types.ts +++ b/packages/types/src/types.ts @@ -96,6 +96,7 @@ export type Encode = { [key: string]: boolean }; export type SortBy = { [key: string]: boolean | string | undefined }; export interface Config { + instanceCache?: any; requestingIp?: string; resolutions: Resolution[]; qualities: Quality[]; diff --git a/packages/utils/src/cache.ts b/packages/utils/src/cache.ts new file mode 100644 index 00000000..356bb628 --- /dev/null +++ b/packages/utils/src/cache.ts @@ -0,0 +1,58 @@ +class CacheItem { + constructor( + public value: T, + public lastAccessed: number, + public ttl: number // Time-To-Live in milliseconds + ) {} +} + +export class Cache { + private cache: Map>; + private maxSize: number; + + constructor(maxSize: number) { + this.cache = new Map>(); + this.maxSize = maxSize; + } + + stats(): string { + return `Cache size: ${this.cache.size}`; + } + + get(key: K): V | undefined { + const item = this.cache.get(key); + if (item) { + const now = Date.now(); + if (now - item.lastAccessed > item.ttl) { + this.cache.delete(key); + return undefined; + } + item.lastAccessed = now; + return item.value; + } + return undefined; + } + + set(key: K, value: V, ttl: number): void { + if (this.cache.size >= this.maxSize) { + this.evict(); + } + this.cache.set(key, new CacheItem(value, Date.now(), ttl * 1000)); + } + + private evict(): void { + let oldestKey: K | undefined; + let oldestTime = Infinity; + + for (const [key, item] of this.cache.entries()) { + if (item.lastAccessed < oldestTime) { + oldestTime = item.lastAccessed; + oldestKey = key; + } + } + + if (oldestKey !== undefined) { + this.cache.delete(oldestKey); + } + } +} diff --git a/packages/utils/src/crypto.ts b/packages/utils/src/crypto.ts index 855a9be1..2dddf3cf 100644 --- a/packages/utils/src/crypto.ts +++ b/packages/utils/src/crypto.ts @@ -1,4 +1,9 @@ -import { randomBytes, createCipheriv, createDecipheriv } from 'crypto'; +import { + randomBytes, + createCipheriv, + createDecipheriv, + createHash, +} from 'crypto'; import { deflateSync, inflateSync } from 'zlib'; import { Settings } from './settings'; @@ -78,3 +83,9 @@ export function parseAndDecryptString(data: string): string | null { return null; } } + +export function getTextHash(text: string): string { + const hash = createHash('sha256'); + hash.update(text); + return hash.digest('hex'); +} diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts index 58bfa1a3..5134cc0d 100644 --- a/packages/utils/src/index.ts +++ b/packages/utils/src/index.ts @@ -2,3 +2,4 @@ export * from './crypto'; export * from './details'; export * from './settings'; export * from './mediaflow'; +export * from './cache'; diff --git a/packages/utils/src/mediaflow.ts b/packages/utils/src/mediaflow.ts index 0f2f2aeb..ec4aeef5 100644 --- a/packages/utils/src/mediaflow.ts +++ b/packages/utils/src/mediaflow.ts @@ -1,6 +1,8 @@ import { Config } from '@aiostreams/types'; import path from 'path'; import { Settings } from './settings'; +import { getTextHash } from './crypto'; +import { Cache } from './cache'; const PRIVATE_CIDR = /^(10\.|127\.|172\.(1[6-9]|2[0-9]|3[01])\.|192\.168\.)/; @@ -62,7 +64,8 @@ export function createProxiedMediaFlowUrl( } export async function getMediaFlowPublicIp( - mediaFlowConfig: Config['mediaFlowConfig'] + mediaFlowConfig: Config['mediaFlowConfig'], + cache: Cache ) { try { if (!mediaFlowConfig) { @@ -92,6 +95,17 @@ export async function getMediaFlowPublicIp( return null; } + const cacheKey = getTextHash( + `mediaFlowPublicIp:${mediaFlowConfig.proxyUrl}:${mediaFlowConfig.apiPassword}` + ); + const cachedPublicIp = cache.get(cacheKey); + if (cachedPublicIp) { + console.debug( + `|DBG| mediaflow > getMediaFlowPublicIp > Returning cached public IP` + ); + return cachedPublicIp; + } + console.debug( '|DBG| mediaflow > getMediaFlowPublicIp > GET /proxy/ip?api_password=***' ); @@ -117,6 +131,9 @@ export async function getMediaFlowPublicIp( const data = await response.json(); const publicIp = data.ip; + if (publicIp) { + cache.set(cacheKey, publicIp, 900); + } return publicIp; } catch (error: any) { console.error( diff --git a/packages/utils/src/settings.ts b/packages/utils/src/settings.ts index 78477e08..9f7fabbb 100644 --- a/packages/utils/src/settings.ts +++ b/packages/utils/src/settings.ts @@ -50,6 +50,9 @@ export class Settings { process.env.DEFAULT_MEDIAFLOW_API_PASSWORD ?? ''; public static readonly DEFAULT_MEDIAFLOW_PUBLIC_IP = process.env.DEFAULT_MEDIAFLOW_PUBLIC_IP ?? ''; + public static readonly MAX_CACHE_SIZE = process.env.MAX_CACHE_SIZE + ? parseInt(process.env.MAX_CACHE_SIZE) + : 1024; public static readonly MAX_ADDONS = process.env.MAX_ADDONS ? parseInt(process.env.MAX_ADDONS) : 15; diff --git a/packages/wrappers/src/base.ts b/packages/wrappers/src/base.ts index 76da31ea..3eb105ce 100644 --- a/packages/wrappers/src/base.ts +++ b/packages/wrappers/src/base.ts @@ -9,6 +9,7 @@ import { parseFilename } from '@aiostreams/parser'; import { getMediaFlowConfig, getMediaFlowPublicIp, + getTextHash, serviceDetails, Settings, } from '@aiostreams/utils'; @@ -67,7 +68,10 @@ export class BaseWrapper { let userIp = this.userConfig.requestingIp; const mediaFlowConfig = getMediaFlowConfig(this.userConfig); if (mediaFlowConfig.mediaFlowEnabled) { - const mediaFlowIp = await getMediaFlowPublicIp(mediaFlowConfig); + const mediaFlowIp = await getMediaFlowPublicIp( + mediaFlowConfig, + this.userConfig.instanceCache + ); if (!mediaFlowIp) { throw new Error('Failed to get public IP from MediaFlow'); } @@ -83,6 +87,17 @@ export class BaseWrapper { }, this.indexerTimeout); const url = this.getStreamUrl(streamRequest); + const cache = this.userConfig.instanceCache; + const requestCacheKey = getTextHash(url); + const cachedStreams = cache.get(requestCacheKey); + const sanitisedUrl = + new URL(url).hostname + '/****/' + new URL(url).pathname.split('/').pop(); + if (cachedStreams) { + console.debug( + `|DBG| wrappers > base > ${this.addonName}: Returning cached streams for ${sanitisedUrl}` + ); + return cachedStreams; + } try { // Add requesting IP to headers const headers = new Headers(); @@ -97,7 +112,6 @@ export class BaseWrapper { headers.set('X-Real-IP', userIp); } const urlParts = url.split('/'); - const sanitisedUrl = `${urlParts[0]}//${urlParts[2]}/*************/${urlParts.slice(-3).join('/')}`; console.log( `|INF| wrappers > base > ${this.addonName}: Fetching with timeout ${this.indexerTimeout}ms from ${sanitisedUrl}` ); @@ -143,6 +157,7 @@ export class BaseWrapper { if (!results.streams) { throw new Error('Failed to respond with streams'); } + cache.set(requestCacheKey, results.streams, 300); // cache for 5 minutes return results.streams; } catch (error: any) { clearTimeout(timeout); @@ -215,9 +230,6 @@ export class BaseWrapper { let description = stream.description || stream.title; if (!filename && description) { - console.log( - `|DBG| wrappers > base > parseStream: No filename found in behaviorHints, attempting to parse from description` - ); const lines = description.split('\n'); filename = lines.find( @@ -226,13 +238,6 @@ export class BaseWrapper { /(? base > parseStream: With description: ${description.replace(/\n/g, ' ').trim()}, chose filename as: ${filename.replace(/\n/g, ' ').trim()}` - ); - } else if (!description) { - console.log( - `|WRN| wrappers > base > parseStream: No description found, filename could not be determined` - ); } let stringToParse: string = filename || description || ''; @@ -358,11 +363,6 @@ export class BaseWrapper { }; } }); - if (!provider) { - console.log( - `|WRN| wrappers > base > parseServiceData: No provider found for ${string}` - ); - } return provider; } protected extractSizeInBytes(string: string, k: number): number {