feat: add torrent galaxy builtin addon

This commit is contained in:
Viren070
2025-09-22 19:40:56 +01:00
parent b3a09bf56c
commit 5f537af30a
13 changed files with 527 additions and 2 deletions
+21 -1
View File
@@ -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
+1
View File
@@ -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';
@@ -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<TorrentGalaxyAddonConfig> {
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<NZB[]> {
return [];
}
protected async _searchTorrents(
parsedId: ParsedId,
metadata: SearchMetadata
): Promise<UnprocessedTorrent[]> {
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<string>();
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;
}
}
@@ -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<typeof TorrentGalaxySearchResponse>;
const TorrentGalaxySearchOptions = z.object({
query: z.string(),
page: z.number().default(1),
});
type TorrentGalaxySearchOptions = z.infer<typeof TorrentGalaxySearchOptions>;
const API_BASE_URL = Env.BUILTIN_TORRENT_GALAXY_URL;
class TorrentGalaxyAPI {
private headers: Record<string, string>;
private readonly searchCache = Cache.getInstance<string, any>(
'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<TorrentGalaxySearchResponse> {
let queryParams = new URLSearchParams();
if (options.page) {
queryParams.set('page', options.page.toString());
}
return this.searchCache.wrap(
() =>
this.request<TorrentGalaxySearchResponse>(
`/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<T>(
endpoint: string,
options: {
schema: z.ZodSchema<T>;
body?: unknown;
method?: string;
timeout?: number;
queryParams?: URLSearchParams;
}
): Promise<T> {
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;
@@ -0,0 +1 @@
export * from './addon.js';
+1
View File
@@ -15,5 +15,6 @@ export {
NewznabAddon,
ProwlarrAddon,
KnabenAddon,
TorrentGalaxyAddon,
} from './builtins/index.js';
export { PresetManager } from './presets/index.js';
+4 -1
View File
@@ -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`);
}
@@ -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, any>
): string {
return `${Env.INTERNAL_URL}/builtins/torrent-galaxy/${this.base64EncodeJSON(
this.getBaseConfig(userData, services)
)}/manifest.json`;
}
}
+20
View File
@@ -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,
+2
View File
@@ -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
+2
View File
@@ -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(
@@ -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';
@@ -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;