From 67c3a35a39d807db1044d3893c2bb56e906dfbb9 Mon Sep 17 00:00:00 2001 From: Viren070 Date: Thu, 6 Nov 2025 14:45:01 +0000 Subject: [PATCH] refactor(nzbdav): adjustments --- packages/core/src/builtins/base/debrid.ts | 61 +++++++++- packages/core/src/debrid/nzbdav.ts | 136 +++++++++++----------- packages/core/src/presets/newznab.ts | 33 ++++++ packages/core/src/presets/nzbhydra.ts | 29 +++++ packages/core/src/streams/proxifier.ts | 5 - packages/core/src/utils/constants.ts | 2 +- 6 files changed, 193 insertions(+), 73 deletions(-) diff --git a/packages/core/src/builtins/base/debrid.ts b/packages/core/src/builtins/base/debrid.ts index a7b11e57..0d1245ee 100644 --- a/packages/core/src/builtins/base/debrid.ts +++ b/packages/core/src/builtins/base/debrid.ts @@ -13,6 +13,7 @@ import { encryptString, Env, formatZodError, + fromUrlSafeBase64, getSimpleTextHash, getTimeTakenSincePoint, SERVICE_DETAILS, @@ -40,6 +41,8 @@ import { MetadataService } from '../../metadata/service.js'; import { Logger } from 'winston'; import pLimit from 'p-limit'; import { cleanTitle } from '../../parser/utils.js'; +import { NzbDavConfig, NzbDAVService } from '../../debrid/nzbdav.js'; +import { createProxy } from '../../proxy/index.js'; export interface SearchMetadata extends TitleMetadata { primaryTitle?: string; @@ -249,12 +252,68 @@ export abstract class BaseDebridAddon { Env.BUILTIN_PLAYBACK_LINK_VALIDITY ); + const results = [...processedTorrents.results, ...processedNzbs.results]; + let nzbdavAuth; + const usingNzbDav = this.userData.services.some((s) => s.id === 'nzbdav'); + const encodedNzbdavAuth = this.userData.services.find( + (s) => s.id === 'nzbdav' + )?.credential; + if (usingNzbDav && encodedNzbdavAuth) { + const { success, data } = NzbDavConfig.safeParse( + JSON.parse(fromUrlSafeBase64(encodedNzbdavAuth)) + ); + + nzbdavAuth = data; + } + let proxyIndices: number[] = []; + if (nzbdavAuth) { + proxyIndices = results + .map((result, index) => ({ result, index })) + .filter(({ result }) => result.service?.id === 'nzbdav') + .map(({ index }) => index); + } + const resultStreams = await Promise.all( - [...processedTorrents.results, ...processedNzbs.results].map((result) => + results.map((result) => this._createStream(result, encryptedStoreAuths, metadataId) ) ); + // proxy the indexes in streamsToProxy + if (proxyIndices.length > 0 && nzbdavAuth) { + const proxy = createProxy({ + id: 'builtin', + enabled: true, + credentials: nzbdavAuth.aiostreamsAuth, + }); + + const proxiedStreams = await proxy.generateUrls( + proxyIndices + .map((i) => resultStreams[i]) + .map((stream) => ({ + url: stream.url!, + filename: stream.behaviorHints?.filename ?? undefined, + headers: { + request: { + Authorization: `Basic ${Buffer.from( + `${nzbdavAuth.webdavUser}:${nzbdavAuth.webdavPassword}` + ).toString('base64')}`, + }, + }, + })) + ); + + if (proxiedStreams) { + for (let i = 0; i < proxyIndices.length; i++) { + const index = proxyIndices[i]; + const proxiedUrl = proxiedStreams[i]; + if (proxiedUrl) { + resultStreams[index].url = proxiedUrl; + } + } + } + } + const processingErrors = [ ...processedTorrents.errors, ...processedNzbs.errors, diff --git a/packages/core/src/debrid/nzbdav.ts b/packages/core/src/debrid/nzbdav.ts index fe36477e..befe8fd7 100644 --- a/packages/core/src/debrid/nzbdav.ts +++ b/packages/core/src/debrid/nzbdav.ts @@ -281,7 +281,7 @@ class NzbDAVApi { } } -const NzbDavConfig = z.object({ +export const NzbDavConfig = z.object({ nzbdavUrl: z .string() .transform((s) => s.trim().replace(/^\/+/, '').replace(/\/+$/, '')), @@ -314,7 +314,29 @@ export class NzbDAVService implements DebridService { this.nzbdavApi = new NzbDAVApi(this.auth.nzbdavUrl, this.auth.nzbdavApiKey); } - private async collectFilesRecursively( + private async collectFiles( + path: string + ): Promise<{ files: FileStat[]; depth: number }> { + // First, try using deep mode (recursive) + try { + const contents = (await this.webdavClient.getDirectoryContents(path, { + deep: true, + })) as FileStat[]; + + const files = contents.filter((item) => item.type === 'file'); + + return { files, depth: 0 }; + } catch (error) { + logger.warn(`Deep listing failed, falling back to manual traversal`, { + path, + error: (error as Error).message, + }); + // Fall back to manual traversal + return this.collectFilesManually(path, 0); + } + } + + private async collectFilesManually( path: string, currentDepth: number = 0 ): Promise<{ files: FileStat[]; depth: number }> { @@ -367,7 +389,7 @@ export class NzbDAVService implements DebridService { const allFiles: FileStat[] = [...files]; for (const dir of directories) { - const { files: subFiles } = await this.collectFilesRecursively( + const { files: subFiles } = await this.collectFilesManually( dir.filename, currentDepth + 1 ); @@ -453,12 +475,17 @@ export class NzbDAVService implements DebridService { return cachedLink; } - logger.debug(`Adding NZB to NzbDAV for ${nzb}`, { hash, filename }); + logger.debug(`Resolving NZB`, { + hash, + filename, + nzbUrl: maskSensitiveInfo(nzb), + }); const category = metadata?.season || metadata?.episode ? 'Tv' : 'Movies'; // Add NZB and get nzoId - const { nzoId } = await this.nzbdavApi.addUrl(nzb, category, filename); + const addResult = await this.nzbdavApi.addUrl(nzb, category, filename); + const nzoId = addResult.nzoId; // Poll history until download is complete const pollStartTime = Date.now(); @@ -468,19 +495,17 @@ export class NzbDAVService implements DebridService { const jobCategory = slot.category || category; logger.debug(`NZB download completed`, { + nzoId, jobName, jobCategory, - nzoId, time: getTimeTakenSincePoint(pollStartTime), }); // Get list of all files in the content folder recursively, stopping when we find video files const contentPath = `/content/${jobCategory}/${jobName}`; - const listStartTime = Date.now(); - const { files: allFiles, depth } = - await this.collectFilesRecursively(contentPath); + const { files: allFiles, depth } = await this.collectFiles(contentPath); if (allFiles.length === 0) { throw new DebridError('No files found in NZB download', { @@ -519,33 +544,39 @@ export class NzbDAVService implements DebridService { files: debridFiles, }; - // Parse all file names for matching - const allStrings = [jobName, ...debridFiles.map((f) => f.name ?? '')]; - const parseResults: ParsedResult[] = allStrings.map((string) => - parseTorrentTitle(string) - ); - const parsedFiles = new Map(); - for (const [index, result] of parseResults.entries()) { - parsedFiles.set(allStrings[index], result); + let selectedFile; + + if (debridFiles.length === 1) { + selectedFile = debridFiles[0]; + } else { + // Parse all file names for matching + const allStrings = [jobName, ...debridFiles.map((f) => f.name ?? '')]; + const parseResults: ParsedResult[] = allStrings.map((string) => + parseTorrentTitle(string) + ); + const parsedFiles = new Map(); + for (const [index, result] of parseResults.entries()) { + parsedFiles.set(allStrings[index], result); + } + + const nzbInfo = { + type: 'usenet' as const, + nzb, + hash, + title: jobName, + metadata, + size: debridFiles.reduce((sum, f) => sum + f.size, 0), + }; + + // Select a file based on the available metadata and files + selectedFile = await selectFileInTorrentOrNZB( + nzbInfo, + debridDownload, + parsedFiles, + metadata + ); } - const nzbInfo = { - type: 'usenet' as const, - nzb, - hash, - title: jobName, - metadata, - size: debridFiles.reduce((sum, f) => sum + f.size, 0), - }; - - // Select a file based on the available metadata and files - const selectedFile = await selectFileInTorrentOrNZB( - nzbInfo, - debridDownload, - parsedFiles, - metadata - ); - if (!selectedFile) { throw new DebridError('No matching file found', { statusCode: 400, @@ -564,38 +595,11 @@ export class NzbDAVService implements DebridService { }); const filePath = selectedFile.path || `${contentPath}/${selectedFile.name}`; - const webdavLink = `${this.auth.nzbdavUrl}${filePath}`; - - const playbackLink = ( - await new BuiltinProxy({ - enabled: true, - id: 'builtin', - credentials: this.auth.aiostreamsAuth, - }).generateUrls([ - { - url: webdavLink, - filename: selectedFile.name || filename, - headers: { - request: { - Authorization: `Basic ${Buffer.from( - `${this.auth.webdavUser}:${this.auth.webdavPassword}` - ).toString('base64')}`, - }, - }, - }, - ]) - )?.[0]; - - if (!playbackLink) { - throw new DebridError('Failed to generate proxied playback link', { - statusCode: 500, - statusText: 'Internal Server Error', - code: 'UNKNOWN', - headers: {}, - body: { webdavLink }, - type: 'api_error', - }); - } + let playbackLink = `${this.auth.nzbdavUrl}${filePath}`; + // const playbackUrl = new URL(playbackLink); + // playbackUrl.username = this.auth.webdavUser; + // playbackUrl.password = encodeURIComponent(this.auth.webdavPassword); + // playbackLink = playbackUrl.toString(); logger.debug(`Generated playback link`, { playbackLink }); diff --git a/packages/core/src/presets/newznab.ts b/packages/core/src/presets/newznab.ts index 5c69c69c..560bb694 100644 --- a/packages/core/src/presets/newznab.ts +++ b/packages/core/src/presets/newznab.ts @@ -6,6 +6,10 @@ import { BuiltinAddonPreset } from './builtin.js'; export class NewznabPreset extends BuiltinAddonPreset { static override get METADATA() { const supportedResources = [constants.STREAM_RESOURCE]; + const supportedServices = [ + constants.TORBOX_SERVICE, + constants.NZBDAV_SERVICE, + ] as const; const options: Option[] = [ { id: 'name', @@ -82,6 +86,21 @@ export class NewznabPreset extends BuiltinAddonPreset { }, ], }, + { + id: 'services', + name: 'Services', + description: + 'Optionally override the services that are used. If not specified, then the services that are enabled and supported will be used.', + type: 'multi-select', + required: false, + showInSimpleMode: false, + options: supportedServices.map((service) => ({ + value: service, + label: constants.SERVICE_DETAILS[service].name, + })), + default: undefined, + emptyIsUndefined: true, + }, { id: 'forceQuerySearch', name: 'Force Query Search', @@ -90,6 +109,15 @@ export class NewznabPreset extends BuiltinAddonPreset { required: false, default: false, }, + { + id: 'useMultipleInstances', + name: 'Use Multiple Instances', + description: + 'Newznab supports multiple services in one instance of the addon - which is used by default. If this is enabled, then the addon will be created for each service.', + type: 'boolean', + default: false, + showInSimpleMode: false, + }, ]; return { @@ -120,6 +148,11 @@ export class NewznabPreset extends BuiltinAddonPreset { )}` ); } + if (options.useMultipleInstances) { + return usableServices.map((service) => + this.generateAddon(userData, options, [service.id]) + ); + } return [ this.generateAddon( userData, diff --git a/packages/core/src/presets/nzbhydra.ts b/packages/core/src/presets/nzbhydra.ts index a5fc7810..265c368c 100644 --- a/packages/core/src/presets/nzbhydra.ts +++ b/packages/core/src/presets/nzbhydra.ts @@ -6,6 +6,10 @@ import { Env } from '../utils/index.js'; export class NZBHydraPreset extends NewznabPreset { static override get METADATA() { const supportedResources = [constants.STREAM_RESOURCE]; + const supportedServices = [ + constants.TORBOX_SERVICE, + constants.NZBDAV_SERVICE, + ] as const; const options: Option[] = [ { id: 'name', @@ -27,6 +31,21 @@ export class NZBHydraPreset extends NewznabPreset { forceInUi: false, }, }, + { + id: 'services', + name: 'Services', + description: + 'Optionally override the services that are used. If not specified, then the services that are enabled and supported will be used.', + type: 'multi-select', + required: false, + showInSimpleMode: false, + options: supportedServices.map((service) => ({ + value: service, + label: constants.SERVICE_DETAILS[service].name, + })), + default: undefined, + emptyIsUndefined: true, + }, { id: 'mediaTypes', name: 'Media Types', @@ -78,6 +97,16 @@ export class NZBHydraPreset extends NewznabPreset { required: false, default: true, }, + + { + id: 'useMultipleInstances', + name: 'Use Multiple Instances', + description: + 'Newznab supports multiple services in one instance of the addon - which is used by default. If this is enabled, then the addon will be created for each service.', + type: 'boolean', + default: false, + showInSimpleMode: false, + }, ]; return { diff --git a/packages/core/src/streams/proxifier.ts b/packages/core/src/streams/proxifier.ts index 4b34966f..02a3d4c3 100644 --- a/packages/core/src/streams/proxifier.ts +++ b/packages/core/src/streams/proxifier.ts @@ -14,11 +14,6 @@ class Proxifier { private shouldProxyStream(stream: ParsedStream): boolean { const streamService = stream.service ? stream.service.id : 'none'; const proxy = this.userData.proxy; - // all nzb dav streams are proxied - if (stream.service?.id === 'nzbdav') { - stream.proxied = true; - return false; - } if (!stream.url || !proxy?.enabled || !proxy.url) { return false; } diff --git a/packages/core/src/utils/constants.ts b/packages/core/src/utils/constants.ts index 037922cd..ed508480 100644 --- a/packages/core/src/utils/constants.ts +++ b/packages/core/src/utils/constants.ts @@ -426,7 +426,7 @@ const SERVICE_DETAILS: Record< id: 'aiostreamsAuth', name: 'AIOStreams Proxy Auth', description: - 'The AIOStreams Proxy Auth username:password pair from the `AIOSTREAMS_AUTH` environment variable. It is required to play streams from Nzb DAV.', + 'It is required to proxy the NzbDAV streams through AIOStreams. Provide a username:password pair from the `AIOSTREAMS_AUTH` environment variable.', type: 'password', required: true, },