@ -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. */ | |||||
} | |||||
} |