feat: add check owned option

This commit is contained in:
Viren070
2025-11-26 02:16:28 +00:00
parent fd7c6d1b67
commit 5434da5777
13 changed files with 239 additions and 71 deletions
+3
View File
@@ -93,6 +93,9 @@ BUILTIN_DEBRID_INSTANT_AVAILABILITY_CACHE_TTL=1800
# How long download links for an item remain cached for.
# Default: 1 hour
BUILTIN_DEBRID_PLAYBACK_LINK_CACHE_TTL=3600
# How long the list of downloads in the user's library is cached for.
# Default: 5 minutes
BUILTIN_DEBRID_LIBRARY_CACHE_TTL=300
# The timeout for getting the magnet from a torrent link. Happens in bulk during searches when only download URL is present.
BUILTIN_GET_TORRENT_TIMEOUT=5000
+10 -2
View File
@@ -61,6 +61,7 @@ export const BaseDebridConfigSchema = z.object({
tmdbReadAccessToken: z.string().optional(),
tvdbApiKey: z.string().optional(),
cacheAndPlay: CacheAndPlaySchema.optional(),
checkOwned: z.boolean().optional().default(true),
});
export type BaseDebridConfig = z.infer<typeof BaseDebridConfigSchema>;
@@ -244,7 +245,14 @@ export abstract class BaseDebridAddon<T extends BaseDebridConfig> {
searchMetadata,
this.clientIp
),
processNZBs(nzbResults, nzbServices, id, searchMetadata, this.clientIp),
processNZBs(
nzbResults,
nzbServices,
id,
searchMetadata,
this.clientIp,
this.userData.checkOwned
),
]);
const encryptedStoreAuths = this.userData.services.reduce(
@@ -706,7 +714,7 @@ export abstract class BaseDebridAddon<T extends BaseDebridConfig> {
: '⏳'
: '';
const name = `[${shortCode} ${cacheIndicator}${torrentOrNzb.service?.owned ? ' ☁️' : ''}] ${this.name}`;
const name = `[${shortCode} ${cacheIndicator}${torrentOrNzb.service?.library ? ' ☁️' : ''}] ${this.name}`;
const description = `${torrentOrNzb.title}\n${torrentOrNzb.file.name}\n${
torrentOrNzb.indexer ? `🔍 ${torrentOrNzb.indexer}` : ''
} ${'seeders' in torrentOrNzb && torrentOrNzb.seeders ? `👤 ${torrentOrNzb.seeders}` : ''} ${
@@ -131,7 +131,7 @@ abstract class SourceHandler {
? parseAgeString(torrentOrNzb.age)
: undefined;
const name = `[${shortCode} ${cacheIndicator}${torrentOrNzb.service?.owned ? ' ☁️' : ''}] TorBox Search`;
const name = `[${shortCode} ${cacheIndicator}${torrentOrNzb.service?.library ? ' ☁️' : ''}] TorBox Search`;
const description = `${torrentOrNzb.title}\n${torrentOrNzb.file.name}\n${
torrentOrNzb.indexer ? `🔍 ${torrentOrNzb.indexer}` : ''
} ${'seeders' in torrentOrNzb && torrentOrNzb.seeders ? `👤 ${torrentOrNzb.seeders}` : ''} ${
@@ -347,7 +347,7 @@ export class TorrentSourceHandler extends SourceHandler {
);
results.forEach((result) => {
result.service!.owned =
result.service!.library =
fetchResult.torrents.find((torrent) => torrent.hash === result.hash)
?.owned ?? false;
});
@@ -586,7 +586,7 @@ export class UsenetSourceHandler extends SourceHandler {
);
results.forEach((result) => {
result.service!.owned =
result.service!.library =
nzbs.find((nzb) => nzb.hash === result.hash)?.owned ?? false;
});
+20 -10
View File
@@ -226,7 +226,7 @@ async function processTorrentsForDebridService(
service: {
id: service.id,
cached: magnetCheckResult?.status === 'cached',
owned: false,
library: magnetCheckResult?.library === true,
},
});
}
@@ -235,6 +235,8 @@ async function processTorrentsForDebridService(
logger.debug(`Finished processing of torrents`, {
service: service.id,
torrents: torrents.length,
validTorrents: validTorrents.length,
finalTorrents: results.length,
totalTime: getTimeTakenSincePoint(processingStart),
parseTime,
});
@@ -329,7 +331,8 @@ export async function processNZBs(
debridServices: BuiltinDebridServices,
stremioId: string,
metadata?: Metadata,
clientIp?: string
clientIp?: string,
checkOwned: boolean = true
): Promise<{
results: NZBWithSelectedFile[];
errors: { serviceId: BuiltinServiceId; error: Error }[];
@@ -347,7 +350,8 @@ export async function processNZBs(
service,
stremioId,
metadata,
clientIp
clientIp,
checkOwned
);
return { serviceId: service.id, results: serviceResults, error: null };
} catch (error) {
@@ -375,7 +379,8 @@ async function processNZBsForDebridService(
service: BuiltinDebridServices[number],
stremioId: string,
metadata?: Metadata,
clientIp?: string
clientIp?: string,
checkOwned: boolean = true
): Promise<NZBWithSelectedFile[]> {
const startTime = Date.now();
const debridService = getDebridService(
@@ -391,7 +396,8 @@ async function processNZBsForDebridService(
const results: NZBWithSelectedFile[] = [];
const nzbCheckResults = await debridService.checkNzbs(
nzbs.map((nzb) => nzb.hash)
nzbs.map((nzb) => ({ name: nzb.title, hash: nzb.hash })),
checkOwned
);
logger.debug(`Retrieved NZB status from debrid`, {
@@ -413,7 +419,7 @@ async function processNZBsForDebridService(
// Filter NZBs that pass validation checks
const validNZBs: {
nzb: NZB;
nzbCheckResult: any;
nzbCheckResult: DebridDownload | undefined;
parsedTitle: ParsedResult;
}[] = [];
for (const nzb of nzbs) {
@@ -471,15 +477,19 @@ async function processNZBsForDebridService(
service: {
id: service.id,
cached: nzbCheckResult?.status === 'cached',
owned: false,
library: nzbCheckResult?.library === true,
},
});
}
}
logger.debug(
`Processed ${nzbs.length} NZBs for ${service.id} in ${getTimeTakenSincePoint(processingStart)}`
);
logger.debug(`Finished processing of NZBs`, {
service: service.id,
nzbs: nzbs.length,
validNzbs: validNZBs.length,
finalNzbs: results.length,
totalTime: getTimeTakenSincePoint(processingStart),
});
return results;
}
+5 -2
View File
@@ -3,7 +3,7 @@ import {
UsenetStreamService,
UsenetStreamServiceConfig,
} from './usenet-stream-base.js';
import { DebridServiceConfig } from './base.js';
import { DebridServiceConfig, PlaybackInfo } from './base.js';
import { ServiceId, createLogger, fromUrlSafeBase64 } from '../utils/index.js';
import { basename } from 'path';
@@ -49,7 +49,10 @@ export class AltmountService extends UsenetStreamService {
return '/complete';
}
protected getExpectedFolderName(nzbUrl: string, filename: string): string {
protected getExpectedFolderName(
nzb: PlaybackInfo & { type: 'usenet' }
): string {
const nzbUrl = nzb.nzb;
// Altmount uses basename of the NZB URL
return nzbUrl.endsWith('.nzb')
? basename(nzbUrl, '.nzb')
+5 -1
View File
@@ -86,6 +86,7 @@ export type DebridFile = z.infer<typeof DebridFileSchema>;
export interface DebridDownload {
id: string | number;
library?: boolean;
hash?: string;
name?: string;
size?: number;
@@ -174,7 +175,10 @@ export interface DebridService {
generateTorrentLink(link: string, clientIp?: string): Promise<string>;
// Usenet specific methods
checkNzbs?(nzbs: string[]): Promise<DebridDownload[]>;
checkNzbs?(
nzbs: { name?: string; hash?: string }[],
checkOwned?: boolean
): Promise<DebridDownload[]>;
listNzbs?(id?: string): Promise<DebridDownload[]>;
addNzb?(nzb: string, name: string): Promise<DebridDownload>;
generateUsenetLink?(
+5 -4
View File
@@ -6,7 +6,7 @@ import {
UsenetStreamService,
UsenetStreamServiceConfig,
} from './usenet-stream-base.js';
import { DebridServiceConfig } from './base.js';
import { DebridServiceConfig, PlaybackInfo } from './base.js';
import { ServiceId, createLogger, fromUrlSafeBase64 } from '../utils/index.js';
const logger = createLogger('nzbdav');
@@ -51,8 +51,9 @@ export class NzbDAVService extends UsenetStreamService {
return '/content';
}
protected getExpectedFolderName(nzbUrl: string, filename: string): string {
// NzbDAV uses the filename parameter
return filename;
protected getExpectedFolderName(
nzb: PlaybackInfo & { type: 'usenet' }
): string {
return nzb.filename ?? 'unknown_nzb';
}
}
+8 -2
View File
@@ -64,10 +64,16 @@ export class TorboxDebridService implements DebridService {
return this.stremthru.generateTorrentLink(link, clientIp);
}
public async checkNzbs(hashes: string[]): Promise<DebridDownload[]> {
public async checkNzbs(
nzbs: { name?: string; hash?: string }[]
): Promise<DebridDownload[]> {
nzbs = nzbs.filter((nzb) => nzb.hash);
if (nzbs.length === 0) {
return [];
}
const cachedResults: DebridDownload[] = [];
const hashesToCheck: string[] = [];
for (const hash of hashes) {
for (const { hash } of nzbs as { hash: string }[]) {
const cacheKey = getSimpleTextHash(hash);
const cached =
await TorboxDebridService.instantAvailabilityCache.get(cacheKey);
+146 -45
View File
@@ -118,7 +118,10 @@ export class SABnzbdApi {
}
protected async request<T extends z.ZodType>(
params: Record<string, string>,
params: Record<
string,
string | undefined | number | boolean | null | string[]
>,
schema: T,
timeoutMs: number = 80000
): Promise<{
@@ -129,12 +132,14 @@ export class SABnzbdApi {
}> {
const url = new URL(this.apiUrl);
Object.entries(params).forEach(([key, value]) => {
url.searchParams.append(key, value);
if (!value) return;
const val = Array.isArray(value) ? value.join(',') : String(value);
url.searchParams.append(key, val);
});
this.logger.debug(`Making ${this.serviceName} API request`, {
...params,
apikey: maskSensitiveInfo(params.apikey),
apikey: maskSensitiveInfo(this.apiKey),
fullUrl: maskSensitiveInfo(url.toString()),
});
@@ -299,6 +304,47 @@ export class SABnzbdApi {
return { nzoId };
}
async history(
params: {
start?: number;
limit?: number;
nzoIds?: string[];
category?: string;
} = {}
) {
const tParams = {
mode: 'history',
apikey: this.apiKey,
start: params.start ?? 0,
limit: params.limit ?? 50,
nzo_ids: params.nzoIds ? params.nzoIds.join(',') : undefined,
category: params.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',
}
);
}
return transformed.history;
}
async waitForHistorySlot(
nzoId: string,
category: string,
@@ -308,40 +354,12 @@ export class SABnzbdApi {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const params = {
mode: 'history',
apikey: this.apiKey,
start: '0',
limit: '50',
nzo_ids: nzoId,
const history = await this.history({
nzoIds: [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
);
const slot = history.slots.find((entry) => entry.nzoId === nzoId);
if (slot) {
if (slot.status === 'completed') {
@@ -401,6 +419,9 @@ export abstract class UsenetStreamService implements DebridService {
protected static playbackLinkCache = Cache.getInstance<string, string>(
'usenet-stream:link'
);
protected static libraryCache = Cache.getInstance<string, DebridDownload[]>(
'usenet-stream:library'
);
readonly supportsUsenet = true;
abstract readonly serviceName: ServiceId;
@@ -421,8 +442,7 @@ export abstract class UsenetStreamService implements DebridService {
* NzbDAV uses the filename parameter, Altmount uses basename of URL
*/
protected abstract getExpectedFolderName(
nzbUrl: string,
filename: string
nzb: PlaybackInfo & { type: 'usenet' }
): string;
constructor(
@@ -566,7 +586,67 @@ export abstract class UsenetStreamService implements DebridService {
throw new Error('Unsupported operation');
}
public async checkNzbs(hashes: string[]): Promise<DebridDownload[]> {
public async listNzbs(): Promise<DebridDownload[]> {
const cacheKey = `${this.serviceName}:${this.config.token}`;
const { result } = await DistributedLock.getInstance().withLock(
`uss:library:${cacheKey}`,
async () => {
const start = Date.now();
const cachedNzbs = await UsenetStreamService.libraryCache.get(cacheKey);
if (cachedNzbs) {
this.serviceLogger.debug(
`Using cached NZB list for ${this.serviceName}`
);
return cachedNzbs;
}
// const path = `${this.getContentPathPrefix()}/${UsenetStreamService.AIOSTREAMS_CATEGORY}`;
// const contents = (await this.webdavClient.getDirectoryContents(
// path
// )) as FileStat[];
// const nzbs = contents.map((item, index) => ({
// id: index,
// status: 'cached' as const,
// hash: item.basename,
// size: item.size,
// files: [],
// }));
// this.serviceLogger.debug(`Listed NZBs from WebDAV`, {
// count: nzbs.length,
// time: getTimeTakenSincePoint(start),
// });
const history = await this.api.history();
const nzbs: DebridDownload[] = history.slots.map((slot, index) => ({
id: index,
status: 'cached' as const,
name: slot.name,
}));
this.serviceLogger.debug(`Listed NZBs from history`, {
count: nzbs.length,
time: getTimeTakenSincePoint(start),
});
await UsenetStreamService.libraryCache.set(
cacheKey,
nzbs,
Env.BUILTIN_DEBRID_LIBRARY_CACHE_TTL,
true
);
return nzbs;
},
{
type: 'memory',
timeout: 5000,
}
);
return result;
}
public async checkNzbs(
nzbs: { name?: string; hash?: string }[],
checkOwned: boolean = true
): Promise<DebridDownload[]> {
// if aiostreamsAuth is present, validate it.
if (this.auth.aiostreamsAuth) {
try {
@@ -582,12 +662,29 @@ export abstract class UsenetStreamService implements DebridService {
});
}
}
let libraryNzbs: DebridDownload[] = [];
try {
libraryNzbs = checkOwned ? await this.listNzbs() : [];
} catch (error) {
this.serviceLogger.warn(`Failed to list library NZBs for checkNzbs`, {
error: (error as Error).message,
});
}
// All NZBs are "cached" since it's streaming-based
return hashes.map((h, index) => ({
id: index,
status: 'cached',
hash: h,
}));
return nzbs.map(({ hash: h, name: n }, index) => {
const libraryNzb = libraryNzbs.find(
(nzb) => nzb.name === n || nzb.name === h
);
return {
id: index,
status: 'cached',
library: !!libraryNzb,
hash: h,
name: n,
};
});
}
public async resolve(
@@ -632,7 +729,7 @@ export abstract class UsenetStreamService implements DebridService {
});
const category = metadata?.season || metadata?.episode ? 'Tv' : 'Movies';
const expectedFolderName = this.getExpectedFolderName(nzb, filename);
const expectedFolderName = this.getExpectedFolderName(playbackInfo);
// Check if content already exists at the expected path
const expectedContentPath = `${this.getContentPathPrefix()}/${category}/${expectedFolderName}`;
@@ -676,7 +773,11 @@ export abstract class UsenetStreamService implements DebridService {
// Only add NZB if content doesn't already exist
if (!alreadyExists) {
const addResult = await this.api.addUrl(nzb, category, filename);
const addResult = await this.api.addUrl(
nzb,
category,
expectedFolderName
);
nzoId = addResult.nzoId;
// Poll history until download is complete
+2 -2
View File
@@ -72,7 +72,7 @@ export interface TorrentWithSelectedFile extends Torrent {
service?: {
id: BuiltinServiceId;
cached: boolean;
owned: boolean;
library: boolean;
};
}
@@ -81,7 +81,7 @@ export interface NZBWithSelectedFile extends NZB {
service?: {
id: BuiltinServiceId;
cached: boolean;
owned: boolean;
library: boolean;
};
}
+10
View File
@@ -161,6 +161,15 @@ export class NewznabPreset extends BuiltinAddonPreset {
default: false,
showInSimpleMode: false,
},
{
id: 'checkOwned',
name: 'Check Owned NZBs',
description:
'When searching for NZBs, check if the NZB is already owned (in your library) and mark it as such if so. Note: only applies to nzbDAV/Altmount.',
type: 'boolean',
default: true,
showInSimpleMode: false,
},
{
id: 'useMultipleInstances',
name: 'Use Multiple Instances',
@@ -266,6 +275,7 @@ export class NewznabPreset extends BuiltinAddonPreset {
) {
const config = {
...this.getBaseConfig(userData, services),
checkOwned: options.checkOwned ?? true,
url: options.newznabUrl,
apiPath: options.apiPath,
apiKey: options.apiKey,
+18
View File
@@ -105,6 +105,24 @@ export class NZBHydraPreset extends NewznabPreset {
{ label: 'Both', value: 'both' },
],
},
{
id: 'paginate',
name: 'Paginate Results',
description:
'Enabling this option will make the addon paginate through all available results to provide a more comprehensive set of results. Enabling this can increase the time taken to return results, some endpoints may not support pagination, and this will also increase the number of requests.',
type: 'boolean',
default: false,
showInSimpleMode: false,
},
{
id: 'checkOwned',
name: 'Check Owned NZBs',
description:
'When searching for NZBs, check if the NZB is already owned (in your library) and mark it as such if so. Note: only applies to nzbDAV/Altmount.',
type: 'boolean',
default: true,
showInSimpleMode: false,
},
{
id: 'useMultipleInstances',
name: 'Use Multiple Instances',
+4
View File
@@ -1650,6 +1650,10 @@ export const Env = cleanEnv(process.env, {
default: 60 * 60, // 1 hour
desc: 'Builtin Debrid playback link cache TTL',
}),
BUILTIN_DEBRID_LIBRARY_CACHE_TTL: num({
default: 60 * 5, // 5 minutes
desc: 'Builtin Debrid NZB list cache TTL',
}),
BUILTIN_DEBRID_METADATA_STORE: str({
choices: ['redis', 'sql', 'memory'],
default: undefined,