@ -1,63 +0,0 @@ | |||
import { MovieTicket } from "@autoplex/database"; | |||
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: MovieTicket, 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.title ?? "", movie.year ?? undefined); | |||
} | |||
/** | |||
* 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};` | |||
} | |||
} |
@ -1,5 +0,0 @@ | |||
import TorrentGalaxy from "./providers/torrentgalaxy"; | |||
export { | |||
TorrentGalaxy | |||
} |
@ -1,153 +0,0 @@ | |||
/** | |||
* 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, title: string = "", year?: number) { | |||
// Split the meta info after the year if possible to make parsing more reliable | |||
let split = torrentName.split(new RegExp(`${year}|\\(${year}\\)`)); | |||
let metaInfo = split[split.length - 1]; | |||
title = split.length > 1 ? "" : 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; | |||
} |
@ -1,25 +0,0 @@ | |||
import { MovieTicket } from "@autoplex/database"; | |||
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 static readonly PROVIDES: MediaType = MediaType.None; | |||
/** | |||
* Search for movies | |||
*/ | |||
public abstract searchMovie(movie: MovieTicket): Promise<Torrent[]>; | |||
} |
@ -1,30 +0,0 @@ | |||
import { MovieTicket } from "@autoplex/database"; | |||
import Provider, { MediaType } from "../Provider"; | |||
import Torrent from "../../Torrent"; | |||
import { search, Sort } from "./search"; | |||
export default class TorrentGalaxy extends Provider | |||
{ | |||
/** | |||
* Indicate that this provider provides movies | |||
*/ | |||
public static readonly PROVIDES = MediaType.Movies; | |||
/** | |||
* Search for a movie | |||
*/ | |||
public async searchMovie(movie: MovieTicket) { | |||
if (movie.imdbId === null) { | |||
return []; | |||
} | |||
let torrents = await search(movie.imdbId, undefined, Sort.Seeders); | |||
return torrents.torrents.map(torrent => new Torrent( | |||
movie, | |||
torrent.name, | |||
torrent.size, | |||
torrent.seeders, | |||
torrent.magnet | |||
)); | |||
} | |||
} |
@ -1,135 +0,0 @@ | |||
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($: cheerio.Root, row: cheerio.Cheerio): 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; | |||
} |
@ -1,58 +0,0 @@ | |||
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; | |||
} |
@ -1,82 +0,0 @@ | |||
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); | |||
} | |||
} |