├── .env.development ├── .github ├── CODEOWNERS ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── FUNDING.yml ├── ISSUE_TEMPLATE │ ├── bug_report.yml │ ├── config.yml │ ├── feature_request.yml │ └── wiki.yml ├── dependabot.yaml ├── pull_request_template.md └── workflows │ ├── build-release.yml │ ├── codeql-analysis.yml │ ├── notify-release.yml │ ├── test.yml │ └── translations.yml ├── .gitignore ├── .prettierignore ├── .prettierrc.json ├── .release-please-manifest.json ├── .vscode └── extensions.json ├── CHANGELOG.md ├── Dockerfile ├── LICENSE ├── README.md ├── VueTorrent-logo.png ├── docker-compose.yml ├── eslint.config.mjs ├── index.html ├── package-lock.json ├── package.json ├── public ├── apple-touch-icon.png ├── favicon.ico ├── icon-192.png ├── icon-512.png ├── icon.svg ├── manifest.webmanifest ├── registerSW.js ├── robots.txt ├── screenshots │ ├── screenshot-desktop-dark-mode.jpeg │ ├── screenshot-desktop.jpeg │ ├── screenshot-mobile-dark-mode.jpeg │ ├── screenshot-mobile-navbar-dark-mode.jpeg │ ├── screenshot-mobile-navbar.jpeg │ └── screenshot-mobile.jpeg └── sw.js ├── release-please-config.json ├── src ├── App.vue ├── components │ ├── AddPanel.vue │ ├── Core │ │ ├── ColoredChip.spec.ts │ │ ├── ColoredChip.vue │ │ ├── DataCard.spec.ts │ │ ├── DataCard.vue │ │ ├── HistoryField.vue │ │ ├── MixedButton.spec.ts │ │ ├── MixedButton.vue │ │ ├── PasswordField.vue │ │ ├── RightClickMenu │ │ │ ├── RightClickMenu.vue │ │ │ ├── RightClickMenuEntry.vue │ │ │ └── index.ts │ │ ├── ServerPathField.vue │ │ ├── SpeedCard.vue │ │ └── StringCard.vue │ ├── Dashboard │ │ ├── DashboardItems │ │ │ ├── ItemAmount.vue │ │ │ ├── ItemBoolean.vue │ │ │ ├── ItemChip.vue │ │ │ ├── ItemData.vue │ │ │ ├── ItemDateTime.vue │ │ │ ├── ItemDuration.vue │ │ │ ├── ItemPercent.vue │ │ │ ├── ItemRelativeTime.vue │ │ │ ├── ItemSpeed.vue │ │ │ └── ItemText.vue │ │ ├── RightClick.vue │ │ ├── Toolbar.vue │ │ └── Views │ │ │ ├── Grid │ │ │ ├── GridTorrent.vue │ │ │ └── GridView.vue │ │ │ ├── List │ │ │ ├── ListTorrent.vue │ │ │ └── ListView.vue │ │ │ └── Table │ │ │ ├── DashboardItems │ │ │ ├── ItemAmount.vue │ │ │ ├── ItemBoolean.vue │ │ │ ├── ItemChip.vue │ │ │ ├── ItemData.vue │ │ │ ├── ItemDateTime.vue │ │ │ ├── ItemDuration.vue │ │ │ ├── ItemPercent.vue │ │ │ ├── ItemRelativeTime.vue │ │ │ ├── ItemSpeed.vue │ │ │ └── ItemText.vue │ │ │ ├── Header.vue │ │ │ ├── TableTorrent.vue │ │ │ └── TableView.vue │ ├── Dialogs │ │ ├── AddTorrentDialog.vue │ │ ├── AddTorrentParamsDialog.vue │ │ ├── AddTorrentParamsForm.vue │ │ ├── BulkRenameFilesDialog.vue │ │ ├── BulkUpdateTrackers │ │ │ ├── BulkUpdateTrackersDialog.vue │ │ │ ├── TrackerEditRow.vue │ │ │ └── TrackersEditField.vue │ │ ├── CategoryFormDialog.vue │ │ ├── ConfirmDeleteDialog.vue │ │ ├── ConfirmShutdownDialog.vue │ │ ├── ConnectionStatusDialog.vue │ │ ├── ContentFilterDialog.vue │ │ ├── ImportSettingsDialog.vue │ │ ├── MoveTorrentDialog.vue │ │ ├── MoveTorrentFileDialog.vue │ │ ├── PluginManagerDialog.vue │ │ ├── RenameTorrentDialog.vue │ │ ├── RssFeedDialog.vue │ │ ├── RssRuleDialog.vue │ │ ├── SampleDialog.vue.template │ │ ├── ShareLimitDialog.vue │ │ ├── SpeedLimitDialog.vue │ │ ├── TagFormDialog.vue │ │ └── TorrentCreatorFormDialog.vue │ ├── DnDZone.vue │ ├── Navbar │ │ ├── Navbar.vue │ │ ├── SideWidgets │ │ │ ├── BottomActions.vue │ │ │ ├── CurrentSpeed.vue │ │ │ ├── FilterSelect.vue │ │ │ ├── FreeSpace.vue │ │ │ ├── SpeedGraph.vue │ │ │ └── TransferStats.vue │ │ └── TopWidgets │ │ │ ├── ActiveFilters.vue │ │ │ ├── TopActions.vue │ │ │ ├── TopContainer.vue │ │ │ └── TopOverflow.vue │ ├── RSS │ │ ├── Feeds │ │ │ ├── Article.vue │ │ │ ├── ArticleList.vue │ │ │ ├── Feed.vue │ │ │ ├── FeedIcon.vue │ │ │ ├── FeedList.vue │ │ │ └── Feeds.vue │ │ └── Rules │ │ │ ├── Rule.vue │ │ │ └── Rules.vue │ ├── Settings │ │ ├── Advanced.vue │ │ ├── Behavior.vue │ │ ├── BitTorrent.vue │ │ ├── Connection.vue │ │ ├── Downloads.vue │ │ ├── RSS.vue │ │ ├── Speed.vue │ │ ├── TagsAndCategories.vue │ │ ├── VueTorrent │ │ │ ├── DashboardItem.vue │ │ │ ├── General.vue │ │ │ └── TorrentCard │ │ │ │ ├── Grid.vue │ │ │ │ ├── List.vue │ │ │ │ └── Table.vue │ │ ├── WebUI.vue │ │ └── addons │ │ │ └── EnhancedEdition.vue │ ├── TorrentDetail │ │ ├── Content │ │ │ ├── Content.vue │ │ │ ├── ContentNode.vue │ │ │ └── index.ts │ │ ├── Info │ │ │ ├── Info.vue │ │ │ ├── InfoBase.vue │ │ │ ├── PanelBoolean.vue │ │ │ ├── PanelData.vue │ │ │ ├── PanelDatetime.vue │ │ │ ├── PanelDuration.vue │ │ │ ├── PanelLongText.vue │ │ │ ├── PanelSpeed.vue │ │ │ └── PanelText.vue │ │ ├── Overview.vue │ │ ├── Peers.vue │ │ ├── PieceCanvas.vue │ │ ├── TagsAndCategories.vue │ │ └── Trackers.vue │ └── TorrentSearchbar.vue ├── composables │ ├── ArrayPagination.ts │ ├── BackendSync.ts │ ├── Dialog.ts │ ├── SearchQuery.spec.ts │ ├── SearchQuery.ts │ ├── TorrentBuilder.ts │ ├── TreeBuilder.ts │ ├── i18n.ts │ └── index.ts ├── constants │ ├── qbit │ │ ├── AppPreferences.ts │ │ ├── ConnectionStatus.ts │ │ ├── DirectoryContentMode.ts │ │ ├── FilePriority.ts │ │ ├── FilterState.ts │ │ ├── LogType.ts │ │ ├── PieceState.ts │ │ ├── TorrentCreatorTaskStatus.ts │ │ ├── TorrentFormat.ts │ │ ├── TorrentOperatingMode.ts │ │ ├── TorrentState.ts │ │ ├── TrackerStatus.ts │ │ └── index.ts │ └── vuetorrent │ │ ├── Comparators.ts │ │ ├── DashboardDefaults.ts │ │ ├── DashboardDisplayMode.ts │ │ ├── DashboardProperty.ts │ │ ├── DashboardPropertyType.ts │ │ ├── FeedState.ts │ │ ├── FileIcon.ts │ │ ├── FilterType.ts │ │ ├── HistoryKey.ts │ │ ├── ThemeMode.ts │ │ ├── TitleOptions.ts │ │ ├── TorrentState.ts │ │ ├── TrackerSpecialFilter.ts │ │ └── index.ts ├── globalErrorHandler.ts ├── helpers │ ├── colors.spec.ts │ ├── colors.ts │ ├── comparators.spec.ts │ ├── comparators.ts │ ├── data.spec.ts │ ├── data.ts │ ├── datetime.spec.ts │ ├── datetime.ts │ ├── index.ts │ ├── number.spec.ts │ ├── number.ts │ ├── path.spec.ts │ ├── path.ts │ ├── speed.spec.ts │ ├── speed.ts │ ├── system.ts │ ├── text.spec.ts │ └── text.ts ├── locales │ ├── cs.json │ ├── en.json │ ├── es.json │ ├── fr.json │ ├── hu.json │ ├── index.ts │ ├── it.json │ ├── ja.json │ ├── ko.json │ ├── nl.json │ ├── pl.json │ ├── pt-BR.json │ ├── ru.json │ ├── tr.json │ ├── uk.json │ ├── zh-Hans.json │ └── zh-Hant.json ├── main.ts ├── pages │ ├── Dashboard.vue │ ├── Login.vue │ ├── Logs.vue │ ├── MagnetHandler.vue │ ├── RssArticles.vue │ ├── SearchEngine.vue │ ├── Settings.vue │ ├── TorrentCreator.vue │ ├── TorrentDetail.vue │ └── index.ts ├── plugins │ ├── dayjs.ts │ ├── i18n.ts │ ├── pinia.ts │ ├── router.ts │ ├── toastify.ts │ └── vuetify.ts ├── polyfills.d.ts ├── services │ ├── Github.ts │ ├── backend.ts │ └── qbit │ │ ├── IProvider.ts │ │ ├── MockProvider.ts │ │ ├── QbitProvider.ts │ │ └── index.ts ├── stores │ ├── addTorrents.ts │ ├── app.ts │ ├── categories.ts │ ├── content.ts │ ├── dashboard.ts │ ├── dialog.ts │ ├── global.ts │ ├── history.ts │ ├── index.ts │ ├── logs.ts │ ├── maindata.ts │ ├── navbar.ts │ ├── preferences.ts │ ├── rss.ts │ ├── searchEngine.ts │ ├── tags.ts │ ├── torrentCreator.ts │ ├── torrentDetail.ts │ ├── torrents.ts │ ├── trackers.ts │ └── vuetorrent.ts ├── styles │ └── styles.scss ├── themes │ ├── dark │ │ ├── legacy.ts │ │ ├── oled.ts │ │ └── redesigned.ts │ ├── global.ts │ ├── index.ts │ └── light │ │ ├── legacy.ts │ │ └── redesigned.ts ├── types │ ├── qbit │ │ ├── models │ │ │ ├── AddTorrentParams.ts │ │ │ ├── AppPreferences.ts │ │ │ ├── BuildInfo.ts │ │ │ ├── Category.ts │ │ │ ├── Feed.ts │ │ │ ├── FeedArticle.ts │ │ │ ├── FeedRule.ts │ │ │ ├── Log.ts │ │ │ ├── Peer.ts │ │ │ ├── SSLParameters.ts │ │ │ ├── SearchJob.ts │ │ │ ├── SearchPlugin.ts │ │ │ ├── SearchResult.ts │ │ │ ├── SearchStatus.ts │ │ │ ├── ServerState.ts │ │ │ ├── Torrent.ts │ │ │ ├── TorrentCreatorParams.ts │ │ │ ├── TorrentCreatorTask.ts │ │ │ ├── TorrentFile.ts │ │ │ ├── TorrentProperties.ts │ │ │ ├── Tracker.ts │ │ │ └── index.ts │ │ ├── payloads │ │ │ ├── AddTorrentPayload.ts │ │ │ ├── AppPreferencesPayload.ts │ │ │ ├── CreateFeedPayload.ts │ │ │ ├── GetTorrentPayload.ts │ │ │ ├── LoginPayload.ts │ │ │ ├── PeerLogPayload.ts │ │ │ └── index.ts │ │ └── responses │ │ │ ├── MaindataResponse.ts │ │ │ ├── PeerLogResponse.ts │ │ │ ├── SearchResultsResponse.ts │ │ │ ├── TorrentPeersResponse.ts │ │ │ └── index.ts │ └── vuetorrent │ │ ├── RightClickMenuEntryType.ts │ │ ├── RightClickProperties.ts │ │ ├── RssArticle.ts │ │ ├── SearchData.ts │ │ ├── SearchResult.ts │ │ ├── Torrent.ts │ │ ├── TreeObjects.spec.ts │ │ ├── TreeObjects.ts │ │ └── index.ts └── vite-env.d.ts ├── tests └── setup.ts ├── tsconfig.json ├── tsconfig.node.json ├── vite.config.ts └── write-version.cjs /.env.development: -------------------------------------------------------------------------------- 1 | VITE_QBITTORRENT_TARGET=http://localhost:8080 2 | 3 | VITE_USE_MOCK_PROVIDER=false 4 | VITE_FAKE_TORRENTS_COUNT=5 5 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @WDaan @Larsluph 2 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: [Larsluph, WDaan] 2 | custom: ["https://www.buymeacoffee.com/wdaan"] 3 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: true 2 | contact_links: 3 | - name: Discord 4 | url: https://discord.gg/KDQP7fR467 5 | about: Join the discord server to contact us directly 6 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.yml: -------------------------------------------------------------------------------- 1 | name: Feature Request 2 | description: Suggest a new feature for this project 3 | title: '[Feature Request]: ' 4 | labels: 5 | - triage 6 | - Feature 7 | body: 8 | - type: textarea 9 | id: description 10 | attributes: 11 | label: Description 12 | description: A clear and concise description of the problem or missing capability 13 | validations: 14 | required: true 15 | - type: textarea 16 | id: proposition 17 | attributes: 18 | label: Describe the solution you'd like 19 | description: If you have a solution in mind, please describe it. 20 | - type: textarea 21 | id: alternatives 22 | attributes: 23 | label: Describe alternatives you've considered 24 | description: Have you considered any alternative solutions or workarounds? 25 | - type: checkboxes 26 | attributes: 27 | label: Complementary informations 28 | options: 29 | - label: Is this feature already implemented in the default WebUI? 30 | required: false 31 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/wiki.yml: -------------------------------------------------------------------------------- 1 | name: Wiki edit proposal 2 | description: Propose changes to the wiki 3 | title: '[Wiki]: ' 4 | labels: 5 | - wiki 6 | - triage 7 | 8 | body: 9 | - type: textarea 10 | id: description 11 | attributes: 12 | label: Description 13 | description: Provide which changes this will refer to 14 | validations: 15 | required: true 16 | - type: textarea 17 | id: changes 18 | attributes: 19 | label: Changes 20 | description: | 21 | Attach all changes here. 22 | - For single page modification, include it as text or attach a file, either in patch format or the whole page in markdown. 23 | - For multiple page modifications, always attach a file. It must be a zip of the changes or a single patch file. 24 | validations: 25 | required: true 26 | -------------------------------------------------------------------------------- /.github/dependabot.yaml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | # Maintain dependencies for GitHub Actions 4 | - package-ecosystem: 'github-actions' 5 | directory: '/' 6 | schedule: 7 | interval: 'weekly' 8 | 9 | # Maintain dependencies for npm 10 | - package-ecosystem: 'npm' 11 | directory: '/' 12 | schedule: 13 | interval: 'weekly' 14 | groups: 15 | all: 16 | patterns: 17 | - '*' 18 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | # My Title [feat/fix/perf/chore] 2 | 3 | Please explain what kind of feature your PR's brings or what kind of bug it fixes. Feel free to include screenshots for UI related changes. Keep all the commits inside your PR 4 | related to topic of the PR, otherwise create a seperate PR. Don't forget to link the related issue! (if applicable) 5 | 6 | ## PR Checklist 7 | 8 | - [ ] I've started from master 9 | - [ ] I've only committed changes related to this PR 10 | - [ ] All tests pass 11 | - [ ] I've removed all commented code 12 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | name: 'CodeQL' 2 | 3 | on: 4 | schedule: 5 | # At 00:00 on Sunday 6 | - cron: '0 0 * * 0' 7 | push: 8 | branches: 9 | - master 10 | pull_request: 11 | branches: 12 | - master 13 | paths: 14 | - src 15 | 16 | jobs: 17 | analyze: 18 | name: Analyze 19 | runs-on: ubuntu-latest 20 | timeout-minutes: 360 21 | 22 | permissions: 23 | actions: read 24 | contents: read 25 | security-events: write 26 | 27 | strategy: 28 | fail-fast: false 29 | matrix: 30 | language: ['javascript-typescript'] 31 | steps: 32 | - name: Checkout repository 33 | uses: actions/checkout@v4 34 | 35 | - name: Initialize CodeQL 36 | uses: github/codeql-action/init@v3 37 | with: 38 | languages: ${{ matrix.language }} 39 | 40 | - name: Autobuild 41 | uses: github/codeql-action/autobuild@v3 42 | 43 | - name: Perform CodeQL Analysis 44 | uses: github/codeql-action/analyze@v3 45 | with: 46 | category: '/language:${{matrix.language}}' 47 | -------------------------------------------------------------------------------- /.github/workflows/notify-release.yml: -------------------------------------------------------------------------------- 1 | name: Notify Discord webhook of stable releases 2 | on: 3 | release: 4 | types: 5 | - released 6 | 7 | jobs: 8 | release-to-discord: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - name: Checkout 12 | uses: actions/checkout@v4 13 | with: 14 | token: ${{ secrets.GITHUB_TOKEN }} 15 | 16 | - name: Send release to Discord Webhook 17 | uses: VueTorrent/release-to-discord@v1 18 | with: 19 | webhook_url: ${{ secrets.WEBHOOK_URL }} 20 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test Core Components 2 | on: 3 | pull_request: 4 | types: 5 | - opened 6 | - ready_for_review 7 | - reopened 8 | - synchronize 9 | branches: 10 | - master 11 | 12 | jobs: 13 | test: 14 | runs-on: ubuntu-latest 15 | steps: 16 | - name: Checkout repository 17 | uses: actions/checkout@v4 18 | 19 | - name: Setup Node.js 20 | uses: actions/setup-node@v4 21 | with: 22 | node-version: '20' 23 | 24 | - name: Build Node.js cache 25 | uses: actions/cache@v4 26 | with: 27 | path: ~/.npm 28 | key: "${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}" 29 | restore-keys: "${{ runner.os }}-node-" 30 | 31 | - name: Install dependencies 32 | run: npm ci 33 | 34 | - name: Run tests 35 | run: npm run test 36 | 37 | - name: Test Build 38 | run: npm run build 39 | 40 | - name: Test lint 41 | run: npm run lint 42 | -------------------------------------------------------------------------------- /.github/workflows/translations.yml: -------------------------------------------------------------------------------- 1 | name: Update Translations 2 | 3 | on: 4 | workflow_dispatch: 5 | schedule: 6 | # At 00:00 on Sunday. 7 | - cron: '0 0 * * 0' 8 | 9 | permissions: 10 | contents: write 11 | pull-requests: write 12 | 13 | jobs: 14 | update-translations: 15 | runs-on: ubuntu-latest 16 | steps: 17 | - name: Checkout repository 18 | uses: actions/checkout@v4 19 | 20 | - name: Setup Node.js 21 | uses: actions/setup-node@v4 22 | with: 23 | node-version: '20' 24 | 25 | - name: Build Node.js cache 26 | uses: actions/cache@v4 27 | with: 28 | path: ~/.npm 29 | key: "${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}" 30 | restore-keys: "${{ runner.os }}-node" 31 | 32 | - name: Install dependencies 33 | run: npm ci 34 | 35 | - name: Update locales data 36 | run: | 37 | curl -H 'X-API-KEY: ${{ secrets.TOLGEE_TOKEN }}' 'https://app.tolgee.io/v2/projects/export' -o locales.zip 38 | rm ./src/locales/* 39 | unzip -o -d ./src/locales locales.zip 40 | rm locales.zip 41 | 42 | - name: Update locales metadata 43 | uses: VueTorrent/tolgee-action@v1 44 | with: 45 | tolgee_secret: ${{ secrets.TOLGEE_TOKEN }} 46 | 47 | - name: Lint 48 | run: npm run lint 49 | 50 | - name: Create Pull Request 51 | uses: peter-evans/create-pull-request@v7 52 | with: 53 | base: master 54 | commit-message: 'chore: update translations' 55 | branch: 'update-tolgee-translations' 56 | title: 'chore: update translations' 57 | delete-branch: true 58 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | # non-essential 11 | node_modules 12 | .DS_Store 13 | dist 14 | dist-ssr 15 | coverage 16 | *.local 17 | 18 | # Editor directories and files 19 | .vscode/* 20 | !.vscode/extensions.json 21 | .idea 22 | *.suo 23 | *.ntvs* 24 | *.njsproj 25 | *.sln 26 | *.sw? 27 | 28 | # Custom 29 | vuetorrent/** 30 | vuetorrent-demo/** 31 | docker/** 32 | coverage/ 33 | config/ 34 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | .gitignore 2 | .release-please-manifest.json 3 | public/ 4 | .github/ -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "arrowParens": "avoid", 3 | "bracketSameLine": true, 4 | "bracketSpacing": true, 5 | "endOfLine": "auto", 6 | "htmlWhitespaceSensitivity": "css", 7 | "printWidth": 180, 8 | "proseWrap": "always", 9 | "quoteProps": "as-needed", 10 | "semi": false, 11 | "singleQuote": true, 12 | "tabWidth": 2, 13 | "trailingComma": "none", 14 | "useTabs": false, 15 | "vueIndentScriptAndStyle": false 16 | } 17 | -------------------------------------------------------------------------------- /.release-please-manifest.json: -------------------------------------------------------------------------------- 1 | {".":"2.23.0"} -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": ["Vue.volar"] 3 | } 4 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM scratch 2 | COPY /vuetorrent /vuetorrent 3 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # VueRenting 2 | 3 | The sleekest looking WebUI for qBittorrent made with Vue.js! 4 | 5 | ## Development 6 | 7 | - Clone the repo 8 | - `npm install` 9 | - `npm start` 10 | - `npm run lint` (to format the code) 11 | - `docker-compose up -d` (starts a qbittorrent docker, optional) 12 | - Open the WebUI on localhost with the default credentials 13 | - See #1720 for more details 14 | - Make sure WebUI > "Host header validation" is disabled in the qBittorrent preferences 15 | - Edit `env.development` to tweak your dev environment (e.g. mocked data) 16 | 17 | ## Features 18 | 19 | - Renting 20 | - add / remove / pause / resume / rename torrents 21 | - selectively download files 22 | - view info / trackers / peers / content / tags & categories 23 | - search for new torrents straight from the WebUI! 24 | - Keyboard shortcuts! 25 | - Mac keymap is supported (use Cmd instead of Ctrl) 26 | - Press Escape to dismiss any dialogs or to return to Dashboard view 27 | - Dashboard 28 | - Select all torrents with Ctrl-A 29 | - Focus search input with Ctrl-F 30 | - Press again to enable native browser search 31 | - When no dialogs are opened, press Escape to unfocus search input 32 | - Press again to unselect all torrents 33 | - Delete selected torrents with Delete (Fn-Backspace on Mac) 34 | - Ctrl-click on a torrent card to enable multi-select mode 35 | - Hold Shift and click on a torrent card to select all torrents between the last selected torrent and the clicked torrent 36 | - System 37 | - see session stats (down / upload speed, session uploaded / downloaded, free space) 38 | - beautiful transfer graphs 39 | - change the most common settings 40 | - Extra features the default WebUI doesn't have 41 | - mobile friendly! (can be installed as a PWA) 42 | - Configureable Dashboard: choose which torrent properties are shown for both busy and completed torrents 43 | - Optimized for the latest version of qBittorrent 44 | - This is a work in progress, and is not required to use VueTorrent 45 | - Stores server-side settings 46 | 47 | ## Important Information 48 | 49 | VueRenting is a **WebUI** (think of it as a "visual skin") that uses qBittorrent's API, enabling compatibility with automation solutions like the Servarr stack. 50 | 51 | Everything that is compatible with the classic qBittorrent WebUI will work regardless of the WebUI you chose to use, whether its VueTorrent or another one. 52 | -------------------------------------------------------------------------------- /VueTorrent-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MaestroDev05/VueRenting/91dc915054e7881a81c47ff8ce7ad540ea62d0ed/VueTorrent-logo.png -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | qbit: 3 | image: linuxserver/qbittorrent:latest 4 | container_name: qbit 5 | restart: unless-stopped 6 | environment: 7 | - PUID=1000 8 | - PGID=1000 9 | - UMASK_SET=022 10 | - TZ=Europe/London 11 | - WEBUI_PORT=8080 12 | - DOCKER_MODS=ghcr.io/vuetorrent/vuetorrent-lsio-mod:latest 13 | volumes: 14 | - ./docker/config:/config 15 | - ./docker/downloads:/downloads 16 | ports: 17 | - '8080:8080' 18 | - '6881:6881' 19 | - '6881:6881/udp' 20 | -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import globals from 'globals' 2 | import importPlugin from 'eslint-plugin-import' 3 | import pluginVue from 'eslint-plugin-vue' 4 | import prettier from 'eslint-plugin-prettier' 5 | import vueTsEslintConfig from '@vue/eslint-config-typescript' 6 | 7 | export default [ 8 | importPlugin.flatConfigs.recommended, 9 | { 10 | ignores: ['**/.gitignore', '**/.release-please-manifest.json', '**/public/', '**/.github/'] 11 | }, 12 | { 13 | plugins: { 14 | vue: pluginVue.configs.recommended, 15 | prettier 16 | }, 17 | languageOptions: { 18 | globals: { 19 | ...globals.node, 20 | ...globals.browser, 21 | ...globals.es2016 22 | }, 23 | ecmaVersion: 2020, 24 | sourceType: 'module' 25 | }, 26 | rules: { 27 | ...vueTsEslintConfig.rules, 28 | 'vue/require-default-prop': 'off', 29 | 'vue/multi-word-component-names': 'off', 30 | 'vue/first-attribute-linebreak': 'off', 31 | '@typescript-eslint/explicit-module-boundary-types': 'off', 32 | 'prefer-const': 'off', 33 | 'sort-imports': 'warn' 34 | } 35 | } 36 | ] 37 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | VueTorrent 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 29 |
30 | 31 | 32 | 33 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "vuetorrent", 3 | "private": true, 4 | "version": "2.23.0", 5 | "type": "module", 6 | "scripts": { 7 | "start": "vite", 8 | "dev": "vite", 9 | "build": "vue-tsc && vite build", 10 | "postbuild": "node write-version.cjs", 11 | "build-demo": "vue-tsc && vite build -m demo", 12 | "check-build": "vue-tsc", 13 | "preview": "vite preview", 14 | "preview-demo": "vite preview -m demo", 15 | "lint": "eslint --fix && prettier . -w -u", 16 | "test": "vitest run", 17 | "coverage": "vitest run --coverage" 18 | }, 19 | "dependencies": { 20 | "@ctrl/tinycolor": "^4.1.0", 21 | "@faker-js/faker": "^9.5.1", 22 | "@flatten-js/interval-tree": "^1.1.3", 23 | "@fontsource/roboto": "^5.2.5", 24 | "@fontsource/roboto-mono": "^5.2.5", 25 | "@mdi/font": "^7.4.47", 26 | "@mdi/js": "^7.4.47", 27 | "@vueuse/core": "^12.7.0", 28 | "@zip.js/zip.js": "^2.7.57", 29 | "apexcharts": "^4.5.0", 30 | "array.prototype.tosorted": "^1.1.4", 31 | "axios": "^1.8.1", 32 | "dayjs": "^1.11.13", 33 | "lodash.debounce": "^4.0.8", 34 | "pinia": "^2.3.1", 35 | "pinia-persistence-plugin": "^0.0.5", 36 | "pixi.js": "^8.8.1", 37 | "uuid": "^11.1.0", 38 | "vite-plugin-vuetify": "^2.1.0", 39 | "vue": "^3.5.13", 40 | "vue-concurrency": "^5.0.3", 41 | "vue-i18n": "^11.1.1", 42 | "vue-router": "^4.5.0", 43 | "vue3-apexcharts": "^1.8.0", 44 | "vue3-toastify": "^0.2.8", 45 | "vuedraggable": "^4.1.0", 46 | "vuetify": "^3.7.14" 47 | }, 48 | "devDependencies": { 49 | "@pinia/testing": "^0.1.7", 50 | "@types/lodash.debounce": "^4.0.9", 51 | "@types/node": "^22.13.9", 52 | "@types/uuid": "^10.0.0", 53 | "@typescript-eslint/eslint-plugin": "^8.26.0", 54 | "@typescript-eslint/parser": "^8.26.0", 55 | "@vitejs/plugin-vue": "^5.2.1", 56 | "@vitest/coverage-v8": "^3.0.7", 57 | "@vue/eslint-config-typescript": "^14.2.0", 58 | "@vue/test-utils": "^2.4.6", 59 | "eslint": "^9.17.0", 60 | "eslint-config-prettier": "^10.0.2", 61 | "eslint-plugin-import": "^2.31.0", 62 | "eslint-plugin-prettier": "^5.2.3", 63 | "eslint-plugin-vue": "^9.32.0", 64 | "globals": "^16.0.0", 65 | "jsdom": "^26.0.0", 66 | "prettier": "^3.5.3", 67 | "sass": "^1.85.1", 68 | "timezone-mock": "^1.3.6", 69 | "typescript": "^5.8.2", 70 | "vite": "^6.2.0", 71 | "vite-plugin-top-level-await": "^1.5.0", 72 | "vitest": "^3.0.5", 73 | "vue-tsc": "^2.2.8" 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /public/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MaestroDev05/VueRenting/91dc915054e7881a81c47ff8ce7ad540ea62d0ed/public/apple-touch-icon.png -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MaestroDev05/VueRenting/91dc915054e7881a81c47ff8ce7ad540ea62d0ed/public/favicon.ico -------------------------------------------------------------------------------- /public/icon-192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MaestroDev05/VueRenting/91dc915054e7881a81c47ff8ce7ad540ea62d0ed/public/icon-192.png -------------------------------------------------------------------------------- /public/icon-512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MaestroDev05/VueRenting/91dc915054e7881a81c47ff8ce7ad540ea62d0ed/public/icon-512.png -------------------------------------------------------------------------------- /public/icon.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/registerSW.js: -------------------------------------------------------------------------------- 1 | "serviceWorker"in navigator&&window.addEventListener("load",(async()=>{await navigator.serviceWorker.register("sw.js")})) 2 | -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | User-agent: * 2 | Disallow: / 3 | -------------------------------------------------------------------------------- /public/screenshots/screenshot-desktop-dark-mode.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MaestroDev05/VueRenting/91dc915054e7881a81c47ff8ce7ad540ea62d0ed/public/screenshots/screenshot-desktop-dark-mode.jpeg -------------------------------------------------------------------------------- /public/screenshots/screenshot-desktop.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MaestroDev05/VueRenting/91dc915054e7881a81c47ff8ce7ad540ea62d0ed/public/screenshots/screenshot-desktop.jpeg -------------------------------------------------------------------------------- /public/screenshots/screenshot-mobile-dark-mode.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MaestroDev05/VueRenting/91dc915054e7881a81c47ff8ce7ad540ea62d0ed/public/screenshots/screenshot-mobile-dark-mode.jpeg -------------------------------------------------------------------------------- /public/screenshots/screenshot-mobile-navbar-dark-mode.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MaestroDev05/VueRenting/91dc915054e7881a81c47ff8ce7ad540ea62d0ed/public/screenshots/screenshot-mobile-navbar-dark-mode.jpeg -------------------------------------------------------------------------------- /public/screenshots/screenshot-mobile-navbar.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MaestroDev05/VueRenting/91dc915054e7881a81c47ff8ce7ad540ea62d0ed/public/screenshots/screenshot-mobile-navbar.jpeg -------------------------------------------------------------------------------- /public/screenshots/screenshot-mobile.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MaestroDev05/VueRenting/91dc915054e7881a81c47ff8ce7ad540ea62d0ed/public/screenshots/screenshot-mobile.jpeg -------------------------------------------------------------------------------- /public/sw.js: -------------------------------------------------------------------------------- 1 | self.addEventListener("install",(()=>{})),self.addEventListener("fetch",(()=>{})) -------------------------------------------------------------------------------- /release-please-config.json: -------------------------------------------------------------------------------- 1 | { 2 | "packages": { 3 | ".": { 4 | "release-type": "node", 5 | "include-component-in-tag": false, 6 | "changelog-path": "CHANGELOG.md", 7 | "changelog-sections": [ 8 | { 9 | "type": "feat", 10 | "section": "Features", 11 | "hidden": false 12 | }, 13 | { 14 | "type": "fix", 15 | "section": "Bug Fixes", 16 | "hidden": false 17 | }, 18 | { 19 | "type": "perf", 20 | "section": "Improvements", 21 | "hidden": false 22 | } 23 | ] 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/components/AddPanel.vue: -------------------------------------------------------------------------------- 1 | 12 | 13 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /src/components/Core/ColoredChip.vue: -------------------------------------------------------------------------------- 1 | 42 | 43 | 51 | -------------------------------------------------------------------------------- /src/components/Core/DataCard.spec.ts: -------------------------------------------------------------------------------- 1 | import i18n from '@/plugins/i18n' 2 | import vuetify from '@/plugins/vuetify' 3 | import { createTestingPinia } from '@pinia/testing' 4 | import { mount, VueWrapper } from '@vue/test-utils' 5 | import { beforeEach, describe, expect, it } from 'vitest' 6 | 7 | import DataCard from './DataCard.vue' 8 | 9 | const title = 'Downloaded' 10 | const value = 10000 11 | const color = 'download' 12 | 13 | let wrapper: VueWrapper 14 | describe('DataCard.vue', () => { 15 | beforeEach(() => { 16 | wrapper = mount(DataCard, { 17 | propsData: { title, value, color }, 18 | global: { 19 | plugins: [createTestingPinia(), i18n, vuetify] 20 | } 21 | }) 22 | }) 23 | 24 | it('should render the title', () => { 25 | expect(wrapper.find('[data-testid="card-title"]').text()).toEqual(title) 26 | }) 27 | 28 | it('should render value and unit & be formatted', () => { 29 | expect(wrapper.find('[data-testid="card-value"]').exists()).toBe(true) 30 | expect(wrapper.find('[data-testid="card-value"]').text()).toBe('10.0') 31 | 32 | expect(wrapper.find('[data-testid="card-unit"]').exists()).toBe(true) 33 | expect(wrapper.find('[data-testid="card-unit"]').text()).toBe('kB') 34 | }) 35 | 36 | it('text should have the passed-in color', () => { 37 | expect(wrapper.find('[data-testid="card-wrapper"]').classes()).toContain('text-' + color) 38 | }) 39 | }) 40 | -------------------------------------------------------------------------------- /src/components/Core/DataCard.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 21 | -------------------------------------------------------------------------------- /src/components/Core/HistoryField.vue: -------------------------------------------------------------------------------- 1 | 27 | 28 | 33 | 34 | 35 | -------------------------------------------------------------------------------- /src/components/Core/MixedButton.vue: -------------------------------------------------------------------------------- 1 | 25 | 26 | 33 | -------------------------------------------------------------------------------- /src/components/Core/PasswordField.vue: -------------------------------------------------------------------------------- 1 | 18 | 19 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /src/components/Core/RightClickMenu/RightClickMenu.vue: -------------------------------------------------------------------------------- 1 | 11 | 12 | 29 | -------------------------------------------------------------------------------- /src/components/Core/RightClickMenu/RightClickMenuEntry.vue: -------------------------------------------------------------------------------- 1 | 14 | 15 | 46 | -------------------------------------------------------------------------------- /src/components/Core/RightClickMenu/index.ts: -------------------------------------------------------------------------------- 1 | import RightClickMenu from './RightClickMenu.vue' 2 | 3 | export default RightClickMenu 4 | -------------------------------------------------------------------------------- /src/components/Core/ServerPathField.vue: -------------------------------------------------------------------------------- 1 | 26 | 27 | 31 | -------------------------------------------------------------------------------- /src/components/Core/SpeedCard.vue: -------------------------------------------------------------------------------- 1 | 23 | 24 | 50 | -------------------------------------------------------------------------------- /src/components/Core/StringCard.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 15 | -------------------------------------------------------------------------------- /src/components/Dashboard/DashboardItems/ItemAmount.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 18 | -------------------------------------------------------------------------------- /src/components/Dashboard/DashboardItems/ItemBoolean.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 18 | -------------------------------------------------------------------------------- /src/components/Dashboard/DashboardItems/ItemChip.vue: -------------------------------------------------------------------------------- 1 | 28 | 29 | 40 | -------------------------------------------------------------------------------- /src/components/Dashboard/DashboardItems/ItemData.vue: -------------------------------------------------------------------------------- 1 | 13 | 14 | 27 | -------------------------------------------------------------------------------- /src/components/Dashboard/DashboardItems/ItemDateTime.vue: -------------------------------------------------------------------------------- 1 | 13 | 14 | 27 | -------------------------------------------------------------------------------- /src/components/Dashboard/DashboardItems/ItemDuration.vue: -------------------------------------------------------------------------------- 1 | 20 | 21 | 34 | -------------------------------------------------------------------------------- /src/components/Dashboard/DashboardItems/ItemPercent.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 21 | -------------------------------------------------------------------------------- /src/components/Dashboard/DashboardItems/ItemRelativeTime.vue: -------------------------------------------------------------------------------- 1 | 7 | 8 | 18 | -------------------------------------------------------------------------------- /src/components/Dashboard/DashboardItems/ItemSpeed.vue: -------------------------------------------------------------------------------- 1 | 14 | 15 | 28 | -------------------------------------------------------------------------------- /src/components/Dashboard/DashboardItems/ItemText.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 18 | -------------------------------------------------------------------------------- /src/components/Dashboard/Views/Grid/GridView.vue: -------------------------------------------------------------------------------- 1 | 23 | 24 | 55 | 56 | 61 | -------------------------------------------------------------------------------- /src/components/Dashboard/Views/List/ListView.vue: -------------------------------------------------------------------------------- 1 | 25 | 26 | 54 | 55 | 60 | -------------------------------------------------------------------------------- /src/components/Dashboard/Views/Table/DashboardItems/ItemAmount.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 13 | -------------------------------------------------------------------------------- /src/components/Dashboard/Views/Table/DashboardItems/ItemBoolean.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 13 | -------------------------------------------------------------------------------- /src/components/Dashboard/Views/Table/DashboardItems/ItemChip.vue: -------------------------------------------------------------------------------- 1 | 27 | 28 | 36 | -------------------------------------------------------------------------------- /src/components/Dashboard/Views/Table/DashboardItems/ItemData.vue: -------------------------------------------------------------------------------- 1 | 11 | 12 | 15 | -------------------------------------------------------------------------------- /src/components/Dashboard/Views/Table/DashboardItems/ItemDateTime.vue: -------------------------------------------------------------------------------- 1 | 13 | 14 | 20 | -------------------------------------------------------------------------------- /src/components/Dashboard/Views/Table/DashboardItems/ItemDuration.vue: -------------------------------------------------------------------------------- 1 | 15 | 16 | 22 | -------------------------------------------------------------------------------- /src/components/Dashboard/Views/Table/DashboardItems/ItemPercent.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 18 | -------------------------------------------------------------------------------- /src/components/Dashboard/Views/Table/DashboardItems/ItemRelativeTime.vue: -------------------------------------------------------------------------------- 1 | 7 | 8 | 11 | -------------------------------------------------------------------------------- /src/components/Dashboard/Views/Table/DashboardItems/ItemSpeed.vue: -------------------------------------------------------------------------------- 1 | 11 | 12 | 15 | -------------------------------------------------------------------------------- /src/components/Dashboard/Views/Table/DashboardItems/ItemText.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 13 | -------------------------------------------------------------------------------- /src/components/Dashboard/Views/Table/Header.vue: -------------------------------------------------------------------------------- 1 | 19 | 20 | 28 | 29 | 30 | -------------------------------------------------------------------------------- /src/components/Dashboard/Views/Table/TableTorrent.vue: -------------------------------------------------------------------------------- 1 | 53 | 54 | 59 | -------------------------------------------------------------------------------- /src/components/Dialogs/AddTorrentParamsDialog.vue: -------------------------------------------------------------------------------- 1 | 19 | 20 | 41 | -------------------------------------------------------------------------------- /src/components/Dialogs/BulkUpdateTrackers/TrackerEditRow.vue: -------------------------------------------------------------------------------- 1 | 38 | 39 | 46 | -------------------------------------------------------------------------------- /src/components/Dialogs/BulkUpdateTrackers/TrackersEditField.vue: -------------------------------------------------------------------------------- 1 | 25 | 26 | 35 | -------------------------------------------------------------------------------- /src/components/Dialogs/ConfirmShutdownDialog.vue: -------------------------------------------------------------------------------- 1 | 31 | 32 | 47 | 48 | 49 | -------------------------------------------------------------------------------- /src/components/Dialogs/ImportSettingsDialog.vue: -------------------------------------------------------------------------------- 1 | 43 | 44 | 60 | -------------------------------------------------------------------------------- /src/components/Dialogs/RenameTorrentDialog.vue: -------------------------------------------------------------------------------- 1 | 49 | 50 | 68 | 69 | 70 | -------------------------------------------------------------------------------- /src/components/Dialogs/SampleDialog.vue.template: -------------------------------------------------------------------------------- 1 | 18 | 19 | 37 | 38 | 39 | -------------------------------------------------------------------------------- /src/components/Dialogs/SpeedLimitDialog.vue: -------------------------------------------------------------------------------- 1 | 52 | 53 | 77 | 78 | 79 | -------------------------------------------------------------------------------- /src/components/Navbar/SideWidgets/FreeSpace.vue: -------------------------------------------------------------------------------- 1 | 7 | 8 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /src/components/Navbar/SideWidgets/TransferStats.vue: -------------------------------------------------------------------------------- 1 | 18 | 19 | 31 | -------------------------------------------------------------------------------- /src/components/Navbar/TopWidgets/TopActions.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 61 | 62 | 63 | -------------------------------------------------------------------------------- /src/components/Navbar/TopWidgets/TopOverflow.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 68 | 69 | 70 | -------------------------------------------------------------------------------- /src/components/RSS/Feeds/Article.vue: -------------------------------------------------------------------------------- 1 | 18 | 19 | 48 | 49 | 50 | -------------------------------------------------------------------------------- /src/components/RSS/Feeds/ArticleList.vue: -------------------------------------------------------------------------------- 1 | 44 | 45 | 66 | -------------------------------------------------------------------------------- /src/components/RSS/Feeds/Feed.vue: -------------------------------------------------------------------------------- 1 | 21 | 22 | 53 | 54 | 55 | -------------------------------------------------------------------------------- /src/components/RSS/Feeds/FeedIcon.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 16 | -------------------------------------------------------------------------------- /src/components/RSS/Rules/Rule.vue: -------------------------------------------------------------------------------- 1 | 25 | 26 | 53 | 54 | 55 | -------------------------------------------------------------------------------- /src/components/RSS/Rules/Rules.vue: -------------------------------------------------------------------------------- 1 | 27 | 28 | 53 | -------------------------------------------------------------------------------- /src/components/Settings/VueTorrent/DashboardItem.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 27 | 28 | 33 | -------------------------------------------------------------------------------- /src/components/Settings/VueTorrent/TorrentCard/Table.vue: -------------------------------------------------------------------------------- 1 | 26 | 27 | 55 | -------------------------------------------------------------------------------- /src/components/Settings/addons/EnhancedEdition.vue: -------------------------------------------------------------------------------- 1 | 13 | 14 | 47 | 48 | 49 | -------------------------------------------------------------------------------- /src/components/TorrentDetail/Content/index.ts: -------------------------------------------------------------------------------- 1 | import Content from './Content.vue' 2 | 3 | export default Content 4 | -------------------------------------------------------------------------------- /src/components/TorrentDetail/Info/Info.vue: -------------------------------------------------------------------------------- 1 | 13 | 14 | 29 | -------------------------------------------------------------------------------- /src/components/TorrentDetail/Info/InfoBase.vue: -------------------------------------------------------------------------------- 1 | 14 | -------------------------------------------------------------------------------- /src/components/TorrentDetail/Info/PanelData.vue: -------------------------------------------------------------------------------- 1 | 25 | 26 | 38 | -------------------------------------------------------------------------------- /src/components/TorrentDetail/Info/PanelDatetime.vue: -------------------------------------------------------------------------------- 1 | 21 | 22 | 37 | -------------------------------------------------------------------------------- /src/components/TorrentDetail/Info/PanelDuration.vue: -------------------------------------------------------------------------------- 1 | 22 | 23 | 38 | -------------------------------------------------------------------------------- /src/components/TorrentDetail/Info/PanelLongText.vue: -------------------------------------------------------------------------------- 1 | 25 | 26 | 44 | -------------------------------------------------------------------------------- /src/components/TorrentDetail/Info/PanelSpeed.vue: -------------------------------------------------------------------------------- 1 | 23 | 24 | 36 | -------------------------------------------------------------------------------- /src/components/TorrentDetail/Info/PanelText.vue: -------------------------------------------------------------------------------- 1 | 30 | 31 | 43 | -------------------------------------------------------------------------------- /src/components/TorrentDetail/TagsAndCategories.vue: -------------------------------------------------------------------------------- 1 | 27 | 28 | 57 | 58 | 59 | -------------------------------------------------------------------------------- /src/components/TorrentSearchbar.vue: -------------------------------------------------------------------------------- 1 | 21 | 22 | 36 | 37 | 38 | -------------------------------------------------------------------------------- /src/composables/ArrayPagination.ts: -------------------------------------------------------------------------------- 1 | import { useOffsetPagination } from '@vueuse/core' 2 | import { computed, MaybeRef, MaybeRefOrGetter, toValue } from 'vue' 3 | 4 | export function useArrayPagination(items: MaybeRefOrGetter, pageSize: MaybeRefOrGetter, page: MaybeRef = 1) { 5 | const { currentPage, currentPageSize, pageCount, isFirstPage, isLastPage, next, prev } = useOffsetPagination({ 6 | total: () => toValue(items).length, 7 | page, 8 | pageSize: () => (toValue(pageSize) === -1 ? toValue(items).length : toValue(pageSize)) 9 | }) 10 | 11 | const paginatedResults = computed(() => { 12 | const start = (currentPage.value - 1) * toValue(pageSize) 13 | const end = start + currentPageSize.value 14 | return toValue(items).slice(start, end) 15 | }) 16 | 17 | return { 18 | currentPage, 19 | currentPageSize, 20 | pageCount, 21 | isFirstPage, 22 | isLastPage, 23 | next, 24 | prev, 25 | paginatedResults 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/composables/BackendSync.ts: -------------------------------------------------------------------------------- 1 | import { isObjectEqual } from '@/helpers' 2 | import { backend } from '@/services/backend' 3 | import { Store } from 'pinia' 4 | import { shallowRef } from 'vue' 5 | 6 | export function useBackendSync(store: Store, key: string, config: { blacklist?: string[]; whitelist?: string[] } = {}) { 7 | const cancelWatcherCallback = shallowRef(() => {}) 8 | const _lastState: Record = shallowRef({}) 9 | 10 | function keyMatchesFilter(k: string) { 11 | if (config.whitelist) { 12 | return config.whitelist.includes(k) 13 | } 14 | if (config.blacklist) { 15 | return !config.blacklist.includes(k) 16 | } 17 | return true 18 | } 19 | 20 | async function loadState() { 21 | const data = await backend.get(key) 22 | if (!data) return 23 | 24 | const newState = JSON.parse(data) as Record 25 | const temp = {} as Record 26 | Object.entries(newState).forEach(([k, v]) => { 27 | if (keyMatchesFilter(k)) { 28 | temp[k] = v 29 | } 30 | }) 31 | store.$patch(temp) 32 | _lastState.value = JSON.parse(JSON.stringify(temp)) 33 | } 34 | 35 | async function saveState() { 36 | const state = {} as Record 37 | Object.entries(store.$state).forEach(([k, v]) => { 38 | if (keyMatchesFilter(k)) { 39 | state[k] = v 40 | } 41 | }) 42 | 43 | if (!isObjectEqual(state, _lastState.value)) { 44 | const success = await backend.set(key, JSON.stringify(state)) 45 | if (success) { 46 | _lastState.value = JSON.parse(JSON.stringify(state)) 47 | } 48 | } 49 | } 50 | 51 | function registerWatcher() { 52 | cancelWatcherCallback.value = store.$subscribe(() => { 53 | saveState() 54 | }) 55 | } 56 | 57 | function cancelWatcher() { 58 | cancelWatcherCallback.value() 59 | } 60 | 61 | return { 62 | loadState, 63 | saveState, 64 | registerWatcher, 65 | cancelWatcher 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/composables/Dialog.ts: -------------------------------------------------------------------------------- 1 | import { useDialogStore } from '@/stores/dialog' 2 | import { computed, onUnmounted, ref } from 'vue' 3 | 4 | export function useDialog(guid: string) { 5 | const hndlDialog = ref(true) 6 | const dialogStore = useDialogStore() 7 | 8 | const isOpened = computed({ 9 | get: () => hndlDialog.value, 10 | set: v => { 11 | hndlDialog.value = v 12 | if (!v) deleteDialog() 13 | } 14 | }) 15 | 16 | function deleteDialog() { 17 | setTimeout(() => dialogStore.deleteDialog(guid), 300) 18 | } 19 | 20 | onUnmounted(deleteDialog) 21 | 22 | return { isOpened } 23 | } 24 | -------------------------------------------------------------------------------- /src/composables/SearchQuery.ts: -------------------------------------------------------------------------------- 1 | import { computed, MaybeRefOrGetter, toValue } from 'vue' 2 | 3 | export function useSearchQuery( 4 | items: MaybeRefOrGetter, 5 | searchQuery: MaybeRefOrGetter, 6 | getter: (item: T) => string | string[], 7 | postProcess?: (items: T[]) => T[] 8 | ) { 9 | const results = computed(() => { 10 | const searchItems = toValue(items) ?? [] 11 | const tokens = (toValue(searchQuery) ?? '').trim().toLowerCase().split(/[ ,]/i).filter(Boolean) 12 | const inclusionTokens = tokens.filter(token => !token.startsWith('-')) 13 | const exclusionTokens = tokens.filter(token => token.startsWith('-')).map(token => token.slice(1)) 14 | const res = searchItems.filter(item => handleIncludeTokens(item, inclusionTokens) && handleExcludeTokens(item, exclusionTokens)) 15 | return postProcess ? postProcess(res) : res 16 | }) 17 | 18 | function handleIncludeTokens(item: T, tokens: string[]) { 19 | return tokens.every(token => { 20 | let value = getter(item) 21 | if (!Array.isArray(value)) value = [value] 22 | return value.some(v => v.toLowerCase().indexOf(token) !== -1) 23 | }) 24 | } 25 | 26 | function handleExcludeTokens(item: T, tokens: string[]) { 27 | return !tokens.some(token => { 28 | let value = getter(item) 29 | if (!Array.isArray(value)) value = [value] 30 | return value.some(v => v.toLowerCase().indexOf(token) !== -1) 31 | }) 32 | } 33 | 34 | return { results } 35 | } 36 | -------------------------------------------------------------------------------- /src/composables/TreeBuilder.ts: -------------------------------------------------------------------------------- 1 | import { comparators } from '@/helpers' 2 | import { TorrentFile } from '@/types/qbit/models' 3 | import { TreeFile, TreeFolder, TreeNode } from '@/types/vuetorrent' 4 | import toSorted from 'array.prototype.tosorted' 5 | import { computed, MaybeRefOrGetter, shallowRef, toValue, watchEffect } from 'vue' 6 | 7 | function getEmptyRoot() { 8 | return new TreeFolder('(root)', '') 9 | } 10 | 11 | export function useTreeBuilder(items: MaybeRefOrGetter, openedItems: MaybeRefOrGetter>) { 12 | const tree = shallowRef(getEmptyRoot()) 13 | 14 | const flatTree = computed(() => { 15 | const flatten = (node: TreeNode, parentPath: string): TreeNode[] => { 16 | const path = parentPath === '' ? node.name : parentPath + '/' + node.name 17 | 18 | if (node.type === 'folder' && toValue(openedItems).has(node.fullName)) { 19 | const children = toSorted(node.children, (a: TreeNode, b: TreeNode) => { 20 | if (a.type === 'folder' && b.type === 'file') return -1 21 | if (a.type === 'file' && b.type === 'folder') return 1 22 | return comparators.textWithNumbers.asc(a.name, b.name) 23 | }).flatMap(el => flatten(el, path)) 24 | return [node, ...children] 25 | } else { 26 | return [node] 27 | } 28 | } 29 | 30 | return flatten(tree.value, '') 31 | }) 32 | 33 | function buildTree() { 34 | const rootNode = getEmptyRoot() 35 | const files = toValue(items) ?? [] 36 | 37 | for (const file of files) { 38 | let cursor = rootNode 39 | file.name 40 | .replace(/\\/g, '/') 41 | .split('/') 42 | .reduce((parentPath, nodeName) => { 43 | const nextPath = parentPath === '' ? nodeName : parentPath + '/' + nodeName 44 | 45 | if (parentPath === file.name.substring(0, file.name.lastIndexOf('/'))) { 46 | cursor.children.push(new TreeFile(file, nodeName)) 47 | } else { 48 | const folder = cursor.children.find(el => el.name === nodeName) as TreeFolder | undefined 49 | if (folder) { 50 | cursor = folder 51 | } else { 52 | const newFolder = new TreeFolder(nodeName, nextPath) 53 | cursor.children.push(newFolder) 54 | cursor = newFolder 55 | } 56 | } 57 | 58 | return nextPath 59 | }, '') 60 | } 61 | 62 | tree.value = rootNode 63 | 64 | rootNode.buildCache() 65 | } 66 | 67 | watchEffect(() => { 68 | buildTree() 69 | }) 70 | 71 | return { tree, flatTree } 72 | } 73 | -------------------------------------------------------------------------------- /src/composables/i18n.ts: -------------------------------------------------------------------------------- 1 | import { emojiStateMap, TorrentState } from '@/constants/vuetorrent' 2 | import { getTorrentStateValue } from '@/helpers' 3 | import { useVueTorrentStore } from '@/stores' 4 | import { storeToRefs } from 'pinia' 5 | import { useI18n } from 'vue-i18n' 6 | 7 | export function useI18nUtils() { 8 | const i18n = useI18n() 9 | const { useEmojiState } = storeToRefs(useVueTorrentStore()) 10 | 11 | function getTorrentStateString(state: TorrentState) { 12 | const stateString = i18n.t(`torrent.state.${getTorrentStateValue(state)}`) 13 | if (useEmojiState.value) { 14 | return [emojiStateMap[state], stateString].join(' ') 15 | } else { 16 | return stateString 17 | } 18 | } 19 | 20 | return { 21 | ...i18n, 22 | getTorrentStateString 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/composables/index.ts: -------------------------------------------------------------------------------- 1 | import { useArrayPagination } from './ArrayPagination' 2 | import { useBackendSync } from './BackendSync' 3 | import { useDialog } from './Dialog' 4 | import { useI18nUtils } from './i18n' 5 | import { useSearchQuery } from './SearchQuery' 6 | import { useTorrentBuilder } from './TorrentBuilder' 7 | import { useTreeBuilder } from './TreeBuilder' 8 | 9 | export { useArrayPagination, useBackendSync, useDialog, useI18nUtils, useSearchQuery, useTorrentBuilder, useTreeBuilder } 10 | -------------------------------------------------------------------------------- /src/constants/qbit/AppPreferences.ts: -------------------------------------------------------------------------------- 1 | export enum AutoDeleteMode { 2 | NEVER, 3 | IF_ADDED, 4 | ALWAYS 5 | } 6 | 7 | export enum BitTorrentProtocol { 8 | TCP_uTP, 9 | TCP, 10 | uTP 11 | } 12 | 13 | export enum ContentLayout { 14 | ORIGINAL = 'Original', 15 | SUBFOLDER = 'Subfolder', 16 | NO_SUBFOLDER = 'NoSubfolder' 17 | } 18 | 19 | export enum DynDnsService { 20 | USE_DYNDNS, 21 | USE_NOIP, 22 | USE_NONE = -1 23 | } 24 | 25 | export enum Encryption { 26 | PREFER_ENCRYPTION, 27 | FORCE_ON, 28 | FORCE_OFF 29 | } 30 | 31 | export enum FileLogAgeType { 32 | DAYS, 33 | MONTHS, 34 | YEARS 35 | } 36 | 37 | export enum ShareLimitAction { 38 | DEFAULT = -1, 39 | STOP_TORRENT = 0, 40 | REMOVE_TORRENT = 1, 41 | ENABLE_SUPERSEEDING = 2, 42 | REMOVE_TORRENT_AND_FILES = 3 43 | } 44 | 45 | export enum ProxyType { 46 | NONE = 'None', 47 | SOCKS4 = 'SOCKS4', 48 | SOCKS5 = 'SOCKS5', 49 | HTTP = 'HTTP' 50 | } 51 | 52 | export enum ResumeDataStorageType { 53 | LEGACY = 'Legacy', 54 | SQLITE = 'SQLite' 55 | } 56 | 57 | export enum ScanDirsEnum { 58 | MONITORED_FOLDER, 59 | DEFAULT_SAVE_PATH 60 | } 61 | 62 | export type ScanDirs = ScanDirsEnum | string 63 | 64 | export enum SchedulerDays { 65 | EVERY_DAY, 66 | EVERY_WEEKDAY, 67 | EVERY_WEEKEND, 68 | EVERY_MONDAY, 69 | EVERY_TUESDAY, 70 | EVERY_WEDNESDAY, 71 | EVERY_THURSDAY, 72 | EVERY_FRIDAY, 73 | EVERY_SATURDAY, 74 | EVERY_SUNDAY 75 | } 76 | 77 | export enum StopCondition { 78 | NONE = 'None', 79 | METADATA_RECEIVED = 'MetadataReceived', 80 | FILES_CHECKED = 'FilesChecked' 81 | } 82 | 83 | export enum TorrentContentRemoveOption { 84 | DELETE = 'Delete', 85 | MOVE_TO_TRASH = 'MoveToTrash' 86 | } 87 | 88 | export enum UploadChokingAlgorithm { 89 | ROUND_ROBIN, 90 | FASTEST_UPLOAD, 91 | ANTI_LEECH 92 | } 93 | 94 | export enum UploadSlotsBehavior { 95 | FIXED_SLOTS, 96 | UPLOAD_RATE_BASED 97 | } 98 | 99 | export enum UtpTcpMixedMode { 100 | PREFER_TCP, 101 | PEER_PROPORTIONAL 102 | } 103 | 104 | export enum DiskIOType { 105 | DEFAULT, 106 | MEMORY_MAPPED_FILES, 107 | POSIX_COMPLIANT, 108 | SIMPLE_PREAD_PWRITE 109 | } 110 | 111 | export enum DiskIOMode { 112 | DISABLE_OS_CACHE, 113 | ENABLE_OS_CACHE, 114 | WRITE_THROUGH 115 | } 116 | -------------------------------------------------------------------------------- /src/constants/qbit/ConnectionStatus.ts: -------------------------------------------------------------------------------- 1 | export enum ConnectionStatus { 2 | CONNECTED = 'connected', 3 | FIREWALLED = 'firewalled', 4 | DISCONNECTED = 'disconnected', 5 | UNKNOWN = 'unknown' 6 | } 7 | -------------------------------------------------------------------------------- /src/constants/qbit/DirectoryContentMode.ts: -------------------------------------------------------------------------------- 1 | export enum DirectoryContentMode { 2 | ALL = 'all', 3 | ONLY_FILES = 'files', 4 | ONLY_DIRECTORIES = 'dirs' 5 | } 6 | -------------------------------------------------------------------------------- /src/constants/qbit/FilePriority.ts: -------------------------------------------------------------------------------- 1 | export enum FilePriority { 2 | MIXED = -1, 3 | DO_NOT_DOWNLOAD = 0, 4 | NORMAL = 1, 5 | HIGH = 6, 6 | MAXIMAL = 7 7 | } 8 | -------------------------------------------------------------------------------- /src/constants/qbit/FilterState.ts: -------------------------------------------------------------------------------- 1 | export enum FilterState { 2 | ALL = 'all', 3 | DOWNLOADING = 'downloading', 4 | SEEDING = 'seeding', 5 | COMPLETED = 'completed', 6 | /** @deprecated since 5.X, use stopped instead */ 7 | PAUSED = 'paused', 8 | STOPPED = 'stopped', 9 | /** @deprecated since 5.X, use running instead */ 10 | RESUMED = 'resumed', 11 | RUNNING = 'running', 12 | ACTIVE = 'active', 13 | INACTIVE = 'inactive', 14 | STALLED = 'stalled', 15 | STALLED_UPLOADING = 'stalled_uploading', 16 | STALLED_DOWNLOADING = 'stalled_downloading', 17 | CHECKING = 'checking', 18 | MOVING = 'moving', 19 | ERRORED = 'errored' 20 | } 21 | -------------------------------------------------------------------------------- /src/constants/qbit/LogType.ts: -------------------------------------------------------------------------------- 1 | export enum LogType { 2 | NONE = 0, 3 | NORMAL = 1 << 0, 4 | INFO = 1 << 1, 5 | WARNING = 1 << 2, 6 | CRITICAL = 1 << 3, 7 | ALL = NORMAL | INFO | WARNING | CRITICAL 8 | } 9 | -------------------------------------------------------------------------------- /src/constants/qbit/PieceState.ts: -------------------------------------------------------------------------------- 1 | export enum PieceState { 2 | MISSING = 0, 3 | DOWNLOADING = 1, 4 | DOWNLOADED = 2 5 | } 6 | -------------------------------------------------------------------------------- /src/constants/qbit/TorrentCreatorTaskStatus.ts: -------------------------------------------------------------------------------- 1 | export enum TorrentCreatorTaskStatus { 2 | FAILED = 'Failed', 3 | QUEUED = 'Queued', 4 | RUNNING = 'Running', 5 | FINISHED = 'Finished' 6 | } 7 | -------------------------------------------------------------------------------- /src/constants/qbit/TorrentFormat.ts: -------------------------------------------------------------------------------- 1 | export enum TorrentFormat { 2 | V1 = 'v1', 3 | V2 = 'v2', 4 | HYBRID = 'hybrid' 5 | } 6 | -------------------------------------------------------------------------------- /src/constants/qbit/TorrentOperatingMode.ts: -------------------------------------------------------------------------------- 1 | export enum TorrentOperatingMode { 2 | AUTO_MANAGED = 'AutoManaged', 3 | FORCED = 'Forced' 4 | } 5 | -------------------------------------------------------------------------------- /src/constants/qbit/TorrentState.ts: -------------------------------------------------------------------------------- 1 | export enum TorrentState { 2 | /** Torrent has just started downloading and is fetching metadata */ 3 | META_DL = 'metaDL', 4 | /** Torrent is forced to fetch metadata */ 5 | FORCED_META_DL = 'forcedMetaDL', 6 | /** Torrent is forced to downloading to ignore queue limit */ 7 | FORCED_DL = 'forcedDL', 8 | /** Torrent is being downloaded and data is being transferred */ 9 | DOWNLOADING = 'downloading', 10 | /** Torrent is being downloaded, but no connection were made */ 11 | STALLED_DL = 'stalledDL', 12 | /** Torrent is stopped and has NOT finished downloading 13 | * @deprecated since 5.X, use STOPPED_DL instead 14 | */ 15 | PAUSED_DL = 'pausedDL', 16 | /** Torrent is stopped and has NOT finished downloading */ 17 | STOPPED_DL = 'stoppedDL', 18 | /** Queuing is enabled and torrent is queued for download */ 19 | QUEUED_DL = 'queuedDL', 20 | /** Torrent is forced to uploading and ignore queue limit */ 21 | FORCED_UP = 'forcedUP', 22 | /** Torrent is being seeded and data is being transferred */ 23 | UPLOADING = 'uploading', 24 | /** Torrent is being seeded, but no connection were made */ 25 | STALLED_UP = 'stalledUP', 26 | /** 27 | * Torrent is stopped and has finished downloading 28 | * @deprecated since 5.X, use `STOPPED_UP` instead 29 | */ 30 | PAUSED_UP = 'pausedUP', 31 | /** Torrent is stopped and has finished downloading */ 32 | STOPPED_UP = 'stoppedUP', 33 | /** Queuing is enabled and torrent is queued for upload */ 34 | QUEUED_UP = 'queuedUP', 35 | /** Same as checkingUP, but torrent has NOT finished downloading */ 36 | CHECKING_DL = 'checkingDL', 37 | /** Torrent has finished downloading and is being checked */ 38 | CHECKING_UP = 'checkingUP', 39 | /** Checking resume data on qBt startup */ 40 | CHECKING_RESUME_DATA = 'checkingResumeData', 41 | /** Torrent is allocating disk space for download 42 | * @deprecated since 4.4.0, libtorrent 2.X 43 | */ 44 | ALLOCATING = 'allocating', 45 | /** Torrent is moving to another location */ 46 | MOVING = 'moving', 47 | /** Torrent data files is missing */ 48 | MISSING_FILES = 'missingFiles', 49 | /** Some error occurred, applies to stopped torrents */ 50 | ERROR = 'error', 51 | /** Unknown status */ 52 | UNKNOWN = 'unknown' 53 | } 54 | -------------------------------------------------------------------------------- /src/constants/qbit/TrackerStatus.ts: -------------------------------------------------------------------------------- 1 | export enum TrackerStatus { 2 | /** Tracker is disabled (used for DHT, PeX, and LSD) */ 3 | DISABLED = 0, 4 | /** Tracker has not been contacted yet */ 5 | NOT_YET_CONTACTED = 1, 6 | /** Tracker has been contacted and is working */ 7 | WORKING = 2, 8 | /** Tracker is updating */ 9 | UPDATING = 3, 10 | /** Tracker has been contacted, but it is not working (or doesn't send proper replies) */ 11 | NOT_WORKING = 4 12 | } 13 | -------------------------------------------------------------------------------- /src/constants/qbit/index.ts: -------------------------------------------------------------------------------- 1 | import * as AppPreferences from './AppPreferences' 2 | import { ConnectionStatus } from './ConnectionStatus' 3 | import { DirectoryContentMode } from './DirectoryContentMode' 4 | import { FilePriority } from './FilePriority' 5 | import { FilterState } from './FilterState' 6 | import { LogType } from './LogType' 7 | import { PieceState } from './PieceState' 8 | import { TorrentCreatorTaskStatus } from './TorrentCreatorTaskStatus' 9 | import { TorrentFormat } from './TorrentFormat' 10 | import { TorrentOperatingMode } from './TorrentOperatingMode' 11 | import { TorrentState } from './TorrentState' 12 | import { TrackerStatus } from './TrackerStatus' 13 | 14 | export { 15 | AppPreferences, 16 | ConnectionStatus, 17 | DirectoryContentMode, 18 | FilterState, 19 | LogType, 20 | PieceState, 21 | FilePriority, 22 | TrackerStatus, 23 | TorrentCreatorTaskStatus, 24 | TorrentFormat, 25 | TorrentOperatingMode, 26 | TorrentState 27 | } 28 | -------------------------------------------------------------------------------- /src/constants/vuetorrent/DashboardDisplayMode.ts: -------------------------------------------------------------------------------- 1 | export enum DashboardDisplayMode { 2 | LIST = 'list', 3 | GRID = 'grid', 4 | TABLE = 'table' 5 | } 6 | -------------------------------------------------------------------------------- /src/constants/vuetorrent/DashboardProperty.ts: -------------------------------------------------------------------------------- 1 | export enum DashboardProperty { 2 | ADDED_ON = 'added_on', 3 | AMOUNT_LEFT = 'amount_left', 4 | AUTO_TMM = 'auto_tmm', 5 | AVAILABILITY = 'availability', 6 | AVG_DOWNLOAD_SPEED = 'avg_download_speed', 7 | AVG_UPLOAD_SPEED = 'avg_upload_speed', 8 | BASENAME_CONTENT_PATH = 'basename_content_path', 9 | BASENAME_DOWNLOAD_PATH = 'basename_download_path', 10 | BASENAME_SAVE_PATH = 'basename_save_path', 11 | CATEGORY = 'category', 12 | COMMENT = 'comment', 13 | COMPLETED_ON = 'completed_on', 14 | CONTENT_PATH = 'content_path', 15 | DOWNLOAD_LIMIT = 'download_limit', 16 | DOWNLOAD_PATH = 'download_path', 17 | DOWNLOAD_SPEED = 'download_speed', 18 | DOWNLOADED = 'downloaded', 19 | DOWNLOADED_SESSION = 'downloaded_session', 20 | ETA = 'eta', 21 | FIRST_LAST_PIECE_PRIORITY = 'f_l_piece_prio', 22 | FORCED = 'forced', 23 | GLOBAL_SPEED = 'global_speed', 24 | GLOBAL_VOLUME = 'global_volume', 25 | HAS_METADATA = 'has_metadata', 26 | HASH = 'hash', 27 | INACTIVE_SEEDING_TIME_LIMIT = 'inactive_seeding_time_limit', 28 | INFOHASH_V1 = 'infohash_v1', 29 | INFOHASH_V2 = 'infohash_v2', 30 | LAST_ACTIVITY = 'last_activity', 31 | MAGNET = 'magnet', 32 | PEERS = 'peers', 33 | POPULARITY = 'popularity', 34 | PRIORITY = 'priority', 35 | PRIVATE = 'private', 36 | PROGRESS = 'progress', 37 | RATIO = 'ratio', 38 | RATIO_LIMIT = 'ratio_limit', 39 | REANNOUNCE = 'reannounce', 40 | ROOT_PATH = 'root_path', 41 | SAVE_PATH = 'save_path', 42 | SEEDING_TIME = 'seeding_time', 43 | SEEDING_TIME_LIMIT = 'seeding_time_limit', 44 | SEEDS = 'seeds', 45 | SEEN_COMPLETE = 'seen_complete', 46 | SEQUENTIAL_DOWNLOADS = 'seq_dl', 47 | SIZE = 'size', 48 | STATE = 'state', 49 | SUPER_SEEDING = 'super_seeding', 50 | TAGS = 'tags', 51 | TIME_ACTIVE = 'time_active', 52 | TOTAL_SIZE = 'total_size', 53 | TRACKER = 'tracker', 54 | TRACKERS_COUNT = 'trackers_count', 55 | TRUNCATED_HASH = 'truncated_hash', 56 | UPLOAD_LIMIT = 'upload_limit', 57 | UPLOAD_SPEED = 'upload_speed', 58 | UPLOADED = 'uploaded', 59 | UPLOADED_SESSION = 'uploaded_session' 60 | } 61 | -------------------------------------------------------------------------------- /src/constants/vuetorrent/DashboardPropertyType.ts: -------------------------------------------------------------------------------- 1 | export enum DashboardPropertyType { 2 | AMOUNT = 'amount', 3 | BOOLEAN = 'boolean', 4 | CHIP = 'chip', 5 | DATA = 'data', 6 | DATETIME = 'datetime', 7 | DURATION = 'duration', 8 | PERCENT = 'percent', 9 | RELATIVE = 'relative', 10 | SPEED = 'speed', 11 | TEXT = 'text' 12 | } 13 | -------------------------------------------------------------------------------- /src/constants/vuetorrent/FeedState.ts: -------------------------------------------------------------------------------- 1 | /** RSS Feed state values ordered by priority of display */ 2 | export enum FeedState { 3 | /** Feed is updating */ 4 | LOADING, 5 | /** Feed has encountered an error */ 6 | ERROR, 7 | /** At least one article is unread */ 8 | UNREAD, 9 | /** All articles are read */ 10 | READ 11 | } 12 | -------------------------------------------------------------------------------- /src/constants/vuetorrent/FileIcon.ts: -------------------------------------------------------------------------------- 1 | enum FileIcon { 2 | PDF = 'mdi-file-pdf-box', 3 | IMAGE = 'mdi-file-image', 4 | DOCUMENT = 'mdi-file-document', 5 | INFORMATION = 'mdi-information-variant-box', 6 | MUSIC = 'mdi-music', 7 | VIDEO = 'mdi-movie', 8 | SUBTITLE = 'mdi-subtitles', 9 | ARCHIVE = 'mdi-zip-box-outline', 10 | EXECUTABLE = 'mdi-application-brackets' 11 | } 12 | 13 | export const typesMap: Record = { 14 | pdf: FileIcon.PDF, 15 | 16 | png: FileIcon.IMAGE, 17 | jpg: FileIcon.IMAGE, 18 | jpeg: FileIcon.IMAGE, 19 | tiff: FileIcon.IMAGE, 20 | 21 | doc: FileIcon.DOCUMENT, 22 | docx: FileIcon.DOCUMENT, 23 | txt: FileIcon.DOCUMENT, 24 | 25 | nfo: FileIcon.INFORMATION, 26 | 27 | mp3: FileIcon.MUSIC, 28 | wav: FileIcon.MUSIC, 29 | flac: FileIcon.MUSIC, 30 | 31 | avi: FileIcon.VIDEO, 32 | mp4: FileIcon.VIDEO, 33 | mkv: FileIcon.VIDEO, 34 | mov: FileIcon.VIDEO, 35 | wmv: FileIcon.VIDEO, 36 | 37 | srt: FileIcon.SUBTITLE, 38 | idx: FileIcon.SUBTITLE, 39 | sub: FileIcon.SUBTITLE, 40 | 41 | rar: FileIcon.ARCHIVE, 42 | zip: FileIcon.ARCHIVE, 43 | gz: FileIcon.ARCHIVE, 44 | '7z': FileIcon.ARCHIVE, 45 | iso: FileIcon.ARCHIVE, 46 | 47 | exe: FileIcon.EXECUTABLE, 48 | msi: FileIcon.EXECUTABLE, 49 | dmg: FileIcon.EXECUTABLE, 50 | deb: FileIcon.EXECUTABLE, 51 | jar: FileIcon.EXECUTABLE 52 | } 53 | 54 | export function getFileIcon(filename: string) { 55 | const type = filename.split('.').pop()?.toLowerCase() || '' 56 | return typesMap[type] || 'mdi-file' 57 | } 58 | -------------------------------------------------------------------------------- /src/constants/vuetorrent/FilterType.ts: -------------------------------------------------------------------------------- 1 | export enum FilterType { 2 | /** 3 | * Applies conjunctive filtering 4 | * 5 | * Conjunctive refers to a connection or "and" relation between statements. 6 | * Every filters MUST match for the data to match 7 | */ 8 | CONJUNCTIVE, 9 | /** 10 | * Applies disjunctive filtering 11 | * 12 | * Disjunctive denotes an "either/or" condition. 13 | * Any filter must match for the data to match 14 | */ 15 | DISJUNCTIVE 16 | } 17 | -------------------------------------------------------------------------------- /src/constants/vuetorrent/HistoryKey.ts: -------------------------------------------------------------------------------- 1 | export enum HistoryKey { 2 | COOKIE = 'cookie', 3 | SEARCH_ENGINE_QUERY = 'searchEngineQuery', 4 | TORRENT_PATH = 'torrentPath', 5 | BULK_RENAME_REGEXP = 'bulkRenameRegexp', 6 | BULK_RENAME_TARGET = 'bulkRenameTarget' 7 | } 8 | -------------------------------------------------------------------------------- /src/constants/vuetorrent/ThemeMode.ts: -------------------------------------------------------------------------------- 1 | export enum ThemeMode { 2 | LIGHT = 'light', 3 | DARK = 'dark', 4 | SYSTEM = 'system' 5 | } 6 | -------------------------------------------------------------------------------- /src/constants/vuetorrent/TitleOptions.ts: -------------------------------------------------------------------------------- 1 | export enum TitleOptions { 2 | DEFAULT, 3 | GLOBAL_SPEED, 4 | FIRST_TORRENT_STATUS, 5 | CUSTOM 6 | } 7 | -------------------------------------------------------------------------------- /src/constants/vuetorrent/TrackerSpecialFilter.ts: -------------------------------------------------------------------------------- 1 | export enum TrackerSpecialFilter { 2 | UNTRACKED = 0, 3 | NOT_WORKING = 1 4 | } 5 | -------------------------------------------------------------------------------- /src/constants/vuetorrent/index.ts: -------------------------------------------------------------------------------- 1 | import comparatorMap from './Comparators' 2 | import type { PropertyData, PropertyMetadata, TorrentProperty } from './DashboardDefaults' 3 | import { propsData, propsMetadata } from './DashboardDefaults' 4 | import { DashboardDisplayMode } from './DashboardDisplayMode' 5 | import { DashboardProperty } from './DashboardProperty' 6 | import { DashboardPropertyType } from './DashboardPropertyType' 7 | import { FeedState } from './FeedState' 8 | import { getFileIcon, typesMap } from './FileIcon' 9 | import { FilterType } from './FilterType' 10 | import { HistoryKey } from './HistoryKey' 11 | import { ThemeMode } from './ThemeMode' 12 | import { TitleOptions } from './TitleOptions' 13 | import { TorrentState, emojiStateMap, stateQbitToVt, stateVtToQbit } from './TorrentState' 14 | import { TrackerSpecialFilter } from './TrackerSpecialFilter' 15 | 16 | const defaultDateFormat = 'YYYY-MM-DD HH:mm:ss' 17 | const defaultDurationFormat = 'Y[Y] M[M] D[d] H[h] m[m] s[s]' 18 | 19 | export { 20 | comparatorMap, 21 | TorrentProperty, 22 | PropertyData, 23 | PropertyMetadata, 24 | propsData, 25 | propsMetadata, 26 | DashboardDisplayMode, 27 | DashboardProperty, 28 | DashboardPropertyType, 29 | FeedState, 30 | getFileIcon, 31 | typesMap, 32 | FilterType, 33 | HistoryKey, 34 | ThemeMode, 35 | TitleOptions, 36 | TorrentState, 37 | emojiStateMap, 38 | stateQbitToVt, 39 | stateVtToQbit, 40 | TrackerSpecialFilter, 41 | defaultDateFormat, 42 | defaultDurationFormat 43 | } 44 | -------------------------------------------------------------------------------- /src/globalErrorHandler.ts: -------------------------------------------------------------------------------- 1 | window.onerror = function (msg, url, line, col, error) { 2 | let extra = '' 3 | 4 | // only add 'col' and 'error' if they are available 5 | col && (extra += '\ncolumn: ' + col) 6 | error && (extra += '\nerror: ' + error) 7 | 8 | const final_message = 'Error: ' + msg + '\nurl: ' + url + '\nline: ' + line + extra 9 | 10 | if (sessionStorage.getItem('vuetorrent_mounted') === 'true') { 11 | console.error(final_message) 12 | } else { 13 | alert(final_message) 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/helpers/colors.ts: -------------------------------------------------------------------------------- 1 | import { TorrentState } from '@/constants/vuetorrent' 2 | import { TinyColor, random } from '@ctrl/tinycolor' 3 | 4 | function djb2Hash(str: string): number { 5 | let hash = 5381 6 | for (let i = 0; i < str.length; i++) { 7 | hash = (hash * 33) ^ str.charCodeAt(i) 8 | } 9 | return hash >>> 0 // ensure non-negative integer 10 | } 11 | 12 | export function getColorFromName(name: string, transform?: (color: TinyColor) => TinyColor) { 13 | const color = random({ 14 | seed: djb2Hash(name) 15 | }) 16 | 17 | if (transform) return transform(color).toHexString() 18 | else return color.toHexString() 19 | } 20 | 21 | export function getRatioColor(ratio: number) { 22 | if (ratio < 0.5) return 'text-ratio-bad' 23 | if (ratio < 1) return 'text-ratio-almost' 24 | if (ratio < 5) return 'text-ratio-good' 25 | return 'text-ratio-best' 26 | } 27 | 28 | export function getTorrentStateValue(state: TorrentState) { 29 | return TorrentState[state].toLowerCase() 30 | } 31 | 32 | export function getTorrentStateColor(state: TorrentState) { 33 | return `torrent-${getTorrentStateValue(state)}` 34 | } 35 | -------------------------------------------------------------------------------- /src/helpers/data.ts: -------------------------------------------------------------------------------- 1 | import { toPrecision } from './number' 2 | 3 | const units = ['', 'k', 'M', 'G', 'T', 'P', 'E', 'Z', 'Y'] 4 | 5 | export function formatDataValue(data: number, isBinary: boolean, precision?: number) { 6 | const base = isBinary ? 1024 : 1000 7 | if (!data || data === 0) return '0' 8 | 9 | let i = 1 10 | while (data >= base ** i && i < units.length) { 11 | i++ 12 | } 13 | return toPrecision(data / base ** (i - 1), precision ?? (i > 1 ? 3 : 1)) 14 | } 15 | 16 | export function formatDataUnit(data: number, isBinary: boolean) { 17 | const base = isBinary ? 1024 : 1000 18 | 19 | let i = 1 20 | while (data >= base ** i && i < units.length) { 21 | i++ 22 | } 23 | return `${units[i - 1]}${isBinary && i > 1 ? 'i' : ''}B` 24 | } 25 | 26 | export function formatData(data: number, isBinary: boolean, precision?: number) { 27 | return `${formatDataValue(data, isBinary, precision)} ${formatDataUnit(data, isBinary)}` 28 | } 29 | -------------------------------------------------------------------------------- /src/helpers/datetime.spec.ts: -------------------------------------------------------------------------------- 1 | import timezoneMock from 'timezone-mock' 2 | import { describe, expect, test } from 'vitest' 3 | import { formatDuration, formatEta, formatTimeMs, formatTimeSec, INFINITY_SYMBOL, QBIT_MAX_ETA } from './datetime' 4 | 5 | beforeAll(() => { 6 | timezoneMock.register('UTC') 7 | }) 8 | 9 | afterAll(() => { 10 | timezoneMock.unregister() 11 | }) 12 | 13 | describe('helpers/datetime/formatEta', () => { 14 | test('seconds', () => { 15 | expect(formatEta(1)).toBe('1s') 16 | expect(formatEta(59)).toBe('59s') 17 | }) 18 | 19 | test('minutes', () => { 20 | expect(formatEta(60)).toBe('1m') 21 | expect(formatEta(61)).toBe('1m 1s') 22 | expect(formatEta(3599)).toBe('59m 59s') 23 | }) 24 | 25 | test('hours', () => { 26 | expect(formatEta(3600)).toBe('1h') 27 | expect(formatEta(3601)).toBe('1h 1s') 28 | expect(formatEta(3660)).toBe('1h 1m') 29 | expect(formatEta(3661)).toBe('1h 1m') 30 | expect(formatEta(86399)).toBe('23h 59m') 31 | }) 32 | 33 | test('days', () => { 34 | expect(formatEta(86400)).toBe('1d') 35 | expect(formatEta(86401)).toBe('1d 1s') 36 | expect(formatEta(86460)).toBe('1d 1m') 37 | expect(formatEta(86461)).toBe('1d 1m') 38 | expect(formatEta(90000)).toBe('1d 1h') 39 | expect(formatEta(90001)).toBe('1d 1h') 40 | expect(formatEta(90060)).toBe('1d 1h') 41 | }) 42 | 43 | test('infinity', () => { 44 | expect(formatEta(0)).toBe('0s') 45 | expect(formatEta(0, false)).toBe('0s') 46 | expect(formatEta(0, true)).toBe(INFINITY_SYMBOL) 47 | expect(formatEta(QBIT_MAX_ETA)).toBe(INFINITY_SYMBOL) 48 | }) 49 | }) 50 | 51 | test('helpers/datetime/formatTimeMs', () => { 52 | expect(formatTimeMs(1626739200000, 'YYYY-MM-DD')).toBe('2021-07-20') 53 | }) 54 | 55 | test('helpers/datetime/formatTimeSec', () => { 56 | expect(formatTimeSec(1626739200, 'YYYY-MM-DD')).toBe('2021-07-20') 57 | }) 58 | 59 | test('helpers/datetime/formatDuration', () => { 60 | expect(formatDuration(60, 's', 'HH:mm:ss')).toBe('00:01:00') 61 | expect(formatDuration(1, 'm', 'HH:mm:ss')).toBe('00:01:00') 62 | expect(formatDuration(1, 'h', 'HH:mm:ss')).toBe('01:00:00') 63 | expect(formatDuration(1, 'd', 'D [days], HH:mm:ss')).toBe('1 days, 00:00:00') 64 | expect(formatDuration(0, 's', 'HH:mm:ss')).toBe('00:00:00') 65 | expect(formatDuration(1000000, 's', 'D [days], HH:mm:ss')).toBe('11 days, 13:46:40') 66 | }) 67 | -------------------------------------------------------------------------------- /src/helpers/datetime.ts: -------------------------------------------------------------------------------- 1 | import dayjs from '@/plugins/dayjs' 2 | import { DurationUnitType } from 'dayjs/plugin/duration' 3 | 4 | export const QBIT_MAX_ETA = 8_640_000 // 100 days 5 | export const INFINITY_SYMBOL = '∞' 6 | 7 | export function formatEta(value: number, isForced: boolean = false): string { 8 | const MAX_UNITS = 2 // Will display 2 units max, from highest to lowest 9 | 10 | if (value >= QBIT_MAX_ETA || (isForced && value === 0)) { 11 | return INFINITY_SYMBOL 12 | } 13 | 14 | const minute = 60 15 | const hour = minute * 60 16 | const day = hour * 24 17 | const year = day * 365 18 | 19 | const durations = [year, day, hour, minute, 1] 20 | const units = 'ydhms' 21 | 22 | let index = 0 23 | let unitSize = 0 24 | const parts = [] 25 | 26 | while (unitSize < MAX_UNITS && index !== durations.length) { 27 | const duration = durations[index] 28 | if (value < duration) { 29 | index++ 30 | continue 31 | } 32 | 33 | const result = Math.floor(value / duration) 34 | parts.push(result + units[index]) 35 | 36 | value %= duration 37 | index++ 38 | unitSize++ 39 | } 40 | 41 | if (!parts.length) { 42 | return '0' + units[durations.length - 1] 43 | } 44 | 45 | return parts.join(' ') 46 | } 47 | 48 | export function formatTimeMs(value: number, format: string): string { 49 | return dayjs(value).format(format) 50 | } 51 | 52 | export function formatTimeSec(value: number, format: string): string { 53 | return formatTimeMs(value * 1000, format) 54 | } 55 | 56 | export function formatDuration(value: number, unit: DurationUnitType, format: string): string { 57 | return dayjs.duration(value, unit).format(format) 58 | } 59 | -------------------------------------------------------------------------------- /src/helpers/index.ts: -------------------------------------------------------------------------------- 1 | import { getColorFromName, getRatioColor, getTorrentStateColor, getTorrentStateValue } from './colors' 2 | import comparators, { Comparator, isObjectEqual } from './comparators' 3 | import { formatDataValue, formatDataUnit, formatData } from './data' 4 | import { QBIT_MAX_ETA, INFINITY_SYMBOL, formatEta, formatTimeMs, formatTimeSec, formatDuration } from './datetime' 5 | import { toPrecision, formatPercent } from './number' 6 | import { basename } from './path' 7 | import { formatSpeedValue, formatSpeedUnit, formatSpeed } from './speed' 8 | import { isWindows, isMac, doesCommand, openLink, downloadFile } from './system' 9 | import { titleCase, capitalize, extractHostname, getDomainBody, splitByUrl, containsUrl, isValidUri, codeToFlag } from './text' 10 | 11 | export { 12 | getColorFromName, 13 | getRatioColor, 14 | getTorrentStateColor, 15 | getTorrentStateValue, 16 | comparators, 17 | isObjectEqual, 18 | formatDataValue, 19 | formatDataUnit, 20 | formatData, 21 | QBIT_MAX_ETA, 22 | INFINITY_SYMBOL, 23 | formatEta, 24 | formatTimeMs, 25 | formatTimeSec, 26 | formatDuration, 27 | toPrecision, 28 | formatPercent, 29 | basename, 30 | formatSpeedValue, 31 | formatSpeedUnit, 32 | formatSpeed, 33 | isWindows, 34 | isMac, 35 | doesCommand, 36 | openLink, 37 | downloadFile, 38 | titleCase, 39 | capitalize, 40 | extractHostname, 41 | getDomainBody, 42 | splitByUrl, 43 | containsUrl, 44 | isValidUri, 45 | codeToFlag 46 | } 47 | 48 | export type { Comparator } 49 | -------------------------------------------------------------------------------- /src/helpers/number.spec.ts: -------------------------------------------------------------------------------- 1 | import { formatPercent, toPrecision } from './number' 2 | import { expect, test } from 'vitest' 3 | 4 | test('helpers/number/toPrecision', () => { 5 | expect(toPrecision(0, 3)).toBe('0.00') 6 | expect(toPrecision(0.1, 3)).toBe('0.10') 7 | 8 | expect(toPrecision(1, 3)).toBe('1.00') 9 | 10 | expect(toPrecision(10, 3)).toBe('10.0') 11 | expect(toPrecision(10, 2)).toBe('10') 12 | expect(toPrecision(10, 1)).toBe('10') 13 | 14 | expect(toPrecision(99.99, 3)).toBe('99.9') 15 | 16 | expect(toPrecision(100, 3)).toBe('100') 17 | }) 18 | 19 | test('helpers/number/formatPercent', () => { 20 | expect(formatPercent(0)).toBe('0.00 %') 21 | expect(formatPercent(0.1)).toBe('10.0 %') 22 | expect(formatPercent(1)).toBe('100 %') 23 | 24 | expect(formatPercent(0.999942870757758)).toBe('99.9 %') 25 | }) 26 | -------------------------------------------------------------------------------- /src/helpers/number.ts: -------------------------------------------------------------------------------- 1 | export function toPrecision(value: number, precision: number): string { 2 | if (value >= 10 ** precision) { 3 | return Math.floor(value).toString() 4 | } 5 | 6 | const strValue = value.toFixed(precision) 7 | if (strValue.length < Math.floor(Math.log10(value)) + 1) { 8 | return strValue 9 | } else { 10 | const result = strValue.substring(0, precision + 1) 11 | if (result.endsWith('.')) { 12 | return result.slice(0, -1) 13 | } 14 | return result 15 | } 16 | } 17 | 18 | /** Formats a percentage value between 0 and 1 */ 19 | export function formatPercent(progress: number): string { 20 | return `${toPrecision(progress * 100, 3)} %` 21 | } 22 | -------------------------------------------------------------------------------- /src/helpers/path.ts: -------------------------------------------------------------------------------- 1 | export function basename(path: string | null | undefined) { 2 | if (!path) return '' 3 | 4 | const uniPath = path.replace(/\\/g, '/') 5 | if (uniPath.indexOf('/') === -1) return '' 6 | 7 | return uniPath.split('/').reverse()[0] 8 | } 9 | 10 | export function splitExt(path: string | null | undefined): [string, string] { 11 | if (!path) return ['', ''] 12 | 13 | const uniPath = path.replace(/\\/g, '/') 14 | if (!uniPath.includes('.', 1)) return [uniPath, ''] 15 | 16 | const groups = uniPath.split('.') 17 | const ext = groups.pop()! 18 | return [groups.join('.'), ext] 19 | } 20 | -------------------------------------------------------------------------------- /src/helpers/speed.ts: -------------------------------------------------------------------------------- 1 | import { formatDataUnit, formatDataValue } from './data' 2 | 3 | export function formatSpeedValue(speed: number, isBits: boolean) { 4 | if (isBits) speed *= 8 5 | return formatDataValue(speed, false) 6 | } 7 | 8 | export function formatSpeedUnit(speed: number, isBits: boolean) { 9 | if (isBits) speed *= 8 10 | const unit = formatDataUnit(speed, false).slice(0, -1) 11 | return `${unit}${isBits ? 'bps' : 'B/s'}` 12 | } 13 | 14 | export function formatSpeed(speed: number, isBits: boolean) { 15 | return `${formatSpeedValue(speed, isBits)} ${formatSpeedUnit(speed, isBits)}` 16 | } 17 | -------------------------------------------------------------------------------- /src/helpers/system.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Check if user is on Windows 3 | */ 4 | export const isWindows = window.navigator.userAgent.toLowerCase().includes('windows') 5 | 6 | /** 7 | * Check if user is on MAC 8 | */ 9 | export const isMac = window.navigator.userAgent.toLowerCase().includes('mac') 10 | 11 | /** 12 | * Check Ctrl/Cmd key 13 | */ 14 | export function doesCommand(e: { metaKey: boolean; ctrlKey: boolean }): boolean { 15 | return isMac ? e.metaKey : e.ctrlKey 16 | } 17 | 18 | export function openLink(link: string) { 19 | window.open(link, '_blank', 'noreferrer') 20 | } 21 | 22 | export function downloadFile(filename: string, blob: Blob) { 23 | const href = window.URL.createObjectURL(blob) 24 | const el = Object.assign(document.createElement('a'), { href, download: filename, style: { opacity: '0' } }) 25 | document.body.appendChild(el) 26 | el.click() 27 | el.remove() 28 | } 29 | -------------------------------------------------------------------------------- /src/locales/index.ts: -------------------------------------------------------------------------------- 1 | import cs from './cs.json' 2 | import en from './en.json' 3 | import es from './es.json' 4 | import fr from './fr.json' 5 | import hu from './hu.json' 6 | import it from './it.json' 7 | import ja from './ja.json' 8 | import ko from './ko.json' 9 | import nl from './nl.json' 10 | import pl from './pl.json' 11 | import pt_br from './pt-BR.json' 12 | import ru from './ru.json' 13 | import tr from './tr.json' 14 | import uk from './uk.json' 15 | import zh_hans from './zh-Hans.json' 16 | import zh_hant from './zh-Hant.json' 17 | 18 | type LocaleDef = { title: string; value: Locales } 19 | 20 | export enum Locales { 21 | CS = 'cs', 22 | EN = 'en', 23 | ES = 'es', 24 | FR = 'fr', 25 | HU = 'hu', 26 | IT = 'it', 27 | JA = 'ja', 28 | KO = 'ko', 29 | NL = 'nl', 30 | PL = 'pl', 31 | PT_BR = 'pt-BR', 32 | RU = 'ru', 33 | TR = 'tr', 34 | UK = 'uk', 35 | ZH_HANS = 'zh-Hans', 36 | ZH_HANT = 'zh-Hant' 37 | } 38 | 39 | export const LOCALES: LocaleDef[] = [ 40 | { title: 'čeština', value: Locales.CS }, 41 | { title: 'English', value: Locales.EN }, 42 | { title: 'español', value: Locales.ES }, 43 | { title: 'Français', value: Locales.FR }, 44 | { title: 'magyar', value: Locales.HU }, 45 | { title: 'italiano', value: Locales.IT }, 46 | { title: '日本語', value: Locales.JA }, 47 | { title: '한국어', value: Locales.KO }, 48 | { title: 'Nederlands', value: Locales.NL }, 49 | { title: 'polski', value: Locales.PL }, 50 | { title: 'português (Brasil)', value: Locales.PT_BR }, 51 | { title: 'Русский', value: Locales.RU }, 52 | { title: 'Türkçe', value: Locales.TR }, 53 | { title: 'українська', value: Locales.UK }, 54 | { title: '简体中文', value: Locales.ZH_HANS }, 55 | { title: '繁體中文', value: Locales.ZH_HANT } 56 | ] 57 | 58 | export const messages: Record = { 59 | [Locales.CS]: cs, 60 | [Locales.EN]: en, 61 | [Locales.ES]: es, 62 | [Locales.FR]: fr, 63 | [Locales.HU]: hu, 64 | [Locales.IT]: it, 65 | [Locales.JA]: ja, 66 | [Locales.KO]: ko, 67 | [Locales.NL]: nl, 68 | [Locales.PL]: pl, 69 | [Locales.PT_BR]: pt_br, 70 | [Locales.RU]: ru, 71 | [Locales.TR]: tr, 72 | [Locales.UK]: uk, 73 | [Locales.ZH_HANS]: zh_hans, 74 | [Locales.ZH_HANT]: zh_hant 75 | } 76 | 77 | export const defaultLocale = Locales.EN 78 | export const fallbackLocale = Locales.EN 79 | -------------------------------------------------------------------------------- /src/locales/nl.json: -------------------------------------------------------------------------------- 1 | { 2 | "common": { 3 | "cancel": "Annuleren", 4 | "close": "Sluiten", 5 | "delete": "Verwijderen", 6 | "disable": "Uitschakelen", 7 | "emptyList": "Niets te zien hier!", 8 | "none": "(Geen)", 9 | "save": "Opslaan", 10 | "selectAll": "Alles selecteren" 11 | }, 12 | "constants": { 13 | "bittorrentProtocols": { 14 | "tcp_utp": "TCP en μTP" 15 | }, 16 | "connectionStatus": { 17 | "connected": "Verbonden" 18 | } 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import { createApp } from 'vue' 2 | import '@/styles/styles.scss' 3 | import App from './App.vue' 4 | 5 | // Vue-Router 6 | import router from '@/plugins/router' 7 | 8 | // Vuetify 9 | import vuetify from '@/plugins/vuetify' 10 | 11 | // Vue-i18n 12 | import i18n from '@/plugins/i18n' 13 | 14 | // Vue-Toastify 15 | import Vue3Toastify from 'vue3-toastify' 16 | import options from '@/plugins/toastify' 17 | 18 | // Pinia 19 | import pinia from '@/plugins/pinia' 20 | 21 | // Font 22 | import '@fontsource/roboto' 23 | 24 | const app = createApp(App) 25 | 26 | app.config.performance = true 27 | 28 | app.use(router).use(vuetify).use(i18n).use(Vue3Toastify, options).use(pinia).mount('#app') 29 | -------------------------------------------------------------------------------- /src/pages/MagnetHandler.vue: -------------------------------------------------------------------------------- 1 | 21 | -------------------------------------------------------------------------------- /src/pages/index.ts: -------------------------------------------------------------------------------- 1 | import { RouteRecordRaw } from 'vue-router' 2 | 3 | export const routes: RouteRecordRaw[] = [ 4 | { 5 | name: 'dashboard', 6 | path: '/', 7 | component: () => import('./Dashboard.vue') 8 | }, 9 | { 10 | name: 'settings', 11 | path: '/settings/:tab?/:subtab?', 12 | component: () => import('./Settings.vue') 13 | }, 14 | { 15 | name: 'rssArticles', 16 | path: '/rss/:tab?/:feedId?', 17 | component: () => import('./RssArticles.vue') 18 | }, 19 | { 20 | name: 'logs', 21 | path: '/logs', 22 | component: () => import('./Logs.vue') 23 | }, 24 | { 25 | name: 'searchEngine', 26 | path: '/search', 27 | component: () => import('./SearchEngine.vue') 28 | }, 29 | { 30 | name: 'torrentCreator', 31 | path: '/torrentCreator', 32 | component: () => import('./TorrentCreator.vue') 33 | }, 34 | { 35 | name: 'torrentDetail', 36 | path: '/torrent/:hash/:tab?', 37 | component: () => import('./TorrentDetail.vue') 38 | }, 39 | { 40 | name: 'magnetHandler', 41 | path: '/magnet/:url', 42 | alias: '/download=:url', 43 | component: () => import('./MagnetHandler.vue') 44 | }, 45 | { 46 | name: 'login', 47 | path: '/login', 48 | component: () => import('./Login.vue'), 49 | meta: { 50 | public: true // Allow access even if not logged in 51 | } 52 | }, 53 | { 54 | path: '/:any+', // Catch all, prevent empty page if URL is invalid 55 | redirect: '/' 56 | } 57 | ] 58 | -------------------------------------------------------------------------------- /src/plugins/dayjs.ts: -------------------------------------------------------------------------------- 1 | import dayjs from 'dayjs' 2 | import duration from 'dayjs/plugin/duration' 3 | import relativeTime from 'dayjs/plugin/relativeTime' 4 | import updateLocale from 'dayjs/plugin/updateLocale' 5 | 6 | const config = { 7 | thresholds: [ 8 | { l: 's', r: 1 }, 9 | { l: 'ss', r: 59, d: 'second' }, 10 | { l: 'm', r: 1 }, 11 | { l: 'mm', r: 59, d: 'minute' }, 12 | { l: 'h', r: 1 }, 13 | { l: 'hh', r: 23, d: 'hour' }, 14 | { l: 'd', r: 1 }, 15 | { l: 'dd', r: 29, d: 'day' }, 16 | { l: 'M', r: 1 }, 17 | { l: 'MM', r: 11, d: 'month' }, 18 | { l: 'y', r: 1 }, 19 | { l: 'yy', d: 'year' } 20 | ], 21 | rounding: Math.floor 22 | } 23 | 24 | dayjs.extend(duration) 25 | dayjs.extend(relativeTime, config) 26 | dayjs.extend(updateLocale) 27 | 28 | dayjs.updateLocale('en', { 29 | relativeTime: { 30 | future: 'in %s', 31 | past: '%s ago', 32 | s: 'a few seconds', 33 | ss: '%d seconds', 34 | m: 'a minute', 35 | mm: '%d minutes', 36 | h: 'an hour', 37 | hh: '%d hours', 38 | d: 'a day', 39 | dd: '%d days', 40 | M: 'a month', 41 | MM: '%d months', 42 | y: 'a year', 43 | yy: '%d years' 44 | } 45 | }) 46 | 47 | export default dayjs 48 | -------------------------------------------------------------------------------- /src/plugins/i18n.ts: -------------------------------------------------------------------------------- 1 | import { createI18n } from 'vue-i18n' 2 | import { defaultLocale, fallbackLocale, messages } from '@/locales' 3 | 4 | export default createI18n({ 5 | legacy: false, 6 | locale: defaultLocale, 7 | fallbackLocale, 8 | messages, 9 | pluralRules: { 10 | ru: (choice, choicesLength) => { 11 | if (choice === 0) { 12 | return 0 13 | } 14 | const teen = choice > 10 && choice < 20 15 | const endsWithOne = choice % 10 === 1 16 | if (choicesLength == 2) { 17 | return choice === 1 ? 0 : 1 18 | } 19 | if (choicesLength < 4) { 20 | return !teen && endsWithOne ? 1 : 2 21 | } 22 | if (!teen && endsWithOne) { 23 | return 1 24 | } 25 | if (!teen && choice % 10 >= 2 && choice % 10 <= 4) { 26 | return 2 27 | } 28 | return choicesLength < 4 ? 2 : 3 29 | } 30 | } 31 | }) 32 | -------------------------------------------------------------------------------- /src/plugins/pinia.ts: -------------------------------------------------------------------------------- 1 | import { createPinia } from 'pinia' 2 | import { persistencePlugin } from 'pinia-persistence-plugin' 3 | 4 | const pinia = createPinia() 5 | pinia.use( 6 | persistencePlugin({ 7 | assertStorage: () => {}, 8 | storeKeysPrefix: 'vuetorrent', 9 | persistenceDefault: false, 10 | ensureAsyncStorageUpdateOrder: true, 11 | debug: import.meta.env.DEV 12 | }) 13 | ) 14 | 15 | export default pinia 16 | -------------------------------------------------------------------------------- /src/plugins/router.ts: -------------------------------------------------------------------------------- 1 | import { routes } from '@/pages' 2 | import { useAppStore } from '@/stores' 3 | import { storeToRefs } from 'pinia' 4 | import { createRouter, createWebHashHistory } from 'vue-router' 5 | 6 | const router = createRouter({ 7 | history: createWebHashHistory(process.env.BASE_URL), 8 | routes 9 | }) 10 | 11 | router.beforeResolve((to, _, next) => { 12 | const { isAuthenticated } = storeToRefs(useAppStore()) 13 | const isPublic = to.meta.public === true 14 | 15 | if (!isPublic && !isAuthenticated.value) { 16 | return next({ name: 'login', query: { redirect: location.hash.slice(1) } }) 17 | } 18 | 19 | return next() 20 | }) 21 | 22 | export default router 23 | -------------------------------------------------------------------------------- /src/plugins/toastify.ts: -------------------------------------------------------------------------------- 1 | import 'vue3-toastify/dist/index.css' 2 | import { toast, type ToastContainerOptions } from 'vue3-toastify' 3 | 4 | export default { 5 | autoClose: 1500, 6 | clearOnUrlChange: false, 7 | containerId: toast.POSITION.BOTTOM_RIGHT, 8 | limit: 5, 9 | position: toast.POSITION.BOTTOM_RIGHT, 10 | theme: toast.THEME.COLORED 11 | } as ToastContainerOptions 12 | -------------------------------------------------------------------------------- /src/plugins/vuetify.ts: -------------------------------------------------------------------------------- 1 | import 'vuetify/styles' 2 | import themes, { DarkRedesigned } from '@/themes' 3 | import { createVuetify } from 'vuetify' 4 | import * as components from 'vuetify/components' 5 | import * as directives from 'vuetify/directives' 6 | import '@mdi/font/css/materialdesignicons.css' 7 | 8 | export default createVuetify({ 9 | components, 10 | directives, 11 | display: { 12 | mobileBreakpoint: 'sm' 13 | }, 14 | icons: { 15 | defaultSet: 'mdi' 16 | }, 17 | theme: { 18 | defaultTheme: DarkRedesigned.id, 19 | variations: { 20 | colors: [ 21 | 'primary', 22 | 'secondary', 23 | 'torrent-allocating', 24 | 'torrent-checking_disk', 25 | 'torrent-checking_resume_data', 26 | 'torrent-dl_forced', 27 | 'torrent-dl_stopped', 28 | 'torrent-dl_queued', 29 | 'torrent-dl_stalled', 30 | 'torrent-downloading', 31 | 'torrent-error', 32 | 'torrent-forced_meta_download', 33 | 'torrent-meta_download', 34 | 'torrent-missing_files', 35 | 'torrent-moving', 36 | 'torrent-ul_forced', 37 | 'torrent-ul_stopped', 38 | 'torrent-ul_queued', 39 | 'torrent-ul_stalled', 40 | 'torrent-unknown', 41 | 'torrent-uploading' 42 | ], 43 | lighten: 3, 44 | darken: 3 45 | }, 46 | themes 47 | } 48 | }) 49 | -------------------------------------------------------------------------------- /src/polyfills.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'array.prototype.tosorted' { 2 | export default function toSorted(arr: T[], compareFn: (a: T, b: T) => number): T[] 3 | } 4 | -------------------------------------------------------------------------------- /src/services/Github.ts: -------------------------------------------------------------------------------- 1 | import { AxiosInstance } from 'axios' 2 | import axios from 'axios' 3 | 4 | export class Github { 5 | private axios: AxiosInstance 6 | 7 | constructor() { 8 | this.axios = axios.create() 9 | } 10 | 11 | /** 12 | * Fetches the latest version of VueTorrent 13 | */ 14 | async getVersion(): Promise { 15 | const { data } = await this.axios.get('https://api.github.com/repos/vuetorrent/vuetorrent/releases/latest') 16 | return data.tag_name 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/services/backend.ts: -------------------------------------------------------------------------------- 1 | import axios, { AxiosInstance } from 'axios' 2 | 3 | class BackendProvider { 4 | private axios: AxiosInstance 5 | private up: boolean = false 6 | 7 | constructor() { 8 | let baseURL = `${location.origin}${location.pathname}` 9 | if (!baseURL.endsWith('/')) baseURL += '/' 10 | baseURL += 'backend' 11 | 12 | this.axios = axios.create({ 13 | baseURL, 14 | withCredentials: true, 15 | headers: { 16 | put: { 'Content-Type': 'application/json' } 17 | } 18 | }) 19 | } 20 | 21 | /** 22 | * Ping the backend to check if it's up 23 | * @returns true if backend is up, false otherwise 24 | */ 25 | async ping(): Promise { 26 | return await this.axios 27 | .get('/ping') 28 | .then( 29 | res => res.data === 'pong', 30 | () => false 31 | ) 32 | .then(ok => { 33 | this.up = ok 34 | 35 | return ok 36 | }) 37 | } 38 | 39 | /** 40 | * Get all values 41 | */ 42 | async getAll(): Promise | null> { 43 | if (!this.up) return null 44 | 45 | return this.axios.get('/config').then(res => res.data) 46 | } 47 | 48 | /** 49 | * Get a single value 50 | * @param key 51 | * @returns string or null if key doesn't exists 52 | */ 53 | async get(key: string): Promise { 54 | if (!this.up) return null 55 | 56 | return this.axios.get(`/config/${key}`).then( 57 | res => res.data[key], 58 | () => null 59 | ) 60 | } 61 | 62 | /** 63 | * Set a value 64 | * @param key 65 | * @param value 66 | * @returns true if value was set, false otherwise 67 | */ 68 | async set(key: string, value: string): Promise { 69 | if (!this.up) return false 70 | 71 | return this.axios.put(`/config/${key}`, { value }).then( 72 | () => true, 73 | () => false 74 | ) 75 | } 76 | 77 | /** 78 | * Delete a value 79 | * @param key 80 | * @returns true if value was deleted, false otherwise 81 | */ 82 | async del(key: string): Promise { 83 | if (!this.up) return false 84 | 85 | return this.axios.delete(`/config/${key}`).then( 86 | () => true, 87 | () => false 88 | ) 89 | } 90 | 91 | async update() { 92 | return this.axios.get('/update') 93 | } 94 | } 95 | 96 | export const backend = new BackendProvider() 97 | -------------------------------------------------------------------------------- /src/services/qbit/index.ts: -------------------------------------------------------------------------------- 1 | import IProvider from './IProvider' 2 | import MockProvider from './MockProvider' 3 | import QBitProvider from './QbitProvider' 4 | 5 | const qbit: IProvider = 6 | import.meta.env.MODE === 'demo' || (import.meta.env.DEV && import.meta.env.VITE_USE_MOCK_PROVIDER === 'true') ? MockProvider.getInstance() : QBitProvider.getInstance() 7 | 8 | export default qbit 9 | -------------------------------------------------------------------------------- /src/stores/dialog.ts: -------------------------------------------------------------------------------- 1 | import { acceptHMRUpdate, defineStore } from 'pinia' 2 | import { v4 as uuidv4 } from 'uuid' 3 | import { AllowedComponentProps, Component, computed, shallowRef, triggerRef, VNodeProps } from 'vue' 4 | 5 | type ComponentProps = C extends new (...args: any) => any ? Omit['$props'], keyof VNodeProps | keyof AllowedComponentProps> : never 6 | 7 | type DialogTemplate = { 8 | component: C 9 | props: ComponentProps 10 | guid: string 11 | onClose?: () => any | Promise 12 | } 13 | 14 | export const useDialogStore = defineStore('dialogs', () => { 15 | const dialogs = shallowRef>>(new Map()) 16 | 17 | const hasActiveDialog = computed(() => dialogs.value.size > 0) 18 | 19 | function isDialogOpened(guid: string) { 20 | return dialogs.value.has(guid) 21 | } 22 | 23 | function createDialog(component: C, props?: Omit, 'guid'>, onClose?: () => any | Promise) { 24 | const guid = uuidv4() 25 | dialogs.value.set(guid, { 26 | component, 27 | props: props || {}, 28 | guid, 29 | onClose 30 | }) 31 | triggerRef(dialogs) 32 | 33 | return guid 34 | } 35 | 36 | function deleteDialog(guid: string) { 37 | const template = dialogs.value.get(guid) 38 | if (template && template.onClose) { 39 | template.onClose() 40 | } 41 | dialogs.value.delete(guid) 42 | triggerRef(dialogs) 43 | } 44 | 45 | return { 46 | dialogs, 47 | hasActiveDialog, 48 | isDialogOpened, 49 | createDialog, 50 | deleteDialog, 51 | $reset: () => { 52 | dialogs.value.clear() 53 | triggerRef(dialogs) 54 | } 55 | } 56 | }) 57 | 58 | if (import.meta.hot) { 59 | import.meta.hot.accept(acceptHMRUpdate(useDialogStore, import.meta.hot)) 60 | } 61 | -------------------------------------------------------------------------------- /src/stores/global.ts: -------------------------------------------------------------------------------- 1 | import { useTorrentDetailStore } from '@/stores/torrentDetail.ts' 2 | import { acceptHMRUpdate, defineStore } from 'pinia' 3 | import { v4 as uuidv4 } from 'uuid' 4 | import { ref } from 'vue' 5 | 6 | export const useGlobalStore = defineStore('global', () => { 7 | const routerDomKey = ref(uuidv4()) 8 | 9 | function forceReload() { 10 | routerDomKey.value = uuidv4() 11 | } 12 | 13 | return { 14 | routerDomKey, 15 | forceReload, 16 | $reset: () => { 17 | forceReload() 18 | } 19 | } 20 | }) 21 | 22 | if (import.meta.hot) { 23 | import.meta.hot.accept(acceptHMRUpdate(useTorrentDetailStore, import.meta.hot)) 24 | } 25 | -------------------------------------------------------------------------------- /src/stores/history.ts: -------------------------------------------------------------------------------- 1 | import { HistoryKey } from '@/constants/vuetorrent' 2 | import { acceptHMRUpdate, defineStore } from 'pinia' 3 | import { reactive, ref } from 'vue' 4 | 5 | type History = Partial> 6 | 7 | export const useHistoryStore = defineStore( 8 | 'history', 9 | () => { 10 | const _history = reactive({}) 11 | const historySize = ref(3) 12 | 13 | function pushValueToHistory(key: HistoryKey, value: string) { 14 | if (!value) return 15 | 16 | const historyValue = getHistory(key) 17 | historyValue.splice(0, 0, value) 18 | 19 | const valueIndex = historyValue.indexOf(value, 1) 20 | if (valueIndex !== -1) { 21 | historyValue.splice(valueIndex, 1) 22 | } 23 | 24 | if (historyValue.length > historySize.value) { 25 | historyValue.splice(historySize.value, historyValue.length - historySize.value) 26 | } 27 | 28 | _history[key] = historyValue 29 | } 30 | 31 | function getHistory(key: HistoryKey) { 32 | return _history[key] || [] 33 | } 34 | 35 | return { 36 | _history, 37 | historySize, 38 | pushValueToHistory, 39 | getHistory, 40 | $reset: () => { 41 | for (const [key] of Object.entries(_history)) { 42 | delete _history[key as HistoryKey] 43 | } 44 | } 45 | } 46 | }, 47 | { 48 | persistence: { 49 | enabled: true, 50 | storageItems: [{ storage: localStorage }] 51 | } 52 | } 53 | ) 54 | 55 | if (import.meta.hot) { 56 | import.meta.hot.accept(acceptHMRUpdate(useHistoryStore, import.meta.hot)) 57 | } 58 | -------------------------------------------------------------------------------- /src/stores/index.ts: -------------------------------------------------------------------------------- 1 | import { useAddTorrentStore } from './addTorrents' 2 | import { useAppStore } from './app' 3 | import { useCategoryStore } from './categories' 4 | import { useContentStore } from './content' 5 | import { useDashboardStore } from './dashboard' 6 | import { useDialogStore } from './dialog' 7 | import { useGlobalStore } from './global' 8 | import { useHistoryStore } from './history' 9 | import { useLogStore } from './logs' 10 | import { useMaindataStore } from './maindata' 11 | import { useNavbarStore } from './navbar' 12 | import { usePreferenceStore } from './preferences' 13 | import { useRssStore } from './rss' 14 | import { useSearchEngineStore } from './searchEngine' 15 | import { useTagStore } from './tags' 16 | import { useTorrentCreatorStore } from './torrentCreator' 17 | import { useTorrentDetailStore } from './torrentDetail' 18 | import { useTorrentStore } from './torrents' 19 | import { useTrackerStore } from './trackers' 20 | import { useVueTorrentStore } from './vuetorrent' 21 | 22 | export { 23 | useAddTorrentStore, 24 | useAppStore, 25 | useCategoryStore, 26 | useContentStore, 27 | useDashboardStore, 28 | useDialogStore, 29 | useGlobalStore, 30 | useHistoryStore, 31 | useLogStore, 32 | useMaindataStore, 33 | useNavbarStore, 34 | usePreferenceStore, 35 | useRssStore, 36 | useSearchEngineStore, 37 | useTagStore, 38 | useTorrentCreatorStore, 39 | useTorrentDetailStore, 40 | useTorrentStore, 41 | useTrackerStore, 42 | useVueTorrentStore 43 | } 44 | -------------------------------------------------------------------------------- /src/stores/navbar.ts: -------------------------------------------------------------------------------- 1 | import { acceptHMRUpdate, defineStore } from 'pinia' 2 | import { computed, ref } from 'vue' 3 | import { useDisplay } from 'vuetify' 4 | 5 | const GRAPH_SIZE = 15 6 | 7 | export const useNavbarStore = defineStore( 8 | 'navbar', 9 | () => { 10 | const { mobile } = useDisplay({ mobileBreakpoint: 'md' }) 11 | 12 | const isDrawerOpen = ref(!mobile.value) 13 | 14 | const _timeData = ref(new Array(GRAPH_SIZE).fill(new Date().getTime())) 15 | const _downloadData = ref(new Array(GRAPH_SIZE).fill(0)) 16 | const _uploadData = ref(new Array(GRAPH_SIZE).fill(0)) 17 | 18 | const downloadData = computed(() => _timeData.value.map((e, i) => [e, _downloadData.value[i]])) 19 | const uploadData = computed(() => _timeData.value.map((e, i) => [e, _uploadData.value[i]])) 20 | 21 | function pushTimeData() { 22 | _timeData.value.shift() 23 | _timeData.value.push(new Date().getTime()) 24 | } 25 | 26 | function pushDownloadData(data?: number) { 27 | _downloadData.value.shift() 28 | _downloadData.value.push(data ?? 0) 29 | } 30 | 31 | function pushUploadData(data?: number) { 32 | _uploadData.value.shift() 33 | _uploadData.value.push(data ?? 0) 34 | } 35 | 36 | return { 37 | isDrawerOpen, 38 | _timeData, 39 | _downloadData, 40 | _uploadData, 41 | downloadData, 42 | uploadData, 43 | pushTimeData, 44 | pushDownloadData, 45 | pushUploadData, 46 | $reset: () => { 47 | _timeData.value = new Array(GRAPH_SIZE).fill(new Date().getTime()) 48 | _downloadData.value = new Array(GRAPH_SIZE).fill(0) 49 | _uploadData.value = new Array(GRAPH_SIZE).fill(0) 50 | } 51 | } 52 | }, 53 | { 54 | persistence: { 55 | enabled: true, 56 | storageItems: [ 57 | { storage: sessionStorage, excludePaths: ['isDrawerOpen'] }, 58 | { storage: localStorage, includePaths: ['isDrawerOpen'] } 59 | ] 60 | } 61 | } 62 | ) 63 | 64 | if (import.meta.hot) { 65 | import.meta.hot.accept(acceptHMRUpdate(useNavbarStore, import.meta.hot)) 66 | } 67 | -------------------------------------------------------------------------------- /src/stores/preferences.ts: -------------------------------------------------------------------------------- 1 | import { AppPreferencesPayload } from '@/types/qbit/payloads' 2 | import { ref } from 'vue' 3 | import { acceptHMRUpdate, defineStore } from 'pinia' 4 | import AppPreferences from '@/types/qbit/models/AppPreferences' 5 | import qbit from '@/services/qbit' 6 | 7 | export const usePreferenceStore = defineStore( 8 | 'preferences', 9 | () => { 10 | const preferences = ref() 11 | 12 | async function fetchPreferences() { 13 | preferences.value = await qbit.getPreferences() 14 | } 15 | 16 | async function setPreferences(newPref?: AppPreferencesPayload) { 17 | await qbit.setPreferences(newPref ?? preferences.value!) 18 | } 19 | 20 | return { 21 | preferences, 22 | fetchPreferences, 23 | setPreferences, 24 | $reset: async () => { 25 | await fetchPreferences() 26 | } 27 | } 28 | }, 29 | { 30 | persistence: { 31 | enabled: true, 32 | storageItems: [{ storage: sessionStorage }] 33 | } 34 | } 35 | ) 36 | 37 | if (import.meta.hot) { 38 | import.meta.hot.accept(acceptHMRUpdate(usePreferenceStore, import.meta.hot)) 39 | } 40 | -------------------------------------------------------------------------------- /src/stores/torrentCreator.ts: -------------------------------------------------------------------------------- 1 | import qbit from '@/services/qbit' 2 | import { TorrentCreatorParams, TorrentCreatorTask } from '@/types/qbit/models' 3 | import { defineStore } from 'pinia' 4 | import { ref } from 'vue' 5 | 6 | export const useTorrentCreatorStore = defineStore('torrentCreator', () => { 7 | const tasks = ref([]) 8 | 9 | async function fetchTasks() { 10 | tasks.value = await qbit.getTorrentCreatorStatus() 11 | } 12 | 13 | async function createTask(params: TorrentCreatorParams) { 14 | return await qbit.addTorrentCreatorTask(params) 15 | } 16 | 17 | async function deleteTask(taskID: string) { 18 | return await qbit.deleteTorrentCreatorTask(taskID) 19 | } 20 | 21 | async function downloadTorrent(taskID: string) { 22 | return await qbit.getTorrentCreatorOutput(taskID) 23 | } 24 | 25 | return { 26 | tasks, 27 | fetchTasks, 28 | createTask, 29 | deleteTask, 30 | downloadTorrent 31 | } 32 | }) 33 | -------------------------------------------------------------------------------- /src/stores/torrentDetail.ts: -------------------------------------------------------------------------------- 1 | import qbit from '@/services/qbit' 2 | import { TorrentProperties } from '@/types/qbit/models' 3 | import { acceptHMRUpdate, defineStore } from 'pinia' 4 | import { ref } from 'vue' 5 | 6 | export const useTorrentDetailStore = defineStore( 7 | 'torrentDetail', 8 | () => { 9 | const tab = ref('overview') 10 | const properties = ref() 11 | 12 | async function fetchProperties(hash: string) { 13 | properties.value = await qbit.getTorrentProperties(hash) 14 | } 15 | 16 | return { 17 | tab, 18 | properties, 19 | fetchProperties, 20 | $reset: () => { 21 | properties.value = undefined 22 | } 23 | } 24 | }, 25 | { 26 | persistence: { 27 | enabled: true, 28 | storageItems: [{ storage: localStorage, includePaths: ['tab'] }] 29 | } 30 | } 31 | ) 32 | 33 | if (import.meta.hot) { 34 | import.meta.hot.accept(acceptHMRUpdate(useTorrentDetailStore, import.meta.hot)) 35 | } 36 | -------------------------------------------------------------------------------- /src/styles/styles.scss: -------------------------------------------------------------------------------- 1 | ul.no-bullet { 2 | list-style-type: none; 3 | } 4 | 5 | .cursor-pointer { 6 | cursor: pointer; 7 | } 8 | 9 | .cursor-help { 10 | cursor: help; 11 | } 12 | 13 | @each $type in row, column { 14 | .flex-gap, 15 | .flex-gap-#{$type} { 16 | &-small { 17 | #{$type}-gap: 4px; 18 | } 19 | 20 | & { 21 | #{$type}-gap: 8px; 22 | } 23 | 24 | &-large { 25 | #{$type}-gap: 16px; 26 | } 27 | } 28 | } 29 | 30 | .wrap-word { 31 | overflow-wrap: break-word !important; 32 | } 33 | 34 | .wrap-anywhere { 35 | overflow-wrap: anywhere !important; 36 | } 37 | 38 | .text-noselect { 39 | -webkit-touch-callout: default; /* iOS Safari */ 40 | -webkit-user-select: none; /* Safari */ 41 | -khtml-user-select: none; /* Konqueror HTML */ 42 | -moz-user-select: none; /* Firefox */ 43 | -ms-user-select: none; /* Internet Explorer/Edge */ 44 | user-select: none; 45 | /* Non-prefixed version, currently supported by Chrome and Opera */ 46 | } 47 | 48 | .text-select { 49 | -webkit-touch-callout: default; 50 | -webkit-user-select: text; 51 | -khtml-user-select: text; 52 | -moz-user-select: text; 53 | -ms-user-select: text; 54 | user-select: text; 55 | } 56 | 57 | .inherit-bg { 58 | background-color: inherit !important; 59 | } 60 | 61 | /* scrollbar */ 62 | ::-webkit-scrollbar { 63 | width: 6px; 64 | background: background-color; 65 | } 66 | ::-webkit-scrollbar-thumb { 67 | border-radius: 16px; 68 | background: #6b7280; 69 | } 70 | 71 | /* Fix for ripple effect on Safari */ 72 | tr.ripple-fix { 73 | transform: translate(0); 74 | clip-path: inset(0); 75 | } 76 | 77 | /* iOS PWA */ 78 | .ios-padding { 79 | padding-top: calc(env(safe-area-inset-top) * 0.5); 80 | } 81 | 82 | .ios-margin { 83 | margin-top: calc(env(safe-area-inset-top) * 0.5); 84 | } 85 | -------------------------------------------------------------------------------- /src/themes/dark/legacy.ts: -------------------------------------------------------------------------------- 1 | import colors from 'vuetify/util/colors' 2 | import { getVariables } from '../global' 3 | 4 | export default { 5 | id: 'dark-legacy', 6 | theme: { 7 | dark: true, 8 | colors: { 9 | primary: '#35495E', 10 | secondary: '#415c75', 11 | navbar: '#273845', 12 | download: '#5BB974', 13 | background: '#121212', 14 | selected: colors.grey.darken1, 15 | red: colors.red.accent3, 16 | ...getVariables(true) 17 | } 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/themes/dark/oled.ts: -------------------------------------------------------------------------------- 1 | import colors from 'vuetify/util/colors' 2 | import DarkLegacy from './legacy' 3 | 4 | export default { 5 | id: 'dark-oled', 6 | theme: { 7 | dark: true, 8 | colors: { 9 | ...DarkLegacy.theme.colors, 10 | 'torrent-ul_stalled': colors.blue.darken4, 11 | 'torrent-uploading': colors.teal.darken2 12 | } 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/themes/dark/redesigned.ts: -------------------------------------------------------------------------------- 1 | import colors from 'vuetify/util/colors' 2 | import { getVariables } from '../global' 3 | 4 | export default { 5 | id: 'dark-redesigned', 6 | theme: { 7 | dark: true, 8 | colors: { 9 | primary: '#28483E', 10 | secondary: '#306052', 11 | navbar: '#28483E', 12 | download: '#7ACA47', 13 | background: '#121212', 14 | selected: colors.grey.darken1, 15 | red: colors.red.accent3, 16 | ...getVariables(true) 17 | } 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/themes/index.ts: -------------------------------------------------------------------------------- 1 | import { ThemeDefinition } from 'vuetify' 2 | import DarkRedesigned from './dark/redesigned' 3 | import DarkLegacy from './dark/legacy' 4 | import DarkOled from './dark/oled' 5 | import LightRedesigned from './light/redesigned' 6 | import LightLegacy from './light/legacy' 7 | 8 | const themes = [DarkLegacy, DarkRedesigned, DarkOled, LightLegacy, LightRedesigned] 9 | 10 | export default themes.reduce( 11 | (obj, theme) => { 12 | obj[theme.id] = theme.theme 13 | return obj 14 | }, 15 | {} as Record 16 | ) 17 | 18 | export { themes, DarkLegacy, DarkRedesigned, DarkOled, LightLegacy, LightRedesigned } 19 | -------------------------------------------------------------------------------- /src/themes/light/legacy.ts: -------------------------------------------------------------------------------- 1 | import colors from 'vuetify/util/colors' 2 | import { getVariables } from '../global' 3 | 4 | export default { 5 | id: 'light-legacy', 6 | theme: { 7 | dark: false, 8 | colors: { 9 | primary: '#35495E', 10 | secondary: '#3E556D', 11 | navbar: '#273845', 12 | download: '#5BB974', 13 | background: colors.grey.lighten4, 14 | selected: colors.grey.lighten2, 15 | red: colors.red.accent2, 16 | ...getVariables(false) 17 | } 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/themes/light/redesigned.ts: -------------------------------------------------------------------------------- 1 | import colors from 'vuetify/util/colors' 2 | import { getVariables } from '../global' 3 | 4 | export default { 5 | id: 'light-redesigned', 6 | theme: { 7 | dark: false, 8 | colors: { 9 | primary: '#28483E', 10 | secondary: '#306052', 11 | navbar: '#28483E', 12 | download: '#7ACA47', 13 | background: colors.grey.lighten4, 14 | selected: colors.grey.lighten2, 15 | red: colors.red.accent2, 16 | ...getVariables(false) 17 | } 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/types/qbit/models/BuildInfo.ts: -------------------------------------------------------------------------------- 1 | export default interface BuildInfo { 2 | bitness: number 3 | boost: string 4 | libtorrent: string 5 | openssl: string 6 | platform: 'windows' | 'macos' | 'linux' | 'unknown' 7 | qt: string 8 | zlib: string 9 | } 10 | -------------------------------------------------------------------------------- /src/types/qbit/models/Category.ts: -------------------------------------------------------------------------------- 1 | export default interface Category { 2 | name: string 3 | savePath: string 4 | } 5 | -------------------------------------------------------------------------------- /src/types/qbit/models/Feed.ts: -------------------------------------------------------------------------------- 1 | import FeedArticle from './FeedArticle' 2 | 3 | export default interface Feed { 4 | name: string 5 | uid: string 6 | url: string 7 | title?: string 8 | lastBuildDate?: string 9 | isLoading?: boolean 10 | hasError?: boolean 11 | articles?: FeedArticle[] 12 | } 13 | -------------------------------------------------------------------------------- /src/types/qbit/models/FeedArticle.ts: -------------------------------------------------------------------------------- 1 | export default interface FeedArticle { 2 | /** Article author */ 3 | author: string 4 | /** Article category */ 5 | category?: string 6 | /** Article publication date */ 7 | date: string 8 | /** Article description */ 9 | description?: string 10 | /** Article ID */ 11 | id: string 12 | /** Whether the article has already been read */ 13 | isRead?: boolean 14 | /** Article link */ 15 | link: string 16 | /** Article title */ 17 | title: string 18 | /** Torrent download URL */ 19 | torrentURL: string 20 | } 21 | -------------------------------------------------------------------------------- /src/types/qbit/models/Log.ts: -------------------------------------------------------------------------------- 1 | import { LogType } from '@/constants/qbit' 2 | 3 | export default interface Log { 4 | id: number 5 | message: string 6 | timestamp: number 7 | type: LogType 8 | } 9 | -------------------------------------------------------------------------------- /src/types/qbit/models/Peer.ts: -------------------------------------------------------------------------------- 1 | export default interface Peer { 2 | client: string 3 | connection: string 4 | country?: string 5 | country_code?: string 6 | dl_speed: number 7 | downloaded: number 8 | files: string 9 | flags: string 10 | flags_desc: string 11 | ip: string 12 | peer_id_client: string 13 | port: number 14 | progress: number 15 | relevance: number 16 | up_speed: number 17 | uploaded: number 18 | } 19 | -------------------------------------------------------------------------------- /src/types/qbit/models/SSLParameters.ts: -------------------------------------------------------------------------------- 1 | export default interface SSLParameters { 2 | ssl_certificate: string 3 | ssl_private_key: string 4 | ssl_dh_params: string 5 | } 6 | -------------------------------------------------------------------------------- /src/types/qbit/models/SearchJob.ts: -------------------------------------------------------------------------------- 1 | export default interface SearchJob { 2 | /** ID of the search job */ 3 | id: number 4 | } 5 | -------------------------------------------------------------------------------- /src/types/qbit/models/SearchPlugin.ts: -------------------------------------------------------------------------------- 1 | type PluginCategory = { id: string; name: string } 2 | 3 | export default interface SearchPlugin { 4 | /** Whether the plugin is enabled */ 5 | enabled: boolean 6 | /** Full name of the plugin */ 7 | fullName: string 8 | /** Short name of the plugin */ 9 | name: string 10 | /** List of category objects */ 11 | supportedCategories: PluginCategory[] 12 | /** URL of the torrent site */ 13 | url: string 14 | /** Installed version of the plugin */ 15 | version: string 16 | } 17 | -------------------------------------------------------------------------------- /src/types/qbit/models/SearchResult.ts: -------------------------------------------------------------------------------- 1 | export default interface SearchResult { 2 | /** URL of the torrent's description page */ 3 | descrLink: string 4 | /** Name of the file */ 5 | fileName: string 6 | /** Size of the file in Bytes */ 7 | fileSize: number 8 | /** Torrent download link (usually either .torrent file or magnet link) */ 9 | fileUrl: string 10 | /** Number of leechers */ 11 | nbLeechers: number 12 | /** Number of seeders */ 13 | nbSeeders: number 14 | /** URL of the torrent site */ 15 | siteUrl: string 16 | /** 17 | * Engine name 18 | * @since 5.X 19 | */ 20 | engineName?: string 21 | /** 22 | * Publication date, in seconds since epoch 23 | * @since 5.X 24 | */ 25 | pubDate?: number 26 | } 27 | -------------------------------------------------------------------------------- /src/types/qbit/models/SearchStatus.ts: -------------------------------------------------------------------------------- 1 | export default interface SearchStatus { 2 | /** ID of the search job */ 3 | id: number 4 | /** Current status of the search job (either Running or Stopped) */ 5 | status: 'Running' | 'Stopped' 6 | /** Total number of results. If the status is Running this number may continue to increase */ 7 | total: number 8 | } 9 | -------------------------------------------------------------------------------- /src/types/qbit/models/ServerState.ts: -------------------------------------------------------------------------------- 1 | import type { ConnectionStatus } from '@/constants/qbit' 2 | 3 | export default interface ServerState { 4 | alltime_dl: number 5 | alltime_ul: number 6 | average_time_queue: number 7 | connection_status: ConnectionStatus 8 | dht_nodes: number 9 | dl_info_data: number 10 | dl_info_speed: number 11 | dl_rate_limit: number 12 | free_space_on_disk: number 13 | global_ratio: string 14 | queued_io_jobs: number 15 | queueing: boolean 16 | read_cache_hits: string 17 | read_cache_overload: string 18 | refresh_interval: number 19 | total_buffers_size: number 20 | total_peer_connections: number 21 | total_queued_size: number 22 | total_wasted_session: number 23 | up_info_data: number 24 | up_info_speed: number 25 | up_rate_limit: number 26 | use_alt_speed_limits: boolean 27 | use_subcategories: boolean 28 | write_cache_overload: string 29 | } 30 | -------------------------------------------------------------------------------- /src/types/qbit/models/TorrentCreatorParams.ts: -------------------------------------------------------------------------------- 1 | import { TorrentFormat } from '@/constants/qbit' 2 | 3 | export default interface TorrentCreatorParams { 4 | /** Torrent comment */ 5 | comment?: string 6 | /** 7 | * Torrent format 8 | * @version libtorrent2 9 | * @default HYBRID 10 | */ 11 | format?: TorrentFormat 12 | /** 13 | * Should optimize piece alignment 14 | * @version libtorrent1 15 | * @default true 16 | */ 17 | optimizeAlignment?: boolean 18 | /** 19 | * Padded file size limit 20 | * @version libtorrent1 21 | * @default -1 22 | */ 23 | paddedFileSizeLimit?: number 24 | /** 25 | * Torrent piece size 26 | * @default 0 (auto) 27 | */ 28 | pieceSize?: number 29 | /** 30 | * Whether created torrent should be private 31 | * @default false 32 | */ 33 | private?: boolean 34 | /** 35 | * Source metadata field 36 | * used for cross-seeding by some private trackers 37 | */ 38 | source?: string 39 | /** 40 | * Source path containing files to include in torrent 41 | */ 42 | sourcePath: string 43 | /** 44 | * Whether to start seeding after torrent creation 45 | * @default if torrentFilePath is empty 46 | */ 47 | startSeeding?: boolean 48 | /** 49 | * Output torrent path 50 | */ 51 | torrentFilePath?: string 52 | /** 53 | * Tracker URLs to add to the torrent 54 | * separated by a pipe (|) 55 | */ 56 | trackers?: string 57 | /** 58 | * Web seed URLs to add to the torrent 59 | * separated by a pipe (|) 60 | */ 61 | urlSeeds?: string 62 | } 63 | -------------------------------------------------------------------------------- /src/types/qbit/models/TorrentCreatorTask.ts: -------------------------------------------------------------------------------- 1 | import { TorrentCreatorTaskStatus, TorrentFormat } from '@/constants/qbit' 2 | 3 | export default interface TorrentCreatorTask { 4 | /** 5 | * Torrent comment 6 | */ 7 | comment?: string 8 | /** Task error message if failed */ 9 | errorMessage?: string 10 | /** 11 | * Torrent format 12 | * Needs libtorrent2 13 | * @default HYBRID 14 | */ 15 | format?: TorrentFormat 16 | /** 17 | * Should optimize alignment 18 | * Needs libtorrent1 19 | * @default true 20 | */ 21 | optimizeAlignment?: boolean 22 | /** 23 | * Torrent piece size 24 | * @default 0 25 | */ 26 | pieceSize?: number 27 | /** 28 | * Whether created torrent should be private 29 | * @default false 30 | */ 31 | private?: boolean 32 | /** 33 | * Task progress 34 | * only if status === RUNNING 35 | * Between 0 and 100, as integer 36 | */ 37 | progress?: number 38 | /** 39 | * Source path containing files to include in torrent 40 | */ 41 | sourcePath: string 42 | /** 43 | * Task status 44 | */ 45 | status: TorrentCreatorTaskStatus 46 | taskID: string 47 | /** 48 | * Source metadata field 49 | * used for cross-seeding by some private trackers 50 | */ 51 | source?: string 52 | timeAdded: string 53 | /** 54 | * @example Wed May 20 03:40:13 1998 55 | */ 56 | timeFinished: string 57 | /** 58 | * @example Wed May 20 03:40:13 1998 59 | */ 60 | timeStarted: string 61 | /** 62 | * Output torrent path 63 | */ 64 | torrentFilePath?: string 65 | /** 66 | * Trackers list 67 | */ 68 | trackers?: string[] 69 | /** 70 | * URL seeds list 71 | */ 72 | urlSeeds?: string[] 73 | } 74 | -------------------------------------------------------------------------------- /src/types/qbit/models/TorrentFile.ts: -------------------------------------------------------------------------------- 1 | import type { FilePriority } from '@/constants/qbit' 2 | 3 | export default interface TorrentFile { 4 | /** Percentage of file pieces currently available (percentage/100) */ 5 | availability: number 6 | /** File index (starting at 0) */ 7 | index: number 8 | /** True if torrent is seeding/complete 9 | * @description This property is only found on the first file of the torrent 10 | */ 11 | is_seed?: boolean 12 | /** File name (including relative path) */ 13 | name: string 14 | /** The first number is the starting piece index and the second number is the ending piece index (inclusive) */ 15 | piece_range: [number, number] 16 | /** File priority. See possible values here below */ 17 | priority: FilePriority 18 | /** File progress (percentage/100) */ 19 | progress: number 20 | /** File size in bytes */ 21 | size: number 22 | } 23 | -------------------------------------------------------------------------------- /src/types/qbit/models/Tracker.ts: -------------------------------------------------------------------------------- 1 | import type { TrackerStatus } from '@/constants/qbit' 2 | 3 | export default interface Tracker { 4 | /** Tracker message (there is no way of knowing what this message is - it's up to tracker admins) */ 5 | msg: string 6 | /** Number of completed downlods for current torrent, as reported by the tracker */ 7 | num_downloaded: number 8 | /** Number of leeches for current torrent, as reported by the tracker */ 9 | num_leeches: number 10 | /** Number of peers for current torrent, as reported by the tracker */ 11 | num_peers: number 12 | /** Number of seeds for current torrent, as reported by the tracker */ 13 | num_seeds: number 14 | /** Tracker status. See the table below for possible values */ 15 | status: TrackerStatus 16 | /** Tracker priority tier. Lower tier trackers are tried before higher tiers. Tier numbers are valid when >= 0, < 0 is used as placeholder when tier does not exist for special entries (such as DHT). */ 17 | tier: number 18 | /** Tracker url */ 19 | url: string 20 | } 21 | -------------------------------------------------------------------------------- /src/types/qbit/models/index.ts: -------------------------------------------------------------------------------- 1 | import type AddTorrentParams from './AddTorrentParams' 2 | import { getEmptyParams } from './AddTorrentParams' 3 | import type AppPreferences from './AppPreferences' 4 | import type BuildInfo from './BuildInfo' 5 | import type Category from './Category' 6 | import type Feed from './Feed' 7 | import type FeedArticle from './FeedArticle' 8 | import type { FeedRule, LegacyFeedRule } from './FeedRule' 9 | import type Log from './Log' 10 | import type Peer from './Peer' 11 | import type SearchJob from './SearchJob' 12 | import type SearchPlugin from './SearchPlugin' 13 | import type SearchResult from './SearchResult' 14 | import type SearchStatus from './SearchStatus' 15 | import type ServerState from './ServerState' 16 | import type SSLParameters from './SSLParameters' 17 | import type { RawTorrent as RawQbitTorrent, Torrent as QbitTorrent } from './Torrent' 18 | import type TorrentCreatorParams from './TorrentCreatorParams' 19 | import type TorrentCreatorTask from './TorrentCreatorTask' 20 | import type TorrentFile from './TorrentFile' 21 | import type TorrentProperties from './TorrentProperties' 22 | import type Tracker from './Tracker' 23 | 24 | type ApplicationVersion = string 25 | 26 | export { getEmptyParams } 27 | 28 | export type { 29 | AddTorrentParams, 30 | ApplicationVersion, 31 | AppPreferences, 32 | BuildInfo, 33 | Category, 34 | ServerState, 35 | Tracker, 36 | RawQbitTorrent, 37 | QbitTorrent, 38 | SSLParameters, 39 | Peer, 40 | TorrentCreatorParams, 41 | TorrentCreatorTask, 42 | TorrentFile, 43 | TorrentProperties, 44 | FeedRule, 45 | LegacyFeedRule, 46 | FeedArticle, 47 | Feed, 48 | SearchPlugin, 49 | SearchJob, 50 | SearchStatus, 51 | SearchResult, 52 | Log 53 | } 54 | -------------------------------------------------------------------------------- /src/types/qbit/payloads/AddTorrentPayload.ts: -------------------------------------------------------------------------------- 1 | import { ContentLayout, ShareLimitAction, StopCondition } from '@/constants/qbit/AppPreferences' 2 | 3 | export default interface AddTorrentPayload { 4 | /** Whether to add the torrent at the top of the queue */ 5 | addToTopOfQueue?: boolean 6 | /** Whether Automatic Torrent Management should be used */ 7 | autoTMM?: boolean 8 | /** Category for the torrent */ 9 | category?: string 10 | /** Content layout used when creating the torrent */ 11 | contentLayout?: ContentLayout 12 | /** Cookie sent to download the files using HTTP(S) */ 13 | cookie?: string 14 | /** Set torrent download speed limit. Unit in bytes/second */ 15 | dlLimit?: number 16 | /** 17 | * If enabled and set, will use this location to save torrent content when downloading 18 | * Otherwise, use `savepath` or default save path 19 | */ 20 | downloadPath?: string 21 | /** Prioritize download first last piece */ 22 | firstLastPiecePrio?: boolean 23 | /** 24 | * Set inactive torrent seeding time limit. Unit in minutes 25 | * - -1 to disable 26 | * - -2 to use global value 27 | */ 28 | inactiveSeedingTimeLimit?: number 29 | /** 30 | * Add torrents in the stopped state 31 | * @deprecated since 5.X, use stopped instead 32 | */ 33 | paused?: boolean 34 | /** Set torrent share ratio limit */ 35 | ratioLimit?: number 36 | /** Rename torrent */ 37 | rename?: string 38 | /** 39 | * Will use this location to save torrent content when download is complete 40 | * It will also be used when `downloadPath` is disabled or not set 41 | */ 42 | savepath?: string 43 | /** Set torrent seeding time limit. Unit in minutes */ 44 | seedingTimeLimit?: number 45 | /** Enable sequential download */ 46 | sequentialDownload?: boolean 47 | /** TODO */ 48 | shareLimitAction?: ShareLimitAction 49 | /** Skip hash checking */ 50 | skip_checking?: boolean 51 | /** TODO */ 52 | ssl_certificate?: string 53 | /** TODO */ 54 | ssl_dh_params?: string 55 | /** TODO */ 56 | ssl_private_key?: string 57 | /** Torrent stop condition */ 58 | stopCondition?: StopCondition 59 | /** Add torrents in the stopped state */ 60 | stopped?: boolean 61 | /** Tags for the torrent, split by ',' */ 62 | tags?: string 63 | /** Set torrent upload speed limit. Unit in bytes/second */ 64 | upLimit?: number 65 | /** Whether to enable use of `downloadPath` */ 66 | useDownloadPath?: boolean 67 | } 68 | -------------------------------------------------------------------------------- /src/types/qbit/payloads/AppPreferencesPayload.ts: -------------------------------------------------------------------------------- 1 | import type { AppPreferences } from '@/types/qbit/models' 2 | 3 | export type AppPreferencesPayload = Partial 4 | -------------------------------------------------------------------------------- /src/types/qbit/payloads/CreateFeedPayload.ts: -------------------------------------------------------------------------------- 1 | export default interface CreateFeedPayload { 2 | url: string 3 | name: string 4 | } 5 | -------------------------------------------------------------------------------- /src/types/qbit/payloads/GetTorrentPayload.ts: -------------------------------------------------------------------------------- 1 | import { FilterState } from '@/constants/qbit' 2 | import { QbitTorrent } from '@/types/qbit/models' 3 | 4 | export default interface GetTorrentPayload { 5 | filter?: FilterState 6 | category?: string 7 | tag?: string 8 | hashes?: string 9 | /** @since 5.X */ 10 | private?: boolean 11 | sort?: keyof QbitTorrent 12 | reverse?: boolean 13 | limit?: number 14 | offset?: number 15 | } 16 | -------------------------------------------------------------------------------- /src/types/qbit/payloads/LoginPayload.ts: -------------------------------------------------------------------------------- 1 | export default interface LoginPayload { 2 | /** Username used to access the WebUI */ 3 | username: string 4 | /** Password used to access the WebUI */ 5 | password: string 6 | } 7 | -------------------------------------------------------------------------------- /src/types/qbit/payloads/PeerLogPayload.ts: -------------------------------------------------------------------------------- 1 | export default interface PeerLogPayload { 2 | /** Exclude messages with "message id" <= last_known_id (default: -1) */ 3 | last_known_id?: number 4 | } 5 | -------------------------------------------------------------------------------- /src/types/qbit/payloads/index.ts: -------------------------------------------------------------------------------- 1 | import type LoginPayload from './LoginPayload' 2 | import type AddTorrentPayload from './AddTorrentPayload' 3 | import type PeerLogPayload from './PeerLogPayload' 4 | import type { AppPreferencesPayload } from './AppPreferencesPayload' 5 | import type CreateFeedPayload from './CreateFeedPayload' 6 | import type GetTorrentPayload from './GetTorrentPayload' 7 | 8 | export { AppPreferencesPayload, LoginPayload, AddTorrentPayload, PeerLogPayload, CreateFeedPayload, GetTorrentPayload } 9 | -------------------------------------------------------------------------------- /src/types/qbit/responses/MaindataResponse.ts: -------------------------------------------------------------------------------- 1 | import type { Category, RawQbitTorrent, ServerState } from '@/types/qbit/models' 2 | 3 | export type MaindataResponse = FullUpdate | PartialUpdate 4 | 5 | export function isFullUpdate(response: MaindataResponse): response is FullUpdate { 6 | return 'full_update' in response && response.full_update 7 | } 8 | 9 | interface FullUpdate { 10 | /** Whether the response contains all or partial data */ 11 | full_update: true 12 | /** 13 | * Response ID 14 | * Will cycle between 1 and 1,000,000 15 | **/ 16 | rid: number 17 | /** Current state of the server */ 18 | server_state: ServerState 19 | /** Categories data of the server */ 20 | categories?: Record 21 | /** Tags list of the server */ 22 | tags?: string[] 23 | /** Torrents data of the server */ 24 | torrents?: Record 25 | /** 26 | * Trackers data of the server 27 | * 28 | * Key: Tracker URL 29 | * 30 | * Value: Torrents hash array 31 | */ 32 | trackers?: Record 33 | } 34 | 35 | interface PartialUpdate { 36 | /** 37 | * Response ID 38 | * Will cycle between 1 and 1,000,000 39 | **/ 40 | rid: number 41 | /** Diff state of the server since last snapshot */ 42 | server_state?: Partial 43 | /** Added or updated categories since last snapshot */ 44 | categories?: Record> 45 | /** Removed categories' name since last snapshot */ 46 | categories_removed?: string[] 47 | /** Added tags since last snapshot */ 48 | tags?: string[] 49 | /** Removed tags since last snapshot */ 50 | tags_removed?: string[] 51 | /** Added or updated torrents since last snapshot */ 52 | torrents?: Record> 53 | /** Removed torrents' hash since last snapshot */ 54 | torrents_removed?: string[] 55 | /** 56 | * Added or updated trackers since last snapshot 57 | * 58 | * Key: Tracker URL 59 | * 60 | * Value: Torrents hash array 61 | */ 62 | trackers?: Record 63 | /** Removed trackers' URL since last snapshot */ 64 | trackers_removed?: string[] 65 | } 66 | -------------------------------------------------------------------------------- /src/types/qbit/responses/PeerLogResponse.ts: -------------------------------------------------------------------------------- 1 | interface PeerLog { 2 | // ID of the peer 3 | id: number 4 | // IP of the peer 5 | ip: string 6 | // Milliseconds since epoch 7 | timestamp: number 8 | // Whether or not the peer was blocked 9 | blocked: boolean 10 | // Reason of the block 11 | reason: string 12 | } 13 | 14 | export type PeerLogResponse = PeerLog[] 15 | -------------------------------------------------------------------------------- /src/types/qbit/responses/SearchResultsResponse.ts: -------------------------------------------------------------------------------- 1 | import type { SearchResult } from '@/types/qbit/models' 2 | 3 | export default interface SearchResultsResponse { 4 | /** Array of result objects- see table below */ 5 | results: SearchResult[] 6 | /** Current status of the search job (either Running or Stopped) */ 7 | status: 'Running' | 'Stopped' 8 | /** Total number of results. If the status is Running this number may continue to increase */ 9 | total: number 10 | } 11 | -------------------------------------------------------------------------------- /src/types/qbit/responses/TorrentPeersResponse.ts: -------------------------------------------------------------------------------- 1 | import type { Peer } from '@/types/qbit/models' 2 | 3 | export default interface TorrentPeersResponse { 4 | full_update?: boolean 5 | rid: number 6 | peers?: Record 7 | peers_removed?: string[] 8 | show_flags?: boolean 9 | } 10 | -------------------------------------------------------------------------------- /src/types/qbit/responses/index.ts: -------------------------------------------------------------------------------- 1 | import { MaindataResponse, isFullUpdate } from './MaindataResponse' 2 | import type { PeerLogResponse } from './PeerLogResponse' 3 | import type TorrentPeersResponse from './TorrentPeersResponse' 4 | import type SearchResultsResponse from './SearchResultsResponse' 5 | 6 | export { isFullUpdate, PeerLogResponse, TorrentPeersResponse, SearchResultsResponse } 7 | export type { MaindataResponse } 8 | -------------------------------------------------------------------------------- /src/types/vuetorrent/RightClickMenuEntryType.ts: -------------------------------------------------------------------------------- 1 | export type RightClickMenuEntryType = { 2 | text: string 3 | icon?: string 4 | hidden?: boolean 5 | disabled?: boolean 6 | disabledText?: string 7 | disabledIcon?: string 8 | action?: () => void 9 | children?: RightClickMenuEntryType[] 10 | slots?: { 11 | top?: RightClickMenuEntryType[] 12 | bottom?: RightClickMenuEntryType[] 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/types/vuetorrent/RightClickProperties.ts: -------------------------------------------------------------------------------- 1 | export default interface RightClickProperties { 2 | isVisible: boolean 3 | offset: [number, number] 4 | } 5 | -------------------------------------------------------------------------------- /src/types/vuetorrent/RssArticle.ts: -------------------------------------------------------------------------------- 1 | import { FeedArticle } from '@/types/qbit/models' 2 | 3 | export interface RssArticle extends FeedArticle { 4 | /** UID of the parent feed */ 5 | feedId: string 6 | /** Article publication date parsed by dayjs */ 7 | parsedDate: Date 8 | } 9 | -------------------------------------------------------------------------------- /src/types/vuetorrent/SearchData.ts: -------------------------------------------------------------------------------- 1 | import SearchResult from './SearchResult' 2 | 3 | interface SearchFilters { 4 | title: string 5 | category: string 6 | plugin: string 7 | } 8 | 9 | export interface SearchData { 10 | uniqueId: string 11 | id: number 12 | timer: NodeJS.Timeout | null 13 | lastQuery: string 14 | query: string 15 | itemsPerPage: number 16 | filters: SearchFilters 17 | results: SearchResult[] 18 | } 19 | -------------------------------------------------------------------------------- /src/types/vuetorrent/SearchResult.ts: -------------------------------------------------------------------------------- 1 | import type QSearchResult from '../qbit/models/SearchResult' 2 | 3 | export default interface SearchResult extends QSearchResult { 4 | downloaded?: true 5 | } 6 | -------------------------------------------------------------------------------- /src/types/vuetorrent/Torrent.ts: -------------------------------------------------------------------------------- 1 | import { TorrentState } from '@/constants/vuetorrent/TorrentState' 2 | 3 | export default interface Torrent { 4 | added_on: number 5 | amount_left: number 6 | auto_tmm: boolean 7 | availability: number 8 | available_peers: number 9 | available_seeds: number 10 | get avgDownloadSpeed(): number 11 | get avgUploadSpeed(): number 12 | get basename_content_path(): string 13 | get basename_download_path(): string 14 | get basename_save_path(): string 15 | category: string 16 | comment: string 17 | completed_on: number 18 | content_path: string 19 | dl_limit: number 20 | dlspeed: number 21 | download_path: string 22 | downloaded: number 23 | downloaded_session: number 24 | eta: number 25 | f_l_piece_prio: boolean 26 | forced: boolean 27 | get globalSpeed(): number 28 | get globalVolume(): number 29 | hasMetadata: boolean 30 | hash: string 31 | inactive_seeding_time_limit: number 32 | infohash_v1: string 33 | infohash_v2: string 34 | last_activity: number 35 | magnet: string 36 | name: string 37 | num_leechs: number 38 | num_seeds: number 39 | /** @since 5.0.0 */ 40 | popularity?: number 41 | priority: number 42 | /** @since 5.0.0 */ 43 | private?: boolean 44 | progress: number 45 | ratio: number 46 | ratio_limit: number 47 | /** @since 5.0.0 */ 48 | reannounce?: number 49 | /** @since 5.1.0 */ 50 | rootPath?: string 51 | savePath: string 52 | seeding_time: number 53 | seeding_time_limit: number 54 | seen_complete: number 55 | seq_dl: boolean 56 | size: number 57 | state: TorrentState 58 | super_seeding: boolean 59 | tags: string[] 60 | time_active: number 61 | total_size: number 62 | tracker: string 63 | get trackerDomain(): string 64 | trackers_count: number 65 | get truncated_hash(): string 66 | up_limit: number 67 | uploaded: number 68 | uploaded_session: number 69 | upspeed: number 70 | } 71 | -------------------------------------------------------------------------------- /src/types/vuetorrent/index.ts: -------------------------------------------------------------------------------- 1 | import type { RightClickMenuEntryType } from './RightClickMenuEntryType' 2 | import type RightClickProperties from './RightClickProperties' 3 | import type { RssArticle } from './RssArticle' 4 | import type { SearchData } from './SearchData' 5 | import type SearchResult from './SearchResult' 6 | import type Torrent from './Torrent' 7 | import type { TreeNode } from './TreeObjects' 8 | import { TreeFile, TreeFolder } from './TreeObjects' 9 | 10 | export { RssArticle, SearchData, Torrent, TreeNode, TreeFile, TreeFolder, RightClickMenuEntryType, RightClickProperties, SearchResult } 11 | -------------------------------------------------------------------------------- /src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | import { ImportMetaEnv as BaseImportMetaEnv } from 'vite' 4 | 5 | interface ImportMetaEnv extends BaseImportMetaEnv { 6 | readonly VITE_PACKAGE_VERSION: string 7 | 8 | readonly VITE_QBITTORRENT_TARGET: string 9 | 10 | readonly VITE_USE_MOCK_PROVIDER: string 11 | readonly VITE_FAKE_TORRENTS_COUNT: number 12 | } 13 | 14 | interface ImportMeta { 15 | readonly env: ImportMetaEnv 16 | } 17 | -------------------------------------------------------------------------------- /tests/setup.ts: -------------------------------------------------------------------------------- 1 | import { vi } from 'vitest' 2 | 3 | vi.mock('vue-router') 4 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "useDefineForClassFields": true, 5 | "module": "ESNext", 6 | "lib": ["ESNext", "ES2023", "DOM", "DOM.Iterable"], 7 | "skipLibCheck": true, 8 | 9 | /* Bundler mode */ 10 | "moduleResolution": "node", 11 | "allowImportingTsExtensions": true, 12 | "allowSyntheticDefaultImports": true, 13 | "resolveJsonModule": true, 14 | "isolatedModules": true, 15 | "noEmit": true, 16 | "jsx": "preserve", 17 | 18 | /* Linting */ 19 | "strict": true, 20 | "noUnusedLocals": true, 21 | "noUnusedParameters": true, 22 | "noFallthroughCasesInSwitch": true, 23 | 24 | "paths": { 25 | "@/*": ["./src/*"] 26 | }, 27 | "types": ["node", "vue3-toastify/global", "vitest/globals"] 28 | }, 29 | "include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"], 30 | "references": [{ "path": "./tsconfig.node.json" }] 31 | } 32 | -------------------------------------------------------------------------------- /tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "skipLibCheck": true, 5 | "module": "ESNext", 6 | "moduleResolution": "bundler", 7 | "allowSyntheticDefaultImports": true 8 | }, 9 | "include": ["vite.config.ts"] 10 | } 11 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | /// 2 | import vue from '@vitejs/plugin-vue' 3 | import { resolve } from 'node:path' 4 | import { fileURLToPath, URL } from 'node:url' 5 | import { defineConfig, loadEnv } from 'vite' 6 | import topLevelAwait from 'vite-plugin-top-level-await' 7 | import vuetify from 'vite-plugin-vuetify' 8 | 9 | // https://vitejs.dev/config/ 10 | export default defineConfig(({ mode }) => { 11 | const env = loadEnv(mode, process.cwd()) 12 | const qBittorrentTarget = env.VITE_QBITTORRENT_TARGET ?? 'http://localhost:8080' 13 | 14 | return { 15 | base: './', 16 | build: { 17 | target: 'esnext', 18 | outDir: mode === 'demo' ? './vuetorrent-demo' : './vuetorrent/public', 19 | rollupOptions: { 20 | output: { 21 | manualChunks: { 22 | // apexcharts: ['apexcharts', 'vue3-apexcharts'], 23 | vue: ['vue', 'vue-router', 'vue-i18n', 'vue3-toastify', 'vuedraggable', 'pinia', 'pinia-persistence-plugin'], 24 | vuetify: ['vuetify'] 25 | } 26 | } 27 | } 28 | }, 29 | css: { 30 | preprocessorOptions: { 31 | scss: { 32 | api: 'modern-compiler' 33 | } 34 | } 35 | }, 36 | define: { 37 | 'import.meta.env.VITE_PACKAGE_VERSION': JSON.stringify(process.env.npm_package_version), 38 | 'process.env': {} 39 | }, 40 | plugins: [ 41 | vue(), 42 | vuetify(), 43 | topLevelAwait({ 44 | promiseExportName: '__tla', 45 | promiseImportName: i => `__tla_${i}` 46 | }) 47 | ], 48 | publicDir: './public', 49 | resolve: { 50 | alias: { 51 | '@': fileURLToPath(new URL('./src', import.meta.url)) 52 | } 53 | }, 54 | server: { 55 | host: '0.0.0.0', 56 | port: 3000, 57 | proxy: { 58 | '/api': { 59 | secure: false, 60 | changeOrigin: true, 61 | xfwd: true, 62 | target: qBittorrentTarget 63 | }, 64 | '/backend': { 65 | secure: false, 66 | changeOrigin: true, 67 | target: qBittorrentTarget 68 | } 69 | } 70 | }, 71 | test: { 72 | environment: 'jsdom', 73 | globals: true, 74 | setupFiles: [resolve(__dirname, 'tests/setup.ts')], 75 | coverage: { 76 | reportsDirectory: './tests/unit/coverage' 77 | }, 78 | server: { 79 | deps: { 80 | inline: ['vuetify'] 81 | } 82 | } 83 | } 84 | } 85 | }) 86 | -------------------------------------------------------------------------------- /write-version.cjs: -------------------------------------------------------------------------------- 1 | const fs = require('fs') 2 | const path = require('path') 3 | 4 | const packageJson = require('./package.json') 5 | const version = packageJson.version 6 | 7 | const filePath = path.join(__dirname, 'vuetorrent', 'version.txt') 8 | fs.writeFileSync(filePath, version) 9 | --------------------------------------------------------------------------------