mirror of
https://github.com/Viren070/AIOStreams.git
synced 2025-12-01 23:14:04 +01:00
feat(altmount): add integration
This commit is contained in:
@@ -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)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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 || ''))
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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':
|
||||
|
||||
Reference in New Issue
Block a user