From 5434da5777c45a7c06e77ea3d9d70d6bd4da0bdc Mon Sep 17 00:00:00 2001 From: Viren070 Date: Wed, 26 Nov 2025 02:16:28 +0000 Subject: [PATCH] feat: add check owned option --- .env.sample | 3 + packages/core/src/builtins/base/debrid.ts | 12 +- .../builtins/torbox-search/source-handlers.ts | 6 +- packages/core/src/builtins/utils/debrid.ts | 30 ++- packages/core/src/debrid/altmount.ts | 7 +- packages/core/src/debrid/base.ts | 6 +- packages/core/src/debrid/nzbdav.ts | 9 +- packages/core/src/debrid/torbox.ts | 10 +- .../core/src/debrid/usenet-stream-base.ts | 191 +++++++++++++----- packages/core/src/debrid/utils.ts | 4 +- packages/core/src/presets/newznab.ts | 10 + packages/core/src/presets/nzbhydra.ts | 18 ++ packages/core/src/utils/env.ts | 4 + 13 files changed, 239 insertions(+), 71 deletions(-) diff --git a/.env.sample b/.env.sample index 4625f63b..63562feb 100644 --- a/.env.sample +++ b/.env.sample @@ -93,6 +93,9 @@ BUILTIN_DEBRID_INSTANT_AVAILABILITY_CACHE_TTL=1800 # How long download links for an item remain cached for. # Default: 1 hour BUILTIN_DEBRID_PLAYBACK_LINK_CACHE_TTL=3600 +# How long the list of downloads in the user's library is cached for. +# Default: 5 minutes +BUILTIN_DEBRID_LIBRARY_CACHE_TTL=300 # The timeout for getting the magnet from a torrent link. Happens in bulk during searches when only download URL is present. BUILTIN_GET_TORRENT_TIMEOUT=5000 diff --git a/packages/core/src/builtins/base/debrid.ts b/packages/core/src/builtins/base/debrid.ts index da06be06..afa5c4ec 100644 --- a/packages/core/src/builtins/base/debrid.ts +++ b/packages/core/src/builtins/base/debrid.ts @@ -61,6 +61,7 @@ export const BaseDebridConfigSchema = z.object({ tmdbReadAccessToken: z.string().optional(), tvdbApiKey: z.string().optional(), cacheAndPlay: CacheAndPlaySchema.optional(), + checkOwned: z.boolean().optional().default(true), }); export type BaseDebridConfig = z.infer; @@ -244,7 +245,14 @@ export abstract class BaseDebridAddon { searchMetadata, this.clientIp ), - processNZBs(nzbResults, nzbServices, id, searchMetadata, this.clientIp), + processNZBs( + nzbResults, + nzbServices, + id, + searchMetadata, + this.clientIp, + this.userData.checkOwned + ), ]); const encryptedStoreAuths = this.userData.services.reduce( @@ -706,7 +714,7 @@ export abstract class BaseDebridAddon { : '⏳' : ''; - const name = `[${shortCode} ${cacheIndicator}${torrentOrNzb.service?.owned ? ' ☁️' : ''}] ${this.name}`; + const name = `[${shortCode} ${cacheIndicator}${torrentOrNzb.service?.library ? ' ☁️' : ''}] ${this.name}`; const description = `${torrentOrNzb.title}\n${torrentOrNzb.file.name}\n${ torrentOrNzb.indexer ? `🔍 ${torrentOrNzb.indexer}` : '' } ${'seeders' in torrentOrNzb && torrentOrNzb.seeders ? `👤 ${torrentOrNzb.seeders}` : ''} ${ diff --git a/packages/core/src/builtins/torbox-search/source-handlers.ts b/packages/core/src/builtins/torbox-search/source-handlers.ts index cc1afabc..fa37ac4a 100644 --- a/packages/core/src/builtins/torbox-search/source-handlers.ts +++ b/packages/core/src/builtins/torbox-search/source-handlers.ts @@ -131,7 +131,7 @@ abstract class SourceHandler { ? parseAgeString(torrentOrNzb.age) : undefined; - const name = `[${shortCode} ${cacheIndicator}${torrentOrNzb.service?.owned ? ' ☁️' : ''}] TorBox Search`; + const name = `[${shortCode} ${cacheIndicator}${torrentOrNzb.service?.library ? ' ☁️' : ''}] TorBox Search`; const description = `${torrentOrNzb.title}\n${torrentOrNzb.file.name}\n${ torrentOrNzb.indexer ? `🔍 ${torrentOrNzb.indexer}` : '' } ${'seeders' in torrentOrNzb && torrentOrNzb.seeders ? `👤 ${torrentOrNzb.seeders}` : ''} ${ @@ -347,7 +347,7 @@ export class TorrentSourceHandler extends SourceHandler { ); results.forEach((result) => { - result.service!.owned = + result.service!.library = fetchResult.torrents.find((torrent) => torrent.hash === result.hash) ?.owned ?? false; }); @@ -586,7 +586,7 @@ export class UsenetSourceHandler extends SourceHandler { ); results.forEach((result) => { - result.service!.owned = + result.service!.library = nzbs.find((nzb) => nzb.hash === result.hash)?.owned ?? false; }); diff --git a/packages/core/src/builtins/utils/debrid.ts b/packages/core/src/builtins/utils/debrid.ts index e6d5bcf9..68a23090 100644 --- a/packages/core/src/builtins/utils/debrid.ts +++ b/packages/core/src/builtins/utils/debrid.ts @@ -226,7 +226,7 @@ async function processTorrentsForDebridService( service: { id: service.id, cached: magnetCheckResult?.status === 'cached', - owned: false, + library: magnetCheckResult?.library === true, }, }); } @@ -235,6 +235,8 @@ async function processTorrentsForDebridService( logger.debug(`Finished processing of torrents`, { service: service.id, torrents: torrents.length, + validTorrents: validTorrents.length, + finalTorrents: results.length, totalTime: getTimeTakenSincePoint(processingStart), parseTime, }); @@ -329,7 +331,8 @@ export async function processNZBs( debridServices: BuiltinDebridServices, stremioId: string, metadata?: Metadata, - clientIp?: string + clientIp?: string, + checkOwned: boolean = true ): Promise<{ results: NZBWithSelectedFile[]; errors: { serviceId: BuiltinServiceId; error: Error }[]; @@ -347,7 +350,8 @@ export async function processNZBs( service, stremioId, metadata, - clientIp + clientIp, + checkOwned ); return { serviceId: service.id, results: serviceResults, error: null }; } catch (error) { @@ -375,7 +379,8 @@ async function processNZBsForDebridService( service: BuiltinDebridServices[number], stremioId: string, metadata?: Metadata, - clientIp?: string + clientIp?: string, + checkOwned: boolean = true ): Promise { const startTime = Date.now(); const debridService = getDebridService( @@ -391,7 +396,8 @@ async function processNZBsForDebridService( const results: NZBWithSelectedFile[] = []; const nzbCheckResults = await debridService.checkNzbs( - nzbs.map((nzb) => nzb.hash) + nzbs.map((nzb) => ({ name: nzb.title, hash: nzb.hash })), + checkOwned ); logger.debug(`Retrieved NZB status from debrid`, { @@ -413,7 +419,7 @@ async function processNZBsForDebridService( // Filter NZBs that pass validation checks const validNZBs: { nzb: NZB; - nzbCheckResult: any; + nzbCheckResult: DebridDownload | undefined; parsedTitle: ParsedResult; }[] = []; for (const nzb of nzbs) { @@ -471,15 +477,19 @@ async function processNZBsForDebridService( service: { id: service.id, cached: nzbCheckResult?.status === 'cached', - owned: false, + library: nzbCheckResult?.library === true, }, }); } } - logger.debug( - `Processed ${nzbs.length} NZBs for ${service.id} in ${getTimeTakenSincePoint(processingStart)}` - ); + logger.debug(`Finished processing of NZBs`, { + service: service.id, + nzbs: nzbs.length, + validNzbs: validNZBs.length, + finalNzbs: results.length, + totalTime: getTimeTakenSincePoint(processingStart), + }); return results; } diff --git a/packages/core/src/debrid/altmount.ts b/packages/core/src/debrid/altmount.ts index 19ab6fa2..4a50f3f8 100644 --- a/packages/core/src/debrid/altmount.ts +++ b/packages/core/src/debrid/altmount.ts @@ -3,7 +3,7 @@ import { UsenetStreamService, UsenetStreamServiceConfig, } from './usenet-stream-base.js'; -import { DebridServiceConfig } from './base.js'; +import { DebridServiceConfig, PlaybackInfo } from './base.js'; import { ServiceId, createLogger, fromUrlSafeBase64 } from '../utils/index.js'; import { basename } from 'path'; @@ -49,7 +49,10 @@ export class AltmountService extends UsenetStreamService { return '/complete'; } - protected getExpectedFolderName(nzbUrl: string, filename: string): string { + protected getExpectedFolderName( + nzb: PlaybackInfo & { type: 'usenet' } + ): string { + const nzbUrl = nzb.nzb; // Altmount uses basename of the NZB URL return nzbUrl.endsWith('.nzb') ? basename(nzbUrl, '.nzb') diff --git a/packages/core/src/debrid/base.ts b/packages/core/src/debrid/base.ts index dd5fb1b3..4b542960 100644 --- a/packages/core/src/debrid/base.ts +++ b/packages/core/src/debrid/base.ts @@ -86,6 +86,7 @@ export type DebridFile = z.infer; export interface DebridDownload { id: string | number; + library?: boolean; hash?: string; name?: string; size?: number; @@ -174,7 +175,10 @@ export interface DebridService { generateTorrentLink(link: string, clientIp?: string): Promise; // Usenet specific methods - checkNzbs?(nzbs: string[]): Promise; + checkNzbs?( + nzbs: { name?: string; hash?: string }[], + checkOwned?: boolean + ): Promise; listNzbs?(id?: string): Promise; addNzb?(nzb: string, name: string): Promise; generateUsenetLink?( diff --git a/packages/core/src/debrid/nzbdav.ts b/packages/core/src/debrid/nzbdav.ts index a2949a0c..24557466 100644 --- a/packages/core/src/debrid/nzbdav.ts +++ b/packages/core/src/debrid/nzbdav.ts @@ -6,7 +6,7 @@ import { UsenetStreamService, UsenetStreamServiceConfig, } from './usenet-stream-base.js'; -import { DebridServiceConfig } from './base.js'; +import { DebridServiceConfig, PlaybackInfo } from './base.js'; import { ServiceId, createLogger, fromUrlSafeBase64 } from '../utils/index.js'; const logger = createLogger('nzbdav'); @@ -51,8 +51,9 @@ export class NzbDAVService extends UsenetStreamService { return '/content'; } - protected getExpectedFolderName(nzbUrl: string, filename: string): string { - // NzbDAV uses the filename parameter - return filename; + protected getExpectedFolderName( + nzb: PlaybackInfo & { type: 'usenet' } + ): string { + return nzb.filename ?? 'unknown_nzb'; } } diff --git a/packages/core/src/debrid/torbox.ts b/packages/core/src/debrid/torbox.ts index 7883daf9..4fa84cbe 100644 --- a/packages/core/src/debrid/torbox.ts +++ b/packages/core/src/debrid/torbox.ts @@ -64,10 +64,16 @@ export class TorboxDebridService implements DebridService { return this.stremthru.generateTorrentLink(link, clientIp); } - public async checkNzbs(hashes: string[]): Promise { + public async checkNzbs( + nzbs: { name?: string; hash?: string }[] + ): Promise { + nzbs = nzbs.filter((nzb) => nzb.hash); + if (nzbs.length === 0) { + return []; + } const cachedResults: DebridDownload[] = []; const hashesToCheck: string[] = []; - for (const hash of hashes) { + for (const { hash } of nzbs as { hash: string }[]) { const cacheKey = getSimpleTextHash(hash); const cached = await TorboxDebridService.instantAvailabilityCache.get(cacheKey); diff --git a/packages/core/src/debrid/usenet-stream-base.ts b/packages/core/src/debrid/usenet-stream-base.ts index f4c28a84..14033077 100644 --- a/packages/core/src/debrid/usenet-stream-base.ts +++ b/packages/core/src/debrid/usenet-stream-base.ts @@ -118,7 +118,10 @@ export class SABnzbdApi { } protected async request( - params: Record, + params: Record< + string, + string | undefined | number | boolean | null | string[] + >, schema: T, timeoutMs: number = 80000 ): Promise<{ @@ -129,12 +132,14 @@ export class SABnzbdApi { }> { const url = new URL(this.apiUrl); Object.entries(params).forEach(([key, value]) => { - url.searchParams.append(key, value); + if (!value) return; + const val = Array.isArray(value) ? value.join(',') : String(value); + url.searchParams.append(key, val); }); this.logger.debug(`Making ${this.serviceName} API request`, { ...params, - apikey: maskSensitiveInfo(params.apikey), + apikey: maskSensitiveInfo(this.apiKey), fullUrl: maskSensitiveInfo(url.toString()), }); @@ -299,6 +304,47 @@ export class SABnzbdApi { return { nzoId }; } + async history( + params: { + start?: number; + limit?: number; + nzoIds?: string[]; + category?: string; + } = {} + ) { + const tParams = { + mode: 'history', + apikey: this.apiKey, + start: params.start ?? 0, + limit: params.limit ?? 50, + nzo_ids: params.nzoIds ? params.nzoIds.join(',') : undefined, + category: params.category, + }; + + const { + data: parsed, + statusCode, + statusText, + headers, + } = await this.request(params, HistoryResponseSchema, 60000); + const transformed = transformHistoryResponse(parsed); + + if (transformed.status === false || !transformed.history) { + throw new DebridError( + `Failed to query history: ${transformed.error || 'Unknown error'}`, + { + statusCode, + statusText, + code: convertStatusCodeToError(statusCode), + headers, + body: JSON.stringify(parsed), + type: 'api_error', + } + ); + } + return transformed.history; + } + async waitForHistorySlot( nzoId: string, category: string, @@ -308,40 +354,12 @@ export class SABnzbdApi { const deadline = Date.now() + timeoutMs; while (Date.now() < deadline) { - const params = { - mode: 'history', - apikey: this.apiKey, - start: '0', - limit: '50', - nzo_ids: nzoId, + const history = await this.history({ + nzoIds: [nzoId], category, - }; + }); - const { - data: parsed, - statusCode, - statusText, - headers, - } = await this.request(params, HistoryResponseSchema, 60000); - const transformed = transformHistoryResponse(parsed); - - if (transformed.status === false || !transformed.history) { - throw new DebridError( - `Failed to query history: ${transformed.error || 'Unknown error'}`, - { - statusCode, - statusText, - code: convertStatusCodeToError(statusCode), - headers, - body: JSON.stringify(parsed), - type: 'api_error', - } - ); - } - - const slot = transformed.history.slots.find( - (entry) => entry.nzoId === nzoId - ); + const slot = history.slots.find((entry) => entry.nzoId === nzoId); if (slot) { if (slot.status === 'completed') { @@ -401,6 +419,9 @@ export abstract class UsenetStreamService implements DebridService { protected static playbackLinkCache = Cache.getInstance( 'usenet-stream:link' ); + protected static libraryCache = Cache.getInstance( + 'usenet-stream:library' + ); readonly supportsUsenet = true; abstract readonly serviceName: ServiceId; @@ -421,8 +442,7 @@ export abstract class UsenetStreamService implements DebridService { * NzbDAV uses the filename parameter, Altmount uses basename of URL */ protected abstract getExpectedFolderName( - nzbUrl: string, - filename: string + nzb: PlaybackInfo & { type: 'usenet' } ): string; constructor( @@ -566,7 +586,67 @@ export abstract class UsenetStreamService implements DebridService { throw new Error('Unsupported operation'); } - public async checkNzbs(hashes: string[]): Promise { + public async listNzbs(): Promise { + const cacheKey = `${this.serviceName}:${this.config.token}`; + + const { result } = await DistributedLock.getInstance().withLock( + `uss:library:${cacheKey}`, + async () => { + const start = Date.now(); + const cachedNzbs = await UsenetStreamService.libraryCache.get(cacheKey); + if (cachedNzbs) { + this.serviceLogger.debug( + `Using cached NZB list for ${this.serviceName}` + ); + return cachedNzbs; + } + + // const path = `${this.getContentPathPrefix()}/${UsenetStreamService.AIOSTREAMS_CATEGORY}`; + // const contents = (await this.webdavClient.getDirectoryContents( + // path + // )) as FileStat[]; + // const nzbs = contents.map((item, index) => ({ + // id: index, + // status: 'cached' as const, + // hash: item.basename, + // size: item.size, + // files: [], + // })); + // this.serviceLogger.debug(`Listed NZBs from WebDAV`, { + // count: nzbs.length, + // time: getTimeTakenSincePoint(start), + // }); + const history = await this.api.history(); + const nzbs: DebridDownload[] = history.slots.map((slot, index) => ({ + id: index, + status: 'cached' as const, + name: slot.name, + })); + this.serviceLogger.debug(`Listed NZBs from history`, { + count: nzbs.length, + time: getTimeTakenSincePoint(start), + }); + await UsenetStreamService.libraryCache.set( + cacheKey, + nzbs, + Env.BUILTIN_DEBRID_LIBRARY_CACHE_TTL, + true + ); + + return nzbs; + }, + { + type: 'memory', + timeout: 5000, + } + ); + return result; + } + + public async checkNzbs( + nzbs: { name?: string; hash?: string }[], + checkOwned: boolean = true + ): Promise { // if aiostreamsAuth is present, validate it. if (this.auth.aiostreamsAuth) { try { @@ -582,12 +662,29 @@ export abstract class UsenetStreamService implements DebridService { }); } } + let libraryNzbs: DebridDownload[] = []; + + try { + libraryNzbs = checkOwned ? await this.listNzbs() : []; + } catch (error) { + this.serviceLogger.warn(`Failed to list library NZBs for checkNzbs`, { + error: (error as Error).message, + }); + } + // All NZBs are "cached" since it's streaming-based - return hashes.map((h, index) => ({ - id: index, - status: 'cached', - hash: h, - })); + return nzbs.map(({ hash: h, name: n }, index) => { + const libraryNzb = libraryNzbs.find( + (nzb) => nzb.name === n || nzb.name === h + ); + return { + id: index, + status: 'cached', + library: !!libraryNzb, + hash: h, + name: n, + }; + }); } public async resolve( @@ -632,7 +729,7 @@ export abstract class UsenetStreamService implements DebridService { }); const category = metadata?.season || metadata?.episode ? 'Tv' : 'Movies'; - const expectedFolderName = this.getExpectedFolderName(nzb, filename); + const expectedFolderName = this.getExpectedFolderName(playbackInfo); // Check if content already exists at the expected path const expectedContentPath = `${this.getContentPathPrefix()}/${category}/${expectedFolderName}`; @@ -676,7 +773,11 @@ export abstract class UsenetStreamService implements DebridService { // Only add NZB if content doesn't already exist if (!alreadyExists) { - const addResult = await this.api.addUrl(nzb, category, filename); + const addResult = await this.api.addUrl( + nzb, + category, + expectedFolderName + ); nzoId = addResult.nzoId; // Poll history until download is complete diff --git a/packages/core/src/debrid/utils.ts b/packages/core/src/debrid/utils.ts index 1d768742..764e595f 100644 --- a/packages/core/src/debrid/utils.ts +++ b/packages/core/src/debrid/utils.ts @@ -72,7 +72,7 @@ export interface TorrentWithSelectedFile extends Torrent { service?: { id: BuiltinServiceId; cached: boolean; - owned: boolean; + library: boolean; }; } @@ -81,7 +81,7 @@ export interface NZBWithSelectedFile extends NZB { service?: { id: BuiltinServiceId; cached: boolean; - owned: boolean; + library: boolean; }; } diff --git a/packages/core/src/presets/newznab.ts b/packages/core/src/presets/newznab.ts index 42e58572..be2d02b6 100644 --- a/packages/core/src/presets/newznab.ts +++ b/packages/core/src/presets/newznab.ts @@ -161,6 +161,15 @@ export class NewznabPreset extends BuiltinAddonPreset { default: false, showInSimpleMode: false, }, + { + id: 'checkOwned', + name: 'Check Owned NZBs', + description: + 'When searching for NZBs, check if the NZB is already owned (in your library) and mark it as such if so. Note: only applies to nzbDAV/Altmount.', + type: 'boolean', + default: true, + showInSimpleMode: false, + }, { id: 'useMultipleInstances', name: 'Use Multiple Instances', @@ -266,6 +275,7 @@ export class NewznabPreset extends BuiltinAddonPreset { ) { const config = { ...this.getBaseConfig(userData, services), + checkOwned: options.checkOwned ?? true, url: options.newznabUrl, apiPath: options.apiPath, apiKey: options.apiKey, diff --git a/packages/core/src/presets/nzbhydra.ts b/packages/core/src/presets/nzbhydra.ts index 0b6b2207..3ff48275 100644 --- a/packages/core/src/presets/nzbhydra.ts +++ b/packages/core/src/presets/nzbhydra.ts @@ -105,6 +105,24 @@ export class NZBHydraPreset extends NewznabPreset { { label: 'Both', value: 'both' }, ], }, + { + id: 'paginate', + name: 'Paginate Results', + description: + 'Enabling this option will make the addon paginate through all available results to provide a more comprehensive set of results. Enabling this can increase the time taken to return results, some endpoints may not support pagination, and this will also increase the number of requests.', + type: 'boolean', + default: false, + showInSimpleMode: false, + }, + { + id: 'checkOwned', + name: 'Check Owned NZBs', + description: + 'When searching for NZBs, check if the NZB is already owned (in your library) and mark it as such if so. Note: only applies to nzbDAV/Altmount.', + type: 'boolean', + default: true, + showInSimpleMode: false, + }, { id: 'useMultipleInstances', name: 'Use Multiple Instances', diff --git a/packages/core/src/utils/env.ts b/packages/core/src/utils/env.ts index 2946f4cb..b83a10ca 100644 --- a/packages/core/src/utils/env.ts +++ b/packages/core/src/utils/env.ts @@ -1650,6 +1650,10 @@ export const Env = cleanEnv(process.env, { default: 60 * 60, // 1 hour desc: 'Builtin Debrid playback link cache TTL', }), + BUILTIN_DEBRID_LIBRARY_CACHE_TTL: num({ + default: 60 * 5, // 5 minutes + desc: 'Builtin Debrid NZB list cache TTL', + }), BUILTIN_DEBRID_METADATA_STORE: str({ choices: ['redis', 'sql', 'memory'], default: undefined,