refactor: consistently use cacheable for caching

This commit is contained in:
WebStreamr 2025-09-02 13:33:24 +00:00
parent 50764b3602
commit dc093f7109
No known key found for this signature in database
5 changed files with 72 additions and 56 deletions

52
package-lock.json generated
View file

@ -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",

View file

@ -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",

View file

@ -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<string, UrlResult[]>;
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<UrlResult[]> {
@ -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<UrlResult[]>(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<UrlResult[]>(cacheKey, urlResults, extractor.ttl);
}
return urlResults;

View file

@ -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<string | number, SourceResult[]>;
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<SourceResult[]>(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<SourceResult[]>(cacheKey, sourceResults);
return sourceResults;
}

View file

@ -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<string, Buffer>({ max: 2048 });
private readonly rateLimitedCache = new TTLCache<string, undefined>({ 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<string, SemaphoreInterface>();
private readonly hostUserAgentMap = new Map<string, string>();
private readonly cookieJar = new CookieJar();
private readonly timeoutsCountCache = new TTLCache<string, number>({ 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<true>(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<HttpCacheItem | undefined> {
const buffer = await this.httpCache.get<Buffer>(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<Buffer>(key, gzipSync(JSON.stringify(httpCacheItem)), httpCacheItem.ttl);
}
private headersToObject(headers: Headers): Record<string, string> {
@ -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<Response> {
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<true>(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<number>(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<number>(url.host)) ?? 0;
const newCount = count + 1;
await this.timeoutsCountCache.set<number>(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<number>(url.host)) ?? 0;
const newCount = Math.max(0, count - 1);
await this.timeoutsCountCache.set<number>(url.host, newCount, this.TIMEOUT_CACHE_TTL);
});
}