Rhilip

qbittorrent_test.ts

Feb 8th, 2021
121
0
Never
Not a member of Pastebin yet? Sign Up, it unlocks many cool features!
  1. /**
  2.  * @see https://github.com/qbittorrent/qBittorrent/wiki/Web-API-Documentation
  3.  *
  4.  * 注意,因为使用大驼峰命名的形式,所以qBittorrent在各变量命名中均写成 QBittorrent
  5.  * TODO: 增加qBittorrent 3.x 使用的API支持
  6.  */
  7. import {
  8.   AddTorrentOptions, CustomPathDescription,
  9.   Torrent, TorrentClient,
  10.   TorrentClientConfig, TorrentClientMetaData,
  11.   TorrentFilterRules, TorrentState
  12. } from '@/shared/interfaces/btclients'
  13. import axios, { AxiosError, AxiosRequestConfig, AxiosResponse } from 'axios'
  14. import * as crypto from 'crypto'
  15. import { random } from 'lodash-es'
  16. import urlparse from 'url-parse'
  17.  
  18. export const clientConfig: TorrentClientConfig = {
  19.   type: 'qBittorrent',
  20.   name: 'qBittorrent',
  21.   uuid: '4c0f3c06-0b41-4828-9770-e8ef56da6a5c',
  22.   address: 'http://localhost:9091/',
  23.   username: '',
  24.   password: '',
  25.   timeout: 60 * 1e3
  26. }
  27.  
  28. // noinspection JSUnusedGlobalSymbols
  29. export const clientMetaData: TorrentClientMetaData = {
  30.   description: 'qBittorrent是一个跨平台的自由BitTorrent客户端,其图形用户界面是由Qt所写成的。',
  31.   warning: [
  32.     '当前仅支持 qBittorrent v4.1+',
  33.     '由于浏览器限制,需要禁用 qBittorrent 的『启用跨站请求伪造(CSRF)保护』功能才能正常使用',
  34.     '注意:由于 qBittorrent 验证机制限制,第一次测试连接成功后,后续测试无论密码正确与否都会提示成功。'
  35.   ],
  36.   feature: {
  37.     CustomPath: {
  38.       allowed: true,
  39.       description: CustomPathDescription
  40.     }
  41.   }
  42. }
  43.  
  44. type TrueFalseStr = 'true' | 'false';
  45.  
  46. /**
  47.  * @url: https://github.com/qbittorrent/qBittorrent/wiki/WebUI-API-(qBittorrent-v3.1.x)#get-torrent-list
  48.  * @url: https://github.com/qbittorrent/qBittorrent/wiki/WebUI-API-(qBittorrent-v3.2.0-v4.0.4)#get-torrent-list
  49.  * @url: https://github.com/qbittorrent/qBittorrent/wiki/WebUI-API-(qBittorrent-4.1)#get-torrent-list
  50.  */
  51. enum QbittorrentTorrentState {
  52.   Error = 'error',
  53.   PausedUP = 'pausedUP',
  54.   PausedDL = 'pausedDL',
  55.   QueuedUP = 'queuedUP',
  56.   QueuedDL = 'queuedDL',
  57.   Uploading = 'uploading',
  58.   StalledUP = 'stalledUP',
  59.   CheckingUP = 'checkingUP',
  60.   CheckingDL = 'checkingDL',
  61.   Downloading = 'downloading',
  62.   StalledDL = 'stalledDL',
  63.   ForcedDL = 'forcedDL',
  64.   ForcedUP = 'forcedUP',
  65.   MetaDL = 'metaDL',
  66.   Allocating = 'allocating',
  67.   QueuedForChecking = 'queuedForChecking',
  68.   CheckingResumeData = 'checkingResumeData',
  69.   Moving = 'moving',
  70.   Unknown = 'unknown',
  71.   MissingFiles = 'missingFiles',
  72. }
  73.  
  74. interface QbittorrentTorrent extends Torrent {
  75.   id: string;
  76. }
  77.  
  78. type QbittorrentTorrentFilters =
  79.   | 'all'
  80.   | 'downloading'
  81.   | 'completed'
  82.   | 'paused'
  83.   | 'active'
  84.   | 'inactive'
  85.   | 'resumed'
  86.   | 'stalled'
  87.   | 'stalled_uploading'
  88.   | 'stalled_downloading';
  89.  
  90. interface QbittorrentTorrentFilterRules extends TorrentFilterRules {
  91.   hashes?: string|string[];
  92.   filter?: QbittorrentTorrentFilters;
  93.   category?: string;
  94.   sort?: string;
  95.   offset?: number;
  96.   reverse?: boolean|TrueFalseStr;
  97. }
  98.  
  99. interface QbittorrentAddTorrentOptions extends AddTorrentOptions {
  100.   /**
  101.    * Download folder
  102.    */
  103.   savepath: string;
  104.   /**
  105.    * Cookie sent to download the .torrent file
  106.    */
  107.   cookie: string;
  108.   /**
  109.    * Category for the torrent
  110.    */
  111.   category: string;
  112.   /**
  113.    * Skip hash checking. Possible values are true, false (default)
  114.    */
  115.   'skip_checking': TrueFalseStr;
  116.   /**
  117.    * Add torrents in the paused state. Possible values are true, false (default)
  118.    */
  119.   paused: TrueFalseStr;
  120.   /**
  121.    * Create the root folder. Possible values are true, false, unset (default)
  122.    */
  123.   'root_folder': TrueFalseStr | null;
  124.   /**
  125.    * Rename torrent
  126.    */
  127.   rename: string;
  128.   /**
  129.    * Set torrent upload speed limit. Unit in bytes/second
  130.    */
  131.   upLimit: number;
  132.   /**
  133.    * Set torrent download speed limit. Unit in bytes/second
  134.    */
  135.   dlLimit: number;
  136.   /**
  137.    * Whether Automatic Torrent Management should be used, disables use of savepath
  138.    */
  139.   useAutoTMM: TrueFalseStr;
  140.   /**
  141.    * Enable sequential download. Possible values are true, false (default)
  142.    */
  143.   sequentialDownload: TrueFalseStr;
  144.   /**
  145.    * Prioritize download first last piece. Possible values are true, false (default)
  146.    */
  147.   firstLastPiecePrio: TrueFalseStr;
  148. }
  149.  
  150. interface rawTorrent {
  151.   /**
  152.    * Torrent name
  153.    */
  154.   name: string;
  155.   hash: string;
  156.   'magnet_uri': string;
  157.   /**
  158.    * datetime in seconds
  159.    */
  160.   'added_on': number;
  161.   /**
  162.    * Torrent size
  163.    */
  164.   size: number;
  165.   /**
  166.    * Torrent progress
  167.    */
  168.   progress: number;
  169.   /**
  170.    * Torrent download speed (bytes/s)
  171.    */
  172.   dlspeed: number;
  173.   /**
  174.    * Torrent upload speed (bytes/s)
  175.    */
  176.   upspeed: number;
  177.   /**
  178.    * Torrent priority (-1 if queuing is disabled)
  179.    */
  180.   priority: number;
  181.   /**
  182.    * Torrent seeds connected to
  183.    */
  184.   'num_seeds': number;
  185.   /**
  186.    * Torrent seeds in the swarm
  187.    */
  188.   'num_complete': number;
  189.   /**
  190.    * Torrent leechers connected to
  191.    */
  192.   'num_leechs': number;
  193.   /**
  194.    * Torrent leechers in the swarm
  195.    */
  196.   'num_incomplete': number;
  197.   /**
  198.    * Torrent share ratio
  199.    */
  200.   ratio: number;
  201.   /**
  202.    * Torrent ETA
  203.    */
  204.   eta: number;
  205.   /**
  206.    * Torrent state
  207.    */
  208.   state: QbittorrentTorrentState;
  209.   /**
  210.    * Torrent sequential download state
  211.    */
  212.   'seq_dl': boolean;
  213.   /**
  214.    * Torrent first last piece priority state
  215.    */
  216.   'f_l_piece_prio': boolean;
  217.   /**
  218.    * Torrent copletion datetime in seconds
  219.    */
  220.   'completion_on': number;
  221.   /**
  222.    * Torrent tracker
  223.    */
  224.   tracker: string;
  225.   /**
  226.    * Torrent download limit
  227.    */
  228.   'dl_limit': number;
  229.   /**
  230.    * Torrent upload limit
  231.    */
  232.   'up_limit': number;
  233.   /**
  234.    * Amount of data downloaded
  235.    */
  236.   downloaded: number;
  237.   /**
  238.    * Amount of data uploaded
  239.    */
  240.   uploaded: number;
  241.   /**
  242.    * Amount of data downloaded since program open
  243.    */
  244.   'downloaded_session': number;
  245.   /**
  246.    * Amount of data uploaded since program open
  247.    */
  248.   'uploaded_session': number;
  249.   /**
  250.    * Amount of data left to download
  251.    */
  252.   'amount_left': number;
  253.   /**
  254.    * Torrent save path
  255.    */
  256.   'save_path': string;
  257.   /**
  258.    * Amount of data completed
  259.    */
  260.   completed: number;
  261.   /**
  262.    * Upload max share ratio
  263.    */
  264.   'max_ratio': number;
  265.   /**
  266.    * Upload max seeding time
  267.    */
  268.   'max_seeding_time': number;
  269.   /**
  270.    * Upload share ratio limit
  271.    */
  272.   'ratio_limit': number;
  273.   /**
  274.    * Upload seeding time limit
  275.    */
  276.   'seeding_time_limit': number;
  277.   /**
  278.    * Indicates the time when the torrent was last seen complete/whole
  279.    */
  280.   'seen_complete': number;
  281.   /**
  282.    * Last time when a chunk was downloaded/uploaded
  283.    */
  284.   'last_activity': number;
  285.   /**
  286.    * Size including unwanted data
  287.    */
  288.   'total_size': number;
  289.  
  290.   'time_active': number;
  291.   /**
  292.    * Category name
  293.    */
  294.   category: string;
  295. }
  296.  
  297. /**
  298.  * @url: https://github.com/qbittorrent/qBittorrent/wiki#webui-api
  299.  */
  300. type QbittorrentApiType = 'Obsolete' | 'Previous' | 'Current'
  301. type QbittorrentApiEndPoint = 'preferences'
  302.   | 'getTorrents'
  303.   | 'addTorrentByUrl' | 'addTorrentByFile'
  304.   | 'startTorrent' | 'stopTorrent' | 'deleteTorrent'
  305.  
  306. const QbittorrentApiMap: {
  307.   [key in QbittorrentApiType]: {
  308.     [key in QbittorrentApiEndPoint]: AxiosRequestConfig
  309.   }
  310. } = {
  311.   // qBittorrent v4.1+
  312.   Current: {
  313.     preferences: { url: '/api/v2/app/preferences' },
  314.     addTorrentByFile: { url: '/api/v2/torrents/add', method: 'post' },
  315.     addTorrentByUrl: { url: '/api/v2/torrents/add', method: 'post' },
  316.     deleteTorrent: { url: '/api/v2/torrents/delete' },
  317.     getTorrents: { url: '/api/v2/torrents/info' },
  318.     startTorrent: { url: '/api/v2/torrents/resume' },
  319.     stopTorrent: { url: '/api/v2/torrents/pause' }
  320.   },
  321.   // qBittorrent v3.2.0-v4.0.4
  322.   Previous: {
  323.     preferences: { url: '/query/preferences' },
  324.     addTorrentByFile: { url: '/command/upload', method: 'post' },
  325.     addTorrentByUrl: { url: '/command/download', method: 'post' },
  326.     deleteTorrent: { url: '/command/delete' },
  327.     getTorrents: { url: '/json/torrents' },
  328.     startTorrent: { url: '/command/resume' },
  329.     stopTorrent: { url: '/command/pause' }
  330.   },
  331.   // qBittorrent v3.1.x
  332.   Obsolete: {
  333.     preferences: { url: '/json/preferences' },
  334.     addTorrentByFile: { url: '/command/upload', method: 'post' },
  335.     addTorrentByUrl: { url: '/command/download', method: 'post' },
  336.     deleteTorrent: { url: '/command/delete' },
  337.     getTorrents: { url: '/query/torrents' },
  338.     startTorrent: { url: '/command/resume' },
  339.     stopTorrent: { url: '/command/pause' }
  340.   }
  341. }
  342.  
  343. function normalizePieces (pieces: string | string[], joinBy:string = '|'): string {
  344.   if (Array.isArray(pieces)) {
  345.     return pieces.join(joinBy)
  346.   }
  347.   return pieces
  348. }
  349.  
  350. // noinspection JSUnusedGlobalSymbols
  351. export default class QBittorrent implements TorrentClient {
  352.   readonly version = 'v0.2.0'; // 这个是我们实现的版本号
  353.   readonly config: TorrentClientConfig;
  354.  
  355.   private qbtVersion?: string;
  356.   private qbtApiType?: QbittorrentApiType;
  357.  
  358.   private digestCont: number = 0;
  359.  
  360.   constructor (options: Partial<TorrentClientConfig> = {}) {
  361.     this.config = { ...clientConfig, ...options }
  362.   }
  363.  
  364.   getApiEndpointRequestConfig (endpoint: QbittorrentApiEndPoint): AxiosRequestConfig {
  365.     return QbittorrentApiMap[this.qbtApiType as QbittorrentApiType][endpoint]
  366.   }
  367.  
  368.   async getQbtApiVersion (): Promise<QbittorrentApiType> {
  369.     if (!this.qbtApiType) {
  370.       try {
  371.         /**
  372.          * 首先尝试请求 Current 的 /api/v2/app/version 接口,
  373.          * 如果正常,则直接设置 this.qbtVersion 和 this.qbtApiType
  374.          */
  375.         const { data } = await axios.get('/api/v2/app/version', { baseURL: this.config.address })
  376.         this.qbtVersion = data
  377.         this.qbtApiType = 'Current'
  378.       } catch (e) {
  379.         /**
  380.          * 此时我们会遇到 401, 403, 404 三种情况
  381.          *  - 401: Obsolete ,需要通过 Digest Auth 认证
  382.          *  - 403: Current , 需要登录
  383.          *  - 404: Obsolete 通过 Digest Auth 认证
  384.          *         Previous 转而通过 /version/qbittorrent 接口确定
  385.          */
  386.         const responseStatus = (e as AxiosError).response?.status
  387.         if (responseStatus === 401) { // 'Obsolete' 版 此时会遇到 http code 401
  388.           this.qbtVersion = 'v3.1.0' // 直接设定为 v3.1.0 因为我们实在无法从WebApi中确定 Version
  389.           this.qbtApiType = 'Obsolete'
  390.         } else if (responseStatus === 403) {
  391.           this.qbtApiType = 'Current'
  392.         } else if (responseStatus === 404) {
  393.           // 对于 404 的情况,我们需要进一步确认 qbtApiType 是 'Previous' 还是 'Obsolete'
  394.           try {
  395.             const { data } = await axios.get('/version/qbittorrent', { baseURL: this.config.address })
  396.             this.qbtVersion = data
  397.             this.qbtApiType = 'Previous'
  398.           } catch (e) {
  399.             const responseStatus = (e as AxiosError).response?.status
  400.             if (responseStatus === 404) { // 'Obsolete' 版 此时会遇到 http code 404
  401.               this.qbtVersion = 'v3.1.0'
  402.               this.qbtApiType = 'Obsolete'
  403.             }
  404.           }
  405.         }
  406.       }
  407.     }
  408.  
  409.     return this.qbtApiType!
  410.   }
  411.  
  412.   async ping (): Promise<boolean> {
  413.     // 通过 preferences 接口来确定是否登录成功,因为此接口都需要认证通过才能获得具体情况
  414.     try {
  415.       const { data } = await this.request('preferences')
  416.       // eslint-disable-next-line no-prototype-builtins
  417.       return 'locale' in data
  418.     } catch (e) {
  419.       return false
  420.     }
  421.   }
  422.  
  423.   async request (endpoint: QbittorrentApiEndPoint, config: AxiosRequestConfig = {}): Promise<AxiosResponse> {
  424.     const qbtApiVersion = await this.getQbtApiVersion()
  425.  
  426.     const opts = {
  427.       baseURL: this.config.address,
  428.       timeout: this.config.timeout,
  429.       ...QbittorrentApiMap[qbtApiVersion][endpoint],
  430.       ...config
  431.     }
  432.  
  433.     try {
  434.       return await axios.request(opts)
  435.     } catch (err) {
  436.       if (qbtApiVersion === 'Obsolete') {
  437.         if (err.response.status === 401 && err.response.headers['www-authenticate']) {
  438.           const authDetails = err.response.headers['www-authenticate'].split(', ').map((v:string) => v.split('='))
  439.  
  440.           ++this.digestCont
  441.           const nonceCount = ('00000000' + this.digestCont).slice(-8)
  442.           const cnonce = crypto.randomBytes(24).toString('hex')
  443.  
  444.           const realm = authDetails[0][1].replace(/"/g, '')
  445.           const nonce = authDetails[2][1].replace(/"/g, '')
  446.  
  447.           const md5 = (str:string) => crypto.createHash('md5').update(str).digest('hex')
  448.  
  449.           const HA1 = md5(`${this.config.username}:${realm}:${this.config.password}`)
  450.           const path = urlparse(opts.url!).pathname
  451.           const HA2 = md5(`${opts.method || 'GET'}:${path}`)
  452.           const response = md5(`${HA1}:${nonce}:${nonceCount}:${cnonce}:auth:${HA2}`)
  453.  
  454.           const authorization = `Digest username="${this.config.username}",realm="${realm}",` +
  455.             `nonce="${nonce}",uri="${path}",qop="auth",algorithm="MD5",` +
  456.             `response="${response}",nc="${nonceCount}",cnonce="${cnonce}"`
  457.  
  458.           if (opts.headers) {
  459.             opts.headers.authorization = authorization
  460.           } else {
  461.             opts.headers = { authorization }
  462.           }
  463.  
  464.           return await axios.request(opts)
  465.         }
  466.       } else {
  467.         const form = qbtApiVersion === 'Current' ? new FormData() : new URLSearchParams()
  468.         const path = qbtApiVersion === 'Current' ? '/api/v2/auth/login' : '/login'
  469.  
  470.         form.append('username', this.config.username)
  471.         form.append('password', this.config.password)
  472.         await axios.post(path, form, {
  473.           baseURL: this.config.address,
  474.           timeout: this.config.timeout,
  475.           withCredentials: true
  476.         })
  477.  
  478.         return await axios.request(opts)
  479.       }
  480.  
  481.       throw err
  482.     }
  483.   }
  484.  
  485.   async addTorrent (urls: string, options: Partial<QbittorrentAddTorrentOptions> = {}): Promise<boolean> {
  486.     const formData = new FormData()
  487.  
  488.     // 处理链接
  489.     if (urls.startsWith('magnet:') || !options.localDownload) {
  490.       formData.append('urls', urls)
  491.     } else if (options.localDownload) {
  492.       const req = await axios.get(urls, {
  493.         responseType: 'blob'
  494.       })
  495.       // FIXME 使用
  496.       formData.append('torrents', req.data, String(random(0, 4096, true)) + '.torrent')
  497.     }
  498.  
  499.     // 将通用字段转成qbt字段
  500.     if (options.savePath) {
  501.       formData.append('savepath', options.savePath)
  502.     }
  503.  
  504.     if (options.label) {
  505.       formData.append('category', options.label)
  506.     }
  507.  
  508.     if (options.addAtPaused) {
  509.       formData.append('paused', options.addAtPaused ? 'true' : 'false')
  510.     }
  511.  
  512.     formData.append('useAutoTMM', 'false') // 关闭自动管理
  513.  
  514.     const res = await this.request('addTorrentByUrl', { method: 'post', data: formData })
  515.     return res.data === 'Ok.'
  516.   }
  517.  
  518.   async getTorrentsBy (filter: QbittorrentTorrentFilterRules): Promise<QbittorrentTorrent[]> {
  519.     if (filter.hashes) {
  520.       filter.hashes = normalizePieces(filter.hashes)
  521.     }
  522.  
  523.     // 将通用项处理成qbt对应的项目
  524.     if (filter.complete) {
  525.       filter.filter = 'completed'
  526.       delete filter.complete
  527.     }
  528.  
  529.     const res = await this.request('getTorrents', { params: filter })
  530.     return res.data.map((torrent: rawTorrent) => {
  531.       let state = TorrentState.unknown
  532.  
  533.       switch (torrent.state) {
  534.         case QbittorrentTorrentState.ForcedDL:
  535.         case QbittorrentTorrentState.Downloading:
  536.         case QbittorrentTorrentState.MetaDL:
  537.         case QbittorrentTorrentState.StalledDL:
  538.           state = TorrentState.downloading
  539.           break
  540.         case QbittorrentTorrentState.Allocating:
  541.           // state = 'stalledDL';
  542.           state = TorrentState.queued
  543.           break
  544.         case QbittorrentTorrentState.ForcedUP:
  545.         case QbittorrentTorrentState.Uploading:
  546.         case QbittorrentTorrentState.StalledUP:
  547.           state = TorrentState.seeding
  548.           break
  549.         case QbittorrentTorrentState.PausedDL:
  550.           state = TorrentState.paused
  551.           break
  552.         case QbittorrentTorrentState.PausedUP:
  553.           // state = 'completed';
  554.           state = TorrentState.paused
  555.           break
  556.         case QbittorrentTorrentState.QueuedDL:
  557.         case QbittorrentTorrentState.QueuedUP:
  558.           state = TorrentState.queued
  559.           break
  560.         case QbittorrentTorrentState.CheckingDL:
  561.         case QbittorrentTorrentState.CheckingUP:
  562.         case QbittorrentTorrentState.QueuedForChecking:
  563.         case QbittorrentTorrentState.CheckingResumeData:
  564.         case QbittorrentTorrentState.Moving:
  565.           state = TorrentState.checking
  566.           break
  567.         case QbittorrentTorrentState.Error:
  568.         case QbittorrentTorrentState.Unknown:
  569.         case QbittorrentTorrentState.MissingFiles:
  570.           state = TorrentState.error
  571.           break
  572.         default:
  573.           break
  574.       }
  575.  
  576.       const isCompleted = torrent.progress === 1
  577.  
  578.       return {
  579.         id: torrent.hash,
  580.         infoHash: torrent.hash,
  581.         name: torrent.name,
  582.         state,
  583.         dateAdded: torrent.added_on,
  584.         isCompleted,
  585.         progress: torrent.progress,
  586.         label: torrent.category,
  587.         savePath: torrent.save_path,
  588.         totalSize: torrent.total_size,
  589.         ratio: torrent.ratio,
  590.         uploadSpeed: torrent.upspeed,
  591.         downloadSpeed: torrent.dlspeed,
  592.         totalUploaded: torrent.uploaded,
  593.         totalDownloaded: torrent.downloaded
  594.       } as Torrent
  595.     })
  596.   }
  597.  
  598.   async getAllTorrents (): Promise<QbittorrentTorrent[]> {
  599.     return await this.getTorrentsBy({})
  600.   }
  601.  
  602.   async getTorrent (id: any): Promise<QbittorrentTorrent> {
  603.     return (await this.getTorrentsBy({ hashes: id }))[0]
  604.   }
  605.  
  606.   // 注意方法虽然支持一次对多个种子进行操作,但仍建议每次均只操作一个种子
  607.   async pauseTorrent (hashes: string | string[] | 'all'): Promise<boolean> {
  608.     const params = {
  609.       hashes: hashes === 'all' ? 'all' : normalizePieces(hashes)
  610.     }
  611.     await this.request('stopTorrent', { params })
  612.     return true
  613.   }
  614.  
  615.   // 注意方法虽然支持一次对多个种子进行操作,但仍建议每次均只操作一个种子
  616.   async removeTorrent (hashes: string | string[] | 'all', removeData: boolean = false): Promise<boolean> {
  617.     const params = {
  618.       hashes: hashes === 'all' ? 'all' : normalizePieces(hashes),
  619.       removeData
  620.     }
  621.     await this.request('deleteTorrent', { params })
  622.     return true
  623.   }
  624.  
  625.   // 注意方法虽然支持一次对多个种子进行操作,但仍建议每次均只操作一个种子
  626.   async resumeTorrent (hashes: string | string[] | 'all'): Promise<any> {
  627.     const params = {
  628.       hashes: hashes === 'all' ? 'all' : normalizePieces(hashes)
  629.     }
  630.     await this.request('startTorrent', { params })
  631.     return true
  632.   }
  633. }
  634.  
Add Comment
Please, Sign In to add comment