Not a member of Pastebin yet?
Sign Up,
it unlocks many cool features!
- /**
- * @see https://github.com/qbittorrent/qBittorrent/wiki/Web-API-Documentation
- *
- * 注意,因为使用大驼峰命名的形式,所以qBittorrent在各变量命名中均写成 QBittorrent
- * TODO: 增加qBittorrent 3.x 使用的API支持
- */
- import {
- AddTorrentOptions, CustomPathDescription,
- Torrent, TorrentClient,
- TorrentClientConfig, TorrentClientMetaData,
- TorrentFilterRules, TorrentState
- } from '@/shared/interfaces/btclients'
- import axios, { AxiosError, AxiosRequestConfig, AxiosResponse } from 'axios'
- import * as crypto from 'crypto'
- import { random } from 'lodash-es'
- import urlparse from 'url-parse'
- export const clientConfig: TorrentClientConfig = {
- type: 'qBittorrent',
- name: 'qBittorrent',
- uuid: '4c0f3c06-0b41-4828-9770-e8ef56da6a5c',
- address: 'http://localhost:9091/',
- username: '',
- password: '',
- timeout: 60 * 1e3
- }
- // noinspection JSUnusedGlobalSymbols
- export const clientMetaData: TorrentClientMetaData = {
- description: 'qBittorrent是一个跨平台的自由BitTorrent客户端,其图形用户界面是由Qt所写成的。',
- warning: [
- '当前仅支持 qBittorrent v4.1+',
- '由于浏览器限制,需要禁用 qBittorrent 的『启用跨站请求伪造(CSRF)保护』功能才能正常使用',
- '注意:由于 qBittorrent 验证机制限制,第一次测试连接成功后,后续测试无论密码正确与否都会提示成功。'
- ],
- feature: {
- CustomPath: {
- allowed: true,
- description: CustomPathDescription
- }
- }
- }
- type TrueFalseStr = 'true' | 'false';
- /**
- * @url: https://github.com/qbittorrent/qBittorrent/wiki/WebUI-API-(qBittorrent-v3.1.x)#get-torrent-list
- * @url: https://github.com/qbittorrent/qBittorrent/wiki/WebUI-API-(qBittorrent-v3.2.0-v4.0.4)#get-torrent-list
- * @url: https://github.com/qbittorrent/qBittorrent/wiki/WebUI-API-(qBittorrent-4.1)#get-torrent-list
- */
- enum QbittorrentTorrentState {
- Error = 'error',
- PausedUP = 'pausedUP',
- PausedDL = 'pausedDL',
- QueuedUP = 'queuedUP',
- QueuedDL = 'queuedDL',
- Uploading = 'uploading',
- StalledUP = 'stalledUP',
- CheckingUP = 'checkingUP',
- CheckingDL = 'checkingDL',
- Downloading = 'downloading',
- StalledDL = 'stalledDL',
- ForcedDL = 'forcedDL',
- ForcedUP = 'forcedUP',
- MetaDL = 'metaDL',
- Allocating = 'allocating',
- QueuedForChecking = 'queuedForChecking',
- CheckingResumeData = 'checkingResumeData',
- Moving = 'moving',
- Unknown = 'unknown',
- MissingFiles = 'missingFiles',
- }
- interface QbittorrentTorrent extends Torrent {
- id: string;
- }
- type QbittorrentTorrentFilters =
- | 'all'
- | 'downloading'
- | 'completed'
- | 'paused'
- | 'active'
- | 'inactive'
- | 'resumed'
- | 'stalled'
- | 'stalled_uploading'
- | 'stalled_downloading';
- interface QbittorrentTorrentFilterRules extends TorrentFilterRules {
- hashes?: string|string[];
- filter?: QbittorrentTorrentFilters;
- category?: string;
- sort?: string;
- offset?: number;
- reverse?: boolean|TrueFalseStr;
- }
- interface QbittorrentAddTorrentOptions extends AddTorrentOptions {
- /**
- * Download folder
- */
- savepath: string;
- /**
- * Cookie sent to download the .torrent file
- */
- cookie: string;
- /**
- * Category for the torrent
- */
- category: string;
- /**
- * Skip hash checking. Possible values are true, false (default)
- */
- 'skip_checking': TrueFalseStr;
- /**
- * Add torrents in the paused state. Possible values are true, false (default)
- */
- paused: TrueFalseStr;
- /**
- * Create the root folder. Possible values are true, false, unset (default)
- */
- 'root_folder': TrueFalseStr | null;
- /**
- * Rename torrent
- */
- rename: string;
- /**
- * Set torrent upload speed limit. Unit in bytes/second
- */
- upLimit: number;
- /**
- * Set torrent download speed limit. Unit in bytes/second
- */
- dlLimit: number;
- /**
- * Whether Automatic Torrent Management should be used, disables use of savepath
- */
- useAutoTMM: TrueFalseStr;
- /**
- * Enable sequential download. Possible values are true, false (default)
- */
- sequentialDownload: TrueFalseStr;
- /**
- * Prioritize download first last piece. Possible values are true, false (default)
- */
- firstLastPiecePrio: TrueFalseStr;
- }
- interface rawTorrent {
- /**
- * Torrent name
- */
- name: string;
- hash: string;
- 'magnet_uri': string;
- /**
- * datetime in seconds
- */
- 'added_on': number;
- /**
- * Torrent size
- */
- size: number;
- /**
- * Torrent progress
- */
- progress: number;
- /**
- * Torrent download speed (bytes/s)
- */
- dlspeed: number;
- /**
- * Torrent upload speed (bytes/s)
- */
- upspeed: number;
- /**
- * Torrent priority (-1 if queuing is disabled)
- */
- priority: number;
- /**
- * Torrent seeds connected to
- */
- 'num_seeds': number;
- /**
- * Torrent seeds in the swarm
- */
- 'num_complete': number;
- /**
- * Torrent leechers connected to
- */
- 'num_leechs': number;
- /**
- * Torrent leechers in the swarm
- */
- 'num_incomplete': number;
- /**
- * Torrent share ratio
- */
- ratio: number;
- /**
- * Torrent ETA
- */
- eta: number;
- /**
- * Torrent state
- */
- state: QbittorrentTorrentState;
- /**
- * Torrent sequential download state
- */
- 'seq_dl': boolean;
- /**
- * Torrent first last piece priority state
- */
- 'f_l_piece_prio': boolean;
- /**
- * Torrent copletion datetime in seconds
- */
- 'completion_on': number;
- /**
- * Torrent tracker
- */
- tracker: string;
- /**
- * Torrent download limit
- */
- 'dl_limit': number;
- /**
- * Torrent upload limit
- */
- 'up_limit': number;
- /**
- * Amount of data downloaded
- */
- downloaded: number;
- /**
- * Amount of data uploaded
- */
- uploaded: number;
- /**
- * Amount of data downloaded since program open
- */
- 'downloaded_session': number;
- /**
- * Amount of data uploaded since program open
- */
- 'uploaded_session': number;
- /**
- * Amount of data left to download
- */
- 'amount_left': number;
- /**
- * Torrent save path
- */
- 'save_path': string;
- /**
- * Amount of data completed
- */
- completed: number;
- /**
- * Upload max share ratio
- */
- 'max_ratio': number;
- /**
- * Upload max seeding time
- */
- 'max_seeding_time': number;
- /**
- * Upload share ratio limit
- */
- 'ratio_limit': number;
- /**
- * Upload seeding time limit
- */
- 'seeding_time_limit': number;
- /**
- * Indicates the time when the torrent was last seen complete/whole
- */
- 'seen_complete': number;
- /**
- * Last time when a chunk was downloaded/uploaded
- */
- 'last_activity': number;
- /**
- * Size including unwanted data
- */
- 'total_size': number;
- 'time_active': number;
- /**
- * Category name
- */
- category: string;
- }
- /**
- * @url: https://github.com/qbittorrent/qBittorrent/wiki#webui-api
- */
- type QbittorrentApiType = 'Obsolete' | 'Previous' | 'Current'
- type QbittorrentApiEndPoint = 'preferences'
- | 'getTorrents'
- | 'addTorrentByUrl' | 'addTorrentByFile'
- | 'startTorrent' | 'stopTorrent' | 'deleteTorrent'
- const QbittorrentApiMap: {
- [key in QbittorrentApiType]: {
- [key in QbittorrentApiEndPoint]: AxiosRequestConfig
- }
- } = {
- // qBittorrent v4.1+
- Current: {
- preferences: { url: '/api/v2/app/preferences' },
- addTorrentByFile: { url: '/api/v2/torrents/add', method: 'post' },
- addTorrentByUrl: { url: '/api/v2/torrents/add', method: 'post' },
- deleteTorrent: { url: '/api/v2/torrents/delete' },
- getTorrents: { url: '/api/v2/torrents/info' },
- startTorrent: { url: '/api/v2/torrents/resume' },
- stopTorrent: { url: '/api/v2/torrents/pause' }
- },
- // qBittorrent v3.2.0-v4.0.4
- Previous: {
- preferences: { url: '/query/preferences' },
- addTorrentByFile: { url: '/command/upload', method: 'post' },
- addTorrentByUrl: { url: '/command/download', method: 'post' },
- deleteTorrent: { url: '/command/delete' },
- getTorrents: { url: '/json/torrents' },
- startTorrent: { url: '/command/resume' },
- stopTorrent: { url: '/command/pause' }
- },
- // qBittorrent v3.1.x
- Obsolete: {
- preferences: { url: '/json/preferences' },
- addTorrentByFile: { url: '/command/upload', method: 'post' },
- addTorrentByUrl: { url: '/command/download', method: 'post' },
- deleteTorrent: { url: '/command/delete' },
- getTorrents: { url: '/query/torrents' },
- startTorrent: { url: '/command/resume' },
- stopTorrent: { url: '/command/pause' }
- }
- }
- function normalizePieces (pieces: string | string[], joinBy:string = '|'): string {
- if (Array.isArray(pieces)) {
- return pieces.join(joinBy)
- }
- return pieces
- }
- // noinspection JSUnusedGlobalSymbols
- export default class QBittorrent implements TorrentClient {
- readonly version = 'v0.2.0'; // 这个是我们实现的版本号
- readonly config: TorrentClientConfig;
- private qbtVersion?: string;
- private qbtApiType?: QbittorrentApiType;
- private digestCont: number = 0;
- constructor (options: Partial<TorrentClientConfig> = {}) {
- this.config = { ...clientConfig, ...options }
- }
- getApiEndpointRequestConfig (endpoint: QbittorrentApiEndPoint): AxiosRequestConfig {
- return QbittorrentApiMap[this.qbtApiType as QbittorrentApiType][endpoint]
- }
- async getQbtApiVersion (): Promise<QbittorrentApiType> {
- if (!this.qbtApiType) {
- try {
- /**
- * 首先尝试请求 Current 的 /api/v2/app/version 接口,
- * 如果正常,则直接设置 this.qbtVersion 和 this.qbtApiType
- */
- const { data } = await axios.get('/api/v2/app/version', { baseURL: this.config.address })
- this.qbtVersion = data
- this.qbtApiType = 'Current'
- } catch (e) {
- /**
- * 此时我们会遇到 401, 403, 404 三种情况
- * - 401: Obsolete ,需要通过 Digest Auth 认证
- * - 403: Current , 需要登录
- * - 404: Obsolete 通过 Digest Auth 认证
- * Previous 转而通过 /version/qbittorrent 接口确定
- */
- const responseStatus = (e as AxiosError).response?.status
- if (responseStatus === 401) { // 'Obsolete' 版 此时会遇到 http code 401
- this.qbtVersion = 'v3.1.0' // 直接设定为 v3.1.0 因为我们实在无法从WebApi中确定 Version
- this.qbtApiType = 'Obsolete'
- } else if (responseStatus === 403) {
- this.qbtApiType = 'Current'
- } else if (responseStatus === 404) {
- // 对于 404 的情况,我们需要进一步确认 qbtApiType 是 'Previous' 还是 'Obsolete'
- try {
- const { data } = await axios.get('/version/qbittorrent', { baseURL: this.config.address })
- this.qbtVersion = data
- this.qbtApiType = 'Previous'
- } catch (e) {
- const responseStatus = (e as AxiosError).response?.status
- if (responseStatus === 404) { // 'Obsolete' 版 此时会遇到 http code 404
- this.qbtVersion = 'v3.1.0'
- this.qbtApiType = 'Obsolete'
- }
- }
- }
- }
- }
- return this.qbtApiType!
- }
- async ping (): Promise<boolean> {
- // 通过 preferences 接口来确定是否登录成功,因为此接口都需要认证通过才能获得具体情况
- try {
- const { data } = await this.request('preferences')
- // eslint-disable-next-line no-prototype-builtins
- return 'locale' in data
- } catch (e) {
- return false
- }
- }
- async request (endpoint: QbittorrentApiEndPoint, config: AxiosRequestConfig = {}): Promise<AxiosResponse> {
- const qbtApiVersion = await this.getQbtApiVersion()
- const opts = {
- baseURL: this.config.address,
- timeout: this.config.timeout,
- ...QbittorrentApiMap[qbtApiVersion][endpoint],
- ...config
- }
- try {
- return await axios.request(opts)
- } catch (err) {
- if (qbtApiVersion === 'Obsolete') {
- if (err.response.status === 401 && err.response.headers['www-authenticate']) {
- const authDetails = err.response.headers['www-authenticate'].split(', ').map((v:string) => v.split('='))
- ++this.digestCont
- const nonceCount = ('00000000' + this.digestCont).slice(-8)
- const cnonce = crypto.randomBytes(24).toString('hex')
- const realm = authDetails[0][1].replace(/"/g, '')
- const nonce = authDetails[2][1].replace(/"/g, '')
- const md5 = (str:string) => crypto.createHash('md5').update(str).digest('hex')
- const HA1 = md5(`${this.config.username}:${realm}:${this.config.password}`)
- const path = urlparse(opts.url!).pathname
- const HA2 = md5(`${opts.method || 'GET'}:${path}`)
- const response = md5(`${HA1}:${nonce}:${nonceCount}:${cnonce}:auth:${HA2}`)
- const authorization = `Digest username="${this.config.username}",realm="${realm}",` +
- `nonce="${nonce}",uri="${path}",qop="auth",algorithm="MD5",` +
- `response="${response}",nc="${nonceCount}",cnonce="${cnonce}"`
- if (opts.headers) {
- opts.headers.authorization = authorization
- } else {
- opts.headers = { authorization }
- }
- return await axios.request(opts)
- }
- } else {
- const form = qbtApiVersion === 'Current' ? new FormData() : new URLSearchParams()
- const path = qbtApiVersion === 'Current' ? '/api/v2/auth/login' : '/login'
- form.append('username', this.config.username)
- form.append('password', this.config.password)
- await axios.post(path, form, {
- baseURL: this.config.address,
- timeout: this.config.timeout,
- withCredentials: true
- })
- return await axios.request(opts)
- }
- throw err
- }
- }
- async addTorrent (urls: string, options: Partial<QbittorrentAddTorrentOptions> = {}): Promise<boolean> {
- const formData = new FormData()
- // 处理链接
- if (urls.startsWith('magnet:') || !options.localDownload) {
- formData.append('urls', urls)
- } else if (options.localDownload) {
- const req = await axios.get(urls, {
- responseType: 'blob'
- })
- // FIXME 使用
- formData.append('torrents', req.data, String(random(0, 4096, true)) + '.torrent')
- }
- // 将通用字段转成qbt字段
- if (options.savePath) {
- formData.append('savepath', options.savePath)
- }
- if (options.label) {
- formData.append('category', options.label)
- }
- if (options.addAtPaused) {
- formData.append('paused', options.addAtPaused ? 'true' : 'false')
- }
- formData.append('useAutoTMM', 'false') // 关闭自动管理
- const res = await this.request('addTorrentByUrl', { method: 'post', data: formData })
- return res.data === 'Ok.'
- }
- async getTorrentsBy (filter: QbittorrentTorrentFilterRules): Promise<QbittorrentTorrent[]> {
- if (filter.hashes) {
- filter.hashes = normalizePieces(filter.hashes)
- }
- // 将通用项处理成qbt对应的项目
- if (filter.complete) {
- filter.filter = 'completed'
- delete filter.complete
- }
- const res = await this.request('getTorrents', { params: filter })
- return res.data.map((torrent: rawTorrent) => {
- let state = TorrentState.unknown
- switch (torrent.state) {
- case QbittorrentTorrentState.ForcedDL:
- case QbittorrentTorrentState.Downloading:
- case QbittorrentTorrentState.MetaDL:
- case QbittorrentTorrentState.StalledDL:
- state = TorrentState.downloading
- break
- case QbittorrentTorrentState.Allocating:
- // state = 'stalledDL';
- state = TorrentState.queued
- break
- case QbittorrentTorrentState.ForcedUP:
- case QbittorrentTorrentState.Uploading:
- case QbittorrentTorrentState.StalledUP:
- state = TorrentState.seeding
- break
- case QbittorrentTorrentState.PausedDL:
- state = TorrentState.paused
- break
- case QbittorrentTorrentState.PausedUP:
- // state = 'completed';
- state = TorrentState.paused
- break
- case QbittorrentTorrentState.QueuedDL:
- case QbittorrentTorrentState.QueuedUP:
- state = TorrentState.queued
- break
- case QbittorrentTorrentState.CheckingDL:
- case QbittorrentTorrentState.CheckingUP:
- case QbittorrentTorrentState.QueuedForChecking:
- case QbittorrentTorrentState.CheckingResumeData:
- case QbittorrentTorrentState.Moving:
- state = TorrentState.checking
- break
- case QbittorrentTorrentState.Error:
- case QbittorrentTorrentState.Unknown:
- case QbittorrentTorrentState.MissingFiles:
- state = TorrentState.error
- break
- default:
- break
- }
- const isCompleted = torrent.progress === 1
- return {
- id: torrent.hash,
- infoHash: torrent.hash,
- name: torrent.name,
- state,
- dateAdded: torrent.added_on,
- isCompleted,
- progress: torrent.progress,
- label: torrent.category,
- savePath: torrent.save_path,
- totalSize: torrent.total_size,
- ratio: torrent.ratio,
- uploadSpeed: torrent.upspeed,
- downloadSpeed: torrent.dlspeed,
- totalUploaded: torrent.uploaded,
- totalDownloaded: torrent.downloaded
- } as Torrent
- })
- }
- async getAllTorrents (): Promise<QbittorrentTorrent[]> {
- return await this.getTorrentsBy({})
- }
- async getTorrent (id: any): Promise<QbittorrentTorrent> {
- return (await this.getTorrentsBy({ hashes: id }))[0]
- }
- // 注意方法虽然支持一次对多个种子进行操作,但仍建议每次均只操作一个种子
- async pauseTorrent (hashes: string | string[] | 'all'): Promise<boolean> {
- const params = {
- hashes: hashes === 'all' ? 'all' : normalizePieces(hashes)
- }
- await this.request('stopTorrent', { params })
- return true
- }
- // 注意方法虽然支持一次对多个种子进行操作,但仍建议每次均只操作一个种子
- async removeTorrent (hashes: string | string[] | 'all', removeData: boolean = false): Promise<boolean> {
- const params = {
- hashes: hashes === 'all' ? 'all' : normalizePieces(hashes),
- removeData
- }
- await this.request('deleteTorrent', { params })
- return true
- }
- // 注意方法虽然支持一次对多个种子进行操作,但仍建议每次均只操作一个种子
- async resumeTorrent (hashes: string | string[] | 'all'): Promise<any> {
- const params = {
- hashes: hashes === 'all' ? 'all' : normalizePieces(hashes)
- }
- await this.request('startTorrent', { params })
- return true
- }
- }
Add Comment
Please, Sign In to add comment