@ -0,0 +1,8 @@ | |||
DB_TYPE = mysql | |||
DB_HOST = database | |||
DB_PORT = 3306 | |||
DB_USER = root | |||
DB_PASSWORD_FILE = /run/secrets/mysql_root_password | |||
DB_DATABASE = autoplex_torrent | |||
DEFAULT_STORAGE_PATH = ./.data |
@ -0,0 +1,7 @@ | |||
{ | |||
"cli": { | |||
"entitiesDir": "src/database/entities", | |||
"migrationsDir": "src/database/migrations", | |||
"subscribersDir": "src/database/subscribers" | |||
} | |||
} |
@ -0,0 +1,38 @@ | |||
import { Equal } from "typeorm"; | |||
import connectToDatabase from "./database"; | |||
import TorrentClient from "./services/TorrentClient"; | |||
export default class Application | |||
{ | |||
/** | |||
* The torrent client instance | |||
*/ | |||
private __client: TorrentClient; | |||
/** | |||
* Create the application | |||
*/ | |||
public constructor() { | |||
this.__client = new TorrentClient(); | |||
} | |||
/** | |||
* Boot the application services | |||
*/ | |||
private async boot() { | |||
await connectToDatabase(); | |||
await this.__client.boot(); | |||
} | |||
/** | |||
* Start and run the application | |||
*/ | |||
public async exec() { | |||
await this.boot(); | |||
console.log("Torrent client ready"); | |||
} | |||
public get client() { | |||
return this.__client; | |||
} | |||
} |
@ -0,0 +1,73 @@ | |||
import { Entity, PrimaryGeneratedColumn, Column, BaseEntity, ManyToOne } from "typeorm"; | |||
import WebTorrent from "webtorrent-hybrid"; | |||
@Entity() | |||
export default class Torrent extends BaseEntity | |||
{ | |||
static fromWebTorrent(torrent: WebTorrent.Torrent) { | |||
let entity = new Torrent(); | |||
entity.infoHash = torrent.infoHash; | |||
entity.torrentFile = torrent.torrentFile; | |||
entity.setSelectedFiles(torrent.files); | |||
entity.downloadPath = torrent.path; | |||
return entity; | |||
} | |||
@PrimaryGeneratedColumn() | |||
id!: number; | |||
@Column() | |||
infoHash!: string; | |||
@Column("blob") | |||
torrentFile!: Buffer; | |||
@Column({nullable: true}) | |||
selectOnly?: string; | |||
@Column() | |||
downloadPath!: string; | |||
/** | |||
* Get the list of selected files | |||
*/ | |||
selectedFiles() { | |||
let result: number[] = []; | |||
for (let range of (this.selectOnly ?? "").split(',')) { | |||
// @TODO Check this map function... it's weird | |||
let [index, end] = range.split('-').map(num => parseInt(num)); | |||
do { | |||
result.push(index); | |||
} while(index++ < end); | |||
} | |||
return result; | |||
} | |||
/** | |||
* Update the selected files from the torrent | |||
*/ | |||
setSelectedFiles(files: WebTorrent.TorrentFile[]) { | |||
let ranges: string[] = []; | |||
let offset: number | null = null; | |||
let range: number = 0; | |||
files.forEach((file, i) => { | |||
if (file.isSelected) { | |||
if (offset === null) { | |||
offset = i; | |||
range = 0; | |||
} else { | |||
range++; | |||
} | |||
} else { | |||
if (offset !== null) { | |||
ranges.push(offset + (range > 0 ? `-${offset + range}` : "")); | |||
offset = null; | |||
} | |||
} | |||
}); | |||
if (offset !== null) { | |||
ranges.push(offset + (range > 0 ? `-${offset + range}` : "")); | |||
} | |||
this.selectOnly = ranges.join(','); | |||
} | |||
} |
@ -0,0 +1,5 @@ | |||
import Torrent from "./Torrent"; | |||
export default [ | |||
Torrent | |||
]; |
@ -0,0 +1,17 @@ | |||
import { readFileSync } from "fs"; | |||
import { createConnection } from "typeorm"; | |||
import entities from "./entities"; | |||
export default async function connectToDatabase() { | |||
return createConnection({ | |||
type : <"mysql" | "mariadb">process.env["DB_TYPE"], | |||
host : process.env["DB_HOST"], | |||
port : parseInt(<string>process.env["DB_PORT"]), | |||
username : process.env["DB_USER"], | |||
password : readFileSync(<string>process.env["DB_PASSWORD_FILE"]).toString().trim(), | |||
database : "autoplex_torrent", | |||
synchronize: process.env["NODE_ENV"] != "production", | |||
entities, | |||
migrations: ["src/migrations/*.ts"] | |||
}); | |||
} |
@ -0,0 +1,7 @@ | |||
import Application from "./Application"; | |||
// Create the application | |||
let app = new Application(); | |||
// Execute the app | |||
app.exec(); |
@ -0,0 +1,199 @@ | |||
import assert from "assert"; | |||
import MagnetUri from "magnet-uri"; | |||
import WebTorrent from "webtorrent-hybrid"; | |||
import parseTorrent from "parse-torrent"; | |||
import { extname, join, sep } from "path"; | |||
import Torrent from "../database/entities/Torrent"; | |||
import rimraf from "rimraf"; | |||
interface AddOptions { | |||
downloadPath?: string; | |||
extensions?: string | string[]; | |||
} | |||
export default class TorrentClient | |||
{ | |||
/** | |||
* The current WebTorrent instance (available after boot) | |||
*/ | |||
private __webtorrent!: WebTorrent.Instance; | |||
// --------------------------------------------------------------------------------------------- | |||
public async boot() { | |||
this.__webtorrent = new WebTorrent(); | |||
this.__webtorrent.on("error", error => this.onError(error)); | |||
this.__webtorrent.on("torrent", torrent => { | |||
torrent.on("done", () => this.onTorrentFinish(torrent)); | |||
// torrent.on("download", (...args) => this.onTorrentDownload(torrent, ...args)); | |||
// torrent.on("upload", (...args) => this.onTorrentUpload(torrent, ...args)); | |||
torrent.on("error", (...args) => this.onTorrentError(torrent, ...args)); | |||
torrent.on("noPeers", (...args) => this.onTorrentNoPeers(torrent, ...args)); | |||
torrent.on("wire", (...args) => this.onTorrentWire(torrent, ...args)); | |||
}); | |||
await this.loadTorrents(); | |||
} | |||
// Event Handling ------------------------------------------------------------------------------ | |||
protected onError(error: string | Error) { | |||
} | |||
protected onTorrentError(torrent: WebTorrent.Torrent, error: string | Error) { | |||
console.error(torrent.name, "had an error", error); | |||
Torrent.delete({ infoHash: torrent.infoHash }); | |||
} | |||
protected onTorrentFinish(torrent: WebTorrent.Torrent) { | |||
console.log(torrent.name, "finished"); | |||
} | |||
/** | |||
* @NOTE: Ignoring these two events for performance purposes | |||
*/ | |||
// protected onTorrentDownload(torrent: WebTorrent.Torrent, bytes: number) {} | |||
// protected onTorrentUpload(torrent: WebTorrent.Torrent, bytes: number) {} | |||
protected onTorrentNoPeers(torrent: WebTorrent.Torrent, announceTypes: "tracker" | "dht") { | |||
} | |||
protected onTorrentWire(torrent: WebTorrent.Torrent, wire: any, address: string | undefined) { | |||
} | |||
// Torrent Storage ----------------------------------------------------------------------------- | |||
/** | |||
* Load the torrents from the database | |||
*/ | |||
protected async loadTorrents() { | |||
let torrents = await Torrent.find(); | |||
for (let torrent of torrents) { | |||
let torrentInfo = parseTorrent(torrent.torrentFile); | |||
this.addTorrent(torrentInfo, torrent.downloadPath, torrent.selectedFiles()); | |||
} | |||
} | |||
/** | |||
* Delete a torrent from storage | |||
*/ | |||
protected async deleteTorrent(torrent: WebTorrent.Torrent) { | |||
return await Torrent.delete({ infoHash: torrent.infoHash }); | |||
} | |||
/** | |||
* Store a torrent in the database | |||
*/ | |||
protected async storeTorrent(torrent: WebTorrent.Torrent) { | |||
return await Torrent.fromWebTorrent(torrent).save(); | |||
} | |||
/** | |||
* Delete the download files from a torrent | |||
*/ | |||
protected async removeTorrentFiles(torrent: WebTorrent.Torrent) { | |||
return new Promise<void>((resolve, reject) => { | |||
let toRemove = new Set<string>(); | |||
torrent.files.forEach(file => { | |||
toRemove.add(file.path.split(sep)[0]); | |||
}); | |||
toRemove.forEach(path => { | |||
rimraf(join(torrent.path, path), (err) => { | |||
if (err) { | |||
return reject(err); | |||
} | |||
resolve(); | |||
}); | |||
}); | |||
}); | |||
} | |||
// Torrent Handling ---------------------------------------------------------------------------- | |||
/** | |||
* Add a torrent to the client | |||
*/ | |||
protected addTorrent(torrent: MagnetUri.Instance, downloadPath: string, selectOnly?: number[]) { | |||
// Select only: Select no files by default due to broken selection mechanism in Webtorrent | |||
torrent.so = selectOnly ?? []; | |||
return this.__webtorrent.add(torrent, { path: downloadPath }); | |||
} | |||
/** | |||
* Remove a torrent from the client | |||
*/ | |||
protected removeTorrent(torrent: WebTorrent.Torrent) { | |||
this.deleteTorrent(torrent); | |||
this.__webtorrent.remove(torrent); | |||
} | |||
/** | |||
* Select the initial list of files to download on a newly added torrent | |||
*/ | |||
protected filterSelectFiles(torrent: WebTorrent.Torrent, extensions?: string | string[]) { | |||
if (extensions === undefined) { | |||
torrent.files.forEach(file => file.select()); | |||
return; | |||
} | |||
let exts = new Set(typeof extensions === "string" ? extensions.split(',') : extensions); | |||
torrent.files.forEach(file => { | |||
if (exts.has(extname(file.path).slice(1))) { | |||
file.select(); | |||
} else { | |||
file.deselect(); | |||
} | |||
}); | |||
} | |||
// Torrent Client Interface -------------------------------------------------------------------- | |||
/** | |||
* Add a torrent to the client | |||
*/ | |||
public async add(info: string | Buffer, options: AddOptions = {}) { | |||
// Parse the torrent | |||
let torrentInfo = parseTorrent(info); | |||
assert(typeof torrentInfo.infoHash === "string"); | |||
// If the torrent already exists, skip it | |||
assert(this.__webtorrent.get(torrentInfo.infoHash) === null, "Torrent has already been added"); | |||
// Add the torrent to the client | |||
let torrent = this.addTorrent(torrentInfo, options.downloadPath ?? <string>process.env["DEFAULT_STORAGE_PATH"]); | |||
// When the metadata has beened fetched, select the files to download and store the torrent | |||
torrent.once("metadata", () => { | |||
this.filterSelectFiles(torrent, options.extensions); | |||
this.storeTorrent(torrent); | |||
console.log(torrent.path); | |||
}); | |||
return torrent; | |||
} | |||
/** | |||
* Remove a torrent from the client | |||
*/ | |||
public async remove(info: string | Buffer, withData: false) { | |||
// Parse the torrent | |||
let torrentInfo = parseTorrent(info); | |||
assert(typeof torrentInfo.infoHash === "string"); | |||
// Get the torrent and ensure it exists it exists | |||
let torrent = this.__webtorrent.get(torrentInfo.infoHash); | |||
assert(torrent !== undefined, "Torrent has not been added"); | |||
// Remove the torrent | |||
this.removeTorrent(torrent); | |||
// Delete data if necessary | |||
if (withData) { | |||
this.removeTorrentFiles(torrent); | |||
} | |||
} | |||
} |
@ -0,0 +1,31 @@ | |||
declare const MagnetUri: MagnetUri.MagnetUri; | |||
declare module "magnet-uri" { | |||
declare namespace MagnetUri { | |||
interface MagnetUri { | |||
(uri: string): Instance; | |||
decode(uri: string): Instance; | |||
encode(parsed: Instance): string; | |||
} | |||
interface Instance extends Object { | |||
dn?: string | string[]; | |||
tr?: string | string[]; | |||
xs?: string | string[]; | |||
as?: string | string[]; | |||
ws?: string | string[]; | |||
kt?: string[]; | |||
ix?: number | number[]; | |||
xt?: string | string[]; | |||
so?: number[]; | |||
infoHash?: string; | |||
infoHashBuffer?: Buffer; | |||
name?: string | string[]; | |||
keywords?: string | string[]; | |||
announce?: string[]; | |||
urlList?: string[]; | |||
} | |||
} | |||
export = MagnetUri; | |||
} |
@ -0,0 +1,38 @@ | |||
import * as glob from "glob"; | |||
import * as fs from "fs"; | |||
declare function rimraf(path: string, options: rimraf.Options, callback: (error?: Error) => void): void; | |||
declare function rimraf(path: string, callback: (error?: Error) => void): void; | |||
declare namespace rimraf { | |||
/** | |||
* It can remove stuff synchronously, too. | |||
* But that's not so good. Use the async API. | |||
* It's better. | |||
*/ | |||
function sync(path: string, options?: Options): void; | |||
/** | |||
* see {@link https://github.com/isaacs/rimraf/blob/79b933fb362b2c51bedfa448be848e1d7ed32d7e/README.md#options} | |||
*/ | |||
interface Options { | |||
maxBusyTries?: number; | |||
emfileWait?: number; | |||
/** @default false */ | |||
disableGlob?: boolean; | |||
glob?: glob.IOptions | false; | |||
unlink?: typeof fs.unlink; | |||
chmod?: typeof fs.chmod; | |||
stat?: typeof fs.stat; | |||
lstat?: typeof fs.lstat; | |||
rmdir?: typeof fs.rmdir; | |||
readdir?: typeof fs.readdir; | |||
unlinkSync?: typeof fs.unlinkSync; | |||
chmodSync?: typeof fs.chmodSync; | |||
statSync?: typeof fs.statSync; | |||
lstatSync?: typeof fs.lstatSync; | |||
rmdirSync?: typeof fs.rmdirSync; | |||
readdirSync?: typeof fs.readdirSync; | |||
} | |||
} | |||
export = rimraf; |
@ -0,0 +1,232 @@ | |||
// declare module "webtorrent-hybrid" { | |||
// declare class WebTorrent { | |||
// } | |||
// declare const WebTorrent: WebTorrent.WebTorrent; | |||
// declare namespace WebTorrent { | |||
// interface WebTorrent { | |||
// new (config?: Options): Instance; | |||
// (config?: Options): Instance; | |||
// WEBRTC_SUPPORT: boolean; | |||
// } | |||
// } | |||
// export = WebTorrent; | |||
// } | |||
// Type definitions for WebTorrent 0.109 | |||
// Project: https://github.com/feross/webtorrent, https://webtorrent.io | |||
// Definitions by: Bazyli Brzóska <https://github.com/niieani> | |||
// Tomasz Łaziuk <https://github.com/tlaziuk> | |||
// Gabriel Juchault <https://github.com/gjuchault> | |||
// Adam Crowder <https://github.com/cheeseandcereal> | |||
// Definitions: https://github.com/DefinitelyTyped/DefinitelyTyped | |||
/// <reference types="node" /> | |||
declare module "webtorrent-hybrid" { | |||
import { Instance as MagnetUri } from 'magnet-uri'; | |||
import { Instance as ParseTorrent } from 'parse-torrent'; | |||
import { Instance as SimplePeer } from 'simple-peer'; | |||
import { RequestOptions, Server } from 'http'; | |||
import { Wire } from 'bittorrent-protocol'; | |||
declare const WebTorrent: WebTorrent.WebTorrent; | |||
declare namespace WebTorrent { | |||
interface WebTorrent { | |||
new (config?: Options): Instance; | |||
(config?: Options): Instance; | |||
WEBRTC_SUPPORT: boolean; | |||
} | |||
interface Options { | |||
maxConns?: number; | |||
nodeId?: string | Buffer; | |||
peerId?: string | Buffer; | |||
tracker?: boolean | {}; | |||
dht?: boolean | {}; | |||
webSeeds?: boolean; | |||
utp?: boolean; | |||
} | |||
interface TorrentOptions { | |||
announce?: any[]; | |||
getAnnounceOpts?(): void; | |||
maxWebConns?: number; | |||
path?: string; | |||
store?(chunkLength: number, storeOpts: { length: number, files: File[], torrent: Torrent, }): any; | |||
private?: boolean; | |||
} | |||
interface TorrentDestroyOptions { | |||
destroyStore?: boolean; | |||
} | |||
interface Instance extends NodeJS.EventEmitter { | |||
on(event: 'torrent', callback: (torrent: Torrent) => void): this; | |||
on(event: 'error', callback: (err: Error | string) => void): this; | |||
add(torrent: string | Buffer | File | ParseTorrent | MagnetUri, opts?: TorrentOptions, cb?: (torrent: Torrent) => any): Torrent; | |||
add(torrent: string | Buffer | File | ParseTorrent | MagnetUri, cb?: (torrent: Torrent) => any): Torrent; | |||
seed(input: string | string[] | File | File[] | FileList | Buffer | Buffer[] | NodeJS.ReadableStream | NodeJS.ReadableStream[], opts?: TorrentOptions, cb?: (torrent: Torrent) => any): Torrent; | |||
seed(input: string | string[] | File | File[] | FileList | Buffer | Buffer[] | NodeJS.ReadableStream | NodeJS.ReadableStream[], cb?: (torrent: Torrent) => any): Torrent; | |||
remove(torrentId: Torrent | string | Buffer, opts?: TorrentDestroyOptions, callback?: (err: Error | string) => void): void; | |||
destroy(callback?: (err: Error | string) => void): void; | |||
readonly torrents: Torrent[]; | |||
get(torrentId: Torrent | string | Buffer): Torrent | void; | |||
readonly downloadSpeed: number; | |||
readonly uploadSpeed: number; | |||
readonly progress: number; | |||
readonly ratio: number; | |||
} | |||
interface Torrent extends NodeJS.EventEmitter { | |||
readonly infoHash: string; | |||
readonly magnetURI: string; | |||
readonly torrentFile: Buffer; | |||
readonly torrentFileBlobURL: string; | |||
readonly files: TorrentFile[]; | |||
readonly announce: string[]; | |||
readonly pieces: Array<TorrentPiece | null>; | |||
readonly timeRemaining: number; | |||
readonly received: number; | |||
readonly downloaded: number; | |||
readonly uploaded: number; | |||
readonly downloadSpeed: number; | |||
readonly uploadSpeed: number; | |||
readonly progress: number; | |||
readonly ratio: number; | |||
readonly length: number; | |||
readonly pieceLength: number; | |||
readonly lastPieceLength: number; | |||
readonly numPeers: number; | |||
readonly path: string; | |||
readonly ready: boolean; | |||
readonly paused: boolean; | |||
readonly done: boolean; | |||
readonly name: string; | |||
readonly created: Date; | |||
readonly createdBy: string; | |||
readonly comment: string; | |||
readonly maxWebConns: number; | |||
destroy(opts?: TorrentDestroyOptions, cb?: (err: Error | string) => void): void; | |||
addPeer(peer: string | SimplePeer): boolean; | |||
addWebSeed(url: string): void; | |||
removePeer(peer: string | SimplePeer): void; | |||
select(start: number, end: number, priority?: number, notify?: () => void): void; | |||
deselect(start: number, end: number, priority: number): void; | |||
createServer(opts?: RequestOptions): Server; | |||
pause(): void; | |||
resume(): void; | |||
rescanFiles(callback?: (err: Error | string | null) => void): void; | |||
on(event: 'infoHash' | 'metadata' | 'ready' | 'done', callback: () => void): this; | |||
on(event: 'warning' | 'error', callback: (err: Error | string) => void): this; | |||
on(event: 'download' | 'upload', callback: (bytes: number) => void): this; | |||
on(event: 'wire', callback: (wire: Wire, addr?: string) => void): this; | |||
on(event: 'noPeers', callback: (announceType: 'tracker' | 'dht') => void): this; | |||
} | |||
interface TorrentFile extends NodeJS.EventEmitter { | |||
// Custom property to check if a file is selected | |||
readonly isSelected: boolean; | |||
readonly name: string; | |||
readonly path: string; | |||
readonly length: number; | |||
readonly downloaded: number; | |||
readonly progress: number; | |||
select(): void; | |||
deselect(): void; | |||
createReadStream(opts?: { start: number, end: number, }): NodeJS.ReadableStream; | |||
getBuffer(callback: (err: string | Error | undefined, buffer?: Buffer) => void): void; | |||
appendTo( | |||
rootElement: HTMLElement | string, | |||
opts?: { autoplay?: boolean, controls?: boolean, maxBlobLength?: number }, | |||
callback?: (err: Error | undefined, element: HTMLMediaElement) => void): void; | |||
appendTo(rootElement: HTMLElement | string, callback?: (err: Error | undefined, element: HTMLMediaElement) => void): void; | |||
renderTo( | |||
rootElement: HTMLMediaElement | string, | |||
opts?: { autoplay?: boolean, controls?: boolean, maxBlobLength?: number }, | |||
callback?: (err: Error | undefined, element: HTMLMediaElement) => void): void; | |||
renderTo(rootElement: HTMLMediaElement | string, callback?: (err: Error | undefined, element: HTMLMediaElement) => void): void; | |||
getBlob(callback: (err: string | Error | undefined, blob?: Blob) => void): void; | |||
getBlobURL(callback: (err: string | Error | undefined, blobURL?: string) => void): void; | |||
} | |||
interface TorrentPiece { | |||
readonly length: number; | |||
readonly missing: number; | |||
} | |||
} | |||
export = WebTorrent; | |||
} | |||