feat: add knaben builtin addon

This commit is contained in:
Viren070
2025-09-20 20:10:38 +01:00
parent f494a1c4c9
commit 2790f45541
15 changed files with 451 additions and 3 deletions
@@ -35,6 +35,7 @@ export interface SearchMetadata extends TitleMetadata {
imdbId?: string | null;
tmdbId?: number | null;
tvdbId?: number | null;
isAnime?: boolean;
}
export const BaseDebridConfigSchema = z.object({
@@ -314,6 +315,7 @@ export abstract class BaseDebridAddon<T extends BaseDebridConfig> {
imdbId,
tmdbId: metadata.tmdbId ?? null,
tvdbId: metadata.tvdbId ?? null,
isAnime: animeEntry ? true : false,
};
this.logger.debug(
+1
View File
@@ -3,3 +3,4 @@ export * from './torbox-search/index.js';
export * from './torznab/index.js';
export * from './newznab/index.js';
export * from './prowlarr/index.js';
export * from './knaben/index.js';
+131
View File
@@ -0,0 +1,131 @@
import {
BaseDebridAddon,
BaseDebridConfigSchema,
SearchMetadata,
} from '../base/debrid.js';
import { z } from 'zod';
import {
createLogger,
getTimeTakenSincePoint,
ParsedId,
} from '../../utils/index.js';
import KnabenAPI, { KnabenCategory } from './api.js';
import { NZB, UnprocessedTorrent } from '../../debrid/utils.js';
import { extractTrackersFromMagnet } from '../utils/debrid.js';
const logger = createLogger('knaben');
export const KnabenAddonConfigSchema = BaseDebridConfigSchema;
export type KnabenAddonConfig = z.infer<typeof KnabenAddonConfigSchema>;
export class KnabenAddon extends BaseDebridAddon<KnabenAddonConfig> {
readonly id = 'knaben';
readonly name = 'Knaben';
readonly version = '1.0.0';
readonly logger = logger;
readonly api: KnabenAPI;
constructor(userData: KnabenAddonConfig, clientIp?: string) {
super(userData, KnabenAddonConfigSchema, clientIp);
this.api = new KnabenAPI();
}
protected async _searchNzbs(
parsedId: ParsedId,
metadata: SearchMetadata
): Promise<NZB[]> {
return [];
}
protected async _searchTorrents(
parsedId: ParsedId,
metadata: SearchMetadata
): Promise<UnprocessedTorrent[]> {
const queries = [];
let categories: number[] = [];
if (!metadata.primaryTitle) {
return [];
}
if (parsedId.mediaType === 'movie') {
categories.push(KnabenCategory.Movies);
queries.push(`${metadata.primaryTitle} ${metadata.year}`);
} else {
categories.push(KnabenCategory.TV);
if (metadata.isAnime) {
categories.push(KnabenCategory.Anime);
}
if (metadata.absoluteEpisode) {
queries.push(
`${metadata.primaryTitle} ${metadata.absoluteEpisode.toString().padStart(2, '0')}`
);
}
if (parsedId.season) {
queries.push(
`${metadata.primaryTitle} S${parsedId.season.toString().padStart(2, '0')}`
);
}
if (parsedId.season && parsedId.episode) {
queries.push(
`${metadata.primaryTitle} S${parsedId.season.toString().padStart(2, '0')}E${parsedId.episode.toString().padStart(2, '0')}`
);
}
}
if (queries.length === 0) {
return [];
}
logger.info(`Performing knaben search`, { queries, categories });
const searchPromises = queries.map(async (q) => {
const start = Date.now();
const { hits } = await this.api.search({
query: q,
categories,
size: 300,
});
logger.info(
`Knaben search for ${q} took ${getTimeTakenSincePoint(start)}`,
{
results: hits.length,
}
);
return hits;
});
const allResults = await Promise.all(searchPromises);
const hits = allResults.flat();
const seenTorrents = new Set<string>();
const torrents: UnprocessedTorrent[] = [];
for (const hit of hits) {
if (!hit.hash && !hit.link) {
logger.warn(
`Knaben search hit has no hash or download url: ${JSON.stringify(hit)}`
);
continue;
}
if (seenTorrents.has(hit.hash ?? hit.link ?? '')) {
continue;
}
let sources: string[] = [];
if (hit.magnetUrl) {
sources = extractTrackersFromMagnet(hit.magnetUrl);
}
torrents.push({
hash: hit.hash ?? undefined,
downloadUrl: hit.link ?? undefined,
sources,
indexer: hit.tracker,
seeders: hit.seeders,
title: hit.title,
size: hit.bytes,
type: 'torrent',
});
}
return torrents;
}
}
+180
View File
@@ -0,0 +1,180 @@
import { Cache } from '../../utils/cache.js';
import { Env } from '../../utils/env.js';
import { formatZodError, makeRequest } from '../../utils/index.js';
import { createLogger } from '../../utils/index.js';
import { z } from 'zod';
const logger = createLogger('knaben');
enum KnabenCategory {
TV = 2000000,
Movies = 3000000,
Anime = 6000000,
}
const KnabenSearchHitSchema = z.looseObject({
bytes: z.number(),
cachedOrigin: z.string(),
category: z.string(),
categoryId: z.array(z.number()),
date: z.iso.datetime({ offset: true }),
details: z.url(),
hash: z
.string()
.nullable()
.transform((val) => (val ? val.toLowerCase() : null)),
id: z.string(),
lastSeen: z.iso.datetime({ offset: true }),
magnetUrl: z.string().nullable(),
link: z.url().nullable(),
peers: z.number(),
seeders: z.number(),
score: z.number().nullable(),
title: z.string(),
tracker: z.string(),
trackerId: z.string(),
virusDetection: z.number().min(0).max(1),
});
type KnabenSearchHit = z.infer<typeof KnabenSearchHitSchema>;
const KnabenSearchResponse = z.object({
hits: z.array(KnabenSearchHitSchema),
max_score: z.number().nullable(),
total: z.object({
relation: z.enum(['eq', 'gte', 'lte']),
value: z.number(),
}),
});
type KnabenSearchResponse = z.infer<typeof KnabenSearchResponse>;
const KnabenSearchOptions = z.object({
searchType: z
.union([
z.literal('score'),
z.string().regex(/^(100|[1-9]?\d)%$/, {
message: 'Must be "score" or a percentage string like "80%"',
}),
])
.default('score')
.optional(),
searchField: z.keyof(KnabenSearchHitSchema).default('title').optional(),
query: z.string(),
orderBy: z.keyof(KnabenSearchHitSchema).optional(),
orderDirection: z.enum(['asc', 'desc']).default('desc').optional(),
categories: z.array(z.number()).optional(),
from: z.number().default(0).optional(),
size: z.number().min(1).max(300).default(150).optional(),
hideUnsafe: z.boolean().default(false).optional(),
hideXXX: z.boolean().default(true).optional(),
secondsSinceLastSeen: z.number().optional(),
});
type KnabenSearchOptions = z.infer<typeof KnabenSearchOptions>;
const KnabenSearchOptionsRequest = KnabenSearchOptions.transform((data) => ({
search_type: data['searchType'],
search_field: data['searchField'],
query: data['query'],
order_by: data['orderBy'],
order_direction: data['orderDirection'],
categories: data['categories'],
from: data['from'],
size: data['size'],
hide_unsafe: data['hideUnsafe'],
hide_xxx: data['hideXXX'],
seconds_since_last_seen: data['secondsSinceLastSeen'],
}));
const API_BASE_URL = 'https://api.knaben.org';
const API_VERSION = '1';
class KnabenAPI {
private headers: Record<string, string>;
private readonly searchCache = Cache.getInstance<string, any>(
'knaben:search'
);
constructor() {
this.headers = {
'Content-Type': 'application/json',
'User-Agent':
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
Accept: 'application/json',
};
}
async search(options: KnabenSearchOptions): Promise<KnabenSearchResponse> {
const body = KnabenSearchOptionsRequest.parse(options);
return this.searchCache.wrap(
() =>
this.request<KnabenSearchResponse>('', {
schema: KnabenSearchResponse,
method: 'POST',
timeout: Env.BUILTIN_KNABEN_SEARCH_TIMEOUT,
body,
}),
`knaben:search:${JSON.stringify(options)}`,
Env.BUILTIN_KNABEN_SEARCH_CACHE_TTL
);
}
private async request<T>(
endpoint: string,
options: {
schema: z.ZodSchema<T>;
body?: unknown;
method?: string;
timeout?: number;
}
): Promise<T> {
const { schema, body, method = 'GET' } = options;
let path = `/v${API_VERSION}`;
if (endpoint) {
path += `/${endpoint.startsWith('/') ? endpoint.slice(1) : endpoint}`;
}
const url = new URL(path, API_BASE_URL);
logger.debug(`Making ${method} request to ${path}`);
try {
const response = await makeRequest(url.toString(), {
method,
headers: this.headers,
body: body ? JSON.stringify(body) : undefined,
timeout: options.timeout ?? Env.MAX_TIMEOUT,
});
const data = (await response.json()) as unknown;
if (!response.ok) {
throw new Error(
`Knaben API error (${response.status}): ${response.statusText}`
);
}
try {
return schema.parse(data);
} catch (error) {
throw new Error(
`Failed to parse Knaben API response: ${formatZodError(error as z.ZodError)}`
);
}
} catch (error) {
logger.error(
`Request to ${path} failed: ${error instanceof Error ? error.message : 'Unknown error'}`
);
throw error instanceof Error
? error
: new Error('Unknown error occurred');
}
}
}
export { KnabenCategory };
export type { KnabenSearchOptions, KnabenSearchResponse, KnabenSearchHit };
export default KnabenAPI;
@@ -0,0 +1 @@
export * from './addon.js';
+1
View File
@@ -14,5 +14,6 @@ export {
TorznabAddon,
NewznabAddon,
ProwlarrAddon,
KnabenAddon,
} from './builtins/index.js';
export { PresetManager } from './presets/index.js';
+68
View File
@@ -0,0 +1,68 @@
import { Option, UserData } from '../db/index.js';
import { Env, constants } from '../utils/index.js';
import { StremThruPreset } from './stremthru.js';
import { TorznabPreset } from './torznab.js';
export class KnabenPreset extends TorznabPreset {
static override get METADATA() {
const supportedResources = [constants.STREAM_RESOURCE];
const options: Option[] = [
{
id: 'name',
name: 'Name',
description: 'What to call this addon',
type: 'string',
required: true,
default: 'Knaben',
},
{
id: 'timeout',
name: 'Timeout',
description: 'The timeout for this addon',
type: 'number',
required: true,
default: Env.BUILTIN_KNABEN_TIMEOUT || Env.DEFAULT_TIMEOUT,
},
{
id: 'services',
name: 'Services',
description:
'Optionally override the services that are used. If not specified, then the services that are enabled and supported will be used.',
type: 'multi-select',
required: false,
showInNoobMode: false,
options: StremThruPreset.supportedServices.map((service) => ({
value: service,
label: constants.SERVICE_DETAILS[service].name,
})),
default: undefined,
emptyIsUndefined: true,
},
];
return {
ID: 'knaben',
NAME: 'Knaben',
LOGO: '/assets/knaben_logo.png',
URL: `${Env.INTERNAL_URL}/builtins/knaben`,
TIMEOUT: Env.BUILTIN_KNABEN_TIMEOUT || Env.DEFAULT_TIMEOUT,
USER_AGENT: Env.DEFAULT_USER_AGENT,
SUPPORTED_SERVICES: StremThruPreset.supportedServices,
DESCRIPTION: 'Directly search Knaben for results with your services.',
OPTIONS: options,
SUPPORTED_STREAM_TYPES: [constants.DEBRID_STREAM_TYPE],
SUPPORTED_RESOURCES: supportedResources,
BUILTIN: true,
};
}
protected static override generateManifestUrl(
userData: UserData,
services: constants.ServiceId[],
options: Record<string, any>
): string {
return `${Env.INTERNAL_URL}/builtins/knaben/${this.base64EncodeJSON(
this.getBaseConfig(userData, services)
)}/manifest.json`;
}
}
+5 -1
View File
@@ -61,6 +61,7 @@ import { NewznabPreset } from './newznab.js';
import { ProwlarrPreset } from './prowlarr.js';
import { JackettPreset } from './jackett.js';
import { NZBHydraPreset } from './nzbhydra.js';
import { KnabenPreset } from './knaben.js';
let PRESET_LIST: string[] = [
'custom',
@@ -72,8 +73,9 @@ let PRESET_LIST: string[] = [
'mediafusion',
'stremthruTorz',
'stremthruStore',
'animetosho',
'zilean',
'knaben',
'animetosho',
'prowlarr',
'jackett',
'nzbhydra',
@@ -264,6 +266,8 @@ export class PresetManager {
return JackettPreset;
case 'nzbhydra':
return NZBHydraPreset;
case 'knaben':
return KnabenPreset;
default:
throw new Error(`Preset ${id} not found`);
}
+13
View File
@@ -1726,6 +1726,19 @@ export const Env = cleanEnv(process.env, {
desc: 'Builtin Prowlarr Indexers cache TTL',
}),
BUILTIN_KNABEN_TIMEOUT: num({
default: undefined,
desc: 'Builtin Knaben timeout',
}),
BUILTIN_KNABEN_SEARCH_TIMEOUT: num({
default: 30000, // 30 seconds
desc: 'Builtin Knaben Search timeout',
}),
BUILTIN_KNABEN_SEARCH_CACHE_TTL: num({
default: 7 * 24 * 60 * 60, // 7 days
desc: 'Builtin Knaben Search cache TTL',
}),
// Rate limiting settings
DISABLE_RATE_LIMITS: bool({
default: false,
+1 -1
View File
@@ -87,7 +87,7 @@ export class TorrentClient {
let metadata: TorrentMetadata;
// Handle redirects
if (response.status === 302 || response.status === 301) {
if (response.status >= 300 && response.status < 400) {
const redirectUrl = response.headers.get('Location');
if (!redirectUrl) {
throw new Error('Redirect location not found');
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

+2
View File
@@ -27,6 +27,7 @@ import {
torznab,
newznab,
prowlarr,
knaben,
} from './routes/builtins/index.js';
import {
ipMiddleware,
@@ -130,6 +131,7 @@ builtinsRouter.use('/torbox-search', torboxSearch);
builtinsRouter.use('/torznab', torznab);
builtinsRouter.use('/newznab', newznab);
builtinsRouter.use('/prowlarr', prowlarr);
builtinsRouter.use('/knaben', knaben);
app.use('/builtins', builtinsRouter);
app.get(
@@ -3,3 +3,4 @@ export { default as torboxSearch } from './torbox-search.js';
export { default as torznab } from './torznab.js';
export { default as newznab } from './newznab.js';
export { default as prowlarr } from './prowlarr.js';
export { default as knaben } from './knaben.js';
@@ -0,0 +1,45 @@
import { Router, Request, Response, NextFunction } from 'express';
import { AIOStreams, AIOStreamResponse, KnabenAddon } from '@aiostreams/core';
import { createLogger } from '@aiostreams/core';
const router: Router = Router();
const logger = createLogger('server');
router.get(
'/:encodedConfig/manifest.json',
async (req: Request, res: Response, next: NextFunction) => {
const { encodedConfig } = req.params;
const config = encodedConfig
? JSON.parse(Buffer.from(encodedConfig, 'base64').toString('utf-8'))
: undefined;
try {
const manifest = new KnabenAddon(config, req.userIp).getManifest();
res.json(manifest);
} catch (error) {
next(error);
}
}
);
router.get(
'/:encodedConfig/stream/:type/:id.json',
async (req: Request, res: Response, next: NextFunction) => {
const { encodedConfig, type, id } = req.params;
const config = JSON.parse(
Buffer.from(encodedConfig, 'base64').toString('utf-8')
);
try {
const addon = new KnabenAddon(config, req.userIp);
const streams = await addon.getStreams(type, id);
res.json({
streams: streams,
});
} catch (error) {
next(error);
}
}
);
export default router;
@@ -1,6 +1,5 @@
import { Router, Request, Response, NextFunction } from 'express';
import { AIOStreams, AIOStreamResponse, TorznabAddon } from '@aiostreams/core';
import { stremioStreamRateLimiter } from '../../middlewares/ratelimit';
import { createLogger } from '@aiostreams/core';
const router: Router = Router();