├── .browserslistrc ├── .devcontainer ├── Dockerfile ├── devcontainer.json └── docker-compose.yml ├── .editorconfig ├── .eslintrc.js ├── .github ├── ISSUE_TEMPLATE │ └── bug_report.md └── workflows │ ├── release.yml │ └── test.yml ├── .gitignore ├── .yarnrc.yml ├── DEV.md ├── INSTALL.md ├── LICENSE ├── README.md ├── babel.config.js ├── bin └── fix-site-icon-size.mjs ├── jest.config.js ├── package.json ├── postcss.config.js ├── public ├── img │ └── icons │ │ ├── apple-touch-icon.png │ │ ├── favicon-16x16.png │ │ ├── favicon-192x192.png │ │ └── favicon-32x32.png ├── index.html ├── manifest.json └── robots.txt ├── screenshot ├── CORS-settings.png ├── add-torrents.png ├── main.png ├── rss-rule.png └── rss.png ├── src ├── Api.ts ├── App.vue ├── assets │ ├── logo.png │ ├── site_icons │ │ ├── 2xfree.png │ │ ├── chdbits.png │ │ ├── hares.png │ │ ├── hdchina.png │ │ ├── hdsky.png │ │ ├── hdtime.png │ │ ├── kamept.png │ │ ├── keepfrds.png │ │ ├── lemonhd.png │ │ ├── m-team.png │ │ ├── nexusphp.png │ │ ├── opencd.png │ │ ├── ourbits.png │ │ ├── pterclub.png │ │ ├── pthome.png │ │ ├── ptsbao.png │ │ ├── pttime.png │ │ ├── soulvoice.png │ │ ├── springsunday.png │ │ ├── totheglory.png │ │ └── u2.png │ └── styles.scss ├── buildInfo.ts ├── components │ ├── AddForm.vue │ ├── Drawer.vue │ ├── Footer.vue │ ├── GlobalDialog.vue │ ├── GlobalSnackBar.vue │ ├── LoginForm.vue │ ├── MainToolbar.vue │ ├── Torrents.vue │ ├── dialogs │ │ ├── ConfirmDeleteDialog.vue │ │ ├── ConfirmSetCategoryDialog.vue │ │ ├── EditTrackerDialog.vue │ │ ├── InfoDialog.vue │ │ ├── LogsDialog.vue │ │ ├── Panel.vue │ │ ├── Peers.vue │ │ ├── RssDialog.vue │ │ ├── RssRulesDialog.vue │ │ ├── TorrentContent.vue │ │ ├── TorrentInfo.vue │ │ ├── Trackers.vue │ │ ├── baseTorrentInfo.ts │ │ ├── searchDialog │ │ │ ├── PluginsManager.vue │ │ │ ├── SearchDialog.vue │ │ │ └── SearchDialogForm.vue │ │ └── settingsDialog │ │ │ ├── DownloadSettings.vue │ │ │ ├── PreferenceRow.vue │ │ │ ├── RssSettings.vue │ │ │ ├── SettingsDialog.vue │ │ │ ├── SpeedSettings.vue │ │ │ └── WebUISettings.vue │ ├── drawer │ │ ├── DrawerFooter.vue │ │ └── FilterGroup.vue │ └── types.ts ├── consts.ts ├── directives.ts ├── filters.ts ├── locale │ ├── en.ts │ ├── index.ts │ ├── nl.ts │ ├── ru.ts │ ├── tr.ts │ ├── zh-CN.ts │ └── zh-TW.ts ├── main.ts ├── mixins │ └── hasTask.ts ├── plugins │ ├── composition-api.ts │ ├── i18n.ts │ └── vuetify.ts ├── protocolHandler.ts ├── router.ts ├── shims-tsx.d.ts ├── shims-vue.d.ts ├── sites.ts ├── store │ ├── addForm.ts │ ├── config.ts │ ├── dialog.ts │ ├── index.ts │ ├── searchEngine.ts │ ├── snackBar.ts │ └── types.ts ├── types.ts └── utils │ ├── index.ts │ └── vue-object-merge.ts ├── tag-nightly.sh ├── tests └── unit │ ├── .eslintrc.js │ ├── components │ └── FilterGroup.spec.ts │ ├── filters.spec.ts │ ├── store │ ├── config.spec.ts │ └── index.spec.ts │ ├── utils.spec.ts │ └── utils.ts ├── tsconfig.json ├── vue.config.js └── yarn.lock /.browserslistrc: -------------------------------------------------------------------------------- 1 | > 1% 2 | last 2 versions 3 | not ie <= 8 4 | -------------------------------------------------------------------------------- /.devcontainer/Dockerfile: -------------------------------------------------------------------------------- 1 | # See here for image contents: https://github.com/microsoft/vscode-dev-containers/tree/v0.177.0/containers/typescript-node/.devcontainer/base.Dockerfile 2 | 3 | # [Choice] Node.js version: 16, 14, 12 4 | ARG VARIANT="16-buster" 5 | FROM mcr.microsoft.com/vscode/devcontainers/typescript-node:0-${VARIANT} 6 | 7 | # [Optional] Uncomment this section to install additional OS packages. 8 | # RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \ 9 | # && apt-get -y install --no-install-recommends 10 | 11 | # [Optional] Uncomment if you want to install an additional version of node using nvm 12 | # ARG EXTRA_NODE_VERSION=10 13 | # RUN su node -c "source /usr/local/share/nvm/nvm.sh && nvm install ${EXTRA_NODE_VERSION}" 14 | 15 | # [Optional] Uncomment if you want to install more global node packages 16 | # RUN su node -c "npm install -g " 17 | 18 | -------------------------------------------------------------------------------- /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | // For format details, see https://aka.ms/devcontainer.json. For config options, see the README at: 2 | // https://github.com/microsoft/vscode-dev-containers/tree/v0.177.0/containers/typescript-node 3 | { 4 | "name": "qBittorrent", 5 | 6 | // Update the 'dockerComposeFile' list if you have more compose files or use different names. 7 | "dockerComposeFile": "docker-compose.yml", 8 | 9 | // The 'service' property is the name of the service for the container that VS Code should 10 | // use. Update this value and .devcontainer/docker-compose.yml to the real service name. 11 | "service": "app", 12 | 13 | // The optional 'workspaceFolder' property is the path VS Code should open by default when 14 | // connected. This is typically a volume mount in .devcontainer/docker-compose.yml 15 | "workspaceFolder": "/workspace", 16 | 17 | // Set *default* container specific settings.json values on container create. 18 | "settings": { 19 | "terminal.integrated.defaultProfile.linux": "/bin/bash" 20 | }, 21 | 22 | // Add the IDs of extensions you want installed when the container is created. 23 | "extensions": [ 24 | "dbaeumer.vscode-eslint", 25 | "jcbuisson.vue", 26 | "eamodio.gitlens", 27 | "donjayamanne.githistory" 28 | ], 29 | 30 | // Use 'forwardPorts' to make a list of ports inside the container available locally. 31 | "forwardPorts": [ 32 | 8000, 33 | 8080 34 | ], 35 | 36 | // Use 'postCreateCommand' to run commands after the container is created. 37 | "postCreateCommand": "yarn install", 38 | 39 | // Comment out connect as root instead. More info: https://aka.ms/vscode-remote/containers/non-root. 40 | "remoteUser": "node" 41 | } -------------------------------------------------------------------------------- /.devcontainer/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | 3 | services: 4 | qb.test: 5 | image: linuxserver/qbittorrent 6 | container_name: qbittorrent 7 | environment: 8 | - PUID=1000 9 | - PGID=1000 10 | - UMASK_SET=022 11 | - WEBUI_PORT=8080 12 | ports: 13 | - 8080:8080 14 | volumes: 15 | - ./qbittorrent:/config 16 | - ./downloads:/downloads 17 | restart: unless-stopped 18 | app: 19 | container_name: qb-web 20 | depends_on: 21 | - qb.test 22 | # Using a Dockerfile is optional, but included for completeness. 23 | build: 24 | context: . 25 | dockerfile: Dockerfile 26 | # [Optional] You can use build args to set options. e.g. 'VARIANT' below affects the image in the Dockerfile 27 | args: 28 | VARIANT: 16-buster 29 | 30 | volumes: 31 | # This is where VS Code should expect to find your project's source code and the value of "workspaceFolder" in .devcontainer/devcontainer.json 32 | - ..:/workspace:cached 33 | 34 | # Uncomment the next line to use Docker from inside the container. See https://aka.ms/vscode-remote/samples/docker-from-docker-compose for details. 35 | # - /var/run/docker.sock:/var/run/docker.sock 36 | 37 | # Overrides default command so things don't shut down after the process ends. 38 | command: /bin/sh -c "while sleep 1000; do :; done" 39 | 40 | # Runs app on the same network as the service container, allows "forwardPorts" in devcontainer.json function. 41 | network_mode: service:qb.test -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | [*.{js,jsx,ts,tsx,vue}] 2 | indent_style = space 3 | indent_size = 2 4 | end_of_line = lf 5 | trim_trailing_whitespace = true 6 | insert_final_newline = true 7 | max_line_length = 100 8 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | const isProdEnv = process.env.NODE_ENV === 'production'; 2 | 3 | module.exports = { 4 | root: true, 5 | env: { 6 | node: true, 7 | }, 8 | plugins: [ 9 | ], 10 | extends: [ 11 | 'plugin:vue/strongly-recommended', 12 | 'eslint:recommended', 13 | '@vue/typescript/recommended', 14 | ], 15 | rules: { 16 | 'no-console': isProdEnv ? 'error' : 'warn', 17 | 'no-debugger': isProdEnv ? 'error' : 'warn', 18 | 19 | "comma-dangle": ["error", "always-multiline"], 20 | 21 | '@typescript-eslint/no-non-null-assertion': 'off', 22 | '@typescript-eslint/no-explicit-any': 'off', 23 | 24 | 'vue/singleline-html-element-content-newline': ['warn', { 25 | ignores: ['pre', 'textarea', 'span', 'v-icon'], 26 | }], 27 | }, 28 | parserOptions: { 29 | parser: '@typescript-eslint/parser', 30 | }, 31 | overrides: [ 32 | { 33 | files: [ 34 | '**/__tests__/*.{j,t}s?(x)', 35 | '**/tests/unit/**/*.spec.{j,t}s?(x)', 36 | ], 37 | env: { 38 | jest: true, 39 | }, 40 | }, 41 | ], 42 | }; 43 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: "[BUG]" 5 | labels: bug 6 | assignees: '' 7 | 8 | --- 9 | 10 | ## Describe the bug 11 | 12 | _description of what the bug is, and the conditions when it occurs._ 13 | 14 | ## How to reproduce 15 | Steps to reproduce the behavior: 16 | 17 | 1. Go to '...' 18 | 2. Click on '....' 19 | 3. Scroll down to '....' 20 | 4. See error 21 | 22 | ## Expected behavior 23 | 24 | _description of what you expected to happen._ 25 | 26 | ## Official behavior 27 | 28 | _description of what happened in the official web ui._ 29 | 30 | ## Versions 31 | 32 | - qBittorrent: [e.g. 4.2.5] 33 | - qb-web: [e.g. 20201023] 34 | 35 | ## Additional notes 36 | 37 | _Add any other notes about the problem here._ 38 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | tags: 6 | - nightly-* 7 | 8 | jobs: 9 | build: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v4 13 | - name: Use Node.js 14 | uses: actions/setup-node@v4 15 | with: 16 | node-version: '16' 17 | - run: | 18 | corepack enable 19 | yarn install --frozen-lockfile 20 | 21 | - name: Set env 22 | run: echo "RELEASE_FILE=qb-web-${GITHUB_REF#refs/*/}.zip" >> $GITHUB_ENV 23 | 24 | - name: Pack Release 25 | run: | 26 | yarn run build 27 | # see https://github.com/qbittorrent/qBittorrent/pull/10485, fixed in qb v4.2.0 28 | cp dist/public/{index,login}.html 29 | cp INSTALL.md dist 30 | zip -r $RELEASE_FILE dist 31 | 32 | - name: Publish 33 | run: | 34 | cd dist/public 35 | git init 36 | git config user.name ${{ github.actor }} 37 | git config user.email ${{ github.actor }}@users.noreply.github.com 38 | git remote add origin https://x-access-token:${{ github.token }}@github.com/${{ github.repository }}.git 39 | git checkout -b gh-pages 40 | git add --all 41 | git commit -m "Publish" 42 | git push origin gh-pages -f 43 | 44 | - id: create_release 45 | uses: actions/create-release@v1 46 | env: 47 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 48 | with: 49 | tag_name: ${{ github.ref }} 50 | release_name: ${{ github.ref }} 51 | prerelease: true 52 | 53 | - uses: actions/upload-release-asset@v1 54 | env: 55 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 56 | with: 57 | upload_url: ${{ steps.create_release.outputs.upload_url }} 58 | asset_path: ${{ env.RELEASE_FILE }} 59 | asset_name: ${{ env.RELEASE_FILE }} 60 | asset_content_type: application/zip 61 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | test: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/checkout@v4 10 | - name: Use Node.js 11 | uses: actions/setup-node@v4 12 | with: 13 | node-version: '16' 14 | - run: | 15 | corepack enable 16 | yarn install --frozen-lockfile 17 | 18 | - run: yarn run lint --no-fix --max-warnings 0 19 | - run: yarn run test:unit -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | /dist 4 | /coverage 5 | 6 | # local env files 7 | .env.local 8 | .env.*.local 9 | 10 | # Log files 11 | npm-debug.log* 12 | yarn-debug.log* 13 | yarn-error.log* 14 | 15 | # Editor directories and files 16 | .idea 17 | .vscode 18 | *.suo 19 | *.ntvs* 20 | *.njsproj 21 | *.sln 22 | *.sw? 23 | 24 | /.devcontainer/downloads 25 | /.devcontainer/qbittorrent -------------------------------------------------------------------------------- /.yarnrc.yml: -------------------------------------------------------------------------------- 1 | nodeLinker: node-modules 2 | -------------------------------------------------------------------------------- /DEV.md: -------------------------------------------------------------------------------- 1 | # qb-web 2 | 3 | ## Project setup 4 | ``` 5 | yarn install 6 | ``` 7 | 8 | ### Compiles and hot-reloads for development 9 | ``` 10 | yarn run serve 11 | ``` 12 | 13 | ### Compiles and minifies for production 14 | ``` 15 | yarn run build 16 | ``` 17 | 18 | ### Lints and fixes files 19 | ``` 20 | yarn run lint 21 | ``` 22 | 23 | ### Customize configuration 24 | See [Configuration Reference](https://cli.vuejs.org/config/). 25 | -------------------------------------------------------------------------------- /INSTALL.md: -------------------------------------------------------------------------------- 1 | Download release from: https://github.com/CzBiX/qb-web/releases/latest 2 | 3 | Setup: 4 | 5 | 1. Extrace all files. 6 | 2. Open Web UI Options dialog, Set "Files location" of "alternative Web UI" to this folder(without `public` suffix). 7 | 8 | RECOVERY: 9 | If something was going wrong, you can append `/api/v2/app/setPreferences?json=%7B%22alternative_webui_enabled%22:false%7D` after URL to disable alternative Web UI. 10 | 11 | ======== 12 | 13 | 安装说明: 14 | 15 | 1. 解压所有文件。 16 | 2. 打开 Web 用户界面的设置,将 “使用备用 Web UI” 的 “文件路径” 设置为本目录(不包含 `public` 后缀)。 17 | 18 | 恢复: 19 | 如果因为配置错误导致无法访问 Web 界面,可以手动在 URL 地址后面加上 `/api/v2/app/setPreferences?json=%7B%22alternative_webui_enabled%22:false%7D` 来禁用备用 UI 功能。 20 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # qb-web 2 | ## Info 3 | [![Tested](https://img.shields.io/badge/Tested-qBittorrent%20≥%20v4.2.5-brightgreen)](#) 4 | [![Release](https://img.shields.io/github/v/release/CzBiX/qb-web?include_prereleases)](https://github.com/CzBiX/qb-web/releases/latest) 5 | [![Gitter](https://badges.gitter.im/qb-web/community.svg)](https://gitter.im/qb-web/community?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge) 6 | [![CI](https://github.com/CzBiX/qb-web/workflows/CI/badge.svg)](https://github.com/CzBiX/qb-web/actions) 7 | 8 | ## Features 9 | Keywords: SPA, RSS, Search, Responsive Design, Modern Design, i18n 10 | 11 | Languages: English, 中文, Русский, Türkçe 12 | 13 | [TODO](https://github.com/CzBiX/qb-web/projects/2) 14 | 15 | ## How to use 16 | see: [Wiki](https://github.com/CzBiX/qb-web/wiki/How-to-use) 17 | 18 | ## Wiki 19 | 20 | [Running multi WebUI at the same time](https://github.com/CzBiX/qb-web/wiki/Running-multi-WebUI-at-the-same-time) 21 | 22 | ## Screenshot 23 | 24 | ![Main](./screenshot/main.png) 25 | ![Add Torrents](./screenshot/add-torrents.png) 26 | ![RSS](./screenshot/rss.png) 27 | ![RSS Rule](./screenshot/rss-rule.png) 28 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [ 3 | '@vue/cli-plugin-babel/preset', 4 | ], 5 | }; 6 | -------------------------------------------------------------------------------- /bin/fix-site-icon-size.mjs: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-console */ 2 | import path from 'path' 3 | import fs from 'fs/promises' 4 | import { execSync } from 'child_process' 5 | 6 | const ICON_DIR = 'src/assets/site_icons' 7 | const ICON_SIZE = 48 8 | 9 | function execShell(command) { 10 | const output = execSync(command) 11 | return output.toString().trim() 12 | } 13 | 14 | function isIconFile(iconPath) { 15 | const output = execShell(`file -b --mime-type ${iconPath}`) 16 | return output.includes('image/vnd.microsoft.icon') 17 | } 18 | 19 | function getIconIndex(iconPath) { 20 | const output = execShell(`magick identify -format "%s:%w\\n" ${iconPath}`) 21 | let lastIndex = 0 22 | for (const line of output.split('\n')) { 23 | const [index, width] = line.split(':') 24 | const size = parseInt(width) 25 | if (size >= ICON_SIZE) { 26 | return index 27 | } 28 | 29 | lastIndex = index 30 | } 31 | 32 | return lastIndex 33 | } 34 | 35 | function convertIcon(iconPath, outputPath) { 36 | const iconIndex = getIconIndex(iconPath) 37 | execShell(`magick convert -thumbnail "${ICON_SIZE}x${ICON_SIZE}>" ${iconPath}[${iconIndex}] ${outputPath}`) 38 | } 39 | 40 | async function fixSiteIcon(name) { 41 | const iconPath = path.join(ICON_DIR, name) 42 | if (!isIconFile(iconPath)) { 43 | return 44 | } 45 | 46 | const tmpPath = path.join(ICON_DIR, '_tmp.ico') 47 | await fs.copyFile(iconPath, tmpPath) 48 | try { 49 | convertIcon(tmpPath, iconPath) 50 | } finally { 51 | await fs.unlink(tmpPath) 52 | } 53 | 54 | console.log(`Converted ${name}`) 55 | } 56 | 57 | async function main() { 58 | let files = [] 59 | if (process.argv.length > 2) { 60 | files = process.argv.slice(2) 61 | } else { 62 | files = await fs.readdir(ICON_DIR) 63 | } 64 | 65 | files = files.filter(name => { 66 | return name.endsWith('.png') 67 | }) 68 | 69 | for (const name of files) { 70 | await fixSiteIcon(name) 71 | } 72 | } 73 | 74 | main() -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | const isCi = !!process.env.CI; 2 | 3 | module.exports = { 4 | preset: '@vue/cli-plugin-unit-jest/presets/typescript-and-babel', 5 | collectCoverage: isCi, 6 | }; 7 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "qb-web", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "serve": "vue-cli-service serve", 7 | "build": "vue-cli-service build", 8 | "test:unit": "vue-cli-service test:unit", 9 | "lint": "vue-cli-service lint" 10 | }, 11 | "dependencies": { 12 | "@mdi/font": "^5.0.45", 13 | "@types/node-polyglot": "^0.4.34", 14 | "@vue/composition-api": "^1.0.5", 15 | "axios": "^0.21.1", 16 | "core-js": "^3.6.5", 17 | "dayjs": "^1.8.23", 18 | "debug": "^4.1.1", 19 | "lodash": "^4.17.21", 20 | "node-polyglot": "^2.4.0", 21 | "roboto-fontface": "*", 22 | "vue": "^2.6.11", 23 | "vue-class-component": "^7.2.3", 24 | "vue-property-decorator": "^9.1.2", 25 | "vue-router": "^3.2.0", 26 | "vuetify": "^2.5.8", 27 | "vuex": "^3.4.0" 28 | }, 29 | "devDependencies": { 30 | "@types/debug": "^4.1.5", 31 | "@types/jest": "^25.1.4", 32 | "@types/lodash": "^4.14.149", 33 | "@typescript-eslint/eslint-plugin": "^5.4.0", 34 | "@typescript-eslint/parser": "^5.4.0", 35 | "@vue/cli-plugin-babel": "~5.0.8", 36 | "@vue/cli-plugin-eslint": "~5.0.8", 37 | "@vue/cli-plugin-pwa": "~5.0.8", 38 | "@vue/cli-plugin-router": "~5.0.8", 39 | "@vue/cli-plugin-typescript": "~5.0.8", 40 | "@vue/cli-plugin-unit-jest": "~5.0.8", 41 | "@vue/cli-plugin-vuex": "~5.0.8", 42 | "@vue/cli-service": "~5.0.8", 43 | "@vue/eslint-config-typescript": "^9.1.0", 44 | "@vue/test-utils": "1.0.0-beta.29", 45 | "@vue/vue2-jest": "^27.0.0-alpha.3", 46 | "eslint": "^7.32.0", 47 | "eslint-plugin-vue": "^8.0.3", 48 | "jest": "^27.1.0", 49 | "lint-staged": "^10.1.1", 50 | "sass": "^1.26.5", 51 | "sass-loader": "^8.0.2", 52 | "ts-jest": "^27.0.4", 53 | "typescript": "~4.5.5", 54 | "vue-cli-plugin-vuetify": "^2.0.5", 55 | "vue-template-compiler": "^2.6.11", 56 | "vuetify-loader": "^1.4.3" 57 | }, 58 | "gitHooks": { 59 | "pre-commit": "lint-staged" 60 | }, 61 | "lint-staged": { 62 | "*.{js,vue,ts}": [ 63 | "vue-cli-service lint" 64 | ] 65 | }, 66 | "packageManager": "yarn@3.5.0" 67 | } 68 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | autoprefixer: {}, 4 | }, 5 | }; 6 | -------------------------------------------------------------------------------- /public/img/icons/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CzBiX/qb-web/26a1228d17a6ae24976ab7de07669fc188b0698e/public/img/icons/apple-touch-icon.png -------------------------------------------------------------------------------- /public/img/icons/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CzBiX/qb-web/26a1228d17a6ae24976ab7de07669fc188b0698e/public/img/icons/favicon-16x16.png -------------------------------------------------------------------------------- /public/img/icons/favicon-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CzBiX/qb-web/26a1228d17a6ae24976ab7de07669fc188b0698e/public/img/icons/favicon-192x192.png -------------------------------------------------------------------------------- /public/img/icons/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CzBiX/qb-web/26a1228d17a6ae24976ab7de07669fc188b0698e/public/img/icons/favicon-32x32.png -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | qBittorrent Web UI 11 | 12 | 13 | 16 |
17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "icons": [ 3 | { 4 | "src": "./img/icons/favicon-192x192.png", 5 | "sizes": "192x192", 6 | "type": "image/png" 7 | } 8 | ] 9 | } -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | User-agent: * 2 | Disallow: 3 | -------------------------------------------------------------------------------- /screenshot/CORS-settings.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CzBiX/qb-web/26a1228d17a6ae24976ab7de07669fc188b0698e/screenshot/CORS-settings.png -------------------------------------------------------------------------------- /screenshot/add-torrents.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CzBiX/qb-web/26a1228d17a6ae24976ab7de07669fc188b0698e/screenshot/add-torrents.png -------------------------------------------------------------------------------- /screenshot/main.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CzBiX/qb-web/26a1228d17a6ae24976ab7de07669fc188b0698e/screenshot/main.png -------------------------------------------------------------------------------- /screenshot/rss-rule.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CzBiX/qb-web/26a1228d17a6ae24976ab7de07669fc188b0698e/screenshot/rss-rule.png -------------------------------------------------------------------------------- /screenshot/rss.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CzBiX/qb-web/26a1228d17a6ae24976ab7de07669fc188b0698e/screenshot/rss.png -------------------------------------------------------------------------------- /src/App.vue: -------------------------------------------------------------------------------- 1 | 57 | 58 | 265 | 266 | 271 | 272 | 277 | -------------------------------------------------------------------------------- /src/assets/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CzBiX/qb-web/26a1228d17a6ae24976ab7de07669fc188b0698e/src/assets/logo.png -------------------------------------------------------------------------------- /src/assets/site_icons/2xfree.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CzBiX/qb-web/26a1228d17a6ae24976ab7de07669fc188b0698e/src/assets/site_icons/2xfree.png -------------------------------------------------------------------------------- /src/assets/site_icons/chdbits.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CzBiX/qb-web/26a1228d17a6ae24976ab7de07669fc188b0698e/src/assets/site_icons/chdbits.png -------------------------------------------------------------------------------- /src/assets/site_icons/hares.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CzBiX/qb-web/26a1228d17a6ae24976ab7de07669fc188b0698e/src/assets/site_icons/hares.png -------------------------------------------------------------------------------- /src/assets/site_icons/hdchina.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CzBiX/qb-web/26a1228d17a6ae24976ab7de07669fc188b0698e/src/assets/site_icons/hdchina.png -------------------------------------------------------------------------------- /src/assets/site_icons/hdsky.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CzBiX/qb-web/26a1228d17a6ae24976ab7de07669fc188b0698e/src/assets/site_icons/hdsky.png -------------------------------------------------------------------------------- /src/assets/site_icons/hdtime.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CzBiX/qb-web/26a1228d17a6ae24976ab7de07669fc188b0698e/src/assets/site_icons/hdtime.png -------------------------------------------------------------------------------- /src/assets/site_icons/kamept.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CzBiX/qb-web/26a1228d17a6ae24976ab7de07669fc188b0698e/src/assets/site_icons/kamept.png -------------------------------------------------------------------------------- /src/assets/site_icons/keepfrds.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CzBiX/qb-web/26a1228d17a6ae24976ab7de07669fc188b0698e/src/assets/site_icons/keepfrds.png -------------------------------------------------------------------------------- /src/assets/site_icons/lemonhd.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CzBiX/qb-web/26a1228d17a6ae24976ab7de07669fc188b0698e/src/assets/site_icons/lemonhd.png -------------------------------------------------------------------------------- /src/assets/site_icons/m-team.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CzBiX/qb-web/26a1228d17a6ae24976ab7de07669fc188b0698e/src/assets/site_icons/m-team.png -------------------------------------------------------------------------------- /src/assets/site_icons/nexusphp.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CzBiX/qb-web/26a1228d17a6ae24976ab7de07669fc188b0698e/src/assets/site_icons/nexusphp.png -------------------------------------------------------------------------------- /src/assets/site_icons/opencd.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CzBiX/qb-web/26a1228d17a6ae24976ab7de07669fc188b0698e/src/assets/site_icons/opencd.png -------------------------------------------------------------------------------- /src/assets/site_icons/ourbits.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CzBiX/qb-web/26a1228d17a6ae24976ab7de07669fc188b0698e/src/assets/site_icons/ourbits.png -------------------------------------------------------------------------------- /src/assets/site_icons/pterclub.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CzBiX/qb-web/26a1228d17a6ae24976ab7de07669fc188b0698e/src/assets/site_icons/pterclub.png -------------------------------------------------------------------------------- /src/assets/site_icons/pthome.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CzBiX/qb-web/26a1228d17a6ae24976ab7de07669fc188b0698e/src/assets/site_icons/pthome.png -------------------------------------------------------------------------------- /src/assets/site_icons/ptsbao.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CzBiX/qb-web/26a1228d17a6ae24976ab7de07669fc188b0698e/src/assets/site_icons/ptsbao.png -------------------------------------------------------------------------------- /src/assets/site_icons/pttime.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CzBiX/qb-web/26a1228d17a6ae24976ab7de07669fc188b0698e/src/assets/site_icons/pttime.png -------------------------------------------------------------------------------- /src/assets/site_icons/soulvoice.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CzBiX/qb-web/26a1228d17a6ae24976ab7de07669fc188b0698e/src/assets/site_icons/soulvoice.png -------------------------------------------------------------------------------- /src/assets/site_icons/springsunday.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CzBiX/qb-web/26a1228d17a6ae24976ab7de07669fc188b0698e/src/assets/site_icons/springsunday.png -------------------------------------------------------------------------------- /src/assets/site_icons/totheglory.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CzBiX/qb-web/26a1228d17a6ae24976ab7de07669fc188b0698e/src/assets/site_icons/totheglory.png -------------------------------------------------------------------------------- /src/assets/site_icons/u2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CzBiX/qb-web/26a1228d17a6ae24976ab7de07669fc188b0698e/src/assets/site_icons/u2.png -------------------------------------------------------------------------------- /src/assets/styles.scss: -------------------------------------------------------------------------------- 1 | @import '~vuetify/src/styles/styles.sass'; 2 | 3 | @mixin dialog-title { 4 | @include theme(v-card) using($material) { 5 | .v-card__title { 6 | background-color: map-get($material, 'app-bar'); 7 | } 8 | } 9 | } 10 | 11 | @mixin dark-mode-value($light, $dark) { 12 | &.theme--light { 13 | @content($light); 14 | } 15 | &.theme--dark { 16 | @content($dark); 17 | } 18 | } -------------------------------------------------------------------------------- /src/buildInfo.ts: -------------------------------------------------------------------------------- 1 | let buildInfo = process.env.GIT_TAG 2 | 3 | if (!buildInfo) { 4 | buildInfo = 'dev' 5 | } 6 | 7 | // eslint-disable-next-line no-console 8 | console.log(`%c qb-web Build %c ${buildInfo} `, 9 | 'background-color: #555; color: #fff; border-radius: 3px 0 0 3px;', 10 | 'background-color: #1976d2; color: #fff; border-radius: 0 3px 3px 0;', 11 | ) 12 | 13 | export default buildInfo -------------------------------------------------------------------------------- /src/components/GlobalDialog.vue: -------------------------------------------------------------------------------- 1 | 40 | 41 | 144 | 145 | -------------------------------------------------------------------------------- /src/components/GlobalSnackBar.vue: -------------------------------------------------------------------------------- 1 | 20 | 21 | 55 | -------------------------------------------------------------------------------- /src/components/LoginForm.vue: -------------------------------------------------------------------------------- 1 | 69 | 70 | 133 | -------------------------------------------------------------------------------- /src/components/MainToolbar.vue: -------------------------------------------------------------------------------- 1 | 40 | 41 | 88 | 89 | 114 | -------------------------------------------------------------------------------- /src/components/dialogs/ConfirmDeleteDialog.vue: -------------------------------------------------------------------------------- 1 | 57 | 58 | 120 | 121 | 141 | -------------------------------------------------------------------------------- /src/components/dialogs/ConfirmSetCategoryDialog.vue: -------------------------------------------------------------------------------- 1 | 57 | 58 | 121 | 122 | 142 | -------------------------------------------------------------------------------- /src/components/dialogs/EditTrackerDialog.vue: -------------------------------------------------------------------------------- 1 | 102 | 103 | 211 | 212 | 238 | -------------------------------------------------------------------------------- /src/components/dialogs/InfoDialog.vue: -------------------------------------------------------------------------------- 1 | 99 | 100 | 146 | 147 | 187 | -------------------------------------------------------------------------------- /src/components/dialogs/LogsDialog.vue: -------------------------------------------------------------------------------- 1 | 44 | 45 | 113 | 114 | 129 | -------------------------------------------------------------------------------- /src/components/dialogs/Panel.vue: -------------------------------------------------------------------------------- 1 | 18 | 19 | 32 | 33 | 51 | -------------------------------------------------------------------------------- /src/components/dialogs/Peers.vue: -------------------------------------------------------------------------------- 1 | 44 | 45 | 133 | 134 | 145 | -------------------------------------------------------------------------------- /src/components/dialogs/TorrentContent.vue: -------------------------------------------------------------------------------- 1 | 33 | 34 | 193 | 194 | 206 | -------------------------------------------------------------------------------- /src/components/dialogs/TorrentInfo.vue: -------------------------------------------------------------------------------- 1 | 77 | 78 | 200 | 201 | 231 | -------------------------------------------------------------------------------- /src/components/dialogs/Trackers.vue: -------------------------------------------------------------------------------- 1 | 22 | 23 | 78 | -------------------------------------------------------------------------------- /src/components/dialogs/baseTorrentInfo.ts: -------------------------------------------------------------------------------- 1 | import { Prop, Watch, Component } from 'vue-property-decorator' 2 | import HasTask from '@/mixins/hasTask' 3 | 4 | @Component 5 | export default class BaseTorrentInfo extends HasTask { 6 | @Prop(Boolean) 7 | readonly isActive!: boolean 8 | 9 | protected fetchInfo(): Promise { 10 | throw 'Not implement' 11 | } 12 | 13 | protected async doTask() { 14 | await this.fetchInfo() 15 | 16 | return !this.isActive 17 | } 18 | 19 | startTask() { 20 | this.setTaskAndRun(this.doTask, 5000) 21 | } 22 | 23 | created() { 24 | if (this.isActive) { 25 | this.startTask() 26 | } 27 | } 28 | 29 | @Watch('isActive') 30 | async onActived(v: boolean) { 31 | if (v) { 32 | this.startTask() 33 | } else { 34 | this.cancelTask(); 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/components/dialogs/searchDialog/PluginsManager.vue: -------------------------------------------------------------------------------- 1 | 29 | 30 | 64 | -------------------------------------------------------------------------------- /src/components/dialogs/searchDialog/SearchDialog.vue: -------------------------------------------------------------------------------- 1 | 61 | 62 | 205 | 206 | 211 | -------------------------------------------------------------------------------- /src/components/dialogs/searchDialog/SearchDialogForm.vue: -------------------------------------------------------------------------------- 1 | 111 | 112 | 228 | 229 | 257 | -------------------------------------------------------------------------------- /src/components/dialogs/settingsDialog/DownloadSettings.vue: -------------------------------------------------------------------------------- 1 | 128 | 129 | 164 | 165 | 179 | -------------------------------------------------------------------------------- /src/components/dialogs/settingsDialog/PreferenceRow.vue: -------------------------------------------------------------------------------- 1 | 21 | 22 | 32 | 33 | -------------------------------------------------------------------------------- /src/components/dialogs/settingsDialog/RssSettings.vue: -------------------------------------------------------------------------------- 1 | 22 | 23 | 52 | 53 | 62 | -------------------------------------------------------------------------------- /src/components/dialogs/settingsDialog/SettingsDialog.vue: -------------------------------------------------------------------------------- 1 | 58 | 59 | 110 | 111 | 119 | -------------------------------------------------------------------------------- /src/components/dialogs/settingsDialog/SpeedSettings.vue: -------------------------------------------------------------------------------- 1 | 102 | 103 | 148 | 149 | 158 | -------------------------------------------------------------------------------- /src/components/dialogs/settingsDialog/WebUISettings.vue: -------------------------------------------------------------------------------- 1 | 124 | 125 | 173 | -------------------------------------------------------------------------------- /src/components/drawer/DrawerFooter.vue: -------------------------------------------------------------------------------- 1 | 88 | 89 | 218 | 219 | 229 | -------------------------------------------------------------------------------- /src/components/drawer/FilterGroup.vue: -------------------------------------------------------------------------------- 1 | 49 | 50 | 91 | 92 | 131 | -------------------------------------------------------------------------------- /src/components/types.ts: -------------------------------------------------------------------------------- 1 | export interface Group { 2 | title: string; 3 | icon: string; 4 | children: Child[]; 5 | model: boolean | null; 6 | select: string; 7 | } 8 | 9 | export interface Child { 10 | title: string; 11 | key: string | null; 12 | icon: string; 13 | append: string | null; 14 | } -------------------------------------------------------------------------------- /src/consts.ts: -------------------------------------------------------------------------------- 1 | export const enum StateType { 2 | Downloading = 'downloading', 3 | Seeding = 'seeding', 4 | Completed = 'completed', 5 | Resumed = 'resumed', 6 | Paused = 'pasued', 7 | Active = 'active', 8 | Inactive = 'inactive', 9 | Errored = 'errored', 10 | } 11 | 12 | export const AllStateTypes = [ 13 | StateType.Downloading, 14 | StateType.Seeding, 15 | StateType.Completed, 16 | StateType.Resumed, 17 | StateType.Paused, 18 | StateType.Active, 19 | StateType.Inactive, 20 | StateType.Errored, 21 | ]; 22 | -------------------------------------------------------------------------------- /src/directives.ts: -------------------------------------------------------------------------------- 1 | import Vue from 'vue'; 2 | 3 | Vue.directive('class', (el, binding) => { 4 | const clsName = binding.arg!; 5 | el.classList.toggle(clsName, binding.value); 6 | }); 7 | -------------------------------------------------------------------------------- /src/filters.ts: -------------------------------------------------------------------------------- 1 | import dayjs from 'dayjs'; 2 | import Vue from 'vue'; 3 | 4 | /* eslint-disable no-param-reassign */ 5 | export function toPrecision(value: number, precision: number) { 6 | const limit = 10 ** precision; 7 | if (value >= limit) { 8 | return value.toString(); 9 | } 10 | if (value >= 1) { 11 | if (value >= limit - 1) { 12 | return limit.toString(); 13 | } 14 | 15 | return value.toPrecision(precision); 16 | } 17 | 18 | return value.toFixed(precision - 1); 19 | } 20 | 21 | export function formatSize(value: number): string { 22 | const units = 'KMGTP'; 23 | let index = value ? Math.floor(Math.log2(value) / 10) : 0; 24 | 25 | value = value / (1024 ** index); 26 | if (value >= 999) { 27 | value /= 1024; 28 | index++; 29 | } 30 | 31 | const unit = index === 0 ? 'B' : `${units[index - 1]}iB`; 32 | 33 | if (index === 0) { 34 | return `${value} ${unit}`; 35 | } 36 | 37 | return `${toPrecision(value, 3)} ${unit}`; 38 | } 39 | 40 | Vue.filter('formatSize', formatSize); 41 | Vue.filter('size', formatSize); 42 | 43 | export interface DurationOptions { 44 | dayLimit?: number; 45 | maxUnitSize?: number; 46 | minUnit?: number; 47 | } 48 | 49 | export function formatDuration(value: number, options?: DurationOptions) { 50 | const minute = 60; 51 | const hour = minute * 60; 52 | const day = hour * 24; 53 | const year = day * 365; 54 | 55 | const durations = [year, day, hour, minute, 1]; 56 | const units = 'ydhms'; 57 | 58 | let index = 0; 59 | let unitSize = 0; 60 | const parts = []; 61 | 62 | const defaultOptions: DurationOptions = { 63 | maxUnitSize: 2, 64 | dayLimit: 0, 65 | minUnit: 0, 66 | }; 67 | 68 | const opt = options ? Object.assign(defaultOptions, options) : defaultOptions; 69 | 70 | if (opt.dayLimit && value >= opt.dayLimit * day) { 71 | return '∞'; 72 | } 73 | 74 | while ((!opt.maxUnitSize || unitSize !== opt.maxUnitSize) && index !== durations.length) { 75 | const duration = durations[index]; 76 | if (value < duration) { 77 | index++; 78 | continue; 79 | } else if (opt.minUnit && (durations.length - index) <= opt.minUnit) { 80 | break 81 | } 82 | 83 | const result = Math.floor(value / duration); 84 | parts.push(result + units[index]); 85 | 86 | // eslint-disable-next-line 87 | value %= duration; 88 | index++; 89 | unitSize++; 90 | } 91 | 92 | // if (unitSize < 2 && index !== durations.length) { 93 | // const result = Math.floor(value / durations[index]); 94 | // parts.push(result + units[index]); 95 | // } 96 | 97 | if (!parts.length) { 98 | return '0' + units[durations.length - 1 - opt.minUnit!]; 99 | } 100 | 101 | return parts.join(' '); 102 | } 103 | 104 | Vue.filter('formatDuration', formatDuration); 105 | 106 | export function formatTimestamp(timestamp: number | null) { 107 | if (timestamp == null || timestamp === -1) { 108 | return ''; 109 | } 110 | 111 | const m = dayjs.unix(timestamp); 112 | return m.format('YYYY-MM-DD HH:mm:ss'); 113 | } 114 | 115 | Vue.filter('formatTimestamp', formatTimestamp); 116 | 117 | export function formatAsDuration(timestamp: number, options?: DurationOptions) { 118 | const duration = (Date.now() / 1000) - timestamp; 119 | return formatDuration(duration, options); 120 | } 121 | 122 | Vue.filter('formatAsDuration', formatAsDuration); 123 | 124 | export function formatProgress(progress: number) { 125 | // eslint-disable-next-line 126 | progress *= 100; 127 | return `${toPrecision(progress, 3)}%`; 128 | } 129 | 130 | Vue.filter('progress', formatProgress); 131 | 132 | export function parseDate(str: string) { 133 | if (!str) { 134 | return null 135 | } 136 | 137 | return Date.parse(str) / 1000 138 | } 139 | 140 | Vue.filter('parseDate', parseDate) 141 | -------------------------------------------------------------------------------- /src/locale/index.ts: -------------------------------------------------------------------------------- 1 | import Polyglot from 'node-polyglot'; 2 | import langEn from './en'; 3 | import langRu from './ru'; 4 | import langTr from './tr'; 5 | import langZhCn from './zh-CN'; 6 | import langZhTw from './zh-TW'; 7 | import langNl from './nl'; 8 | 9 | import { loadConfig } from '@/store/config'; 10 | 11 | export const translations = { 12 | en: langEn, 13 | 'ru': langRu, 14 | 'tr': langTr, 15 | 'zh-CN': langZhCn, 16 | 'zh-TW': langZhTw, 17 | 'nl': langNl, 18 | } 19 | 20 | export type LocaleKey = keyof typeof translations | null; 21 | 22 | const polyglot = new Polyglot({ 23 | phrases: translations.en, 24 | }); 25 | 26 | function matchLocale() { 27 | const { languages } = navigator; 28 | 29 | for (const code of languages) { 30 | if (code in translations) { 31 | return (code as LocaleKey)!; 32 | } 33 | } 34 | 35 | return 'en' 36 | } 37 | 38 | export const defaultLocale = matchLocale() 39 | 40 | function updateLocale() { 41 | let locale = loadConfig()['locale'] as LocaleKey; 42 | 43 | if (!locale) { 44 | locale = defaultLocale; 45 | } 46 | 47 | if (locale === polyglot.locale()) { 48 | return; 49 | } 50 | 51 | polyglot.locale(locale); 52 | polyglot.extend(translations[locale]); 53 | } 54 | 55 | updateLocale(); 56 | 57 | export default polyglot; 58 | export const tr = polyglot.t.bind(polyglot); 59 | export { updateLocale }; 60 | -------------------------------------------------------------------------------- /src/locale/tr.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/camelcase */ 2 | export default { 3 | lang: 'Türkçe', 4 | auto: 'Otomatik', 5 | 6 | close: 'Kapat', 7 | no: 'Hayır', 8 | yes: 'Evet', 9 | cancel: 'İptal', 10 | ok: 'Tamam', 11 | 12 | start: 'Başlat', 13 | stop: 'Durdur', 14 | submit: 'Tamam', 15 | edit: 'Düzenle', 16 | delete: 'Sil', 17 | todo: 'Yapılacak', 18 | resume: 'Devam Et', 19 | pause: 'Duraklat', 20 | force_start: 'Zorla Başlat', 21 | info: 'Bilgi', 22 | reset: 'Sıfırla', 23 | login: 'Oturum Aç', 24 | search: 'Ara', 25 | refresh: 'Yenile', 26 | location: 'Kaydetme yeri', 27 | rename: 'Yeniden adlandır', 28 | trigger_application_shutdown: 'qBittorrent\'ten çık', 29 | reannounce: 'Yeniden Duyur', 30 | recheck: 'Yeniden Denetle', 31 | 32 | username: 'Kullanıcı Adı', 33 | password: 'Parola', 34 | 35 | name: 'Adı', 36 | size: 'Boyut', 37 | progress: 'İlerleme', 38 | status: 'Durum', 39 | seeds: 'Gönderim', 40 | peers: 'Kişi', 41 | dl_speed: 'İnd. Hızı', 42 | up_speed: 'Gön. Hızı', 43 | eta: 'TBS', 44 | ratio: 'Oran', 45 | added_on: 'Eklenme', 46 | 47 | settings: 'Ayarlar', 48 | logs: 'Günlükler', 49 | light: 'Aydınlık', 50 | dark: 'Karanlık', 51 | 52 | all: 'Tümü', 53 | category: 'Kategori |||| Kategoriler', 54 | uncategorized: 'Kategorilenmemiş', 55 | others: 'Diğerleri', 56 | sites: 'Siteler', 57 | files: 'Dosyalar', 58 | less: 'Daha Az', 59 | more: 'Daha Çok', 60 | feed: 'Bildirim', 61 | date: 'Tarih', 62 | query: 'Sorgu', 63 | plugin: 'Eklenti |||| Eklentiler', 64 | action: 'Eylem |||| Eylemler', 65 | search_engine: 'Arama motoru', 66 | usage: 'Kullanım', 67 | plugin_manager: 'Eklenti yöneticisi', 68 | 69 | title: { 70 | _: 'Başlık', 71 | add_torrents: 'Torrent ekle', 72 | delete_torrents: 'Torrent\'leri sil', 73 | set_category: 'Kategtori ayarla', 74 | edit_tracker: 'İzleyicileri Düzenle', 75 | set_location: 'Yeri ayarla...', 76 | recheck_torrents: 'Torrent\'leri yeniden denetle', 77 | }, 78 | 79 | label: { 80 | switch_to_old_ui: 'Resmi Web Arayüzü\'ne geç', 81 | create_subfolder: 'Alt klasör oluştur', 82 | start_torrent: 'Torrent\'i başlat', 83 | skip_hash_check: 'Adresleme denetimini atla', 84 | in_sequential_order: 'Sıralı düzende indir', 85 | first_and_last_pieces_first: 'Önce ilk ve son parçaları indir', 86 | 87 | also_delete_files: 'Aynı zamanda sabit diskteki dosyaları da sil', 88 | 89 | auto_tmm: 'Otomatik Torrent Yönetimi', 90 | 91 | adding: 'Ekleniyor…', 92 | reloading: 'Yeniden yükleniyor…', 93 | deleting: 'Siliniyor…', 94 | moving: 'Taşınıyor…', 95 | moved: 'Taşındı.', 96 | next: 'İleri', 97 | back: 'Geri', 98 | confirm: 'Onayla', 99 | reannounced: 'Yeniden duyuruldu.', 100 | rechecking: 'Yeniden denetleniyor…', 101 | dht_nodes: '%{smart_count} düğüm |||| %{smart_count} düğüm', 102 | base_url: 'Ana makine URL', 103 | }, 104 | 105 | msg: { 106 | item_is_required: '%{item} gerekli!', 107 | }, 108 | 109 | dialog: { 110 | trigger_exit_qb: { 111 | title: 'qBittorrent\'ten Çık', 112 | text: 'qBittorrent uygulamasından çıkmak istediğinize emin misiniz?', 113 | }, 114 | add_torrents: { 115 | placeholder: 'Torrentleri yüklemek için\nlinkleri buraya girin\nveya sağdaki ataç butonuna tıklayıp seçim yapın.', 116 | hint: 'Her satıra sadece bir bağlantı', 117 | }, 118 | delete_torrents: { 119 | msg: 'Seçilen torrent\'leri aktarım listesinden silmek istediğinize emin misiniz?', 120 | also_delete_same_name_torrents: 'Aynı zamanda, aynı isimli bir torrenti de sil. |||| Aynı zamanda, aynı isimli %{smart_count} torrentleri de sil.', 121 | }, 122 | set_category: { 123 | move: 'Seçilmiş torrentlerin kategorilerini, %{category} olarak değiştirmek istediğinize emin misiniz?', 124 | reset: 'Seçilmiş torrentlerin kategorilerini sıfırlamak istediğinize emin misiniz?', 125 | also_move_same_name_torrents: 'Aynı zamanda, aynı isimli bir torrenti de taşı. |||| Aynı zamanda, aynı isimli %{smart_count} torrentleri de taşı.', 126 | }, 127 | switch_locale: { 128 | msg: 'Dili %{lang} olarak değiştirmek istediğinize emin misiniz?\nBu eylem sayfayı yeniden yükleyecek.', 129 | }, 130 | recheck_torrents: { 131 | msg: 'Torrentleri yeniden denetlemek istediğinize emin misiniz?', 132 | }, 133 | rss: { 134 | add_feed: 'Bildirim ekle', 135 | feed_url: 'Bildirim URL\'si', 136 | auto_refresh: 'Otomatik yenile', 137 | auto_download: 'Otomatik indir', 138 | delete_feeds: 'Seçilmiş bildirimlerin silmek istediğinize emin misiniz?', 139 | date_format: '%{date} (%{duration} önce)', 140 | }, 141 | rss_rule: { 142 | add_rule: 'Yeni kural ekle', 143 | new_rule_name: 'Yeni kural adı', 144 | delete_rule: 'Seçilmiş kuralları silmek istediğinize emin misiniz?', 145 | title: 'RSS indirici', 146 | rule_settings: 'Kural ayarları', 147 | 148 | use_regex: 'Regex kullan', 149 | must_contain: 'İçermeli', 150 | must_not_contain: 'İçermemeli', 151 | episode_filter: 'Bölüm süzgeci', 152 | smart_episode: 'Akıllı bölüm süzgeci kullan', 153 | assign_category: 'Kategori ata', 154 | 155 | apply_to_feeds: 'Kuralı bildirimlere uygula', 156 | }, 157 | }, 158 | 159 | state: { 160 | _: 'Durum', 161 | 162 | downloading: 'İndiriliyor', 163 | seeding: 'Gönderiliyor', 164 | completed: 'Tamamlandı', 165 | resumed: 'Devam Edildi', 166 | paused: 'Duraklatıldı', 167 | active: 'Etkin', 168 | inactive: 'Etkin Değil', 169 | errored: 'Hata Oldu', 170 | }, 171 | } 172 | -------------------------------------------------------------------------------- /src/locale/zh-CN.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/camelcase */ 2 | export default { 3 | lang: '简体中文', 4 | auto: '自动', 5 | 6 | close: '关闭', 7 | no: '否', 8 | yes: '是', 9 | cancel: '取消', 10 | ok: '确定', 11 | 12 | start: '开始', 13 | stop: '停止', 14 | submit: '提交', 15 | edit: '编辑', 16 | delete: '删除', 17 | todo: '待办', 18 | resume: '恢复', 19 | pause: '暂停', 20 | force_start: '强制继续', 21 | toggle_sequential: '切换顺序下载', 22 | info: '信息', 23 | reset: '重置', 24 | login: '登录', 25 | search: '搜索', 26 | refresh: '刷新', 27 | location: '位置', 28 | rename: '重命名', 29 | trigger_application_shutdown: '退出qBittorrent', 30 | reannounce: '重新通告', 31 | recheck: '重新检查', 32 | 33 | username: '用户名', 34 | password: '密码', 35 | 36 | name: '名称', 37 | size: '大小', 38 | progress: '进度', 39 | status: '状态', 40 | seeds: '做种', 41 | peers: '用户', 42 | dl_speed: '下载速度', 43 | up_speed: '上传速度', 44 | eta: '剩余时间', 45 | ratio: '比率', 46 | added_on: '添加时间', 47 | 48 | settings: '设置', 49 | logs: '日志', 50 | light: '亮色', 51 | dark: '暗色', 52 | 53 | all: '全部', 54 | category: '分类', 55 | uncategorized: '未分类', 56 | tag: '标签', 57 | untagged: '无标签', 58 | others: '其他', 59 | sites: '站点', 60 | files: '文件', 61 | less: '更少', 62 | more: '更多', 63 | feed: '订阅', 64 | date: '日期', 65 | query: '查询', 66 | plugin: '插件', 67 | action: '操作', 68 | search_engine: '搜索引擎', 69 | usage: '用法', 70 | plugin_manager: '插件管理', 71 | update_plugins: '更新插件', 72 | 73 | preferences: { 74 | change_applied: '配置已保存', 75 | downloads: '下载', 76 | adding_torrent: '添加 torrent 时', 77 | create_subfolder_enabled: '为多个文件的 Torrent 创建子目录', 78 | start_paused_enabled: '不要自动开始下载', 79 | auto_delete_mode: '完成后删除 .torrent 文件', 80 | preallocate_all: '为所有文件预分配磁盘空间', 81 | incomplete_files_ext: '为不完整的文件添加扩展名 .!qB', 82 | saving_management: '保存管理', 83 | auto_tmm_enabled: '默认 Torrent 管理模式', 84 | torrent_changed_tmm_enabled: '当 Torrent 分类修改时', 85 | save_path_changed_tmm_enabled: '当默认保存路径修改时', 86 | category_changed_tmm_enabled: '当分类保存路径修改时', 87 | auto_mode: '自动', 88 | manual_mode: '手动', 89 | switch_torrent_mode_to_manual: '切换受影响的 Torrent 至手动模式', 90 | move_affected_torrent: '重新定位受影响的 Torrent', 91 | save_path: '默认保存路径', 92 | temp_path: '保存未完成的 torrent 到', 93 | export_dir: '复制 .torrent 文件到', 94 | export_dir_fin: '复制下载完成的 .torrent 文件到', 95 | 96 | speed: '速度', 97 | global_rate_limits: '全局速度限制', 98 | alternate_rate_limits: '备用速度限制', 99 | alternate_schedule_enable_time: '设置备用速度限制的启用时间', 100 | apply_speed_limit: '设置速度限制', 101 | dl_limit: '下载 (KiB/s)', 102 | up_limit: '上传 (KiB/s)', 103 | zero_for_unlimited: '0 为无限制', 104 | schedule_from: '从', 105 | schedule_to: '到', 106 | scheduler_days: '时间', 107 | limit_utp_rate: '对 µTP 协议进行速度限制', 108 | limit_tcp_overhead: '对传送总开销进行速度限制', 109 | limit_lan_peers: '对本地网络用户进行速度限制', 110 | 111 | connection: '连接', 112 | bittorrent: 'BitTorrent', 113 | 114 | rss: 'RSS', 115 | rss_processing_enabled: '启用自动刷新', 116 | rss_auto_downloading_enabled: '启用自动下载种子', 117 | rss_refresh_interval: '订阅刷新间隔', 118 | 119 | webui: 'Web UI', 120 | data_update_interval: '数据更新频率(ms)', 121 | webui_remote_control: 'Web 用户界面(远程控制)', 122 | ip_address: 'IP 地址', 123 | ip_port: '端口', 124 | enable_upnp: '使用我的路由器的 UPnP / NAT-PMP 功能来转发端口', 125 | authentication: '验证', 126 | web_ui_username: '用户名', 127 | web_ui_password: '密码', 128 | bypass_local_auth: '对本地主机上的客户端跳过身份验证', 129 | bypass_auth_subnet_whitelist: '对 IP 子网白名单中的客户端跳过身份验证', 130 | web_ui_session_timeout: '会话超时', 131 | web_ui_ban_duration: '禁止', 132 | web_ui_max_auth_fail_count: '连续失败后禁止客户端次数', 133 | web_ui_seconds: '秒', 134 | new_password: '更改当前的密码...', 135 | 136 | display_speed_in_title: '在网页标题显示当前速度', 137 | }, 138 | 139 | title: { 140 | _: '标题', 141 | add_torrents: '添加种子', 142 | delete_torrents: '删除种子', 143 | set_category: '设置分类', 144 | edit_tracker: '编辑 Tracker', 145 | set_location: '修改文件位置', 146 | recheck_torrents: '重新检查种子', 147 | }, 148 | 149 | label: { 150 | switch_to_old_ui: '切换到原版 UI', 151 | create_subfolder: '创建子文件夹', 152 | start_torrent: '开始种子', 153 | skip_hash_check: '跳过哈希校验', 154 | in_sequential_order: '按顺序下载', 155 | first_and_last_pieces_first: '先下载首尾文件块', 156 | 157 | also_delete_files: '同时删除文件', 158 | 159 | auto_tmm: '自动种子管理', 160 | 161 | adding: '添加…', 162 | reloading: '刷新中…', 163 | deleting: '删除中…', 164 | moving: '移动中…', 165 | moved: '已移动', 166 | next: '下一步', 167 | back: '返回', 168 | confirm: '确定', 169 | reannounced: '已重新通告', 170 | rechecking: '重新检查中…', 171 | dht_nodes: '%{smart_count} 节点', 172 | base_url: 'Base URL', 173 | }, 174 | 175 | msg: { 176 | 'item_is_required': '%{item}不能为空', 177 | }, 178 | 179 | dialog: { 180 | trigger_exit_qb: { 181 | title: '退出 qBittorrent', 182 | text: '您确定要退出qBittorrent吗?', 183 | }, 184 | add_torrents: { 185 | placeholder: '将种子拖到这里上传,\n或者点击右边的附件图标来选择。', 186 | hint: '每行一个链接', 187 | }, 188 | delete_torrents: { 189 | msg: '确定要删除选中的种子吗?', 190 | also_delete_same_name_torrents: '同时删除 %{smart_count} 个同名的种子', 191 | }, 192 | set_category: { 193 | move: '确定要移动选中的种子到分类 %{category} 吗?', 194 | reset: '确定重置选中的种子的分类吗?', 195 | also_move_same_name_torrents: '同时移动 %{smart_count} 个同名的种子', 196 | }, 197 | switch_locale: { 198 | msg: '确定要切换语言为 %{lang} 吗?\n这将会刷新页面。', 199 | }, 200 | recheck_torrents: { 201 | msg: '确定要重新检查选中的种子吗?', 202 | }, 203 | rss: { 204 | add_feed: '添加订阅', 205 | feed_url: '订阅 URL', 206 | auto_refresh: '自动刷新', 207 | auto_download: '自动下载', 208 | delete_feeds: '确定要删除选中的订阅吗?', 209 | date_format: '%{date}(%{duration} 之前)', 210 | }, 211 | rss_rule: { 212 | add_rule: '添加规则', 213 | new_rule_name: '新规则的名称', 214 | delete_rule: '确定要删除选中的规则吗?', 215 | title: 'RSS 自动下载', 216 | rule_settings: '规则设置', 217 | 218 | use_regex: '使用正则', 219 | must_contain: '必须包含', 220 | must_not_contain: '必须排除', 221 | episode_filter: '剧集过滤', 222 | smart_episode: '使用智能剧集过滤', 223 | assign_category: '分配分类', 224 | 225 | apply_to_feeds: '应用到订阅', 226 | }, 227 | }, 228 | 229 | category_state: { 230 | _: '状态', 231 | 232 | downloading: '下载', 233 | seeding: '做种', 234 | completed: '完成', 235 | resumed: '恢复', 236 | paused: '暂停', 237 | active: '活动', 238 | inactive: '空闲', 239 | errored: '错误', 240 | }, 241 | 242 | torrent_state: { 243 | error: '错误', 244 | missingFiles: '文件丢失', 245 | uploading: '上传中', 246 | pausedUP: '完成', 247 | queuedUP: '排队上传', 248 | stalledUP: '上传', 249 | checkingUP: '上传校验', 250 | forcedUP: '强制上传', 251 | allocating: '分配空间', 252 | downloading: '下载中', 253 | metaDL: '获取信息', 254 | pausedDL: '暂停下载', 255 | queuedDL: '排队下载', 256 | stalledDL: '下载', 257 | checkingDL: '下载校验', 258 | forceDL: '强制下载', 259 | checkingResumeData: '快速校验', 260 | moving: '移动中', 261 | unknown: '未知', 262 | }, 263 | 264 | prop_tab_bar: { 265 | general: '普通', 266 | trackers: 'Tracker', 267 | peers: '用户', 268 | // httpSource: 'HTTP 源', 269 | content: '内容', 270 | }, 271 | properties_widget: { 272 | disabled: '禁用', 273 | notContracted: '未联系', 274 | working: '工作', 275 | updating: '更新...', 276 | notWorking: '未工作', 277 | 278 | tier: '层级', 279 | url: 'URL', 280 | status: '状态', 281 | numPeers: '用户', 282 | numSeeds: '种子', 283 | numLeeches: '下载', 284 | numDownloaded: '下载次数', 285 | msg: '消息', 286 | 287 | progress: '进度', 288 | transfer: '传输', 289 | information: '信息', 290 | 291 | timeActive: '活动时间', 292 | eta: '剩余时间', 293 | connections: '连接', 294 | downloaded: '已下载', 295 | uploaded: '已上传', 296 | seeds: '种子', 297 | downloadSpeed: '下载速度', 298 | uploadSpeed: '上传速度', 299 | peers: '用户', 300 | wasted: '已丢弃', 301 | shareRatio: '分享率', 302 | reannounce: '下次汇报', 303 | lastSeen: '最后完整可见', 304 | totalSize: '总大小', 305 | pieces: '区块', 306 | createdBy: '创建', 307 | createdOn: '创建于', 308 | addedOn: '添加于', 309 | completedOn: '完成于', 310 | torrentHash: '种子哈希', 311 | savePath: '保存路径', 312 | comment: '注释', 313 | 314 | ip: 'IP', 315 | connection: '连接', 316 | flags: '标志', 317 | client: '客户端', 318 | relevance: '文件关联', 319 | files: '文件', 320 | 321 | seeded: '已做种', 322 | second: '秒', 323 | total: '总计', 324 | max: '最大', 325 | have: '已完成', 326 | }, 327 | } 328 | -------------------------------------------------------------------------------- /src/locale/zh-TW.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/camelcase */ 2 | export default { 3 | lang: '繁體中文', 4 | auto: '自動', 5 | 6 | close: '關閉', 7 | no: '否', 8 | yes: '是', 9 | cancel: '取消', 10 | ok: '確定', 11 | 12 | start: '開始', 13 | stop: '停止', 14 | submit: '提交', 15 | edit: '編輯', 16 | delete: '刪除', 17 | todo: '待辦', 18 | resume: '恢復', 19 | pause: '暫停', 20 | force_start: '強制繼續', 21 | info: '資訊', 22 | reset: '重置', 23 | login: '登入', 24 | search: '搜索', 25 | refresh: '刷新', 26 | location: '位置', 27 | rename: '重新命名', 28 | trigger_application_shutdown: '退出qBittorrent', 29 | reannounce: '重新通告', 30 | recheck: '重新檢查', 31 | 32 | username: '使用者名稱', 33 | password: '密碼', 34 | 35 | name: '名稱', 36 | size: '大小', 37 | progress: '進度', 38 | status: '狀態', 39 | seeds: '做種', 40 | peers: '用戶', 41 | dl_speed: '下載速度', 42 | up_speed: '上傳速度', 43 | eta: '剩餘時間', 44 | ratio: '分享率', 45 | added_on: '添加時間', 46 | 47 | settings: '設定', 48 | 49 | logs: '日誌', 50 | light: '亮色', 51 | dark: '暗色', 52 | 53 | all: '全部', 54 | category: '分類', 55 | uncategorized: '未分類', 56 | others: '其他', 57 | sites: '站點', 58 | files: '文件', 59 | less: '更少', 60 | more: '更多', 61 | feed: '訂閱', 62 | date: '日期', 63 | query: '查詢', 64 | plugin: '插件', 65 | action: '操作', 66 | search_engine: '搜尋引擎', 67 | 68 | preferences: { 69 | change_applied: '設定已保存', 70 | downloads: '下載', 71 | adding_torrent: '添加 Torrent 時', 72 | create_subfolder_enabled: '為多個文件的 Torrent 創建子目錄', 73 | start_paused_enabled: '不要自動開始下載', 74 | auto_delete_mode: '完成後刪除 .torrent 文件', 75 | preallocate_all: '為所有文件預分配磁碟空間', 76 | incomplete_files_ext: '為不完整的文件添加副檔名 .!qB', 77 | saving_management: '保存管理', 78 | auto_tmm_enabled: '默認 Torrent 管理模式', 79 | torrent_changed_tmm_enabled: '當 Torrent 分類修改時', 80 | save_path_changed_tmm_enabled: '當默認保存路徑修改時', 81 | category_changed_tmm_enabled: '當分類保存路徑修改時', 82 | auto_mode: '自動', 83 | manual_mode: '手動', 84 | switch_torrent_mode_to_manual: '切換受影響的 Torrent 至手動模式', 85 | move_affected_torrent: '重新定位受影響的 Torrent', 86 | save_path: '默認保存路徑', 87 | temp_path: '保存未完成的 torrent 到', 88 | export_dir: '複製 .torrent 文件到', 89 | export_dir_fin: '複製下載完成的 .torrent 文件到', 90 | 91 | speed: '速度', 92 | global_rate_limits: '全局速度限制', 93 | alternate_rate_limits: '備用速度限制', 94 | alternate_schedule_enable_time: '設定備用速度限制的啟用時間', 95 | apply_speed_limit: '設定速度限制', 96 | dl_limit: '下載 (KiB/s)', 97 | up_limit: '上傳 (KiB/s)', 98 | zero_for_unlimited: '0 為無限制', 99 | schedule_from: '從', 100 | schedule_to: '到', 101 | scheduler_days: '時間', 102 | limit_utp_rate: '對 µTP 協議進行速度限制', 103 | limit_tcp_overhead: '對傳送總開銷進行速度限制', 104 | limit_lan_peers: '對本地網路用戶進行速度限制', 105 | 106 | connection: '連接', 107 | bittorrent: 'BitTorrent', 108 | 109 | webui: 'Web UI', 110 | data_update_interval: '數據更新頻率(ms)', 111 | webui_remote_control: 'Web 用戶界面(遠端控制)', 112 | ip_address: 'IP 地址', 113 | ip_port: '埠', 114 | enable_upnp: '使用我的路由器的 UPnP / NAT-PMP 功能來轉發埠', 115 | authentication: '驗證', 116 | web_ui_username: '使用者名稱', 117 | web_ui_password: '密碼', 118 | bypass_local_auth: '對本地主機上的用戶端跳過身份驗證', 119 | bypass_auth_subnet_whitelist: '對 IP 子網白名單中的用戶端跳過身份驗證', 120 | web_ui_session_timeout: '會話超時', 121 | web_ui_ban_duration: '禁止', 122 | web_ui_max_auth_fail_count: '連續失敗後禁止用戶端次數', 123 | web_ui_seconds: '秒', 124 | new_password: '更改當前的密碼...', 125 | 126 | display_speed_in_title: '在網頁標題顯示當前速度', 127 | }, 128 | 129 | title: { 130 | _: '標題', 131 | add_torrents: '添加種子', 132 | delete_torrents: '刪除種子', 133 | set_category: '設定分類', 134 | edit_tracker: '編輯 Tracker', 135 | set_location: '修改檔案位置', 136 | recheck_torrents: '重新檢查種子', 137 | }, 138 | 139 | label: { 140 | switch_to_old_ui: '切換到原版 UI', 141 | create_subfolder: '創建子文件夾', 142 | start_torrent: '開始種子', 143 | skip_hash_check: '跳過哈希校驗', 144 | in_sequential_order: '按順序下載', 145 | first_and_last_pieces_first: '先下載首尾文件塊', 146 | 147 | also_delete_files: '同時刪除文件', 148 | 149 | auto_tmm: '自動種子管理', 150 | 151 | adding: '添加…', 152 | reloading: '刷新中…', 153 | deleting: '刪除中…', 154 | moving: '移動中…', 155 | moved: '已移動', 156 | next: '下一步', 157 | back: '返回', 158 | confirm: '確定', 159 | reannounced: '已重新通告', 160 | rechecking: '重新檢查中…', 161 | dht_nodes: '%{smart_count} 節點', 162 | }, 163 | 164 | msg: { 165 | 'item_is_required': '%{item}不能為空', 166 | }, 167 | 168 | dialog: { 169 | trigger_exit_qb: { 170 | title: '退出 qBittorrent', 171 | text: '您確定要退出qBittorrent嗎?', 172 | }, 173 | add_torrents: { 174 | placeholder: '將種子拖到這裡上傳,\n或者點擊右邊的附件圖示來選擇。', 175 | hint: '每行一個連結', 176 | }, 177 | delete_torrents: { 178 | msg: '確定要刪除選中的種子嗎?', 179 | also_delete_same_name_torrents: '同時刪除 %{smart_count} 個同名的種子', 180 | }, 181 | set_category: { 182 | move: '確定要移動選中的種子到分類 %{category} 嗎?', 183 | reset: '確定重置選中的種子的分類嗎?', 184 | also_move_same_name_torrents: '同時移動 %{smart_count} 個同名的種子', 185 | }, 186 | switch_locale: { 187 | msg: '確定要切換语言為 %{lang} 嗎?\n這將會刷新頁面。', 188 | }, 189 | recheck_torrents: { 190 | msg: '確定要重新檢查選中的種子嗎?', 191 | }, 192 | rss: { 193 | add_feed: '添加訂閱', 194 | feed_url: '訂閱 URL', 195 | auto_refresh: '自動刷新', 196 | auto_download: '自動下載', 197 | delete_feeds: '確定要刪除選中的訂閱嗎?', 198 | date_format: '%{date}(%{duration} 之前)', 199 | }, 200 | rss_rule: { 201 | add_rule: '添加規則', 202 | new_rule_name: '新規則的名稱', 203 | delete_rule: '確定要刪除選中的規則嗎?', 204 | title: 'RSS 自動下載', 205 | rule_settings: '規則設定', 206 | 207 | use_regex: '使用正則', 208 | must_contain: '必須包含', 209 | must_not_contain: '必須排除', 210 | episode_filter: '劇集過濾', 211 | smart_episode: '使用智慧劇集過濾', 212 | assign_category: '分配分類', 213 | 214 | apply_to_feeds: '應用到訂閱', 215 | }, 216 | }, 217 | 218 | category_state: { 219 | _: '狀態', 220 | 221 | downloading: '下載', 222 | seeding: '做種', 223 | completed: '完成', 224 | resumed: '恢復', 225 | paused: '暫停', 226 | active: '活動', 227 | inactive: '空閒', 228 | errored: '錯誤', 229 | }, 230 | 231 | torrent_state: { 232 | error: '錯誤', 233 | missingFiles: '文件遺失', 234 | uploading: '上傳中', 235 | pausedUP: '完成', 236 | queuedUP: '排隊上傳', 237 | stalledUP: '上傳', 238 | checkingUP: '上傳校驗', 239 | forcedUP: '強制上傳', 240 | allocating: '分配空間', 241 | downloading: '下載中', 242 | metaDL: '獲取資訊', 243 | pausedDL: '暫停下載', 244 | queuedDL: '排隊下載', 245 | stalledDL: '下載', 246 | checkingDL: '下載校驗', 247 | forceDL: '強制下載', 248 | checkingResumeData: '快速校驗', 249 | moving: '移動中', 250 | unknown: '未知', 251 | }, 252 | } 253 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import Vue from 'vue'; 2 | import './plugins/i18n'; 3 | import './plugins/composition-api'; 4 | import vuetify from './plugins/vuetify'; 5 | 6 | import store from './store'; 7 | // import router from './router'; 8 | import './filters'; 9 | import './directives'; 10 | import './locale'; 11 | 12 | import './buildInfo'; 13 | 14 | import App from './App.vue'; 15 | 16 | import 'roboto-fontface/css/roboto/roboto-fontface.css'; 17 | import '@mdi/font/css/materialdesignicons.css'; 18 | 19 | Vue.config.productionTip = false; 20 | 21 | new Vue({ 22 | store, 23 | vuetify, 24 | render: h => h(App), 25 | }).$mount('#app'); 26 | -------------------------------------------------------------------------------- /src/mixins/hasTask.ts: -------------------------------------------------------------------------------- 1 | import Vue from 'vue' 2 | import Component from 'vue-class-component' 3 | 4 | @Component 5 | export default class HasTask extends Vue { 6 | destroy?: boolean 7 | call?: CallableFunction 8 | taskId?: number 9 | interval = 2000 10 | 11 | setTaskAndRun(call: CallableFunction, interval?: number) { 12 | this.call = call 13 | 14 | if (interval) { 15 | this.interval = interval 16 | } 17 | 18 | this.runTask() 19 | } 20 | 21 | async runTask() { 22 | this.cancelTask() 23 | 24 | let r = this.call!() 25 | if (r instanceof Promise) { 26 | r = await r 27 | } 28 | 29 | if (this.destroy || r) { 30 | return 31 | } 32 | 33 | this.taskId = setTimeout(this.runTask, this.interval) 34 | } 35 | 36 | cancelTask() { 37 | if (this.taskId) { 38 | clearTimeout(this.taskId); 39 | this.taskId = 0; 40 | } 41 | } 42 | 43 | beforeDestroy() { 44 | this.destroy = true; 45 | this.cancelTask(); 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/plugins/composition-api.ts: -------------------------------------------------------------------------------- 1 | import Vue from 'vue'; 2 | import VueCompositionApi from '@vue/composition-api'; 3 | 4 | Vue.use(VueCompositionApi); 5 | -------------------------------------------------------------------------------- /src/plugins/i18n.ts: -------------------------------------------------------------------------------- 1 | import Vue from 'vue'; 2 | import { tr } from '@/locale'; 3 | 4 | class I18n { 5 | static install() { 6 | Vue.prototype.$t = tr; 7 | } 8 | } 9 | 10 | Vue.use(I18n); 11 | -------------------------------------------------------------------------------- /src/plugins/vuetify.ts: -------------------------------------------------------------------------------- 1 | import Vue from 'vue'; 2 | import Vuetify from 'vuetify/lib'; 3 | import i18n from '@/locale'; 4 | 5 | Vue.use(Vuetify); 6 | 7 | let locale = i18n.locale(); 8 | switch (locale) { 9 | case 'zh-CN': 10 | locale = 'zh-Hans'; 11 | break; 12 | case 'zh-TW': 13 | locale = 'zh-Hant'; 14 | break; 15 | default: 16 | locale = locale.split('-', 1)[0]; 17 | break; 18 | } 19 | 20 | // eslint-disable-next-line @typescript-eslint/no-var-requires 21 | const { default: translation } = require('vuetify/src/locale/' + locale); 22 | 23 | export default new Vuetify({ 24 | lang: { 25 | locales: { [locale]: translation }, 26 | current: locale, 27 | }, 28 | icons: { 29 | iconfont: 'mdi', 30 | }, 31 | }); 32 | -------------------------------------------------------------------------------- /src/protocolHandler.ts: -------------------------------------------------------------------------------- 1 | import debug from 'debug'; 2 | 3 | const log = debug('app:protocolHandler'); 4 | 5 | function registerProtocolHandler() { 6 | if (!('registerProtocolHandler' in navigator)) { 7 | return; 8 | } 9 | 10 | const baseUrl = location.origin + location.pathname; 11 | 12 | try { 13 | navigator.registerProtocolHandler('magnet', baseUrl + '#download=%s', document.title); 14 | } catch (e) { 15 | log('Register protocol handler failed.', e); 16 | } 17 | } 18 | 19 | function checkDownloadUrl() { 20 | if (!location.hash) { 21 | return null 22 | } 23 | 24 | const params = new URLSearchParams(location.hash.substring(1)); 25 | const url = params.get('download') 26 | if (!url) { 27 | return null; 28 | } 29 | 30 | params.delete('download'); 31 | location.hash = '#' + params.toString() 32 | return url 33 | } 34 | 35 | export { registerProtocolHandler, checkDownloadUrl }; 36 | -------------------------------------------------------------------------------- /src/router.ts: -------------------------------------------------------------------------------- 1 | import Vue from 'vue'; 2 | import Router from 'vue-router'; 3 | // import Home from './views/Home.vue'; 4 | 5 | Vue.use(Router); 6 | 7 | export default new Router({ 8 | mode: 'history', 9 | base: process.env.BASE_URL, 10 | routes: [ 11 | // { 12 | // path: '/', 13 | // name: 'home', 14 | // component: Home, 15 | // }, 16 | // { 17 | // path: '/about', 18 | // name: 'about', 19 | // // route level code-splitting 20 | // // this generates a separate chunk (about.[hash].js) for this route 21 | // // which is lazy-loaded when the route is visited. 22 | // component: () => import(/* webpackChunkName: "about" */ './views/About.vue'), 23 | // }, 24 | ], 25 | }); 26 | -------------------------------------------------------------------------------- /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 | 4 | export default Vue; 5 | } 6 | -------------------------------------------------------------------------------- /src/sites.ts: -------------------------------------------------------------------------------- 1 | function getSiteIcon(name: string): string { 2 | return require(`@/assets/site_icons/${name}.png`); 3 | } 4 | 5 | export interface SiteInfo { 6 | name: string; 7 | icon?: string; 8 | } 9 | 10 | const sites: {[key: string]: SiteInfo} = { 11 | 'm-team.cc': { 12 | name: 'M-Team', 13 | icon: getSiteIcon('m-team'), 14 | }, 15 | 'keepfrds.com': { 16 | name: 'PT@KEEPFRDS', 17 | icon: getSiteIcon('keepfrds'), 18 | }, 19 | 'springsunday.net': { 20 | name: 'SSD', 21 | icon: getSiteIcon('springsunday'), 22 | }, 23 | 'hdchina.org': { 24 | name: 'HDChina', 25 | icon: getSiteIcon('hdchina'), 26 | }, 27 | 'chdbits.co': { 28 | name: 'CHDBits', 29 | icon: getSiteIcon('chdbits'), 30 | }, 31 | 'hdhome.org': { 32 | name: 'HDHome', 33 | icon: getSiteIcon('nexusphp'), 34 | }, 35 | 'dmhy.org': { 36 | name: 'U2', 37 | icon: getSiteIcon('u2'), 38 | }, 39 | 'dmhy.best': { 40 | name: 'U2', 41 | icon: getSiteIcon('u2'), 42 | }, 43 | 'totheglory.im': { 44 | name: 'TTG', 45 | icon: getSiteIcon('totheglory'), 46 | }, 47 | 'oshen.win': { 48 | name: 'OshenPT', 49 | icon: getSiteIcon('nexusphp'), 50 | }, 51 | 'soulvoice.club': { 52 | name: '铃音Club', 53 | icon: getSiteIcon('soulvoice'), 54 | }, 55 | 'ourbits.club': { 56 | name: 'OurBits', 57 | icon: getSiteIcon('ourbits'), 58 | }, 59 | 'btschool.club': { 60 | name: 'BTSCHOOL', 61 | }, 62 | 'ptsbao.club': { 63 | name: '烧包', 64 | icon: getSiteIcon('ptsbao'), 65 | }, 66 | 'pterclub.com': { 67 | name: 'PTer', 68 | icon: getSiteIcon('pterclub'), 69 | }, 70 | 'hdtime.org': { 71 | name: 'HDTime', 72 | icon: getSiteIcon('hdtime'), 73 | }, 74 | 'hddolby.com': { 75 | name: 'HD Dolby', 76 | }, 77 | 'lemonhd.org': { 78 | name: 'LemonHD', 79 | icon: getSiteIcon('lemonhd'), 80 | }, 81 | 'hares.top': { 82 | name: 'HaresClub', 83 | icon: getSiteIcon('hares'), 84 | }, 85 | 'pthome.net': { 86 | name: 'PTHOME', 87 | icon: getSiteIcon('pthome'), 88 | }, 89 | 'hdsky.me': { 90 | name: 'HDSky', 91 | icon: getSiteIcon('hdsky'), 92 | }, 93 | 'hdfans.org': { 94 | name: 'HDFans', 95 | icon: getSiteIcon('nexusphp'), 96 | }, 97 | 'hdatmos.club': { 98 | name: 'HDAtmos', 99 | icon: getSiteIcon('nexusphp'), 100 | }, 101 | 'hdzone.me': { 102 | name: 'HDZone', 103 | icon: getSiteIcon('nexusphp'), 104 | }, 105 | 'open.cd': { 106 | name: 'OpenCD', 107 | icon: getSiteIcon('opencd'), 108 | }, 109 | '1ptba.com': { 110 | name: '1PTBar', 111 | icon: getSiteIcon('nexusphp'), 112 | }, 113 | 'pttime.org': { 114 | name: 'PTTime', 115 | icon: getSiteIcon('pttime'), 116 | }, 117 | 'beitai.pt': { 118 | name: '备胎', 119 | icon: getSiteIcon('nexusphp'), 120 | }, 121 | 'kamept.com': { 122 | name: 'kamept', 123 | icon: getSiteIcon('kamept'), 124 | }, 125 | 'nicept.net': { 126 | name: 'NicePT', 127 | icon: getSiteIcon('nexusphp'), 128 | }, 129 | '2xfree.org': { 130 | name: '2xfree', 131 | icon: getSiteIcon('2xfree'), 132 | }, 133 | }; 134 | 135 | export default sites; 136 | -------------------------------------------------------------------------------- /src/store/addForm.ts: -------------------------------------------------------------------------------- 1 | import { Module } from 'vuex'; 2 | import { AddFormState } from './types'; 3 | 4 | export const addFormStore: Module = { 5 | state() { 6 | return { 7 | isOpen: false, 8 | downloadItem: null, 9 | }; 10 | }, 11 | getters: { 12 | isOpen(state) { 13 | return state.isOpen; 14 | }, 15 | }, 16 | mutations: { 17 | openAddForm(state) { 18 | state.isOpen = true; 19 | }, 20 | closeAddForm(state) { 21 | state.isOpen = false; 22 | state.downloadItem = null; 23 | }, 24 | addFormDownloadItem(state, payload) { 25 | const { downloadItem } = payload; 26 | state.downloadItem = downloadItem; 27 | }, 28 | }, 29 | }; 30 | -------------------------------------------------------------------------------- /src/store/config.ts: -------------------------------------------------------------------------------- 1 | import { isPlainObject, merge } from 'lodash'; 2 | import Vue from 'vue'; 3 | import { Module } from 'vuex'; 4 | import { ConfigState, ConfigPayload } from './types'; 5 | 6 | const configKey = 'qb-config'; 7 | 8 | export interface Config { 9 | baseUrl: string | null; 10 | updateInterval: number; 11 | pageOptions: any; 12 | filter: { 13 | state: string | null; 14 | category: string | null; 15 | site: string | null; 16 | }; 17 | locale: string | null; 18 | darkMode: string | null; 19 | displaySpeedInTitle: boolean | null; 20 | } 21 | 22 | const defaultConfig = { 23 | baseUrl: null, 24 | updateInterval: 2000, 25 | pageOptions: { 26 | itemsPerPage: 50, 27 | }, 28 | filter: { 29 | state: null, 30 | category: null, 31 | site: null, 32 | }, 33 | locale: null, 34 | darkMode: null, 35 | displaySpeedInTitle: false, 36 | }; 37 | 38 | function saveConfig(obj: any) { 39 | localStorage.setItem(configKey, JSON.stringify(obj)); 40 | } 41 | 42 | export function loadConfig(): Partial { 43 | const tmp = localStorage.getItem(configKey); 44 | if (!tmp) { 45 | return {}; 46 | } 47 | 48 | return JSON.parse(tmp); 49 | } 50 | 51 | export const configStore: Module = { 52 | state() { 53 | return { 54 | userConfig: loadConfig(), 55 | }; 56 | }, 57 | mutations: { 58 | updateConfig(state, payload: ConfigPayload) { 59 | const { key, value } = payload; 60 | if (isPlainObject(value)) { 61 | const tmp = merge({}, state.userConfig[key], value); 62 | Vue.set(state.userConfig, key, tmp); 63 | } else { 64 | Vue.set(state.userConfig, key, value); 65 | } 66 | 67 | saveConfig(state.userConfig); 68 | }, 69 | }, 70 | getters: { 71 | config(state) { 72 | return merge({}, defaultConfig, state.userConfig); 73 | }, 74 | }, 75 | }; 76 | -------------------------------------------------------------------------------- /src/store/dialog.ts: -------------------------------------------------------------------------------- 1 | import { merge, cloneDeep } from 'lodash' 2 | import { Module } from 'vuex'; 3 | import { DialogState } from './types'; 4 | 5 | export const dialogStore: Module = { 6 | state() { 7 | return { 8 | config: null, 9 | }; 10 | }, 11 | mutations: { 12 | showDialog(state, payload) { 13 | state.config = cloneDeep(payload); 14 | }, 15 | closeDialog(state) { 16 | state.config = null; 17 | }, 18 | }, 19 | actions: { 20 | asyncShowDialog({ commit }, payload) { 21 | return new Promise((resolve) => { 22 | const options = merge({}, payload, { 23 | callback: resolve, 24 | }) 25 | 26 | commit('showDialog', options); 27 | }) 28 | }, 29 | }, 30 | }; 31 | -------------------------------------------------------------------------------- /src/store/index.ts: -------------------------------------------------------------------------------- 1 | import { merge, map, groupBy, sortBy } from 'lodash'; 2 | import Vue from 'vue'; 3 | import Vuex from 'vuex'; 4 | import { computed, Ref } from '@vue/composition-api'; 5 | 6 | import { configStore } from './config'; 7 | import { dialogStore } from './dialog'; 8 | import { snackBarStore } from './snackBar'; 9 | import { addFormStore } from './addForm'; 10 | import { AllStateTypes } from '../consts'; 11 | import { torrentIsState } from '../utils'; 12 | import searchEngineStore from './searchEngine'; 13 | import { RootState } from './types'; 14 | import stateMerge from '@/utils/vue-object-merge'; 15 | import api from '@/Api'; 16 | import { Torrent } from '@/types' 17 | 18 | Vue.use(Vuex); 19 | 20 | const store = new Vuex.Store({ 21 | modules: { 22 | config: configStore, 23 | dialog: dialogStore, 24 | snackBar: snackBarStore, 25 | addForm: addFormStore, 26 | searchEngine: searchEngineStore, 27 | }, 28 | state: { 29 | rid: 0, 30 | mainData: undefined, 31 | preferences: null, 32 | pasteUrl: null, 33 | needAuth: false, 34 | query: null, 35 | }, 36 | mutations: { 37 | /* eslint-disable no-param-reassign */ 38 | updateMainData(state, payload) { 39 | state.rid = payload.rid; 40 | delete payload.rid; 41 | if (payload.full_update) { 42 | delete payload.full_update; 43 | state.mainData = payload; 44 | } else { 45 | const mainData = state.mainData!; 46 | if (payload.torrents_removed) { 47 | for (const hash of payload.torrents_removed) { 48 | Vue.delete(mainData.torrents, hash); 49 | } 50 | delete payload.torrents_removed; 51 | } 52 | if (payload.categories_removed) { 53 | for (const key of payload.categories_removed) { 54 | Vue.delete(mainData, key); 55 | } 56 | delete payload.categories_removed; 57 | } 58 | if (payload.tags_removed) { 59 | for (const key of payload.tags_removed) { 60 | Vue.delete(mainData, key); 61 | } 62 | delete payload.categories_removed; 63 | } 64 | stateMerge(mainData, payload); 65 | } 66 | }, 67 | updatePreferences(state, payload) { 68 | state.preferences = payload; 69 | }, 70 | setPasteUrl(state, payload) { 71 | const { url } = payload; 72 | state.pasteUrl = url; 73 | }, 74 | updateNeedAuth(state, payload) { 75 | state.needAuth = payload; 76 | }, 77 | setQuery(state, payload) { 78 | state.query = payload; 79 | }, 80 | /* eslint-enable no-param-reassign */ 81 | }, 82 | getters: { 83 | allPreferences(state) { 84 | return state.preferences; 85 | }, 86 | savePath(state) { 87 | return state.preferences['save_path']; 88 | }, 89 | isDataReady(state) { 90 | return !!state.mainData; 91 | }, 92 | allTorrents(state) { 93 | if (!state.mainData) { 94 | return []; 95 | } 96 | 97 | return map(state.mainData.torrents, (value, key) => merge({}, value, { hash: key })); 98 | }, 99 | allCategories(state) { 100 | if (!state.mainData) { 101 | return []; 102 | } 103 | 104 | const categories = map(state.mainData.categories, 105 | (value, key) => merge({}, value, { key })); 106 | return sortBy(categories, 'name'); 107 | }, 108 | allTags(state) { 109 | if (!state.mainData) { 110 | return []; 111 | } 112 | 113 | const finalTags: any[] = [] 114 | const tags = state.mainData.tags ?? []; 115 | for (const tag of tags) { 116 | finalTags.push({ 117 | "key": tag, 118 | "name": tag, 119 | }); 120 | } 121 | return sortBy(finalTags, 'name'); 122 | }, 123 | torrentGroupByCategory(state, getters) { 124 | return groupBy(getters.allTorrents, torrent => torrent.category); 125 | }, 126 | torrentGroupByTag(state, getters) { 127 | const result: Record = {} 128 | for (const torrent of getters.allTorrents) { 129 | if (!torrent.tags) { 130 | continue; 131 | } 132 | 133 | const tags: string[] = torrent.tags.split(', '); 134 | tags.forEach(tag => { 135 | let list: Torrent[] = result[tag] 136 | if (!list) { 137 | list = [] 138 | result[tag] = list; 139 | } 140 | list.push(torrent); 141 | }); 142 | } 143 | return result; 144 | }, 145 | torrentGroupBySite(state, getters) { 146 | return groupBy(getters.allTorrents, (torrent) => { 147 | if (!torrent.tracker) { 148 | return ''; 149 | } 150 | 151 | const url = new URL(torrent.tracker); 152 | return url.hostname; 153 | }); 154 | }, 155 | torrentGroupByState(__, getters) { 156 | const result: any = {}; 157 | const put = (state: any, torrent: any) => { 158 | let list: any[] = result[state]; 159 | if (!list) { 160 | list = []; 161 | result[state] = list; 162 | } 163 | list.push(torrent); 164 | }; 165 | 166 | for (const torrent of getters.allTorrents) { 167 | for (const type of AllStateTypes) { 168 | if (torrentIsState(type, torrent.state)) { 169 | put(type, torrent); 170 | } 171 | } 172 | } 173 | 174 | return result; 175 | }, 176 | }, 177 | actions: { 178 | async updatePreferencesRequest({ dispatch }, preferences) { 179 | try { 180 | await api.setPreferences(preferences); 181 | //setPreference api return a empty response. Need to update preference by another request. 182 | const preferenceRes = await api.getAppPreferences(); 183 | dispatch("updatePreferencesRequestSuccess", preferenceRes.data); 184 | } catch { 185 | dispatch("updatePreferencesRequestFailure"); 186 | } 187 | }, 188 | updatePreferencesRequestSuccess({ commit }, preferences) { 189 | commit("updatePreferences", preferences); 190 | }, 191 | updatePreferencesRequestFailure() { 192 | alert('Preferences failed to update'); 193 | }, 194 | }, 195 | }); 196 | 197 | export default store; 198 | 199 | export function useStore() { 200 | return store; 201 | } 202 | 203 | export function useMutations(mutations: [string], namespace?: string) { 204 | const result: {[key: string]: () => any} = {}; 205 | 206 | mutations.forEach((m) => { 207 | const method = namespace ? `${namespace}/${m}` : m; 208 | result[m] = (..._args) => store.commit(method, ..._args); 209 | }); 210 | 211 | return result; 212 | } 213 | 214 | export function useState(states: [string], namespace?: string) { 215 | const state = namespace ? (store.state as any)[namespace] : store.state; 216 | 217 | const result: {[key: string]: Readonly>>} = {}; 218 | 219 | states.forEach((s) => { 220 | result[s] = computed(() => state[s]); 221 | }); 222 | 223 | return result; 224 | } 225 | -------------------------------------------------------------------------------- /src/store/searchEngine.ts: -------------------------------------------------------------------------------- 1 | import { Module } from "vuex"; 2 | import { SearchPlugin } from "@/types"; 3 | import { SearchEnginePage } from "./types"; 4 | import api from "@/Api"; 5 | 6 | export default { 7 | state: { 8 | searchPlugins: [], 9 | isPluginManagerOpen: false, 10 | }, 11 | mutations: { 12 | setSearchPlugins(state, plugins: SearchPlugin[] | undefined | null) { 13 | state.searchPlugins = plugins; 14 | }, 15 | openPluginManager(state) { 16 | state.isPluginManagerOpen = true; 17 | }, 18 | closePluginManager(state) { 19 | state.isPluginManagerOpen = false; 20 | }, 21 | }, 22 | getters: { 23 | allSearchPlugins(state): SearchPlugin[] | undefined | null { 24 | return state.searchPlugins; 25 | }, 26 | }, 27 | actions: { 28 | fetchSearchPlugins({ dispatch }) { 29 | // semantic helper 30 | dispatch("getSearchPluginsRequest"); 31 | }, 32 | async getSearchPluginsRequest({ dispatch }) { 33 | try { 34 | const searchPlugins = await api.getSearchPlugins(); 35 | 36 | dispatch("getSearchPluginRequestSuccess", searchPlugins); 37 | } catch { 38 | dispatch("getSearchPluginsRequestFailure"); 39 | } 40 | }, 41 | getSearchPluginRequestSuccess({ commit }, searchPlugins) { 42 | commit("setSearchPlugins", undefined); 43 | 44 | commit("setSearchPlugins", searchPlugins); 45 | }, 46 | getSearchPluginRequestFailure({ commit }) { 47 | commit("setSearchPlugins", null); 48 | }, 49 | togglePluginAvailability({ dispatch }, plugin) { 50 | dispatch("togglePluginEnableRequest", plugin); 51 | }, 52 | async togglePluginEnableRequest({ dispatch }, plugin: SearchPlugin) { 53 | try { 54 | await api.enablePlugin(plugin, !plugin.enabled); // switch plugin enable state 55 | 56 | dispatch("enablePluginRequestSuccess", plugin); 57 | } catch { 58 | // Do nothing 59 | } 60 | }, 61 | enablePluginRequestSuccess({ dispatch }) { 62 | dispatch('fetchSearchPlugins'); // refresh the plugins 63 | }, 64 | async updatePluginsRequest({ dispatch }) { 65 | try { 66 | await api.updateSearchPlugins(); 67 | 68 | dispatch("updatePluginsRequestSuccess"); 69 | } catch { 70 | dispatch("updatePluginsRequestFailure"); 71 | } 72 | }, 73 | async updatePluginsRequestSuccess({ dispatch }) { 74 | await dispatch('getSearchPluginsRequest'); 75 | }, 76 | updatePluginsRequestFailure() { 77 | // Do nothing 78 | }, 79 | }, 80 | } as Module; 81 | -------------------------------------------------------------------------------- /src/store/snackBar.ts: -------------------------------------------------------------------------------- 1 | import { cloneDeep, isString } from 'lodash'; 2 | import { Module } from 'vuex'; 3 | import { SnackBarState } from './types'; 4 | 5 | export const snackBarStore: Module = { 6 | state() { 7 | return { 8 | config: null, 9 | }; 10 | }, 11 | mutations: { 12 | showSnackBar(state, payload) { 13 | if (isString(payload)) { 14 | state.config = { 15 | text: payload, 16 | }; 17 | } else { 18 | state.config = cloneDeep(payload); 19 | } 20 | }, 21 | closeSnackBar(state) { 22 | state.config = null; 23 | }, 24 | }, 25 | }; 26 | -------------------------------------------------------------------------------- /src/store/types.ts: -------------------------------------------------------------------------------- 1 | import { MainData, SearchPlugin } from '@/types'; 2 | import { Config } from './config'; 3 | 4 | export interface RootState { 5 | rid: number; 6 | mainData?: MainData; 7 | preferences: any; 8 | pasteUrl: string | null; 9 | needAuth: boolean; 10 | query: string | null; 11 | } 12 | 13 | export interface SearchEnginePage { 14 | searchPlugins: SearchPlugin[] | null | undefined; 15 | isPluginManagerOpen: boolean; 16 | } 17 | 18 | export interface AddFormState { 19 | isOpen: boolean; 20 | downloadItem: { 21 | title: string; 22 | url: string; 23 | } | null; 24 | } 25 | 26 | export interface TorrentFilter { 27 | state: string; 28 | category: string; 29 | tag: string; 30 | site: string; 31 | query: string; 32 | } 33 | 34 | export interface ConfigState { 35 | userConfig: any; 36 | } 37 | 38 | export interface ConfigPayload { 39 | key: keyof Config; 40 | value: any; 41 | } 42 | 43 | export enum DialogType { 44 | Alert, 45 | YesNo, 46 | OkCancel, 47 | Input, 48 | Custom, 49 | } 50 | 51 | export interface DialogConfig { 52 | dialog?: any; 53 | 54 | title?: string; 55 | text: string; 56 | callback?: CallableFunction; 57 | type?: DialogType; 58 | buttons?: any; 59 | 60 | rules?: CallableFunction[]; 61 | placeholder?: string; 62 | value?: string; 63 | } 64 | 65 | export interface DialogState { 66 | config: DialogConfig | null; 67 | } 68 | 69 | export interface SnackBarConfig { 70 | text: string; 71 | btnText?: string; 72 | callback?: CallableFunction; 73 | } 74 | 75 | export interface SnackBarState { 76 | config: SnackBarConfig | null; 77 | } 78 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable camelcase */ 2 | export interface BaseTorrent { 3 | added_on: number; 4 | amount_left: number; 5 | auto_tmm: boolean; 6 | availability: number; 7 | category: string; 8 | completed: number; 9 | completion_on: number; 10 | dl_limit: number; 11 | dlspeed: number; 12 | downloaded: number; 13 | downloaded_session: number; 14 | eta: number; 15 | f_l_piece_prio: boolean; 16 | force_start: boolean; 17 | last_activity: number; 18 | magnet_uri: string; 19 | max_ratio: number; 20 | max_seeding_time: number; 21 | name: string; 22 | num_complete: number; 23 | num_incomplete: number; 24 | num_leechs: number; 25 | num_seeds: number; 26 | priority: number; 27 | progress: number; 28 | ratio: number; 29 | ratio_limit: number; 30 | save_path: string; 31 | seeding_time_limit: number; 32 | seen_complete: number; 33 | seq_dl: boolean; 34 | size: number; 35 | state: string; 36 | super_seeding: boolean; 37 | tags: string; 38 | time_active: number; 39 | total_size: number; 40 | tracker: string; 41 | up_limit: number; 42 | uploaded: number; 43 | uploaded_session: number; 44 | upspeed: number; 45 | } 46 | 47 | export interface Torrent extends BaseTorrent { 48 | hash: string; 49 | } 50 | 51 | export interface Category { 52 | key: string; 53 | name: string; 54 | savePath?: string; 55 | } 56 | 57 | export interface ApiCategory { 58 | [key: string]: { 59 | name: string; 60 | savePath?: string; 61 | }; 62 | } 63 | 64 | export interface SimpleCategory { 65 | name: string | null; 66 | savePath?: string; 67 | } 68 | 69 | export interface Tag { 70 | key: string; 71 | name: string; 72 | } 73 | 74 | export interface ServerState { 75 | alltime_dl: number; 76 | alltime_ul: number; 77 | average_time_queue: number; 78 | connection_status: string; 79 | dht_nodes: number; 80 | dl_info_data: number; 81 | dl_info_speed: number; 82 | dl_rate_limit: number; 83 | free_space_on_disk: number; 84 | global_ratio: string; 85 | queued_io_jobs: number; 86 | queueing: boolean; 87 | read_cache_hits: string; 88 | read_cache_overload: string; 89 | refresh_interval: number; 90 | total_buffers_size: number; 91 | total_peer_connections: number; 92 | total_queued_size: number; 93 | total_wasted_session: number; 94 | up_info_data: number; 95 | up_info_speed: number; 96 | up_rate_limit: number; 97 | use_alt_speed_limits: boolean; 98 | write_cache_overload: string; 99 | } 100 | 101 | export interface MainData { 102 | categories: Record; 103 | tags: [string]; 104 | server_state: ServerState; 105 | torrents: Record; 106 | } 107 | 108 | export interface RssTorrent { 109 | category?: string; 110 | comment?: string; 111 | date?: string; 112 | description?: string; 113 | id: string; 114 | link: string; 115 | title: string; 116 | torrentURL: string; 117 | } 118 | 119 | export interface RssItem { 120 | articles: RssTorrent[]; 121 | hasError: boolean; 122 | isLoading: boolean; 123 | lastBuildDate: string; 124 | title: string; 125 | uid: string; 126 | url: string; 127 | } 128 | 129 | export interface RssNode { 130 | [key: string]: RssNode | RssItem; 131 | } 132 | 133 | export interface RssRule { 134 | enabled: boolean; 135 | mustContain: string; 136 | mustNotContain: string; 137 | useRegex: boolean; 138 | episodeFilter: string; 139 | smartFilter: boolean; 140 | previouslyMatchedEpisodes: string[]; 141 | affectedFeeds: string[]; 142 | createSubfolder: boolean | null; 143 | ignoreDays: number; 144 | lastMatch: string; 145 | addPaused: boolean | null; 146 | assignedCategory: string; 147 | savepath: string; 148 | } 149 | 150 | export interface TorrentProperties { 151 | addition_date: number; 152 | comment: string; 153 | completion_date: number; 154 | created_by: string; 155 | creation_date: number; 156 | dl_limit: number; 157 | dl_speed: number; 158 | dl_speed_avg: number; 159 | eta: number; 160 | last_seen: number; 161 | nb_connections: number; 162 | nb_connections_limit: number; 163 | peers: number; 164 | peers_total: number; 165 | piece_size: number; 166 | pieces_have: number; 167 | pieces_num: number; 168 | reannounce: number; 169 | save_path: string; 170 | seeding_time: number; 171 | seeds: number; 172 | seeds_total: number; 173 | share_ratio: number; 174 | time_elapsed: number; 175 | total_downloaded: number; 176 | total_downloaded_session: number; 177 | total_size: number; 178 | total_uploaded: number; 179 | total_uploaded_session: number; 180 | total_wasted: number; 181 | up_limit: number; 182 | up_speed: number; 183 | up_speed_avg: number; 184 | } 185 | 186 | export interface Preferences { 187 | add_trackers: string; 188 | add_trackers_enabled: boolean; 189 | alt_dl_limit: number; 190 | alt_up_limit: number; 191 | alternative_webui_enabled: boolean; 192 | alternative_webui_path: string; 193 | anonymous_mode: boolean; 194 | auto_delete_mode: number; 195 | auto_tmm_enabled: boolean; 196 | autorun_enabled: boolean; 197 | autorun_program: string; 198 | banned_IPs: string; 199 | bittorrent_protocol: number; 200 | bypass_auth_subnet_whitelist: string; 201 | bypass_auth_subnet_whitelist_enabled: boolean; 202 | bypass_local_auth: boolean; 203 | category_changed_tmm_enabled: boolean; 204 | create_subfolder_enabled: boolean; 205 | dht: boolean; 206 | dl_limit: number; 207 | dont_count_slow_torrents: boolean; 208 | dyndns_domain: string; 209 | dyndns_enabled: boolean; 210 | dyndns_password: string; 211 | dyndns_service: number; 212 | dyndns_username: string; 213 | encryption: number; 214 | export_dir: string; 215 | export_dir_fin: string; 216 | force_proxy: boolean; 217 | incomplete_files_ext: boolean; 218 | ip_filter_enabled: boolean; 219 | ip_filter_path: string; 220 | ip_filter_trackers: boolean; 221 | limit_lan_peers: boolean; 222 | limit_tcp_overhead: boolean; 223 | limit_utp_rate: boolean; 224 | listen_port: number; 225 | locale: string; 226 | lsd: boolean; 227 | mail_notification_auth_enabled: boolean; 228 | mail_notification_email: string; 229 | mail_notification_enabled: boolean; 230 | mail_notification_password: string; 231 | mail_notification_sender: string; 232 | mail_notification_smtp: string; 233 | mail_notification_ssl_enabled: boolean; 234 | mail_notification_username: string; 235 | max_active_downloads: number; 236 | max_active_torrents: number; 237 | max_active_uploads: number; 238 | max_connec: number; 239 | max_connec_per_torrent: number; 240 | max_ratio: number; 241 | max_ratio_act: number; 242 | max_ratio_enabled: boolean; 243 | max_seeding_time: number; 244 | max_seeding_time_enabled: boolean; 245 | max_uploads: number; 246 | max_uploads_per_torrent: number; 247 | pex: boolean; 248 | preallocate_all: boolean; 249 | proxy_auth_enabled: boolean; 250 | proxy_ip: string; 251 | proxy_password: string; 252 | proxy_peer_connections: boolean; 253 | proxy_port: number; 254 | proxy_torrents_only: boolean; 255 | proxy_type: number; 256 | proxy_username: string; 257 | queueing_enabled: boolean; 258 | random_port: boolean; 259 | rss_auto_downloading_enabled: boolean; 260 | rss_max_articles_per_feed: number; 261 | rss_processing_enabled: boolean; 262 | rss_refresh_interval: number; 263 | save_path: string; 264 | save_path_changed_tmm_enabled: boolean; 265 | scan_dirs: { [key: string]: string | number }; 266 | schedule_from_hour: number; 267 | schedule_from_min: number; 268 | schedule_to_hour: number; 269 | schedule_to_min: number; 270 | scheduler_days: number; 271 | scheduler_enabled: boolean; 272 | slow_torrent_dl_rate_threshold: number; 273 | slow_torrent_inactive_timer: number; 274 | slow_torrent_ul_rate_threshold: number; 275 | ssl_cert: string; 276 | ssl_key: string; 277 | start_paused_enabled: boolean; 278 | temp_path: string; 279 | temp_path_enabled: boolean; 280 | torrent_changed_tmm_enabled: boolean; 281 | up_limit: number; 282 | upnp: boolean; 283 | use_https: boolean; 284 | web_ui_address: string; 285 | web_ui_clickjacking_protection_enabled: boolean; 286 | web_ui_csrf_protection_enabled: boolean; 287 | web_ui_domain_list: string; 288 | web_ui_host_header_validation_enabled: boolean; 289 | web_ui_password: string; 290 | web_ui_port: number; 291 | web_ui_upnp: boolean; 292 | web_ui_username: string; 293 | web_ui_max_auth_fail_count: number; 294 | web_ui_ban_duration: number; 295 | web_ui_session_timeout: number; 296 | } 297 | 298 | export interface SearchPlugin { 299 | enabled: boolean; 300 | fullName: string; 301 | name: string; 302 | supportedCategories: string[]; 303 | url: string; 304 | version: string; 305 | } 306 | 307 | export interface SearchTaskTorrent { 308 | descrLink: string; 309 | fileName: string; 310 | fileSize: number; 311 | fileUrl: string; 312 | nbLeechers: number; 313 | nbSeeders: number; 314 | siteUrl: string; 315 | } 316 | 317 | export interface SearchTaskResponse { 318 | results: SearchTaskTorrent[]; 319 | status: 'Running' | 'Stopped'; 320 | total: number; 321 | } 322 | -------------------------------------------------------------------------------- /src/utils/index.ts: -------------------------------------------------------------------------------- 1 | import { StateType } from '@/consts'; 2 | import { Torrent } from '@/types'; 3 | 4 | const dlState = ['downloading', 'metaDL', 'stalledDL', 'checkingDL', 'pausedDL', 'queuedDL', 'forcedDL', 'allocating']; 5 | const upState = ['uploading', 'stalledUP', 'checkingUP', 'queuedUP', 'forcedUP']; 6 | const completeState = ['uploading', 'stalledUP', 'checkingUP', 'pausedUP', 'queuedUP', 'forcedUP']; 7 | const activeState = ['metaDL', 'downloading', 'forcedDL', 'uploading', 'forcedUP', 'moving']; 8 | const errorState = ['error', 'missingFiles']; 9 | 10 | export function torrentIsState(type: StateType, state: string) { 11 | let result; 12 | switch (type) { 13 | case StateType.Downloading: { 14 | result = dlState.includes(state); 15 | break; 16 | } 17 | case StateType.Seeding: { 18 | result = upState.includes(state); 19 | break; 20 | } 21 | case StateType.Completed: { 22 | result = completeState.includes(state); 23 | break; 24 | } 25 | case StateType.Resumed: 26 | case StateType.Paused: { 27 | const paused = state.startsWith('paused'); 28 | result = type === StateType.Paused ? paused : !paused; 29 | break; 30 | } 31 | case StateType.Active: 32 | case StateType.Inactive: { 33 | const active = activeState.includes(state); 34 | result = type === StateType.Active ? active : !active; 35 | break; 36 | } 37 | case StateType.Errored: { 38 | result = errorState.includes(state); 39 | break; 40 | } 41 | default: 42 | throw Error('Invalid type'); 43 | } 44 | 45 | return result; 46 | } 47 | 48 | export function timeout(ms: number) { 49 | return new Promise(resolve => setTimeout(resolve, ms)); 50 | } 51 | /** 52 | * @deprecated renamed to `timeout` 53 | */ 54 | export const sleep = timeout; 55 | 56 | export function codeToFlag(code: string) { 57 | const magicNumber = 0x1F1A5; 58 | 59 | // eslint-disable-next-line 60 | code = code.toUpperCase(); 61 | const codePoints = [...code].map(c => magicNumber + c.charCodeAt(0)); 62 | const char = String.fromCodePoint(...codePoints); 63 | const url = 'https://cdn.jsdelivr.net/npm/twemoji/2/svg/' 64 | + `${codePoints[0].toString(16)}-${codePoints[1].toString(16)}.svg`; 65 | 66 | return { 67 | char, 68 | url, 69 | }; 70 | } 71 | 72 | export const isWindows = navigator.userAgent.includes('Windows'); 73 | 74 | export function findSameNamedTorrents(allTorrents: Torrent[], torrents: Torrent[]) { 75 | const hashes = torrents.map(t => t.hash); 76 | const result = []; 77 | for (const t1 of torrents) { 78 | for (const t2 of allTorrents) { 79 | if (hashes.includes(t2.hash)) { 80 | continue; 81 | } 82 | 83 | if (t1.name !== t2.name) { 84 | continue; 85 | } 86 | 87 | result.push(t2); 88 | hashes.push(t2.hash); 89 | } 90 | } 91 | 92 | return result; 93 | } 94 | 95 | export function typed(value: T): T { 96 | return value; 97 | } 98 | -------------------------------------------------------------------------------- /src/utils/vue-object-merge.ts: -------------------------------------------------------------------------------- 1 | import Vue from 'vue'; 2 | import { isPlainObject } from 'lodash'; 3 | 4 | // based on https://github.com/richardtallent/vue-object-merge/blob/main/index.js 5 | 6 | export const stateMerge = function(state: any, value: any, propName?: string, ignoreNull?: boolean) { 7 | if (isPlainObject(state) && (propName == null || propName in state)) { 8 | const o = propName == null ? state : state[propName]; 9 | if (o != null && isPlainObject(value)) { 10 | for (const prop in value) { 11 | stateMerge(o, value[prop], prop, ignoreNull); 12 | } 13 | return; 14 | } 15 | } 16 | if (!ignoreNull || value !== null) Vue.set(state, propName!, value); 17 | 18 | return state; 19 | }; 20 | 21 | export default stateMerge; -------------------------------------------------------------------------------- /tag-nightly.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | git tag nightly-$(date +'%Y%m%d') 4 | 5 | -------------------------------------------------------------------------------- /tests/unit/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { 3 | jest: true, 4 | }, 5 | }; 6 | -------------------------------------------------------------------------------- /tests/unit/components/FilterGroup.spec.ts: -------------------------------------------------------------------------------- 1 | import Vuex from 'vuex'; 2 | import { createLocalVue, shallowMount } from '@vue/test-utils'; 3 | import '@/directives'; 4 | import FilterGroup from '@/components/drawer/FilterGroup.vue'; 5 | import * as types from '@/components/types'; 6 | import { mock } from '../utils'; 7 | 8 | const localVue = createLocalVue(); 9 | localVue.use(Vuex); 10 | 11 | const userConfig = { 12 | filter: { 13 | foo: 'bar', 14 | }, 15 | }; 16 | 17 | const store = new Vuex.Store({ 18 | getters: { 19 | config() { 20 | return userConfig; 21 | }, 22 | }, 23 | mutations: { 24 | updateConfig: jest.fn(), 25 | }, 26 | }); 27 | 28 | function mount(propsData: object) { 29 | return shallowMount(FilterGroup, { 30 | localVue, 31 | store, 32 | propsData, 33 | stubs: [ 34 | 'v-list-group', 35 | 'v-list-item', 36 | 'v-list-item-icon', 37 | 'v-list-item-title', 38 | 'v-list-item-content', 39 | 'v-img', 40 | ], 41 | }); 42 | } 43 | 44 | const emptyGroup: types.Group = { 45 | title: '', 46 | icon: '', 47 | children: [], 48 | model: false, 49 | select: '', 50 | }; 51 | 52 | const emptyChild: types.Child = { 53 | title: '', 54 | key: null, 55 | icon: '', 56 | append: null, 57 | }; 58 | 59 | const mockGroup = mock(emptyGroup); 60 | const mockChild = mock(emptyChild); 61 | 62 | test('normal create', () => { 63 | const group = mockGroup({ 64 | children: [ 65 | mockChild({ 66 | key: 'bar', 67 | }), 68 | ], 69 | select: 'foo', 70 | model: true, 71 | }); 72 | 73 | const wrapper = mount({ 74 | group, 75 | }); 76 | const vmAny = wrapper.vm as any; 77 | 78 | expect(vmAny.selected).toEqual('bar'); 79 | expect(vmAny.model).toBeTruthy(); 80 | }); 81 | 82 | test('manual select child', () => { 83 | const group = mockGroup({ 84 | select: 'foo', 85 | }); 86 | 87 | const wrapper = mount({ 88 | group, 89 | }); 90 | const vmAny = wrapper.vm as any; 91 | 92 | vmAny.select('ha'); 93 | expect(vmAny.selected).toEqual('ha'); 94 | }); 95 | 96 | test('unselect if can not found children', () => { 97 | const group = mockGroup({ 98 | select: 'foo', 99 | }); 100 | 101 | const wrapper = mount({ 102 | group, 103 | }); 104 | 105 | expect((wrapper.vm as any).selected).toBeNull(); 106 | }); 107 | -------------------------------------------------------------------------------- /tests/unit/filters.spec.ts: -------------------------------------------------------------------------------- 1 | import dayjs from 'dayjs'; 2 | import { 3 | formatDuration, formatTimestamp, formatProgress, formatSize, formatAsDuration, toPrecision, 4 | } from '@/filters'; 5 | 6 | describe('to precision', () => { 7 | test.each([ 8 | [0.1, 1, '0'], 9 | [0.1, 2, '0.1'], 10 | [0.9, 1, '1'], 11 | [99.5, 2, '100'], 12 | [122, 1, '122'], 13 | ])('case %#', (value, precision, result) => { 14 | expect(toPrecision(value, precision)).toEqual(result); 15 | }); 16 | }); 17 | 18 | describe('format size', () => { 19 | test.each([ 20 | [0, '0 B'], 21 | [10, '10 B'], 22 | [500, '500 B'], 23 | [998, '998 B'], 24 | [999, '0.98 KiB'], 25 | [1000, '0.98 KiB'], 26 | ])('case %#', (value, result) => { 27 | expect(formatSize(value)).toEqual(result); 28 | }); 29 | }); 30 | 31 | describe('format duration', () => { 32 | test.each([ 33 | [0, undefined, '0s'], 34 | [0, { minUnit: 1}, '0m'], 35 | [2 * 60 + 35, undefined, '2m 35s'], 36 | [2 * 60 + 35, { minUnit: 1}, '2m'], 37 | [3600 * 24, { dayLimit: 1 }, '∞'], 38 | [3600 * 24, undefined, '1d'], 39 | [3600 * 26, undefined, '1d 2h'], 40 | ])('case %#', (value, options, result) => { 41 | expect(formatDuration(value, options)).toEqual(result); 42 | }); 43 | }); 44 | 45 | describe('format timestamp', () => { 46 | test.each([ 47 | // [948602096, '2000-01-23 12:34:56'], # commented out due to timezone issue 48 | [null, ''], 49 | [-1, ''], 50 | ])('case %#', (value, result) => { 51 | expect(formatTimestamp(value)).toEqual(result); 52 | }); 53 | }); 54 | 55 | test('format as duration', () => { 56 | const date = dayjs().subtract(1, 'd').subtract(1, 'h'); 57 | expect(formatAsDuration(date.unix())).toEqual('1d 1h'); 58 | }); 59 | 60 | describe('format progress', () => { 61 | test.each([ 62 | [0, '0.00'], 63 | [0.0001, '0.01'], 64 | [0.001, '0.10'], 65 | [0.01, '1.00'], 66 | [0.1, '10.0'], 67 | [1, '100'], 68 | ])('case %#', (value, result) => { 69 | expect(formatProgress(value)).toEqual(`${result}%`); 70 | }); 71 | }); 72 | -------------------------------------------------------------------------------- /tests/unit/store/config.spec.ts: -------------------------------------------------------------------------------- 1 | import Vuex from 'vuex'; 2 | import { createLocalVue } from '@vue/test-utils'; 3 | import { configStore } from '@/store/config'; 4 | 5 | 6 | const localVue = createLocalVue(); 7 | localVue.use(Vuex); 8 | 9 | const store = new Vuex.Store({ 10 | modules: { 11 | config: configStore, 12 | }, 13 | }); 14 | 15 | beforeEach(() => { 16 | store.replaceState({ 17 | config: { 18 | userConfig: {}, 19 | }, 20 | }); 21 | }); 22 | 23 | test('load config', () => { 24 | const spyGet = jest.spyOn(Object.getPrototypeOf(localStorage), 'getItem'); 25 | spyGet.mockReturnValue('{"foo": "bar"}'); 26 | 27 | // eslint-disable-next-line no-shadow 28 | const store = new Vuex.Store({ 29 | modules: { 30 | config: configStore, 31 | }, 32 | }); 33 | 34 | expect(store.getters.config).toMatchObject({ 35 | foo: 'bar', 36 | }); 37 | 38 | spyGet.mockRestore(); 39 | }); 40 | 41 | test('config getter', () => { 42 | expect(store.getters.config).not.toEqual({}); 43 | }); 44 | 45 | describe('update config', () => { 46 | const spySet = jest.spyOn(Object.getPrototypeOf(localStorage), 'setItem'); 47 | 48 | beforeEach(() => { 49 | spySet.mockClear(); 50 | }); 51 | afterAll(() => { 52 | spySet.mockRestore(); 53 | }); 54 | 55 | test('update object', () => { 56 | const value1 = { 57 | foo1: 'bar1', 58 | }; 59 | 60 | store.commit('updateConfig', { 61 | key: 'obj', 62 | value: value1, 63 | }); 64 | 65 | expect(store.state.config.userConfig).toEqual({ 66 | obj: value1, 67 | }); 68 | 69 | const value2 = { 70 | foo2: 'bar2', 71 | }; 72 | store.commit('updateConfig', { 73 | key: 'obj', 74 | value: value2, 75 | }); 76 | 77 | expect(store.state.config.userConfig).toEqual({ 78 | obj: Object.assign({}, value1, value2), 79 | }); 80 | }); 81 | 82 | test('update plain type', () => { 83 | store.commit('updateConfig', { 84 | key: 'foo', 85 | value: 'bar', 86 | }); 87 | 88 | expect(store.getters.config).toMatchObject({ 89 | foo: 'bar', 90 | }); 91 | expect(spySet).toBeCalled(); 92 | }); 93 | }); 94 | -------------------------------------------------------------------------------- /tests/unit/store/index.spec.ts: -------------------------------------------------------------------------------- 1 | import Vuex from 'vuex'; 2 | import { createLocalVue } from '@vue/test-utils'; 3 | import store from '@/store'; 4 | import { RootState } from '@/store/types'; 5 | import { mock, mockBaseTorrent } from '../utils'; 6 | 7 | 8 | const localVue = createLocalVue(); 9 | localVue.use(Vuex); 10 | 11 | const emtpyState: RootState = { 12 | rid: 0, 13 | mainData: undefined, 14 | preferences: null, 15 | pasteUrl: null, 16 | needAuth: false, 17 | query: null, 18 | }; 19 | 20 | const mockState = mock(emtpyState); 21 | 22 | beforeEach(() => { 23 | store.replaceState(emtpyState); 24 | }); 25 | 26 | test('update preferences', () => { 27 | const obj = { 28 | url: 'something', 29 | }; 30 | store.commit('updatePreferences', obj); 31 | 32 | expect(store.state.preferences).toEqual(obj); 33 | }); 34 | 35 | test('set paste url', () => { 36 | store.commit('setPasteUrl', { 37 | url: 'something', 38 | }); 39 | 40 | expect(store.state.pasteUrl).toEqual('something'); 41 | }); 42 | 43 | describe('all torrents getter', () => { 44 | test('empty', () => { 45 | expect(store.getters.allTorrents).toEqual([]); 46 | }); 47 | 48 | test('with data', () => { 49 | store.replaceState(mockState({ 50 | mainData: { 51 | categories: {}, 52 | tags: [""], 53 | // eslint-disable-next-line @typescript-eslint/camelcase 54 | server_state: undefined as any, 55 | torrents: { 56 | a: mockBaseTorrent({}), 57 | b: mockBaseTorrent({}), 58 | }, 59 | }, 60 | })); 61 | 62 | expect(store.getters.allTorrents).toMatchObject([ 63 | { hash: 'a' }, { hash: 'b' }, 64 | ]); 65 | }); 66 | }); 67 | -------------------------------------------------------------------------------- /tests/unit/utils.spec.ts: -------------------------------------------------------------------------------- 1 | import { findSameNamedTorrents, codeToFlag, sleep } from '@/utils'; 2 | import { mockTorrent } from './utils'; 3 | 4 | test('timeout', async () => { 5 | jest.useFakeTimers(); 6 | 7 | const fn = jest.fn(); 8 | 9 | sleep(1000).then(fn); 10 | expect(fn).not.toBeCalled(); 11 | 12 | jest.advanceTimersByTime(500); 13 | // HACK: force wait promise, 14 | // see https://stackoverflow.com/a/51132058/2806903 15 | // and https://stackoverflow.com/a/52196951/2806903 16 | await Promise.resolve(); 17 | expect(fn).not.toBeCalled(); 18 | 19 | jest.advanceTimersByTime(500); 20 | await Promise.resolve(); 21 | expect(fn).toBeCalled(); 22 | }); 23 | 24 | test('code to flag', () => { 25 | expect(codeToFlag('CN')).toMatchObject({ 26 | char: '🇨🇳', 27 | url: expect.stringContaining('1f1e8-1f1f3'), 28 | }); 29 | }); 30 | 31 | describe('find same named torrents', () => { 32 | const torrents = [ 33 | mockTorrent({ 34 | hash: '0', 35 | name: 'A', 36 | }), 37 | mockTorrent({ 38 | hash: '1', 39 | name: 'B', 40 | }), 41 | mockTorrent({ 42 | hash: '2', 43 | name: 'A', 44 | }), 45 | mockTorrent({ 46 | hash: '3', 47 | name: 'C', 48 | }), 49 | ]; 50 | 51 | test.each([ 52 | [[mockTorrent({ name: 'A' })], [torrents[0], torrents[2]]], 53 | [[mockTorrent({ name: 'B' })], [torrents[1]]], 54 | [[mockTorrent({ name: 'C' })], [torrents[3]]], 55 | [[mockTorrent({ hash: '0', name: 'A' })], [torrents[2]]], 56 | ])('case %#', (target, result) => { 57 | expect(findSameNamedTorrents(torrents, target)).toEqual(result); 58 | }); 59 | }); 60 | -------------------------------------------------------------------------------- /tests/unit/utils.ts: -------------------------------------------------------------------------------- 1 | import { Torrent, BaseTorrent } from '@/types'; 2 | 3 | const emptyBaseTorrent: BaseTorrent = { 4 | /* eslint-disable @typescript-eslint/camelcase */ 5 | added_on: 0, 6 | amount_left: 0, 7 | auto_tmm: false, 8 | availability: 0, 9 | category: '', 10 | completed: 0, 11 | completion_on: 0, 12 | dl_limit: 0, 13 | dlspeed: 0, 14 | downloaded: 0, 15 | downloaded_session: 0, 16 | eta: 0, 17 | f_l_piece_prio: false, 18 | force_start: false, 19 | last_activity: 0, 20 | magnet_uri: '', 21 | max_ratio: 0, 22 | max_seeding_time: 0, 23 | name: '', 24 | num_complete: 0, 25 | num_incomplete: 0, 26 | num_leechs: 0, 27 | num_seeds: 0, 28 | priority: 0, 29 | progress: 0, 30 | ratio: 0, 31 | ratio_limit: 0, 32 | save_path: '', 33 | seeding_time_limit: 0, 34 | seen_complete: 0, 35 | seq_dl: false, 36 | size: 0, 37 | state: '', 38 | super_seeding: false, 39 | tags: '', 40 | time_active: 0, 41 | total_size: 0, 42 | tracker: '', 43 | up_limit: 0, 44 | uploaded: 0, 45 | uploaded_session: 0, 46 | upspeed: 0, 47 | /* eslint-enable @typescript-eslint/camelcase */ 48 | }; 49 | 50 | const emptyTorrent: Torrent = Object.assign({}, emptyBaseTorrent, { 51 | hash: '', 52 | }); 53 | 54 | export function mock(empty: T) { 55 | return (props: Partial): T => Object.assign({}, empty, props); 56 | } 57 | 58 | export const mockBaseTorrent = mock(emptyBaseTorrent); 59 | export const mockTorrent = mock(emptyTorrent); 60 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "esnext", 4 | "module": "esnext", 5 | "strict": true, 6 | "jsx": "preserve", 7 | "importHelpers": true, 8 | "moduleResolution": "node", 9 | "esModuleInterop": true, 10 | "experimentalDecorators": true, 11 | "allowSyntheticDefaultImports": true, 12 | "sourceMap": true, 13 | "baseUrl": ".", 14 | "types": [ 15 | "webpack-env", 16 | "vuetify", 17 | "jest" 18 | ], 19 | "paths": { 20 | "@/*": [ 21 | "src/*" 22 | ] 23 | }, 24 | "lib": [ 25 | "esnext", 26 | "dom", 27 | "dom.iterable", 28 | "scripthost" 29 | ] 30 | }, 31 | "include": [ 32 | "src/**/*.ts", 33 | "src/**/*.tsx", 34 | "src/**/*.vue", 35 | "tests/**/*.ts", 36 | "tests/**/*.tsx" 37 | ], 38 | "exclude": [ 39 | "node_modules" 40 | ] 41 | } 42 | -------------------------------------------------------------------------------- /vue.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | outputDir: 'dist/public', 3 | publicPath: './', 4 | 5 | pwa: { 6 | // name: "qb-web", 7 | themeColor: "#4d8ad5", 8 | msTileColor: "#4d8ad5", 9 | appleMobileWebAppCapable: 'yes', 10 | 11 | iconPaths: { 12 | favicon32: 'img/icons/favicon-32x32.png', 13 | favicon16: 'img/icons/favicon-16x16.png', 14 | appleTouchIcon: 'img/icons/apple-touch-icon.png', 15 | maskIcon: null, 16 | msTileImage: null, 17 | }, 18 | }, 19 | 20 | devServer: { 21 | port: 8000, 22 | proxy: { 23 | '/api': { 24 | target: 'http://qb.test:8080', 25 | }, 26 | }, 27 | }, 28 | 29 | chainWebpack(config) { 30 | config.plugin('define').tap(args => { 31 | let arg = args[0] 32 | arg = { 33 | ...arg, 34 | 'process.env.GIT_TAG': JSON.stringify(process.env.GIT_TAG), 35 | } 36 | 37 | return [arg] 38 | }) 39 | }, 40 | }; 41 | --------------------------------------------------------------------------------