From 26c9987355e844a02647a09ab7092dfbf56fb962 Mon Sep 17 00:00:00 2001 From: Jesse Chan Date: Sat, 26 Dec 2020 11:50:16 +0800 Subject: [PATCH] server: support initial seeding (aka superseeding) https://www.bittorrent.org/beps/bep_0016.html --- server/routes/api/torrents.ts | 41 +++++++++++++++++-- .../Transmission/clientGatewayService.ts | 5 +++ server/services/feedService.ts | 1 + .../interfaces/clientGatewayService.ts | 9 ++++ .../qBittorrent/clientGatewayService.ts | 12 +++++- .../qBittorrent/clientRequestManager.ts | 16 ++++++++ .../services/rTorrent/clientGatewayService.ts | 31 ++++++++++++++ .../methodCallConfigs/torrentList.ts | 6 +++ shared/schema/api/torrents.ts | 4 ++ shared/types/Torrent.ts | 2 + shared/types/api/torrents.ts | 10 +++++ 11 files changed, 132 insertions(+), 5 deletions(-) diff --git a/server/routes/api/torrents.ts b/server/routes/api/torrents.ts index d57ed8c0..3abb6ebf 100644 --- a/server/routes/api/torrents.ts +++ b/server/routes/api/torrents.ts @@ -19,6 +19,7 @@ import type { DeleteTorrentsOptions, MoveTorrentsOptions, SetTorrentContentsPropertiesOptions, + SetTorrentsInitialSeedingOptions, SetTorrentsPriorityOptions, SetTorrentsSequentialOptions, SetTorrentsTrackersOptions, @@ -121,7 +122,17 @@ router.post('/add-urls', async (req, r return; } - const {urls, cookies, destination, tags, isBasePath, isCompleted, isSequential, start} = parsedResult.data; + const { + urls, + cookies, + destination, + tags, + isBasePath, + isCompleted, + isSequential, + isInitialSeeding, + start, + } = parsedResult.data; const finalDestination = await getDestination(req.services, { destination, @@ -142,6 +153,7 @@ router.post('/add-urls', async (req, r isBasePath: isBasePath ?? false, isCompleted: isCompleted ?? false, isSequential: isSequential ?? false, + isInitialSeeding: isInitialSeeding ?? false, start: start ?? false, }) .then((response) => { @@ -173,7 +185,7 @@ router.post('/add-files', async (req, return; } - const {files, destination, tags, isBasePath, isCompleted, isSequential, start} = parsedResult.data; + const {files, destination, tags, isBasePath, isCompleted, isSequential, isInitialSeeding, start} = parsedResult.data; const finalDestination = await getDestination(req.services, { destination, @@ -193,6 +205,7 @@ router.post('/add-files', async (req, isBasePath: isBasePath ?? false, isCompleted: isCompleted ?? false, isSequential: isSequential ?? false, + isInitialSeeding: isInitialSeeding ?? false, start: start ?? false, }) .then((response) => { @@ -215,7 +228,7 @@ router.post('/add-files', async (req, * @return {Error} 500 - failure response - application/json */ router.post('/create', async (req, res) => { - const {name, sourcePath, trackers, comment, infoSource, isPrivate, tags, start} = req.body; + const {name, sourcePath, trackers, comment, infoSource, isPrivate, isInitialSeeding, tags, start} = req.body; const callback = getResponseFn(res); if (typeof sourcePath !== 'string') { @@ -265,6 +278,7 @@ router.post('/create', async (req, res) isBasePath: true, isCompleted: true, isSequential: false, + isInitialSeeding: isInitialSeeding ?? false, start: start || false, }) .catch(() => { @@ -411,6 +425,27 @@ router.post('/delete', (req, res) => { }); }); +/** + * PATCH /api/torrents/initial-seeding + * @summary Sets initial seeding mode of torrents. + * @tags Torrent + * @security User + * @param {SetTorrentsInitialSeedingOptions} request.body.required - options - application/json + * @return {object} 200 - success response - application/json + * @return {Error} 500 - failure response - application/json + */ +router.patch('/initial-seeding', (req, res) => { + req.services?.clientGatewayService?.setTorrentsInitialSeeding(req.body).then( + (response) => { + req.services?.torrentService.fetchTorrentList(); + res.status(200).json(response); + }, + (err) => { + res.status(500).json(err); + }, + ); +}); + /** * PATCH /api/torrents/priority * @summary Sets priority of torrents. diff --git a/server/services/Transmission/clientGatewayService.ts b/server/services/Transmission/clientGatewayService.ts index c56ffc83..35c4d307 100644 --- a/server/services/Transmission/clientGatewayService.ts +++ b/server/services/Transmission/clientGatewayService.ts @@ -220,6 +220,10 @@ class TransmissionClientGatewayService extends ClientGatewayService { .then(this.processClientRequestSuccess, this.processClientRequestError); } + async setTorrentsInitialSeeding(): Promise { + throw new Error('Transmission does not support this feature.'); + } + async setTorrentsPriority({hashes, priority}: SetTorrentsPriorityOptions): Promise { let transmissionPriority = TransmissionPriority.TR_PRI_NORMAL; @@ -368,6 +372,7 @@ class TransmissionClientGatewayService extends ClientGatewayService { upTotal: torrent.uploadedEver, eta: torrent.eta, isPrivate: torrent.isPrivate, + isInitialSeeding: false, isSequential: false, message: torrent.errorString, peersConnected: torrent.peersGettingFromUs, diff --git a/server/services/feedService.ts b/server/services/feedService.ts index 4b2b9edd..cdb01315 100644 --- a/server/services/feedService.ts +++ b/server/services/feedService.ts @@ -257,6 +257,7 @@ class FeedService extends BaseService { isBasePath: false, isCompleted: false, isSequential: false, + isInitialSeeding: false, }) .then(() => { this.db.update({_id: feedID}, {$inc: {count: 1}}, {upsert: true}); diff --git a/server/services/interfaces/clientGatewayService.ts b/server/services/interfaces/clientGatewayService.ts index 5161d5e5..808eee46 100644 --- a/server/services/interfaces/clientGatewayService.ts +++ b/server/services/interfaces/clientGatewayService.ts @@ -8,6 +8,7 @@ import type { DeleteTorrentsOptions, MoveTorrentsOptions, SetTorrentContentsPropertiesOptions, + SetTorrentsInitialSeedingOptions, SetTorrentsPriorityOptions, SetTorrentsSequentialOptions, SetTorrentsTrackersOptions, @@ -100,6 +101,14 @@ abstract class ClientGatewayService extends BaseService; + /** + * Sets initial seeding mode of torrents + * + * @param {SetTorrentsInitialSeedingOptions} options - An object of options... + * @return {Promise} - Rejects with error. + */ + abstract setTorrentsInitialSeeding(options: SetTorrentsInitialSeedingOptions): Promise; + /** * Sets priority of torrents * diff --git a/server/services/qBittorrent/clientGatewayService.ts b/server/services/qBittorrent/clientGatewayService.ts index e0e1fc46..329e1735 100644 --- a/server/services/qBittorrent/clientGatewayService.ts +++ b/server/services/qBittorrent/clientGatewayService.ts @@ -8,6 +8,7 @@ import type { DeleteTorrentsOptions, MoveTorrentsOptions, SetTorrentContentsPropertiesOptions, + SetTorrentsInitialSeedingOptions, SetTorrentsPriorityOptions, SetTorrentsSequentialOptions, SetTorrentsTrackersOptions, @@ -48,7 +49,7 @@ class QBittorrentClientGatewayService extends ClientGatewayService { isSequential, start, }: Required): Promise { - // TODO: isCompleted not implemented + // TODO: isCompleted and isInitialSeeding not implemented const fileBuffers = files.map((file) => { return Buffer.from(file, 'base64'); @@ -74,7 +75,7 @@ class QBittorrentClientGatewayService extends ClientGatewayService { isSequential, start, }: Required): Promise { - // TODO: isCompleted not implemented + // TODO: isCompleted and isInitialSeeding not implemented return this.clientRequestManager .torrentsAddURLs(urls, { @@ -184,6 +185,12 @@ class QBittorrentClientGatewayService extends ClientGatewayService { .then(this.processClientRequestSuccess, this.processClientRequestError); } + async setTorrentsInitialSeeding({hashes, isInitialSeeding}: SetTorrentsInitialSeedingOptions): Promise { + return this.clientRequestManager + .torrentsSetSuperSeeding(hashes, isInitialSeeding) + .then(this.processClientRequestSuccess, this.processClientRequestError); + } + async setTorrentsPriority({hashes, priority}: SetTorrentsPriorityOptions): Promise { // TODO: qBittorrent uses queue and priority here has a different meaning switch (priority) { @@ -318,6 +325,7 @@ class QBittorrentClientGatewayService extends ClientGatewayService { eta: info.eta >= 8640000 ? -1 : info.eta, hash: info.hash, isPrivate, + isInitialSeeding: info.super_seeding, isSequential: info.seq_dl, message: '', // in tracker method name: info.name, diff --git a/server/services/qBittorrent/clientRequestManager.ts b/server/services/qBittorrent/clientRequestManager.ts index bd286b06..3ab8d367 100644 --- a/server/services/qBittorrent/clientRequestManager.ts +++ b/server/services/qBittorrent/clientRequestManager.ts @@ -299,6 +299,22 @@ class ClientRequestManager { } } + async torrentsSetSuperSeeding(hashes: Array, value: boolean): Promise { + if (hashes.length > 0) { + return axios + .get(`${this.apiBase}/torrents/setSuperSeeding`, { + params: { + hashes: hashes.join('|'), + value: value ? 'true' : 'false', + }, + headers: {Cookie: await this.authCookie}, + }) + .then(() => { + // returns nothing + }); + } + } + async torrentsToggleSequentialDownload(hashes: Array): Promise { if (hashes.length > 0) { return axios diff --git a/server/services/rTorrent/clientGatewayService.ts b/server/services/rTorrent/clientGatewayService.ts index 4f18da5d..962addd2 100644 --- a/server/services/rTorrent/clientGatewayService.ts +++ b/server/services/rTorrent/clientGatewayService.ts @@ -14,6 +14,7 @@ import type { DeleteTorrentsOptions, MoveTorrentsOptions, SetTorrentContentsPropertiesOptions, + SetTorrentsInitialSeedingOptions, SetTorrentsPriorityOptions, SetTorrentsSequentialOptions, SetTorrentsTrackersOptions, @@ -66,6 +67,7 @@ class RTorrentClientGatewayService extends ClientGatewayService { isBasePath, isCompleted, isSequential, + isInitialSeeding, start, }: Required): Promise { const torrentPaths = await Promise.all( @@ -85,6 +87,7 @@ class RTorrentClientGatewayService extends ClientGatewayService { isBasePath, isCompleted, isSequential, + isInitialSeeding, start, }); } @@ -100,6 +103,7 @@ class RTorrentClientGatewayService extends ClientGatewayService { isBasePath, isCompleted, isSequential, + isInitialSeeding, start, }: Required): Promise { await fs.promises.mkdir(destination, {recursive: true}); @@ -156,6 +160,10 @@ class RTorrentClientGatewayService extends ClientGatewayService { additionalCalls.push(`d.down.sequential.set=1`); } + if (isInitialSeeding) { + additionalCalls.push(`d.connection_seed.set=initial_seed`); + } + return { methodName: start ? 'load.start' : 'load.normal', params: ['', torrentPath].concat(additionalCalls), @@ -420,6 +428,28 @@ class RTorrentClientGatewayService extends ClientGatewayService { ); } + async setTorrentsInitialSeeding({hashes, isInitialSeeding}: SetTorrentsInitialSeedingOptions): Promise { + const hashesToRestart: Array = hashes.filter( + (hash) => !this.services?.torrentService.getTorrent(hash).status.includes('stopped'), + ); + + await this.stopTorrents({hashes}); + + await this.clientRequestManager + .methodCall('system.multicall', [ + hashes.map((hash) => ({ + methodName: 'd.connection_seed.set', + params: [hash, isInitialSeeding ? 'initial_seed' : 'seed'], + })), + ]) + .then(this.processClientRequestSuccess, this.processClientRequestError) + .then(() => { + // returns nothing. + }); + + await this.startTorrents({hashes: hashesToRestart}); + } + async setTorrentsPriority({hashes, priority}: SetTorrentsPriorityOptions): Promise { const methodCalls = hashes.reduce((accumulator: MultiMethodCalls, hash) => { accumulator.push({ @@ -638,6 +668,7 @@ class RTorrentClientGatewayService extends ClientGatewayService { eta: getTorrentETAFromProperties(response), hash: response.hash, isPrivate: response.isPrivate, + isInitialSeeding: response.isInitialSeeding, isSequential: response.isSequential, message: response.message, name: response.name, diff --git a/server/services/rTorrent/constants/methodCallConfigs/torrentList.ts b/server/services/rTorrent/constants/methodCallConfigs/torrentList.ts index e298c020..ab6bede4 100644 --- a/server/services/rTorrent/constants/methodCallConfigs/torrentList.ts +++ b/server/services/rTorrent/constants/methodCallConfigs/torrentList.ts @@ -30,6 +30,12 @@ const torrentListMethodCallConfigs = { methodCall: 'd.is_private=', transformValue: booleanTransformer, }, + isInitialSeeding: { + methodCall: 'd.connection_seed=', + transformValue: (value: unknown): boolean => { + return value === 'initial_seed'; + }, + }, isSequential: { methodCall: 'd.down.sequential=', transformValue: booleanTransformer, diff --git a/shared/schema/api/torrents.ts b/shared/schema/api/torrents.ts index 8b67da91..9238b0ef 100644 --- a/shared/schema/api/torrents.ts +++ b/shared/schema/api/torrents.ts @@ -23,6 +23,8 @@ export const addTorrentByURLSchema = object({ isCompleted: boolean().optional(), // Whether contents of a torrent should be downloaded sequentially [default: false] isSequential: boolean().optional(), + // Whether to use initial seeding mode [default: false] + isInitialSeeding: boolean().optional(), // Whether to start torrent [default: false] start: boolean().optional(), }); @@ -43,6 +45,8 @@ export const addTorrentByFileSchema = object({ isCompleted: boolean().optional(), // Whether contents of a torrent should be downloaded sequentially [default: false] isSequential: boolean().optional(), + // Whether to use initial seeding mode [default: false] + isInitialSeeding: boolean().optional(), // Whether to start torrent [default: false] start: boolean().optional(), }); diff --git a/shared/types/Torrent.ts b/shared/types/Torrent.ts index 26f48f14..6a047be6 100644 --- a/shared/types/Torrent.ts +++ b/shared/types/Torrent.ts @@ -27,6 +27,8 @@ export interface TorrentProperties { eta: number; hash: string; isPrivate: boolean; + // If initial seeding mode (aka super seeding) is enabled + isInitialSeeding: boolean; // If sequential download is enabled isSequential: boolean; message: string; diff --git a/shared/types/api/torrents.ts b/shared/types/api/torrents.ts index 4ab3ec8e..3aad020e 100644 --- a/shared/types/api/torrents.ts +++ b/shared/types/api/torrents.ts @@ -17,6 +17,8 @@ export interface CreateTorrentOptions { infoSource?: string; // Whether the torrent is private isPrivate: boolean; + // Whether to use initial seeding mode + isInitialSeeding?: boolean; // Whether to start torrent start?: boolean; // Tags, not added to torrent file @@ -63,6 +65,14 @@ export interface StopTorrentsOptions { hashes: Array; } +// PATCH /api/torrents/initial-seeding +export interface SetTorrentsInitialSeedingOptions { + // An array of string representing hashes of torrents to operate on + hashes: Array; + // If initial seeding mode (aka super seeding) is enabled + isInitialSeeding: boolean; +} + // PATCH /api/torrents/priority export interface SetTorrentsPriorityOptions { // An array of string representing hashes of torrents to operate on