feat(altmount): add integration

This commit is contained in:
Viren070
2025-11-07 17:14:07 +00:00
parent 115fb45dbb
commit 9ba07aef44
12 changed files with 1078 additions and 641 deletions
+94 -15
View File
@@ -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<T extends BaseDebridConfig> {
);
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<T extends BaseDebridConfig> {
);
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<T extends BaseDebridConfig> {
});
const proxiedStreams = await proxy.generateUrls(
proxyIndices
nzbdavProxyIndices
.map((i) => resultStreams[i])
.map((stream) => ({
url: stream.url!,
@@ -305,8 +338,8 @@ export abstract class BaseDebridAddon<T extends BaseDebridConfig> {
);
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<T extends BaseDebridConfig> {
);
// 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)
);
}
}
+5 -1
View File
@@ -38,7 +38,11 @@ export class NewznabAddon extends BaseNabAddon<NewznabAddonConfig, NewznabApi> {
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(
+60
View File
@@ -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<string> {
const parsedConfig = AltmountConfig.parse(
JSON.parse(fromUrlSafeBase64(this.config.token))
);
return `${parsedConfig.altmountUrl}/webdav${filePath}`;
}
}
+5
View File
@@ -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 });
+33 -613
View File
@@ -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<typeof AddUrlResponseSchema>
) => ({
status: data.status,
nzoIds: data.nzo_ids,
error: data.error,
});
const transformHistorySlot = (slot: z.infer<typeof HistorySlotSchema>) => ({
nzoId: slot.nzo_id,
status: slot.status.toLowerCase(),
name: slot.name,
category: slot.category,
failMessage: slot.fail_message,
});
const transformHistoryResponse = (
data: z.infer<typeof HistoryResponseSchema>
) => ({
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<T extends z.ZodType>(
params: Record<string, string>,
schema: T,
timeoutMs: number = 80000
): Promise<z.infer<T>> {
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<ReturnType<typeof transformHistorySlot>> {
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<string, string>(
'nzbdav:link'
);
readonly supportsUsenet = true;
export class NzbDAVService extends UsenetStreamService {
readonly serviceName: ServiceId = 'nzbdav';
readonly serviceLogger = logger;
private readonly auth: z.infer<typeof NzbDavConfig>;
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<DebridDownload[]> {
throw new Error('Unsupported operation');
}
public async checkMagnets(
magnets: string[],
sid?: string
): Promise<DebridDownload[]> {
throw new Error('Unsupported operation');
}
public async addMagnet(magnet: string): Promise<DebridDownload> {
throw new Error('Unsupported operation');
}
public async generateTorrentLink(
link: string,
clientIp?: string
): Promise<string> {
throw new Error('Unsupported operation');
}
public async checkNzbs(hashes: string[]): Promise<DebridDownload[]> {
// 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<string | undefined> {
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<string | undefined> {
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<string, ParsedResult>();
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<string> {
const parsedConfig = NzbDavConfig.parse(
JSON.parse(fromUrlSafeBase64(this.config.token))
);
return playbackLink;
return `${parsedConfig.nzbdavUrl}${filePath}`;
}
}
@@ -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<typeof AddUrlResponseSchema>
) => ({
status: data.status,
nzoIds: data.nzo_ids,
error: data.error,
});
const transformHistorySlot = (slot: z.infer<typeof HistorySlotSchema>) => ({
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<typeof HistoryResponseSchema>
) => ({
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<T extends z.ZodType>(
params: Record<string, string>,
schema: T,
timeoutMs: number = 80000
): Promise<{
data: z.infer<T>;
statusCode: number;
statusText: string;
headers: Record<string, string>;
}> {
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<ReturnType<typeof transformHistorySlot>> {
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<string, string>(
'usenet-stream:link'
);
readonly supportsUsenet = true;
abstract readonly serviceName: ServiceId;
abstract readonly serviceLogger: ReturnType<typeof createLogger>;
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<DebridDownload[]> {
throw new Error('Unsupported operation');
}
public async checkMagnets(
magnets: string[],
sid?: string
): Promise<DebridDownload[]> {
throw new Error('Unsupported operation');
}
public async addMagnet(magnet: string): Promise<DebridDownload> {
throw new Error('Unsupported operation');
}
public async generateTorrentLink(
link: string,
clientIp?: string
): Promise<string> {
throw new Error('Unsupported operation');
}
public async checkNzbs(hashes: string[]): Promise<DebridDownload[]> {
// 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<string | undefined> {
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<string | undefined> {
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<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
);
}
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<string>;
}
+3 -1
View File
@@ -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 || ''))
);
}
+15
View File
@@ -77,10 +77,25 @@ export class BuiltinAddonPreset extends Preset {
})
),
};
const altmountSpecialCase: Partial<
Record<ServiceId, (credentials: any) => 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,
});
}
+3 -2
View File
@@ -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],
+3 -2
View File
@@ -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],
+60 -6
View File
@@ -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,
+2 -1
View File
@@ -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':