mirror of
https://github.com/Viren070/AIOStreams.git
synced 2025-12-01 23:14:04 +01:00
feat(builtins): search with background refresh
This commit is contained in:
@@ -102,6 +102,11 @@ BUILTIN_GET_TORRENT_TIMEOUT=5000
|
|||||||
# How many of the get torrent tasks should be running at the same time
|
# How many of the get torrent tasks should be running at the same time
|
||||||
# Higher values will increase search times but use more system resources.
|
# Higher values will increase search times but use more system resources.
|
||||||
BUILTIN_GET_TORRENT_CONCURRENCY=100
|
BUILTIN_GET_TORRENT_CONCURRENCY=100
|
||||||
|
# The minimum interval between background refreshes for built-in addon search caches. Triggered during normal searches.
|
||||||
|
# e.g. Searches remain cached for the addon-specific TTLs but once this interval has passed, a background refresh can be triggered to ensure fresh results
|
||||||
|
# without making the user wait.
|
||||||
|
# Default: 1 day
|
||||||
|
BUILTIN_MINIMUM_BACKGROUND_REFRESH_INTERVAL=86400
|
||||||
|
|
||||||
|
|
||||||
# ---- Bitmagnet ----
|
# ---- Bitmagnet ----
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
} from '../../../utils/index.js';
|
} from '../../../utils/index.js';
|
||||||
import { Parser } from 'xml2js';
|
import { Parser } from 'xml2js';
|
||||||
import { Logger } from 'winston';
|
import { Logger } from 'winston';
|
||||||
|
import { searchWithBackgroundRefresh } from '../../utils/general.js';
|
||||||
|
|
||||||
// --- Generic Custom Error ---
|
// --- Generic Custom Error ---
|
||||||
export class NabApiError extends Error {
|
export class NabApiError extends Error {
|
||||||
@@ -333,12 +334,21 @@ export class BaseNabApi<N extends 'torznab' | 'newznab'> {
|
|||||||
params: Record<string, string | number | boolean> = {}
|
params: Record<string, string | number | boolean> = {}
|
||||||
): Promise<SearchResponse<N>> {
|
): Promise<SearchResponse<N>> {
|
||||||
const cacheKey = `${this.baseUrl}${this.apiPath}?t=${searchFunction}&${JSON.stringify(params)}&apikey=${this.apiKey}`;
|
const cacheKey = `${this.baseUrl}${this.apiPath}?t=${searchFunction}&${JSON.stringify(params)}&apikey=${this.apiKey}`;
|
||||||
const result = await this.searchCache.wrap(
|
|
||||||
() => this.request(searchFunction, this.SearchResultSchema, params),
|
return searchWithBackgroundRefresh({
|
||||||
cacheKey,
|
searchCache: this.searchCache as Cache<string, SearchResponse<N>>,
|
||||||
Env.BUILTIN_NAB_SEARCH_CACHE_TTL
|
searchCacheKey: cacheKey,
|
||||||
);
|
bgCacheKey: `nab:${cacheKey}`,
|
||||||
return result as SearchResponse<N>;
|
cacheTTL: Env.BUILTIN_NAB_SEARCH_CACHE_TTL,
|
||||||
|
fetchFn: () =>
|
||||||
|
this.request(
|
||||||
|
searchFunction,
|
||||||
|
this.SearchResultSchema,
|
||||||
|
params
|
||||||
|
) as Promise<SearchResponse<N>>,
|
||||||
|
isEmptyResult: (result) => result.results.length === 0,
|
||||||
|
logger: this.logger,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private removeTrailingSlash = (path: string) =>
|
private removeTrailingSlash = (path: string) =>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { Cache } from '../../utils/cache.js';
|
|||||||
import { Env } from '../../utils/env.js';
|
import { Env } from '../../utils/env.js';
|
||||||
import { formatZodError, makeRequest } from '../../utils/index.js';
|
import { formatZodError, makeRequest } from '../../utils/index.js';
|
||||||
import { createLogger } from '../../utils/index.js';
|
import { createLogger } from '../../utils/index.js';
|
||||||
|
import { searchWithBackgroundRefresh } from '../utils/general.js';
|
||||||
|
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
@@ -102,9 +103,10 @@ const API_VERSION = '1';
|
|||||||
class KnabenAPI {
|
class KnabenAPI {
|
||||||
private headers: Record<string, string>;
|
private headers: Record<string, string>;
|
||||||
|
|
||||||
private readonly searchCache = Cache.getInstance<string, any>(
|
private readonly searchCache = Cache.getInstance<
|
||||||
'knaben:search'
|
string,
|
||||||
);
|
KnabenSearchResponse
|
||||||
|
>('knaben:search');
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this.headers = {
|
this.headers = {
|
||||||
@@ -117,18 +119,23 @@ class KnabenAPI {
|
|||||||
|
|
||||||
async search(options: KnabenSearchOptions): Promise<KnabenSearchResponse> {
|
async search(options: KnabenSearchOptions): Promise<KnabenSearchResponse> {
|
||||||
const body = KnabenSearchOptionsRequest.parse(options);
|
const body = KnabenSearchOptionsRequest.parse(options);
|
||||||
|
const cacheKey = JSON.stringify(options);
|
||||||
|
|
||||||
return this.searchCache.wrap(
|
return searchWithBackgroundRefresh({
|
||||||
() =>
|
searchCache: this.searchCache,
|
||||||
|
searchCacheKey: cacheKey,
|
||||||
|
bgCacheKey: `knaben:${cacheKey}`,
|
||||||
|
cacheTTL: Env.BUILTIN_KNABEN_SEARCH_CACHE_TTL,
|
||||||
|
fetchFn: () =>
|
||||||
this.request<KnabenSearchResponse>('', {
|
this.request<KnabenSearchResponse>('', {
|
||||||
schema: KnabenSearchResponse,
|
schema: KnabenSearchResponse,
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
timeout: Env.BUILTIN_KNABEN_SEARCH_TIMEOUT,
|
timeout: Env.BUILTIN_KNABEN_SEARCH_TIMEOUT,
|
||||||
body,
|
body,
|
||||||
}),
|
}),
|
||||||
`knaben:search:${JSON.stringify(options)}`,
|
isEmptyResult: (result) => result.hits.length === 0,
|
||||||
Env.BUILTIN_KNABEN_SEARCH_CACHE_TTL
|
logger,
|
||||||
);
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private async request<T>(
|
private async request<T>(
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
import { fetch } from 'undici';
|
import { fetch } from 'undici';
|
||||||
import {
|
import {
|
||||||
Cache,
|
Cache,
|
||||||
|
createLogger,
|
||||||
DistributedLock,
|
DistributedLock,
|
||||||
Env,
|
Env,
|
||||||
formatZodError,
|
formatZodError,
|
||||||
makeRequest,
|
makeRequest,
|
||||||
} from '../../utils/index.js';
|
} from '../../utils/index.js';
|
||||||
import z from 'zod';
|
import z from 'zod';
|
||||||
|
import { searchWithBackgroundRefresh } from '../utils/general.js';
|
||||||
|
|
||||||
interface ResponseMeta {
|
interface ResponseMeta {
|
||||||
headers: Record<string, string>;
|
headers: Record<string, string>;
|
||||||
@@ -80,6 +82,8 @@ const ProwlarrApiSearchItemSchema = z.object({
|
|||||||
|
|
||||||
const ProwlarrApiSearchSchema = z.array(ProwlarrApiSearchItemSchema);
|
const ProwlarrApiSearchSchema = z.array(ProwlarrApiSearchItemSchema);
|
||||||
|
|
||||||
|
const logger = createLogger('prowlarr');
|
||||||
|
|
||||||
export type ProwlarrApiSearchItem = z.infer<typeof ProwlarrApiSearchItemSchema>;
|
export type ProwlarrApiSearchItem = z.infer<typeof ProwlarrApiSearchItemSchema>;
|
||||||
|
|
||||||
class ProwlarrApi {
|
class ProwlarrApi {
|
||||||
@@ -90,7 +94,7 @@ class ProwlarrApi {
|
|||||||
|
|
||||||
private readonly searchCache = Cache.getInstance<
|
private readonly searchCache = Cache.getInstance<
|
||||||
string,
|
string,
|
||||||
ProwlarrApiSearchItem[]
|
ProwlarrApiResponse<ProwlarrApiSearchItem[]>
|
||||||
>('prowlarr-api:search');
|
>('prowlarr-api:search');
|
||||||
|
|
||||||
private readonly indexersCache = Cache.getInstance<
|
private readonly indexersCache = Cache.getInstance<
|
||||||
@@ -156,8 +160,14 @@ class ProwlarrApi {
|
|||||||
limit?: number;
|
limit?: number;
|
||||||
offset?: number;
|
offset?: number;
|
||||||
}): Promise<ProwlarrApiResponse<ProwlarrApiSearchItem[]>> {
|
}): Promise<ProwlarrApiResponse<ProwlarrApiSearchItem[]>> {
|
||||||
return this.searchCache.wrap(
|
const cacheKey = `${this.baseUrl}:${type}:${query}:${indexerIds.join(',')}:${limit}:${offset}`;
|
||||||
() =>
|
|
||||||
|
return searchWithBackgroundRefresh({
|
||||||
|
searchCache: this.searchCache,
|
||||||
|
searchCacheKey: cacheKey,
|
||||||
|
bgCacheKey: `prowlarr:${cacheKey}`,
|
||||||
|
cacheTTL: Env.BUILTIN_PROWLARR_SEARCH_CACHE_TTL,
|
||||||
|
fetchFn: () =>
|
||||||
this.request<ProwlarrApiSearchItem[]>(
|
this.request<ProwlarrApiSearchItem[]>(
|
||||||
'search',
|
'search',
|
||||||
{
|
{
|
||||||
@@ -169,9 +179,9 @@ class ProwlarrApi {
|
|||||||
},
|
},
|
||||||
ProwlarrApiSearchSchema
|
ProwlarrApiSearchSchema
|
||||||
),
|
),
|
||||||
`${this.baseUrl}:${type}:${query}:${indexerIds.join(',')}:${limit}:${offset}`,
|
isEmptyResult: (result) => result.data.length === 0,
|
||||||
Env.BUILTIN_PROWLARR_SEARCH_CACHE_TTL
|
logger,
|
||||||
);
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private getPath(endpoint: string) {
|
private getPath(endpoint: string) {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { Cache } from '../../utils/cache.js';
|
|||||||
import { Env } from '../../utils/env.js';
|
import { Env } from '../../utils/env.js';
|
||||||
import { formatZodError, makeRequest } from '../../utils/index.js';
|
import { formatZodError, makeRequest } from '../../utils/index.js';
|
||||||
import { createLogger } from '../../utils/index.js';
|
import { createLogger } from '../../utils/index.js';
|
||||||
|
import { searchWithBackgroundRefresh } from '../utils/general.js';
|
||||||
|
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
@@ -75,9 +76,10 @@ const API_BASE_URL = Env.BUILTIN_TORRENT_GALAXY_URL;
|
|||||||
class TorrentGalaxyAPI {
|
class TorrentGalaxyAPI {
|
||||||
private headers: Record<string, string>;
|
private headers: Record<string, string>;
|
||||||
|
|
||||||
private readonly searchCache = Cache.getInstance<string, any>(
|
private readonly searchCache = Cache.getInstance<
|
||||||
'torrent-galaxy:search'
|
string,
|
||||||
);
|
TorrentGalaxySearchResponse
|
||||||
|
>('torrent-galaxy:search');
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this.headers = {
|
this.headers = {
|
||||||
@@ -95,8 +97,13 @@ class TorrentGalaxyAPI {
|
|||||||
if (options.page) {
|
if (options.page) {
|
||||||
queryParams.set('page', options.page.toString());
|
queryParams.set('page', options.page.toString());
|
||||||
}
|
}
|
||||||
return this.searchCache.wrap(
|
const cacheKey = JSON.stringify(options);
|
||||||
() =>
|
return searchWithBackgroundRefresh({
|
||||||
|
searchCache: this.searchCache,
|
||||||
|
searchCacheKey: cacheKey,
|
||||||
|
bgCacheKey: `tgx:${cacheKey}`,
|
||||||
|
cacheTTL: Env.BUILTIN_TORRENT_GALAXY_SEARCH_CACHE_TTL,
|
||||||
|
fetchFn: () =>
|
||||||
this.request<TorrentGalaxySearchResponse>(
|
this.request<TorrentGalaxySearchResponse>(
|
||||||
`/get-posts/keywords:${encodeURIComponent(options.query)}:format:json`,
|
`/get-posts/keywords:${encodeURIComponent(options.query)}:format:json`,
|
||||||
{
|
{
|
||||||
@@ -105,9 +112,9 @@ class TorrentGalaxyAPI {
|
|||||||
queryParams,
|
queryParams,
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
`${JSON.stringify(options)}`,
|
isEmptyResult: (result) => result.results.length === 0,
|
||||||
Env.BUILTIN_TORRENT_GALAXY_SEARCH_CACHE_TTL
|
logger,
|
||||||
);
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private async request<T>(
|
private async request<T>(
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
|
import { Logger } from 'winston';
|
||||||
|
import { Cache } from '../../utils/cache.js';
|
||||||
import { Env } from '../../utils/env.js';
|
import { Env } from '../../utils/env.js';
|
||||||
|
import { createLogger } from '../../utils/index.js';
|
||||||
import pLimit from 'p-limit';
|
import pLimit from 'p-limit';
|
||||||
|
|
||||||
export const createQueryLimit = () =>
|
export const createQueryLimit = () =>
|
||||||
@@ -37,3 +40,134 @@ export function useAllTitles(url: string): boolean {
|
|||||||
}
|
}
|
||||||
return !!Env.BUILTIN_SCRAPE_WITH_ALL_TITLES;
|
return !!Env.BUILTIN_SCRAPE_WITH_ALL_TITLES;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const bgRefreshCache = Cache.getInstance<string, number>(
|
||||||
|
'builtins:bg-refresh'
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Options for the searchWithBackgroundRefresh function
|
||||||
|
*/
|
||||||
|
interface SearchWithBgRefreshOptions<T> {
|
||||||
|
searchCache: Cache<string, T>;
|
||||||
|
searchCacheKey: string;
|
||||||
|
bgCacheKey: string;
|
||||||
|
cacheTTL: number;
|
||||||
|
fetchFn: () => Promise<T>;
|
||||||
|
isEmptyResult: (result: T) => boolean;
|
||||||
|
logger: Logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Performs a cached search with background refresh support.
|
||||||
|
*
|
||||||
|
* When a cached result exists:
|
||||||
|
* - Returns the cached result immediately
|
||||||
|
* - Schedules a background refresh if the minimum interval has passed
|
||||||
|
*
|
||||||
|
* When no cached result exists:
|
||||||
|
* - Performs the search synchronously
|
||||||
|
* - Caches the result (unless empty)
|
||||||
|
* - Records the refresh timestamp
|
||||||
|
*
|
||||||
|
* @param options - Configuration options for the search
|
||||||
|
* @returns The search result (cached or fresh)
|
||||||
|
*/
|
||||||
|
export async function searchWithBackgroundRefresh<T>(
|
||||||
|
options: SearchWithBgRefreshOptions<T>
|
||||||
|
): Promise<T> {
|
||||||
|
const {
|
||||||
|
searchCacheKey,
|
||||||
|
bgCacheKey,
|
||||||
|
searchCache,
|
||||||
|
cacheTTL,
|
||||||
|
fetchFn,
|
||||||
|
isEmptyResult,
|
||||||
|
logger,
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
const cachedResult = await searchCache.get(searchCacheKey);
|
||||||
|
|
||||||
|
if (cachedResult !== undefined) {
|
||||||
|
triggerBackgroundRefresh({
|
||||||
|
searchCache,
|
||||||
|
searchCacheKey,
|
||||||
|
bgCacheKey,
|
||||||
|
cacheTTL,
|
||||||
|
fetchFn,
|
||||||
|
isEmptyResult,
|
||||||
|
logger,
|
||||||
|
});
|
||||||
|
return cachedResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await fetchFn();
|
||||||
|
|
||||||
|
// Don't cache empty results
|
||||||
|
if (!isEmptyResult(result)) {
|
||||||
|
await searchCache.set(searchCacheKey, result, cacheTTL);
|
||||||
|
await bgRefreshCache.set(
|
||||||
|
bgCacheKey,
|
||||||
|
Date.now(),
|
||||||
|
Env.BUILTIN_MINIMUM_BACKGROUND_REFRESH_INTERVAL
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Triggers a background refresh if the minimum interval has passed.
|
||||||
|
* This function is fire-and-forget and does not block.
|
||||||
|
*/
|
||||||
|
function triggerBackgroundRefresh<T>(options: {
|
||||||
|
searchCache: Cache<string, T>;
|
||||||
|
searchCacheKey: string;
|
||||||
|
bgCacheKey: string;
|
||||||
|
cacheTTL: number;
|
||||||
|
fetchFn: () => Promise<T>;
|
||||||
|
isEmptyResult: (result: T) => boolean;
|
||||||
|
logger: Logger;
|
||||||
|
}): void {
|
||||||
|
const {
|
||||||
|
searchCacheKey,
|
||||||
|
bgCacheKey,
|
||||||
|
searchCache,
|
||||||
|
cacheTTL,
|
||||||
|
fetchFn,
|
||||||
|
isEmptyResult,
|
||||||
|
logger,
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const lastRefresh = await bgRefreshCache.get(bgCacheKey);
|
||||||
|
const now = Date.now();
|
||||||
|
const intervalMs = Env.BUILTIN_MINIMUM_BACKGROUND_REFRESH_INTERVAL * 1000;
|
||||||
|
|
||||||
|
if (lastRefresh && now - lastRefresh < intervalMs) {
|
||||||
|
// Not enough time has passed since last refresh
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Perform background refresh
|
||||||
|
logger.debug(`Starting background refresh for: ${searchCacheKey}`);
|
||||||
|
const freshResult = await fetchFn();
|
||||||
|
|
||||||
|
// Update cache if result is not empty
|
||||||
|
if (!isEmptyResult(freshResult)) {
|
||||||
|
await searchCache.set(searchCacheKey, freshResult, cacheTTL, true);
|
||||||
|
await bgRefreshCache.set(
|
||||||
|
bgCacheKey,
|
||||||
|
now,
|
||||||
|
Env.BUILTIN_MINIMUM_BACKGROUND_REFRESH_INTERVAL
|
||||||
|
);
|
||||||
|
logger.info(`Background refreshed cache for: ${searchCacheKey}`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(
|
||||||
|
`Background refresh failed for: ${searchCacheKey} - ${error instanceof Error ? error.message : 'Unknown error'}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}
|
||||||
|
|||||||
@@ -1696,6 +1696,10 @@ export const Env = cleanEnv(process.env, {
|
|||||||
default: 7 * 24 * 60 * 60, // 7 days
|
default: 7 * 24 * 60 * 60, // 7 days
|
||||||
desc: 'Builtin Torrent metadata cache TTL',
|
desc: 'Builtin Torrent metadata cache TTL',
|
||||||
}),
|
}),
|
||||||
|
BUILTIN_MINIMUM_BACKGROUND_REFRESH_INTERVAL: num({
|
||||||
|
default: 1 * 24 * 60 * 60, // 1 day
|
||||||
|
desc: 'Minimum interval between background refreshes for built-in addon search caches. Triggered during normal searches.',
|
||||||
|
}),
|
||||||
|
|
||||||
BUILTIN_GDRIVE_CLIENT_ID: str({
|
BUILTIN_GDRIVE_CLIENT_ID: str({
|
||||||
default: undefined,
|
default: undefined,
|
||||||
|
|||||||
@@ -23,7 +23,9 @@ interface TorrentMetadata {
|
|||||||
|
|
||||||
export class TorrentClient {
|
export class TorrentClient {
|
||||||
static readonly #metadataCache = Cache.getInstance<string, TorrentMetadata>(
|
static readonly #metadataCache = Cache.getInstance<string, TorrentMetadata>(
|
||||||
'torrent-metadata'
|
'torrent-metadata',
|
||||||
|
undefined,
|
||||||
|
Env.REDIS_URI ? 'redis' : 'sql'
|
||||||
);
|
);
|
||||||
|
|
||||||
// Track in-progress fetches to avoid duplicate requests
|
// Track in-progress fetches to avoid duplicate requests
|
||||||
|
|||||||
Reference in New Issue
Block a user