From dc093f7109198db095900ed77f13c1d146df366f Mon Sep 17 00:00:00 2001 From: WebStreamr <210764791+webstreamr@users.noreply.github.com> Date: Tue, 2 Sep 2025 13:33:24 +0000 Subject: [PATCH] refactor: consistently use `cacheable` for caching --- package-lock.json | 52 ++++++++++++++++++------------ package.json | 3 +- src/extractor/ExtractorRegistry.ts | 17 +++++----- src/source/Source.ts | 15 +++++---- src/utils/Fetcher.ts | 41 ++++++++++++----------- 5 files changed, 72 insertions(+), 56 deletions(-) diff --git a/package-lock.json b/package-lock.json index 2791c98..9781621 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,16 +9,15 @@ "version": "0.45.2", "license": "MIT", "dependencies": { - "@isaacs/ttlcache": "^1.4.1", "@types/jsdom": "^21.1.7", "async-mutex": "^0.5.0", "bytes": "^3.1.2", + "cacheable": "^1.10.4", "cheerio": "^1.0.0", "express": "^5.1.0", "fetch-socks": "^1.3.2", "http-cache-semantics": "^4.2.0", "jsdom": "^26.1.0", - "lru-cache": "^11.1.0", "randomstring": "^1.3.1", "slugify": "^1.6.6", "tough-cookie": "^6.0.0", @@ -1025,15 +1024,6 @@ "node": ">=12" } }, - "node_modules/@isaacs/ttlcache": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/@isaacs/ttlcache/-/ttlcache-1.4.1.tgz", - "integrity": "sha512-RQgQ4uQ+pLbqXfOmieB91ejmLwvSgv9nLx6sT6sD83s7umBypgg+OIBOBbEUiJXrfpnp9j0mRhYYdzp9uqq3lA==", - "license": "ISC", - "engines": { - "node": ">=12" - } - }, "node_modules/@istanbuljs/load-nyc-config": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz", @@ -1442,6 +1432,12 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@keyv/serialize": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@keyv/serialize/-/serialize-1.1.0.tgz", + "integrity": "sha512-RlDgexML7Z63Q8BSaqhXdCYNBy/JQnqYIwxofUrNLGCblOMHp+xux2Q8nLMLlPpgHQPoU0Do8Z6btCpRBEqZ8g==", + "license": "MIT" + }, "node_modules/@napi-rs/wasm-runtime": { "version": "0.2.12", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz", @@ -3064,6 +3060,25 @@ "node": ">= 0.8" } }, + "node_modules/cacheable": { + "version": "1.10.4", + "resolved": "https://registry.npmjs.org/cacheable/-/cacheable-1.10.4.tgz", + "integrity": "sha512-Gd7ccIUkZ9TE2odLQVS+PDjIvQCdJKUlLdJRVvZu0aipj07Qfx+XIej7hhDrKGGoIxV5m5fT/kOJNJPQhQneRg==", + "license": "MIT", + "dependencies": { + "hookified": "^1.11.0", + "keyv": "^5.5.0" + } + }, + "node_modules/cacheable/node_modules/keyv": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-5.5.0.tgz", + "integrity": "sha512-QG7qR2tijh1ftOvClut4YKKg1iW6cx3GZsKoGyJPxHkGWK9oJhG9P3j5deP0QQOGDowBMVQFaP+Vm4NpGYvmIQ==", + "license": "MIT", + "dependencies": { + "@keyv/serialize": "^1.1.0" + } + }, "node_modules/call-bind": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", @@ -5190,6 +5205,12 @@ "node": ">= 0.4" } }, + "node_modules/hookified": { + "version": "1.12.0", + "resolved": "https://registry.npmjs.org/hookified/-/hookified-1.12.0.tgz", + "integrity": "sha512-hMr1Y9TCLshScrBbV2QxJ9BROddxZ12MX9KsCtuGGy/3SmmN5H1PllKerrVlSotur9dlE8hmUKAOSa3WDzsZmQ==", + "license": "MIT" + }, "node_modules/html-encoding-sniffer": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", @@ -6832,15 +6853,6 @@ "node": ">= 12.0.0" } }, - "node_modules/lru-cache": { - "version": "11.1.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.1.0.tgz", - "integrity": "sha512-QIXZUBJUx+2zHUdQujWejBkcD9+cs94tLn0+YL8UrCh+D5sCXZ4c7LaEH48pNwRY3MLDgqUFyhlCyjJPf1WP0A==", - "license": "ISC", - "engines": { - "node": "20 || >=22" - } - }, "node_modules/make-dir": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", diff --git a/package.json b/package.json index 4ae471d..43510c9 100644 --- a/package.json +++ b/package.json @@ -31,16 +31,15 @@ "node": "^22.0.0" }, "dependencies": { - "@isaacs/ttlcache": "^1.4.1", "@types/jsdom": "^21.1.7", "async-mutex": "^0.5.0", "bytes": "^3.1.2", + "cacheable": "^1.10.4", "cheerio": "^1.0.0", "express": "^5.1.0", "fetch-socks": "^1.3.2", "http-cache-semantics": "^4.2.0", "jsdom": "^26.1.0", - "lru-cache": "^11.1.0", "randomstring": "^1.3.1", "slugify": "^1.6.6", "tough-cookie": "^6.0.0", diff --git a/src/extractor/ExtractorRegistry.ts b/src/extractor/ExtractorRegistry.ts index 165c261..fc477d3 100644 --- a/src/extractor/ExtractorRegistry.ts +++ b/src/extractor/ExtractorRegistry.ts @@ -1,4 +1,4 @@ -import TTLCache from '@isaacs/ttlcache'; +import { Cacheable, CacheableMemory, Keyv } from 'cacheable'; import winston from 'winston'; import { Context, CountryCode, UrlResult } from '../types'; import { isExtractorDisabled } from '../utils'; @@ -7,12 +7,13 @@ import { Extractor } from './Extractor'; export class ExtractorRegistry { private readonly logger: winston.Logger; private readonly extractors: Extractor[]; - private readonly urlResultCache: TTLCache; + private readonly urlResultCache: Cacheable; public constructor(logger: winston.Logger, extractors: Extractor[]) { this.logger = logger; this.extractors = extractors; - this.urlResultCache = new TTLCache({ max: 4096 }); + + this.urlResultCache = new Cacheable({ primary: new Keyv({ store: new CacheableMemory({ lruSize: 4096 }) }) }); } public async handle(ctx: Context, url: URL, countryCode: CountryCode, title?: string | undefined): Promise { @@ -26,17 +27,17 @@ export class ExtractorRegistry { ? `${extractor.id}_${normalizedUrl}_${ctx.config.mediaFlowProxyUrl}` : `${extractor.id}_${normalizedUrl}`; - let urlResults = this.urlResultCache.get(cacheKey) ?? []; - if (this.urlResultCache.has(cacheKey)) { - return urlResults.map(urlResult => ({ ...urlResult, ttl: this.urlResultCache.getRemainingTTL(cacheKey) })); + const storedDataRaw = await this.urlResultCache.get(cacheKey, { raw: true }); + if (storedDataRaw) { + return (storedDataRaw.value as UrlResult[]).map(urlResult => ({ ...urlResult, ttl: storedDataRaw.expires as number - Date.now(), url: new URL(urlResult.url) })); } this.logger.info(`Extract stream URL using ${extractor.id} extractor from ${url}`, ctx); - urlResults = await extractor.extract(ctx, normalizedUrl, countryCode, title); + const urlResults = await extractor.extract(ctx, normalizedUrl, countryCode, title); if (!urlResults.some(urlResult => urlResult.error) && extractor.ttl) { - this.urlResultCache.set(cacheKey, urlResults, { ttl: extractor.ttl }); + await this.urlResultCache.set(cacheKey, urlResults, extractor.ttl); } return urlResults; diff --git a/src/source/Source.ts b/src/source/Source.ts index f45bf19..3865242 100644 --- a/src/source/Source.ts +++ b/src/source/Source.ts @@ -1,4 +1,4 @@ -import TTLCache from '@isaacs/ttlcache'; +import { Cacheable, CacheableMemory, Keyv } from 'cacheable'; import { ContentType } from 'stremio-addon-sdk'; import { NotFoundError } from '../error'; import { Context, CountryCode } from '../types'; @@ -23,21 +23,22 @@ export abstract class Source { public abstract readonly baseUrl: string; - private readonly sourceResultCache: TTLCache; + private readonly sourceResultCache: Cacheable; public constructor() { - this.sourceResultCache = new TTLCache({ max: 1024, ttl: this.ttl }); + this.sourceResultCache = new Cacheable({ primary: new Keyv({ store: new CacheableMemory({ lruSize: 1024, ttl: this.ttl }) }) }); } protected abstract handleInternal(ctx: Context, type: ContentType, id: Id): Promise<(SourceResult[])>; public async handle(ctx: Context, type: ContentType, id: Id): Promise<(SourceResult[])> { const cacheKey = id.toString(); - if (this.sourceResultCache.has(cacheKey)) { - return this.sourceResultCache.get(cacheKey) as SourceResult[]; + + let sourceResults = await this.sourceResultCache.get(cacheKey); + if (sourceResults) { + return sourceResults.map(sourceResult => ({ ...sourceResult, url: new URL(sourceResult.url) })); } - let sourceResults: SourceResult[]; try { sourceResults = await this.handleInternal(ctx, type, id); } catch (error) { @@ -48,7 +49,7 @@ export abstract class Source { } } - this.sourceResultCache.set(cacheKey, sourceResults); + await this.sourceResultCache.set(cacheKey, sourceResults); return sourceResults; } diff --git a/src/utils/Fetcher.ts b/src/utils/Fetcher.ts index 5396b70..5a3bf78 100644 --- a/src/utils/Fetcher.ts +++ b/src/utils/Fetcher.ts @@ -1,8 +1,7 @@ import { gunzipSync, gzipSync } from 'zlib'; -import TTLCache from '@isaacs/ttlcache'; import { Mutex, Semaphore, SemaphoreInterface, withTimeout } from 'async-mutex'; +import { Cacheable, CacheableMemory, Keyv } from 'cacheable'; import CachePolicy from 'http-cache-semantics'; -import { LRUCache } from 'lru-cache'; import { Cookie, CookieJar } from 'tough-cookie'; import { fetch, Headers, RequestInit, Response } from 'undici'; import winston from 'winston'; @@ -64,13 +63,13 @@ export class Fetcher { private readonly logger: winston.Logger; - private readonly httpCache = new LRUCache({ max: 2048 }); - private readonly rateLimitedCache = new TTLCache({ max: 1024 }); + private readonly httpCache = new Cacheable({ primary: new Keyv({ store: new CacheableMemory({ lruSize: 2048 }) }) }); + private readonly rateLimitedCache = new Cacheable({ primary: new Keyv({ store: new CacheableMemory({ lruSize: 1024 }) }) }); private readonly semaphores = new Map(); private readonly hostUserAgentMap = new Map(); private readonly cookieJar = new CookieJar(); - private readonly timeoutsCountCache = new TTLCache({ max: 1024 }); + private readonly timeoutsCountCache = new Cacheable({ primary: new Keyv({ store: new CacheableMemory({ lruSize: 1024 }) }) }); private readonly timeoutsCountMutex = new Mutex(); public constructor(logger: winston.Logger) { @@ -178,7 +177,7 @@ export class Fetcher { if (httpCacheItem.status === 429) { const retryAfter = parseInt(`${httpCacheItem.headers['retry-after']}`); if (!isNaN(retryAfter)) { - this.rateLimitedCache.set(url.host, undefined, { ttl: retryAfter * 1000 }); + await this.rateLimitedCache.set(url.host, true, retryAfter); } throw new TooManyRequestsError(retryAfter); @@ -199,18 +198,18 @@ export class Fetcher { return policy.timeToLive(); }; - private cacheGet(key: string): HttpCacheItem | undefined { - const buffer = this.httpCache.get(key); + private async cacheGet(key: string): Promise { + const buffer = await this.httpCache.get(key); return buffer ? JSON.parse(gunzipSync(buffer).toString()) : undefined; } - private cacheSet(key: string, httpCacheItem: HttpCacheItem) { + private async cacheSet(key: string, httpCacheItem: HttpCacheItem) { if (httpCacheItem.ttl <= 0) { return; } - this.httpCache.set(key, gzipSync(JSON.stringify(httpCacheItem)), { ttl: httpCacheItem.ttl }); + await this.httpCache.set(key, gzipSync(JSON.stringify(httpCacheItem)), httpCacheItem.ttl); } private headersToObject(headers: Headers): Record { @@ -229,7 +228,7 @@ export class Fetcher { const request: CachePolicy.Request = { url: url.href, method: newInit.method ?? 'GET', headers: {} }; const cacheKey = this.determineCacheKey(url, init); - let httpCacheItem = this.cacheGet(cacheKey); + let httpCacheItem = await this.cacheGet(cacheKey); const noCache = init?.noCache ?? false; if (httpCacheItem && !noCache) { this.logger.info(`Cached fetch ${request.method} ${url}`, ctx); @@ -244,7 +243,7 @@ export class Fetcher { httpCacheItem = { headers: policy.responseHeaders(), status: response.status, statusText: response.statusText, body, ttl }; - this.cacheSet(cacheKey, httpCacheItem); + await this.cacheSet(cacheKey, httpCacheItem); return this.handleHttpCacheItem(ctx, httpCacheItem, url, init); }; @@ -252,11 +251,13 @@ export class Fetcher { protected async fetchWithTimeout(ctx: Context, url: URL, init?: CustomRequestInit, tryCount = 0): Promise { this.logger.info(`Fetch ${init?.method ?? 'GET'} ${url}`, ctx); - if (this.rateLimitedCache.has(url.host)) { - throw new TooManyRequestsError(this.rateLimitedCache.getRemainingTTL(url.host) / 1000); + const isRateLimitedRaw = await this.rateLimitedCache.get(url.host, { raw: true }); + if (isRateLimitedRaw && isRateLimitedRaw.value) { + throw new TooManyRequestsError((isRateLimitedRaw.expires as number - Date.now()) / 1000); } - if ((this.timeoutsCountCache.get(url.host) ?? 0) >= (init?.timeoutsCountThrow ?? this.DEFAULT_TIMEOUTS_COUNT_THROW)) { + const timeouts = (await this.timeoutsCountCache.get(url.host)) ?? 0; + if (timeouts >= (init?.timeoutsCountThrow ?? this.DEFAULT_TIMEOUTS_COUNT_THROW)) { throw new TooManyTimeoutsError(); } @@ -299,15 +300,17 @@ export class Fetcher { private async increaseTimeoutsCount(url: URL) { await this.timeoutsCountMutex.runExclusive(async () => { - const count = (this.timeoutsCountCache.get(url.host) ?? 0) + 1; - this.timeoutsCountCache.set(url.host, count, { ttl: this.TIMEOUT_CACHE_TTL }); + const count = (await this.timeoutsCountCache.get(url.host)) ?? 0; + const newCount = count + 1; + await this.timeoutsCountCache.set(url.host, newCount, this.TIMEOUT_CACHE_TTL); }); } private async decreaseTimeoutsCount(url: URL) { await this.timeoutsCountMutex.runExclusive(async () => { - const count = Math.max(0, (this.timeoutsCountCache.get(url.host) ?? 0) - 1); - this.timeoutsCountCache.set(url.host, count, { ttl: this.TIMEOUT_CACHE_TTL }); + const count = (await this.timeoutsCountCache.get(url.host)) ?? 0; + const newCount = Math.max(0, count - 1); + await this.timeoutsCountCache.set(url.host, newCount, this.TIMEOUT_CACHE_TTL); }); }