diff --git a/packages/core/src/builtins/base/debrid.ts b/packages/core/src/builtins/base/debrid.ts index d4f800d6..df2bc425 100644 --- a/packages/core/src/builtins/base/debrid.ts +++ b/packages/core/src/builtins/base/debrid.ts @@ -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 { imdbId, tmdbId: metadata.tmdbId ?? null, tvdbId: metadata.tvdbId ?? null, + isAnime: animeEntry ? true : false, }; this.logger.debug( diff --git a/packages/core/src/builtins/index.ts b/packages/core/src/builtins/index.ts index 932620bb..6693a736 100644 --- a/packages/core/src/builtins/index.ts +++ b/packages/core/src/builtins/index.ts @@ -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'; diff --git a/packages/core/src/builtins/knaben/addon.ts b/packages/core/src/builtins/knaben/addon.ts new file mode 100644 index 00000000..04256a53 --- /dev/null +++ b/packages/core/src/builtins/knaben/addon.ts @@ -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; + +export class KnabenAddon extends BaseDebridAddon { + 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 { + return []; + } + + protected async _searchTorrents( + parsedId: ParsedId, + metadata: SearchMetadata + ): Promise { + 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(); + 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; + } +} diff --git a/packages/core/src/builtins/knaben/api.ts b/packages/core/src/builtins/knaben/api.ts new file mode 100644 index 00000000..81a9c335 --- /dev/null +++ b/packages/core/src/builtins/knaben/api.ts @@ -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; + +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; + +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; + +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; + + private readonly searchCache = Cache.getInstance( + '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 { + const body = KnabenSearchOptionsRequest.parse(options); + + return this.searchCache.wrap( + () => + this.request('', { + 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( + endpoint: string, + options: { + schema: z.ZodSchema; + body?: unknown; + method?: string; + timeout?: number; + } + ): Promise { + 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; diff --git a/packages/core/src/builtins/knaben/index.ts b/packages/core/src/builtins/knaben/index.ts new file mode 100644 index 00000000..3ee341c5 --- /dev/null +++ b/packages/core/src/builtins/knaben/index.ts @@ -0,0 +1 @@ +export * from './addon.js'; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index caa5e7ce..7e7115fd 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -14,5 +14,6 @@ export { TorznabAddon, NewznabAddon, ProwlarrAddon, + KnabenAddon, } from './builtins/index.js'; export { PresetManager } from './presets/index.js'; diff --git a/packages/core/src/presets/knaben.ts b/packages/core/src/presets/knaben.ts new file mode 100644 index 00000000..bbb7ef84 --- /dev/null +++ b/packages/core/src/presets/knaben.ts @@ -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 { + return `${Env.INTERNAL_URL}/builtins/knaben/${this.base64EncodeJSON( + this.getBaseConfig(userData, services) + )}/manifest.json`; + } +} diff --git a/packages/core/src/presets/presetManager.ts b/packages/core/src/presets/presetManager.ts index da0aff25..348fe69c 100644 --- a/packages/core/src/presets/presetManager.ts +++ b/packages/core/src/presets/presetManager.ts @@ -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`); } diff --git a/packages/core/src/utils/env.ts b/packages/core/src/utils/env.ts index fc2c7ba4..8a76b51f 100644 --- a/packages/core/src/utils/env.ts +++ b/packages/core/src/utils/env.ts @@ -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, diff --git a/packages/core/src/utils/torrent.ts b/packages/core/src/utils/torrent.ts index 44141703..4d16c915 100644 --- a/packages/core/src/utils/torrent.ts +++ b/packages/core/src/utils/torrent.ts @@ -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'); diff --git a/packages/frontend/public/assets/knaben_logo.png b/packages/frontend/public/assets/knaben_logo.png new file mode 100644 index 00000000..99583743 Binary files /dev/null and b/packages/frontend/public/assets/knaben_logo.png differ diff --git a/packages/server/src/app.ts b/packages/server/src/app.ts index 98a89e2d..6913c5b2 100644 --- a/packages/server/src/app.ts +++ b/packages/server/src/app.ts @@ -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( diff --git a/packages/server/src/routes/builtins/index.ts b/packages/server/src/routes/builtins/index.ts index 6ca9a73a..43de07de 100644 --- a/packages/server/src/routes/builtins/index.ts +++ b/packages/server/src/routes/builtins/index.ts @@ -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'; diff --git a/packages/server/src/routes/builtins/knaben.ts b/packages/server/src/routes/builtins/knaben.ts new file mode 100644 index 00000000..49d6ca2a --- /dev/null +++ b/packages/server/src/routes/builtins/knaben.ts @@ -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; diff --git a/packages/server/src/routes/builtins/torznab.ts b/packages/server/src/routes/builtins/torznab.ts index 77132646..a3d707ed 100644 --- a/packages/server/src/routes/builtins/torznab.ts +++ b/packages/server/src/routes/builtins/torznab.ts @@ -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();