Files
flood/server/services/qBittorrent/clientGatewayService.ts
2024-08-13 22:31:39 +00:00

534 lines
19 KiB
TypeScript

import fs from 'node:fs';
import {homedir} from 'node:os';
import path from 'node:path';
import type {
AddTorrentByFileOptions,
AddTorrentByURLOptions,
ReannounceTorrentsOptions,
SetTorrentsTagsOptions,
} from '@shared/schema/api/torrents';
import type {QBittorrentConnectionSettings} from '@shared/schema/ClientConnectionSettings';
import type {SetClientSettingsOptions} from '@shared/types/api/client';
import type {
CheckTorrentsOptions,
DeleteTorrentsOptions,
MoveTorrentsOptions,
SetTorrentContentsPropertiesOptions,
SetTorrentsInitialSeedingOptions,
SetTorrentsPriorityOptions,
SetTorrentsSequentialOptions,
SetTorrentsTrackersOptions,
StartTorrentsOptions,
StopTorrentsOptions,
} from '@shared/types/api/torrents';
import type {ClientSettings} from '@shared/types/ClientSettings';
import type {TorrentList, TorrentListSummary, TorrentProperties} from '@shared/types/Torrent';
import type {TorrentContent} from '@shared/types/TorrentContent';
import type {TorrentPeer} from '@shared/types/TorrentPeer';
import type {TorrentTracker} from '@shared/types/TorrentTracker';
import type {TransferSummary} from '@shared/types/TransferData';
import parseTorrent from 'parse-torrent';
import {TorrentPriority} from '../../../shared/types/Torrent';
import {TorrentContentPriority} from '../../../shared/types/TorrentContent';
import {TorrentTrackerType} from '../../../shared/types/TorrentTracker';
import {fetchUrls} from '../../util/fetchUtil';
import {getDomainsFromURLs} from '../../util/torrentPropertiesUtil';
import ClientGatewayService from '../clientGatewayService';
import ClientRequestManager from './clientRequestManager';
import {QBittorrentTorrentContentPriority, QBittorrentTorrentTrackerStatus} from './types/QBittorrentTorrentsMethods';
import {
getTorrentPeerPropertiesFromFlags,
getTorrentStatusFromState,
getTorrentTrackerTypeFromURL,
} from './util/torrentPropertiesUtil';
class QBittorrentClientGatewayService extends ClientGatewayService {
private clientRequestManager = new ClientRequestManager(this.user.client as QBittorrentConnectionSettings);
private cachedProperties: Record<
string,
Pick<TorrentProperties, 'comment' | 'dateCreated' | 'isPrivate' | 'trackerURIs'>
> = {};
async addTorrentsByFile({
files,
destination,
tags,
isBasePath,
isCompleted,
isInitialSeeding,
isSequential,
start,
}: Required<AddTorrentByFileOptions>): Promise<string[]> {
const fileBuffers: Buffer[] = [];
const torrentHashes: string[] = (
await Promise.all(
files.map(async (file) => {
try {
const fileBuffer = Buffer.from(file, 'base64');
const {infoHash} = parseTorrent(fileBuffer);
fileBuffers.push(fileBuffer);
return infoHash;
} catch {
return;
}
}),
)
).filter((hash) => hash) as string[];
if (torrentHashes[0] == null) {
throw new Error();
}
await this.clientRequestManager
.torrentsAddFiles(fileBuffers, {
savepath: destination,
tags: tags.join(','),
paused: !start,
root_folder: !isBasePath,
contentLayout: isBasePath ? 'NoSubfolder' : undefined,
sequentialDownload: isSequential,
skip_checking: isCompleted,
})
.then(this.processClientRequestSuccess, this.processClientRequestError);
await this.setTorrentsInitialSeeding({hashes: torrentHashes, isInitialSeeding});
return torrentHashes;
}
async addTorrentsByURL({
urls: inputUrls,
cookies,
destination,
tags,
isBasePath,
isCompleted,
isInitialSeeding,
isSequential,
start,
}: Required<AddTorrentByURLOptions>): Promise<string[]> {
const {files, urls} = await fetchUrls(inputUrls, cookies);
if (!files[0] && !urls[0]) {
throw new Error();
}
await this.clientRequestManager
.torrentsAddURLs(urls, {
savepath: destination,
tags: tags.join(','),
paused: !start,
root_folder: !isBasePath,
contentLayout: isBasePath ? 'NoSubfolder' : undefined,
sequentialDownload: isSequential,
skip_checking: isCompleted,
})
.then(this.processClientRequestSuccess, this.processClientRequestError);
if (files[0]) {
return this.addTorrentsByFile({
files: files.map((file) => file.toString('base64')) as [string, ...string[]],
destination,
tags,
isBasePath,
isCompleted,
isInitialSeeding,
isSequential,
start,
});
}
return [];
}
async checkTorrents({hashes}: CheckTorrentsOptions): Promise<void> {
return this.clientRequestManager
.torrentsRecheck(hashes)
.then(this.processClientRequestSuccess, this.processClientRequestError);
}
async getTorrentContents(hash: TorrentProperties['hash']): Promise<Array<TorrentContent>> {
return this.clientRequestManager
.getTorrentContents(hash)
.then(this.processClientRequestSuccess, this.processClientRequestError)
.then((contents) => {
return contents.map((content, index) => {
let priority = TorrentContentPriority.NORMAL;
switch (content.priority) {
case QBittorrentTorrentContentPriority.DO_NOT_DOWNLOAD:
priority = TorrentContentPriority.DO_NOT_DOWNLOAD;
break;
case QBittorrentTorrentContentPriority.HIGH:
case QBittorrentTorrentContentPriority.MAXIMUM:
priority = TorrentContentPriority.HIGH;
break;
default:
break;
}
return {
index,
path: content.name,
filename: content.name.split('/').pop() || '',
percentComplete: content.progress * 100,
priority,
sizeBytes: content.size,
};
});
});
}
async getTorrentPeers(hash: TorrentProperties['hash']): Promise<Array<TorrentPeer>> {
return this.clientRequestManager
.syncTorrentPeers(hash)
.then(this.processClientRequestSuccess, this.processClientRequestError)
.then((peers) => {
return Object.keys(peers).reduce((accumulator: Array<TorrentPeer>, ip_and_port) => {
const peer = peers[ip_and_port];
// Only displays connected peers
if (!peer.flags.includes('D') && !peer.flags.includes('U')) {
return accumulator;
}
const properties = getTorrentPeerPropertiesFromFlags(peer.flags);
accumulator.push({
address: peer.ip,
country: peer.country_code,
clientVersion: peer.client,
completedPercent: peer.progress * 100,
downloadRate: peer.dl_speed,
uploadRate: peer.up_speed,
isEncrypted: properties.isEncrypted,
isIncoming: properties.isIncoming,
});
return accumulator;
}, []);
});
}
async getTorrentTrackers(hash: TorrentProperties['hash']): Promise<Array<TorrentTracker>> {
return this.clientRequestManager
.getTorrentTrackers(hash)
.then(this.processClientRequestSuccess, this.processClientRequestError)
.then((trackers) => {
return trackers
.filter((tracker) => tracker.status !== QBittorrentTorrentTrackerStatus.DISABLED)
.map((tracker) => {
return {
url: tracker.url,
type: getTorrentTrackerTypeFromURL(tracker.url),
};
});
});
}
async moveTorrents({hashes, destination}: MoveTorrentsOptions): Promise<void> {
return this.clientRequestManager
.torrentsSetLocation(hashes, destination)
.then(this.processClientRequestSuccess, this.processClientRequestError);
}
async reannounceTorrents({hashes}: ReannounceTorrentsOptions): Promise<void> {
return this.clientRequestManager
.torrentsReannounce(hashes)
.then(this.processClientRequestSuccess, this.processClientRequestError);
}
async removeTorrents({hashes, deleteData}: DeleteTorrentsOptions): Promise<void> {
return this.clientRequestManager
.torrentsDelete(hashes, deleteData || false)
.then(this.processClientRequestSuccess, this.processClientRequestError);
}
async setTorrentsInitialSeeding({hashes, isInitialSeeding}: SetTorrentsInitialSeedingOptions): Promise<void> {
return this.clientRequestManager
.torrentsSetSuperSeeding(hashes, isInitialSeeding)
.then(this.processClientRequestSuccess, this.processClientRequestError);
}
async setTorrentsPriority({hashes, priority}: SetTorrentsPriorityOptions): Promise<void> {
// TODO: qBittorrent uses queue and priority here has a different meaning
switch (priority) {
case TorrentPriority.DO_NOT_DOWNLOAD:
return this.stopTorrents({hashes});
case TorrentPriority.LOW:
return this.clientRequestManager
.torrentsSetBottomPrio(hashes)
.then(this.processClientRequestSuccess, this.processClientRequestError);
case TorrentPriority.HIGH:
return this.clientRequestManager
.torrentsSetTopPrio(hashes)
.then(this.processClientRequestSuccess, this.processClientRequestError);
default:
return undefined;
}
}
async setTorrentsSequential({hashes, isSequential}: SetTorrentsSequentialOptions): Promise<void> {
// qBittorrent API is not idempotent...
// get torrent list so we know if the state needs to be flipped
const {torrents} = await this.fetchTorrentList();
const flipNeeded: Array<string> = hashes.filter((hash) => {
const currentIsSequential = torrents[hash.toUpperCase()]?.isSequential;
return currentIsSequential != null && currentIsSequential !== isSequential;
});
return this.clientRequestManager
.torrentsToggleSequentialDownload(flipNeeded)
.then(this.processClientRequestSuccess, this.processClientRequestError);
}
async setTorrentsTags({hashes, tags}: SetTorrentsTagsOptions): Promise<void> {
return this.clientRequestManager.torrentsRemoveTags(hashes).then(() => {
this.clientRequestManager
.torrentsAddTags(hashes, tags)
.then(this.processClientRequestSuccess, this.processClientRequestError);
});
}
async setTorrentsTrackers({hashes, trackers}: SetTorrentsTrackersOptions): Promise<void> {
return Promise.all(
hashes.map(async (hash) => {
const currentTrackerURLs = await this.getTorrentTrackers(hash).then((currentTrackers) =>
currentTrackers.filter((tracker) => tracker.type !== TorrentTrackerType.DHT).map((tracker) => tracker.url),
);
await this.clientRequestManager.torrentsRemoveTrackers(hash, currentTrackerURLs);
return this.clientRequestManager
.torrentsAddTrackers(hash, trackers)
.then(this.processClientRequestSuccess, this.processClientRequestError)
.then(() => delete this.cachedProperties[hash.toLowerCase()]);
}),
).then(() => undefined);
}
async setTorrentContentsPriority(
hash: string,
{indices, priority}: SetTorrentContentsPropertiesOptions,
): Promise<void> {
let qbFilePriority = QBittorrentTorrentContentPriority.NORMAL;
switch (priority) {
case TorrentContentPriority.DO_NOT_DOWNLOAD:
qbFilePriority = QBittorrentTorrentContentPriority.DO_NOT_DOWNLOAD;
break;
case TorrentContentPriority.HIGH:
qbFilePriority = QBittorrentTorrentContentPriority.HIGH;
break;
default:
break;
}
return this.clientRequestManager
.torrentsFilePrio(hash, indices, qbFilePriority)
.then(this.processClientRequestSuccess, this.processClientRequestError);
}
async startTorrents({hashes}: StartTorrentsOptions): Promise<void> {
return this.clientRequestManager
.torrentsResume(hashes)
.then(this.processClientRequestSuccess, this.processClientRequestError);
}
async stopTorrents({hashes}: StopTorrentsOptions): Promise<void> {
return this.clientRequestManager
.torrentsPause(hashes)
.then(this.processClientRequestSuccess, this.processClientRequestError);
}
async fetchTorrentList(): Promise<TorrentListSummary> {
return this.clientRequestManager
.getTorrentInfos()
.then(this.processClientRequestSuccess, this.processClientRequestError)
.then(async (infos) => {
this.emit('PROCESS_TORRENT_LIST_START');
// qBittorrent can not handle requests in a highly concurrent way.
for await (const {hash} of infos) {
if (this.cachedProperties[hash] == null) {
const properties = await this.clientRequestManager.getTorrentProperties(hash).catch(() => undefined);
const trackers = await this.clientRequestManager.getTorrentTrackers(hash).catch(() => undefined);
if (properties != null && trackers != null && Array.isArray(trackers)) {
this.cachedProperties[hash] = {
comment: properties?.comment,
dateCreated: properties?.creation_date,
isPrivate: trackers[0]?.msg.includes('is private'),
trackerURIs: getDomainsFromURLs(
trackers
.map((tracker) => tracker.url)
.filter((url) => getTorrentTrackerTypeFromURL(url) !== TorrentTrackerType.DHT),
),
};
}
}
}
const torrentList: TorrentList = Object.assign(
{},
...(await Promise.all(
infos.map(async (info) => {
const {
comment = '',
dateCreated = 0,
isPrivate = false,
trackerURIs = [],
} = this.cachedProperties[info.hash] || {};
const torrentProperties: TorrentProperties = {
bytesDone: info.completed,
comment: comment,
dateActive: info.dlspeed > 0 || info.upspeed > 0 ? -1 : info.last_activity,
dateAdded: info.added_on,
dateCreated,
dateFinished: info.completion_on,
directory: info.save_path,
downRate: info.dlspeed,
downTotal: info.downloaded,
eta: info.eta >= 8640000 ? -1 : info.eta,
hash: info.hash.toUpperCase(),
isPrivate,
isInitialSeeding: info.super_seeding,
isSequential: info.seq_dl,
message: '', // in tracker method
name: info.name,
peersConnected: info.num_leechs,
peersTotal: info.num_incomplete,
percentComplete: info.progress * 100,
priority: 1,
ratio: info.ratio,
seedsConnected: info.num_seeds,
seedsTotal: info.num_complete,
sizeBytes: info.size,
status: getTorrentStatusFromState(info.state),
tags: info.tags === '' ? [] : info.tags.split(',').map((tag) => tag.trim()),
trackerURIs,
upRate: info.upspeed,
upTotal: info.uploaded,
};
this.emit('PROCESS_TORRENT', torrentProperties);
return {
[torrentProperties.hash]: torrentProperties,
};
}),
)),
);
const torrentListSummary = {
id: Date.now(),
torrents: torrentList,
};
this.emit('PROCESS_TORRENT_LIST_END', torrentListSummary);
return torrentListSummary;
});
}
async fetchTransferSummary(): Promise<TransferSummary> {
return this.clientRequestManager
.getTransferInfo()
.then(this.processClientRequestSuccess, this.processClientRequestError)
.then((info) => {
return {
downRate: info.dl_info_speed,
downTotal: info.dl_info_data,
upRate: info.up_info_speed,
upTotal: info.up_info_data,
};
});
}
async getClientSessionDirectory(): Promise<{path: string; case: 'lower' | 'upper'}> {
// qBittorrent API does not provide session directory.
// We can only guess with the common locations here.
switch (process.platform) {
case 'win32':
if (process.env.LOCALAPPDATA) {
return {path: path.join(process.env.LOCALAPPDATA, '\\qBittorrent\\BT_backup'), case: 'lower'};
}
return {path: path.join(homedir(), '\\AppData\\Local\\qBittorrent\\BT_backup'), case: 'lower'};
case 'darwin':
return {path: path.join(homedir(), '/Library/Application Support/qBittorrent/BT_backup'), case: 'lower'};
default:
const legacyPath = path.join(homedir(), '/.local/share/data/qBittorrent/BT_backup');
try {
await fs.promises.access(legacyPath);
return {path: legacyPath, case: 'lower'};
} catch {
return {path: path.join(homedir(), '/.local/share/qBittorrent/BT_backup'), case: 'lower'};
}
}
}
async getClientSettings(): Promise<ClientSettings> {
return this.clientRequestManager
.getAppPreferences()
.then(this.processClientRequestSuccess, this.processClientRequestError)
.then((preferences) => {
return {
dht: preferences.dht,
dhtPort: preferences.listen_port,
directoryDefault: preferences.save_path.split(',')[0],
networkHttpMaxOpen: preferences.max_connec,
networkLocalAddress: [preferences.announce_ip],
networkMaxOpenFiles: 0,
networkPortOpen: true,
networkPortRandom: preferences.random_port,
networkPortRange: `${preferences.listen_port}`,
piecesHashOnCompletion: false,
piecesMemoryMax: 0,
protocolPex: preferences.pex,
throttleGlobalDownSpeed: preferences.dl_limit,
throttleGlobalUpSpeed: preferences.up_limit,
throttleMaxPeersNormal: 0,
throttleMaxPeersSeed: 0,
throttleMaxDownloads: 0,
throttleMaxDownloadsGlobal: 0,
throttleMaxUploads: preferences.max_uploads_per_torrent,
throttleMaxUploadsGlobal: preferences.max_uploads,
throttleMinPeersNormal: 0,
throttleMinPeersSeed: 0,
trackersNumWant: 0,
};
});
}
async setClientSettings(settings: SetClientSettingsOptions): Promise<void> {
return this.clientRequestManager
.setAppPreferences({
dht: settings.dht,
save_path: settings.directoryDefault,
max_connec: settings.networkHttpMaxOpen,
announce_ip: settings.networkLocalAddress ? settings.networkLocalAddress[0] : undefined,
random_port: settings.networkPortRandom,
listen_port: settings.networkPortRange ? Number(settings.networkPortRange?.split('-')[0]) : undefined,
pex: settings.protocolPex,
dl_limit: settings.throttleGlobalDownSpeed,
up_limit: settings.throttleGlobalUpSpeed,
max_uploads_per_torrent: settings.throttleMaxUploads,
max_uploads: settings.throttleMaxUploadsGlobal,
})
.then(this.processClientRequestSuccess, this.processClientRequestError);
}
async testGateway(): Promise<void> {
return this.clientRequestManager
.updateAuthCookie()
.then(() => this.processClientRequestSuccess(undefined), this.processClientRequestError);
}
}
export default QBittorrentClientGatewayService;