diff --git a/.env.sample b/.env.sample index 210b5868..44b0dadf 100644 --- a/.env.sample +++ b/.env.sample @@ -103,6 +103,26 @@ BUILTIN_PROWLARR_SEARCH_TIMEOUT= BUILTIN_PROWLARR_SEARCH_CACHE_TTL=604800 BUILTIN_PROWLARR_INDEXERS_CACHE_TTL=1209600 + +# --- Knaben ---- +# BUILTIN_DEFAULT_KNABEN_TIMEOUT= +# BUILTIN_KNABEN_SEARCH_TIMEOUT=30000, +# BUILTIN_KNABEN_SEARCH_CACHE_TTL=604800 + + +# --- Torrent Galaxy --- +# BUILTIN_TORRENT_GALAXY_URL=https://torrentgalaxy.space +# Default timeout of the addon in the marketplace +# BUILTIN_DEFAULT_TORRENT_GALAXY_TIMEOUT= +# The timeout for requests to TGx +# BUILTIN_TORRENT_GALAXY_SEARCH_TIMEOUT=30000 +# How long each search is cached for. +# BUILTIN_TORRENT_GALAXY_SEARCH_CACHE_TTL=604800 +# The maximum number of pages to fetch. +# BUILTIN_TORRENT_GALAXY_PAGE_LIMIT=5 + + + # ---- Jackett # Optionally provide a default Jackett URL and API Key here. Users cannot see the values set here. # BUILTIN_JACKETT_URL= @@ -111,7 +131,7 @@ BUILTIN_PROWLARR_INDEXERS_CACHE_TTL=1209600 # ---- NZBHydra2 ------ # Optionally provide a default NZBHydra URL and API Key here. Users cannot see the values set here. # BUILTIN_NZBHYDRA_URL= -# BUILDIN_NZBHYDRA_API_KEY= +# BUILTIN_NZBHYDRA_API_KEY= # ---- Stremio GDrive ----- # Client ID and Secret generated following this guide: https://guides.viren070.me/stremio/addons/stremio-gdrive diff --git a/packages/core/src/builtins/index.ts b/packages/core/src/builtins/index.ts index 6693a736..7dea0026 100644 --- a/packages/core/src/builtins/index.ts +++ b/packages/core/src/builtins/index.ts @@ -4,3 +4,4 @@ export * from './torznab/index.js'; export * from './newznab/index.js'; export * from './prowlarr/index.js'; export * from './knaben/index.js'; +export * from './torrent-galaxy/index.js'; diff --git a/packages/core/src/builtins/torrent-galaxy/addon.ts b/packages/core/src/builtins/torrent-galaxy/addon.ts new file mode 100644 index 00000000..ab27cf9b --- /dev/null +++ b/packages/core/src/builtins/torrent-galaxy/addon.ts @@ -0,0 +1,179 @@ +import { + BaseDebridAddon, + BaseDebridConfigSchema, + SearchMetadata, +} from '../base/debrid.js'; +import { z } from 'zod'; +import { + createLogger, + getTimeTakenSincePoint, + ParsedId, +} from '../../utils/index.js'; +import TorrentGalaxyAPI, { TorrentGalaxyCategory } from './api.js'; +import { NZB, UnprocessedTorrent } from '../../debrid/utils.js'; +import { + extractTrackersFromMagnet, + validateInfoHash, +} from '../utils/debrid.js'; +import { Env } from '../../utils/env.js'; + +const logger = createLogger('torrent-galaxy'); + +export const TorrentGalaxyAddonConfigSchema = BaseDebridConfigSchema; + +export type TorrentGalaxyAddonConfig = z.infer< + typeof TorrentGalaxyAddonConfigSchema +>; + +const WHITELISTED_CATEGORIES = [ + TorrentGalaxyCategory.Anime, + TorrentGalaxyCategory.TV, + TorrentGalaxyCategory.Movies, +]; + +export class TorrentGalaxyAddon extends BaseDebridAddon { + readonly id = 'torrent-galaxy'; + readonly name = 'Torrent Galaxy'; + readonly version = '1.0.0'; + readonly logger = logger; + readonly api: TorrentGalaxyAPI; + + constructor(userData: TorrentGalaxyAddonConfig, clientIp?: string) { + super(userData, TorrentGalaxyAddonConfigSchema, clientIp); + this.api = new TorrentGalaxyAPI(); + } + + protected async _searchNzbs( + parsedId: ParsedId, + metadata: SearchMetadata + ): Promise { + return []; + } + + protected async _searchTorrents( + parsedId: ParsedId, + metadata: SearchMetadata + ): Promise { + if (!metadata.primaryTitle) { + return []; + } + + const queries = this.buildQueries(parsedId, metadata); + if (metadata.imdbId) { + queries.push(metadata.imdbId); + } + + if (queries.length === 0) { + return []; + } + + logger.info(`Performing torrent galaxy search`, { queries }); + + const searchPromises = queries.map(async (q) => { + const start = Date.now(); + + // First fetch to get total and page size + logger.debug(`Fetching first page for query "${q}"`); + const firstPageResponse = await this.api.search({ + query: q, + page: 1, + }); + + const { total, pageSize } = firstPageResponse; + let allResults = [...firstPageResponse.results]; + + // Calculate required pages + const totalPages = Math.min( + Math.ceil(total / pageSize), + Env.BUILTIN_TORRENT_GALAXY_PAGE_LIMIT + ); + + if (totalPages <= 1) { + logger.info( + `Torrent Galaxy search for ${q} took ${getTimeTakenSincePoint(start)}`, + { + results: allResults.length, + pages: 1, + } + ); + return allResults; + } + + // Create array of page numbers to fetch (skip page 1 as we already have it) + const pageNumbers = Array.from( + { length: totalPages - 1 }, + (_, i) => i + 2 + ); + + logger.debug( + `Fetching ${pageNumbers.length} additional pages in parallel for query "${q}"` + ); + + // Fetch all remaining pages in parallel + const pagePromises = pageNumbers.map(async (pageNum) => { + const { results } = await this.api.search({ + query: q, + page: pageNum, + }); + logger.debug(`Fetched page ${pageNum} for query "${q}"`, { + newResults: results.length, + }); + return results; + }); + + const remainingResults = await Promise.all(pagePromises); + allResults.push(...remainingResults.flat()); + + logger.info( + `Torrent Galaxy search for ${q} took ${getTimeTakenSincePoint(start)}`, + { + results: allResults.length, + pages: totalPages, + } + ); + return allResults; + }); + + const allResults = await Promise.all(searchPromises); + const results = allResults + .flat() + .filter( + (result) => + WHITELISTED_CATEGORIES.some( + (category) => result.category === category + ) || + (metadata.imdbId && result.imdbId + ? result.imdbId === metadata.imdbId + : true) + ); + + const seenTorrents = new Set(); + const torrents: UnprocessedTorrent[] = []; + for (const result of results) { + const hash = validateInfoHash(result.hash); + if (!hash) { + logger.warn( + `TorrentGalaxy search hit has no hash: ${JSON.stringify(result)}` + ); + continue; + } + const downloadUrl = `https://itorrents.org/${hash.toUpperCase()}.torrent?title=${result.name}`; + if (seenTorrents.has(hash)) { + continue; + } + seenTorrents.add(hash); + + torrents.push({ + hash, + downloadUrl, + sources: [], + indexer: `TGx | ${result.user}`, + seeders: result.seeders, + title: result.name, + size: result.size, + type: 'torrent', + }); + } + return torrents; + } +} diff --git a/packages/core/src/builtins/torrent-galaxy/api.ts b/packages/core/src/builtins/torrent-galaxy/api.ts new file mode 100644 index 00000000..014edc0e --- /dev/null +++ b/packages/core/src/builtins/torrent-galaxy/api.ts @@ -0,0 +1,175 @@ +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('torrent-galaxy'); + +enum TorrentGalaxyCategory { + Movies = 'Movies', + TV = 'TV', + Anime = 'Anime', +} + +const TorrentGalaxySearchResultSchema = z + .looseObject({ + pk: z.string(), // post key + n: z.string(), // name + a: z.number(), // unix timestamp i.e. age + c: z.string(), // category e.g. Movies + s: z.number(), // size + t: z.url().nullable(), // poster URL + u: z.string(), // user + se: z.number(), // seeders + le: z.number(), // leechers + i: z.string().nullable(), // imdb id, + h: z.string().transform((h) => h.toLowerCase()), // hash + tg: z.array(z.string()), // tags. + }) + .transform((data) => ({ + postKey: data.pk, + name: data.n, + age: data.a, + category: data.c, + size: data.s, + posterUrl: data.t, + user: data.u, + seeders: data.se, + leechers: data.le, + imdbId: data.i, + hash: data.h, + tags: data.tg, + })); + +type TorrentGalaxySearchResult = z.infer< + typeof TorrentGalaxySearchResultSchema +>; + +const TorrentGalaxySearchResponse = z + .object({ + page_size: z.number(), + count: z.number(), + total: z.number(), + results: z.array(TorrentGalaxySearchResultSchema), + }) + .transform((data) => ({ + pageSize: data.page_size, + count: data.count, + total: data.total, + results: data.results, + })); + +type TorrentGalaxySearchResponse = z.infer; + +const TorrentGalaxySearchOptions = z.object({ + query: z.string(), + page: z.number().default(1), +}); + +type TorrentGalaxySearchOptions = z.infer; + +const API_BASE_URL = Env.BUILTIN_TORRENT_GALAXY_URL; + +class TorrentGalaxyAPI { + private headers: Record; + + private readonly searchCache = Cache.getInstance( + 'torrent-galaxy: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: TorrentGalaxySearchOptions + ): Promise { + let queryParams = new URLSearchParams(); + if (options.page) { + queryParams.set('page', options.page.toString()); + } + return this.searchCache.wrap( + () => + this.request( + `/get-posts/keywords:${encodeURIComponent(options.query)}:format:json`, + { + schema: TorrentGalaxySearchResponse, + timeout: Env.BUILTIN_TORRENT_GALAXY_SEARCH_TIMEOUT, + queryParams, + } + ), + `${JSON.stringify(options)}`, + Env.BUILTIN_TORRENT_GALAXY_SEARCH_CACHE_TTL + ); + } + + private async request( + endpoint: string, + options: { + schema: z.ZodSchema; + body?: unknown; + method?: string; + timeout?: number; + queryParams?: URLSearchParams; + } + ): Promise { + const { schema, body, method = 'GET' } = options; + let path = ''; + if (endpoint) { + path = `/${endpoint.startsWith('/') ? endpoint.slice(1) : endpoint}`; + } + const url = new URL(path, API_BASE_URL); + if (options.queryParams) { + url.search = options.queryParams.toString(); + } + + 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( + `Torrent Galaxy API error (${response.status}): ${response.statusText}` + ); + } + + try { + return schema.parse(data); + } catch (error) { + throw new Error( + `Failed to parse Torrent Galaxy 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 { TorrentGalaxyCategory }; +export type { + TorrentGalaxySearchOptions, + TorrentGalaxySearchResponse, + TorrentGalaxySearchResult, +}; +export default TorrentGalaxyAPI; diff --git a/packages/core/src/builtins/torrent-galaxy/index.ts b/packages/core/src/builtins/torrent-galaxy/index.ts new file mode 100644 index 00000000..3ee341c5 --- /dev/null +++ b/packages/core/src/builtins/torrent-galaxy/index.ts @@ -0,0 +1 @@ +export * from './addon.js'; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 7e7115fd..3920442a 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -15,5 +15,6 @@ export { NewznabAddon, ProwlarrAddon, KnabenAddon, + TorrentGalaxyAddon, } from './builtins/index.js'; export { PresetManager } from './presets/index.js'; diff --git a/packages/core/src/presets/presetManager.ts b/packages/core/src/presets/presetManager.ts index 9806101e..000a55d0 100644 --- a/packages/core/src/presets/presetManager.ts +++ b/packages/core/src/presets/presetManager.ts @@ -64,7 +64,7 @@ import { NZBHydraPreset } from './nzbhydra.js'; import { KnabenPreset } from './knaben.js'; import { BitmagnetPreset } from './bitmagnet.js'; import { SootioPreset } from './sootio.js'; - +import { TorrentGalaxyPreset } from './torrentGalaxy.js'; let PRESET_LIST: string[] = [ 'custom', 'torznab', @@ -78,6 +78,7 @@ let PRESET_LIST: string[] = [ 'sootio', 'zilean', 'knaben', + 'torrent-galaxy', Env.BUILTIN_BITMAGNET_URL ? 'bitmagnet' : '', 'animetosho', 'prowlarr', @@ -276,6 +277,8 @@ export class PresetManager { return BitmagnetPreset; case 'sootio': return SootioPreset; + case 'torrent-galaxy': + return TorrentGalaxyPreset; default: throw new Error(`Preset ${id} not found`); } diff --git a/packages/core/src/presets/torrentGalaxy.ts b/packages/core/src/presets/torrentGalaxy.ts new file mode 100644 index 00000000..ab0ea56b --- /dev/null +++ b/packages/core/src/presets/torrentGalaxy.ts @@ -0,0 +1,71 @@ +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 TorrentGalaxyPreset 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: 'TorrentGalaxy', + }, + { + id: 'timeout', + name: 'Timeout', + description: 'The timeout for this addon', + type: 'number', + required: true, + default: + Env.BUILTIN_DEFAULT_TORRENT_GALAXY_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: 'torrent-galaxy', + NAME: 'TorrentGalaxy', + LOGO: '', + URL: `${Env.INTERNAL_URL}/builtins/torrent-galaxy`, + TIMEOUT: + Env.BUILTIN_DEFAULT_TORRENT_GALAXY_TIMEOUT || Env.DEFAULT_TIMEOUT, + USER_AGENT: Env.DEFAULT_USER_AGENT, + SUPPORTED_SERVICES: StremThruPreset.supportedServices, + DESCRIPTION: + 'Directly search TorrentGalaxy 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/torrent-galaxy/${this.base64EncodeJSON( + this.getBaseConfig(userData, services) + )}/manifest.json`; + } +} diff --git a/packages/core/src/utils/env.ts b/packages/core/src/utils/env.ts index 2f7f0e91..fde56d86 100644 --- a/packages/core/src/utils/env.ts +++ b/packages/core/src/utils/env.ts @@ -1769,6 +1769,26 @@ export const Env = cleanEnv(process.env, { desc: 'Builtin Knaben Search cache TTL', }), + BUILTIN_TORRENT_GALAXY_URL: url({ + default: 'https://torrentgalaxy.space', + desc: 'Builtin Torrent Galaxy URL', + }), + BUILTIN_DEFAULT_TORRENT_GALAXY_TIMEOUT: num({ + default: undefined, + desc: 'Builtin Torrent Galaxy timeout', + }), + BUILTIN_TORRENT_GALAXY_SEARCH_TIMEOUT: num({ + default: 30000, // 30 seconds + desc: 'Builtin Torrent Galaxy Search timeout', + }), + BUILTIN_TORRENT_GALAXY_SEARCH_CACHE_TTL: num({ + default: 7 * 24 * 60 * 60, // 7 days + desc: 'Builtin Torrent Galaxy Search cache TTL', + }), + BUILTIN_TORRENT_GALAXY_PAGE_LIMIT: num({ + default: 5, + desc: 'The maximum number of pages to fetch.', + }), // Rate limiting settings DISABLE_RATE_LIMITS: bool({ default: false, diff --git a/packages/core/src/utils/logger.ts b/packages/core/src/utils/logger.ts index 20a478a0..f9ac42ef 100644 --- a/packages/core/src/utils/logger.ts +++ b/packages/core/src/utils/logger.ts @@ -45,6 +45,8 @@ const moduleMap: { [key: string]: string } = { newznab: '🔍 NEWZNAB', 'metadata-service': '🔍 METADATA', torrent: '👤 TORRENT', + knaben: '🔍 KNABEN', + 'torrent-galaxy': '🌐 TGx', }; // Define colors for each log level using full names diff --git a/packages/server/src/app.ts b/packages/server/src/app.ts index 6913c5b2..e1ad5667 100644 --- a/packages/server/src/app.ts +++ b/packages/server/src/app.ts @@ -28,6 +28,7 @@ import { newznab, prowlarr, knaben, + torrentGalaxy, } from './routes/builtins/index.js'; import { ipMiddleware, @@ -132,6 +133,7 @@ builtinsRouter.use('/torznab', torznab); builtinsRouter.use('/newznab', newznab); builtinsRouter.use('/prowlarr', prowlarr); builtinsRouter.use('/knaben', knaben); +builtinsRouter.use('/torrent-galaxy', torrentGalaxy); 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 43de07de..9c301c84 100644 --- a/packages/server/src/routes/builtins/index.ts +++ b/packages/server/src/routes/builtins/index.ts @@ -4,3 +4,4 @@ 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'; +export { default as torrentGalaxy } from './torrent-galaxy.js'; diff --git a/packages/server/src/routes/builtins/torrent-galaxy.ts b/packages/server/src/routes/builtins/torrent-galaxy.ts new file mode 100644 index 00000000..efc10175 --- /dev/null +++ b/packages/server/src/routes/builtins/torrent-galaxy.ts @@ -0,0 +1,49 @@ +import { Router, Request, Response, NextFunction } from 'express'; +import { + AIOStreams, + AIOStreamResponse, + TorrentGalaxyAddon, +} 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 TorrentGalaxyAddon(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 TorrentGalaxyAddon(config, req.userIp); + const streams = await addon.getStreams(type, id); + res.json({ + streams: streams, + }); + } catch (error) { + next(error); + } + } +); + +export default router;