mirror of
https://github.com/Viren070/AIOStreams.git
synced 2025-12-01 23:14:04 +01:00
feat: add knaben builtin addon
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
@@ -14,5 +14,6 @@ export {
|
||||
TorznabAddon,
|
||||
NewznabAddon,
|
||||
ProwlarrAddon,
|
||||
KnabenAddon,
|
||||
} from './builtins/index.js';
|
||||
export { PresetManager } from './presets/index.js';
|
||||
|
||||
@@ -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`;
|
||||
}
|
||||
}
|
||||
@@ -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`);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 |
@@ -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();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user