diff --git a/packages/core/src/builtins/base/debrid.ts b/packages/core/src/builtins/base/debrid.ts index 817589d8..5f173850 100644 --- a/packages/core/src/builtins/base/debrid.ts +++ b/packages/core/src/builtins/base/debrid.ts @@ -42,6 +42,7 @@ import { Logger } from 'winston'; import pLimit from 'p-limit'; import { cleanTitle } from '../../parser/utils.js'; import { NzbDavConfig, NzbDAVService } from '../../debrid/nzbdav.js'; +import { AltmountConfig, AltmountService } from '../../debrid/altmount.js'; import { createProxy } from '../../proxy/index.js'; export interface SearchMetadata extends TitleMetadata { @@ -253,24 +254,56 @@ export abstract class BaseDebridAddon { ); const results = [...processedTorrents.results, ...processedNzbs.results]; + + // Setup auth for both NzbDAV and Altmount let nzbdavAuth; - const usingNzbDav = this.userData.services.some((s) => s.id === 'nzbdav'); + let altmountAuth; + const encodedNzbdavAuth = this.userData.services.find( (s) => s.id === 'nzbdav' )?.credential; - if (usingNzbDav && encodedNzbdavAuth) { + const encodedAltmountAuth = this.userData.services.find( + (s) => s.id === 'altmount' + )?.credential; + + if (encodedNzbdavAuth) { const { success, data } = NzbDavConfig.safeParse( JSON.parse(fromUrlSafeBase64(encodedNzbdavAuth)) ); - - nzbdavAuth = data; + if (success) { + nzbdavAuth = data; + } } - let proxyIndices: number[] = []; + + if (encodedAltmountAuth) { + const { success, data } = AltmountConfig.safeParse( + JSON.parse(fromUrlSafeBase64(encodedAltmountAuth)) + ); + if (success) { + altmountAuth = data; + } + } + + // Collect indices for proxying + const nzbdavProxyIndices: number[] = []; + const altmountProxyIndices: number[] = []; + if (nzbdavAuth) { - proxyIndices = results - .map((result, index) => ({ result, index })) - .filter(({ result }) => result.service?.id === 'nzbdav') - .map(({ index }) => index); + nzbdavProxyIndices.push( + ...results + .map((result, index) => ({ result, index })) + .filter(({ result }) => result.service?.id === 'nzbdav') + .map(({ index }) => index) + ); + } + + if (altmountAuth) { + altmountProxyIndices.push( + ...results + .map((result, index) => ({ result, index })) + .filter(({ result }) => result.service?.id === 'altmount') + .map(({ index }) => index) + ); } let resultStreams = await Promise.all( @@ -280,8 +313,8 @@ export abstract class BaseDebridAddon { ); const proxyErrors: Stream[] = []; - // proxy the indexes in streamsToProxy - if (proxyIndices.length > 0 && nzbdavAuth) { + // Proxy NzbDAV streams + if (nzbdavProxyIndices.length > 0 && nzbdavAuth) { const proxy = createProxy({ id: 'builtin', enabled: true, @@ -289,7 +322,7 @@ export abstract class BaseDebridAddon { }); const proxiedStreams = await proxy.generateUrls( - proxyIndices + nzbdavProxyIndices .map((i) => resultStreams[i]) .map((stream) => ({ url: stream.url!, @@ -305,8 +338,8 @@ export abstract class BaseDebridAddon { ); if (proxiedStreams) { - for (let i = 0; i < proxyIndices.length; i++) { - const index = proxyIndices[i]; + for (let i = 0; i < nzbdavProxyIndices.length; i++) { + const index = nzbdavProxyIndices[i]; const proxiedUrl = proxiedStreams[i]; if (proxiedUrl) { resultStreams[index].url = proxiedUrl; @@ -321,7 +354,53 @@ export abstract class BaseDebridAddon { ); // remove all nzbdav streams resultStreams = resultStreams.filter( - (_, i) => !proxyIndices.includes(i) + (_, i) => !nzbdavProxyIndices.includes(i) + ); + } + } + + // Proxy Altmount streams + if (altmountProxyIndices.length > 0 && altmountAuth) { + const proxy = createProxy({ + id: 'builtin', + enabled: true, + credentials: altmountAuth.aiostreamsAuth, + }); + + const proxiedStreams = await proxy.generateUrls( + altmountProxyIndices + .map((i) => resultStreams[i]) + .map((stream) => ({ + url: stream.url!, + filename: stream.behaviorHints?.filename ?? undefined, + headers: { + request: { + Authorization: `Basic ${Buffer.from( + `${altmountAuth.webdavUser}:${altmountAuth.webdavPassword}` + ).toString('base64')}`, + }, + }, + })) + ); + + if (proxiedStreams) { + for (let i = 0; i < altmountProxyIndices.length; i++) { + const index = altmountProxyIndices[i]; + const proxiedUrl = proxiedStreams[i]; + if (proxiedUrl) { + resultStreams[index].url = proxiedUrl; + } + } + } else { + proxyErrors.push( + this._createErrorStream({ + title: `${this.name}`, + description: `Failed to proxy Altmount streams, ensure your proxy auth is correct.`, + }) + ); + // remove all altmount streams + resultStreams = resultStreams.filter( + (_, i) => !altmountProxyIndices.includes(i) ); } } diff --git a/packages/core/src/builtins/newznab/addon.ts b/packages/core/src/builtins/newznab/addon.ts index 9e8f421c..03416d23 100644 --- a/packages/core/src/builtins/newznab/addon.ts +++ b/packages/core/src/builtins/newznab/addon.ts @@ -38,7 +38,11 @@ export class NewznabAddon extends BaseNabAddon { if ( userData.services.some( (s) => - ![constants.TORBOX_SERVICE, constants.NZBDAV_SERVICE].includes(s.id) + ![ + constants.TORBOX_SERVICE, + constants.NZBDAV_SERVICE, + constants.ALTMOUNT_SERVICE, + ].includes(s.id) ) ) { throw new Error( diff --git a/packages/core/src/debrid/altmount.ts b/packages/core/src/debrid/altmount.ts new file mode 100644 index 00000000..3bc9352e --- /dev/null +++ b/packages/core/src/debrid/altmount.ts @@ -0,0 +1,60 @@ +import { z } from 'zod'; +import { + UsenetStreamService, + UsenetStreamServiceConfig, +} from './usenet-stream-base.js'; +import { DebridServiceConfig } from './base.js'; +import { ServiceId, createLogger, fromUrlSafeBase64 } from '../utils/index.js'; +import { basename } from 'path'; + +const logger = createLogger('altmount'); + +export const AltmountConfig = z.object({ + altmountUrl: z + .string() + .transform((s) => s.trim().replace(/^\/+/, '').replace(/\/+$/, '')), + altmountApiKey: z.string(), + webdavUser: z.string(), + webdavPassword: z.string(), + aiostreamsAuth: z.string(), +}); + +export class AltmountService extends UsenetStreamService { + readonly serviceName: ServiceId = 'altmount'; + readonly serviceLogger = logger; + + constructor(config: DebridServiceConfig) { + const parsedConfig = AltmountConfig.parse( + JSON.parse(fromUrlSafeBase64(config.token)) + ); + + const auth: UsenetStreamServiceConfig = { + webdavUrl: `${parsedConfig.altmountUrl}/webdav/`, + webdavUser: parsedConfig.webdavUser, + webdavPassword: parsedConfig.webdavPassword, + apiUrl: `${parsedConfig.altmountUrl}/sabnzbd/api`, + apiKey: parsedConfig.altmountApiKey, + aiostreamsAuth: parsedConfig.aiostreamsAuth, + }; + + super(config, auth, 'altmount'); + } + + protected getContentPathPrefix(): string { + return '/complete'; + } + + protected getExpectedFolderName(nzbUrl: string, filename: string): string { + // Altmount uses basename of the NZB URL + return nzbUrl.endsWith('.nzb') + ? basename(nzbUrl, '.nzb') + : basename(nzbUrl); + } + + protected async generatePlaybackLink(filePath: string): Promise { + const parsedConfig = AltmountConfig.parse( + JSON.parse(fromUrlSafeBase64(this.config.token)) + ); + return `${parsedConfig.altmountUrl}/webdav${filePath}`; + } +} diff --git a/packages/core/src/debrid/index.ts b/packages/core/src/debrid/index.ts index d1bf74a9..154ef6ee 100644 --- a/packages/core/src/debrid/index.ts +++ b/packages/core/src/debrid/index.ts @@ -2,6 +2,8 @@ export * from './base.js'; export * from './utils.js'; export * from './stremthru.js'; export * from './torbox.js'; +export * from './nzbdav.js'; +export * from './altmount.js'; import { ServiceId } from '../utils/index.js'; import { DebridService, DebridServiceConfig } from './base.js'; @@ -9,6 +11,7 @@ import { StremThruInterface } from './stremthru.js'; import { TorboxDebridService } from './torbox.js'; import { StremThruPreset } from '../presets/stremthru.js'; import { NzbDAVService } from './nzbdav.js'; +import { AltmountService } from './altmount.js'; export function getDebridService( serviceName: ServiceId, @@ -25,6 +28,8 @@ export function getDebridService( return new TorboxDebridService(config); case 'nzbdav': return new NzbDAVService(config); + case 'altmount': + return new AltmountService(config); default: if (StremThruPreset.supportedServices.includes(serviceName)) { return new StremThruInterface({ ...config, serviceName }); diff --git a/packages/core/src/debrid/nzbdav.ts b/packages/core/src/debrid/nzbdav.ts index 2a405598..6c2bd8c9 100644 --- a/packages/core/src/debrid/nzbdav.ts +++ b/packages/core/src/debrid/nzbdav.ts @@ -1,298 +1,16 @@ -import { - Env, - ServiceId, - createLogger, - getSimpleTextHash, - Cache, - DistributedLock, - fromUrlSafeBase64, - getTimeTakenSincePoint, - maskSensitiveInfo, -} from '../utils/index.js'; -import { isVideoFile, selectFileInTorrentOrNZB } from './utils.js'; -import { - DebridService, - DebridServiceConfig, - DebridDownload, - PlaybackInfo, - DebridError, - DebridFile, -} from './base.js'; -import { ParsedResult, parseTorrentTitle } from '@viren070/parse-torrent-title'; -import z from 'zod'; -import { createClient, WebDAVClient, FileStat } from 'webdav'; -import { fetch } from 'undici'; -import { BuiltinProxy } from '../proxy/builtin.js'; - // Credit goes to Sanket9225 for the idea and inspiration // https://github.com/Sanket9225/UsenetStreamer/blob/master/server.js +import { z } from 'zod'; +import { + UsenetStreamService, + UsenetStreamServiceConfig, +} from './usenet-stream-base.js'; +import { DebridServiceConfig } from './base.js'; +import { ServiceId, createLogger, fromUrlSafeBase64 } from '../utils/index.js'; + const logger = createLogger('nzbdav'); -// Zod schemas for NzbDAV API responses -const AddUrlResponseSchema = z.object({ - status: z.boolean(), - nzo_ids: z.array(z.string()).optional(), - error: z.string().nullable().optional(), -}); - -const HistorySlotSchema = z.object({ - nzo_id: z.string(), - status: z.string(), - name: z.string().optional(), - category: z.string().optional(), - fail_message: z.string().optional(), -}); - -const HistoryResponseSchema = z.object({ - status: z.boolean(), - history: z - .object({ - slots: z.array(HistorySlotSchema), - }) - .optional(), - error: z.string().nullable().optional(), -}); - -// Transform API responses to camelCase -const transformAddUrlResponse = ( - data: z.infer -) => ({ - status: data.status, - nzoIds: data.nzo_ids, - error: data.error, -}); - -const transformHistorySlot = (slot: z.infer) => ({ - nzoId: slot.nzo_id, - status: slot.status.toLowerCase(), - name: slot.name, - category: slot.category, - failMessage: slot.fail_message, -}); - -const transformHistoryResponse = ( - data: z.infer -) => ({ - status: data.status, - history: { - slots: data.history?.slots.map(transformHistorySlot) ?? [], - }, - error: data.error, -}); - -class NzbDAVApi { - constructor( - private readonly nzbdavUrl: string, - private readonly apiKey: string - ) {} - - private async request( - params: Record, - schema: T, - timeoutMs: number = 80000 - ): Promise> { - const url = new URL(`${this.nzbdavUrl}/api`); - Object.entries(params).forEach(([key, value]) => { - url.searchParams.append(key, value); - }); - - logger.debug(`Making Nzb DAV API request`, { - ...params, - apikey: maskSensitiveInfo(params.apikey), - fullUrl: maskSensitiveInfo(url.toString()), - }); - - try { - const response = await fetch(url.toString(), { - method: 'GET', - headers: { - 'x-api-key': this.apiKey, - }, - signal: AbortSignal.timeout(timeoutMs), - redirect: 'manual', - }); - - const data = await response.json(); - - try { - const parsed = schema.parse(data); - return parsed; - } catch (error) { - if (!response.ok) { - throw new DebridError(`NzbDAV API error: ${response.statusText}`, { - statusCode: response.status, - statusText: response.statusText, - code: 'UNKNOWN', - headers: Object.fromEntries(response.headers.entries()), - body: data, - type: 'api_error', - }); - } - - throw new DebridError(`Invalid NzbDAV API response`, { - statusCode: response.status, - statusText: response.statusText, - code: 'UNKNOWN', - headers: Object.fromEntries(response.headers.entries()), - body: data, - type: 'api_error', - }); - } - } catch (error) { - if (error instanceof DebridError) { - throw error; - } - - if ( - (error as Error).name === 'AbortError' || - (error as Error).name === 'TimeoutError' - ) { - throw new DebridError('Request timeout', { - statusCode: 504, - statusText: 'Gateway Timeout', - code: 'UNKNOWN', - headers: {}, - body: null, - type: 'api_error', - cause: error, - }); - } - - throw new DebridError(`Request failed: ${(error as Error).message}`, { - statusCode: 500, - statusText: 'Internal Server Error', - code: 'UNKNOWN', - headers: {}, - body: error, - type: 'api_error', - cause: error, - }); - } - } - - async addUrl( - nzbUrl: string, - category: string, - jobLabel: string - ): Promise<{ nzoId: string }> { - const params = { - mode: 'addurl', - apikey: this.apiKey, - name: nzbUrl, - cat: category, - nzbname: jobLabel, - output: 'json', - }; - - const parsed = await this.request(params, AddUrlResponseSchema, 80000); - const transformed = transformAddUrlResponse(parsed); - - if (!transformed.status) { - throw new DebridError( - `Failed to queue NZB: ${transformed.error || 'Unknown error'}`, - { - statusCode: 400, - statusText: 'Bad Request', - code: 'UNKNOWN', - headers: {}, - body: parsed, - type: 'api_error', - } - ); - } - - const nzoId = transformed.nzoIds?.[0]; - if (!nzoId) { - throw new DebridError('addurl succeeded but no nzo_id returned', { - statusCode: 400, - statusText: 'Bad Request', - code: 'UNKNOWN', - headers: {}, - body: parsed, - type: 'api_error', - }); - } - - logger.debug(`NZB job successfully added`, { - nzoId, - }); - return { nzoId }; - } - - async waitForHistorySlot( - nzoId: string, - category: string, - timeoutMs: number = 80000, - pollIntervalMs: number = 2000 - ): Promise> { - const deadline = Date.now() + timeoutMs; - - while (Date.now() < deadline) { - const params = { - mode: 'history', - apikey: this.apiKey, - start: '0', - limit: '50', - nzo_ids: nzoId, - category, - }; - - const parsed = await this.request(params, HistoryResponseSchema, 60000); - const transformed = transformHistoryResponse(parsed); - - if (!transformed.status) { - throw new DebridError( - `Failed to query history: ${transformed.error || 'Unknown error'}`, - { - statusCode: 400, - statusText: 'Bad Request', - code: 'UNKNOWN', - headers: {}, - body: parsed, - type: 'api_error', - } - ); - } - - const slot = transformed.history.slots.find( - (entry) => entry.nzoId === nzoId - ); - - if (slot) { - if (slot.status === 'completed') { - return slot; - } - if (slot.status === 'failed') { - const failMessage = slot.failMessage || 'Unknown NZBDav error'; - throw new DebridError(`NZB failed: ${failMessage}`, { - statusCode: 400, - statusText: 'Bad Request', - code: 'UNKNOWN', - headers: {}, - body: { nzoId, category, failMessage }, - type: 'api_error', - }); - } - } - - await new Promise((resolve) => setTimeout(resolve, pollIntervalMs)); - } - - throw new DebridError( - 'Timeout while waiting for NZB to become streamable', - { - statusCode: 504, - statusText: 'Gateway Timeout', - code: 'UNKNOWN', - headers: {}, - body: { nzoId, category }, - type: 'api_error', - } - ); - } -} - export const NzbDavConfig = z.object({ nzbdavUrl: z .string() @@ -303,338 +21,40 @@ export const NzbDavConfig = z.object({ aiostreamsAuth: z.string(), }); -export class NzbDAVService implements DebridService { - private readonly webdavClient: WebDAVClient; - private readonly nzbdavApi: NzbDAVApi; - private static playbackLinkCache = Cache.getInstance( - 'nzbdav:link' - ); - readonly supportsUsenet = true; +export class NzbDAVService extends UsenetStreamService { readonly serviceName: ServiceId = 'nzbdav'; + readonly serviceLogger = logger; - private readonly auth: z.infer; - - private static readonly MIN_FILE_SIZE = 500 * 1024 * 1024; // 500 MB - private static readonly MAX_DEPTH = 6; - - constructor(private readonly config: DebridServiceConfig) { - this.auth = NzbDavConfig.parse(JSON.parse(fromUrlSafeBase64(config.token))); - this.webdavClient = createClient(`${this.auth.nzbdavUrl}/`, { - username: this.auth.webdavUser, - password: this.auth.webdavPassword, - }); - this.nzbdavApi = new NzbDAVApi(this.auth.nzbdavUrl, this.auth.nzbdavApiKey); - } - - 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 }> { - if (currentDepth >= NzbDAVService.MAX_DEPTH) { - logger.warn(`Max depth reached at ${path}`); - return { files: [], depth: currentDepth }; - } - - let contents: FileStat[]; - try { - contents = (await this.webdavClient.getDirectoryContents( - path - )) as FileStat[]; - } catch (error) { - logger.error(`Failed to list directory ${path}`, { error }); - throw new DebridError( - `Failed to list WebDAV directory: ${(error as Error).message}`, - { - statusCode: 500, - statusText: 'Internal Server Error', - code: 'UNKNOWN', - headers: {}, - body: { path, error }, - type: 'api_error', - cause: error, - } - ); - } - - const files = contents.filter((item) => item.type === 'file'); - const directories = contents.filter((item) => item.type === 'directory'); - - // Check if we should stop traversing based on criteria - const hasVideoFile = files.some((file) => isVideoFile(file)); - const hasLargeFile = files.some( - (file) => file.size >= NzbDAVService.MIN_FILE_SIZE + constructor(config: DebridServiceConfig) { + const parsedConfig = NzbDavConfig.parse( + JSON.parse(fromUrlSafeBase64(config.token)) ); - // If we found video files or large files, we're in the right place - if (hasVideoFile || hasLargeFile) { - return { files, depth: currentDepth }; - } - - // If no directories exist, return the files we have - if (directories.length === 0) { - return { files, depth: currentDepth }; - } - - // Otherwise, recurse into subdirectories - const allFiles: FileStat[] = [...files]; - - for (const dir of directories) { - const { files: subFiles } = await this.collectFilesManually( - dir.filename, - currentDepth + 1 - ); - currentDepth = currentDepth + 1; - allFiles.push(...subFiles); - - // If we found video files or large files in a subdirectory, stop searching other directories - const hasVideoInSub = subFiles.some((file) => isVideoFile(file)); - const hasLargeInSub = subFiles.some( - (file) => file.size >= NzbDAVService.MIN_FILE_SIZE - ); - - if (hasVideoInSub || hasLargeInSub) { - break; - } - } - - return { files: allFiles, depth: currentDepth }; - } - - public async listMagnets(): Promise { - throw new Error('Unsupported operation'); - } - - public async checkMagnets( - magnets: string[], - sid?: string - ): Promise { - throw new Error('Unsupported operation'); - } - - public async addMagnet(magnet: string): Promise { - throw new Error('Unsupported operation'); - } - - public async generateTorrentLink( - link: string, - clientIp?: string - ): Promise { - throw new Error('Unsupported operation'); - } - - public async checkNzbs(hashes: string[]): Promise { - // validate proxy auth - try { - BuiltinProxy.validateAuth(this.auth.aiostreamsAuth); - } catch (error) { - throw new DebridError(`Invalid AIOStreams proxy auth`, { - statusCode: 401, - statusText: 'Unauthorized', - code: 'UNAUTHORIZED', - headers: {}, - type: 'api_error', - }); - } - // All NZBs are "cached" with NzbDAV since it's streaming-based - return hashes.map((h, index) => ({ - id: index, - status: 'cached', - hash: h, - })); - } - - public async resolve( - playbackInfo: PlaybackInfo, - filename: string, - cacheAndPlay: boolean - ): Promise { - if (playbackInfo.type === 'torrent') { - throw new Error('Unsupported operation'); - } - const { result } = await DistributedLock.getInstance().withLock( - `nzbdav:resolve:${playbackInfo.hash}:${playbackInfo.metadata?.season}:${playbackInfo.metadata?.episode}:${playbackInfo.metadata?.absoluteEpisode}:${filename}:${this.config.clientIp}:${this.config.token}`, - () => this._resolve(playbackInfo, filename), - { - timeout: 120000, - ttl: 10000, - } - ); - return result; - } - - private async _resolve( - playbackInfo: PlaybackInfo & { type: 'usenet' }, - filename: string - ): Promise { - const { nzb, metadata, hash } = playbackInfo; - - const cacheKey = `${this.serviceName}:${this.config.token}:${this.config.clientIp}:${JSON.stringify(playbackInfo)}`; - - const cachedLink = await NzbDAVService.playbackLinkCache.get(cacheKey); - - if (cachedLink) { - logger.debug(`Using cached link for ${nzb}`); - return cachedLink; - } - - logger.debug(`Resolving NZB`, { - hash, - filename, - nzbUrl: maskSensitiveInfo(nzb), - }); - - const category = metadata?.season || metadata?.episode ? 'Tv' : 'Movies'; - - // Add NZB and get nzoId - const addResult = await this.nzbdavApi.addUrl(nzb, category, filename); - const nzoId = addResult.nzoId; - - // Poll history until download is complete - const pollStartTime = Date.now(); - const slot = await this.nzbdavApi.waitForHistorySlot(nzoId, category); - - const jobName = slot.name || filename; - const jobCategory = slot.category || category; - - logger.debug(`NZB download completed`, { - nzoId, - jobName, - jobCategory, - 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.collectFiles(contentPath); - - if (allFiles.length === 0) { - throw new DebridError('No files found in NZB download', { - statusCode: 400, - statusText: 'Bad Request', - code: 'NO_MATCHING_FILE', - headers: {}, - body: { contentPath }, - type: 'api_error', - }); - } - - const debridFiles: DebridFile[] = allFiles.map((file, index) => ({ - id: index, - name: file.basename, - size: file.size, - path: file.filename, - index, - })); - - logger.debug(`Collected files from path`, { - nzoId, - jobName, - contentPath, - depth, - time: getTimeTakenSincePoint(listStartTime), - count: debridFiles.length, - files: debridFiles.map((f) => f.name), - }); - - const debridDownload: DebridDownload = { - id: nzoId, - hash, - name: jobName, - status: 'downloaded' as const, - files: debridFiles, + const auth: UsenetStreamServiceConfig = { + webdavUrl: `${parsedConfig.nzbdavUrl}/`, + webdavUser: parsedConfig.webdavUser, + webdavPassword: parsedConfig.webdavPassword, + apiUrl: `${parsedConfig.nzbdavUrl}/api`, + apiKey: parsedConfig.nzbdavApiKey, + aiostreamsAuth: parsedConfig.aiostreamsAuth, }; - let selectedFile; + super(config, auth, 'nzbdav'); + } - 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); - } + protected getContentPathPrefix(): string { + return '/content'; + } - const nzbInfo = { - type: 'usenet' as const, - nzb, - hash, - title: jobName, - metadata, - size: debridFiles.reduce((sum, f) => sum + f.size, 0), - }; + protected getExpectedFolderName(nzbUrl: string, filename: string): string { + // NzbDAV uses the filename parameter + return filename; + } - // Select a file based on the available metadata and files - selectedFile = await selectFileInTorrentOrNZB( - nzbInfo, - debridDownload, - parsedFiles, - metadata - ); - } - - if (!selectedFile) { - throw new DebridError('No matching file found', { - statusCode: 400, - statusText: 'Bad Request', - code: 'NO_MATCHING_FILE', - headers: {}, - body: { availableFiles: debridFiles.map((f) => f.name) }, - type: 'api_error', - }); - } - - logger.debug(`Selected file for playback`, { - chosenFile: selectedFile.name, - chosenPath: selectedFile.path, - availableFiles: debridFiles.length, - }); - - const filePath = selectedFile.path || `${contentPath}/${selectedFile.name}`; - 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 }); - - // Cache the result - await NzbDAVService.playbackLinkCache.set( - cacheKey, - playbackLink, - Env.BUILTIN_DEBRID_PLAYBACK_LINK_CACHE_TTL, - true + protected async generatePlaybackLink(filePath: string): Promise { + const parsedConfig = NzbDavConfig.parse( + JSON.parse(fromUrlSafeBase64(this.config.token)) ); - - return playbackLink; + return `${parsedConfig.nzbdavUrl}${filePath}`; } } diff --git a/packages/core/src/debrid/usenet-stream-base.ts b/packages/core/src/debrid/usenet-stream-base.ts new file mode 100644 index 00000000..8dba678c --- /dev/null +++ b/packages/core/src/debrid/usenet-stream-base.ts @@ -0,0 +1,795 @@ +import { + Env, + ServiceId, + createLogger, + getTimeTakenSincePoint, + maskSensitiveInfo, + Cache, + DistributedLock, + fromUrlSafeBase64, +} from '../utils/index.js'; +import { isVideoFile, selectFileInTorrentOrNZB } from './utils.js'; +import { + DebridService, + DebridServiceConfig, + DebridDownload, + PlaybackInfo, + DebridError, + DebridFile, +} from './base.js'; +import { ParsedResult, parseTorrentTitle } from '@viren070/parse-torrent-title'; +import z, { ZodError } from 'zod'; +import { createClient, WebDAVClient, FileStat } from 'webdav'; +import { fetch } from 'undici'; +import { BuiltinProxy } from '../proxy/builtin.js'; +import { basename } from 'path'; + +const logger = createLogger('usenet-stream-base'); + +// Zod schemas for SABnzbd-compatible API responses (used by streaming usenet services) +const AddUrlResponseSchema = z.object({ + status: z.boolean(), + nzo_ids: z.array(z.string()).optional(), + error: z.string().nullable().optional(), +}); + +const HistorySlotSchema = z.object({ + nzo_id: z.string(), + status: z.string().optional(), + name: z.string().optional(), + category: z.string().optional(), + storage: z.string().optional(), + fail_message: z.string().optional(), +}); + +const HistoryResponseSchema = z.object({ + status: z.boolean().optional(), + history: z + .object({ + slots: z.array(HistorySlotSchema), + }) + .optional(), + error: z.string().nullable().optional(), +}); + +// Transform API responses to camelCase +const transformAddUrlResponse = ( + data: z.infer +) => ({ + status: data.status, + nzoIds: data.nzo_ids, + error: data.error, +}); + +const transformHistorySlot = (slot: z.infer) => ({ + nzoId: slot.nzo_id, + status: slot.status?.toLowerCase(), + name: slot.name, + category: slot.category, + storage: slot.storage, + failMessage: slot.fail_message, +}); + +const transformHistoryResponse = ( + data: z.infer +) => ({ + status: data.status, + history: { + slots: data.history?.slots.map(transformHistorySlot) ?? [], + }, + error: data.error, +}); + +const convertStatusCodeToError = (code: number): DebridError['code'] => { + switch (code) { + case 400: + return 'BAD_REQUEST'; + case 401: + return 'UNAUTHORIZED'; + case 403: + return 'FORBIDDEN'; + case 404: + return 'NOT_FOUND'; + case 429: + return 'TOO_MANY_REQUESTS'; + case 500: + return 'INTERNAL_SERVER_ERROR'; + case 501: + return 'NOT_IMPLEMENTED'; + case 503: + return 'SERVICE_UNAVAILABLE'; + default: + return 'UNKNOWN'; + } +}; + +/** + * API client for SABnzbd APIs + */ +export class SABnzbdApi { + constructor( + protected readonly apiUrl: string, + protected readonly apiKey: string, + protected readonly serviceName: string + ) {} + + protected async request( + params: Record, + schema: T, + timeoutMs: number = 80000 + ): Promise<{ + data: z.infer; + statusCode: number; + statusText: string; + headers: Record; + }> { + const url = new URL(this.apiUrl); + Object.entries(params).forEach(([key, value]) => { + url.searchParams.append(key, value); + }); + + logger.debug(`Making ${this.serviceName} API request`, { + ...params, + apikey: maskSensitiveInfo(params.apikey), + fullUrl: maskSensitiveInfo(url.toString()), + }); + + try { + const response = await fetch(url.toString(), { + method: 'GET', + headers: { + 'x-api-key': this.apiKey, + }, + signal: AbortSignal.timeout(timeoutMs), + redirect: 'manual', + }); + let data; + + try { + data = await response.json(); + } catch (error) { + if (!response.ok) { + throw new DebridError( + `${this.serviceName} API error: ${response.statusText}`, + { + statusCode: response.status, + statusText: response.statusText, + code: convertStatusCodeToError(response.status), + headers: Object.fromEntries(response.headers.entries()), + body: null, + type: 'api_error', + } + ); + } + } + + try { + const parsed = schema.parse(data); + return { + data: parsed, + statusCode: response.status, + statusText: response.statusText, + headers: Object.fromEntries(response.headers.entries()), + }; + } catch (error) { + if (!response.ok) { + throw new DebridError( + `${this.serviceName} API error: ${response.statusText}`, + { + statusCode: response.status, + statusText: response.statusText, + code: convertStatusCodeToError(response.status), + headers: Object.fromEntries(response.headers.entries()), + body: data, + type: 'api_error', + } + ); + } + + if (error instanceof ZodError) { + throw new DebridError(`Invalid ${this.serviceName} API response`, { + statusCode: response.status, + statusText: response.statusText, + code: 'UNKNOWN', + headers: Object.fromEntries(response.headers.entries()), + body: data, + type: 'api_error', + }); + } + + throw new DebridError(`Invalid ${this.serviceName} API response`, { + statusCode: response.status, + statusText: response.statusText, + code: 'UNKNOWN', + headers: Object.fromEntries(response.headers.entries()), + body: data, + type: 'api_error', + }); + } + } catch (error) { + if (error instanceof DebridError) { + throw error; + } + + if ( + (error as Error).name === 'AbortError' || + (error as Error).name === 'TimeoutError' + ) { + throw new DebridError('Request timeout', { + statusCode: 504, + statusText: 'Gateway Timeout', + code: 'UNKNOWN', + headers: {}, + body: null, + type: 'api_error', + cause: error, + }); + } + + throw new DebridError(`Request failed: ${(error as Error).message}`, { + statusCode: 500, + statusText: 'Internal Server Error', + code: 'UNKNOWN', + headers: {}, + body: error, + type: 'api_error', + cause: error, + }); + } + } + + async addUrl( + nzbUrl: string, + category: string, + jobLabel: string + ): Promise<{ nzoId: string }> { + const params = { + mode: 'addurl', + apikey: this.apiKey, + name: nzbUrl, + cat: category, + nzbname: jobLabel, + output: 'json', + }; + + const { + data: parsed, + statusCode, + statusText, + headers, + } = await this.request(params, AddUrlResponseSchema, 80000); + const transformed = transformAddUrlResponse(parsed); + + if (!transformed.status) { + throw new DebridError( + `Failed to queue NZB: ${transformed.error || 'Unknown error'}`, + { + statusCode, + statusText, + code: convertStatusCodeToError(statusCode), + headers, + body: parsed, + type: 'api_error', + } + ); + } + + const nzoId = transformed.nzoIds?.[0]; + if (!nzoId) { + throw new DebridError('addurl succeeded but no nzo_id returned', { + statusCode: 400, + statusText: 'Bad Request', + code: 'UNKNOWN', + headers: {}, + body: parsed, + type: 'api_error', + }); + } + + logger.debug(`NZB job successfully added`, { + nzoId, + }); + return { nzoId }; + } + + async waitForHistorySlot( + nzoId: string, + category: string, + timeoutMs: number = 80000, + pollIntervalMs: number = 2000 + ): Promise> { + const deadline = Date.now() + timeoutMs; + + while (Date.now() < deadline) { + const params = { + mode: 'history', + apikey: this.apiKey, + start: '0', + limit: '50', + nzo_ids: 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 + ); + + if (slot) { + if (slot.status === 'completed') { + return slot; + } + if (slot.status === 'failed') { + const failMessage = + slot.failMessage || `Unknown ${this.serviceName} error`; + throw new DebridError(`NZB failed: ${failMessage}`, { + statusCode: 400, + statusText: 'Bad Request', + code: 'UNKNOWN', + headers: {}, + type: 'api_error', + }); + } + } + + await new Promise((resolve) => setTimeout(resolve, pollIntervalMs)); + } + + throw new DebridError( + 'Timeout while waiting for NZB to become streamable', + { + statusCode: 504, + statusText: 'Gateway Timeout', + code: 'UNKNOWN', + headers: {}, + body: { nzoId, category }, + type: 'api_error', + } + ); + } +} + +/** + * Configuration for streaming usenet services that use SABnzbd-compatible APIs + */ +export interface UsenetStreamServiceConfig { + webdavUrl: string; + webdavUser: string; + webdavPassword: string; + apiUrl: string; + apiKey: string; + aiostreamsAuth: string; +} + +/** + * Base class for streaming usenet services (NzbDAV, Altmount). + * These services accept NZBs via a SABnzbd-compatible API and stream content + * directly from usenet providers via WebDAV, rather than downloading to disk. + */ +export abstract class UsenetStreamService implements DebridService { + protected readonly webdavClient: WebDAVClient; + protected readonly api: SABnzbdApi; + protected static playbackLinkCache = Cache.getInstance( + 'usenet-stream:link' + ); + + readonly supportsUsenet = true; + abstract readonly serviceName: ServiceId; + abstract readonly serviceLogger: ReturnType; + + protected readonly auth: UsenetStreamServiceConfig; + protected static readonly MIN_FILE_SIZE = 500 * 1024 * 1024; // 500 MB + protected static readonly MAX_DEPTH = 6; + + /** + * Get the content path prefix for this service + * NzbDAV uses '/content', Altmount uses '/complete' + */ + protected abstract getContentPathPrefix(): string; + + /** + * Get the expected folder name for a given NZB URL + * NzbDAV uses the filename parameter, Altmount uses basename of URL + */ + protected abstract getExpectedFolderName( + nzbUrl: string, + filename: string + ): string; + + constructor( + protected readonly config: DebridServiceConfig, + auth: UsenetStreamServiceConfig, + serviceName: ServiceId + ) { + this.auth = auth; + this.webdavClient = createClient(auth.webdavUrl, { + username: auth.webdavUser, + password: auth.webdavPassword, + }); + this.api = new SABnzbdApi(auth.apiUrl, auth.apiKey, serviceName); + } + + protected 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) { + this.serviceLogger.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); + } + } + + protected async collectFilesManually( + path: string, + currentDepth: number = 0 + ): Promise<{ files: FileStat[]; depth: number }> { + if (currentDepth >= UsenetStreamService.MAX_DEPTH) { + this.serviceLogger.warn(`Max depth reached at ${path}`); + return { files: [], depth: currentDepth }; + } + + let contents: FileStat[]; + try { + contents = (await this.webdavClient.getDirectoryContents( + path + )) as FileStat[]; + } catch (error: any) { + const status = typeof error.status === 'number' ? error.status : 500; + throw new DebridError( + `Failed to list WebDAV directory: ${(error as Error).message}`, + { + statusCode: status, + statusText: status + ? error.message.match(/response: \d+ (.*)/)?.[1] || + 'Internal Server Error' + : 'Internal Server Error', + code: convertStatusCodeToError(status), + headers: {}, + type: 'api_error', + } + ); + } + + const files = contents.filter((item) => item.type === 'file'); + const directories = contents.filter((item) => item.type === 'directory'); + + // Check if we should stop traversing based on criteria + const hasVideoFile = files.some((file) => isVideoFile(file)); + const hasLargeFile = files.some( + (file) => file.size >= UsenetStreamService.MIN_FILE_SIZE + ); + + // If we found video files or large files, we're in the right place + if (hasVideoFile || hasLargeFile) { + return { files, depth: currentDepth }; + } + + // If no directories exist, return the files we have + if (directories.length === 0) { + return { files, depth: currentDepth }; + } + + // Otherwise, recurse into subdirectories + const allFiles: FileStat[] = [...files]; + + for (const dir of directories) { + const { files: subFiles } = await this.collectFilesManually( + dir.filename, + currentDepth + 1 + ); + currentDepth = currentDepth + 1; + allFiles.push(...subFiles); + + // If we found video files or large files in a subdirectory, stop searching other directories + const hasVideoInSub = subFiles.some((file) => isVideoFile(file)); + const hasLargeInSub = subFiles.some( + (file) => file.size >= UsenetStreamService.MIN_FILE_SIZE + ); + + if (hasVideoInSub || hasLargeInSub) { + break; + } + } + + return { files: allFiles, depth: currentDepth }; + } + + public async listMagnets(): Promise { + throw new Error('Unsupported operation'); + } + + public async checkMagnets( + magnets: string[], + sid?: string + ): Promise { + throw new Error('Unsupported operation'); + } + + public async addMagnet(magnet: string): Promise { + throw new Error('Unsupported operation'); + } + + public async generateTorrentLink( + link: string, + clientIp?: string + ): Promise { + throw new Error('Unsupported operation'); + } + + public async checkNzbs(hashes: string[]): Promise { + // validate proxy auth + try { + BuiltinProxy.validateAuth(this.auth.aiostreamsAuth); + } catch (error) { + throw new DebridError(`Invalid AIOStreams proxy auth`, { + statusCode: 401, + statusText: 'Unauthorized', + code: 'UNAUTHORIZED', + headers: {}, + type: 'api_error', + }); + } + // All NZBs are "cached" since it's streaming-based + return hashes.map((h, index) => ({ + id: index, + status: 'cached', + hash: h, + })); + } + + public async resolve( + playbackInfo: PlaybackInfo, + filename: string, + cacheAndPlay: boolean + ): Promise { + if (playbackInfo.type === 'torrent') { + throw new Error('Unsupported operation'); + } + const { result } = await DistributedLock.getInstance().withLock( + `${this.serviceName}:resolve:${playbackInfo.hash}:${playbackInfo.metadata?.season}:${playbackInfo.metadata?.episode}:${playbackInfo.metadata?.absoluteEpisode}:${filename}:${this.config.clientIp}:${this.config.token}`, + () => this._resolve(playbackInfo, filename), + { + timeout: 120000, + ttl: 10000, + } + ); + return result; + } + + protected async _resolve( + playbackInfo: PlaybackInfo & { type: 'usenet' }, + filename: string + ): Promise { + const { nzb, metadata, hash } = playbackInfo; + + const cacheKey = `${this.serviceName}:${this.config.token}:${this.config.clientIp}:${JSON.stringify(playbackInfo)}`; + + const cachedLink = + await UsenetStreamService.playbackLinkCache.get(cacheKey); + + if (cachedLink) { + this.serviceLogger.debug(`Using cached link for ${nzb}`); + return cachedLink; + } + + this.serviceLogger.debug(`Resolving NZB`, { + hash, + filename, + nzbUrl: maskSensitiveInfo(nzb), + }); + + const category = metadata?.season || metadata?.episode ? 'Tv' : 'Movies'; + const expectedFolderName = this.getExpectedFolderName(nzb, filename); + + // Check if content already exists at the expected path + const expectedContentPath = `${this.getContentPathPrefix()}/${category}/${expectedFolderName}`; + let contentPath: string | undefined; + let jobName: string | undefined; + let jobCategory: string | undefined; + let nzoId: string | undefined; + let alreadyExists = false; + + try { + const stat = await this.webdavClient.stat(expectedContentPath); + const statData = 'data' in stat ? stat.data : stat; + if (statData.type === 'directory') { + alreadyExists = true; + contentPath = expectedContentPath; + jobName = expectedFolderName; + jobCategory = category; + this.serviceLogger.debug(`Content already exists`, { + path: expectedContentPath, + }); + } + } catch (error) { + this.serviceLogger.debug(`Content path does not exist, will add NZB`, { + path: expectedContentPath, + error: (error as Error).message, + }); + } + + // Only add NZB if content doesn't already exist + if (!alreadyExists) { + const addResult = await this.api.addUrl(nzb, category, filename); + nzoId = addResult.nzoId; + + // Poll history until download is complete + const pollStartTime = Date.now(); + const slot = await this.api.waitForHistorySlot(nzoId, category); + + // Use slot.storage as source of truth for the content path + jobName = slot.storage ? basename(slot.storage) : slot.name || filename; + jobCategory = slot.category || category; + contentPath = `${this.getContentPathPrefix()}/${jobCategory}/${jobName}`; + + this.serviceLogger.debug(`NZB download completed`, { + nzoId, + jobName, + jobCategory, + contentPath, + time: getTimeTakenSincePoint(pollStartTime), + }); + } + + // Ensure we have a content path + if (!contentPath || !jobName || !jobCategory) { + throw new DebridError('Failed to determine content path', { + statusCode: 500, + statusText: 'Internal Server Error', + code: 'UNKNOWN', + headers: {}, + body: { expectedContentPath, alreadyExists }, + type: 'api_error', + }); + } + + // Get list of all files in the content folder recursively, stopping when we find video files + const listStartTime = Date.now(); + + const { files: allFiles, depth } = await this.collectFiles(contentPath); + + if (allFiles.length === 0) { + throw new DebridError('No files found in NZB download', { + statusCode: 400, + statusText: 'Bad Request', + code: 'NO_MATCHING_FILE', + headers: {}, + body: { contentPath }, + type: 'api_error', + }); + } + + const debridFiles: DebridFile[] = allFiles.map((file, index) => ({ + id: index, + name: file.basename, + size: file.size, + path: file.filename, + index, + })); + + this.serviceLogger.debug(`Collected files from path`, { + nzoId, + jobName, + contentPath, + depth, + time: getTimeTakenSincePoint(listStartTime), + count: debridFiles.length, + files: debridFiles.map((f) => f.name), + }); + + const debridDownload: DebridDownload = { + id: nzoId || `cached-${hash}`, + hash, + name: jobName, + status: 'downloaded' as const, + files: debridFiles, + }; + + 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 + ); + } + + if (!selectedFile) { + throw new DebridError('No matching file found', { + statusCode: 400, + statusText: 'Bad Request', + code: 'NO_MATCHING_FILE', + headers: {}, + body: { availableFiles: debridFiles.map((f) => f.name) }, + type: 'api_error', + }); + } + + this.serviceLogger.debug(`Selected file for playback`, { + chosenFile: selectedFile.name, + chosenPath: selectedFile.path, + availableFiles: debridFiles.length, + }); + + const filePath = selectedFile.path || `${contentPath}/${selectedFile.name}`; + const playbackLink = await this.generatePlaybackLink(filePath); + + this.serviceLogger.debug(`Generated playback link`, { playbackLink }); + + // Cache the result + await UsenetStreamService.playbackLinkCache.set( + cacheKey, + playbackLink, + Env.BUILTIN_DEBRID_PLAYBACK_LINK_CACHE_TTL, + true + ); + + return playbackLink; + } + + /** + * Generate the playback link for a given file path + * This method can be overridden by subclasses to customize the URL format + */ + protected abstract generatePlaybackLink(filePath: string): Promise; +} diff --git a/packages/core/src/debrid/utils.ts b/packages/core/src/debrid/utils.ts index fe0f209f..f1f8bf92 100644 --- a/packages/core/src/debrid/utils.ts +++ b/packages/core/src/debrid/utils.ts @@ -438,9 +438,11 @@ export function isNotVideoFile(file: DebridFile): boolean { '.dbf', '.bak', ]; + const patterns = [/\.7z\.\d+$/]; return ( (file.mimeType && !file.mimeType.includes('video')) || - nonVideoExtensions.some((ext) => file.name?.endsWith(ext) ?? false) + nonVideoExtensions.some((ext) => file.name?.endsWith(ext) ?? false) || + patterns.some((pattern) => pattern.test(file.name || '')) ); } diff --git a/packages/core/src/presets/builtin.ts b/packages/core/src/presets/builtin.ts index 82ebe8cd..3d899403 100644 --- a/packages/core/src/presets/builtin.ts +++ b/packages/core/src/presets/builtin.ts @@ -77,10 +77,25 @@ export class BuiltinAddonPreset extends Preset { }) ), }; + const altmountSpecialCase: Partial< + Record any> + > = { + [constants.ALTMOUNT_SERVICE]: (credentials: any) => + toUrlSafeBase64( + JSON.stringify({ + altmountUrl: credentials.url, + altmountApiKey: credentials.apiKey, + webdavUser: credentials.username, + webdavPassword: credentials.password, + aiostreamsAuth: credentials.aiostreamsAuth, + }) + ), + }; return super.getServiceCredential(serviceId, userData, { ...stremthruSpecialCases, ...specialCases, ...nzbDavSpecialCase, + ...altmountSpecialCase, }); } diff --git a/packages/core/src/presets/newznab.ts b/packages/core/src/presets/newznab.ts index 560bb694..4d9e357c 100644 --- a/packages/core/src/presets/newznab.ts +++ b/packages/core/src/presets/newznab.ts @@ -9,7 +9,8 @@ export class NewznabPreset extends BuiltinAddonPreset { const supportedServices = [ constants.TORBOX_SERVICE, constants.NZBDAV_SERVICE, - ] as const; + constants.ALTMOUNT_SERVICE, + ] as ServiceId[]; const options: Option[] = [ { id: 'name', @@ -127,7 +128,7 @@ export class NewznabPreset extends BuiltinAddonPreset { URL: `${Env.INTERNAL_URL}/builtins/newznab`, TIMEOUT: Env.DEFAULT_TIMEOUT, USER_AGENT: Env.DEFAULT_USER_AGENT, - SUPPORTED_SERVICES: [constants.TORBOX_SERVICE, constants.NZBDAV_SERVICE], + SUPPORTED_SERVICES: supportedServices, DESCRIPTION: 'An addon to get usenet results from a Newznab endpoint.', OPTIONS: options, SUPPORTED_STREAM_TYPES: [constants.USENET_STREAM_TYPE], diff --git a/packages/core/src/presets/nzbhydra.ts b/packages/core/src/presets/nzbhydra.ts index 265c368c..e297b144 100644 --- a/packages/core/src/presets/nzbhydra.ts +++ b/packages/core/src/presets/nzbhydra.ts @@ -9,7 +9,8 @@ export class NZBHydraPreset extends NewznabPreset { const supportedServices = [ constants.TORBOX_SERVICE, constants.NZBDAV_SERVICE, - ] as const; + constants.ALTMOUNT_SERVICE, + ] as ServiceId[]; const options: Option[] = [ { id: 'name', @@ -116,7 +117,7 @@ export class NZBHydraPreset extends NewznabPreset { URL: `${Env.INTERNAL_URL}/builtins/newznab`, TIMEOUT: Env.BUILTIN_DEFAULT_NZBHYDRA_TIMEOUT || Env.DEFAULT_TIMEOUT, USER_AGENT: Env.DEFAULT_USER_AGENT, - SUPPORTED_SERVICES: [constants.TORBOX_SERVICE, constants.NZBDAV_SERVICE], + SUPPORTED_SERVICES: supportedServices, DESCRIPTION: 'An addon to get usenet results from a NZBHydra instance.', OPTIONS: options, SUPPORTED_STREAM_TYPES: [constants.USENET_STREAM_TYPE], diff --git a/packages/core/src/utils/constants.ts b/packages/core/src/utils/constants.ts index 94e343d8..23ab6c79 100644 --- a/packages/core/src/utils/constants.ts +++ b/packages/core/src/utils/constants.ts @@ -204,6 +204,7 @@ const OFFCLOUD_SERVICE = 'offcloud'; const SEEDR_SERVICE = 'seedr'; const EASYNEWS_SERVICE = 'easynews'; const NZBDAV_SERVICE = 'nzbdav'; +const ALTMOUNT_SERVICE = 'altmount'; const SERVICES = [ REALDEBRID_SERVICE, @@ -219,6 +220,7 @@ const SERVICES = [ SEEDR_SERVICE, EASYNEWS_SERVICE, NZBDAV_SERVICE, + ALTMOUNT_SERVICE, ] as const; export const BUILTIN_SUPPORTED_SERVICES = [ @@ -232,6 +234,7 @@ export const BUILTIN_SUPPORTED_SERVICES = [ PIKPAK_SERVICE, OFFCLOUD_SERVICE, NZBDAV_SERVICE, + ALTMOUNT_SERVICE, ] as const; export type ServiceId = (typeof SERVICES)[number]; @@ -385,14 +388,14 @@ const SERVICE_DETAILS: Record< }, [NZBDAV_SERVICE]: { id: NZBDAV_SERVICE, - name: 'Nzb DAV', + name: 'NzbDAV', shortName: 'ND', knownNames: ['ND'], signUpText: 'Stream usenet directly from your provider via Nzb DAV.', credentials: [ { id: 'url', - name: 'Nzb DAV URL', + name: 'NzbDAV URL', description: 'The base URL of your NZB DAV instance. E.g., http://nzbdav:3000 or https://nzbdav.example.com', type: 'string', @@ -400,7 +403,7 @@ const SERVICE_DETAILS: Record< }, { id: 'apiKey', - name: 'Nzb DAV API Key', + name: 'NzbDAV API Key', description: 'Your Nzb DAV API Key, found in the SABnzbd section in settings.', type: 'password', @@ -408,7 +411,7 @@ const SERVICE_DETAILS: Record< }, { id: 'username', - name: 'NZB DAV Username', + name: 'NzbDAV WebDAV Username', description: 'Your Nzb DAV WebDAV Username. Found in the WebDAV section in settings.', type: 'string', @@ -416,9 +419,9 @@ const SERVICE_DETAILS: Record< }, { id: 'password', - name: 'NZB DAV Password', + name: 'NzbDAV WebDAV Password', description: - 'Your NZB DAV WebDAV Password. Found in the WebDAV section in settings.', + 'Your NzbDAV WebDAV Password. Found in the WebDAV section in settings.', type: 'password', required: true, }, @@ -432,6 +435,56 @@ const SERVICE_DETAILS: Record< }, ], }, + [ALTMOUNT_SERVICE]: { + id: ALTMOUNT_SERVICE, + name: 'AltMount', + shortName: 'AM', + knownNames: ['AM'], + signUpText: 'Stream usenet directly from your provider via AltMount.', + credentials: [ + { + id: 'url', + name: 'Altmount URL', + description: + 'The base URL of your AltMount instance. E.g., http://altmount:8080 or https://altmount.example.com', + type: 'string', + required: true, + }, + { + id: 'apiKey', + name: 'AltMount API Key', + description: + 'Your AltMount API Key, found at `Configuration -> System` in the AltMount Web UI.', + type: 'password', + required: true, + }, + { + id: 'username', + name: 'AltMount WebDAV Username', + description: + 'Your AltMount WebDAV Username, found at `Configuration -> WebDAV Server` in the AltMount Web UI.', + type: 'string', + required: true, + }, + { + id: 'password', + name: 'AltMount WebDAV Password', + description: + 'Your AltMount WebDAV Password, found at `Configuration -> WebDAV Server` in the AltMount Web UI.', + type: 'password', + required: true, + }, + { + id: 'aiostreamsAuth', + name: 'AIOStreams Proxy Auth', + description: + 'It is required to proxy the AltMount streams through AIOStreams. Provide a username:password pair from the `AIOSTREAMS_AUTH` environment variable.', + type: 'password', + required: true, + }, + ], + }, + [OFFCLOUD_SERVICE]: { id: OFFCLOUD_SERVICE, name: 'Offcloud', @@ -1142,6 +1195,7 @@ export { OFFCLOUD_SERVICE, SEEDR_SERVICE, NZBDAV_SERVICE, + ALTMOUNT_SERVICE, EASYNEWS_SERVICE, SERVICE_DETAILS, TOP_LEVEL_OPTION_DETAILS, diff --git a/packages/server/src/routes/api/debrid.ts b/packages/server/src/routes/api/debrid.ts index b87f8658..02386090 100644 --- a/packages/server/src/routes/api/debrid.ts +++ b/packages/server/src/routes/api/debrid.ts @@ -114,7 +114,8 @@ router.get( let staticFile: string = StaticFiles.INTERNAL_SERVER_ERROR; if (error instanceof DebridError) { logger.error( - `[${storeAuth.id}] Got Debrid error during debrid resolve: ${error.code}: ${error.message}` + `[${storeAuth.id}] Got Debrid error during debrid resolve: ${error.code}: ${error.message}`, + { ...error, stack: undefined } ); switch (error.code) { case 'UNAVAILABLE_FOR_LEGAL_REASONS':