refactor(nzbdav): adjustments

This commit is contained in:
Viren070
2025-11-06 14:45:01 +00:00
parent 1afc44a752
commit 67c3a35a39
6 changed files with 193 additions and 73 deletions
+60 -1
View File
@@ -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<T extends BaseDebridConfig> {
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,
+70 -66
View File
@@ -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<string, ParsedResult>();
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<string, ParsedResult>();
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 });
+33
View File
@@ -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,
+29
View File
@@ -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 {
-5
View File
@@ -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;
}
+1 -1
View File
@@ -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,
},