├── .github └── workflows │ └── build.yml ├── .gitignore ├── LICENSE ├── README.md ├── babel.config.js ├── build └── icon.png ├── package.json ├── public ├── assets │ ├── client │ │ ├── deluge.ico │ │ ├── qbittorrent.ico │ │ ├── rutorrent.ico │ │ └── transmission.ico │ ├── donate │ │ ├── Rhilip │ │ │ ├── alipay.jpg │ │ │ └── wechat.png │ │ └── ledccn │ │ │ └── wechat.png │ ├── iyuu.png │ └── iyuu_gui.png └── index.html ├── resource ├── home.png ├── login.png └── mission.png ├── src ├── App.vue ├── background.ts ├── components │ ├── Aside.vue │ ├── Gratitude │ │ └── ShowPersons.vue │ ├── Mission │ │ └── Reseed.vue │ ├── Setting │ │ ├── BtClient │ │ │ ├── ClientAdd.vue │ │ │ └── ClientEdit.vue │ │ ├── Other │ │ │ ├── Normal.vue │ │ │ └── weChat.vue │ │ └── Site │ │ │ ├── SiteAdd.vue │ │ │ └── SiteEdit.vue │ └── StateCard.vue ├── interfaces │ ├── BtClient │ │ ├── AbstractClient.ts │ │ ├── deluge.ts │ │ ├── qbittorrent.ts │ │ └── transmission.ts │ ├── IYUU │ │ ├── Forms.ts │ │ └── Site.ts │ └── store.ts ├── main.ts ├── plugins │ ├── backup.ts │ ├── btclient │ │ ├── deluge.ts │ │ ├── factory.ts │ │ ├── qbittorrent.ts │ │ └── transmission.ts │ ├── common.ts │ ├── cookies.ts │ ├── dayjs.ts │ ├── element.ts │ ├── iyuu.ts │ ├── mission │ │ └── reseed.ts │ ├── sites │ │ ├── default.ts │ │ ├── factory.ts │ │ ├── hdchina.ts │ │ ├── hdcity.ts │ │ └── hdsky.ts │ └── uuid.ts ├── router │ └── index.ts ├── shims-tsx.d.ts ├── shims-vue.d.ts ├── store │ ├── index.ts │ ├── modules │ │ ├── IYUU.ts │ │ ├── Mission.ts │ │ └── Status.ts │ └── store-accessor.ts └── views │ ├── Gratitude │ ├── Declare.vue │ └── Donate.vue │ ├── Home.vue │ ├── Layer.vue │ ├── Login.vue │ ├── Mission.vue │ └── Setting │ ├── Backup.vue │ ├── BtClient.vue │ ├── Other.vue │ └── Site.vue ├── tsconfig.json ├── vue.config.js └── yarn.lock /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build and release 2 | 3 | on: 4 | push: 5 | tags: 6 | - v* 7 | 8 | jobs: 9 | release: 10 | runs-on: ${{ matrix.os }} 11 | 12 | strategy: 13 | matrix: 14 | os: [macos-latest, ubuntu-latest, windows-latest] 15 | fail-fast: false 16 | 17 | env: 18 | ELECTRON_CACHE: ${{ github.workspace }}/.cache/electron 19 | ELECTRON_BUILDER_CACHE: ${{ github.workspace }}/.cache/electron-builder 20 | 21 | steps: 22 | - name: Check out Git repository 23 | uses: actions/checkout@v1 24 | 25 | - name: Install Node.js, NPM and Yarn 26 | uses: actions/setup-node@v1 27 | with: 28 | node-version: 14 29 | 30 | - name: Get yarn cache 31 | id: yarn-cache 32 | run: echo "::set-output name=dir::$(yarn cache dir)" 33 | 34 | - name: Cache Node.js modules 35 | uses: actions/cache@v1 36 | with: 37 | path: ${{ steps.yarn-cache.outputs.dir }} 38 | key: ${{ runner.os }}-yarn-${{ hashFiles(format('{0}{1}', github.workspace, '/yarn.lock')) }} 39 | restore-keys: | 40 | ${{ runner.os }}-yarn- 41 | 42 | - name: Cache Electron 43 | uses: actions/cache@v1 44 | with: 45 | path: ${{ github.workspace }}/.cache/electron 46 | key: ${{ runner.os }}-electron-cache-${{ hashFiles(format('{0}{1}', github.workspace, '/yarn.lock')) }} 47 | restore-keys: | 48 | ${{ runner.os }}-electron-cache- 49 | 50 | - name: Cache Electron-Builder 51 | uses: actions/cache@v1 52 | with: 53 | path: ${{ github.workspace }}/.cache/electron-builder 54 | key: ${{ runner.os }}-electron-builder-cache-${{ hashFiles(format('{0}{1}', github.workspace, '/yarn.lock')) }} 55 | restore-keys: | 56 | ${{ runner.os }}-electron-builder-cache- 57 | 58 | - name: Install dependencies 59 | run: yarn install 60 | 61 | - name: Build/release Electron app 62 | uses: samuelmeuli/action-electron-builder@v1 63 | with: 64 | # GitHub token, automatically provided to the action 65 | # (No need to define this secret in the repo settings) 66 | github_token: ${{ secrets.github_token }} 67 | 68 | # If the commit is tagged with a version (e.g. "v1.0.0"), 69 | # release the app after building 70 | release: ${{ startsWith(github.ref, 'refs/tags/v') }} 71 | use_vue_cli: 'true' 72 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | /dist 4 | 5 | # local env files 6 | .env.local 7 | .env.*.local 8 | 9 | # Log files 10 | npm-debug.log* 11 | yarn-debug.log* 12 | yarn-error.log* 13 | pnpm-debug.log* 14 | 15 | # Editor directories and files 16 | .idea 17 | .vscode 18 | *.suo 19 | *.ntvs* 20 | *.njsproj 21 | *.sln 22 | *.sw? 23 | 24 | #Electron-builder output 25 | /dist_electron -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![](public/assets/iyuu_gui.png) 2 | 3 | # IYUU GUI 4 | 5 | 这是一个基于IYUU提供的API,产生的一个可视化操作项目。 6 | 目的是为了降低直接上手PHP版IYUUAutoReseed的难度。 7 | 8 | ## 各级页面预览 9 | 10 | - 登录页 11 | 12 | ![](resource/login.png) 13 | 14 | - 首页 15 | 16 | ![](resource/home.png) 17 | 18 | - 任务启动页 19 | 20 | ![](resource/mission.png) 21 | 22 | ## 任务计划 23 | 24 | - [ ] 完善 `IYUU GUI` 的文档 25 | - [ ] 支持转种任务的建立 26 | - [ ] 支持其他类型的下载客户端 27 | 28 | 29 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [ 3 | '@vue/cli-plugin-babel/preset' 4 | ] 5 | } 6 | -------------------------------------------------------------------------------- /build/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Rhilip/IYUU-GUI/992931c23f5db7c38927f809ee2158dde36ae626/build/icon.png -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "iyuu", 3 | "version": "1.0.8", 4 | "author": "Rhilip", 5 | "license": "AGPL-3.0", 6 | "description": "Another Reseed Tools (GUI) powered by IYUU api", 7 | "private": true, 8 | "scripts": { 9 | "serve": "vue-cli-service serve", 10 | "build": "vue-cli-service build", 11 | "lint": "vue-cli-service lint", 12 | "electron:build": "vue-cli-service electron:build", 13 | "electron:serve": "vue-cli-service electron:serve", 14 | "postinstall": "electron-builder install-app-deps", 15 | "postuninstall": "electron-builder install-app-deps" 16 | }, 17 | "main": "background.js", 18 | "dependencies": { 19 | "axios": "^0.21.1", 20 | "cookie": "^0.4.1", 21 | "core-js": "^3.6.5", 22 | "dayjs": "^1.8.29", 23 | "electron-updater": "^4.3.1", 24 | "element-ui": "^2.4.5", 25 | "file-saver": "^2.0.2", 26 | "jsonar": "^1.8.0", 27 | "lodash": "^4.17.15", 28 | "url-join": "^4.0.1", 29 | "vue": "^2.6.11", 30 | "vue-class-component": "^7.2.3", 31 | "vue-property-decorator": "^8.4.2", 32 | "vue-router": "^3.2.0", 33 | "vue-uuid": "^2.0.2", 34 | "vuex": "^3.4.0", 35 | "vuex-electron": "MaverickMartyn/vuex-electron" 36 | }, 37 | "devDependencies": { 38 | "@types/cookie": "^0.4.0", 39 | "@types/electron-devtools-installer": "^2.2.0", 40 | "@types/file-saver": "^2.0.1", 41 | "@types/lodash": "^4.14.157", 42 | "@types/node": "^12.0.22", 43 | "@types/url-join": "^4.0.0", 44 | "@typescript-eslint/eslint-plugin": "^2.33.0", 45 | "@typescript-eslint/parser": "^2.33.0", 46 | "@vue/cli-plugin-babel": "~4.4.0", 47 | "@vue/cli-plugin-eslint": "~4.4.0", 48 | "@vue/cli-plugin-router": "~4.4.0", 49 | "@vue/cli-plugin-typescript": "~4.4.0", 50 | "@vue/cli-plugin-vuex": "~4.4.0", 51 | "@vue/cli-service": "~4.4.0", 52 | "@vue/eslint-config-typescript": "^5.0.2", 53 | "babel-eslint": "^10.1.0", 54 | "electron": "^8.4.0", 55 | "electron-devtools-installer": "^3.1.0", 56 | "eslint": "^6.7.2", 57 | "eslint-plugin-vue": "^6.2.2", 58 | "typescript": "~3.9.3", 59 | "vue-cli-plugin-electron-builder": "~2.0.0-rc.4", 60 | "vue-cli-plugin-element": "~1.0.1", 61 | "vue-cli-plugin-lodash": "~0.1.3", 62 | "vue-template-compiler": "^2.6.11", 63 | "vuex-module-decorators": "^0.17.0" 64 | }, 65 | "eslintConfig": { 66 | "root": true, 67 | "env": { 68 | "node": true 69 | }, 70 | "extends": [ 71 | "plugin:vue/recommended", 72 | "eslint:recommended", 73 | "@vue/typescript" 74 | ], 75 | "parserOptions": { 76 | "parser": "@typescript-eslint/parser" 77 | }, 78 | "rules": { 79 | "arrow-parens": 0, 80 | "generator-star-spacing": 0, 81 | "vue/html-indent": "off", 82 | "vue/max-attributes-per-line": [ 83 | "error", 84 | { 85 | "singleline": 5, 86 | "multiline": { 87 | "max": 5, 88 | "allowFirstLine": true 89 | } 90 | } 91 | ], 92 | "vue/html-closing-bracket-newline": [ 93 | "error", 94 | { 95 | "singleline": "never", 96 | "multiline": "never" 97 | } 98 | ] 99 | } 100 | }, 101 | "browserslist": [ 102 | "electron >= 8.0.0" 103 | ] 104 | } 105 | -------------------------------------------------------------------------------- /public/assets/client/deluge.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Rhilip/IYUU-GUI/992931c23f5db7c38927f809ee2158dde36ae626/public/assets/client/deluge.ico -------------------------------------------------------------------------------- /public/assets/client/qbittorrent.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Rhilip/IYUU-GUI/992931c23f5db7c38927f809ee2158dde36ae626/public/assets/client/qbittorrent.ico -------------------------------------------------------------------------------- /public/assets/client/rutorrent.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Rhilip/IYUU-GUI/992931c23f5db7c38927f809ee2158dde36ae626/public/assets/client/rutorrent.ico -------------------------------------------------------------------------------- /public/assets/client/transmission.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Rhilip/IYUU-GUI/992931c23f5db7c38927f809ee2158dde36ae626/public/assets/client/transmission.ico -------------------------------------------------------------------------------- /public/assets/donate/Rhilip/alipay.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Rhilip/IYUU-GUI/992931c23f5db7c38927f809ee2158dde36ae626/public/assets/donate/Rhilip/alipay.jpg -------------------------------------------------------------------------------- /public/assets/donate/Rhilip/wechat.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Rhilip/IYUU-GUI/992931c23f5db7c38927f809ee2158dde36ae626/public/assets/donate/Rhilip/wechat.png -------------------------------------------------------------------------------- /public/assets/donate/ledccn/wechat.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Rhilip/IYUU-GUI/992931c23f5db7c38927f809ee2158dde36ae626/public/assets/donate/ledccn/wechat.png -------------------------------------------------------------------------------- /public/assets/iyuu.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Rhilip/IYUU-GUI/992931c23f5db7c38927f809ee2158dde36ae626/public/assets/iyuu.png -------------------------------------------------------------------------------- /public/assets/iyuu_gui.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Rhilip/IYUU-GUI/992931c23f5db7c38927f809ee2158dde36ae626/public/assets/iyuu_gui.png -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 13 |
14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /resource/home.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Rhilip/IYUU-GUI/992931c23f5db7c38927f809ee2158dde36ae626/resource/home.png -------------------------------------------------------------------------------- /resource/login.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Rhilip/IYUU-GUI/992931c23f5db7c38927f809ee2158dde36ae626/resource/login.png -------------------------------------------------------------------------------- /resource/mission.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Rhilip/IYUU-GUI/992931c23f5db7c38927f809ee2158dde36ae626/resource/mission.png -------------------------------------------------------------------------------- /src/App.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 15 | 16 | 25 | -------------------------------------------------------------------------------- /src/background.ts: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | import { app, protocol, BrowserWindow, Tray, Menu, ipcMain } from 'electron' 4 | import { createProtocol } from 'vue-cli-plugin-electron-builder/lib' 5 | import installExtension, { VUEJS_DEVTOOLS } from 'electron-devtools-installer' 6 | import { autoUpdater } from "electron-updater" 7 | import * as path from "path"; 8 | const isDevelopment = process.env.NODE_ENV !== 'production' 9 | const packageInfo = require('../package.json') 10 | 11 | // Keep a global reference of the window object, if you don't, the window will 12 | // be closed automatically when the JavaScript object is garbage collected. 13 | let win: BrowserWindow | null 14 | let tray: Tray | null 15 | 16 | const gotTheLock = app.requestSingleInstanceLock() 17 | 18 | // webSecurity is already disabled in BrowserWindow. However, it seems there is 19 | // a bug in Electron 9 https://github.com/electron/electron/issues/23664. There 20 | // is workaround suggested in the issue 21 | app.commandLine.appendSwitch('disable-features', 'OutOfBlinkCors'); 22 | 23 | // Scheme must be registered before the app is ready 24 | protocol.registerSchemesAsPrivileged([ 25 | { scheme: 'app', privileges: { secure: true, standard: true } } 26 | ]) 27 | 28 | declare var __static: string; 29 | 30 | function createWindow() { 31 | // Create the browser window. 32 | win = new BrowserWindow({ 33 | width: 1000, 34 | height: 700, 35 | icon: path.join(__static, 'assets/iyuu.png'), 36 | 37 | title: `IYUU GUI v${packageInfo.version}`, 38 | 39 | useContentSize: true, 40 | webPreferences: { 41 | // Use pluginOptions.nodeIntegration, leave this alone 42 | // See nklayman.github.io/vue-cli-plugin-electron-builder/guide/security.html#node-integration for more info 43 | nodeIntegration: (process.env 44 | .ELECTRON_NODE_INTEGRATION as unknown) as boolean, 45 | 46 | // 直接使用这个方式,关闭chrome的CORS保护 47 | // refs: https://github.com/SimulatedGREG/electron-vue/issues/387 48 | webSecurity: false as boolean, 49 | } 50 | }) 51 | 52 | // 关闭顶端菜单栏 53 | win.setMenu(null) 54 | 55 | // 禁止更改窗口大小 56 | win.setResizable(false) 57 | 58 | if (process.env.WEBPACK_DEV_SERVER_URL) { 59 | // Load the url of the dev server if in development mode 60 | win.loadURL(process.env.WEBPACK_DEV_SERVER_URL as string) 61 | if (!process.env.IS_TEST) win.webContents.openDevTools() 62 | } else { 63 | createProtocol('app') 64 | // Load the index.html when not in development 65 | win.loadURL('app://./index.html') 66 | autoUpdater.checkForUpdatesAndNotify() 67 | } 68 | 69 | initTray() 70 | 71 | win.on('close', (e: Event) => { 72 | e.preventDefault() 73 | win && win.hide(); 74 | }) 75 | 76 | win.on('closed', () => { 77 | win = null 78 | tray = null 79 | }) 80 | } 81 | 82 | function initTray() { 83 | tray = new Tray(path.join(__static, 'assets/iyuu.png')) 84 | const contextMenu = Menu.buildFromTemplate([ 85 | { 86 | label: '退出', click: () => { 87 | app.quit() 88 | } 89 | } 90 | ]) 91 | 92 | tray.on('click', () => { 93 | win && win.show(); 94 | }) 95 | 96 | // Call this again for Linux because we modified the context menu 97 | tray.setContextMenu(contextMenu) 98 | } 99 | 100 | if (!gotTheLock) { 101 | app.quit() 102 | } else { 103 | app.on('second-instance', (event, commandLine, workingDirectory) => { 104 | // Someone tried to run a second instance, we should focus our window. 105 | if (win) { 106 | if (win.isMinimized()) win.restore() 107 | if (!win.isVisible()) win.show() 108 | if (!win.isFocused()) win.focus() 109 | } 110 | }) 111 | 112 | // Quit when all windows are closed. 113 | app.on('window-all-closed', () => { 114 | // On macOS it is common for applications and their menu bar 115 | // to stay active until the user quits explicitly with Cmd + Q 116 | if (process.platform !== 'darwin') { 117 | app.quit() 118 | } 119 | }) 120 | 121 | app.on('before-quit', () => { 122 | win && win.removeAllListeners('close'); 123 | win && win.close(); 124 | }); 125 | 126 | app.on('activate', () => { 127 | // On macOS it's common to re-create a window in the app when the 128 | // dock icon is clicked and there are no other windows open. 129 | if (win === null) { 130 | createWindow() 131 | } 132 | }) 133 | 134 | app.on('certificate-error', (event, webContents, url, error, certificate, callback) => { 135 | // Verification logic. 136 | event.preventDefault() 137 | callback(true) 138 | }) 139 | 140 | // This method will be called when Electron has finished 141 | // initialization and is ready to create browser windows. 142 | // Some APIs can only be used after this event occurs. 143 | app.on('ready', async () => { 144 | if (isDevelopment && !process.env.IS_TEST) { 145 | // Install Vue Devtools 146 | try { 147 | await installExtension(VUEJS_DEVTOOLS) 148 | } catch (e) { 149 | console.error('Vue Devtools failed to install:', e.toString()) 150 | } 151 | } 152 | createWindow() 153 | }) 154 | 155 | ipcMain.on('close-me', (evt, arg) => { 156 | app.quit() 157 | }) 158 | } 159 | 160 | // Exit cleanly on request from parent process in development mode. 161 | if (isDevelopment) { 162 | if (process.platform === 'win32') { 163 | process.on('message', (data) => { 164 | if (data === 'graceful-exit') { 165 | app.quit() 166 | } 167 | }) 168 | } else { 169 | process.on('SIGTERM', () => { 170 | app.quit() 171 | }) 172 | } 173 | } 174 | -------------------------------------------------------------------------------- /src/components/Aside.vue: -------------------------------------------------------------------------------- 1 | 63 | 64 | 88 | 89 | -------------------------------------------------------------------------------- /src/components/Gratitude/ShowPersons.vue: -------------------------------------------------------------------------------- 1 | 20 | 21 | 44 | 45 | -------------------------------------------------------------------------------- /src/components/Mission/Reseed.vue: -------------------------------------------------------------------------------- 1 | 63 | 64 | 194 | 195 | -------------------------------------------------------------------------------- /src/components/Setting/BtClient/ClientAdd.vue: -------------------------------------------------------------------------------- 1 | 67 | 68 | 169 | 170 | -------------------------------------------------------------------------------- /src/components/Setting/BtClient/ClientEdit.vue: -------------------------------------------------------------------------------- 1 | 41 | 42 | 142 | 143 | -------------------------------------------------------------------------------- /src/components/Setting/Other/Normal.vue: -------------------------------------------------------------------------------- 1 | 24 | 25 | 60 | 61 | -------------------------------------------------------------------------------- /src/components/Setting/Other/weChat.vue: -------------------------------------------------------------------------------- 1 | 23 | 24 | 54 | 55 | -------------------------------------------------------------------------------- /src/components/Setting/Site/SiteAdd.vue: -------------------------------------------------------------------------------- 1 | 66 | 67 | 191 | 192 | 195 | -------------------------------------------------------------------------------- /src/components/Setting/Site/SiteEdit.vue: -------------------------------------------------------------------------------- 1 | 52 | 166 | 167 | 170 | -------------------------------------------------------------------------------- /src/components/StateCard.vue: -------------------------------------------------------------------------------- 1 | 14 | 15 | 32 | 33 | -------------------------------------------------------------------------------- /src/interfaces/BtClient/AbstractClient.ts: -------------------------------------------------------------------------------- 1 | // 此处为构建统一的下载器模型提供可能 2 | 3 | export interface TorrentClient { 4 | config: TorrentClientConfig; 5 | 6 | ping: () => Promise; 7 | 8 | getAllTorrents: () => Promise 9 | getTorrentsBy: (filter: TorrentFilterRules) => Promise 10 | getTorrent: (id: any) => Promise; 11 | 12 | addTorrent: (url: string, options?: Partial) => Promise; 13 | pauseTorrent: (id: any) => Promise; 14 | resumeTorrent: (id: any) => Promise; 15 | removeTorrent: (id: any, removeData?: boolean) => Promise; 16 | } 17 | 18 | export type clientType = 'qbittorrent' | 'transmission' | 'deluge' | 'rtorrent' 19 | 20 | export interface TorrentClientConfig { 21 | /** 22 | * The uuid of client, it's automatically generate by uuid4 when add client 23 | */ 24 | uuid: string; 25 | 26 | type: clientType; 27 | 28 | /** 29 | * The name of client which can help users recognise it quickly 30 | */ 31 | name: string; 32 | 33 | /** 34 | * The full url of torrent client webapi, like: 35 | * - transmission: http://ip:port/transmission/rpc 36 | * - qbittorrent: http://ip:port/ 37 | */ 38 | address: string; 39 | 40 | username?: string; 41 | password?: string; 42 | 43 | /** 44 | * request timeout 45 | */ 46 | timeout?: number; 47 | } 48 | 49 | export interface Torrent { 50 | id: string | number; 51 | infoHash: string; 52 | 53 | name: string; 54 | 55 | /** 56 | * progress percent out of 100 57 | */ 58 | progress: number; 59 | isCompleted: boolean; 60 | 61 | /** 62 | * 1:1 is 1, half seeded is 0.5 63 | */ 64 | ratio: number; 65 | 66 | /** 67 | * date as iso string 68 | */ 69 | dateAdded: string; 70 | 71 | savePath: string; 72 | label?: string; 73 | state: TorrentState; 74 | 75 | /** 76 | * total size of the torrent, in bytes 77 | */ 78 | totalSize: number; 79 | } 80 | 81 | export interface TorrentFilterRules { 82 | ids?: any; 83 | complete?: boolean; 84 | } 85 | 86 | export interface AddTorrentOptions { 87 | /** 88 | * 是否本地下载 89 | */ 90 | localDownload: boolean; 91 | 92 | /** 93 | * 是否将种子置于暂停状态 94 | */ 95 | addAtPaused: boolean; 96 | 97 | /** 98 | * 种子下载地址 99 | */ 100 | savePath: string; 101 | 102 | /** 103 | * called a label in some clients and a category in others 104 | * Notice: Some clients didn't support it 105 | */ 106 | label?: string; 107 | } 108 | 109 | export enum TorrentState { 110 | downloading = 'downloading', 111 | seeding = 'seeding', 112 | paused = 'paused', 113 | queued = 'queued', 114 | checking = 'checking', 115 | error = 'error', 116 | unknown = 'unknown', 117 | } 118 | 119 | -------------------------------------------------------------------------------- /src/interfaces/BtClient/deluge.ts: -------------------------------------------------------------------------------- 1 | import {TorrentFilterRules} from "@/interfaces/BtClient/AbstractClient"; 2 | 3 | 4 | export type DelugeMethod = 5 | 'auth.login' | 'web.update_ui' | 'core.get_torrents_status' | 6 | 'core.add_torrent_url' | 'core.add_torrent_file' | 7 | 'core.remove_torrent' | 'core.pause_torrent' | 'core.resume_torrent' 8 | 9 | export interface DelugeDefaultResponse { 10 | /** 11 | * mostly usless id that increments with every request 12 | */ 13 | id: number; 14 | error: null | string; 15 | result: any; 16 | } 17 | 18 | export type DelugeTorrentField = 19 | "comment" 20 | | "active_time" 21 | | "is_seed" 22 | | "hash" 23 | | "upload_payload_rate" 24 | | "move_completed_path" 25 | | "private" 26 | | "total_payload_upload" 27 | | "paused" 28 | | "seed_rank" 29 | | "seeding_time" 30 | | "max_upload_slots" 31 | | "prioritize_first_last" 32 | | "distributed_copies" 33 | | "download_payload_rate" 34 | | "message" 35 | | "num_peers" 36 | | "max_download_speed" 37 | | "max_connections" 38 | | "compact" 39 | | "ratio" 40 | | "total_peers" 41 | | "total_size" 42 | | "total_wanted" 43 | | "state" 44 | | "file_priorities" 45 | | "max_upload_speed" 46 | | "remove_at_ratio" 47 | | "tracker" 48 | | "save_path" 49 | | "progress" 50 | | "time_added" 51 | | "tracker_host" 52 | | "total_uploaded" 53 | | "files" 54 | | "total_done" 55 | | "num_pieces" 56 | | "tracker_status" 57 | | "total_seeds" 58 | | "move_on_completed" 59 | | "next_announce" 60 | | "stop_at_ratio" 61 | | "file_progress" 62 | | "move_completed" 63 | | "piece_length" 64 | | "all_time_download" 65 | | "move_on_completed_path" 66 | | "num_seeds" 67 | | "peers" 68 | | "name" 69 | | "trackers" 70 | | "total_payload_download" 71 | | "is_auto_managed" 72 | | "seeds_peers_ratio" 73 | | "queue" 74 | | "num_files" 75 | | "eta" 76 | | "stop_ratio" 77 | | "is_finished" 78 | | "label" // if they don't have the label plugin it shouldn't fail 79 | 80 | export interface DelugeRawTorrent { 81 | hash: string, 82 | name: string, 83 | progress: number, 84 | ratio: number, 85 | time_added: number, 86 | save_path: string, 87 | 'label'?: string, 88 | state: 'Downloading' | 'Seeding' | 'Active' | 'Paused' | 'Queued' | 'Checking' | 'Error', 89 | 'total_size': number 90 | } 91 | 92 | export interface DelugeTorrentFilterRules extends TorrentFilterRules { 93 | hash?: string, 94 | state?: string 95 | } 96 | 97 | 98 | export interface DelugeBooleanStatus extends DelugeDefaultResponse { 99 | result: boolean; 100 | } -------------------------------------------------------------------------------- /src/interfaces/BtClient/qbittorrent.ts: -------------------------------------------------------------------------------- 1 | import { 2 | AddTorrentOptions, 3 | Torrent, 4 | TorrentClientConfig, 5 | TorrentFilterRules 6 | } from "@/interfaces/BtClient/AbstractClient"; 7 | 8 | type TrueFalseStr = 'true' | 'false'; 9 | 10 | export interface QbittorrentTorrent extends Torrent { 11 | id: string; 12 | } 13 | 14 | 15 | export interface QbittorrentTorrentClientConfig extends TorrentClientConfig { 16 | username: string; 17 | password: string; 18 | } 19 | 20 | export interface QbittorrentTorrentFilterRules extends TorrentFilterRules { 21 | hashes?: string|string[]; 22 | filter?: QbittorrentTorrentFilters; 23 | category?: string; 24 | sort?: string; 25 | offset?: number; 26 | reverse?: boolean|TrueFalseStr; 27 | } 28 | 29 | export interface QbittorrentAddTorrentOptions extends AddTorrentOptions { 30 | /** 31 | * Download folder 32 | */ 33 | savepath: string; 34 | /** 35 | * Cookie sent to download the .torrent file 36 | */ 37 | cookie: string; 38 | /** 39 | * Category for the torrent 40 | */ 41 | category: string; 42 | /** 43 | * Skip hash checking. Possible values are true, false (default) 44 | */ 45 | skip_checking: TrueFalseStr; 46 | /** 47 | * Add torrents in the paused state. Possible values are true, false (default) 48 | */ 49 | paused: TrueFalseStr; 50 | /** 51 | * Create the root folder. Possible values are true, false, unset (default) 52 | */ 53 | root_folder: TrueFalseStr | null; 54 | /** 55 | * Rename torrent 56 | */ 57 | rename: string; 58 | /** 59 | * Set torrent upload speed limit. Unit in bytes/second 60 | */ 61 | upLimit: number; 62 | /** 63 | * Set torrent download speed limit. Unit in bytes/second 64 | */ 65 | dlLimit: number; 66 | /** 67 | * Whether Automatic Torrent Management should be used, disables use of savepath 68 | */ 69 | useAutoTMM: TrueFalseStr; 70 | /** 71 | * Enable sequential download. Possible values are true, false (default) 72 | */ 73 | sequentialDownload: TrueFalseStr; 74 | /** 75 | * Prioritize download first last piece. Possible values are true, false (default) 76 | */ 77 | firstLastPiecePrio: TrueFalseStr; 78 | } 79 | 80 | export type QbittorrentTorrentFilters = 81 | | 'all' 82 | | 'downloading' 83 | | 'completed' 84 | | 'paused' 85 | | 'active' 86 | | 'inactive' 87 | | 'resumed' 88 | | 'stalled' 89 | | 'stalled_uploading' 90 | | 'stalled_downloading'; 91 | 92 | export enum QbittorrentTorrentState { 93 | /** 94 | * Some error occurred, applies to paused torrents 95 | */ 96 | Error = 'error', 97 | /** 98 | * Torrent is paused and has finished downloading 99 | */ 100 | PausedUP = 'pausedUP', 101 | /** 102 | * Torrent is paused and has NOT finished downloading 103 | */ 104 | PausedDL = 'pausedDL', 105 | /** 106 | * Queuing is enabled and torrent is queued for upload 107 | */ 108 | QueuedUP = 'queuedUP', 109 | /** 110 | * Queuing is enabled and torrent is queued for download 111 | */ 112 | QueuedDL = 'queuedDL', 113 | /** 114 | * Torrent is being seeded and data is being transferred 115 | */ 116 | Uploading = 'uploading', 117 | /** 118 | * Torrent is being seeded, but no connection were made 119 | */ 120 | StalledUP = 'stalledUP', 121 | /** 122 | * Torrent has finished downloading and is being checked; this status also applies to preallocation (if enabled) and checking resume data on qBt startup 123 | */ 124 | CheckingUP = 'checkingUP', 125 | /** 126 | * Same as checkingUP, but torrent has NOT finished downloading 127 | */ 128 | CheckingDL = 'checkingDL', 129 | /** 130 | * Torrent is being downloaded and data is being transferred 131 | */ 132 | Downloading = 'downloading', 133 | /** 134 | * Torrent is being downloaded, but no connection were made 135 | */ 136 | StalledDL = 'stalledDL', 137 | /** 138 | * Torrent is forced to downloading to ignore queue limit 139 | */ 140 | ForcedDL = 'forcedDL', 141 | /** 142 | * Torrent is forced to uploading and ignore queue limit 143 | */ 144 | ForcedUP = 'forcedUP', 145 | /** 146 | * Torrent has just started downloading and is fetching metadata 147 | */ 148 | MetaDL = 'metaDL', 149 | /** 150 | * Torrent is allocating disk space for download 151 | */ 152 | Allocating = 'allocating', 153 | QueuedForChecking = 'queuedForChecking', 154 | /** 155 | * Checking resume data on qBt startup 156 | */ 157 | CheckingResumeData = 'checkingResumeData', 158 | /** 159 | * Torrent is moving to another location 160 | */ 161 | Moving = 'moving', 162 | /** 163 | * Unknown status 164 | */ 165 | Unknown = 'unknown', 166 | /** 167 | * Torrent data files is missing 168 | */ 169 | MissingFiles = 'missingFiles', 170 | } 171 | 172 | export interface rawTorrent { 173 | /** 174 | * Torrent name 175 | */ 176 | name: string; 177 | hash: string; 178 | magnet_uri: string; 179 | /** 180 | * datetime in seconds 181 | */ 182 | added_on: number; 183 | /** 184 | * Torrent size 185 | */ 186 | size: number; 187 | /** 188 | * Torrent progress 189 | */ 190 | progress: number; 191 | /** 192 | * Torrent download speed (bytes/s) 193 | */ 194 | dlspeed: number; 195 | /** 196 | * Torrent upload speed (bytes/s) 197 | */ 198 | upspeed: number; 199 | /** 200 | * Torrent priority (-1 if queuing is disabled) 201 | */ 202 | priority: number; 203 | /** 204 | * Torrent seeds connected to 205 | */ 206 | num_seeds: number; 207 | /** 208 | * Torrent seeds in the swarm 209 | */ 210 | num_complete: number; 211 | /** 212 | * Torrent leechers connected to 213 | */ 214 | num_leechs: number; 215 | /** 216 | * Torrent leechers in the swarm 217 | */ 218 | num_incomplete: number; 219 | /** 220 | * Torrent share ratio 221 | */ 222 | ratio: number; 223 | /** 224 | * Torrent ETA 225 | */ 226 | eta: number; 227 | /** 228 | * Torrent state 229 | */ 230 | state: QbittorrentTorrentState; 231 | /** 232 | * Torrent sequential download state 233 | */ 234 | seq_dl: boolean; 235 | /** 236 | * Torrent first last piece priority state 237 | */ 238 | f_l_piece_prio: boolean; 239 | /** 240 | * Torrent copletion datetime in seconds 241 | */ 242 | completion_on: number; 243 | /** 244 | * Torrent tracker 245 | */ 246 | tracker: string; 247 | /** 248 | * Torrent download limit 249 | */ 250 | dl_limit: number; 251 | /** 252 | * Torrent upload limit 253 | */ 254 | up_limit: number; 255 | /** 256 | * Amount of data downloaded 257 | */ 258 | downloaded: number; 259 | /** 260 | * Amount of data uploaded 261 | */ 262 | uploaded: number; 263 | /** 264 | * Amount of data downloaded since program open 265 | */ 266 | downloaded_session: number; 267 | /** 268 | * Amount of data uploaded since program open 269 | */ 270 | uploaded_session: number; 271 | /** 272 | * Amount of data left to download 273 | */ 274 | amount_left: number; 275 | /** 276 | * Torrent save path 277 | */ 278 | save_path: string; 279 | /** 280 | * Amount of data completed 281 | */ 282 | completed: number; 283 | /** 284 | * Upload max share ratio 285 | */ 286 | max_ratio: number; 287 | /** 288 | * Upload max seeding time 289 | */ 290 | max_seeding_time: number; 291 | /** 292 | * Upload share ratio limit 293 | */ 294 | ratio_limit: number; 295 | /** 296 | * Upload seeding time limit 297 | */ 298 | seeding_time_limit: number; 299 | /** 300 | * Indicates the time when the torrent was last seen complete/whole 301 | */ 302 | seen_complete: number; 303 | /** 304 | * Last time when a chunk was downloaded/uploaded 305 | */ 306 | last_activity: number; 307 | /** 308 | * Size including unwanted data 309 | */ 310 | total_size: number; 311 | time_active: number; 312 | /** 313 | * Category name 314 | */ 315 | category: string; 316 | } -------------------------------------------------------------------------------- /src/interfaces/BtClient/transmission.ts: -------------------------------------------------------------------------------- 1 | import { 2 | AddTorrentOptions, 3 | Torrent, 4 | TorrentClientConfig, 5 | TorrentFilterRules 6 | } from "@/interfaces/BtClient/AbstractClient"; 7 | 8 | export interface TransmissionBaseResponse { 9 | arguments: any; 10 | result: 'success' | string; 11 | tag?: number 12 | } 13 | 14 | export interface TransmissionTorrentGetResponse extends TransmissionBaseResponse { 15 | arguments: { 16 | torrents: rawTorrent[] 17 | } 18 | } 19 | 20 | export interface AddTorrentResponse extends TransmissionBaseResponse { 21 | arguments: { 22 | 'torrent-added': { 23 | id: number; 24 | hashString: string; 25 | name: string; 26 | }; 27 | }; 28 | } 29 | 30 | export type TransmissionTorrentIds = number | Array | 'recently-active' 31 | 32 | export type TransmissionRequestMethod = 33 | 'session-get' | 'session-stats' | 34 | 'torrent-get' | 'torrent-add' | 'torrent-start' | 'torrent-stop' | 'torrent-remove' 35 | 36 | export interface TransmissionAddTorrentOptions extends AddTorrentOptions { 37 | "download-dir": string, 38 | filename: string, 39 | metainfo: string, 40 | paused: boolean, 41 | 42 | } 43 | 44 | export interface TransmissionTorrent extends Torrent { 45 | id: number | string; 46 | } 47 | 48 | export interface TransmissionTorrentFilterRules extends TorrentFilterRules { 49 | ids?: TransmissionTorrentIds; 50 | } 51 | 52 | export interface TransmissionArguments { 53 | 54 | } 55 | 56 | export interface TransmissionTorrentBaseArguments extends TransmissionArguments { 57 | ids?: TransmissionTorrentIds 58 | } 59 | 60 | export interface TransmissionTorrentGetArguments extends TransmissionTorrentBaseArguments { 61 | fields: TransmissionTorrentsField[] 62 | } 63 | 64 | export interface TransmissionTorrentRemoveArguments extends TransmissionTorrentBaseArguments { 65 | 'delete-local-data'?: boolean 66 | } 67 | 68 | export interface TransmissionTorrentClientConfig extends TorrentClientConfig { 69 | 70 | } 71 | 72 | // 这里只写出了部分我们需要的 73 | export interface rawTorrent { 74 | addedDate: number, 75 | id: number, 76 | hashString: string, 77 | isFinished: boolean, 78 | name: string, 79 | percentDone: number, 80 | uploadRatio: number, 81 | downloadDir: string, 82 | status: number, 83 | totalSize: number, 84 | leftUntilDone: number, 85 | labels: string[] 86 | } 87 | 88 | export type TransmissionTorrentsField = 89 | 'activityDate' 90 | | 'addedDate' 91 | | 'bandwidthPriority' 92 | | 'comment' 93 | | 'corruptEver' 94 | | 'creator' 95 | | 'dateCreated' 96 | | 'desiredAvailable' 97 | | 'doneDate' 98 | | 'downloadDir' 99 | | 'downloadedEver' 100 | | 'downloadLimit' 101 | | 'downloadLimited' 102 | | 'editDate' 103 | | 'error' 104 | | 'errorString' 105 | | 'eta' 106 | | 'etaIdle' 107 | | 'files' 108 | | 'fileStats' 109 | | 'hashString' 110 | | 'haveUnchecked' 111 | | 'haveValid' 112 | | 'honorsSessionLimits' 113 | | 'id' 114 | | 'isFinished' 115 | | 'isPrivate' 116 | | 'isStalled' 117 | | 'labels' 118 | | 'leftUntilDone' 119 | | 'magnetLink' 120 | | 'manualAnnounceTime' 121 | | 'maxConnectedPeers' 122 | | 'metadataPercentComplete' 123 | | 'name' 124 | | 'peer-limit' 125 | | 'peers' 126 | | 'peersConnected' 127 | | 'peersFrom' 128 | | 'peersGettingFromUs' 129 | | 'peersSendingToUs' 130 | | 'percentDone' 131 | | 'pieces' 132 | | 'pieceCount' 133 | | 'pieceSize' 134 | | 'priorities' 135 | | 'queuePosition' 136 | | 'rateDownload (B/s)' 137 | | 'rateUpload (B/s)' 138 | | 'recheckProgress' 139 | | 'secondsDownloading' 140 | | 'secondsSeeding' 141 | | 'seedIdleLimit' 142 | | 'seedIdleMode' 143 | | 'seedRatioLimit' 144 | | 'seedRatioMode' 145 | | 'sizeWhenDone' 146 | | 'startDate' 147 | | 'status' 148 | | 'trackers' 149 | | 'trackerStats' 150 | | 'totalSize' 151 | | 'torrentFile' 152 | | 'uploadedEver' 153 | | 'uploadLimit' 154 | | 'uploadLimited' 155 | | 'uploadRatio' 156 | | 'wanted' 157 | | 'webseeds' 158 | | 'webseedsSendingToUs' -------------------------------------------------------------------------------- /src/interfaces/IYUU/Forms.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * 此文件对IYUU的相关API请求参数和相应参数进行描述 3 | * 4 | * 多数接口有简写地址: 5 | * https://api.iyuu.cn/index.php?s=App.Api.Sites 6 | * https://api.iyuu.cn/api/sites 7 | * 8 | * 命名规则示例: 9 | * 接口地址:http://api.iyuu.cn/index.php?s=App.Api.Notify 10 | * 其接口名为 App.Api.Notify 11 | * 则其接口请求参数命名为 apiNotifyRequest 12 | * 接口响应参数命名为 apiNotifyResponse 如果该接口范式,则必须继承 BaseResponse 13 | * 14 | */ 15 | import {Site} from "@/interfaces/IYUU/Site"; 16 | 17 | export interface BaseResponse { 18 | /** 19 | * 状态码,200表示成功,4xx表示客户端非法请求,5xx表示服务器错误 20 | * - 400 ret=400,客户端参数错误或非法请求 21 | * - 404 表示接口服务不存在 22 | * - 406 ret=406,access_token令牌校验不通过 23 | * - 407 ret=407,app_key权限不足,或未知应用 24 | * - 408 ret=408,当前用户禁止使用,或用户未登录 25 | * - 500 表示服务端内部错误 26 | */ 27 | ret: number, 28 | 29 | /** 30 | * 业务数据,由各自接口指定,通常为对象 31 | */ 32 | data: any, 33 | 34 | /** 35 | * 提示信息,失败时的错误提示 36 | */ 37 | msg: string 38 | } 39 | 40 | export interface TorrentInfo { 41 | sid: number, 42 | torrent_id: number, 43 | info_hash: string 44 | } 45 | 46 | /** 47 | * @description 用户登录 48 | * 根据合作站点标识、ID、passkey、爱语飞飞Token进行登录操作 49 | * @url https://api.iyuu.cn/index.php?s=App.User.Login 50 | * @method GET 51 | * @docs https://api.iyuu.cn/docs.php?service=App.User.Login&detail=1&type=fold 52 | */ 53 | export interface userLoginRequest { 54 | token: string, 55 | site: string, 56 | id: string, 57 | passkey: string, 58 | } 59 | 60 | export interface userLoginResponse extends BaseResponse { 61 | /** 62 | * 注意一下,登录成功,data项才是这样,不然是个空字典 63 | * 但是由于Typescript的类型规定,导致使用 Partial<{ ... }> 还是 { ... } | {} 都会报编译错误, 64 | * 所以须先判断ret项 65 | */ 66 | data: { 67 | /** 68 | * 是否登录成功true、失败false 69 | */ 70 | success: boolean, 71 | 72 | /** 73 | * 用户ID 74 | */ 75 | user_id: number, 76 | 77 | /** 78 | * 这个目前文档没列出来,但是实际上有使用,返回格式 79 | * IYUU自动辅种工具:站点******,用户ID:***** 登录成功! 80 | */ 81 | errmsg: string 82 | } 83 | } 84 | 85 | 86 | /** 87 | * @description 获取所有站点信息 88 | * 返回支持的站点列表 89 | * @url https://api.iyuu.cn/index.php?s=App.Api.Sites 90 | * @method GET 91 | * @docs https://api.iyuu.cn/docs.php?service=App.Api.Sites&detail=1&type=fold 92 | */ 93 | export interface apiSitesRequest { 94 | /** 95 | * 爱语飞飞Token 96 | */ 97 | sign: string, 98 | 99 | /** 100 | * 客户端版本号 101 | * 102 | * 版本为空或低于v1.9.1会返回旧数据 { "download_page": "download.php?id={}" } 103 | * 其他情况返回新数据 { "download_page": "download.php?id={}&passkey={passkey}" } 104 | */ 105 | version?: string 106 | } 107 | 108 | export interface apiSitesResponse extends BaseResponse { 109 | data: { 110 | sites: Site[] 111 | } 112 | } 113 | 114 | /** 115 | * @description 查询辅种 116 | * 返回所有辅种数据 infohash索引 117 | * @url http://api.iyuu.cn/index.php?s=App.Api.Infohash 118 | * @method POST 119 | * @docs http://api.iyuu.cn/docs.php?service=App.Api.Infohash&detail=1&type=fold 120 | */ 121 | export interface apiInfohashRequest { 122 | sign: string, 123 | timestamp: number, 124 | version: string, 125 | hash: string[], 126 | sha1: string // sha1(hash) 127 | } 128 | 129 | export interface apiInfohashResponse extends BaseResponse { 130 | data: { 131 | [propName: string]: { 132 | torrent: TorrentInfo[] 133 | } 134 | } 135 | } 136 | 137 | /** 138 | * @description 查询辅种 139 | * 返回所有辅种数据 infohash索引 140 | * @url http://api.iyuu.cn/index.php?s=App.Api.Hash 141 | * @method POST 142 | * @docs http://api.iyuu.cn/docs.php?service=App.Api.Hash&detail=1&type=fold 143 | */ 144 | export interface apiHashRequest extends apiInfohashRequest {} 145 | 146 | export interface apiHashResponse extends BaseResponse { 147 | data: { 148 | hash: string, 149 | torrent: TorrentInfo[] 150 | }[] 151 | } 152 | 153 | /** 154 | * @description 通知接口 155 | * 上报错误种子、异常状态等 156 | * @url http://api.iyuu.cn/index.php?s=App.Api.Notify 157 | * @method GET 158 | * @docs http://api.iyuu.cn/docs.php?service=App.Api.Notify&detail=1&type=fold 159 | */ 160 | export interface apiNotifyRequest { 161 | sigh: string, 162 | site: string, 163 | sid: number, 164 | torrent_id: number, 165 | error: string 166 | } 167 | 168 | export interface apiNotifyResponse extends BaseResponse { 169 | data: { 170 | success: boolean 171 | } 172 | } -------------------------------------------------------------------------------- /src/interfaces/IYUU/Site.ts: -------------------------------------------------------------------------------- 1 | export interface Site { 2 | id: number, 3 | site: string, 4 | base_url: string, 5 | download_page: string, 6 | is_https: number 7 | } 8 | 9 | export interface EnableSite extends Site { 10 | // 这两个应该在添加站点时启用 11 | link: string, 12 | cookies: string, 13 | 14 | // 剩下的应该在编辑站点时使用 15 | /** 16 | * 是否在本地客户端下载种子,然后发送种子内容到下载器 17 | */ 18 | download_torrent: boolean, 19 | 20 | /** 21 | * 下载频率限制,只允许一组 22 | */ 23 | rate_limit: { 24 | // 按总量 25 | maxRequests: number // 单次运行推送总量 26 | requestsDelay: number // 两次下载间隔 27 | } 28 | } -------------------------------------------------------------------------------- /src/interfaces/store.ts: -------------------------------------------------------------------------------- 1 | // 一些不是很适合放在子项里面的 Vuex 中类型定义 2 | 3 | export interface LogInfo { 4 | timestamp: number, 5 | message: string 6 | } -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import Vue from 'vue' 2 | import App from './App.vue' 3 | 4 | import store from './store' 5 | import router from './router' 6 | 7 | import './plugins/element.ts' 8 | import './plugins/uuid.ts' 9 | 10 | Vue.config.productionTip = false 11 | 12 | new Vue({ 13 | store, 14 | router, 15 | render: h => h(App) 16 | }).$mount('#app') 17 | -------------------------------------------------------------------------------- /src/plugins/backup.ts: -------------------------------------------------------------------------------- 1 | import _ from 'lodash' 2 | import UUID from 'uuid' 3 | // @ts-ignore 这个库暂时还没有Typescript支持 4 | import jsonar from 'jsonar' 5 | import FileSaver from 'file-saver' 6 | 7 | import {EnableSite} from "@/interfaces/IYUU/Site"; 8 | import {TorrentClientConfig} from "@/interfaces/BtClient/AbstractClient"; 9 | import {IYUUStore, MissionStore, StatusStore} from "@/store"; 10 | import {isForceDownloadSite} from "@/plugins/sites/factory"; 11 | 12 | const packageInfo = require('../../package.json') 13 | 14 | // 从JSON字段中解析,解析失败返回空对象 15 | function decodeFromJsonString(text: string) { 16 | try { 17 | return JSON.parse(text); 18 | } catch (e) { 19 | return {} 20 | } 21 | } 22 | 23 | export class IYUUAutoReseedBridge { 24 | // 入口方法,内部根据字符串信息判断是使用jsonar库解析PHP Array还是JSON解析 25 | public static decodeFromFile(file: File) { 26 | return new Promise((resolve, reject) => { 27 | const r = new FileReader(); 28 | r.onload = (e) => { 29 | // @ts-ignore 30 | const content: string = e.target.result 31 | 32 | let parsedConfig; 33 | if (content.indexOf(' -1) { 34 | parsedConfig = this.decodeFromPHPArrayString(content) 35 | } else { 36 | parsedConfig = decodeFromJsonString(content) 37 | } 38 | 39 | if (parsedConfig['iyuu.cn'] === IYUUStore.token) { 40 | resolve(parsedConfig) 41 | } else { 42 | reject('配置项未通过检验,或你传入文件中的爱语飞飞令牌与当前登录的不符。') 43 | } 44 | } 45 | r.readAsText(file); 46 | }) 47 | } 48 | 49 | // 从PHPArray中解析,解析失败返回空对象 50 | private static decodeFromPHPArrayString(phpConfigRaw: string) { 51 | const returnFlag = phpConfigRaw.indexOf('return') 52 | const toParsePhpConfig = phpConfigRaw.slice(returnFlag + 6); // return及之前全部忽略 53 | try { 54 | return jsonar.parse(toParsePhpConfig); 55 | } catch (e) { 56 | return {} 57 | } 58 | } 59 | 60 | public static importFromJSON(config: any) { 61 | let clientCount = 0; 62 | let siteCount = 0; 63 | 64 | const clients: any[] = config.default.clients; 65 | for (let i = 0; i < clients.length; i++) { 66 | const client = clients[i] 67 | // 构造config 68 | let mergedClientConfig: TorrentClientConfig = { 69 | type: client.type.toLowerCase(), 70 | name: client.type, 71 | uuid: UUID.v4(), 72 | address: client.host, 73 | username: client.username, 74 | password: client.password, 75 | timeout: (client.type === 'transmission' ? 60 * 2 : 60) * 1e3 76 | } 77 | 78 | // 通过地址判断是不是有重复 79 | if (IYUUStore.enable_clients.findIndex(c => c.address === mergedClientConfig.address) === -1) { 80 | IYUUStore.addEnableClient(mergedClientConfig) 81 | clientCount++ 82 | } 83 | } 84 | 85 | // 遍历我们的未添加sites列表 86 | for (let j = 0; j < IYUUStore.unsignedSites.length; j++) { 87 | let site = IYUUStore.unsignedSites[j] 88 | let mergedSiteConfig: EnableSite = _.merge(site,{ 89 | cookies: "", 90 | download_torrent: false, 91 | link: "", 92 | rate_limit: {maxRequests: 0, requestsDelay: 0}, 93 | }) 94 | if (site.site in config) { 95 | const siteConfigFromPHP = config[site.site] 96 | 97 | // 如果没有cookies和passkey,直接跳过 98 | if (!siteConfigFromPHP.cookies && !siteConfigFromPHP.passkey) { 99 | continue 100 | } 101 | 102 | mergedSiteConfig.cookies = siteConfigFromPHP.cookies 103 | mergedSiteConfig.download_torrent = isForceDownloadSite(site.site) 104 | let link = IYUUStore.siteDownloadLinkTpl(site.site) 105 | if (siteConfigFromPHP.passkey) { 106 | link = link.replace(/{passkey}/ig, siteConfigFromPHP.passkey) 107 | } 108 | if (siteConfigFromPHP.url_replace) { 109 | for (const [key, value] of Object.entries(siteConfigFromPHP.url_replace)) { 110 | if (typeof value === "string") { 111 | link = link.replaceAll(key, value) 112 | } 113 | } 114 | } 115 | 116 | if (siteConfigFromPHP.url_join) { 117 | link += (link.lastIndexOf('?') > -1 ? '&' : '?' ) + siteConfigFromPHP.url_join.join('&') 118 | } 119 | 120 | if (siteConfigFromPHP.limitRule) { 121 | mergedSiteConfig.rate_limit.maxRequests = siteConfigFromPHP.limitRule.count || 0 122 | mergedSiteConfig.rate_limit.requestsDelay = siteConfigFromPHP.limitRule.sleep || 0 123 | } 124 | 125 | if (!link.match(/({[^}]+?})/ig) && link.search('{}') > -1) { 126 | mergedSiteConfig.link = link 127 | IYUUStore.addEnableSite(mergedSiteConfig) 128 | siteCount++ 129 | } 130 | } 131 | } 132 | 133 | return {client: clientCount, site: siteCount} 134 | } 135 | } 136 | 137 | export class ConfigFileBridge { 138 | public static decodeFromFile(file:File) { 139 | return new Promise((resolve, reject) => { 140 | const r = new FileReader(); 141 | r.onload = (e) => { 142 | // @ts-ignore 143 | const content: string = e.target.result 144 | 145 | let parsedConfig = decodeFromJsonString(content); 146 | 147 | if (parsedConfig['token'] === IYUUStore.token) { 148 | resolve(parsedConfig) 149 | } else { 150 | reject('配置项未通过检验,或你传入文件中的爱语飞飞令牌与当前登录的不符。') 151 | } 152 | } 153 | r.readAsText(file); 154 | }) 155 | } 156 | 157 | public static importFromJSON(config: any) { 158 | IYUUStore.restoreFromConfig({ 159 | token: config.token, 160 | sites: config.sites, 161 | clients: config.clients, 162 | apiPreInfoHash: config.config.apiPreInfoHash, 163 | maxRetry: config.config.maxRetry, 164 | weChatNotify: config.config.weChatNotify 165 | }) 166 | StatusStore.restoreFromConfig({ 167 | reseedTorrentCount: config.state.reseedTorrentCount, 168 | startAppCount: config.state.startAppCount, 169 | startMissionCount: config.state.startMissionCount, 170 | }) 171 | MissionStore.restoreFromConfig({ 172 | reseeded: config.hashes.reseeded 173 | }) 174 | } 175 | 176 | public static exportToJSON() { 177 | const exportJSON = { 178 | version: packageInfo.version, 179 | token: IYUUStore.token, 180 | sites: IYUUStore.enable_sites, 181 | clients: IYUUStore.enable_clients, 182 | state: { 183 | startAppCount: StatusStore.startAppCount, 184 | startMissionCount: StatusStore.startMissionCount, 185 | reseedTorrentCount: StatusStore.reseedTorrentCount 186 | }, 187 | hashes: { 188 | reseeded: MissionStore.reseeded 189 | }, 190 | config: { 191 | apiPreInfoHash: IYUUStore.apiPreInfoHash, 192 | maxRetry: IYUUStore.maxRetry, 193 | weChatNotify: IYUUStore.weChatNotify 194 | } 195 | } 196 | 197 | const blob = new Blob([JSON.stringify(exportJSON)], { 198 | type: "text/plain;charset=utf-8" 199 | }) 200 | FileSaver.saveAs(blob, "iyuu_config.json"); 201 | } 202 | } -------------------------------------------------------------------------------- /src/plugins/btclient/deluge.ts: -------------------------------------------------------------------------------- 1 | import { 2 | AddTorrentOptions, 3 | Torrent, 4 | TorrentClient, 5 | TorrentClientConfig, 6 | TorrentState 7 | } from "@/interfaces/BtClient/AbstractClient"; 8 | import { 9 | DelugeBooleanStatus, DelugeDefaultResponse, 10 | DelugeMethod, DelugeRawTorrent, 11 | DelugeTorrentField, DelugeTorrentFilterRules 12 | } from "@/interfaces/BtClient/deluge"; 13 | import urljoin from "url-join"; 14 | import axios, {AxiosResponse} from "axios"; 15 | 16 | 17 | // @ts-ignore 18 | export const defaultDelugeConfig: TorrentClientConfig = { 19 | type: 'deluge', 20 | name: 'deluge', 21 | uuid: '8951af53-c6de-49a2-b2c4-3fb63809f453', 22 | address: 'http://localhost:8112/', 23 | password: '', 24 | timeout: 60 * 1e3 25 | }; 26 | 27 | export default class Deluge implements TorrentClient { 28 | readonly config: TorrentClientConfig; 29 | private readonly address: string; 30 | 31 | private _msgId: number; 32 | private isLogin: boolean = false 33 | 34 | private torrentRequestField: DelugeTorrentField[]= [ 35 | 'hash', 36 | 'name', 37 | 'progress', 38 | 'ratio', 39 | 'time_added', 40 | 'save_path', 41 | 'label', 42 | 'state', 43 | 'total_size' 44 | ] 45 | 46 | constructor(options: Partial) { 47 | this.config = {...defaultDelugeConfig, ...options} 48 | this._msgId = 0 49 | 50 | // 修正服务器地址 51 | let address = this.config.address; 52 | if (address.indexOf('json') === -1) { 53 | address = urljoin(address, '/json') 54 | } 55 | this.address = address 56 | } 57 | 58 | async ping(): Promise { 59 | return await this.login(); 60 | } 61 | 62 | async addTorrent(url: string, options: Partial = {}): Promise { 63 | let delugeOptions = { 64 | add_paused: false 65 | } 66 | 67 | if (options.addAtPaused) { 68 | delugeOptions.add_paused = options.addAtPaused 69 | } 70 | if (options.savePath) { 71 | // @ts-ignore 72 | delugeOptions.download_location = options.savePath 73 | } 74 | 75 | // 由于Deluge添加链接和种子的方法名不一样,分开处理 76 | let method: 'core.add_torrent_file' | 'core.add_torrent_url'; 77 | let params: any; 78 | if (options.localDownload) { // 文件 add_torrent_file 79 | method = 'core.add_torrent_file' 80 | const req = await axios.get(url, { 81 | responseType: 'arraybuffer' 82 | }) 83 | let metainfo = Buffer.from(req.data, 'binary').toString('base64') 84 | params = ['', metainfo, delugeOptions] 85 | } else { // 连接 add_torrent_url 86 | method = 'core.add_torrent_url' 87 | params = [url, delugeOptions] 88 | } 89 | 90 | try { 91 | const res = await this.request(method,params) 92 | const data: DelugeDefaultResponse = res.data 93 | return data.result !== null 94 | } catch (e) { 95 | return false 96 | } 97 | } 98 | 99 | async getAllTorrents(): Promise { 100 | return await this.getTorrentsBy({}) 101 | } 102 | 103 | async getTorrent(id: string): Promise { 104 | // @ts-ignore 105 | return await this.getTorrentsBy({ids: id}) 106 | } 107 | 108 | async getTorrentsBy(filter: DelugeTorrentFilterRules): Promise { 109 | if (filter.ids) { 110 | filter.hash = filter.ids 111 | delete filter.hash 112 | } 113 | 114 | if (filter.complete) { 115 | filter.state = 'Seeding' 116 | delete filter.complete 117 | } 118 | 119 | const req = await this.request('core.get_torrents_status', [ 120 | filter, 121 | this.torrentRequestField, 122 | ]); 123 | 124 | const data: DelugeDefaultResponse = req.data 125 | 126 | // @ts-ignore 127 | return Object.values(data.result).map(t => Deluge._normalizeTorrent(t)); 128 | } 129 | 130 | async pauseTorrent(id: any): Promise { 131 | try { 132 | const req = await this.request('core.pause_torrent', [id]); 133 | const data: DelugeBooleanStatus = req.data 134 | return data.result 135 | } catch (e) { 136 | return false 137 | } 138 | } 139 | 140 | async removeTorrent(id: string, removeData: boolean = false): Promise { 141 | try { 142 | const req = await this.request('core.remove_torrent', [id, removeData]); 143 | const data: DelugeBooleanStatus = req.data 144 | return data.result 145 | } catch (e) { 146 | return false 147 | } 148 | } 149 | 150 | async resumeTorrent(id: any): Promise { 151 | try { 152 | const req = await this.request('core.resume_torrent', [id]); 153 | const data: DelugeBooleanStatus = req.data 154 | return data.result 155 | } catch (e) { 156 | return false 157 | } 158 | } 159 | 160 | private async login(): Promise { 161 | try { 162 | const res = await this.request('auth.login', [this.config.password]) 163 | const data: DelugeBooleanStatus = res.data 164 | this.isLogin = data.result 165 | return data.result 166 | } catch (e) { 167 | return false 168 | } 169 | } 170 | 171 | private async request(method: DelugeMethod, params: any[]): Promise { 172 | if (!this.isLogin && method !=='auth.login') { 173 | await this.login() 174 | } 175 | 176 | return await axios.post(this.address, { 177 | id: this._msgId++, 178 | method: method, 179 | params: params 180 | }, { 181 | headers: { 182 | 'content-type': 'application/json' 183 | } 184 | }) 185 | } 186 | 187 | private static _normalizeTorrent(torrent: DelugeRawTorrent): Torrent { 188 | const dateAdded = new Date(torrent.time_added * 1000).toISOString(); 189 | // normalize state to enum 190 | let state = TorrentState.unknown; 191 | if (Object.keys(TorrentState).includes(torrent.state.toLowerCase())) { 192 | state = TorrentState[torrent.state.toLowerCase() as keyof typeof TorrentState]; 193 | } 194 | 195 | return { 196 | dateAdded: dateAdded, 197 | id: torrent.hash, 198 | infoHash: torrent.hash, 199 | isCompleted: torrent.progress >= 100, 200 | name: torrent.name, 201 | progress: torrent.progress, 202 | ratio: torrent.ratio, 203 | savePath: torrent.save_path, 204 | state: state, 205 | totalSize: torrent.total_size 206 | } 207 | } 208 | } -------------------------------------------------------------------------------- /src/plugins/btclient/factory.ts: -------------------------------------------------------------------------------- 1 | import {TorrentClient, TorrentClientConfig} from "@/interfaces/BtClient/AbstractClient"; 2 | import Qbittorrent, {defaultQbittorrentConfig} from "@/plugins/btclient/qbittorrent"; 3 | import Transmission, {defaultTransmissionConfig} from "@/plugins/btclient/transmission"; 4 | import Deluge, {defaultDelugeConfig} from "@/plugins/btclient/deluge"; 5 | 6 | export const supportClientType = { 7 | 'qbittorrent': defaultQbittorrentConfig, 8 | 'transmission': defaultTransmissionConfig, 9 | 'deluge': defaultDelugeConfig 10 | } 11 | 12 | export default function (config: TorrentClientConfig): TorrentClient { 13 | switch (config.type) { 14 | case "qbittorrent": 15 | return new Qbittorrent(config) 16 | case "transmission": 17 | return new Transmission(config) 18 | case "deluge": 19 | return new Deluge(config) 20 | case "rtorrent": 21 | return new Qbittorrent(config) // FIXME 22 | } 23 | } -------------------------------------------------------------------------------- /src/plugins/btclient/qbittorrent.ts: -------------------------------------------------------------------------------- 1 | import {TorrentClient, TorrentState} from "@/interfaces/BtClient/AbstractClient"; 2 | import { 3 | QbittorrentAddTorrentOptions, 4 | QbittorrentTorrent, 5 | QbittorrentTorrentClientConfig, 6 | QbittorrentTorrentFilterRules, 7 | QbittorrentTorrentState, 8 | rawTorrent, 9 | } from "@/interfaces/BtClient/qbittorrent"; 10 | 11 | import axios, {AxiosResponse, Method} from 'axios' 12 | import urljoin from 'url-join' 13 | import {getRandomInt} from "@/plugins/common"; 14 | 15 | export const defaultQbittorrentConfig: QbittorrentTorrentClientConfig = { 16 | type: 'qbittorrent', 17 | name: 'qbittorrent', 18 | uuid: '0da0e93a-3f5f-4bdd-8f73-aaa006d14771', 19 | address: 'http://localhost:9091/', 20 | username: '', 21 | password: '', 22 | timeout: 60 * 1e3 23 | }; 24 | 25 | export default class Qbittorrent implements TorrentClient { 26 | isLogin: boolean | null = null; 27 | lastCheckLoginAt: number = 0; 28 | config: QbittorrentTorrentClientConfig; 29 | 30 | constructor(options: Partial = {}) { 31 | this.config = {...defaultQbittorrentConfig, ...options} 32 | } 33 | 34 | async ping(): Promise { 35 | try { 36 | const pong = await this.login(); 37 | this.isLogin = pong.data === 'Ok.' 38 | this.lastCheckLoginAt = Date.now() 39 | return this.isLogin 40 | } catch (e) { 41 | return false 42 | } 43 | } 44 | 45 | async login(): Promise { 46 | const form = new FormData(); 47 | form.append('username', this.config.username) 48 | form.append('password', this.config.password) 49 | 50 | return await axios.post(urljoin(this.config.address,'/api/v2', '/auth/login'), form, { 51 | timeout: this.config.timeout, 52 | withCredentials: true 53 | }) 54 | } 55 | 56 | async request(method: Method, path: string, 57 | params?: any, data?: any, 58 | headers?: any): Promise { 59 | // qbt 默认Session时长 3600s,这里取 600s 60 | // FIXME 是否每次操作都login一下好些?就像PTPP一样 61 | if (this.isLogin === null || Date.now() - 600 * 1e3 > this.lastCheckLoginAt) { 62 | await this.ping() 63 | } 64 | 65 | return await axios.request({ 66 | method: method, 67 | url: urljoin(this.config.address, '/api/v2', path), 68 | params: params, 69 | data: data, 70 | headers: headers, 71 | timeout: this.config.timeout, 72 | withCredentials: true 73 | }) 74 | } 75 | 76 | async addTorrent(urls: string, options: Partial = {}): Promise { 77 | const formData = new FormData() 78 | 79 | // 处理链接 80 | if (urls.startsWith('magnet:') || !options.localDownload) { 81 | formData.append('urls', urls) 82 | } else if (options.localDownload) { 83 | const req = await axios.get(urls,{ 84 | responseType: 'blob' 85 | }) 86 | formData.append('torrents', req.data, String(getRandomInt(0, 4096)) + '.torrent') 87 | } 88 | delete options.localDownload 89 | 90 | // 将通用字段转成qbt字段 91 | if (options.savePath) { 92 | options.savepath = options.savePath 93 | delete options.savePath 94 | } 95 | 96 | if (options.label) { 97 | options.category = options.label 98 | delete options.label 99 | } 100 | 101 | if ('addAtPaused' in options) { 102 | options.paused = options.addAtPaused ? 'true' : 'false'; 103 | delete options.addAtPaused 104 | } 105 | 106 | options.useAutoTMM = 'false'; // 关闭自动管理 107 | 108 | for (const [key, value] of Object.entries(options)) { 109 | // @ts-ignore 110 | formData.append(key, value); 111 | } 112 | 113 | const res = await this.request('POST', '/torrents/add', undefined, formData) 114 | return res.data == 'Ok.' 115 | } 116 | 117 | async getTorrentsBy(filter: QbittorrentTorrentFilterRules): Promise { 118 | if (filter.hashes) { 119 | filter.hashes = this._normalizeHashes(filter.hashes) 120 | } 121 | 122 | // 将通用项处理成qbt对应的项目 123 | if (filter.complete) { 124 | filter.filter = 'completed' 125 | delete filter.complete 126 | } 127 | 128 | const res = await this.request('GET', '/torrents/info', filter) 129 | return res.data.map((torrent: rawTorrent) => this._normalizeTorrent(torrent)) 130 | } 131 | 132 | async getAllTorrents(): Promise { 133 | return await this.getTorrentsBy({}) 134 | } 135 | 136 | async getTorrent(id: any): Promise { 137 | const resp = await this.getTorrentsBy({ 138 | hashes: id 139 | }) 140 | 141 | return resp[0] 142 | } 143 | 144 | async pauseTorrent(hashes: string | string[] | 'all'): Promise { 145 | const params = { 146 | hashes: this._normalizeHashes(hashes), 147 | }; 148 | 149 | await this.request('GET', '/torrents/pause', params); 150 | return true; 151 | } 152 | 153 | async removeTorrent(hashes: any, removeData: boolean = false): Promise { 154 | const params = { 155 | hashes: this._normalizeHashes(hashes), 156 | removeData, 157 | }; 158 | await this.request('GET', '/torrents/delete', params); 159 | return true; 160 | } 161 | 162 | async resumeTorrent(hashes: string | string[] | 'all'): Promise { 163 | const params = { 164 | hashes: this._normalizeHashes(hashes), 165 | }; 166 | await this.request('GET', '/torrents/resume', params); 167 | return true; 168 | } 169 | 170 | _normalizeHashes(hashs: string | string[]): string { 171 | if (Array.isArray(hashs)) { 172 | return hashs.join('|') 173 | } 174 | return hashs 175 | } 176 | 177 | _normalizeTorrent(torrent: rawTorrent): QbittorrentTorrent { 178 | let state = TorrentState.unknown; 179 | 180 | switch (torrent.state) { 181 | case QbittorrentTorrentState.ForcedDL: 182 | case QbittorrentTorrentState.MetaDL: 183 | state = TorrentState.downloading; 184 | break; 185 | case QbittorrentTorrentState.Allocating: 186 | // state = 'stalledDL'; 187 | state = TorrentState.queued; 188 | break; 189 | case QbittorrentTorrentState.ForcedUP: 190 | state = TorrentState.seeding; 191 | break; 192 | case QbittorrentTorrentState.PausedDL: 193 | state = TorrentState.paused; 194 | break; 195 | case QbittorrentTorrentState.PausedUP: 196 | // state = 'completed'; 197 | state = TorrentState.paused; 198 | break; 199 | case QbittorrentTorrentState.QueuedDL: 200 | case QbittorrentTorrentState.QueuedUP: 201 | state = TorrentState.queued; 202 | break; 203 | case QbittorrentTorrentState.CheckingDL: 204 | case QbittorrentTorrentState.CheckingUP: 205 | case QbittorrentTorrentState.QueuedForChecking: 206 | case QbittorrentTorrentState.CheckingResumeData: 207 | case QbittorrentTorrentState.Moving: 208 | state = TorrentState.checking; 209 | break; 210 | case QbittorrentTorrentState.Unknown: 211 | case QbittorrentTorrentState.MissingFiles: 212 | state = TorrentState.error; 213 | break; 214 | default: 215 | break; 216 | } 217 | 218 | const isCompleted = torrent.progress === 1; 219 | 220 | return { 221 | id: torrent.hash, 222 | infoHash: torrent.hash, 223 | name: torrent.name, 224 | state, 225 | dateAdded: new Date(torrent.added_on * 1000).toISOString(), 226 | isCompleted, 227 | progress: torrent.progress, 228 | label: torrent.category, 229 | savePath: torrent.save_path, 230 | totalSize: torrent.total_size, 231 | ratio: torrent.ratio 232 | }; 233 | } 234 | } -------------------------------------------------------------------------------- /src/plugins/btclient/transmission.ts: -------------------------------------------------------------------------------- 1 | import { 2 | TorrentClient, 3 | TorrentClientConfig, 4 | TorrentState 5 | } from "@/interfaces/BtClient/AbstractClient"; 6 | 7 | import axios, {AxiosResponse} from 'axios' 8 | import urljoin from "url-join"; 9 | import { 10 | AddTorrentResponse, 11 | rawTorrent, TransmissionAddTorrentOptions, 12 | TransmissionBaseResponse, 13 | TransmissionRequestMethod, 14 | TransmissionTorrent, TransmissionTorrentBaseArguments, 15 | TransmissionTorrentClientConfig, TransmissionTorrentFilterRules, 16 | TransmissionTorrentGetArguments, TransmissionTorrentGetResponse, TransmissionTorrentRemoveArguments 17 | } from "@/interfaces/BtClient/transmission"; 18 | 19 | 20 | export const defaultTransmissionConfig: TransmissionTorrentClientConfig = { 21 | type: 'transmission', 22 | name: 'transmission', 23 | uuid: '69e8ecd2-9a96-436c-ac6f-c199c1abb846', 24 | address: 'http://localhost:9091/transmission/rpc', 25 | username: '', 26 | password: '', 27 | timeout: 3 * 60 * 1e3 28 | }; 29 | 30 | export default class Transmission implements TorrentClient { 31 | config: TorrentClientConfig; 32 | private readonly address: string; 33 | private readonly authHeader: string; 34 | 35 | private sessionId : string = ''; 36 | 37 | constructor(options: Partial = {}) { 38 | this.config = {...defaultTransmissionConfig, ...options} 39 | 40 | // 修正服务器地址 41 | let address = this.config.address; 42 | if (address.indexOf('rpc') === -1) { 43 | address = urljoin(address, '/transmission/rpc') 44 | } 45 | this.address = address 46 | 47 | this.authHeader = 'Basic ' + new Buffer( 48 | this.config.username + (this.config.password ? ':' + this.config.password : '') 49 | ).toString('base64'); // 直接在constructor的时候生成,防止后续使用时多次生成 50 | } 51 | 52 | async addTorrent(url: string, options: Partial = {}): Promise { 53 | if (options.localDownload) { 54 | const req = await axios.get(url, { 55 | responseType: 'arraybuffer' 56 | }) 57 | options.metainfo = Buffer.from(req.data, 'binary').toString('base64') 58 | } else { 59 | options.filename = url 60 | } 61 | delete options.localDownload 62 | 63 | if (options.savePath) { 64 | options['download-dir'] = options.savePath 65 | delete options.savePath 66 | } 67 | 68 | // Transmission 3.0 以上才支持label 69 | if (options.label) { 70 | delete options.label 71 | } 72 | 73 | options.paused = options.addAtPaused; 74 | delete options.addAtPaused 75 | try { 76 | const resp = await this.request('torrent-add', options) 77 | const data: AddTorrentResponse = resp.data 78 | return data.result === 'success' 79 | } catch (e) { 80 | return false 81 | } 82 | } 83 | 84 | async getAllTorrents(): Promise { 85 | return await this.getTorrentsBy({}) 86 | } 87 | 88 | async getTorrent(id: number | string): Promise { 89 | const torrents = await this.getTorrentsBy({ 90 | ids: [id] 91 | }) 92 | return torrents[0] 93 | } 94 | 95 | async getTorrentsBy(filter: TransmissionTorrentFilterRules): Promise { 96 | let args: TransmissionTorrentGetArguments = { 97 | fields: [ 98 | 'addedDate', 99 | 'id', 100 | 'hashString', 101 | 'isFinished', 102 | 'name', 103 | 'percentDone', 104 | 'uploadRatio', 105 | 'downloadDir', 106 | 'status', 107 | 'totalSize', 108 | 'leftUntilDone', 109 | 'labels' 110 | ], 111 | } 112 | 113 | if (filter.ids) { 114 | args.ids = filter.ids 115 | } 116 | 117 | const resp = await this.request('torrent-get', args) 118 | const data:TransmissionTorrentGetResponse = resp.data 119 | let returnTorrents = data.arguments.torrents.map(s => this._normalizeTorrent(s)) 120 | 121 | if (filter.complete) { 122 | returnTorrents = returnTorrents.filter(s => s.isCompleted) 123 | } 124 | 125 | return returnTorrents 126 | } 127 | 128 | async pauseTorrent(id: any): Promise { 129 | const args: TransmissionTorrentBaseArguments = { 130 | ids: id 131 | } 132 | await this.request('torrent-stop', args) 133 | return true 134 | } 135 | 136 | async ping(): Promise { 137 | try { 138 | const resp = await this.request('session-get') 139 | const data: TransmissionBaseResponse = resp.data 140 | return data.result === 'success'; 141 | } catch (e) { 142 | return false 143 | } 144 | } 145 | 146 | async removeTorrent(id: number, removeData: boolean | undefined): Promise { 147 | const args:TransmissionTorrentRemoveArguments = { 148 | ids: id, 149 | 'delete-local-data': removeData 150 | } 151 | await this.request('torrent-remove', args) 152 | return true 153 | } 154 | 155 | async resumeTorrent(id: any): Promise { 156 | const args: TransmissionTorrentBaseArguments = { 157 | ids: id 158 | } 159 | await this.request('torrent-start', args) 160 | return true 161 | } 162 | 163 | async request(method:TransmissionRequestMethod, args: any = {}): Promise { 164 | try { 165 | return await axios.post(this.address, { 166 | method: method, 167 | arguments: args, 168 | }, { 169 | headers: { 170 | Authorization: this.authHeader, 171 | 'X-Transmission-Session-Id': this.sessionId 172 | }, 173 | timeout: this.config.timeout 174 | }) 175 | } catch (error) { 176 | if (error.response && error.response.status === 409) { 177 | this.sessionId = error.response.headers['x-transmission-session-id'] // lower cased header in axios 178 | return await this.request(method, args) 179 | } else { 180 | throw error 181 | } 182 | } 183 | } 184 | 185 | _normalizeTorrent(torrent: rawTorrent): TransmissionTorrent { 186 | const dateAdded = new Date(torrent.addedDate * 1000).toISOString(); 187 | 188 | let state = TorrentState.unknown; 189 | if (torrent.status === 6) { 190 | state = TorrentState.seeding; 191 | } else if (torrent.status === 4) { 192 | state = TorrentState.downloading; 193 | } else if (torrent.status === 0) { 194 | state = TorrentState.paused; 195 | } else if (torrent.status === 2) { 196 | state = TorrentState.checking; 197 | } else if (torrent.status === 3 || torrent.status === 5) { 198 | state = TorrentState.queued; 199 | } 200 | 201 | return { 202 | id: torrent.id, 203 | infoHash: torrent.hashString, 204 | name: torrent.name, 205 | progress: torrent.percentDone, 206 | isCompleted: torrent.leftUntilDone < 1, 207 | ratio: torrent.uploadRatio, 208 | dateAdded: dateAdded, 209 | savePath: torrent.downloadDir, 210 | label: torrent.labels && torrent.labels.length ? torrent.labels[0] : undefined, 211 | state: state, 212 | totalSize: torrent.totalSize 213 | }; 214 | } 215 | } -------------------------------------------------------------------------------- /src/plugins/common.ts: -------------------------------------------------------------------------------- 1 | // 存放一些公共方法 2 | 3 | import {shell} from 'electron' 4 | import {LogInfo} from "@/interfaces/store"; 5 | import dayjs from "dayjs"; 6 | 7 | export async function shellOpen(href: string) { 8 | await shell.openExternal(href) 9 | } 10 | 11 | export function getRandomInt(min: number, max: number) { 12 | min = Math.ceil(min); 13 | max = Math.floor(max); 14 | return Math.floor(Math.random() * (max - min)) + min; //The maximum is exclusive and the minimum is inclusive 15 | } 16 | 17 | export function formatLogs(logs: LogInfo[]) { 18 | return logs 19 | .map(log => `${dayjs(log.timestamp).format('YYYY-MM-DD HH:mm:ss')} ${log.message}`) // 对象整理成字符串 20 | .join('\n') // 用 \n 分割 21 | } 22 | 23 | export function sleep(ms: number) { 24 | return new Promise(resolve => setTimeout(resolve, ms)); 25 | } -------------------------------------------------------------------------------- /src/plugins/cookies.ts: -------------------------------------------------------------------------------- 1 | import _ from 'lodash' 2 | import cookieParser from 'cookie' 3 | import {remote} from 'electron' 4 | import {EnableSite} from "@/interfaces/IYUU/Site"; 5 | 6 | const session = remote.session 7 | 8 | export default class Cookies { 9 | private static editCookiesKeys: string[] = [ 10 | "domain", "expirationDate", "httpOnly", "name", "path", 11 | "sameSite", "secure", "session", "storeId", "value", "id" 12 | ] 13 | 14 | static parseCookies(raw: string): { [key: string]: string } { 15 | if (raw.match(/^\[/)) { 16 | // 如果开头是 [ ,我们认为是 editCookies 导出的结构 17 | let cookies = JSON.parse(raw) 18 | if (cookies.length === 0) { 19 | throw new TypeError('Zero Cookies Length') 20 | } else { 21 | let out: { [key: string]: string } = {} 22 | for (let i = 0; i < cookies.length; i++) { 23 | const cookie = cookies[i] 24 | if (!_.every(this.editCookiesKeys, _.partial(_.has, cookies[i]))) { 25 | throw new TypeError('Some key miss in EditCookies export cookies') 26 | } 27 | out[cookie.name] = cookie.value 28 | } 29 | return out 30 | } 31 | } else { 32 | // 不然,我们认为是 {key}={value}; 形式,直接使用 cookieParser 33 | return cookieParser.parse(raw) 34 | } 35 | } 36 | 37 | static validCookies(raw: string): Boolean { 38 | try { 39 | this.parseCookies(raw) 40 | } catch (e) { 41 | return false 42 | } 43 | return true 44 | } 45 | 46 | // 对 Electron 的 Cookies 对象 wrapper 47 | static async getCookies(filter: Electron.CookiesGetFilter): Promise { 48 | return await session.defaultSession.cookies.get(filter) 49 | } 50 | 51 | static async setCookie(cookie: Electron.CookiesSetDetails): Promise { 52 | return await session.defaultSession.cookies.set(cookie) 53 | } 54 | 55 | static async removeCookie(url: string, name: string): Promise { 56 | return await session.defaultSession.cookies.remove(url, name) 57 | } 58 | 59 | // 在wrapper基础上扩展 60 | static async setCookiesByUrlAndCookiejar(url: string, cookiejar: { [key: string]: string }): Promise { 61 | for (const [key, value] of Object.entries(cookiejar)) { 62 | await this.setCookie({ 63 | url: url, 64 | name: key, 65 | value: value 66 | }) 67 | } 68 | } 69 | 70 | static async setCookiesBySite(site: EnableSite) { 71 | await this.setCookiesByUrlAndCookiejar( 72 | (site.is_https > 0 ? 'https://' : 'http://') + site.base_url + '/', 73 | this.parseCookies(site.cookies) 74 | ) 75 | } 76 | } -------------------------------------------------------------------------------- /src/plugins/dayjs.ts: -------------------------------------------------------------------------------- 1 | import dayjs from 'dayjs' 2 | import duration from 'dayjs/plugin/duration' 3 | 4 | dayjs.extend(duration) 5 | 6 | export default dayjs -------------------------------------------------------------------------------- /src/plugins/element.ts: -------------------------------------------------------------------------------- 1 | import Vue from 'vue' 2 | import Element from 'element-ui' 3 | import 'element-ui/lib/theme-chalk/index.css' 4 | 5 | Vue.use(Element) 6 | -------------------------------------------------------------------------------- /src/plugins/iyuu.ts: -------------------------------------------------------------------------------- 1 | import crypto from 'crypto' 2 | import axios, {AxiosInstance, AxiosResponse} from 'axios' 3 | import {Notification} from 'element-ui' 4 | 5 | import {IYUUStore} from '@/store' 6 | import * as Forms from '@/interfaces/IYUU/Forms' 7 | 8 | 9 | function sha1(data: string | string[]) { 10 | if (Array.isArray(data)) { 11 | data = JSON.stringify(data) 12 | } 13 | return crypto.createHash('sha1').update(data).digest('hex') 14 | } 15 | 16 | function timestamp() { 17 | // @ts-ignore 18 | return parseInt(Date.now() / 1000) 19 | } 20 | 21 | class IyuuEndpoint { 22 | private instance: AxiosInstance; 23 | private apiPoint = 'https://api.iyuu.cn/'; 24 | private version = '1.10.6'; // FAKE origin version 25 | 26 | constructor() { 27 | this.instance = axios.create({ 28 | baseURL: this.apiPoint, 29 | transformResponse: [function (resp) { 30 | // 统一处理IYUU服务器返回信息 31 | // 不知道为什么,这里获得的data项不是JSON字典,而是JSON字符串,将其转换一下 32 | // 如果状态码不对,也统一报错 33 | const data = JSON.parse(resp) 34 | if (data.ret !== 200) { 35 | Notification.error({ 36 | title: '服务器返回错误: ' + data.ret, 37 | message: data.data.errmsg ? data.data.errmsg : data.msg 38 | }) 39 | } 40 | 41 | return data 42 | }] 43 | }) 44 | } 45 | 46 | async sendWeChatMsg(text: string, desp: string|null = null): Promise { 47 | const params = new FormData(); 48 | params.append('text', text); 49 | if (desp) { 50 | params.append('desp', desp); 51 | } 52 | 53 | return await axios.post(`https://iyuu.cn/${this.getSign()}.send`,params) 54 | } 55 | 56 | getSign():string { 57 | // @ts-ignore 58 | return IYUUStore.token 59 | } 60 | 61 | // 用户登录绑定操作 62 | userLogin(userLoginForm: Forms.userLoginRequest): Promise { 63 | // 要求对合作站点用户密钥进行sha1操作 sha1(passkey) 64 | userLoginForm.passkey = sha1(userLoginForm.passkey) 65 | 66 | return new Promise((resolve, reject) => { 67 | this.instance.get('/user/login', { 68 | params: userLoginForm 69 | }).then(resp => { 70 | const data: Forms.userLoginResponse = resp.data 71 | if (data.ret === 200) { 72 | Notification.success({ 73 | title: '登录验证成功', 74 | message: data.data.errmsg 75 | }) 76 | IYUUStore.setToken(userLoginForm.token).then(() => { 77 | resolve(data) 78 | }) 79 | } else { 80 | reject(data) 81 | } 82 | }) 83 | }) 84 | } 85 | 86 | // 获取所有站点支持列表 87 | apiSites(userLoginForm: Forms.userLoginRequest | null = null): Promise { 88 | let sign: string; 89 | if (userLoginForm === null) { 90 | sign = this.getSign() 91 | } else { 92 | sign = userLoginForm.token 93 | } 94 | 95 | return new Promise((resolve, reject) => { 96 | this.instance.get('/api/sites', { 97 | params: { 98 | sign: sign, 99 | version: this.version 100 | } as Forms.apiSitesRequest 101 | }).then(resp => { 102 | const data: Forms.apiSitesResponse = resp.data 103 | 104 | if (data.data.sites) { 105 | Notification.success('从IYUU服务器获取站点数据成功') 106 | IYUUStore.updateSites(data.data.sites).then(() => { 107 | resolve(data) 108 | }) 109 | } else { 110 | // TODO cleanToken 111 | reject(data) 112 | } 113 | }) 114 | }) 115 | } 116 | 117 | private _buildHashForm(infohash:string|string[]): Forms.apiInfohashRequest|Forms.apiHashRequest { 118 | // 强行转成列表 119 | if (typeof infohash === 'string') { 120 | infohash = [infohash] 121 | } 122 | 123 | return { 124 | sign: this.getSign(), 125 | timestamp: timestamp(), 126 | version: this.version, 127 | // @ts-ignore 128 | hash: infohash, 129 | sha1: sha1(infohash), 130 | } 131 | } 132 | 133 | apiInfohash(infohash: string | string[]): Promise { 134 | return new Promise(resolve => { 135 | this.instance.post('/api/infohash', this._buildHashForm(infohash)).then(resp => { 136 | const data: Forms.apiInfohashResponse = resp.data 137 | resolve(data) 138 | }) 139 | }) 140 | } 141 | 142 | apiHash(infohash: string|string[]): Promise { 143 | return new Promise(resolve => { 144 | this.instance.post('/api/hash', this._buildHashForm(infohash)).then(resp => { 145 | const data: Forms.apiHashResponse = resp.data 146 | resolve(data) 147 | }) 148 | }) 149 | } 150 | 151 | apiNotify(info: { 152 | site: string, 153 | sid: number, 154 | torrent_id: number, 155 | error: string 156 | }): Promise 157 | { 158 | const form: Forms.apiNotifyRequest = { sigh: this.getSign(), ...info } 159 | return new Promise(resolve => { 160 | this.instance.get('/api/notify', { 161 | params: form 162 | }).then(resp => { 163 | const data: Forms.apiNotifyResponse = resp.data 164 | resolve(data) 165 | }) 166 | }) 167 | } 168 | } 169 | 170 | const iyuuEndpoint = new IyuuEndpoint() 171 | Object.freeze(iyuuEndpoint) 172 | export default iyuuEndpoint -------------------------------------------------------------------------------- /src/plugins/mission/reseed.ts: -------------------------------------------------------------------------------- 1 | // 转发使用的核心方法 2 | import UUID from 'uuid' 3 | import _ from 'lodash' 4 | import dayjs from "dayjs"; 5 | import {ipcRenderer} from 'electron' 6 | 7 | import {EnableSite} from "@/interfaces/IYUU/Site"; 8 | import {TorrentInfo} from "@/interfaces/IYUU/Forms"; 9 | import {AddTorrentOptions, TorrentClientConfig} from "@/interfaces/BtClient/AbstractClient"; 10 | 11 | import {formatLogs, sleep} from "@/plugins/common"; 12 | import iyuuEndpoint from "@/plugins/iyuu"; 13 | import downloadFactory from '@/plugins/sites/factory'; 14 | import btClientFactory from "@/plugins/btclient/factory"; 15 | import {MissionStore, IYUUStore, StatusStore} from '@/store/store-accessor' // circular import; OK though 16 | import {CookiesExpiredError, TorrentNotExistError} from "@/plugins/sites/default"; 17 | 18 | const packageInfo = require('../../../package.json') 19 | 20 | export interface ReseedStartOption { 21 | dryRun: boolean 22 | label: string, 23 | weChatNotify: boolean, // 是否将信息推送给微信 24 | closeAppAfterRun: boolean 25 | } 26 | 27 | class RateLimitError extends Error { 28 | } 29 | 30 | export default class Reseed { 31 | // 传入参数 32 | private readonly sites: EnableSite[]; 33 | private readonly clients: TorrentClientConfig[]; 34 | private readonly options: Partial; 35 | 36 | // constructor时生成 37 | readonly logId: string; 38 | 39 | private siteIds: number[] 40 | private rateLimitSites: { 41 | [prop_name: string]: { 42 | last_hit_timestamp: number, 43 | total_count: number 44 | } 45 | }; 46 | 47 | private state: { 48 | hashCount: number, 49 | reseedCount: number, 50 | reseedSuccess: number, 51 | reseedFail: number, 52 | reseedPass: number 53 | } 54 | 55 | constructor(sites: EnableSite[], clientsConfig: TorrentClientConfig[], options: Partial) { 56 | this.sites = sites 57 | this.clients = clientsConfig 58 | this.options = options 59 | 60 | this.logId = UUID.v4() 61 | 62 | this.siteIds = sites.map(s => s.id) 63 | this.rateLimitSites = {} 64 | this.state = { 65 | hashCount: 0, 66 | reseedCount: 0, 67 | reseedSuccess: 0, 68 | reseedFail: 0, 69 | reseedPass: 0 70 | } 71 | } 72 | 73 | private missionStart() { 74 | StatusStore.missionStart() 75 | 76 | MissionStore.updateCurrentMissionState({ 77 | processing: true, 78 | logId: this.logId 79 | }) 80 | } 81 | 82 | private async missionEnd() { 83 | this.logger(`辅种任务已完成,任务Id: ${this.logId}`) 84 | MissionStore.updateCurrentMissionState({ 85 | processing: false, 86 | logId: this.logId 87 | }) 88 | 89 | // 发送微信通知 90 | if (this.options.weChatNotify) { 91 | await this.sendWeChatNotify() 92 | } 93 | 94 | // 自动退出 95 | if (this.options.closeAppAfterRun) { 96 | ipcRenderer.send('close-me') 97 | } 98 | } 99 | 100 | private formatTpl(tpl: string): string { 101 | return tpl 102 | .replace(new RegExp('{version}','g'), packageInfo.version) 103 | .replace(new RegExp('{mission_id}','g'), this.logId) 104 | .replace(new RegExp('{clients_count}','g'), String(this.clients.length)) 105 | .replace(new RegExp('{sites_count}','g'), String(this.sites.length)) 106 | .replace(new RegExp('{hashs_count}','g'), String(this.state.hashCount)) 107 | .replace(new RegExp('{reseed_count}','g'), String(this.state.reseedCount)) 108 | .replace(new RegExp('{reseed_success}','g'), String(this.state.reseedSuccess)) 109 | .replace(new RegExp('{reseed_fail}','g'), String(this.state.reseedFail)) 110 | .replace(new RegExp('{reseed_pass}','g'), String(this.state.reseedPass)) 111 | .replace(new RegExp('{full_log}','g'), formatLogs(MissionStore.logByLogId(this.logId))) 112 | } 113 | 114 | private async sendWeChatNotify() { 115 | let title = this.formatTpl(IYUUStore.weChatNotify.reseed.title).slice(0, 100) 116 | let descr = this.formatTpl(IYUUStore.weChatNotify.reseed.descr) 117 | 118 | await iyuuEndpoint.sendWeChatMsg(title, descr) 119 | } 120 | 121 | async start(): Promise { 122 | this.missionStart() 123 | 124 | this.logger(`开始辅种任务,任务Id: ${this.logId}。启用下载服务器数 ${this.clients.length}, 启用站点数 ${this.sites.length}。`) 125 | if (this.options.dryRun) { 126 | this.logger(`这是一次空运行,软件不会把种子链接或者种子文件发送给做种服务器,如果该站点属于特殊站点,软件同样不会解析页面来获取下载链接。`) 127 | 128 | const rateLimitSites = this.sites.filter(s => s.rate_limit && (s.rate_limit.maxRequests > 0 || s.rate_limit.requestsDelay > 0)) 129 | if (rateLimitSites.length > 0) { 130 | this.logger(`这是一次空运行,站点 ${rateLimitSites.map(s => s.site).join(',')} 的下载频率限制同样不会生效。`) 131 | } 132 | } 133 | 134 | // 开始遍历下载器 135 | for (let i = 0; i < this.clients.length; i++) { 136 | try { 137 | await this.loopClient(this.clients[i]) 138 | } catch (e) { 139 | this.logger(`下载器 ${this.clients[i].name}(${this.clients[i].type}) 处理错误: ${e}`) 140 | } 141 | 142 | } 143 | 144 | await this.missionEnd() 145 | } 146 | 147 | private logger(msg: string) { 148 | MissionStore.appendLog({logId: this.logId, logMessage: msg}) 149 | } 150 | 151 | private async loopClient(config: TorrentClientConfig) { 152 | const client = btClientFactory(config) 153 | 154 | this.logger(`正在检查下载服务器 ${client.config.name}(${client.config.type}) 的连接性。`) 155 | const connect = await client.ping() 156 | if (connect) { 157 | this.logger(`已成功连接到下载服务器 ${client.config.name}(${client.config.type}),开始请求已完成种子清单。`) 158 | 159 | // 获得已下载完成的种子 160 | const torrents = await client.getTorrentsBy({ 161 | complete: true 162 | }) 163 | this.state.hashCount += torrents.length 164 | 165 | // 从缓存中获取该下载器已经转发过的infoHash 166 | const reseedTorrentInfoHashs = MissionStore.reseededByClientId(client.config.uuid) 167 | 168 | // 筛选出未被转发过的种子infohash 169 | const unReseedTorrents = torrents.filter(t => !reseedTorrentInfoHashs.includes(t.infoHash)) 170 | 171 | this.logger(`从下载器 ${client.config.name}(${client.config.type}) 中获取到 ${torrents.length} 个已完成种子,其中 未被转发过的有 ${unReseedTorrents.length} 个。`) 172 | this.logger(`当前下载器总共缓存了 ${reseedTorrentInfoHashs.length} 个种子 infoHash 历史。`) 173 | 174 | const chunkUnReseedTorrents = _.chunk(unReseedTorrents, IYUUStore.apiPreInfoHash) 175 | if (chunkUnReseedTorrents.length > 1) { 176 | this.logger(`由于当前infoHash总数超过最大单次请求限制(设置值 ${IYUUStore.apiPreInfoHash}),程序将分成多次进行请求。`) 177 | } 178 | 179 | for (let i = 0; i < chunkUnReseedTorrents.length; i++) { 180 | const chunkUnReseedTorrent = chunkUnReseedTorrents[i] 181 | // 将分片信息请求IYUU服务器 182 | const resp = await iyuuEndpoint.apiHash(chunkUnReseedTorrent.map(t => t.infoHash)) 183 | this.logger(`在提交的 ${chunkUnReseedTorrent.length} 个infoHash值里, IYUU服务器共返回 ${resp.data.length || 0} 个可辅种结果。`) 184 | this.state.reseedCount += resp.data.length || 0 185 | for (let j = 0; j < resp.data.length; j++) { 186 | const reseedTorrentsDataFromIYUU = resp.data[j] 187 | const reseedTorrentDataFromClient = torrents.find(t => t.infoHash === reseedTorrentsDataFromIYUU.hash) 188 | 189 | // 筛选需要转发的种子 190 | const canReseedTorrents = reseedTorrentsDataFromIYUU.torrent.filter(t => { 191 | return this.siteIds.includes(t.sid) // 对应种子在转发站点中 192 | && !reseedTorrentInfoHashs.includes(t.info_hash); // 这个种子未命中该下载器的转发缓存 193 | }) 194 | 195 | this.logger(`种子 ${reseedTorrentsDataFromIYUU.hash}: IYUU 返回 ${reseedTorrentsDataFromIYUU.torrent.length} 个待选种子,其中可添加 ${canReseedTorrents.length} 个`) 196 | this.state.reseedPass += reseedTorrentsDataFromIYUU.torrent.length - canReseedTorrents.length 197 | 198 | let partialFail = 0; // 部分辅种 infohash 出现错误,此时不应添加原种子缓存 199 | for (let k = 0; k < canReseedTorrents.length; k++) { 200 | const reseedTorrent: TorrentInfo = canReseedTorrents[k] 201 | 202 | const siteInfoForThisTorrent = this.sites.find(s => s.id === reseedTorrent.sid) 203 | if (siteInfoForThisTorrent && reseedTorrentDataFromClient) { // 因为ts限制,这里要加一层判断(但实际并没有必要) 204 | if (this.options.dryRun) { 205 | this.logger(`将会推送 站点 ${siteInfoForThisTorrent.site},id为 ${reseedTorrent.torrent_id} 的 种子链接/种子文件 到下载器 ${client.config.name}(${client.config.type})。`) 206 | continue; 207 | } 208 | 209 | // 检查是否触及到站点流控规则 210 | try { 211 | await this.siteRateLimitCheck(siteInfoForThisTorrent) 212 | } catch (e) { 213 | continue 214 | } 215 | 216 | let downloadOptionsForThisTorrent: AddTorrentOptions = { 217 | savePath: reseedTorrentDataFromClient.savePath, 218 | addAtPaused: true, // 置于暂停状态 219 | localDownload: siteInfoForThisTorrent.download_torrent 220 | } 221 | 222 | /* TODO 设置标签 223 | * if (this.options.label) { 224 | * downloadOptionsForThisTorrent.label = this.options.label 225 | * } 226 | */ 227 | 228 | try { 229 | // 使用工厂函数方法,构造种子真实下载链接 230 | let torrentLink = await downloadFactory(reseedTorrent, siteInfoForThisTorrent) 231 | 232 | // 加重试版 推送种子链接(由本地btclient代码根据传入参数决定推送的是链接还是文件) 233 | let retryCount = 0; 234 | while (retryCount++ < IYUUStore.maxRetry) { 235 | const addTorrentStatue = await client.addTorrent(torrentLink, downloadOptionsForThisTorrent) 236 | if (addTorrentStatue) { 237 | this.logger(`添加站点 ${siteInfoForThisTorrent.site} 种子 ${reseedTorrent.info_hash} 成功。`) 238 | StatusStore.torrentReseed() // 增加辅种成功计数 239 | MissionStore.appendReseeded({ // 将这个infoHash加入缓存中 240 | clientId: client.config.uuid, infoHash: reseedTorrent.info_hash 241 | }) 242 | this.state.reseedSuccess++ 243 | break; // 退出重试循环 244 | } else { 245 | partialFail++; 246 | this.state.reseedFail++ 247 | this.logger(`添加站点 ${siteInfoForThisTorrent.site} 种子 ${reseedTorrent.info_hash} 失败。`) 248 | if (retryCount === IYUUStore.maxRetry) { 249 | this.logger(`请考虑为下载器 ${client.config.name}(${client.config.type}) 手动下载添加,链接 ${torrentLink} 。`) 250 | } else { 251 | const sleepSecond = Math.min(30, Math.pow(2, retryCount)) 252 | this.logger(`等待 ${sleepSecond} 秒进行第 ${retryCount} 次推送重试`) 253 | await sleep(sleepSecond * 1e3) 254 | } 255 | } 256 | } 257 | } catch (e) { 258 | this.logger(`种子下载链接构造失败, 站点 ${siteInfoForThisTorrent.site} 种子id: ${reseedTorrent.sid}。原因: ${e}`) 259 | 260 | // Cookies过期 261 | if (e instanceof CookiesExpiredError) { 262 | this.tempRemoveSite(siteInfoForThisTorrent, `${e}`) 263 | } 264 | 265 | if (!(e instanceof TorrentNotExistError)) { 266 | this.state.reseedFail++ 267 | partialFail++; 268 | } 269 | } 270 | } 271 | } 272 | 273 | if (!this.options.dryRun // 非空运行 274 | && canReseedTorrents.length > 0 // 可用辅种数量大于0 275 | && partialFail === 0 // 未出现失败辅种 276 | ) { 277 | MissionStore.appendReseeded({ // 将原种的infohash加入缓存 278 | clientId: client.config.uuid, infoHash: reseedTorrentsDataFromIYUU.hash 279 | }) 280 | } 281 | } 282 | } 283 | 284 | // TODO 285 | } else { 286 | this.logger(`下载器 ${client.config.name}(${client.config.type}) 连接失败`) 287 | } 288 | } 289 | 290 | private tempRemoveSite(site: EnableSite, reason: string = '') { 291 | this.logger(`本次运行不再推送站点 ${site.site}, 原因 : "${reason}"。`) 292 | const siteOrderId = this.siteIds.findIndex(i => i === site.id) 293 | this.siteIds.splice(siteOrderId, 1) 294 | } 295 | 296 | 297 | private async siteRateLimitCheck(site: EnableSite) { 298 | const siteRateLimitRule = site.rate_limit 299 | if (siteRateLimitRule) { 300 | // 建立字典 301 | if (!(site.site in this.rateLimitSites)) { 302 | this.rateLimitSites[site.site] = { 303 | last_hit_timestamp: 0, 304 | total_count: 0 305 | } 306 | } 307 | 308 | // 检查该站点是否达到最大下载 309 | if (siteRateLimitRule.maxRequests > 0) { 310 | if (this.rateLimitSites[site.site].total_count > siteRateLimitRule.maxRequests) { 311 | this.tempRemoveSite(site, `触及到推送限制规则: 单次运行最多推送 ${siteRateLimitRule.maxRequests} 个种子`) 312 | throw new RateLimitError(`站点 ${site.site} 触及到推送限制规则`) 313 | } else { 314 | this.rateLimitSites[site.site].total_count++ 315 | } 316 | } 317 | 318 | if (siteRateLimitRule.requestsDelay > 0) { 319 | const waitTime = siteRateLimitRule.requestsDelay * 1e3 320 | const dateNow = Date.now() 321 | // 计算剩余等待时间,并等待 322 | const elapseTime = dateNow - (waitTime + this.rateLimitSites[site.site].last_hit_timestamp) 323 | if (elapseTime > 0) { 324 | this.logger(`站点 ${site.site} 通过推送间隔检查,上次推送时间 ${dayjs(this.rateLimitSites[site.site].last_hit_timestamp).format('YYYY-MM-DD HH:mm:ss')},间隔 ${elapseTime / 1e3} 秒。`) 325 | } else { 326 | this.logger(`站点 ${site.site} 触及到推送限制规则: 连续两次推送间隔 ${siteRateLimitRule.requestsDelay} 秒。等待 ${-elapseTime / 1e3}秒后重试。`) 327 | await sleep(-elapseTime) 328 | } 329 | 330 | this.rateLimitSites[site.site].last_hit_timestamp = Date.now() 331 | } 332 | } 333 | } 334 | } -------------------------------------------------------------------------------- /src/plugins/sites/default.ts: -------------------------------------------------------------------------------- 1 | // 普通站点链接构造逻辑 2 | 3 | import {EnableSite} from "@/interfaces/IYUU/Site"; 4 | import Cookies from "@/plugins/cookies"; 5 | import {TorrentInfo} from "@/interfaces/IYUU/Forms"; 6 | 7 | export class CookiesExpiredError extends Error {} 8 | export class TorrentNotExistError extends Error {} 9 | export class TorrentNotValidError extends Error {} 10 | 11 | export default async function (reseedInfo: TorrentInfo, site: EnableSite) { 12 | // 将种子连接模板中剩下的{} 替换成 IYUU给出的种子id 13 | let url = site.link.replace(/{}/ig, String(reseedInfo.torrent_id)) 14 | 15 | // 如果站点需要本地下载,则设置Cookies 16 | if (site.download_torrent) { 17 | await Cookies.setCookiesBySite(site) 18 | } 19 | 20 | // 返回原链接 21 | return url 22 | } -------------------------------------------------------------------------------- /src/plugins/sites/factory.ts: -------------------------------------------------------------------------------- 1 | // 种子下载链接构造 工厂函数 2 | 3 | import {EnableSite} from "@/interfaces/IYUU/Site"; 4 | import {TorrentInfo} from "@/interfaces/IYUU/Forms"; 5 | 6 | import defaultSiteDownload from '@/plugins/sites/default' 7 | import HdChinaDownload from '@/plugins/sites/hdchina' 8 | import HDCityDownload from '@/plugins/sites/hdcity' 9 | import HDSkyDownload from '@/plugins/sites/hdsky' 10 | 11 | // 合作站点 12 | export const coSite = [ 13 | 'ourbits', 'hddolby', 'hdhome', 'pthome', 'chdbits', 'hdai' 14 | ] 15 | 16 | export const forceDownloadSite = [ 17 | 'hdchina', 'hdcity', 'hdsky', 18 | // 使用 '&uid={uid}&hash={hash}' 的站点 19 | 'pthome', 'hdhome', 'hddolby', 'hdai' 20 | ] 21 | 22 | export function isForceDownloadSite(name: string) { 23 | return forceDownloadSite.includes(name) 24 | } 25 | 26 | export function defaultSiteRateLimit(name: string) { 27 | switch (name) { 28 | case 'ourbits': 29 | case 'moecat': 30 | case 'ssd': 31 | return {maxRequests: 20, requestsDelay: 15} 32 | case 'hddolby': 33 | case 'hdhome': 34 | case 'pthome': 35 | return {maxRequests: 20, requestsDelay: 5} 36 | case 'hdsky': 37 | return {maxRequests: 20, requestsDelay: 20} 38 | case 'hdchina': 39 | return {maxRequests: 10, requestsDelay: 5} 40 | case 'pt': 41 | return {maxRequests: 20, requestsDelay: 20} 42 | default: 43 | return {maxRequests: 0, requestsDelay: 0} 44 | } 45 | } 46 | 47 | export default async function (reseedInfo: TorrentInfo, site: EnableSite) { 48 | switch (site.site) { 49 | case 'hdchina': 50 | return await HdChinaDownload(reseedInfo, site) 51 | case 'hdcity': 52 | return await HDCityDownload(reseedInfo, site) 53 | case 'hdsky': 54 | return await HDSkyDownload(reseedInfo, site) 55 | default: 56 | /** 57 | * 由于我暂时无精力实现以下站点的传入uid和hash功能, 58 | * 且这些站点在有cookies的情况下,不需要 '&uid={uid}&hash={hash}' 字符串 59 | * 所以将这些站点移入 forceDownloadSite 且在传入链接中删去以上字段, 60 | * 强行使用 /download.php?id={} + Cookies 的形式下载种子 61 | */ 62 | site.download_page = site.download_page.replace('&uid={uid}&hash={hash}', '') 63 | return await defaultSiteDownload(reseedInfo, site) 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /src/plugins/sites/hdchina.ts: -------------------------------------------------------------------------------- 1 | // hdchina,HDSKY站点通用下载链接构造逻辑 2 | 3 | import axios from 'axios' 4 | import urljoin from "url-join"; 5 | 6 | import {EnableSite} from "@/interfaces/IYUU/Site"; 7 | import {TorrentInfo} from "@/interfaces/IYUU/Forms"; 8 | 9 | import Cookies from "@/plugins/cookies"; 10 | import iyuuEndpoint from '@/plugins/iyuu' 11 | import {CookiesExpiredError, TorrentNotExistError} from "@/plugins/sites/default"; 12 | 13 | export default async function (reseedInfo: TorrentInfo, site: EnableSite) { 14 | // 设置站点Cookies 15 | await Cookies.setCookiesBySite(site) 16 | const baseUrl = (site.is_https > 0 ? 'https://' : 'http://') + site.base_url 17 | 18 | // 构造对应种子详情页链接 19 | const detailsUrl = urljoin(baseUrl, `/details.php?id=${reseedInfo.torrent_id}`) 20 | const detailsPageRep = await axios.get(detailsUrl) 21 | const detailsPage = detailsPageRep.data 22 | if (detailsPage.search('该页面必须在登录后才能访问') > -1) { 23 | throw new CookiesExpiredError('站点 Cookies 已过期,请更新后重新辅种!') 24 | } 25 | 26 | // 直接使用正则提取 27 | let path = (detailsPage.match(/href="(download\.php\?hash=[^"]+?)">/) || ['', ''])[1] 28 | if (path != '') { 29 | return urljoin(baseUrl, path) 30 | } 31 | 32 | // 未提取到,则当作该种子不存在,提交IYUU并抛出异常 33 | await iyuuEndpoint.apiNotify({ 34 | site: site.site, 35 | sid: site.id, 36 | torrent_id: reseedInfo.sid, 37 | error: '404' 38 | }) 39 | throw new TorrentNotExistError(`没有该ID的种子 (站点 ${site.site} ID ${reseedInfo.sid})`) 40 | } -------------------------------------------------------------------------------- /src/plugins/sites/hdcity.ts: -------------------------------------------------------------------------------- 1 | // hdcity站点下载链接构造逻辑 2 | 3 | import urljoin from "url-join"; 4 | import axios from "axios"; 5 | 6 | import {EnableSite} from "@/interfaces/IYUU/Site"; 7 | import {TorrentInfo} from "@/interfaces/IYUU/Forms"; 8 | 9 | import Cookies from "@/plugins/cookies"; 10 | import iyuuEndpoint from "@/plugins/iyuu"; 11 | import {CookiesExpiredError, TorrentNotExistError} from "@/plugins/sites/default"; 12 | 13 | let cuhash: string | null = null; 14 | let lastCheck: number = 0; 15 | 16 | export default async function (reseedInfo: TorrentInfo, site: EnableSite) { 17 | const baseUrl = (site.is_https > 0 ? 'https://' : 'http://') + site.base_url 18 | 19 | const dateNow = Date.now() 20 | const elapseTime = dateNow - (600 * 1e3 + lastCheck) 21 | if (typeof cuhash !== 'string' || elapseTime < 0) { 22 | // 设置站点Cookies 23 | await Cookies.setCookiesBySite(site) 24 | const detailsUrl = urljoin(baseUrl, `t-${reseedInfo.torrent_id}`) 25 | const detailsPageRep = await axios.get(detailsUrl) 26 | const detailsPage = detailsPageRep.data 27 | if (detailsPage.search('HDCITY PORTAL') > -1) { 28 | throw new CookiesExpiredError('站点 Cookies 已过期,请更新后重新辅种!') 29 | } 30 | if (detailsPage.search('木有该ID的种子,可能输入错误或已被删除。') > -1) { 31 | await iyuuEndpoint.apiNotify({ 32 | site: site.site, 33 | sid: site.id, 34 | torrent_id: reseedInfo.sid, 35 | error: '404' 36 | }) 37 | throw new TorrentNotExistError(`没有该ID的种子 (站点 ${site.site} ID ${reseedInfo.sid})`) 38 | } 39 | 40 | // 使用正则提取cuhash 41 | let tmpCuhash = (detailsPage.match('cuhash=([a-z0-9]+)') || ['', ''])[1] 42 | if (tmpCuhash) { 43 | cuhash = tmpCuhash 44 | } 45 | 46 | lastCheck = Date.now() 47 | } 48 | 49 | if (cuhash) { 50 | return urljoin(baseUrl, `download?id=${reseedInfo.torrent_id}&cuhash=${cuhash}`) 51 | } 52 | 53 | throw new Error(`未提取到该ID的种子链接 (站点 ${site.site} ID ${reseedInfo.sid})`) 54 | } -------------------------------------------------------------------------------- /src/plugins/sites/hdsky.ts: -------------------------------------------------------------------------------- 1 | // HDSKY站点下载链接构造逻辑 2 | 3 | import axios from 'axios' 4 | import urljoin from "url-join"; 5 | 6 | import {EnableSite} from "@/interfaces/IYUU/Site"; 7 | import {TorrentInfo} from "@/interfaces/IYUU/Forms"; 8 | 9 | import Cookies from "@/plugins/cookies"; 10 | import iyuuEndpoint from '@/plugins/iyuu' 11 | import {CookiesExpiredError, TorrentNotExistError} from "@/plugins/sites/default"; 12 | 13 | export default async function (reseedInfo: TorrentInfo, site: EnableSite) { 14 | // 设置站点Cookies 15 | await Cookies.setCookiesBySite(site) 16 | const baseUrl = (site.is_https > 0 ? 'https://' : 'http://') + site.base_url 17 | 18 | // 构造对应种子详情页链接 19 | const detailsUrl = urljoin(baseUrl, `/details.php?id=${reseedInfo.torrent_id}`) 20 | const detailsPageRep = await axios.get(detailsUrl) 21 | const detailsPage = detailsPageRep.data 22 | if (detailsPage.search('该页面必须在登录后才能访问') > -1) { 23 | throw new CookiesExpiredError('站点 Cookies 已过期,请更新后重新辅种!') 24 | } 25 | 26 | // 直接使用正则提取 27 | let path = (detailsPage.match(/action=['"]([^"]+?download\.php\?[^"]+?)['"]/) || ['', ''])[1] 28 | if (path != '') { 29 | return path.replace(/&/ig,'&') // 由于下载链接中直接带有域名,跳过 30 | } 31 | 32 | // 未提取到,则当作该种子不存在,提交IYUU并抛出异常 33 | await iyuuEndpoint.apiNotify({ 34 | site: site.site, 35 | sid: site.id, 36 | torrent_id: reseedInfo.sid, 37 | error: '404' 38 | }) 39 | throw new TorrentNotExistError(`没有该ID的种子 (站点 ${site.site} ID ${reseedInfo.sid})`) 40 | } -------------------------------------------------------------------------------- /src/plugins/uuid.ts: -------------------------------------------------------------------------------- 1 | import Vue from "vue"; 2 | import UUID from "vue-uuid"; 3 | 4 | // @ts-ignore 5 | Vue.use(UUID); -------------------------------------------------------------------------------- /src/router/index.ts: -------------------------------------------------------------------------------- 1 | import Vue from 'vue' 2 | import VueRouter, { RouteConfig } from 'vue-router' 3 | import { Notification } from 'element-ui' 4 | 5 | import {IYUUStore} from '@/store' 6 | 7 | Vue.use(VueRouter) 8 | 9 | const routes: Array = [ 10 | // 用户登录部分 11 | { 12 | path: '/login', 13 | name: 'Login', 14 | component: () => import('@/views/Login.vue') 15 | }, 16 | 17 | // 概览部分 18 | { 19 | path: '/', 20 | component: () => import('@/views/Layer.vue'), 21 | meta: { 22 | requiresLogin: true 23 | }, 24 | children: [ 25 | // 概览部分 26 | { 27 | path: '', 28 | name: 'Home', 29 | component: () => import('@/views/Home.vue'), 30 | meta: { content: '概览' } 31 | }, 32 | { 33 | path: 'mission', 34 | name: 'Mission', 35 | component: () => import('@/views/Mission.vue'), 36 | meta: { content: '启动任务' } 37 | }, 38 | 39 | // 软件设置部分 40 | { 41 | path: 'setting/site', 42 | name: 'Setting/Site', 43 | component: () => import('@/views/Setting/Site.vue'), 44 | meta: { content: '辅种站点设置' } 45 | }, 46 | { 47 | path: 'setting/btclient', 48 | name: 'Setting/BtClient', 49 | component: () => import('@/views/Setting/BtClient.vue'), 50 | meta: { content: '下载器设置' } 51 | }, 52 | { 53 | path: 'setting/other', 54 | name: 'Setting/Other', 55 | component: () => import('@/views/Setting/Other.vue'), 56 | meta: { content: '其他设置' } 57 | }, 58 | { 59 | path: 'setting/backup', 60 | name: 'Setting/Backup', 61 | component: () => import('@/views/Setting/Backup.vue'), 62 | meta: { content: '参数备份与恢复' } 63 | }, 64 | 65 | // 鸣谢部分 66 | { 67 | path: 'gratitude/declare', 68 | name: 'Declare', 69 | component: () => import('@/views/Gratitude/Declare.vue'), 70 | meta: { content: '项目说明' } 71 | }, 72 | { 73 | path: 'gratitude/donate', 74 | name: 'Donate', 75 | component: () => import('@/views/Gratitude/Donate.vue'), 76 | meta: { content: '捐赠支持' } 77 | } 78 | ] 79 | }, 80 | 81 | // Miss路由时 82 | { 83 | path: '*', 84 | redirect: '/' 85 | } 86 | ] 87 | 88 | const router = new VueRouter({ 89 | routes 90 | }) 91 | 92 | router.beforeEach((to, from, next) => { 93 | // @ts-ignore 94 | if (to.matched.some(record => record.meta.requiresLogin) && !IYUUStore.token) { 95 | Notification.error('未登录,返回登录窗口') 96 | next('/login') 97 | } else { 98 | next() 99 | } 100 | }) 101 | 102 | export default router 103 | -------------------------------------------------------------------------------- /src/shims-tsx.d.ts: -------------------------------------------------------------------------------- 1 | import Vue, { VNode } from 'vue' 2 | 3 | declare global { 4 | namespace JSX { 5 | // tslint:disable no-empty-interface 6 | interface Element extends VNode {} 7 | // tslint:disable no-empty-interface 8 | interface ElementClass extends Vue {} 9 | interface IntrinsicElements { 10 | [elem: string]: any 11 | } 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/shims-vue.d.ts: -------------------------------------------------------------------------------- 1 | declare module '*.vue' { 2 | import Vue from 'vue' 3 | export default Vue 4 | } 5 | -------------------------------------------------------------------------------- /src/store/index.ts: -------------------------------------------------------------------------------- 1 | import Vue from 'vue' 2 | import Vuex, {Store} from 'vuex' 3 | // Note: you shouldn't need to import store modules here. 4 | import {initializeStores, modules} from '@/store/store-accessor' 5 | 6 | // @ts-ignore 7 | import {createPersistedState} from 'vuex-electron' 8 | 9 | Vue.use(Vuex) 10 | 11 | 12 | // Initialize the modules using a Vuex plugin that runs when the root store is 13 | // first initialized. This is vital to using static modules because the 14 | // modules don't know the root store when they are loaded. Initializing them 15 | // when the root store is created allows them to be hooked up properly. 16 | const initializer = (store: Store) => initializeStores(store) 17 | 18 | export const plugins = [ 19 | initializer, 20 | createPersistedState({ 21 | ignoredPaths: [ 22 | "Mission.logs", "Mission.currentMission" 23 | ], 24 | ignoredCommits: [ 25 | 'Mission/appendLog', 26 | 'Mission/updateCurrentMissionState' 27 | ] 28 | }) 29 | ] 30 | 31 | export * from '@/store/store-accessor' // re-export the modules 32 | 33 | // Export the root store. You can add mutations & actions here as well. 34 | // Note that this is a standard Vuex store, not a vuex-module-decorator one. 35 | // (Perhaps could be, but I put everything in modules) 36 | export default new Store({ 37 | plugins, // important! 38 | modules, // important! 39 | state: {}, 40 | mutations: {}, 41 | actions: {}, 42 | strict: process.env.NODE_ENV !== 'production' 43 | }) -------------------------------------------------------------------------------- /src/store/modules/IYUU.ts: -------------------------------------------------------------------------------- 1 | // @ts-ignore 2 | import _ from 'lodash' 3 | import {Notification} from 'element-ui' 4 | import {Module, VuexModule, Mutation, MutationAction} from 'vuex-module-decorators' 5 | 6 | import {Site, EnableSite} from "@/interfaces/IYUU/Site"; 7 | import {TorrentClientConfig} from "@/interfaces/BtClient/AbstractClient"; 8 | import {MissionStore} from '@/store/store-accessor' // circular import; OK though 9 | 10 | @Module({namespaced: true, name: 'IYUU'}) 11 | export default class IYUU extends VuexModule { 12 | // 相当于原来的state 13 | token: string | null = null // 用户Token 14 | sites: Site[] = [] // 此处缓存可以使用sites列表(来自服务器) 15 | enable_sites: EnableSite[] = [] // 此处缓存用户已经添加了的站点信息 16 | enable_clients: TorrentClientConfig[] = [] // 此处缓存用户已经添加了的客户端信息 17 | 18 | apiPreInfoHash: number = 2000 // 单次请求最大infohash数 19 | maxRetry: number = 2 // 下载推送尝试次数 20 | 21 | weChatNotify = { 22 | reseed: { 23 | title: 'IYUU GUI自动辅种-统计报表', 24 | descr: `### GUI 版本号: {version} 25 | ** 任务id: {mission_id}** 26 | ** 下载服务器: {clients_count} 个** [本次任务配置启用的下载器数量] 27 | ** 辅种站点: {sites_count} 个** [本次任务配置启用的辅种站点数量] 28 | ** 总做种: {hashs_count} 个** [客户端做种的hash总数] 29 | ** 返回数据: {reseed_count} 个** [服务器返回的可辅种数据] 30 | ** 成功: {reseed_success} 个** [会把hash加入辅种缓存] 31 | ** 失败: {reseed_fail} 个** [种子下载失败或网络超时等原因引起] 32 | ** 跳过: {reseed_pass} 个** [因为下载器辅种缓存等原因跳过] 33 | 34 | ------ 35 | 36 | ### 详细日志 37 | 38 | {full_log} 39 | ` 40 | }, 41 | transfer: { 42 | title: '', 43 | descr: '' 44 | } 45 | } 46 | 47 | get signedSites() { 48 | return this.enable_sites 49 | } 50 | 51 | get signedBtClient() { 52 | return this.enable_clients 53 | } 54 | 55 | get unsignedSites() { // 获取用户未添加站点列表 56 | return _.filter(this.sites, (site: Site) => { 57 | return _.findIndex(this.enable_sites, {site: site.site}) === -1 58 | }) 59 | } 60 | 61 | get siteInfo() { // 通过站点名获取来自服务器的站点信息 62 | return (siteName: string) => { 63 | return this.sites.find((s: Site) => s.site === siteName) 64 | } 65 | } 66 | 67 | get enableSiteInfo() { // 通过站点名来获取用户添加的站点信息 68 | return (siteName: string) => { 69 | return this.enable_sites.find((s: EnableSite) => s.site === siteName) 70 | } 71 | } 72 | 73 | get siteDownloadLinkTpl() { 74 | return (siteName: string) => { 75 | const siteInfo = this.siteInfo(siteName) as Site 76 | 77 | let linkTpl = '' 78 | if (siteInfo) { 79 | linkTpl += siteInfo.is_https > 0 ? 'https://' : 'http://' 80 | linkTpl += siteInfo.base_url + '/' 81 | linkTpl += siteInfo.download_page 82 | } 83 | 84 | return linkTpl 85 | } 86 | } 87 | 88 | @MutationAction({mutate: ['token']}) 89 | async setToken(token: string) { 90 | return {token: token} 91 | } 92 | 93 | @MutationAction({mutate: ['token', 'enable_sites', 'enable_clients']}) 94 | async cleanToken() { 95 | return { 96 | token: '', 97 | enable_sites: [], 98 | enable_clients: [] 99 | } 100 | } 101 | 102 | @MutationAction({mutate: ['sites']}) 103 | async updateSites(sites: Site[]) { 104 | return {sites: sites} 105 | } 106 | 107 | @MutationAction({mutate: ['sites']}) 108 | async cleanSites() { 109 | return {sites: []} 110 | } 111 | 112 | @Mutation 113 | addEnableSite(site: EnableSite) { 114 | this.enable_sites.push(site) 115 | Notification.success(`新增站点 ${site.site} 信息成功`) 116 | } 117 | 118 | @Mutation 119 | editEnableSite(site: EnableSite) { 120 | const siteIndex = this.enable_sites.findIndex((s: { site: string }) => s.site === site.site) 121 | this.enable_sites[siteIndex] = site 122 | Notification.success(`更新站点 ${site.site} 信息成功`) 123 | } 124 | 125 | @Mutation 126 | removeEnableSite(siteId: number) { 127 | const siteInfo: EnableSite = this.enable_sites[siteId] 128 | this.enable_sites.splice(siteId, 1) 129 | Notification.success('成功删除站点 ' + siteInfo.site) 130 | } 131 | 132 | @MutationAction({mutate: ['enable_sites']}) 133 | async cleanEnableSites() { 134 | return {enable_sites: []} 135 | } 136 | 137 | @Mutation 138 | addEnableClient(client: TorrentClientConfig) { 139 | this.enable_clients.push(client) 140 | Notification.success(`新增下载服务器 ${client.name}(${client.type}) 信息成功`) 141 | } 142 | 143 | @Mutation 144 | editEnableClient(client: TorrentClientConfig) { 145 | const clientIndex = this.enable_clients.findIndex((c) => c.uuid === client.uuid) 146 | this.enable_clients[clientIndex] = client 147 | Notification.success(`更新下载服务器 ${client.name}(${client.type}) 信息成功`) 148 | } 149 | 150 | @Mutation 151 | removeEnableClient(clientId: number) { 152 | const clientInfo: TorrentClientConfig = this.enable_clients[clientId] 153 | this.enable_clients.splice(clientId, 1) 154 | MissionStore.cleanReseededByClientId(clientInfo.uuid) 155 | Notification.success(`成功删除下载服务器 ${clientInfo.name}(${clientInfo.type})`) 156 | } 157 | 158 | @MutationAction({mutate: ['enable_clients']}) 159 | async cleanEnableClients() { 160 | await MissionStore.cleanReseeded() 161 | return {enable_clients: []} 162 | } 163 | 164 | @Mutation 165 | updateNormalConfig(config: { 166 | apiPreInfoHash: number, 167 | maxRetry: number 168 | }) { 169 | this.apiPreInfoHash = config.apiPreInfoHash 170 | this.maxRetry = config.maxRetry 171 | } 172 | 173 | @Mutation 174 | restoreFromConfig(config: { 175 | token: string 176 | sites: EnableSite[] 177 | clients: TorrentClientConfig[] 178 | 179 | apiPreInfoHash: number 180 | maxRetry: number 181 | 182 | weChatNotify: any 183 | }) { 184 | this.token = config.token 185 | this.enable_sites = config.sites 186 | this.enable_clients = config.clients 187 | this.apiPreInfoHash = config.apiPreInfoHash 188 | this.maxRetry = config.maxRetry 189 | this.weChatNotify = config.weChatNotify 190 | } 191 | 192 | @Mutation 193 | updateWechatNotify(config: { 194 | part: 'reseed' | 'transfer', 195 | title: string, 196 | descr: string 197 | }) { 198 | this.weChatNotify[config.part] = { 199 | title: config.title, 200 | descr: config.descr 201 | } 202 | } 203 | } -------------------------------------------------------------------------------- /src/store/modules/Mission.ts: -------------------------------------------------------------------------------- 1 | import {Module, Mutation, MutationAction, VuexModule} from "vuex-module-decorators"; 2 | import {LogInfo} from "@/interfaces/store"; 3 | 4 | @Module({namespaced: true, name: 'Mission'}) 5 | export default class Mission extends VuexModule { 6 | logs: { [logId: string]: LogInfo[] } = {} // 不需要持久化 7 | reseeded: { [clientId: string]: string[] } = {} 8 | 9 | currentMission = { // 不需要持久化 10 | logId: '', 11 | processing: false 12 | } 13 | 14 | get logByLogId(): (logId: string) => LogInfo[] { 15 | return (logId: string) => { 16 | return logId in this.logs ? this.logs[logId] : [] 17 | } 18 | } 19 | 20 | get reseededByClientId(): (clientId: string) => string[] { 21 | return (clientId: string) => { 22 | return clientId in this.reseeded ? this.reseeded[clientId] : [] 23 | } 24 | } 25 | 26 | @Mutation 27 | updateCurrentMissionState(mission: { processing: boolean; logId: string }) { 28 | this.currentMission = mission 29 | } 30 | 31 | @Mutation 32 | appendLog(logInfo: { 33 | logId: string, 34 | logMessage: string 35 | }) { 36 | if (!(logInfo.logId in this.logs)) { 37 | this.logs[logInfo.logId] = [] 38 | } 39 | 40 | this.currentMission.logId = logInfo.logId 41 | this.logs[logInfo.logId].push({ 42 | timestamp: Date.now(), 43 | message: logInfo.logMessage 44 | }) 45 | } 46 | 47 | @Mutation 48 | appendReseeded(hashInfo: { 49 | clientId: string, 50 | infoHash: string 51 | }) { 52 | if (!(hashInfo.clientId in this.reseeded)) { 53 | this.reseeded[hashInfo.clientId] = [] 54 | } 55 | 56 | this.reseeded[hashInfo.clientId].push(hashInfo.infoHash) 57 | } 58 | 59 | @Mutation 60 | cleanReseededByClientId(clientId: string) { 61 | if (clientId in this.reseeded) { 62 | this.reseeded[clientId] = [] 63 | } 64 | } 65 | 66 | @MutationAction({mutate: ['reseeded']}) 67 | async cleanReseeded() { 68 | return {reseeded: []} 69 | } 70 | 71 | @Mutation 72 | restoreFromConfig(config: { 73 | reseeded: any 74 | }) { 75 | this.reseeded = config.reseeded 76 | } 77 | } -------------------------------------------------------------------------------- /src/store/modules/Status.ts: -------------------------------------------------------------------------------- 1 | import {Module, Mutation, MutationAction, VuexModule} from "vuex-module-decorators"; 2 | 3 | @Module({namespaced: true, name: 'Status'}) 4 | export default class Status extends VuexModule { 5 | startAppCount: number = 0 6 | startMissionCount: number = 0 7 | reseedTorrentCount: number = 0 8 | 9 | @Mutation 10 | appStart() { 11 | this.startAppCount++ 12 | } 13 | 14 | @MutationAction({mutate:['startAppCount']}) 15 | async cleanAppStart() { 16 | return {startAppCount: 0} 17 | } 18 | 19 | @Mutation 20 | missionStart() { 21 | this.startMissionCount++ 22 | } 23 | 24 | @MutationAction({mutate:['startMissionCount']}) 25 | async cleanMissionStart() { 26 | return {startMissionCount: 0} 27 | } 28 | 29 | @Mutation 30 | torrentReseed() { 31 | this.reseedTorrentCount++ 32 | } 33 | 34 | @MutationAction({mutate:['reseedTorrentCount']}) 35 | async cleanTorrentReseed() { 36 | return {reseedTorrentCount: 0} 37 | } 38 | 39 | @Mutation 40 | restoreFromConfig(config: { 41 | startAppCount: number 42 | startMissionCount: number 43 | reseedTorrentCount: number 44 | }) { 45 | this.startAppCount = config.startAppCount 46 | this.startMissionCount = config.startMissionCount 47 | this.reseedTorrentCount = config.reseedTorrentCount 48 | } 49 | } -------------------------------------------------------------------------------- /src/store/store-accessor.ts: -------------------------------------------------------------------------------- 1 | // This is the "store accessor": 2 | // It initializes all the modules using a Vuex plugin (see store/index.ts) 3 | // In here you import all your modules, call getModule on them to turn them 4 | // into the actual stores, and then re-export them. 5 | 6 | import {Store} from 'vuex' 7 | import {getModule} from 'vuex-module-decorators' 8 | 9 | import IYUUModule from "@/store/modules/IYUU"; 10 | import MissionModule from "@/store/modules/Mission"; 11 | import StatusModule from "@/store/modules/Status"; 12 | 13 | // Each store is the singleton instance of its module class 14 | // Use these -- they have methods for state/getters/mutations/actions 15 | // (result from getModule(...)) 16 | export let IYUUStore: IYUUModule 17 | export let MissionStore: MissionModule 18 | export let StatusStore: StatusModule 19 | 20 | // initializer plugin: sets up state/getters/mutations/actions for each store 21 | export function initializeStores(store: Store): void { 22 | IYUUStore = getModule(IYUUModule, store) 23 | MissionStore = getModule(MissionModule, store) 24 | StatusStore = getModule(StatusModule, store) 25 | } 26 | 27 | // for use in 'modules' store init (see store/index.ts), so each module 28 | // appears as an element of the root store's state. 29 | // (This is required!) 30 | export const modules = { 31 | 'IYUU': IYUUModule, 32 | 'Mission': MissionModule, 33 | 'Status': StatusModule 34 | } 35 | -------------------------------------------------------------------------------- /src/views/Gratitude/Declare.vue: -------------------------------------------------------------------------------- 1 | 121 | 122 | 240 | 241 | 251 | -------------------------------------------------------------------------------- /src/views/Gratitude/Donate.vue: -------------------------------------------------------------------------------- 1 | 49 | 50 | 59 | 60 | 65 | -------------------------------------------------------------------------------- /src/views/Home.vue: -------------------------------------------------------------------------------- 1 | 46 | 47 | 65 | 66 | 69 | -------------------------------------------------------------------------------- /src/views/Layer.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 23 | 24 | 29 | 30 | 35 | -------------------------------------------------------------------------------- /src/views/Login.vue: -------------------------------------------------------------------------------- 1 | 62 | 63 | 193 | 194 | 203 | 204 | -------------------------------------------------------------------------------- /src/views/Mission.vue: -------------------------------------------------------------------------------- 1 | 59 | 60 | 186 | 187 | 194 | -------------------------------------------------------------------------------- /src/views/Setting/Backup.vue: -------------------------------------------------------------------------------- 1 | 95 | 96 | 183 | 184 | 187 | -------------------------------------------------------------------------------- /src/views/Setting/BtClient.vue: -------------------------------------------------------------------------------- 1 | 73 | 74 | 114 | 115 | 123 | -------------------------------------------------------------------------------- /src/views/Setting/Other.vue: -------------------------------------------------------------------------------- 1 | 49 | 50 | 73 | 74 | 77 | -------------------------------------------------------------------------------- /src/views/Setting/Site.vue: -------------------------------------------------------------------------------- 1 | 71 | 72 | 146 | 147 | 150 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "esnext", 4 | "module": "esnext", 5 | "strict": true, 6 | "jsx": "preserve", 7 | "importHelpers": true, 8 | "moduleResolution": "node", 9 | "experimentalDecorators": true, 10 | "allowJs": true, 11 | "esModuleInterop": true, 12 | "allowSyntheticDefaultImports": true, 13 | "sourceMap": true, 14 | "baseUrl": ".", 15 | "types": [ 16 | "webpack-env" 17 | ], 18 | "paths": { 19 | "@/*": [ 20 | "src/*" 21 | ] 22 | }, 23 | "lib": [ 24 | "esnext", 25 | "dom", 26 | "dom.iterable", 27 | "scripthost" 28 | ] 29 | }, 30 | "include": [ 31 | "src/**/*.ts", 32 | "src/**/*.tsx", 33 | "src/**/*.vue", 34 | "tests/**/*.ts", 35 | "tests/**/*.tsx" 36 | ], 37 | "exclude": [ 38 | "node_modules" 39 | ] 40 | } 41 | -------------------------------------------------------------------------------- /vue.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | pluginOptions: { 3 | electronBuilder: { 4 | 5 | // refs: https://nklayman.github.io/vue-cli-plugin-electron-builder/guide/configuration.html#configuring-electron-builder 6 | // refs: https://www.electron.build/configuration/configuration 7 | builderOptions: { 8 | appId: "info.rhilip.iyuu", 9 | productName: 'IYUU GUI', 10 | copyright: 'Copyright © 2020-2030 Rhilip', 11 | 12 | "nsis": { 13 | "deleteAppDataOnUninstall": true 14 | }, 15 | publish: ['github'] 16 | }, 17 | 18 | // refs: https://github.com/nklayman/vue-cli-plugin-electron-builder/issues/683 19 | nodeIntegration: true, 20 | 21 | // refs: https://nklayman.github.io/vue-cli-plugin-electron-builder/guide/configuration.html#typescript-options 22 | disableMainProcessTypescript: false, // Manually disable typescript plugin for main process. Enable if you want to use regular js for the main process (src/background.js by default). 23 | mainProcessTypeChecking: false, // Manually enable type checking during webpck bundling for background file. 24 | 25 | // If you are using Yarn Workspaces, you may have multiple node_modules folders 26 | // List them all here so that VCP Electron Builder can find them 27 | nodeModulesPath: ['../../node_modules', './node_modules'] 28 | } 29 | }, 30 | 31 | // refs: https://github.com/championswimmer/vuex-module-decorators#using-with-target-es5 32 | transpileDependencies: ['vuex-module-decorators'] 33 | } --------------------------------------------------------------------------------