feat: add redis support

This commit is contained in:
Viren070
2025-08-20 20:47:14 +01:00
parent c31e7fca42
commit bc9dd5c859
23 changed files with 600 additions and 147 deletions
+9
View File
@@ -52,6 +52,15 @@ ADDON_PASSWORD=
# (e.g., postgresql://postgres:password@localhost:5432/aiostreams)
DATABASE_URI=sqlite://./data/db.sqlite
# --- Redis (Optional, for Scaled Deployments) ----
# Optionally provide a Redis URI to use for caching. This is only recommended for
# users running multiple instances (e.g., via Docker Swarm).
#
# For standard, single-container setups, leave this blank. It will default to
# a simpler internal memory cache that is more than sufficient and faster too.
#
# REDIS_URI=redis://your_redis_hostname:6379
# ==============================================================================
# BUILT-IN ADDON CONFIGURATION
+86
View File
@@ -2778,6 +2778,66 @@
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
}
},
"node_modules/@redis/bloom": {
"version": "5.8.2",
"resolved": "https://registry.npmjs.org/@redis/bloom/-/bloom-5.8.2.tgz",
"integrity": "sha512-855DR0ChetZLarblio5eM0yLwxA9Dqq50t8StXKp5bAtLT0G+rZ+eRzzqxl37sPqQKjUudSYypz55o6nNhbz0A==",
"license": "MIT",
"engines": {
"node": ">= 18"
},
"peerDependencies": {
"@redis/client": "^5.8.2"
}
},
"node_modules/@redis/client": {
"version": "5.8.2",
"resolved": "https://registry.npmjs.org/@redis/client/-/client-5.8.2.tgz",
"integrity": "sha512-WtMScno3+eBpTac1Uav2zugXEoXqaU23YznwvFgkPwBQVwEHTDgOG7uEAObtZ/Nyn8SmAMbqkEubJaMOvnqdsQ==",
"license": "MIT",
"dependencies": {
"cluster-key-slot": "1.1.2"
},
"engines": {
"node": ">= 18"
}
},
"node_modules/@redis/json": {
"version": "5.8.2",
"resolved": "https://registry.npmjs.org/@redis/json/-/json-5.8.2.tgz",
"integrity": "sha512-uxpVfas3I0LccBX9rIfDgJ0dBrUa3+0Gc8sEwmQQH0vHi7C1Rx1Qn8Nv1QWz5bohoeIXMICFZRcyDONvum2l/w==",
"license": "MIT",
"engines": {
"node": ">= 18"
},
"peerDependencies": {
"@redis/client": "^5.8.2"
}
},
"node_modules/@redis/search": {
"version": "5.8.2",
"resolved": "https://registry.npmjs.org/@redis/search/-/search-5.8.2.tgz",
"integrity": "sha512-cNv7HlgayavCBXqPXgaS97DRPVWFznuzsAmmuemi2TMCx5scwLiP50TeZvUS06h/MG96YNPe6A0Zt57yayfxwA==",
"license": "MIT",
"engines": {
"node": ">= 18"
},
"peerDependencies": {
"@redis/client": "^5.8.2"
}
},
"node_modules/@redis/time-series": {
"version": "5.8.2",
"resolved": "https://registry.npmjs.org/@redis/time-series/-/time-series-5.8.2.tgz",
"integrity": "sha512-g2NlHM07fK8H4k+613NBsk3y70R2JIM2dPMSkhIjl2Z17SYvaYKdusz85d7VYOrZBWtDrHV/WD2E3vGu+zni8A==",
"license": "MIT",
"engines": {
"node": ">= 18"
},
"peerDependencies": {
"@redis/client": "^5.8.2"
}
},
"node_modules/@rollup/rollup-android-arm-eabi": {
"version": "4.46.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.46.2.tgz",
@@ -5544,6 +5604,15 @@
"node": ">=6"
}
},
"node_modules/cluster-key-slot": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz",
"integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/cmdk": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/cmdk/-/cmdk-1.1.1.tgz",
@@ -11516,6 +11585,22 @@
"node": ">=8.10.0"
}
},
"node_modules/redis": {
"version": "5.8.2",
"resolved": "https://registry.npmjs.org/redis/-/redis-5.8.2.tgz",
"integrity": "sha512-31vunZj07++Y1vcFGcnNWEf5jPoTkGARgfWI4+Tk55vdwHxhAvug8VEtW7Cx+/h47NuJTEg/JL77zAwC6E0OeA==",
"license": "MIT",
"dependencies": {
"@redis/bloom": "5.8.2",
"@redis/client": "5.8.2",
"@redis/json": "5.8.2",
"@redis/search": "5.8.2",
"@redis/time-series": "5.8.2"
},
"engines": {
"node": ">= 18"
}
},
"node_modules/reflect.getprototypeof": {
"version": "1.0.10",
"resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
@@ -14725,6 +14810,7 @@
"moment-timezone": "^0.5.48",
"parse-torrent-title": "github:TheBeastLT/parse-torrent-title",
"pg": "^8.16.0",
"redis": "^5.8.2",
"sqlite": "^5.1.1",
"sqlite3": "^5.1.7",
"stremthru": "^0.8.0",
+1
View File
@@ -19,6 +19,7 @@
"moment-timezone": "^0.5.48",
"parse-torrent-title": "github:TheBeastLT/parse-torrent-title",
"pg": "^8.16.0",
"redis": "^5.8.2",
"sqlite": "^5.1.1",
"sqlite3": "^5.1.7",
"stremthru": "^0.8.0",
+2 -2
View File
@@ -76,7 +76,7 @@ export class GoogleOAuth {
throw new Error('Builtin GDrive client ID and secret are not set');
}
this.refreshToken = refreshToken;
this.accessToken = accessTokenCache.get(this.refreshToken);
this.accessToken = undefined;
}
private static get clientId(): string {
@@ -160,7 +160,7 @@ export class GoogleOAuth {
}
private async refreshAccessToken(): Promise<void> {
const cachedToken = accessTokenCache.get(this.refreshToken);
const cachedToken = await accessTokenCache.get(this.refreshToken);
if (cachedToken) {
this.accessToken = cachedToken;
return;
@@ -76,7 +76,7 @@ export class DebridService {
const cacheKey = getSimpleTextHash(
`${this.serviceConfig.id}:${torrent.hash}`
);
const cached = this.debridCache.get(cacheKey);
const cached = await this.debridCache.get(cacheKey);
if (cached && cached.length > 0) {
cachedResults.push(...cached);
@@ -179,7 +179,9 @@ export class TorrentSourceHandler extends SourceHandler {
}
}
const titleMetadata = this.metadataCache.get(`metadata:${type}:${id}`);
const titleMetadata = await this.metadataCache.get(
`metadata:${type}:${id}`
);
const filesByHash = await this.getAvailableFilesFromDebrid(
torrents,
parsedId,
@@ -229,7 +231,7 @@ export class TorrentSourceHandler extends SourceHandler {
) {
cacheKey += `:${this.searchApi.apiKey}`;
}
const cachedTorrents = this.searchCache.get(cacheKey);
const cachedTorrents = await this.searchCache.get(cacheKey);
if (
cachedTorrents &&
@@ -294,7 +296,7 @@ export class TorrentSourceHandler extends SourceHandler {
return [];
}
this.searchCache.set(
await this.searchCache.set(
cacheKey,
torrents.filter(
(torrent) =>
@@ -364,7 +366,7 @@ export class UsenetSourceHandler extends SourceHandler {
const cacheKey = `usenet:${type}:${id}:${season}:${episode}`;
let usingCachedSearch = false;
let torrents = this.searchCache.get(cacheKey);
let torrents = await this.searchCache.get(cacheKey);
if (!torrents) {
const start = Date.now();
@@ -380,7 +382,7 @@ export class UsenetSourceHandler extends SourceHandler {
if (torrents.length === 0) {
return [];
}
this.searchCache.set(
await this.searchCache.set(
cacheKey,
torrents,
Env.BUILTIN_TORBOX_SEARCH_SEARCH_API_CACHE_TTL
@@ -463,7 +465,9 @@ export class UsenetSourceHandler extends SourceHandler {
const torrentsToCheck: Torrent[] = [];
for (const torrent of torrents) {
const cachedStatus = this.instantAvailabilityCache.get(torrent.hash);
const cachedStatus = await this.instantAvailabilityCache.get(
torrent.hash
);
if (cachedStatus !== undefined) {
instantAvailability.set(torrent.hash, cachedStatus);
} else {
+1
View File
@@ -82,6 +82,7 @@ export class DB {
DB.initialised = true;
DB.dialect = this.uri.dialect;
logger.info('Database initialised');
} catch (error) {
logger.error('Failed to initialize database:', error);
throw error;
+13 -5
View File
@@ -116,10 +116,10 @@ export class DebridInterface {
private async resolveTorrent(
playbackInfo: PlaybackInfo & { type: 'torrent' },
filename: string
) {
): Promise<string | undefined> {
let { hash, index, parsedId } = playbackInfo;
const cacheKey = `${this.storeAuth.storeName}:${hash}:${index ?? 'undefined'}:${this.storeAuth.storeCredential}:${this.clientIp}`;
const cachedLink = DebridInterface.playbackLinkCache.get(cacheKey);
const cachedLink = await DebridInterface.playbackLinkCache.get(cacheKey);
if (cachedLink) {
logger.debug(`Using cached link for ${hash}`);
return cachedLink;
@@ -181,7 +181,11 @@ export class DebridInterface {
});
const playbackLink = link.data.link;
DebridInterface.playbackLinkCache.set(cacheKey, playbackLink, 60 * 30);
await DebridInterface.playbackLinkCache.set(
cacheKey,
playbackLink,
60 * 30
);
return playbackLink;
}
@@ -191,7 +195,7 @@ export class DebridInterface {
): Promise<string | undefined> {
const { nzb } = playbackInfo;
const cacheKey = `${this.storeAuth.storeName}:${this.storeAuth.storeCredential}:${nzb}:${this.clientIp}`;
const cachedLink = DebridInterface.playbackLinkCache.get(cacheKey);
const cachedLink = await DebridInterface.playbackLinkCache.get(cacheKey);
if (cachedLink) {
logger.debug(`Using cached link for ${nzb}`);
return cachedLink;
@@ -236,7 +240,11 @@ export class DebridInterface {
const playbackLink = link.data?.data;
if (playbackLink) {
DebridInterface.playbackLinkCache.set(cacheKey, playbackLink, 60 * 30);
await DebridInterface.playbackLinkCache.set(
cacheKey,
playbackLink,
60 * 30
);
}
return playbackLink;
}
+13 -5
View File
@@ -44,7 +44,11 @@ import { TMDBMetadata, TMDBMetadataResponse } from './metadata/tmdb';
const logger = createLogger('core');
const shuffleCache = Cache.getInstance<string, MetaPreview[]>('shuffle');
const precacheCache = Cache.getInstance<string, boolean>('precache');
const precacheCache = Cache.getInstance<string, boolean>(
'precache',
undefined,
true
);
export interface AIOStreamsError {
title?: string;
@@ -182,7 +186,7 @@ export class AIOStreams {
// within the last 24 hours (Env.PRECACHE_NEXT_EPISODE_MIN_INTERVAL)
let precache = false;
const cacheKey = `precache-${type}-${id}-${this.userData.uuid}`;
const cachedNextEpisode = precacheCache.get(cacheKey, false);
const cachedNextEpisode = await precacheCache.get(cacheKey, false);
if (cachedNextEpisode) {
logger.info(
`The current request for ${type} ${id} has already had the next episode precached within the last ${Env.PRECACHE_NEXT_EPISODE_MIN_INTERVAL} seconds (${precacheCache.getTTL(cacheKey)} seconds left). Skipping precaching.`
@@ -302,7 +306,7 @@ export class AIOStreams {
if (modification?.shuffle && !(extras && extras.includes('search'))) {
// shuffle the catalog array if it is not a search
const cacheKey = `shuffle-${type}-${actualCatalogId}-${extras}-${this.userData.uuid}`;
const cachedShuffle = shuffleCache.get(cacheKey);
const cachedShuffle = await shuffleCache.get(cacheKey);
if (cachedShuffle) {
catalog = cachedShuffle;
} else {
@@ -311,7 +315,7 @@ export class AIOStreams {
[catalog[i], catalog[j]] = [catalog[j], catalog[i]];
}
if (modification.persistShuffleFor) {
shuffleCache.set(
await shuffleCache.set(
cacheKey,
catalog,
modification.persistShuffleFor * 3600
@@ -1344,7 +1348,11 @@ export class AIOStreams {
);
}
const cacheKey = `precache-${type}-${id}-${this.userData.uuid}`;
precacheCache.set(cacheKey, true, Env.PRECACHE_NEXT_EPISODE_MIN_INTERVAL);
await precacheCache.set(
cacheKey,
true,
Env.PRECACHE_NEXT_EPISODE_MIN_INTERVAL
);
logger.info(`Successfully precached a stream for ${id} (${type})`);
} catch (error) {
logger.error(`Error pinging url of first uncached stream`, {
+5 -2
View File
@@ -54,9 +54,12 @@ export class IMDBMetadata {
}
}
private async getImdbSuggestionData(id: string, type: string) {
private async getImdbSuggestionData(
id: string,
type: string
): Promise<Metadata> {
const key = `${id}:${type}`;
const cached = this.titleCache.get(key);
const cached = await this.titleCache.get(key);
if (cached) {
return cached;
}
+3 -3
View File
@@ -92,7 +92,7 @@ export class TMDBMetadata {
// Check cache first
const cacheKey = `${id.type}:${id.value}:${type}`;
const cachedId = TMDBMetadata.idCache.get(cacheKey);
const cachedId = await TMDBMetadata.idCache.get(cacheKey);
if (cachedId) {
return cachedId;
}
@@ -147,7 +147,7 @@ export class TMDBMetadata {
// Check cache first
const cacheKey = `${tmdbId}:${type}`;
const cachedMetadata = TMDBMetadata.metadataCache.get(cacheKey);
const cachedMetadata = await TMDBMetadata.metadataCache.get(cacheKey);
if (cachedMetadata) {
return cachedMetadata;
}
@@ -231,7 +231,7 @@ export class TMDBMetadata {
if (!cacheKey) {
throw new Error('TMDB Access Token or API Key is not set');
}
const cachedResult = TMDBMetadata.validationCache.get(cacheKey);
const cachedResult = await TMDBMetadata.validationCache.get(cacheKey);
if (cachedResult) {
return cachedResult;
}
+6 -2
View File
@@ -285,7 +285,7 @@ export class AICompanionPreset extends Preset {
form.append('include_catalogs_movies', movieFeedCatalogs.join(','));
form.append('include_catalogs_series', seriesFeedCatalogs.join(','));
let manifestUrl: string | undefined = manifestCache.get(cacheKey);
let manifestUrl: string | undefined = await manifestCache.get(cacheKey);
if (manifestUrl) {
return manifestUrl;
}
@@ -327,7 +327,11 @@ export class AICompanionPreset extends Preset {
if (!result.success || !result.manifest_url) {
throw new Error(result.detail || 'Unknown error');
}
manifestCache.set(cacheKey, result.manifest_url, 365 * 24 * 60 * 60);
await manifestCache.set(
cacheKey,
result.manifest_url,
365 * 24 * 60 * 60
);
return result.manifest_url;
} catch (error) {
throw new Error(
+2 -2
View File
@@ -265,7 +265,7 @@ export class AISearchPreset extends Preset {
// request to /aisearch/encrypt with cache and config as body
const cacheKey = `${JSON.stringify(config)}`;
let configId: string | undefined = configCache.get(cacheKey);
let configId: string | undefined = await configCache.get(cacheKey);
if (configId) {
return `${url}/aisearch/${configId}/manifest.json`;
}
@@ -289,7 +289,7 @@ export class AISearchPreset extends Preset {
});
const result = schema.parse(data);
configId = result.encryptedConfig;
configCache.set(cacheKey, configId, 365 * 24 * 60 * 60);
await configCache.set(cacheKey, configId, 365 * 24 * 60 * 60);
return `${url}/aisearch/${configId}/manifest.json`;
} catch (error) {
throw new Error(
+1 -1
View File
@@ -443,7 +443,7 @@ export class MoreLikeThisPreset extends Preset {
forCopy: 'true',
};
const cachedManifest = moreLikeThisManifests.get(
const cachedManifest = await moreLikeThisManifests.get(
getSimpleTextHash(`${url}?${JSON.stringify(config)}`)
);
if (cachedManifest) {
+2 -2
View File
@@ -69,7 +69,7 @@ export abstract class BaseProxy {
}
const cacheKey = `${this.config.id}:${this.config.url}:${this.config.credentials}`;
const cachedPublicIp = cache ? cache.get(cacheKey) : null;
const cachedPublicIp = cache ? await cache.get(cacheKey) : null;
if (cachedPublicIp) {
logger.debug('Returning cached public IP');
return cachedPublicIp;
@@ -99,7 +99,7 @@ export abstract class BaseProxy {
const publicIp = this.getPublicIpFromResponse(data);
if (publicIp && cache) {
cache.set(cacheKey, publicIp, Env.PROXY_IP_CACHE_TTL);
await cache.set(cacheKey, publicIp, Env.PROXY_IP_CACHE_TTL);
} else {
logger.error(
`Proxy did not respond with a public IP. Response: ${JSON.stringify(data)}`
+268
View File
@@ -0,0 +1,268 @@
import { createLogger } from './logger';
import { Env } from './env';
import { RedisClientType, RedisClientOptions, AbortError } from 'redis';
const logger = createLogger('cache');
const REDIS_TIMEOUT = 500;
// Interface that both memory and Redis cache will implement
export interface CacheBackend<K, V> {
get(key: K, updateTTL?: boolean): Promise<V | undefined>;
set(key: K, value: V, ttl: number): Promise<void>;
update(key: K, value: V): Promise<void>;
clear(): Promise<void>;
getTTL(key: K): Promise<number>;
}
// Memory cache implementation
export class MemoryCacheBackend<K, V> implements CacheBackend<K, V> {
private cache: Map<K, CacheItem<V>>;
private maxSize: number;
constructor(maxSize: number) {
this.cache = new Map<K, CacheItem<V>>();
this.maxSize = maxSize;
}
async get(key: K, updateTTL: boolean = false): Promise<V | undefined> {
const item = this.cache.get(key);
if (item) {
const now = Date.now();
item.lastAccessed = now;
if (now - item.createdAt > item.ttl) {
this.cache.delete(key);
return undefined;
}
if (updateTTL) {
item.createdAt = now;
}
return structuredClone(item.value);
}
return undefined;
}
async set(key: K, value: V, ttl: number): Promise<void> {
if (this.cache.size >= this.maxSize) {
this.evict();
}
this.cache.set(
key,
new CacheItem<V>(
structuredClone(value),
Date.now(),
Date.now(),
ttl * 1000
)
);
}
async update(key: K, value: V): Promise<void> {
const item = this.cache.get(key);
if (item) {
item.value = value;
}
}
async clear(): Promise<void> {
this.cache.clear();
}
async getTTL(key: K): Promise<number> {
const item = this.cache.get(key);
if (item) {
return Math.max(
0,
Math.floor((item.createdAt + item.ttl - Date.now()) / 1000)
);
}
return 0;
}
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);
}
}
getSize(): number {
return this.cache.size;
}
getMemoryUsageEstimate(): number {
let totalSize = 0;
for (const item of this.cache.values()) {
try {
totalSize += Buffer.byteLength(JSON.stringify(item), 'utf8');
} catch (e) {
// In case of circular references
}
}
return totalSize;
}
}
// Redis cache implementation with timeout handling
export class RedisCacheBackend<K, V> implements CacheBackend<K, V> {
private client: RedisClientType;
private prefix: string;
private maxSize: number;
private timeout: number;
constructor(
redisClient: RedisClientType,
prefix: string = 'aiostreams:',
maxSize: number = Env.DEFAULT_MAX_CACHE_SIZE,
timeout: number = REDIS_TIMEOUT
) {
this.client = redisClient;
this.prefix = prefix;
this.maxSize = maxSize;
this.timeout = timeout;
}
private getKey(key: K): string {
return `${this.prefix}${String(key)}`;
}
/**
* Execute Redis operation with timeout
* @param operation Function that performs the Redis operation
* @param fallback Value to return if operation times out or fails
* @param errorMessage Message to log if operation fails
*/
private async withTimeout<T>(
operation: () => Promise<T>,
fallback: T,
errorMessage: string
): Promise<T> {
// check if the client is connected
if (!this.client.isOpen) {
logger.error(`${errorMessage}: Redis client is not open`);
return fallback;
}
try {
// Create a promise that rejects after timeout
const timeoutPromise = new Promise<never>((_, reject) => {
const id = setTimeout(() => {
clearTimeout(id);
reject(
new Error(`Redis operation timed out after ${this.timeout}ms`)
);
}, this.timeout);
});
// Race the operation against the timeout
return await Promise.race([operation(), timeoutPromise]);
} catch (err) {
logger.error(`${errorMessage}: ${err}`);
return fallback;
}
}
async get(key: K, updateTTL: boolean = false): Promise<V | undefined> {
const redisKey = this.getKey(key);
return this.withTimeout(
async () => {
const data = await this.client.get(redisKey);
if (!data) return undefined;
if (updateTTL) {
// Update TTL if requested
const ttl = await this.client.ttl(redisKey);
if (ttl > 0) {
await this.client.expire(redisKey, ttl);
}
}
return JSON.parse(data) as V;
},
undefined,
`Error getting key ${String(key)} from Redis`
);
}
async set(key: K, value: V, ttl: number): Promise<void> {
const redisKey = this.getKey(key);
await this.withTimeout(
async () => {
await this.client.set(redisKey, JSON.stringify(value), {
EX: ttl,
});
return true;
},
false,
`Error setting key ${String(key)} in Redis`
);
}
async update(key: K, value: V): Promise<void> {
const redisKey = this.getKey(key);
await this.withTimeout(
async () => {
// Get current TTL
const ttl = await this.client.ttl(redisKey);
if (ttl <= 0) return false; // Key doesn't exist or has no TTL
// Update value but keep the same TTL
await this.client.set(redisKey, JSON.stringify(value), {
EX: ttl,
});
return true;
},
false,
`Error updating key ${String(key)} in Redis`
);
}
async clear(): Promise<void> {
await this.withTimeout(
async () => {
// Delete all keys with this prefix
const keys = await this.client.keys(`${this.prefix}*`);
if (keys && keys.length > 0) {
await this.client.del(keys);
}
return true;
},
false,
`Error clearing Redis cache`
);
}
async getTTL(key: K): Promise<number> {
return this.withTimeout(
async () => {
const ttl = await this.client.ttl(this.getKey(key));
return ttl > 0 ? ttl : 0;
},
0,
`Error getting TTL for key ${String(key)} from Redis`
);
}
}
// Item stored in memory cache
class CacheItem<T> {
constructor(
public value: T,
public lastAccessed: number,
public createdAt: number,
public ttl: number // Time-To-Live in milliseconds
) {}
}
+143 -103
View File
@@ -1,5 +1,11 @@
import { createLogger } from './logger';
import { Env } from './env';
import {
CacheBackend,
MemoryCacheBackend,
RedisCacheBackend,
} from './cache-adapter';
import { createClient, RedisClientType } from 'redis';
const logger = createLogger('cache');
@@ -15,26 +21,113 @@ function formatBytes(bytes: number, decimals: number = 2): string {
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(dm))} ${sizes[i]}`;
}
class CacheItem<T> {
constructor(
public value: T,
public lastAccessed: number,
public createdAt: number,
public ttl: number // Time-To-Live in milliseconds
) {}
}
export class Cache<K, V> {
private static instances: Map<string, any> = new Map();
private cache: Map<K, CacheItem<V>>;
private maxSize: number;
private static isStatsLoopRunning: boolean = false;
private backend: CacheBackend<K, V>;
private maxSize: number;
private name: string;
private constructor(maxSize: number) {
this.cache = new Map<K, CacheItem<V>>();
// Redis client singleton
private static redisClient: RedisClientType | null = null;
private constructor(name: string, maxSize: number, forceMemory: boolean) {
this.name = name;
this.maxSize = maxSize;
Cache.startStatsLoop();
// Initialize the appropriate backend based on environment configuration
if (Env.REDIS_URI && !forceMemory) {
this.backend = new RedisCacheBackend<K, V>(
Cache.getRedisClient(),
`${name}:`,
maxSize
);
logger.debug(`Created Redis cache backend for ${name}`);
} else {
this.backend = new MemoryCacheBackend<K, V>(maxSize);
Cache.startStatsLoop();
logger.debug(`Created Memory cache backend for ${name}`);
}
}
private static getRedisClient(): RedisClientType {
if (!this.redisClient) {
logger.info(`Initialising Redis client connection to ${Env.REDIS_URI}`);
this.redisClient = createClient({
url: Env.REDIS_URI,
});
this.redisClient.on('connect', () => {
logger.info('Connected to Redis server');
});
this.redisClient
.connect()
.then(() => {
if (!this.redisClient) {
throw new Error('Redis client not initialized');
}
this.redisClient.on('reconnecting', () => {
logger.warn('Reconnecting to Redis server');
});
this.redisClient.on('error', (err: any) => {
logger.error(`Redis client error: ${err}`);
});
})
.catch((err: any) => {
throw new Error(`Failed to connect to Redis server: ${err}`);
});
}
return this.redisClient;
}
/**
* Tests the Redis connection by attempting to set and get a test value
* @throws Error if Redis connection test fails
*/
public static async testRedisConnection(): Promise<void> {
if (!Env.REDIS_URI) {
return;
}
try {
const client = this.getRedisClient();
const startTime = Date.now();
while (Date.now() - startTime < 10000) {
if (client.isReady) {
break;
}
await new Promise((resolve) => setTimeout(resolve, 100));
}
if (!client.isReady) {
throw new Error('Redis connection test timed out');
}
const testKey = 'redis:connection:test';
const testValue = 'test-' + Date.now();
await client.set(testKey, testValue, {
expiration: {
type: 'EX',
value: 10,
},
});
const retrievedValue = await client.get(testKey);
if (retrievedValue !== testValue) {
throw new Error('Redis get/set test failed: values do not match');
}
await client.del(testKey);
logger.info('Redis connection test successful');
} catch (err: any) {
throw new Error(`Redis connection test failed: ${err.message}`);
}
}
private static startStatsLoop() {
if (Cache.isStatsLoopRunning) {
return;
@@ -58,11 +151,12 @@ export class Cache<K, V> {
*/
public static getInstance<K, V>(
name: string,
maxSize: number = Env.DEFAULT_MAX_CACHE_SIZE
maxSize: number = Env.DEFAULT_MAX_CACHE_SIZE,
forceMemory: boolean = false
): Cache<K, V> {
if (!this.instances.has(name)) {
logger.debug(`Creating new cache instance: ${name}`);
this.instances.set(name, new Cache<K, V>(maxSize));
this.instances.set(name, new Cache<K, V>(name, maxSize, forceMemory));
}
return this.instances.get(name) as Cache<K, V>;
}
@@ -71,7 +165,7 @@ export class Cache<K, V> {
* Gets the statistics of the cache in use by the program. returns a formatted string containing a list of all cache instances
* and their currently held items, max items
*/
public static stats() {
public static async stats() {
if (!this.instances || this.instances.size === 0) {
return;
}
@@ -85,31 +179,29 @@ export class Cache<K, V> {
'╠══════════════════════╪══════════╪═════════════════╪═════════════════╣',
];
const bodyLines = Array.from(this.instances.entries()).map(
([name, cache]) => {
let instanceSize = 0;
for (const item of cache.cache.values()) {
try {
// Estimate object size by getting the byte length of its JSON string representation.
// This is an approximation but is effective for many use cases.
instanceSize += Buffer.byteLength(JSON.stringify(item), 'utf8');
} catch (e) {
// Could fail on circular references. In that case, we add 0.
instanceSize += 0;
}
}
const bodyLines = [];
grandTotalItems += cache.cache.size;
grandTotalSize += instanceSize;
for (const [name, cache] of this.instances.entries()) {
let itemCount = 0;
let instanceSize = 0;
const nameStr = name.padEnd(20);
const itemsStr = String(cache.cache.size).padEnd(8);
const maxSizeStr = String(cache.maxSize ?? '-').padEnd(15);
const estSizeStr = formatBytes(instanceSize).padEnd(15);
return `║ ${nameStr} │ ${itemsStr} │ ${maxSizeStr} │ ${estSizeStr} ║`;
// Get stats differently depending on the backend type
if (cache.backend instanceof MemoryCacheBackend) {
itemCount = cache.backend.getSize();
instanceSize = cache.backend.getMemoryUsageEstimate();
}
);
grandTotalItems += itemCount;
grandTotalSize += instanceSize;
const nameStr = name.padEnd(20);
const itemsStr = String(itemCount).padEnd(8);
const maxSizeStr = String(cache.maxSize ?? '-').padEnd(15);
const estSizeStr = formatBytes(instanceSize).padEnd(15);
bodyLines.push(
`║ ${nameStr} │ ${itemsStr} │ ${maxSizeStr} │ ${estSizeStr} ║`
);
}
const footer = [
'╚══════════════════════╧══════════╧═════════════════╧═════════════════╝',
@@ -133,31 +225,17 @@ export class Cache<K, V> {
ttl: number,
...args: Parameters<T>
): Promise<ReturnType<T>> {
const cachedValue = this.get(key);
const cachedValue = await this.get(key);
if (cachedValue !== undefined) {
return cachedValue as ReturnType<T>;
}
const result = await fn(...args);
this.set(key, result, ttl);
await this.set(key, result, ttl);
return result;
}
get(key: K, updateTTL: boolean = false): V | undefined {
const item = this.cache.get(key);
if (item) {
const now = Date.now();
item.lastAccessed = now;
if (now - item.createdAt > item.ttl) {
this.cache.delete(key);
return undefined;
}
if (updateTTL) {
item.createdAt = now;
}
return structuredClone(item.value);
}
return undefined;
async get(key: K, updateTTL: boolean = false): Promise<V | undefined> {
return this.backend.get(key, updateTTL);
}
/**
@@ -166,19 +244,8 @@ export class Cache<K, V> {
* @param value The value to set
* @param ttl The TTL in seconds
*/
set(key: K, value: V, ttl: number): void {
if (this.cache.size >= this.maxSize) {
this.evict();
}
this.cache.set(
key,
new CacheItem<V>(
structuredClone(value),
Date.now(),
Date.now(),
ttl * 1000
)
);
async set(key: K, value: V, ttl: number): Promise<void> {
return this.backend.set(key, value, ttl);
}
/**
@@ -186,42 +253,15 @@ export class Cache<K, V> {
* @param key The key to update
* @param value The new value
*/
update(key: K, value: V): void {
const item = this.cache.get(key);
if (item) {
item.value = value;
}
async update(key: K, value: V): Promise<void> {
return this.backend.update(key, value);
}
clear(): void {
this.cache.clear();
async clear(): Promise<void> {
return this.backend.clear();
}
getTTL(key: K): number {
// return the time left in seconds until the item expires
const item = this.cache.get(key);
if (item) {
return Math.max(
0,
Math.floor((item.createdAt + item.ttl - Date.now()) / 1000)
);
}
return 0;
}
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);
}
async getTTL(key: K): Promise<number> {
return this.backend.getTTL(key);
}
}
+4
View File
@@ -253,6 +253,10 @@ export const Env = cleanEnv(process.env, {
default: 'sqlite://./data/db.sqlite',
desc: 'Database URI for the addon',
}),
REDIS_URI: str({
default: undefined,
desc: 'Redis URI for the addon',
}),
ADDON_PROXY: url({
default: undefined,
desc: 'Proxy URL for the addon',
+9 -5
View File
@@ -13,7 +13,11 @@ import {
import { socksDispatcher } from 'fetch-socks';
const logger = createLogger('http');
const urlCount = Cache.getInstance<string, number>('url-count');
const urlCount = Cache.getInstance<string, number>(
'url-count',
undefined,
true
);
export class PossibleRecursiveRequestError extends Error {
constructor(message: string) {
@@ -47,7 +51,7 @@ export interface RequestOptions {
rawOptions?: RequestInit;
}
export function makeRequest(url: string, options: RequestOptions) {
export async function makeRequest(url: string, options: RequestOptions) {
const urlObj = new URL(url);
const useProxy = shouldProxy(urlObj);
const headers = new Headers(options.headers);
@@ -79,7 +83,7 @@ export function makeRequest(url: string, options: RequestOptions) {
// block recursive requests
const key = `${urlObj.toString()}-${options.forwardIp}`;
const currentCount = urlCount.get(key) ?? 0;
const currentCount = (await urlCount.get(key)) ?? 0;
if (
currentCount > Env.RECURSION_THRESHOLD_LIMIT &&
!options.ignoreRecursion
@@ -92,9 +96,9 @@ export function makeRequest(url: string, options: RequestOptions) {
);
}
if (currentCount > 0) {
urlCount.update(key, currentCount + 1);
await urlCount.update(key, currentCount + 1);
} else {
urlCount.set(key, 1, Env.RECURSION_THRESHOLD_WINDOW);
await urlCount.set(key, 1, Env.RECURSION_THRESHOLD_WINDOW);
}
logger.debug(
`Making a ${useProxy ? 'proxied' : 'direct'} request to ${makeUrlLogSafe(
+5 -4
View File
@@ -9,7 +9,7 @@ interface Id {
value: string;
}
const apiKeyValidationCache = Cache.getInstance('rpdbApiKey');
const apiKeyValidationCache = Cache.getInstance<string, boolean>('rpdbApiKey');
const posterCheckCache = Cache.getInstance<string, string>('rpdbPosterCheck');
export class RPDB {
@@ -24,8 +24,8 @@ export class RPDB {
}
}
public async validateApiKey() {
const cached = apiKeyValidationCache.get(this.apiKey);
public async validateApiKey(): Promise<boolean> {
const cached = await apiKeyValidationCache.get(this.apiKey);
if (cached) {
return cached;
}
@@ -53,6 +53,7 @@ export class RPDB {
data.valid,
Env.RPDB_API_KEY_VALIDITY_CACHE_TTL
);
return data.valid;
}
/**
*
@@ -65,7 +66,7 @@ export class RPDB {
): Promise<string | null> {
const parsedId = this.getParsedId(id, type);
const cacheKey = `${type}-${id}`;
const cached = posterCheckCache.get(cacheKey);
const cached = await posterCheckCache.get(cacheKey);
if (cached) {
return cached;
}
+6 -1
View File
@@ -95,8 +95,9 @@ const logStartupInfo = () => {
});
// Database & Storage
logSection('DATABASE & STORAGE', '💾', () => {
logSection('STORAGE & CACHE', '💾', () => {
const dbType = Env.DATABASE_URI.split('://')[0].toUpperCase();
const cacheType = Env.REDIS_URI ? 'Redis' : 'Memory';
logKeyValue('Database Type:', dbType);
if (Env.DATABASE_URI.includes('sqlite')) {
const dbPath =
@@ -108,6 +109,10 @@ const logStartupInfo = () => {
Env.DATABASE_URI.replace(/:\/\/.*@/, '://***@')
); // Hide credentials
}
logKeyValue('Cache Type:', cacheType);
if (Env.REDIS_URI) {
logKeyValue('Redis URI:', Env.REDIS_URI);
}
});
// Logging Configuration
+2 -2
View File
@@ -367,7 +367,7 @@ export class Wrapper {
} = options;
if (cacher) {
const cached = cacher.get(cacheKey);
const cached = await cacher.get(cacheKey);
if (cached) {
logger.info(
`Returning cached ${resourceName} for ${this.getAddonName(this.addon)}`
@@ -380,7 +380,7 @@ export class Wrapper {
const result = await requestFn();
const doCache = shouldCache ? shouldCache(result) : true;
if (cacher && doCache) {
cacher.set(cacheKey, result, cacheTtl);
await cacher.set(cacheKey, result, cacheTtl);
}
return result;
};
+8 -1
View File
@@ -6,6 +6,7 @@ import {
DB,
UserRepository,
logStartupInfo,
Cache,
} from '@aiostreams/core';
const logger = createLogger('server');
@@ -13,7 +14,6 @@ const logger = createLogger('server');
async function initialiseDatabase() {
try {
await DB.getInstance().initialise(Env.DATABASE_URI, []);
logger.info('Database initialised');
} catch (error) {
logger.error('Failed to initialise database:', error);
throw error;
@@ -30,9 +30,16 @@ async function startAutoPrune() {
setTimeout(startAutoPrune, Env.PRUNE_INTERVAL * 1000);
}
async function initialiseRedis() {
if (Env.REDIS_URI) {
await Cache.testRedisConnection();
}
}
async function start() {
try {
await initialiseDatabase();
await initialiseRedis();
if (Env.PRUNE_MAX_DAYS >= 0) {
startAutoPrune();
}