@ -0,0 +1,20 @@ | |||
{ | |||
"name": "@autoplex-api/torrent-search", | |||
"version": "0.0.0", | |||
"main": "dist/lib/index.js", | |||
"types": "dist/typings", | |||
"license": "MIT", | |||
"scripts": { | |||
"build": "yarn run clean && tsc", | |||
"clean": "rimraf ./dist" | |||
}, | |||
"devDependencies": { | |||
"@types/node": "^15.0.1", | |||
"rimraf": "^3.0.2", | |||
"typescript": "^4.2.4" | |||
}, | |||
"dependencies": { | |||
"@autoplex/ipc": "^0.0.0", | |||
"@autoplex/microservice": "^0.0.0" | |||
} | |||
} |
@ -0,0 +1,32 @@ | |||
import { IpcClientService } from "@autoplex/ipc"; | |||
import { Microservice } from "@autoplex/microservice"; | |||
import { SOCKET_PATH } from "./constants"; | |||
import { IMovieSearchInfo } from "./schema"; | |||
export class IpcClient<M extends Microservice = Microservice> extends IpcClientService<M> | |||
{ | |||
/** | |||
* The name of the service | |||
*/ | |||
public readonly NAME = "Torrent Search"; | |||
/** | |||
* The path to the socket file | |||
*/ | |||
protected readonly SOCKET_PATH = SOCKET_PATH; | |||
/** | |||
* Add a torrent to the client | |||
* @param torrent Magnet URI or file buffer | |||
*/ | |||
public async searchMovie(title: string, imdbId?: string, year?: number, altTitles: string[] = []) | |||
{ | |||
let response = await this.request("search_movie", <IMovieSearchInfo> { | |||
title, imdbId, year, altTitles | |||
}); | |||
if (response.error) { | |||
throw new Error("Failed to search for movie torrent"); | |||
} | |||
return <string>response.data; | |||
} | |||
} |
@ -0,0 +1,4 @@ | |||
/** | |||
* The path to the socket file | |||
*/ | |||
export const SOCKET_PATH = "/var/autoplex/ipc/torrent_search.sock"; |
@ -0,0 +1,3 @@ | |||
export * from "./constants"; | |||
export * from "./schema"; | |||
export * from "./IpcClient"; |
@ -0,0 +1,18 @@ | |||
/** | |||
* The movie search request structure | |||
*/ | |||
export interface IMovieSearchInfo { | |||
title : string, | |||
altTitles : string[], | |||
imdbId? : string, | |||
year? : number, | |||
torrentBlacklist: string[] | |||
} | |||
/** | |||
* The generated torrent link structure | |||
*/ | |||
export interface ITorrentLink { | |||
link: string, | |||
type: "magnet"|"file" | |||
} |
@ -0,0 +1,7 @@ | |||
{ | |||
"extends": "../../tsconfig.package.json", | |||
"compilerOptions": { | |||
"outDir": "./dist/lib", | |||
"declarationDir": "./dist/typings" | |||
} | |||
} |
@ -0,0 +1,92 @@ | |||
# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. | |||
# yarn lockfile v1 | |||
"@types/node@^15.0.1": | |||
version "15.0.2" | |||
resolved "https://registry.yarnpkg.com/@types/node/-/node-15.0.2.tgz#51e9c0920d1b45936ea04341aa3e2e58d339fb67" | |||
integrity sha512-p68+a+KoxpoB47015IeYZYRrdqMUcpbK8re/zpFB8Ld46LHC1lPEbp3EXgkEhAYEcPvjJF6ZO+869SQ0aH1dcA== | |||
balanced-match@^1.0.0: | |||
version "1.0.2" | |||
resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.2.tgz#e83e3a7e3f300b34cb9d87f615fa0cbf357690ee" | |||
integrity sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw== | |||
brace-expansion@^1.1.7: | |||
version "1.1.11" | |||
resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-1.1.11.tgz#3c7fcbf529d87226f3d2f52b966ff5271eb441dd" | |||
integrity sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA== | |||
dependencies: | |||
balanced-match "^1.0.0" | |||
concat-map "0.0.1" | |||
concat-map@0.0.1: | |||
version "0.0.1" | |||
resolved "https://registry.yarnpkg.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b" | |||
integrity sha1-2Klr13/Wjfd5OnMDajug1UBdR3s= | |||
fs.realpath@^1.0.0: | |||
version "1.0.0" | |||
resolved "https://registry.yarnpkg.com/fs.realpath/-/fs.realpath-1.0.0.tgz#1504ad2523158caa40db4a2787cb01411994ea4f" | |||
integrity sha1-FQStJSMVjKpA20onh8sBQRmU6k8= | |||
glob@^7.1.3: | |||
version "7.1.7" | |||
resolved "https://registry.yarnpkg.com/glob/-/glob-7.1.7.tgz#3b193e9233f01d42d0b3f78294bbeeb418f94a90" | |||
integrity sha512-OvD9ENzPLbegENnYP5UUfJIirTg4+XwMWGaQfQTY0JenxNvvIKP3U3/tAQSPIu/lHxXYSZmpXlUHeqAIdKzBLQ== | |||
dependencies: | |||
fs.realpath "^1.0.0" | |||
inflight "^1.0.4" | |||
inherits "2" | |||
minimatch "^3.0.4" | |||
once "^1.3.0" | |||
path-is-absolute "^1.0.0" | |||
inflight@^1.0.4: | |||
version "1.0.6" | |||
resolved "https://registry.yarnpkg.com/inflight/-/inflight-1.0.6.tgz#49bd6331d7d02d0c09bc910a1075ba8165b56df9" | |||
integrity sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk= | |||
dependencies: | |||
once "^1.3.0" | |||
wrappy "1" | |||
inherits@2: | |||
version "2.0.4" | |||
resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c" | |||
integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ== | |||
minimatch@^3.0.4: | |||
version "3.0.4" | |||
resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.0.4.tgz#5166e286457f03306064be5497e8dbb0c3d32083" | |||
integrity sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA== | |||
dependencies: | |||
brace-expansion "^1.1.7" | |||
once@^1.3.0: | |||
version "1.4.0" | |||
resolved "https://registry.yarnpkg.com/once/-/once-1.4.0.tgz#583b1aa775961d4b113ac17d9c50baef9dd76bd1" | |||
integrity sha1-WDsap3WWHUsROsF9nFC6753Xa9E= | |||
dependencies: | |||
wrappy "1" | |||
path-is-absolute@^1.0.0: | |||
version "1.0.1" | |||
resolved "https://registry.yarnpkg.com/path-is-absolute/-/path-is-absolute-1.0.1.tgz#174b9268735534ffbc7ace6bf53a5a9e1b5c5f5f" | |||
integrity sha1-F0uSaHNVNP+8es5r9TpanhtcX18= | |||
rimraf@^3.0.2: | |||
version "3.0.2" | |||
resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-3.0.2.tgz#f1a5402ba6220ad52cc1282bac1ae3aa49fd061a" | |||
integrity sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA== | |||
dependencies: | |||
glob "^7.1.3" | |||
typescript@^4.2.4: | |||
version "4.2.4" | |||
resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.2.4.tgz#8610b59747de028fda898a8aef0e103f156d0961" | |||
integrity sha512-V+evlYHZnQkaz8TRBuxTA92yZBPotr5H+WhQ7bD3hZUndx5tGOa1fuCgeSjxAzM1RiN5IzvadIXTVefuuwZCRg== | |||
wrappy@1: | |||
version "1.0.2" | |||
resolved "https://registry.yarnpkg.com/wrappy/-/wrappy-1.0.2.tgz#b5243d8f3ec1aa35f1364605bc0d1036e30ab69f" | |||
integrity sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8= |
@ -0,0 +1,3 @@ | |||
# Autoplex Seeker | |||
Seeker is a standalone container responsible for searching for requested content via direct search and monitoring RSS feeds of various torrent providers |
@ -0,0 +1,9 @@ | |||
{ | |||
"watch": ["src"], | |||
"ext": "ts,json", | |||
"ignore": ["src/**/*.spec.ts"], | |||
"exec": "node --inspect=0.0.0.0:9230 -r ts-node/register src/index.ts", | |||
"events": { | |||
"start": "clear" | |||
} | |||
} |
@ -0,0 +1,31 @@ | |||
{ | |||
"name": "@autoplex-service/torrent-search", | |||
"version": "1.0.0", | |||
"main": "./dist/index.js", | |||
"author": "David Ludwig <davidludwigii@gmail.com>", | |||
"license": "MIT", | |||
"scripts": { | |||
"clean": "rimraf ./dist", | |||
"build": "tsc", | |||
"start": "NODE_ENV=production node .", | |||
"start:dev": "nodemon" | |||
}, | |||
"devDependencies": { | |||
"@types/node": "^14.14.41", | |||
"@types/node-ipc": "^9.1.3", | |||
"@types/xml2js": "^0.4.8", | |||
"nodemon": "^2.0.7", | |||
"rimraf": "^3.0.2", | |||
"ts-node": "^9.1.1", | |||
"typescript": "^4.2.4" | |||
}, | |||
"dependencies": { | |||
"@autoplex-api/torrent": "^0.0.0", | |||
"@autoplex-api/torrent-search": "^0.0.0", | |||
"@autoplex/ipc": "^0.0.0", | |||
"@autoplex/microservice": "^0.0.0", | |||
"cheerio": "^1.0.0-rc.9", | |||
"node-ipc": "^9.1.4", | |||
"xml2js": "^0.4.23" | |||
} | |||
} |
@ -0,0 +1,11 @@ | |||
import { Microservice } from "@autoplex/microservice"; | |||
import * as services from "./services"; | |||
// Create a new application instance | |||
let app = new Microservice(); | |||
// Install the internal services | |||
app.installServices(Object.values(services)); | |||
// Start the application | |||
app.exec().then(process.exit); |
@ -0,0 +1,45 @@ | |||
import { IMovieSearchInfo, SOCKET_PATH } from "@autoplex-api/torrent-search"; | |||
import { Microservice } from "@autoplex/microservice"; | |||
import { IpcServerService } from "@autoplex/ipc"; | |||
import TorrentSearch from "./TorrentSearch"; | |||
export default class IpcInterface extends IpcServerService | |||
{ | |||
/** | |||
* The service name | |||
*/ | |||
public readonly NAME = "IPC"; | |||
/** | |||
* The path to the socket file | |||
*/ | |||
public readonly SOCKET_PATH = SOCKET_PATH; | |||
/** | |||
* The torrent search service | |||
*/ | |||
protected search!: TorrentSearch; | |||
/** | |||
* Link the IPC service to other required internal services | |||
*/ | |||
public link(app: Microservice) { | |||
this.search = app.service<TorrentSearch>("Torrent Search"); | |||
} | |||
/** | |||
* Install the the event handlers | |||
*/ | |||
protected installMessageHandlers() { | |||
this.addMessageHandler("search_movie", this.searchMovie); | |||
} | |||
// Interface Methods --------------------------------------------------------------------------- | |||
/** | |||
* Invoked when a new Movie ticket has been created | |||
*/ | |||
protected async searchMovie(movie: IMovieSearchInfo) { | |||
return await this.search.searchMovie(movie); | |||
} | |||
} |
@ -0,0 +1,61 @@ | |||
import { IMovieSearchInfo, ITorrentLink } from "@autoplex-api/torrent-search/dist/typings"; | |||
import { InternalService } from "@autoplex/microservice"; | |||
import { MediaType, Provider, providers } from "../torrents/providers"; | |||
import { rankTorrents } from "../torrents/ranking"; | |||
import Torrent from "../torrents/Torrent"; | |||
export default class TorrentSearch extends InternalService | |||
{ | |||
/** | |||
* The name of the service | |||
*/ | |||
public readonly NAME = "Torrent Search"; | |||
/** | |||
* Store all providers | |||
*/ | |||
protected providers!: Provider[]; | |||
/** | |||
* Store a list of movie prodivers | |||
*/ | |||
protected movieProviders!: Provider[]; | |||
/** | |||
* Store a list of TV providers | |||
*/ | |||
protected tvProviders!: Provider[]; | |||
/** | |||
* Boot the torrent search sorvice | |||
*/ | |||
public async boot() { | |||
let providerClasses = Object.values(providers); | |||
this.providers = providerClasses.map(ProviderClass => new ProviderClass()); | |||
this.movieProviders = this.providers.filter(provider => provider.PROVIDES & MediaType.Movies); | |||
this.tvProviders = this.providers.filter(provider => provider.PROVIDES & MediaType.TvShows); | |||
} | |||
/** | |||
* Search for a movie | |||
*/ | |||
public async searchMovie(movie: IMovieSearchInfo) { | |||
let results = await Promise.all(this.movieProviders.map(provider => provider.searchMovie(movie))); | |||
let torrents = (<Torrent[]>[]).concat(...results); | |||
if (torrents.length == 0) { | |||
return null; | |||
} | |||
// Determine the preferred torrents | |||
let preferredTorrents = rankTorrents(torrents); | |||
if (preferredTorrents.length == 0) { | |||
preferredTorrents = torrents; | |||
} | |||
// Return the selected torrent | |||
this.log("Found movie torrent for", movie.title); | |||
let link = await preferredTorrents[0].downloadLink(); | |||
return <ITorrentLink>{ | |||
link, | |||
type: link.toLowerCase().startsWith("magnet:?xt=urn:btih:") ? "magnet" : "file" | |||
}; | |||
} | |||
} |
@ -0,0 +1,7 @@ | |||
import IpcInterface from "./IpcInterface"; | |||
import TorrentSearch from "./TorrentSearch"; | |||
export { | |||
IpcInterface, | |||
TorrentSearch | |||
} |
@ -0,0 +1,63 @@ | |||
import { IMovieSearchInfo } from "@autoplex-api/torrent-search/dist/typings"; | |||
import { ITorrentMetaInfo, parseMovieTorrentName } from "./parsing"; | |||
export default class Torrent | |||
{ | |||
/** | |||
* The name of the torrent | |||
*/ | |||
public readonly name: string; | |||
/** | |||
* The size of the torrent in bytes (if available) | |||
*/ | |||
public readonly size: number | null; | |||
/** | |||
* The number of seeders (if available) | |||
*/ | |||
public readonly seeders: number; | |||
/** | |||
* Download link (if available) | |||
*/ | |||
protected readonly link: string | null; | |||
/** | |||
* Metadata of the torrent | |||
*/ | |||
public readonly metadata: ITorrentMetaInfo; | |||
/** | |||
* Create a new Torrent instance | |||
* | |||
* @param name The name of the torrent | |||
* @param size The size of the torrent in bytes (if available) | |||
* @param seeders The number of seeders (if available) | |||
* @param link The number of seeders (if available) | |||
*/ | |||
public constructor(movie: IMovieSearchInfo, name: string, size?: number, seeders?: number, link?: string) { | |||
this.name = name.trim(); | |||
this.size = size ?? null; | |||
this.seeders = seeders ?? 1; | |||
this.link = link ?? null; | |||
this.metadata = parseMovieTorrentName(name, movie); | |||
} | |||
/** | |||
* Return a link to download (magnet or .torrent) | |||
*/ | |||
public async downloadLink() { | |||
if (this.link === null) { | |||
throw Error("Magnet link does not exist"); | |||
} | |||
return this.link; | |||
} | |||
/** | |||
* Serialize this torrent into a string | |||
*/ | |||
public toString() { | |||
return `Name: ${this.name}; Size: ${this.size}; Seeders: ${this.seeders};` | |||
} | |||
} |
@ -0,0 +1,155 @@ | |||
import { IMovieSearchInfo } from "@autoplex-api/torrent-search/dist/typings"; | |||
/** | |||
* Video quality from lowest to highest | |||
*/ | |||
export enum Resolution { | |||
HD4k, | |||
HD1080, | |||
HD720, | |||
SD384, | |||
SD480, | |||
SD360, | |||
Unknown | |||
} | |||
// https://en.wikipedia.org/wiki/Pirated_movie_release_types#DVD_and_VOD_ripping | |||
// https://en.wikipedia.org/wiki/Standard_(warez)#cite_note-txd2k9-13 | |||
/** | |||
* Types of releases from lowest quality to highest | |||
*/ | |||
export enum ReleaseType { | |||
BluRay, | |||
WebDl, | |||
WebRip, | |||
WebCap, | |||
HDRip, | |||
DVDR, | |||
DVDRip, | |||
Unknown, // Unknown is better than cam tbh | |||
HDCAM, | |||
CAM | |||
} | |||
export enum VideoCodec { | |||
XviD, | |||
x264, | |||
x265, | |||
} | |||
export enum VideoCodecFlag { | |||
REMUX, | |||
HDR, | |||
HEVC | |||
} | |||
export enum AudioCodec { | |||
AC3, | |||
DD51, | |||
AAC71, | |||
Atmos71, | |||
TenBit | |||
} | |||
export interface ITorrentMetaInfo { | |||
containsOtherLanguage: boolean, | |||
resolution: Resolution, | |||
releaseType: ReleaseType, | |||
} | |||
/** | |||
* Determine meta-info from a torrent name | |||
*/ | |||
export function parseMovieTorrentName(torrentName: string, movie: IMovieSearchInfo) { | |||
// Split the meta info after the year if possible to make parsing more reliable | |||
let split = torrentName.split(new RegExp(`${movie.year}|\\(${movie.year}\\)`)); | |||
let metaInfo = split[split.length - 1]; | |||
let title = split.length > 1 ? "" : movie.title; // No need to check title in parsing if split correctly | |||
return <ITorrentMetaInfo>{ | |||
containsOtherLanguage: determineIfContainsOtherLanguages(torrentName, title), | |||
resolution: determineResolution(metaInfo, title), | |||
releaseType: determineReleaseType(metaInfo, title), | |||
} | |||
} | |||
/** | |||
* Examine the torrent name for language indicators | |||
*/ | |||
function determineIfContainsOtherLanguages(torrentName: string, title: string) { | |||
let matches = torrentName.match(/\b(?:Hindi|Telugu|Ita|Italian|Spanish|Latino|Russian|Arabic|Dual|Multi)\b/gi); | |||
for (let match of matches ?? []) { | |||
if (title.indexOf(match) == -1) { | |||
return true; | |||
} | |||
} | |||
return false; | |||
} | |||
/** | |||
* Interpret the resolution string as an enum value | |||
*/ | |||
function resolutionFromString(resolution: string) { | |||
switch(resolution.toUpperCase()) { | |||
case "4K": | |||
case "UHD": | |||
case "2160": | |||
return Resolution.HD4k; | |||
case "1080": | |||
return Resolution.HD1080; | |||
case "720": | |||
return Resolution.HD720; | |||
case "480": | |||
return Resolution.SD480; | |||
case "384": | |||
return Resolution.SD384; | |||
case "360": | |||
return Resolution.SD360; | |||
default: | |||
return Resolution.Unknown; | |||
} | |||
} | |||
/** | |||
* Determine the video resolution of the torrent | |||
*/ | |||
function determineResolution(torrentName: string, title: string) { | |||
let matches = torrentName.match(/\b(?:2160|1080|720|480|384|360)p?|UltraHD|UHD|4K\b/gi); | |||
if (matches == null) { | |||
return Resolution.Unknown; | |||
} | |||
let resolution = matches[matches.length - 1]; | |||
// Make sure what was matched is not part of the title... | |||
if (matches.length == 1 && title.indexOf(resolution) != -1) { | |||
return Resolution.Unknown; | |||
} | |||
return resolutionFromString(resolution.replace(/p$/i, "")); | |||
} | |||
/** | |||
* Determine the release type of the torrent | |||
*/ | |||
function determineReleaseType(torrentName: string, title: string) { | |||
let releaseTypeRegex: {[type: string]: RegExp} = { | |||
[ReleaseType.BluRay]: /\b(?:BR|Blu-Ray|BluRay|BDRip|BRRip|BDMV|BDR|BD25|BD50|BD5|BD9)\b/i, | |||
[ReleaseType.WebDl] : /\b(?:WEB.?DL|WEB-DLRip)\b/i, | |||
[ReleaseType.WebRip]: /\b(?:WEB.?Rip|WEB)\b/i, | |||
[ReleaseType.WebCap]: /\bWEB.?Cap\b/i, | |||
[ReleaseType.HDRip] : /\b(?:HC|HD.?Rip)\b/i, | |||
[ReleaseType.DVDR] : /\bDVD.?R|DVD-Full|Full-Rip|DVD.?5|DVD.?9\b/i, | |||
[ReleaseType.DVDRip]: /\bDVD.?Rip|DVD.?Mux/i, | |||
[ReleaseType.HDCAM] : /\b(?:TRUE|HD)CAM\b/i, | |||
[ReleaseType.CAM] : /\bCAM.?Rip\b/i, | |||
}; | |||
let matches: RegExpMatchArray | null; | |||
for (let type in releaseTypeRegex) { | |||
matches = torrentName.match(releaseTypeRegex[type]); | |||
if (!matches) { | |||
continue; | |||
} | |||
if (matches.length == 1 || title.indexOf(matches[matches.length - 1]) == -1) { | |||
return <ReleaseType>parseInt(type); | |||
} | |||
} | |||
return ReleaseType.Unknown; | |||
} |
@ -0,0 +1,60 @@ | |||
import { IMovieSearchInfo } from "@autoplex-api/torrent-search"; | |||
import Torrent from "../Torrent"; | |||
/** | |||
* Media type flags | |||
*/ | |||
export enum MediaType { | |||
None = 0x0, | |||
Movies = 0x1, | |||
TvShows = 0x2 | |||
} | |||
export default abstract class Provider | |||
{ | |||
/** | |||
* Indicate what media types the provider supports | |||
*/ | |||
public abstract readonly PROVIDES: MediaType; | |||
/** | |||
* Search for movies | |||
*/ | |||
public abstract searchMovie(movie: IMovieSearchInfo): Promise<Torrent[]>; | |||
// --------------------------------------------------------------------------------------------- | |||
/** | |||
* Indicate if the provider is currently searching for a torrent | |||
*/ | |||
protected isSearching = false; | |||
/** | |||
* Maintain a queue of search requests | |||
*/ | |||
protected searchQueue: (() => void)[] = []; | |||
/** | |||
* Lock the search | |||
*/ | |||
protected async lock() { | |||
if (this.isSearching) { | |||
await new Promise<void>(resolve => this.searchQueue.push(resolve)); | |||
} | |||
this.isSearching = true; | |||
} | |||
/** | |||
* Unlock the search | |||
*/ | |||
protected unlock(delay: number = 0) { | |||
setTimeout(() => { | |||
let next = this.searchQueue.splice(0, 1)[0]; | |||
if (next === undefined) { | |||
this.isSearching = false; | |||
return; | |||
} | |||
next(); | |||
}, delay); | |||
} | |||
} |
@ -0,0 +1,14 @@ | |||
import Provider, { MediaType } from "./Provider"; | |||
import TorrentGalaxy from "./torrentgalaxy"; | |||
/** | |||
* Export all available torrent providers | |||
*/ | |||
export const providers = { | |||
TorrentGalaxy | |||
}; | |||
/** | |||
* Export the abstract torrent provider class | |||
*/ | |||
export { Provider, MediaType } |
@ -0,0 +1,36 @@ | |||
import { IMovieSearchInfo } from "@autoplex-api/torrent-search"; | |||
import Provider, { MediaType } from "../Provider"; | |||
import Torrent from "../../Torrent"; | |||
import { search, Sort } from "./search"; | |||
/** | |||
* Throttle the torrent search | |||
*/ | |||
const THROTTLE_SEARCH = 3000; | |||
export default class TorrentGalaxy extends Provider | |||
{ | |||
/** | |||
* Indicate that this provider provides movies | |||
*/ | |||
public readonly PROVIDES = MediaType.Movies; | |||
/** | |||
* Search for a movie | |||
*/ | |||
public async searchMovie(movie: IMovieSearchInfo) { | |||
if (movie.imdbId === undefined) { | |||
return []; | |||
} | |||
await this.lock(); | |||
let torrents = await search(movie.imdbId, undefined, Sort.Seeders); | |||
this.unlock(THROTTLE_SEARCH); | |||
return torrents.torrents.map(torrent => new Torrent( | |||
movie, | |||
torrent.name, | |||
torrent.size, | |||
torrent.seeders, | |||
torrent.magnet | |||
)); | |||
} | |||
} |
@ -0,0 +1,136 @@ | |||
import type { Cheerio, CheerioAPI, Element } from "cheerio"; | |||
import cheerio from "cheerio"; | |||
import { request, convertToBytes } from "../../util"; | |||
const BASE_URL = "https://torrentgalaxy.mx/torrents.php?search="; | |||
export enum LanguageId { | |||
AllLanguages = 0, | |||
English = 1, | |||
French = 2, | |||
German = 3, | |||
Italian = 4, | |||
Japanese = 5, | |||
Spanish = 6, | |||
Russian = 7, | |||
Hindi = 8, | |||
OtherMultiple = 9, | |||
Korean = 10, | |||
Danish = 11, | |||
Norwegian = 12, | |||
Dutch = 13, | |||
Chinese = 14, | |||
Portuguese = 15, | |||
Bengali = 16, | |||
Polish = 17, | |||
Turkish = 18, | |||
Telugu = 19, | |||
Urdu = 20, | |||
Arabic = 21, | |||
Swedish = 22, | |||
Romanian = 23, | |||
Thai = 24 | |||
} | |||
export enum Language { | |||
AllLanguages ="AllLanguages", | |||
English ="English", | |||
French ="French", | |||
German ="German", | |||
Italian ="Italian", | |||
Japanese ="Japanese", | |||
Spanish ="Spanish", | |||
Russian ="Russian", | |||
Hindi ="Hindi", | |||
OtherMultiple ="OtherMultiple", | |||
Korean ="Korean", | |||
Danish ="Danish", | |||
Norwegian ="Norwegian", | |||
Dutch ="Dutch", | |||
Chinese ="Chinese", | |||
Portuguese ="Portuguese", | |||
Bengali ="Bengali", | |||
Polish ="Polish", | |||
Turkish ="Turkish", | |||
Telugu ="Telugu", | |||
Urdu ="Urdu", | |||
Arabic ="Arabic", | |||
Swedish ="Swedish", | |||
Romanian ="Romanian", | |||
Thai ="Thai" | |||
} | |||
export enum Category { | |||
Documentaries = 9, | |||
MoviesHD = 42, | |||
MoviesSD = 1, | |||
Movies4K = 3, | |||
MoviesPacks = 4, | |||
TVEpisodesHD = 41, | |||
TVEPisodesSD = 5, | |||
TVPacks = 6, | |||
TVSports = 7 | |||
} | |||
export enum Sort { | |||
Date = "id", | |||
Name = "name", | |||
Size = "size", | |||
Seeders = "seeders" | |||
} | |||
export enum SortOrder { | |||
Asc = "asc", | |||
Desc = "desc", | |||
} | |||
interface ITorrentGalaxyTorrent { | |||
category: number, | |||
language: Language, | |||
name : string, | |||
magnet : string, | |||
size : number, | |||
seeders : number, | |||
leechers: number | |||
} | |||
interface ITorrentGalaxyResults { | |||
torrents: ITorrentGalaxyTorrent[], | |||
total_results: number | |||
} | |||
function scrapeRow($: CheerioAPI, row: Cheerio<Element>): ITorrentGalaxyTorrent { | |||
let children = row.children(); | |||
let category = <string>$(children[0]).find("a").attr("href")?.split("cat=")[1]; | |||
let language = <Language>$(children[2]).find("img[title]").attr("title"); | |||
let name = $(children[3]).text(); | |||
let magnet = <string>$(children[4]).find("a[href^='magnet']").first().attr("href"); | |||
let [size, unit] = $(children[7]).text().split(" "); | |||
let [seeders, leechers] = $(children[10]).text().slice(1, -1).split('/').map(v => parseInt(v)); | |||
return { | |||
category: parseInt(category), | |||
size: convertToBytes(parseFloat(size), unit), | |||
language, name, magnet, seeders, leechers | |||
} | |||
} | |||
function scrapeResults(response: string): ITorrentGalaxyResults { | |||
let torrents: ITorrentGalaxyTorrent[] = []; | |||
let $ = cheerio.load(response); | |||
$(".tgxtable .tgxtablerow").each((_, elem) => { | |||
torrents.push(scrapeRow($, $(elem))); | |||
}); | |||
return { | |||
torrents, | |||
total_results: parseInt($("#filterbox2 > span").text()) | |||
}; | |||
} | |||
/** | |||
* Supports IMDb links too | |||
*/ | |||
export async function search(query: string, language: LanguageId = LanguageId.AllLanguages, sort: Sort = Sort.Date, order: SortOrder = SortOrder.Desc) { | |||
let res = await request(`${BASE_URL}${encodeURI(query)}&lang=${language}&sort=${sort}&order=${order}`); | |||
let results = scrapeResults(res); | |||
return results; | |||
} |
@ -0,0 +1,58 @@ | |||
import { Resolution } from "./parsing"; | |||
import Torrent from "./Torrent"; | |||
/** | |||
* Rank a list of torrents from best to worst to download | |||
*/ | |||
export function rankTorrents(torrents: Torrent[]) { | |||
torrents.sort(sortCompare); | |||
let preferred = torrents.filter(selectPreferredTorrents); | |||
return preferred; | |||
} | |||
/** | |||
* Filter out unwanted torrents | |||
*/ | |||
function selectPreferredTorrents(torrent: Torrent) { | |||
if (torrent.seeders == 0 || torrent.metadata.containsOtherLanguage) { | |||
return false; | |||
} | |||
if (torrent.metadata.resolution == Resolution.HD4k) { | |||
return torrent.size != null && torrent.size < 10*1024*1024*1024; // 10GB | |||
} | |||
return true; | |||
} | |||
/** | |||
* A comparator for ranking torrents | |||
* | |||
* @param a Left side | |||
* @param b Right side | |||
*/ | |||
function sortCompare(a: Torrent, b: Torrent) { | |||
// Languages | |||
let languageCmp = <any>a.metadata.containsOtherLanguage - <any>b.metadata.containsOtherLanguage; | |||
if (languageCmp !== 0) { | |||
return languageCmp; | |||
} | |||
// Resolution | |||
let resolutionCmp = a.metadata.resolution - b.metadata.resolution; | |||
if (resolutionCmp !== 0) { | |||
return resolutionCmp; | |||
} | |||
// If one has only a few seeds, don't worry about the other info. Prioritize seed count | |||
if (a.seeders < 5 || b.seeders < 5) { | |||
let seedersCmp = b.seeders - a.seeders; | |||
if (seedersCmp != 0) { | |||
return seedersCmp; | |||
} | |||
} | |||
// Sort by the file size | |||
let fileSizeCmp = (a.size ?? 0) - (b.size ?? 0); | |||
if (fileSizeCmp !== 0) { | |||
return fileSizeCmp; | |||
} | |||
return 0; | |||
} |
@ -0,0 +1,82 @@ | |||
import { parseString } from "xml2js"; | |||
import https from "https"; | |||
/** | |||
* Perform an RSS/XML request | |||
*/ | |||
export function rssRequest<T = any>(url: string) { | |||
return new Promise<T>((resolve, reject) => { | |||
https.get(url, { headers: { "User-Agent": "Node", "Accept": "application/rss+xml" } }, (response) => { | |||
if (response.statusCode !== 200) { | |||
reject("Status error: " + response.statusCode); | |||
return; | |||
} | |||
response.setEncoding("utf-8"); | |||
let body = ""; | |||
response.on("data", (chunk) => body += chunk); | |||
response.on("end", () => parseString(body, (err, result) => { | |||
if (err) { | |||
reject(err); | |||
return; | |||
} | |||
resolve(result); | |||
})); | |||
}); | |||
}); | |||
} | |||
/** | |||
* Perform a generic GET request | |||
*/ | |||
export function jsonRequest<T = any>(url: string) { | |||
return new Promise<T>((resolve, reject) => { | |||
https.get(url, { headers: { "User-Agent": "Node", "Accept": "*/*" } }, (response) => { | |||
if (response.statusCode !== 200) { | |||
reject("Status error: " + response.statusCode); | |||
return; | |||
} | |||
response.setEncoding("utf-8"); | |||
let body = ""; | |||
response.on("data", (chunk) => body += chunk); | |||
response.on("end", () => resolve(JSON.parse(body))); | |||
}); | |||
}); | |||
} | |||
/** | |||
* Perform a generic GET request | |||
*/ | |||
export function request(url: string, timeout: number = 10000) { | |||
return new Promise<string>((resolve, reject) => { | |||
https.get(url, { headers: { "User-Agent": "Node", "Accept": "*/*" }, timeout }, (response) => { | |||
if (response.statusCode !== 200) { | |||
reject("Status error: " + response.statusCode); | |||
return; | |||
} | |||
response.setEncoding("utf-8"); | |||
let body = ""; | |||
response.on("data", (chunk) => body += chunk); | |||
response.on("end", () => resolve(body)); | |||
}).on("timeout", () => reject("timeout")); | |||
}); | |||
} | |||
export function sleep(ms: number) { | |||
return new Promise(resolve => setTimeout(resolve, ms)); | |||
} | |||
export function convertToBytes(size: number, unit: string, throwUnknownUnit: boolean = true) { | |||
switch(unit.toUpperCase()) { | |||
case "GB": | |||
return Math.ceil(size*1024*1024*1024); | |||
case "MB": | |||
return Math.ceil(size*1024*1024); | |||
case "KB": | |||
return Math.ceil(size*1024*1024); | |||
default: | |||
if (throwUnknownUnit) { | |||
throw new Error("Unknown unit provided"); | |||
} | |||
return Math.ceil(size); | |||
} | |||
} |
@ -0,0 +1,9 @@ | |||
{ | |||
"extends": "../../tsconfig.json", | |||
"compilerOptions": { | |||
"sourceMap": true, /* Generates corresponding '.map' file. */ | |||
"outDir": "./dist", /* Redirect output structure to the directory. */ | |||
"typeRoots": ["./src/typings"], /* List of folders to include type definitions from. */ | |||
"sourceRoot": "./src" /* Specify the location where debugger should locate TypeScript files instead of source locations. */ | |||
} | |||
} |