├── VERSION ├── deactivate.sh ├── .github ├── FUNDING.yml ├── workflows │ ├── tag.yml │ ├── docs.yml │ ├── dependabot-approve-and-auto-merge.yml │ ├── pypi-publish.yml │ ├── update-develop-branch.yml │ └── update-supported-versions.yml ├── ISSUE_TEMPLATE │ ├── config.yml │ ├── 3.docs_request.yml │ ├── 2.feature_request.yml │ └── 1.bug_report.yml ├── pull_request_template.md └── dependabot.yml ├── activate.sh ├── icons ├── qbm_logo.ico ├── qbm_logo.png └── qbm_logo.icns ├── web-ui ├── img │ ├── favicon.ico │ ├── qbm_logo.png │ ├── favicon-16x16.png │ ├── favicon-32x32.png │ ├── apple-touch-icon.png │ ├── android-chrome-192x192.png │ ├── android-chrome-512x512.png │ └── site.webmanifest ├── css │ ├── components │ │ ├── _skeleton.css │ │ ├── _alert.css │ │ ├── _badge.css │ │ ├── _pagination.css │ │ ├── _tabs.css │ │ ├── _accordion.css │ │ ├── _dropdown.css │ │ ├── _toggle-switch.css │ │ ├── _close-icon-button.css │ │ ├── _complex-object-card.css │ │ ├── _array-field.css │ │ ├── _toast.css │ │ ├── _modal.css │ │ ├── _buttons.css │ │ ├── _command-panel.css │ │ └── _key-value-list.css │ └── components.css ├── js │ ├── config-schemas │ │ ├── cat.js │ │ ├── qbt.js │ │ ├── cat_change.js │ │ ├── recyclebin.js │ │ ├── orphaned.js │ │ ├── directory.js │ │ ├── nohardlinks.js │ │ ├── tracker.js │ │ ├── commands.js │ │ └── settings.js │ └── utils │ │ ├── toast.js │ │ ├── dom.js │ │ ├── utils.js │ │ ├── theme-manager.js │ │ ├── modal.js │ │ └── categories.js └── README.md ├── desktop └── tauri │ ├── src │ ├── qbm_logo.png │ └── index.html │ └── src-tauri │ ├── bin │ └── .gitkeep │ ├── Cargo.toml │ ├── tauri.conf.json │ └── build.rs ├── modules ├── core │ ├── __init__.py │ ├── tags.py │ └── category.py ├── torrent_hash_generator.py ├── apprise.py ├── __init__.py └── notifiarr.py ├── MANIFEST.in ├── SUPPORTED_VERSIONS.json ├── .gitignore ├── docs ├── _Footer.md ├── _Sidebar.md ├── Home.md ├── v4-Migration-Guide.md ├── Unraid-Installation.md ├── Docker-Installation.md ├── Web-UI.md └── Installation.md ├── .dockerignore ├── scripts ├── pre-commit │ ├── update-readme-version.sh │ ├── update_develop_version.sh │ └── increase_version.sh ├── edit_tracker.py ├── edit_passkey.py ├── update-readme-version.py ├── remove_cross-seed_tag.py ├── ban_peers.py └── mover.py ├── CHANGELOG ├── ruff.toml ├── LICENSE ├── .pre-commit-config.yaml ├── pyproject.toml ├── setup.py ├── Dockerfile ├── entrypoint.sh └── README.md /VERSION: -------------------------------------------------------------------------------- 1 | 4.6.5 2 | -------------------------------------------------------------------------------- /deactivate.sh: -------------------------------------------------------------------------------- 1 | deactivate 2 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: bobokun 2 | -------------------------------------------------------------------------------- /activate.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | source .venv/bin/activate 3 | -------------------------------------------------------------------------------- /icons/qbm_logo.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/StuffAnThings/qbit_manage/HEAD/icons/qbm_logo.ico -------------------------------------------------------------------------------- /icons/qbm_logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/StuffAnThings/qbit_manage/HEAD/icons/qbm_logo.png -------------------------------------------------------------------------------- /icons/qbm_logo.icns: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/StuffAnThings/qbit_manage/HEAD/icons/qbm_logo.icns -------------------------------------------------------------------------------- /web-ui/img/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/StuffAnThings/qbit_manage/HEAD/web-ui/img/favicon.ico -------------------------------------------------------------------------------- /web-ui/img/qbm_logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/StuffAnThings/qbit_manage/HEAD/web-ui/img/qbm_logo.png -------------------------------------------------------------------------------- /web-ui/img/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/StuffAnThings/qbit_manage/HEAD/web-ui/img/favicon-16x16.png -------------------------------------------------------------------------------- /web-ui/img/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/StuffAnThings/qbit_manage/HEAD/web-ui/img/favicon-32x32.png -------------------------------------------------------------------------------- /desktop/tauri/src/qbm_logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/StuffAnThings/qbit_manage/HEAD/desktop/tauri/src/qbm_logo.png -------------------------------------------------------------------------------- /web-ui/img/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/StuffAnThings/qbit_manage/HEAD/web-ui/img/apple-touch-icon.png -------------------------------------------------------------------------------- /web-ui/img/android-chrome-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/StuffAnThings/qbit_manage/HEAD/web-ui/img/android-chrome-192x192.png -------------------------------------------------------------------------------- /web-ui/img/android-chrome-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/StuffAnThings/qbit_manage/HEAD/web-ui/img/android-chrome-512x512.png -------------------------------------------------------------------------------- /modules/core/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | modules.core contains all the core functions of qbit_manage such as updating categories/tags etc.. 3 | """ 4 | -------------------------------------------------------------------------------- /desktop/tauri/src-tauri/bin/.gitkeep: -------------------------------------------------------------------------------- 1 | # This directory contains server binaries during CI builds 2 | # Local development doesn't require these files 3 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.md 2 | include LICENSE 3 | include VERSION 4 | include SUPPORTED_VERSIONS.json 5 | include qbit_manage.py 6 | recursive-include web-ui * 7 | -------------------------------------------------------------------------------- /SUPPORTED_VERSIONS.json: -------------------------------------------------------------------------------- 1 | { 2 | "master": { 3 | "qbit": "v5.1.3", 4 | "qbitapi": "2025.11.0" 5 | }, 6 | "develop": { 7 | "qbit": "v5.1.4", 8 | "qbitapi": "2025.11.1" 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | *.log* 7 | *.yml 8 | .vscode/* 9 | !.github/** 10 | *.svg 11 | .venv* 12 | qbit_manage.egg-info/ 13 | .tox 14 | *.env 15 | **/build 16 | dist/ 17 | .roo* 18 | memory-bank 19 | **/src-tauri/gen 20 | **/src-tauri/target 21 | -------------------------------------------------------------------------------- /docs/_Footer.md: -------------------------------------------------------------------------------- 1 | ### Want to contribute to this Wiki or have suggestions? 2 | [Fork it and send a pull request.](https://github.com/StuffAnThings/qbit_manage/fork) 3 | 4 | [Submit a Docs request.](https://github.com/StuffAnThings/qbit_manage/issues/new?assignees=bobokun&labels=status%3Anot-yet-viewed%2Cdocumentation&template=3.docs_request.yml&title=%5BDocs%5D%3A+) 5 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | **/dist 2 | **/build 3 | *.spec 4 | **/__pycache__ 5 | /.vscode 6 | **/log 7 | README.md 8 | LICENSE 9 | .gitignore 10 | .dockerignore 11 | .git 12 | .github 13 | .vscode 14 | *.psd 15 | config/**/* 16 | config 17 | Dockerfile 18 | venv 19 | .idea 20 | .venv* 21 | test.py 22 | !config/config.yml.sample 23 | .flake8 24 | qbit_manage.egg-info/ 25 | .tox 26 | *.env 27 | __pycache__ 28 | *.pyc 29 | *.pyo 30 | *.pyd 31 | .env 32 | -------------------------------------------------------------------------------- /.github/workflows/tag.yml: -------------------------------------------------------------------------------- 1 | name: Tag New Version 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | 7 | jobs: 8 | tag: 9 | runs-on: ubuntu-latest 10 | steps: 11 | 12 | - uses: actions/checkout@v6 13 | with: 14 | token: ${{ secrets.PAT }} 15 | fetch-depth: 2 16 | 17 | - uses: Kometa-Team/tag-new-version@master 18 | with: 19 | version-command: | 20 | cat VERSION 21 | -------------------------------------------------------------------------------- /scripts/pre-commit/update-readme-version.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # Try to get the name of the current branch 4 | branch_name=$(git symbolic-ref --short HEAD 2> /dev/null) 5 | 6 | # If the command failed, exit with code 0 7 | if [ $? -ne 0 ]; then 8 | echo "Error: ref HEAD is not a symbolic ref" 9 | exit 0 10 | fi 11 | 12 | # Run the python script with the branch name as an argument 13 | python3 scripts/update-readme-version.py $branch_name 14 | -------------------------------------------------------------------------------- /web-ui/css/components/_skeleton.css: -------------------------------------------------------------------------------- 1 | /* Skeleton Loading */ 2 | .skeleton { 3 | background: linear-gradient( 4 | 90deg, 5 | var(--bg-accent) 25%, 6 | var(--bg-secondary) 50%, 7 | var(--bg-accent) 75% 8 | ); 9 | background-size: 200% 100%; 10 | animation: skeleton-loading 1.5s infinite; 11 | border-radius: var(--border-radius); 12 | } 13 | 14 | @keyframes skeleton-loading { 15 | 0% { 16 | background-position: 200% 0; 17 | } 18 | 100% { 19 | background-position: -200% 0; 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /web-ui/img/site.webmanifest: -------------------------------------------------------------------------------- 1 | { 2 | "name": "qBit Manage", 3 | "short_name": "qBM", 4 | "description": "qBittorrent Management Interface", 5 | "icons": [ 6 | { 7 | "src": "android-chrome-192x192.png", 8 | "sizes": "192x192", 9 | "type": "image/png" 10 | }, 11 | { 12 | "src": "android-chrome-512x512.png", 13 | "sizes": "512x512", 14 | "type": "image/png" 15 | } 16 | ], 17 | "theme_color": "#2196f3", 18 | "background_color": "#ffffff", 19 | "display": "standalone", 20 | "start_url": "/", 21 | "scope": "/" 22 | } 23 | -------------------------------------------------------------------------------- /CHANGELOG: -------------------------------------------------------------------------------- 1 | # Requirements Updated 2 | - "fastapi==0.122.0" 3 | - "qbittorrent-api==2025.11.1" 4 | - "ruff==0.14.6" 5 | 6 | # New Features 7 | - Adds max limit for unregistered torrent removal (New config option: `rem_unregistered_max_torrents`) (Fixes #975) 8 | - Adds tagging all private trackers (New config option: `private_tag`) (Fixes #883) 9 | 10 | # Bug Fixes 11 | - Fixes bug constantly updatingn share limits when using `upload_speed_on_limit_reached` (Fixes #959) (Thanks to @Seros) 12 | 13 | **Full Changelog**: https://github.com/StuffAnThings/qbit_manage/compare/v4.6.4...v4.6.5 14 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | contact_links: 3 | - name: Qbit Manage Wiki 4 | url: https://github.com/StuffAnThings/qbit_manage/wiki 5 | about: Please check the wiki to see if your question has already been answered. 6 | - name: Notifiarr Discord 7 | url: https://discord.com/invite/AURf8Yz 8 | about: Please post your question under the `qbit-manage` channel for support issues. 9 | - name: Ask a question 10 | url: https://github.com/StuffAnThings/qbit_manage/discussions 11 | about: Ask questions and discuss with other community members 12 | -------------------------------------------------------------------------------- /ruff.toml: -------------------------------------------------------------------------------- 1 | line-length = 130 2 | 3 | [lint] 4 | select = [ 5 | "I", # isort - import order 6 | "UP", # pyupgrade 7 | "T10", # debugger 8 | "E", # pycodestyle errors 9 | "W", # pycodestyle warnings 10 | "F", # pyflakes 11 | ] 12 | 13 | ignore = [ 14 | "E722", # E722 Do not use bare except, specify exception instead 15 | "E402", # E402 module level import not at top of file 16 | "UP007", # UP007: Added back support for Python 3.9 17 | "UP045", # UP045: Added back support for Python 3.9 18 | ] 19 | 20 | [lint.isort] 21 | force-single-line = true 22 | 23 | [format] 24 | line-ending = "auto" 25 | -------------------------------------------------------------------------------- /web-ui/css/components/_alert.css: -------------------------------------------------------------------------------- 1 | /* Alert Component */ 2 | .alert { 3 | padding: var(--spacing-md); 4 | border: 1px solid transparent; 5 | border-radius: var(--border-radius); 6 | margin-bottom: var(--spacing-md); 7 | } 8 | 9 | .alert-success { 10 | background-color: #f0fdf4; 11 | border-color: #bbf7d0; 12 | color: #166534; 13 | } 14 | 15 | .alert-warning { 16 | background-color: #fffbeb; 17 | border-color: #fed7aa; 18 | color: #92400e; 19 | } 20 | 21 | .alert-error { 22 | background-color: #fef2f2; 23 | border-color: #fecaca; 24 | color: #991b1b; 25 | } 26 | 27 | .alert-info { 28 | background-color: #eff6ff; 29 | border-color: #bfdbfe; 30 | color: #1e40af; 31 | } 32 | -------------------------------------------------------------------------------- /scripts/pre-commit/update_develop_version.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # Read the current version from the VERSION file 4 | current_version=$(` component. Or, the `` component docs are missing information.' 12 | validations: 13 | required: true 14 | - type: textarea 15 | attributes: 16 | label: Is there any context that might help us understand? 17 | description: A clear description of any added context that might help us understand. 18 | validations: 19 | required: true 20 | - type: input 21 | attributes: 22 | label: Does the docs page already exist? Please link to it. 23 | description: 'Example: https://github.com/StuffAnThings/qbit_manage/wiki/existingpagehere' 24 | validations: 25 | required: false 26 | -------------------------------------------------------------------------------- /web-ui/css/components/_pagination.css: -------------------------------------------------------------------------------- 1 | /* Pagination Component */ 2 | .pagination { 3 | display: flex; 4 | align-items: center; 5 | gap: var(--spacing-xs); 6 | } 7 | 8 | .page-item { 9 | display: inline-flex; 10 | } 11 | 12 | .page-link { 13 | display: flex; 14 | align-items: center; 15 | justify-content: center; 16 | min-width: 2rem; 17 | height: 2rem; 18 | padding: 0 var(--spacing-sm); 19 | border: 1px solid var(--border-color); 20 | border-radius: var(--border-radius); 21 | background-color: var(--bg-primary); 22 | color: var(--text-primary); 23 | text-decoration: none; 24 | transition: all var(--transition-fast); 25 | } 26 | 27 | .page-link:hover { 28 | background-color: var(--bg-secondary); 29 | border-color: var(--border-hover); 30 | } 31 | 32 | .page-item.active .page-link { 33 | background-color: var(--primary-color); 34 | border-color: var(--primary-color); 35 | color: var(--text-inverse); 36 | } 37 | 38 | .page-item.disabled .page-link { 39 | color: var(--text-muted); 40 | pointer-events: none; 41 | background-color: var(--bg-secondary); 42 | } 43 | -------------------------------------------------------------------------------- /web-ui/css/components/_tabs.css: -------------------------------------------------------------------------------- 1 | /* Tabs Component */ 2 | .tabs { 3 | display: flex; 4 | flex-direction: column; 5 | } 6 | 7 | .tab-list { 8 | display: flex; 9 | border-bottom: 1px solid var(--border-color); 10 | margin-bottom: var(--spacing-lg); 11 | } 12 | 13 | .tab-item { 14 | padding: var(--spacing-sm) var(--spacing-lg); 15 | border-bottom: 2px solid transparent; 16 | cursor: pointer; 17 | font-weight: 500; 18 | color: var(--text-secondary); 19 | transition: all var(--transition-fast); 20 | } 21 | 22 | .tab-item:hover { 23 | color: var(--text-primary); 24 | } 25 | 26 | .tab-item.active { 27 | color: var(--primary-color); 28 | border-bottom-color: var(--primary-color); 29 | } 30 | 31 | .nav-link.dirty::after { 32 | content: ''; 33 | display: inline-block; 34 | width: 8px; 35 | height: 8px; 36 | border-radius: 50%; 37 | background-color: var(--warning-color); 38 | margin-left: 8px; 39 | vertical-align: middle; 40 | } 41 | 42 | .tab-content { 43 | flex: 1; 44 | } 45 | 46 | .tab-pane { 47 | display: none; 48 | } 49 | 50 | .tab-pane.active { 51 | display: block; 52 | } 53 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 StuffAnThings 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | 6 | 7 | ## Description 8 | 9 | Please include a summary of the change and which issue is fixed. 10 | 11 | Fixes # (issue) 12 | 13 | ## Type of change 14 | 15 | Please delete options that are not relevant. 16 | 17 | - [ ] Bug fix (non-breaking change which fixes an issue) 18 | - [ ] New feature (non-breaking change which adds functionality) 19 | - [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected) 20 | - [ ] This change requires a documentation update 21 | 22 | 23 | ## Checklist: 24 | 25 | - [ ] My code follows the style guidelines of this project 26 | - [ ] I have performed a self-review of my own code 27 | - [ ] I have commented my code, particularly in hard-to-understand areas 28 | - [ ] I have added or updated the docstring for new or existing methods 29 | - [ ] I have modified this PR to merge to the develop branch 30 | -------------------------------------------------------------------------------- /modules/torrent_hash_generator.py: -------------------------------------------------------------------------------- 1 | import hashlib 2 | 3 | import bencodepy 4 | 5 | from modules import util 6 | from modules.util import Failed 7 | 8 | logger = util.logger 9 | 10 | 11 | class TorrentHashGenerator: 12 | def __init__(self, torrent_file_path): 13 | self.torrent_file_path = torrent_file_path 14 | 15 | def generate_torrent_hash(self): 16 | try: 17 | with open(self.torrent_file_path, "rb") as torrent_file: 18 | torrent_data = torrent_file.read() 19 | try: 20 | torrent_info = bencodepy.decode(torrent_data) 21 | info_data = bencodepy.encode(torrent_info[b"info"]) 22 | info_hash = hashlib.sha1(info_data).hexdigest() 23 | logger.trace(f"info_hash: {info_hash}") 24 | return info_hash 25 | except KeyError: 26 | logger.error("Invalid .torrent file format. 'info' key not found.") 27 | except FileNotFoundError: 28 | logger.error(f"Torrent file '{self.torrent_file_path}' not found.") 29 | except Failed as err: 30 | logger.error(f"TorrentHashGenerator Error: {err}") 31 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://help.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "pip" 9 | directory: "/" 10 | schedule: 11 | interval: "daily" 12 | time: "07:00" 13 | timezone: "America/Toronto" 14 | target-branch: "develop" 15 | assignees: 16 | - "bobokun" 17 | # Specify the file to check for dependencies 18 | # Dependabot will now look at pyproject.toml instead of requirements.txt 19 | allow: 20 | - dependency-type: "direct" 21 | # Specify the file to update 22 | versioning-strategy: increase-if-necessary 23 | - package-ecosystem: github-actions 24 | directory: '/' 25 | schedule: 26 | interval: "daily" 27 | time: "07:00" 28 | timezone: "America/Toronto" 29 | assignees: 30 | - "bobokun" 31 | target-branch: "develop" 32 | ignore: 33 | - dependency-name: "salsify/action-detect-and-tag-new-version" 34 | -------------------------------------------------------------------------------- /web-ui/js/config-schemas/cat.js: -------------------------------------------------------------------------------- 1 | export const catSchema = { 2 | title: 'Categories', 3 | description: 'Define categories and their associated save paths. All save paths in qBittorrent must be defined here. You can use `*` as a wildcard for subdirectories.', 4 | type: 'complex-object', 5 | keyLabel: 'Category Name', 6 | keyDescription: 'Name of the category as it appears in qBittorrent.', 7 | // Special handling for flat string values (category: path format) 8 | flatStringValues: true, 9 | fields: [ 10 | { 11 | type: 'documentation', 12 | title: 'Categories Configuration Guide', 13 | filePath: 'Config-Setup.md', 14 | section: 'cat', 15 | defaultExpanded: false 16 | } 17 | ], 18 | patternProperties: { 19 | ".*": { 20 | type: 'string', 21 | label: 'Save Path', 22 | description: 'The absolute path where torrents in this category should be saved.', 23 | default: '' 24 | } 25 | }, 26 | additionalProperties: { 27 | type: 'string', 28 | label: 'Save Path', 29 | description: 'The absolute path where torrents in this category should be saved.', 30 | default: '' 31 | } 32 | }; 33 | -------------------------------------------------------------------------------- /web-ui/js/config-schemas/qbt.js: -------------------------------------------------------------------------------- 1 | export const qbtSchema = { 2 | title: 'qBittorrent Connection', 3 | description: 'Configure the connection to your qBittorrent client.', 4 | fields: [ 5 | { 6 | type: 'documentation', 7 | title: 'qBittorrent Configuration Guide', 8 | filePath: 'Config-Setup.md', 9 | section: 'qbt', 10 | defaultExpanded: false 11 | }, 12 | { 13 | name: 'host', 14 | type: 'text', 15 | label: 'Host', 16 | description: 'The IP address and port of your qBittorrent WebUI.', 17 | required: true, 18 | placeholder: 'localhost:8080 or qbittorrent:8080' 19 | }, 20 | { 21 | name: 'user', 22 | type: 'text', 23 | label: 'Username', 24 | description: 'The username for your qBittorrent WebUI.', 25 | required: false, 26 | placeholder: 'admin' 27 | }, 28 | { 29 | name: 'pass', 30 | type: 'password', 31 | label: 'Password', 32 | description: 'The password for your qBittorrent WebUI.', 33 | required: false, 34 | placeholder: 'Enter password' 35 | } 36 | ] 37 | }; 38 | -------------------------------------------------------------------------------- /web-ui/js/config-schemas/cat_change.js: -------------------------------------------------------------------------------- 1 | export const catChangeSchema = { 2 | title: 'Category Changes', 3 | description: 'Move torrents from one category to another after they are marked as complete. Be cautious, as this can cause data to be moved if "Default Torrent Management Mode" is set to automatic in qBittorrent.', 4 | type: 'dynamic-key-value-list', 5 | useCategoryDropdown: true, // Flag to indicate this should use category dropdown for keys 6 | fields: [ 7 | { 8 | type: 'documentation', 9 | title: 'Category Changes Documentation', 10 | filePath: 'Config-Setup.md', 11 | section: 'cat_change', 12 | defaultExpanded: false 13 | }, 14 | { 15 | name: 'category_changes', 16 | type: 'object', 17 | label: 'Category Changes', 18 | description: 'Define old and new category names', 19 | properties: { 20 | new_category: { 21 | type: 'text', 22 | label: 'New Category Name', 23 | description: 'Name of the new category', 24 | useCategoryDropdown: true // Flag to indicate this field should use category dropdown 25 | } 26 | } 27 | } 28 | ] 29 | }; 30 | -------------------------------------------------------------------------------- /modules/apprise.py: -------------------------------------------------------------------------------- 1 | """Apprise notification class""" 2 | 3 | import time 4 | 5 | from modules import util 6 | from modules.util import Failed 7 | 8 | logger = util.logger 9 | 10 | 11 | class Apprise: 12 | """Apprise notification class""" 13 | 14 | def __init__(self, config, params): 15 | self.config = config 16 | self.api_url = params["api_url"] 17 | logger.secret(self.api_url) 18 | self.notify_url = ",".join(params["notify_url"]) 19 | response = self.check_api_url() 20 | time.sleep(1) # Pause for 1 second before sending the next request 21 | if response.status_code != 200: 22 | raise Failed(f"Apprise Error: Unable to connect to Apprise using {self.api_url}") 23 | 24 | def check_api_url(self): 25 | """Check if the API URL is valid""" 26 | # Check the response using application.json get header 27 | status_endpoint = self.api_url + "/status" 28 | response = self.config.get(status_endpoint, headers={"Accept": "application/json"}) 29 | if response.status_code != 200: 30 | raise Failed( 31 | f"Apprise Error: Unable to connect to Apprise using {status_endpoint} " 32 | f"with status code {response.status_code}: {response.reason}" 33 | ) 34 | return response 35 | -------------------------------------------------------------------------------- /web-ui/css/components/_accordion.css: -------------------------------------------------------------------------------- 1 | /* Accordion Component */ 2 | .accordion { 3 | border: 1px solid var(--border-color); 4 | border-radius: var(--border-radius); 5 | overflow: hidden; 6 | } 7 | 8 | .accordion-item { 9 | border-bottom: 1px solid var(--border-color); 10 | } 11 | 12 | .accordion-item:last-child { 13 | border-bottom: none; 14 | } 15 | 16 | .accordion-header { 17 | padding: var(--spacing-md) var(--spacing-lg); 18 | background-color: var(--bg-secondary); 19 | cursor: pointer; 20 | display: flex; 21 | align-items: center; 22 | justify-content: space-between; 23 | transition: background-color var(--transition-fast); 24 | } 25 | 26 | .accordion-header:hover { 27 | background-color: var(--bg-accent); 28 | } 29 | 30 | .accordion-title { 31 | margin: 0; 32 | font-size: var(--font-size-base); 33 | font-weight: 500; 34 | } 35 | 36 | .accordion-icon { 37 | transition: transform var(--transition-fast); 38 | } 39 | 40 | .accordion-content { 41 | max-height: 0; 42 | overflow: hidden; 43 | transition: max-height var(--transition-normal); 44 | } 45 | 46 | .accordion-body { 47 | padding: var(--spacing-lg); 48 | background-color: var(--bg-primary); 49 | } 50 | 51 | .accordion-item.active .accordion-icon { 52 | transform: rotate(180deg); 53 | } 54 | 55 | .accordion-item.active .accordion-content { 56 | max-height: 50rem; 57 | } 58 | -------------------------------------------------------------------------------- /desktop/tauri/src-tauri/Cargo.toml: -------------------------------------------------------------------------------- 1 | [build-dependencies] 2 | serde_json = "1.0" 3 | toml = "0.8" 4 | 5 | [build-dependencies.tauri-build] 6 | features = [] 7 | version = "2" 8 | 9 | [dependencies] 10 | once_cell = "1.19" 11 | serde_json = "1.0" 12 | tauri-plugin-opener = "2" 13 | tauri-plugin-shell = "2" 14 | tauri-plugin-single-instance = "2" 15 | 16 | [dependencies.reqwest] 17 | default-features = false 18 | features = ["rustls-tls", "json"] 19 | version = "0.11" 20 | 21 | [dependencies.serde] 22 | features = ["derive"] 23 | version = "1.0" 24 | 25 | [dependencies.tauri] 26 | features = ["tray-icon"] 27 | version = "2" 28 | 29 | [dependencies.tokio] 30 | features = ["macros", "rt-multi-thread", "time", "process"] 31 | version = "1.37" 32 | 33 | [features] 34 | default = [] 35 | winjob = [] 36 | 37 | [package] 38 | authors = ["qbit_manage"] 39 | build = "build.rs" 40 | description = "Tauri desktop shell for qbit_manage with tray + minimize-to-tray and server lifecycle" 41 | edition = "2021" 42 | license = "MIT" 43 | name = "qbit-manage-desktop" 44 | repository = "" 45 | rust-version = "1.70" 46 | version = "4.6.5" 47 | 48 | [target."cfg(unix)".dependencies] 49 | glib = "0.20.0" 50 | libc = "0.2" 51 | 52 | [target."cfg(windows)".dependencies.windows] 53 | features = ["Win32_Foundation", "Win32_System_JobObjects", "Win32_System_Threading", "Win32_Security", "Win32_System_Registry"] 54 | version = "0.58.0" 55 | -------------------------------------------------------------------------------- /.github/workflows/docs.yml: -------------------------------------------------------------------------------- 1 | name: Documentation 2 | 3 | on: 4 | push: 5 | branches: 6 | - develop 7 | paths: 8 | - "docs/**" 9 | repository_dispatch: 10 | types: [docs] 11 | gollum: 12 | 13 | env: 14 | GIT_AUTHOR_NAME: Actionbot 15 | GIT_AUTHOR_EMAIL: actions@github.com 16 | 17 | jobs: 18 | job-sync-docs-to-wiki: 19 | runs-on: ubuntu-latest 20 | if: github.event_name != 'gollum' 21 | steps: 22 | - name: Checkout Repo 23 | uses: actions/checkout@v6 24 | - name: Sync docs to wiki 25 | uses: newrelic/wiki-sync-action@main 26 | with: 27 | source: docs 28 | destination: wiki 29 | token: ${{ secrets.PAT }} 30 | gitAuthorName: ${{ env.GIT_AUTHOR_NAME }} 31 | gitAuthorEmail: ${{ env.GIT_AUTHOR_EMAIL }} 32 | 33 | job-sync-wiki-to-docs: 34 | runs-on: ubuntu-latest 35 | if: github.event_name == 'gollum' 36 | steps: 37 | - name: Checkout Repo 38 | uses: actions/checkout@v6 39 | with: 40 | token: ${{ secrets.PAT }} # allows us to push back to repo 41 | ref: develop 42 | - name: Sync Wiki to Docs 43 | uses: newrelic/wiki-sync-action@main 44 | with: 45 | source: wiki 46 | destination: docs 47 | token: ${{ secrets.PAT }} 48 | gitAuthorName: ${{ env.GIT_AUTHOR_NAME }} 49 | gitAuthorEmail: ${{ env.GIT_AUTHOR_EMAIL }} 50 | branch: develop 51 | -------------------------------------------------------------------------------- /modules/__init__.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | # Define an empty version_info tuple 4 | __version_info__ = () 5 | 6 | # Try to resolve VERSION in a PyInstaller-safe way first, then fall back to repo-relative 7 | version_str = "0.0.0" 8 | try: 9 | # Prefer runtime-extracted path when bundled 10 | try: 11 | from .util import runtime_path # Safe relative import within package 12 | 13 | version_path = runtime_path("VERSION") 14 | if version_path.exists(): 15 | version_str = version_path.read_text(encoding="utf-8").strip() 16 | else: 17 | raise FileNotFoundError 18 | except Exception: 19 | # Fallback to repository structure: modules/../VERSION 20 | project_dir = Path(__file__).resolve().parent 21 | version_file_path = (project_dir / ".." / "VERSION").resolve() 22 | with open(version_file_path, encoding="utf-8") as f: 23 | version_str = f.read().strip() 24 | except Exception: 25 | # Last resort default (keeps package importable even if VERSION missing) 26 | version_str = "0.0.0" 27 | 28 | # Get only the first 3 digits 29 | version_str_split = version_str.rsplit("-", 1)[0] 30 | # Convert the version string to a tuple of integers 31 | try: 32 | __version_info__ = tuple(map(int, version_str_split.split("."))) 33 | except Exception: 34 | __version_info__ = (0, 0, 0) 35 | 36 | # Define the version string using the version_info tuple 37 | __version__ = ".".join(str(i) for i in __version_info__) 38 | -------------------------------------------------------------------------------- /docs/_Sidebar.md: -------------------------------------------------------------------------------- 1 | - [Home](Home) 2 | - [Installation](Installation) 3 | - [Desktop App](Installation#desktop-app-installation) 4 | - [Standalone Binary Installation](Installation#standalone-binary-installation) 5 | - [Python/Source Installation](Installation#pythonsource-installation) 6 | - [Docker Installation](Docker-Installation) 7 | - [unRAID Installation](Unraid-Installation) 8 | - [Config Setup](Config-Setup) 9 | - [Sample Config File](Config-Setup#config-file) 10 | - [List of variables](Config-Setup#list-of-variables) 11 | - [commands](Config-Setup#commands) 12 | - [qbt](Config-Setup#qbt) 13 | - [settings](Config-Setup#settings) 14 | - [directory](Config-Setup#directory) 15 | - [cat](Config-Setup#cat) 16 | - [cat_change](Config-Setup#cat_change) 17 | - [tracker](Config-Setup#tracker) 18 | - [nohardlinks](Config-Setup#nohardlinks) 19 | - [share_limits](Config-Setup#share_limits) 20 | - [recyclebin](Config-Setup#recyclebin) 21 | - [orphaned](Config-Setup#orphaned) 22 | - [apprise](Config-Setup#apprise) 23 | - [notifiarr](Config-Setup#notifiarr) 24 | - [webhooks](Config-Setup#webhooks) 25 | - [Commands](Commands) 26 | - [Web API](Web-API) 27 | - [Web UI](Web-UI) 28 | - Extras 29 | - [Standalone Scripts](Standalone-Scripts) 30 | - [V4 Migration Guide](v4-Migration-Guide) 31 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | repos: 3 | - repo: https://github.com/pre-commit/pre-commit-hooks 4 | rev: v6.0.0 5 | hooks: 6 | - id: trailing-whitespace 7 | - id: end-of-file-fixer 8 | exclude: ^desktop/tauri/src-tauri/tauri\.conf\.json$ 9 | - id: check-merge-conflict 10 | - id: check-json 11 | - id: check-yaml 12 | - id: check-added-large-files 13 | args: [--maxkb=600] 14 | - id: fix-byte-order-marker 15 | - id: pretty-format-json 16 | args: [--autofix, --indent, '4', --no-sort-keys] 17 | exclude: ^desktop/tauri/src-tauri/tauri\.conf\.json$ 18 | - repo: https://github.com/adrienverge/yamllint.git 19 | rev: v1.37.1 # or higher tag 20 | hooks: 21 | - id: yamllint 22 | args: [--format, parsable, --strict] 23 | exclude: ^.github/ 24 | - repo: https://github.com/lyz-code/yamlfix 25 | rev: 1.19.0 26 | hooks: 27 | - id: yamlfix 28 | exclude: ^.github/ 29 | - repo: https://github.com/astral-sh/ruff-pre-commit 30 | # Ruff version. 31 | rev: v0.14.6 32 | hooks: 33 | # Run the linter. 34 | - id: ruff-check 35 | args: [--fix] 36 | # Run the formatter. 37 | - id: ruff-format 38 | - repo: local 39 | hooks: 40 | - id: increase-version 41 | name: Increase version if branch contains "develop" 42 | entry: ./scripts/pre-commit/increase_version.sh 43 | language: script 44 | pass_filenames: false 45 | stages: [pre-commit] 46 | -------------------------------------------------------------------------------- /.github/workflows/dependabot-approve-and-auto-merge.yml: -------------------------------------------------------------------------------- 1 | name: Dependabot Pull Request Approve and Merge 2 | 3 | on: pull_request 4 | 5 | permissions: 6 | pull-requests: write 7 | issues: write 8 | contents: write 9 | repository-projects: write 10 | 11 | jobs: 12 | dependabot: 13 | runs-on: ubuntu-latest 14 | # Checking the actor will prevent your Action run failing on non-Dependabot 15 | # PRs but also ensures that it only does work for Dependabot PRs. 16 | if: ${{ github.actor == 'dependabot[bot]' }} 17 | steps: 18 | # This first step will fail if there's no metadata and so the approval 19 | # will not occur. 20 | - name: Dependabot metadata 21 | id: dependabot-metadata 22 | uses: dependabot/fetch-metadata@v2.4.0 23 | with: 24 | github-token: "${{ secrets.GITHUB_TOKEN }}" 25 | # Here the PR gets approved. 26 | - name: Approve a PR 27 | run: gh pr review --approve "$PR_URL" 28 | env: 29 | PR_URL: ${{ github.event.pull_request.html_url }} 30 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 31 | # Finally, this sets the PR to allow auto-merging for patch and minor 32 | # updates if all checks pass 33 | - name: Enable auto-merge for Dependabot PRs 34 | if: ${{ steps.dependabot-metadata.outputs.update-type != 'version-update:semver-major' }} 35 | run: gh pr merge --auto --squash "$PR_URL" 36 | env: 37 | PR_URL: ${{ github.event.pull_request.html_url }} 38 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 39 | -------------------------------------------------------------------------------- /web-ui/css/components/_dropdown.css: -------------------------------------------------------------------------------- 1 | /* Dropdown Component */ 2 | .dropdown { 3 | position: relative; 4 | display: inline-block; 5 | } 6 | 7 | .dropdown-toggle { 8 | cursor: pointer; 9 | user-select: none; 10 | } 11 | 12 | .dropdown-menu { 13 | position: absolute; 14 | top: 100%; 15 | left: 0; 16 | z-index: var(--z-dropdown); 17 | min-width: 10rem; 18 | padding: var(--spacing-sm) 0; 19 | margin: var(--spacing-xs) 0 0; 20 | background-color: var(--bg-primary); 21 | border: 1px solid var(--border-color); 22 | border-radius: var(--border-radius); 23 | box-shadow: var(--shadow-md); 24 | opacity: 0; 25 | transform: translateY(-10px); 26 | transition: opacity var(--transition-fast), transform var(--transition-fast); 27 | pointer-events: none; 28 | } 29 | 30 | .dropdown-menu.right { 31 | left: auto; 32 | right: 0; 33 | } 34 | 35 | .dropdown-menu.show { 36 | opacity: 1; 37 | transform: translateY(0); 38 | pointer-events: auto; 39 | } 40 | 41 | .dropdown-item { 42 | display: block; 43 | width: 100%; 44 | padding: var(--spacing-sm) var(--spacing-lg); 45 | clear: both; 46 | font-weight: 400; 47 | text-align: inherit; 48 | white-space: nowrap; 49 | background-color: transparent; 50 | border: 0; 51 | color: var(--text-primary); 52 | text-decoration: none; 53 | transition: background-color var(--transition-fast); 54 | cursor: pointer; 55 | } 56 | 57 | .dropdown-item:hover { 58 | background-color: var(--bg-secondary); 59 | } 60 | 61 | .dropdown-divider { 62 | height: 0; 63 | margin: var(--spacing-sm) 0; 64 | overflow: hidden; 65 | border-top: 1px solid var(--border-color); 66 | } 67 | -------------------------------------------------------------------------------- /web-ui/css/components/_toggle-switch.css: -------------------------------------------------------------------------------- 1 | /* Modern Toggle Switch */ 2 | .toggle-container { 3 | display: inline-flex; 4 | align-items: center; 5 | gap: 0.75rem; 6 | cursor: pointer; 7 | } 8 | 9 | .toggle-input { 10 | position: absolute; 11 | opacity: 0; 12 | width: 0; 13 | height: 0; 14 | } 15 | 16 | .toggle-switch { 17 | position: relative; 18 | display: inline-block; 19 | width: 2.75rem; 20 | height: 1.5rem; 21 | flex-shrink: 0; 22 | } 23 | 24 | .toggle-slider { 25 | position: absolute; 26 | cursor: pointer; 27 | top: 0; 28 | left: 0; 29 | right: 0; 30 | bottom: 0; 31 | background-color: var(--bg-accent); 32 | border: 1px solid var(--border-color); 33 | border-radius: 2rem; 34 | transition: all var(--transition-fast); 35 | box-shadow: var(--shadow-sm) inset; 36 | } 37 | 38 | .toggle-slider:before { 39 | position: absolute; 40 | content: ""; 41 | height: 1rem; 42 | width: 1rem; 43 | left: 0.25rem; 44 | bottom: 0.25rem; 45 | background-color: var(--bg-primary); 46 | border-radius: 50%; 47 | transition: all var(--transition-fast); 48 | box-shadow: var(--shadow-sm); 49 | } 50 | 51 | .toggle-input:checked + .toggle-slider { 52 | background-color: var(--primary); 53 | border-color: var(--primary); 54 | } 55 | 56 | .toggle-input:checked + .toggle-slider:before { 57 | transform: translateX(1.25rem); 58 | background-color: var(--bg-primary); 59 | } 60 | 61 | .toggle-input:focus + .toggle-slider { 62 | box-shadow: 0 0 0 3px var(--primary-focus); 63 | } 64 | 65 | .toggle-input:disabled + .toggle-slider { 66 | opacity: 0.5; 67 | cursor: not-allowed; 68 | } 69 | 70 | .toggle-label { 71 | font-size: var(--font-size-sm); 72 | font-weight: 500; 73 | color: var(--text-primary); 74 | user-select: none; 75 | } 76 | -------------------------------------------------------------------------------- /desktop/tauri/src-tauri/tauri.conf.json: -------------------------------------------------------------------------------- 1 | { 2 | "app": { 3 | "security": { 4 | "csp": null 5 | }, 6 | "windows": [ 7 | { 8 | "decorations": true, 9 | "fullscreen": false, 10 | "height": 800, 11 | "label": "main", 12 | "minHeight": 600, 13 | "minWidth": 900, 14 | "resizable": true, 15 | "title": "qBit Manage", 16 | "visible": false, 17 | "width": 1100 18 | } 19 | ], 20 | "withGlobalTauri": true 21 | }, 22 | "build": { 23 | "beforeBuildCommand": "", 24 | "beforeDevCommand": "", 25 | "devUrl": "http://localhost:8080", 26 | "frontendDist": "../src" 27 | }, 28 | "bundle": { 29 | "active": true, 30 | "category": "Utility", 31 | "icon": [ 32 | "../../../icons/qbm_logo.icns", 33 | "../../../icons/qbm_logo.ico", 34 | "../../../icons/qbm_logo.png" 35 | ], 36 | "linux": { 37 | "deb": { 38 | "depends": [ 39 | "libgtk-3-0", 40 | "libayatana-appindicator3-1", 41 | "libwebkit2gtk-4.1-0" 42 | ] 43 | } 44 | }, 45 | "macOS": { 46 | "frameworks": [], 47 | "minimumSystemVersion": "10.13" 48 | }, 49 | "resources": [ 50 | "bin/*" 51 | ], 52 | "targets": [ 53 | "deb", 54 | "nsis", 55 | "app", 56 | "dmg" 57 | ], 58 | "windows": { 59 | "certificateThumbprint": null, 60 | "digestAlgorithm": "sha256", 61 | "nsis": { 62 | "displayLanguageSelector": true, 63 | "installMode": "currentUser", 64 | "installerIcon": "../../../icons/qbm_logo.ico" 65 | }, 66 | "timestampUrl": "" 67 | } 68 | }, 69 | "identifier": "com.qbitmanage.desktop", 70 | "productName": "qBit Manage", 71 | "version": "4.6.5" 72 | } -------------------------------------------------------------------------------- /web-ui/js/config-schemas/recyclebin.js: -------------------------------------------------------------------------------- 1 | export const recyclebinSchema = { 2 | title: 'Recycle Bin', 3 | description: 'Configure the recycle bin to move deleted files to a temporary location instead of permanently deleting them. This provides a safety net for accidental deletions.', 4 | fields: [ 5 | { 6 | type: 'documentation', 7 | title: 'Recycle Bin Configuration Documentation', 8 | filePath: 'Config-Setup.md', 9 | section: 'recyclebin', 10 | defaultExpanded: false 11 | }, 12 | { 13 | name: 'enabled', 14 | type: 'boolean', 15 | label: 'Enable Recycle Bin', 16 | description: 'Enable or disable the recycle bin functionality.', 17 | default: true, 18 | required: true 19 | }, 20 | { 21 | name: 'empty_after_x_days', 22 | type: 'number', 23 | label: 'Empty After X Days', 24 | description: 'Delete files from the recycle bin after this many days. Set to 0 for immediate deletion, or leave empty to never delete.', 25 | min: 0 26 | }, 27 | { 28 | name: 'save_torrents', 29 | type: 'boolean', 30 | label: 'Save Torrents', 31 | description: 'Save a copy of the .torrent and .fastresume files in the recycle bin. Requires `torrents_dir` to be set in the Directory configuration.', 32 | default: false 33 | }, 34 | { 35 | name: 'split_by_category', 36 | type: 'boolean', 37 | label: 'Split by Category', 38 | description: 'Organize the recycle bin by creating subdirectories based on the torrent\'s category save path.', 39 | default: false 40 | } 41 | ] 42 | }; 43 | -------------------------------------------------------------------------------- /modules/notifiarr.py: -------------------------------------------------------------------------------- 1 | import time 2 | from json import JSONDecodeError 3 | 4 | from modules import util 5 | from modules.util import Failed 6 | 7 | logger = util.logger 8 | 9 | 10 | class Notifiarr: 11 | """Notifiarr API""" 12 | 13 | BASE_URL = "https://notifiarr.com/api" 14 | API_VERSION = "v1" 15 | 16 | def __init__(self, config, params): 17 | """Initialize Notifiarr API""" 18 | self.config = config 19 | self.apikey = params["apikey"] 20 | self.header = {"X-API-Key": self.apikey} 21 | self.instance = params["instance"] 22 | self.url = f"{self.BASE_URL}/{self.API_VERSION}/" 23 | logger.secret(self.apikey) 24 | response = self.config.get(f"{self.url}user/qbitManage/", headers=self.header, params={"fetch": "settings"}) 25 | response_json = None 26 | try: 27 | response_json = response.json() 28 | except JSONDecodeError as e: 29 | logger.debug(e) 30 | raise Failed("Notifiarr Error: Invalid response") 31 | if response.status_code >= 400 or ("result" in response_json and response_json["result"] == "error"): 32 | logger.debug(f"Response: {response_json}") 33 | raise Failed(f"({response.status_code} [{response.reason}]) {response_json}") 34 | if not response_json["details"]["response"]: 35 | raise Failed("Notifiarr Error: Invalid apikey") 36 | 37 | def notification(self, json): 38 | """Send notification to Notifiarr""" 39 | params = {"qbit_client": self.config.data["qbt"]["host"], "instance": self.instance} 40 | response = self.config.get(f"{self.url}notification/qbitManage/", json=json, headers=self.header, params=params) 41 | time.sleep(1) # Pause for 1 second before sending the next request 42 | return response 43 | -------------------------------------------------------------------------------- /scripts/edit_tracker.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # This standalone script is used to edit tracker urls from one tracker to another. 3 | # Needs to have qbittorrent-api installed 4 | # pip3 install qbittorrent-api 5 | import sys 6 | 7 | # --DEFINE VARIABLES--# 8 | qbt_host = "qbittorrent:8080" 9 | qbt_user = None 10 | qbt_pass = None 11 | OLD_TRACKER = "https://blutopia.xyz" # This is the tracker you want to replace 12 | # This is the tracker you want to replace it with 13 | NEW_TRACKER = "https://blutopia.cc" 14 | # --DEFINE VARIABLES--# 15 | # --START SCRIPT--# 16 | 17 | try: 18 | from qbittorrentapi import APIConnectionError 19 | from qbittorrentapi import Client 20 | from qbittorrentapi import LoginFailed 21 | except ModuleNotFoundError: 22 | print('Requirements Error: qbittorrent-api not installed. Please install using the command "pip install qbittorrent-api"') 23 | sys.exit(1) 24 | 25 | 26 | if __name__ == "__main__": 27 | try: 28 | client = Client(host=qbt_host, username=qbt_user, password=qbt_pass) 29 | except LoginFailed: 30 | raise ("Qbittorrent Error: Failed to login. Invalid username/password.") 31 | except APIConnectionError: 32 | raise ("Qbittorrent Error: Unable to connect to the client.") 33 | except Exception: 34 | raise ("Qbittorrent Error: Unable to connect to the client.") 35 | torrent_list = client.torrents.info(sort="added_on", reverse=True) 36 | 37 | for torrent in torrent_list: 38 | for x in torrent.trackers: 39 | if OLD_TRACKER in x.url: 40 | newurl = x.url.replace(OLD_TRACKER, NEW_TRACKER) 41 | print(f"torrent name: {torrent.name}, original url: {x.url}, modified url: {newurl}\n") 42 | torrent.remove_trackers(urls=x.url) 43 | torrent.add_trackers(urls=newurl) 44 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools>=42", "wheel"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | # Keep using setup.py for version handling 6 | # Dependencies are specified here for uv to use 7 | 8 | [project] 9 | name = "qbit_manage" 10 | # Version is dynamically determined from setup.py 11 | dynamic = ["version"] 12 | description = "This tool will help manage tedious tasks in qBittorrent and automate them. Tag, categorize, remove Orphaned data, remove unregistered torrents and much much more." 13 | readme = "README.md" 14 | requires-python = ">=3.9" 15 | license = "MIT" 16 | authors = [ 17 | {name = "bobokun"}, 18 | ] 19 | dependencies = [ 20 | "argon2-cffi==25.1.0", 21 | "bencodepy==0.9.5", 22 | "croniter==6.0.0", 23 | "fastapi==0.122.0", 24 | "GitPython==3.1.45", 25 | "humanize==4.13.0", 26 | "pytimeparse2==1.7.1", 27 | "qbittorrent-api==2025.11.1", 28 | "requests==2.32.5", 29 | "retrying==1.4.2", 30 | "ruamel.yaml==0.18.16", 31 | "slowapi==0.1.9", 32 | "uvicorn==0.38.0", 33 | ] 34 | 35 | [project.scripts] 36 | qbit-manage = "qbit_manage:main" 37 | 38 | [project.urls] 39 | Homepage = "https://github.com/StuffAnThings" 40 | Repository = "https://github.com/StuffAnThings/qbit_manage" 41 | 42 | [project.optional-dependencies] 43 | dev = [ 44 | "pre-commit==4.3.0", 45 | "ruff==0.14.6", 46 | ] 47 | 48 | [tool.ruff] 49 | line-length = 130 50 | 51 | [tool.ruff.lint] 52 | select = [ 53 | "I", # isort - import order 54 | "UP", # pyupgrade 55 | "T10", # debugger 56 | "E", # pycodestyle errors 57 | "W", # pycodestyle warnings 58 | "F", # pyflakes 59 | ] 60 | 61 | ignore = [ 62 | "E722", # E722 Do not use bare except, specify exception instead 63 | "E402", # E402 module level import not at top of file 64 | ] 65 | 66 | [tool.ruff.lint.isort] 67 | force-single-line = true 68 | 69 | [tool.ruff.format] 70 | line-ending = "auto" 71 | -------------------------------------------------------------------------------- /web-ui/css/components/_close-icon-button.css: -------------------------------------------------------------------------------- 1 | /* Generic Close/Remove Icon Button */ 2 | .btn-close-icon { 3 | background-color: transparent; /* Transparent background */ 4 | border: none; /* No border */ 5 | color: currentColor; /* Use current text color instead of black */ 6 | border-radius: var(--border-radius-sm); /* Slightly rounded for better UX */ 7 | width: 24px; /* Increased size for better clickability */ 8 | height: 24px; /* Increased size for better clickability */ 9 | min-width: 24px; /* Ensure minimum clickable area */ 10 | min-height: 24px; /* Ensure minimum clickable area */ 11 | display: flex; /* Use flex for better centering */ 12 | align-items: center; 13 | justify-content: center; 14 | padding: 2px; /* Small padding for better click target */ 15 | line-height: 1; 16 | cursor: pointer; 17 | transition: opacity var(--transition-fast); /* Match share limits transition */ 18 | position: relative; /* Ensure proper stacking */ 19 | z-index: 1; /* Ensure it's above other elements */ 20 | pointer-events: auto; /* Explicitly enable pointer events */ 21 | opacity: 0.6; /* Match share limits initial opacity */ 22 | } 23 | 24 | .btn-close-icon:hover { 25 | opacity: 1; /* Match share limits hover opacity */ 26 | } 27 | 28 | /* Ensure SVG icons inside are properly sized and clickable */ 29 | .btn-close-icon .icon, 30 | .btn-close-icon svg { 31 | width: 16px; 32 | height: 16px; 33 | pointer-events: none; /* Prevent SVG from intercepting clicks */ 34 | fill: currentColor; 35 | } 36 | 37 | /* Fix for array item input groups */ 38 | .array-item-input-group { 39 | display: flex; 40 | align-items: center; 41 | gap: var(--spacing-sm); 42 | width: 100%; 43 | } 44 | 45 | .array-item-input-group .form-input { 46 | flex: 1; 47 | } 48 | 49 | .array-item-input-group .btn-close-icon { 50 | flex-shrink: 0; /* Prevent button from shrinking */ 51 | } 52 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/2.feature_request.yml: -------------------------------------------------------------------------------- 1 | name: Feature Request 2 | description: Suggest a new feature for Qbit Manage 3 | title: '[FR]: ' 4 | labels: ['feature request'] 5 | assignees: 'bobokun' 6 | 7 | body: 8 | - type: markdown 9 | attributes: 10 | value: > 11 | Thanks for taking the time to file a feature request! Please fill out this form as completely as possible. 12 | - type: textarea 13 | id: problem-relation 14 | attributes: 15 | label: Is your feature request related to a problem? Please elaborate. 16 | description: A clear and concise description of what the problem is. 17 | placeholder: eg. I'm always frustrated when... 18 | validations: 19 | required: true 20 | - type: textarea 21 | id: solution 22 | attributes: 23 | label: Describe the solution you'd like 24 | description: A clear and concise description of what you want to happen. 25 | validations: 26 | required: true 27 | - type: checkboxes 28 | id: complications 29 | attributes: 30 | label: Does your solution involve any of the following? 31 | options: 32 | - label: New config option 33 | - label: New command option 34 | - type: textarea 35 | id: alternatives 36 | attributes: 37 | label: Describe alternatives you've considered 38 | description: A clear and concise description of any alternative solutions or features you've considered. 39 | validations: 40 | required: true 41 | - type: textarea 42 | id: benefit 43 | attributes: 44 | label: Who will this benefit? 45 | description: Does this feature apply to a great portion of users? 46 | validations: 47 | required: true 48 | - type: textarea 49 | id: additional-info 50 | attributes: 51 | label: Additional Information 52 | description: "[optional] You may provide additional context or screenshots for us to better understand the need of the feature." 53 | -------------------------------------------------------------------------------- /scripts/edit_passkey.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # This standalone script is used to edit passkeys from one tracker. 3 | # Needs to have qbittorrent-api installed 4 | # pip3 install qbittorrent-api 5 | import sys 6 | 7 | # --DEFINE VARIABLES--# 8 | qbt_host = "qbittorrent:8080" 9 | qbt_user = None 10 | qbt_pass = None 11 | TRACKER = "blutopia" # Part of the tracker URL, e.g., "blutopia" or "your-tracker.com" 12 | OLD_PASSKEY = "OLD_PASSKEY" 13 | NEW_PASSKEY = "NEW_PASSKEY" 14 | # --DEFINE VARIABLES--# 15 | # --START SCRIPT--# 16 | 17 | try: 18 | from qbittorrentapi import APIConnectionError 19 | from qbittorrentapi import Client 20 | from qbittorrentapi import LoginFailed 21 | except ModuleNotFoundError: 22 | print('Requirements Error: qbittorrent-api not installed. Please install using the command "pip install qbittorrent-api"') 23 | sys.exit(1) 24 | 25 | 26 | if __name__ == "__main__": 27 | try: 28 | client = Client(host=qbt_host, username=qbt_user, password=qbt_pass) 29 | except LoginFailed: 30 | raise ("Qbittorrent Error: Failed to login. Invalid username/password.") 31 | except APIConnectionError: 32 | raise ("Qbittorrent Error: Unable to connect to the client.") 33 | except Exception: 34 | raise ("Qbittorrent Error: Unable to connect to the client.") 35 | torrent_list = client.torrents.info(sort="added_on", reverse=True) 36 | 37 | for torrent in torrent_list: 38 | for x in torrent.trackers: 39 | if TRACKER in x.url and OLD_PASSKEY in x.url: 40 | try: 41 | newurl = x.url.replace(OLD_PASSKEY, NEW_PASSKEY) 42 | print(f"Updating passkey for torrent name: {torrent.name}\n") 43 | torrent.remove_trackers(urls=x.url) 44 | torrent.add_trackers(urls=newurl) 45 | except Exception as e: 46 | print(f"Error updating tracker for {torrent.name}: {e}") 47 | print("Passkey update completed.") 48 | -------------------------------------------------------------------------------- /web-ui/css/components/_complex-object-card.css: -------------------------------------------------------------------------------- 1 | /* Complex Object Entry Card Styling */ 2 | .complex-object-entry-card { 3 | border: 1px solid var(--border-color); 4 | border-radius: var(--border-radius); 5 | padding: var(--spacing-md); /* Add some padding inside the border */ 6 | margin-bottom: var(--spacing-lg); /* Add space between entries */ 7 | background-color: var(--bg-secondary); /* Changed background color */ 8 | } 9 | 10 | .complex-object-entry-card .form-group { 11 | margin-bottom: var(--spacing-md); /* Reduce margin for form groups within the card */ 12 | } 13 | 14 | .complex-object-entry-card .form-group:last-child { 15 | margin-bottom: 0; /* Remove bottom margin for the last form group */ 16 | } 17 | 18 | .complex-object-key-group { 19 | display: flex; 20 | align-items: center; 21 | gap: var(--spacing-md); 22 | padding-right: calc(28px + var(--spacing-md)); /* Reserve space for close button */ 23 | } 24 | 25 | .complex-object-key-group .form-input, 26 | .complex-object-key-group .form-select { 27 | flex-grow: 1; /* Allow the input/select to take up available space */ 28 | width: auto; /* Override the 100% width from forms.css */ 29 | max-width: none; /* Let flex-grow handle the sizing */ 30 | } 31 | 32 | .complex-object-key-group .form-label { 33 | flex-shrink: 0; /* Prevent label from shrinking */ 34 | min-width: fit-content; /* Ensure label doesn't get too small */ 35 | } 36 | 37 | /* Specific styling for category dropdown to prevent overlap */ 38 | .complex-object-key-dropdown { 39 | width: auto !important; /* Override any inherited width */ 40 | flex-grow: 1; 41 | margin-right: var(--spacing-sm); /* Add some margin from the close button area */ 42 | } 43 | 44 | .array-item-input-group { 45 | display: flex; 46 | align-items: center; 47 | gap: var(--spacing-sm); /* Space between input and button */ 48 | } 49 | 50 | .array-item-input-group .form-input { 51 | flex-grow: 1; /* Allow input to take up space */ 52 | } 53 | -------------------------------------------------------------------------------- /web-ui/js/config-schemas/orphaned.js: -------------------------------------------------------------------------------- 1 | export const orphanedSchema = { 2 | title: 'Orphaned Files', 3 | description: 'Configure settings for managing orphaned files, which are files in your root directory not associated with any torrent.', 4 | fields: [ 5 | { 6 | type: 'documentation', 7 | title: 'Orphaned Files Configuration Documentation', 8 | filePath: 'Config-Setup.md', 9 | section: 'orphaned', 10 | defaultExpanded: false 11 | }, 12 | { 13 | name: 'empty_after_x_days', 14 | type: 'number', 15 | label: 'Empty After X Days', 16 | description: 'Delete orphaned files after they have been in the orphaned directory for this many days. Set to 0 for immediate deletion, or leave empty to never delete.', 17 | min: 0 18 | }, 19 | { 20 | name: 'exclude_patterns', 21 | type: 'array', 22 | label: 'Exclude Patterns', 23 | description: 'A list of glob patterns to exclude files from being considered orphaned (e.g., "**/.DS_Store").', 24 | items: { type: 'text' } 25 | }, 26 | { 27 | name: 'max_orphaned_files_to_delete', 28 | type: 'number', 29 | label: 'Max Orphaned Files to Delete', 30 | description: 'The maximum number of orphaned files to delete in a single run. This is a safeguard to prevent accidental mass deletions. Set to -1 to disable.', 31 | default: 50, 32 | min: -1 33 | }, 34 | { 35 | name: 'min_file_age_minutes', 36 | type: 'number', 37 | label: 'Minimum File Age (Minutes)', 38 | description: 'Minimum age in minutes for files to be considered orphaned. Files newer than this will be protected from deletion to prevent removal of actively uploading files. Set to 0 to disable age protection.', 39 | default: 0, 40 | min: 0 41 | } 42 | ] 43 | }; 44 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from setuptools import find_packages 4 | from setuptools import setup 5 | 6 | # Read version from VERSION file 7 | with open(os.path.join(os.path.dirname(os.path.abspath(__file__)), "VERSION")) as f: 8 | version_str = f.read().strip() 9 | # Get only the first part (without develop suffix) 10 | version = version_str.rsplit("-", 1)[0] 11 | 12 | # User-friendly description from README.md 13 | current_directory = os.path.dirname(os.path.abspath(__file__)) 14 | try: 15 | with open(os.path.join(current_directory, "README.md"), encoding="utf-8") as f: 16 | long_description = f.read() 17 | except Exception: 18 | long_description = "" 19 | 20 | setup( 21 | # Name of the package 22 | name="qbit_manage", 23 | # Packages to include into the distribution 24 | packages=find_packages("."), 25 | py_modules=["qbit_manage"], 26 | package_data={ 27 | "modules": ["../web-ui/**/*", "../VERSION"], 28 | }, 29 | include_package_data=True, 30 | # Start with a small number and increase it with 31 | # every change you make https://semver.org 32 | version=version, 33 | # Chose a license from here: https: // 34 | # help.github.com / articles / licensing - a - 35 | # repository. For example: MIT 36 | license="MIT", 37 | # Short description of your library 38 | description=( 39 | "This tool will help manage tedious tasks in qBittorrent and automate them. " 40 | "Tag, categorize, remove Orphaned data, remove unregistered torrents and much much more." 41 | ), 42 | # Long description of your library 43 | long_description=long_description, 44 | long_description_content_type="text/markdown", 45 | # Your name 46 | author="bobokun", 47 | # Your email 48 | author_email="", 49 | # Either the link to your github or to your website 50 | url="https://github.com/StuffAnThings", 51 | # Link from which the project can be downloaded 52 | download_url="https://github.com/StuffAnThings/qbit_manage", 53 | ) 54 | -------------------------------------------------------------------------------- /web-ui/css/components/_array-field.css: -------------------------------------------------------------------------------- 1 | /* Array Field Styling */ 2 | .array-field .array-items { 3 | display: flex; 4 | flex-direction: column; 5 | gap: var(--spacing-md); 6 | } 7 | .array-field .add-array-item { 8 | margin-top: var(--spacing-sm); 9 | margin-bottom: var(--spacing-md); 10 | } 11 | 12 | .array-item { 13 | display: flex; 14 | align-items: center; /* Align items vertically in the middle */ 15 | gap: var(--spacing-md); 16 | padding: var(--spacing-sm) var(--spacing-md); /* Adjust padding as needed */ 17 | border: 1px solid var(--border-color); 18 | border-radius: var(--border-radius); 19 | background-color: var(--bg-secondary); 20 | } 21 | 22 | .array-item .form-input { 23 | flex-grow: 1; /* Allow input to take up available space */ 24 | margin-bottom: 0; /* Remove default form-group margin */ 25 | } 26 | 27 | .array-item .remove-array-item { 28 | background-color: transparent; 29 | color: var(--text-secondary); 30 | border-radius: var(--border-radius); 31 | width: 28px; 32 | height: 28px; 33 | font-size: var(--font-size-sm); 34 | font-weight: 500; 35 | display: flex; 36 | align-items: center; 37 | justify-content: center; 38 | padding: 0; 39 | cursor: pointer; 40 | transition: all var(--transition-fast); 41 | line-height: 1; 42 | flex-shrink: 0; /* Prevent button from shrinking */ 43 | opacity: 0.7; 44 | box-shadow: 0 1px 2px 0 rgb(0 0 0 / 0.05); 45 | } 46 | 47 | .array-item .remove-array-item:hover { 48 | background-color: var(--bg-secondary); 49 | color: var(--text-primary); 50 | border-color: var(--border-hover); 51 | opacity: 1; 52 | transform: translateY(-1px); 53 | box-shadow: 0 2px 4px 0 rgb(0 0 0 / 0.1); 54 | } 55 | 56 | .array-item .remove-array-item:focus { 57 | outline: none; 58 | border-color: var(--border-focus); 59 | box-shadow: 0 0 0 3px var(--input-focus-ring); 60 | } 61 | 62 | .array-item .remove-array-item:active { 63 | transform: translateY(0); 64 | box-shadow: 0 1px 2px 0 rgb(0 0 0 / 0.05); 65 | } 66 | -------------------------------------------------------------------------------- /scripts/update-readme-version.py: -------------------------------------------------------------------------------- 1 | import json 2 | import re 3 | import subprocess 4 | import sys 5 | 6 | try: 7 | from qbittorrentapi import Version 8 | except ImportError: 9 | subprocess.check_call([sys.executable, "-m", "pip", "install", "qbittorrent-api"]) 10 | from qbittorrentapi import Version 11 | 12 | # Check if a branch name was provided 13 | if len(sys.argv) != 2: 14 | print("Usage: python update_versions.py ") 15 | sys.exit(1) 16 | 17 | branch_name = sys.argv[1] 18 | print(f"Branch name: {branch_name}") 19 | 20 | # Load or initialize the SUPPORTED_VERSIONS.json file 21 | versions_file_path = "SUPPORTED_VERSIONS.json" 22 | try: 23 | with open(versions_file_path, encoding="utf-8") as file: 24 | supported_versions = json.load(file) 25 | except FileNotFoundError: 26 | supported_versions = {} 27 | 28 | # Extract the current qbittorrent-api version from pyproject.toml 29 | print("Reading pyproject.toml...") 30 | with open("pyproject.toml", encoding="utf-8") as file: 31 | content = file.read() 32 | match = re.search(r"qbittorrent-api==([\d.]+)", content) 33 | if match: 34 | qbittorrent_api_version = match.group(1) 35 | else: 36 | raise ValueError("qbittorrent-api version not found in pyproject.toml") 37 | 38 | print(f"Current qbittorrent-api version: {qbittorrent_api_version}") 39 | 40 | # Fetch the latest supported qBittorrent version 41 | supported_version = Version.latest_supported_app_version() 42 | print(f"Latest supported qBittorrent version: {supported_version}") 43 | 44 | # Ensure the branch is initialized in the dictionary 45 | if branch_name not in supported_versions: 46 | supported_versions[branch_name] = {} 47 | 48 | # Update the versions in the dictionary 49 | supported_versions[branch_name]["qbit"] = supported_version 50 | supported_versions[branch_name]["qbitapi"] = qbittorrent_api_version 51 | 52 | print("Writing updated versions to SUPPORTED_VERSIONS.json...") 53 | # Write the updated versions back to SUPPORTED_VERSIONS.json 54 | with open(versions_file_path, "w", encoding="utf-8") as file: 55 | json.dump(supported_versions, file, indent=4) 56 | file.write("\n") 57 | -------------------------------------------------------------------------------- /web-ui/js/config-schemas/directory.js: -------------------------------------------------------------------------------- 1 | export const directorySchema = { 2 | title: 'Directory Paths', 3 | description: 'Configure directory paths for various operations. Proper configuration is crucial for features like orphaned file detection, no-hardlinks tagging, and the recycle bin.', 4 | fields: [ 5 | { 6 | type: 'documentation', 7 | title: 'Directory Configuration Guide', 8 | filePath: 'Config-Setup.md', 9 | section: 'directory', 10 | defaultExpanded: false 11 | }, 12 | { 13 | name: 'root_dir', 14 | type: 'text', 15 | label: 'Root Directory', 16 | description: 'The primary download directory qBittorrent uses. This path is essential for checking for orphaned files, no-hardlinks, and unregistered torrents.', 17 | placeholder: '/path/to/torrents' 18 | }, 19 | { 20 | name: 'remote_dir', 21 | type: 'text', 22 | label: 'Remote Directory', 23 | description: 'If running qbit_manage locally and qBittorrent is in Docker, this should be the host path that maps to `root_dir` inside the container. Not required if qbit_manage is also in a container.', 24 | placeholder: '/mnt/remote' 25 | }, 26 | { 27 | name: 'recycle_bin', 28 | type: 'text', 29 | label: 'Recycle Bin Directory', 30 | description: 'The path to the recycle bin folder. If not specified, it defaults to `.RecycleBin` inside your `root_dir`.', 31 | placeholder: '/path/to/recycle-bin' 32 | }, 33 | { 34 | name: 'torrents_dir', 35 | type: 'text', 36 | label: 'Torrents Directory', 37 | description: 'The path to your qBittorrent `BT_backup` directory. This is required to use the `save_torrents` feature in the recycle bin.', 38 | placeholder: '/path/to/torrent-files' 39 | }, 40 | { 41 | name: 'orphaned_dir', 42 | type: 'text', 43 | label: 'Orphaned Files Directory', 44 | description: 'The path to the orphaned files directory. If not specified, it defaults to `orphaned_data` inside your `root_dir`.', 45 | placeholder: '/path/to/orphaned' 46 | } 47 | ] 48 | }; 49 | -------------------------------------------------------------------------------- /.github/workflows/pypi-publish.yml: -------------------------------------------------------------------------------- 1 | name: PyPI Publish 2 | 3 | on: 4 | push: 5 | tags: 6 | - v* 7 | workflow_dispatch: 8 | inputs: 9 | test_pypi: 10 | description: 'Publish to Test PyPI instead of PyPI' 11 | required: false 12 | default: false 13 | type: boolean 14 | 15 | concurrency: 16 | group: ${{ github.workflow }}-${{ github.ref }} 17 | cancel-in-progress: true 18 | 19 | jobs: 20 | pypi-publish: 21 | runs-on: ubuntu-latest 22 | permissions: 23 | contents: read 24 | id-token: write # Required for trusted publishing to PyPI 25 | steps: 26 | - name: Check Out Repo 27 | uses: actions/checkout@v6 28 | 29 | - name: Setup Python 30 | uses: actions/setup-python@v6 31 | with: 32 | python-version: '3.12' 33 | 34 | - name: Install build dependencies 35 | run: | 36 | python -m pip install --upgrade pip 37 | python -m pip install build twine 38 | 39 | - name: Build package 40 | run: python -m build 41 | 42 | - name: Verify package 43 | run: | 44 | python -m twine check dist/* 45 | ls -la dist/ 46 | 47 | - name: Publish to Test PyPI 48 | if: ${{ github.event.inputs.test_pypi == 'true' }} 49 | uses: pypa/gh-action-pypi-publish@release/v1 50 | with: 51 | repository-url: https://test.pypi.org/legacy/ 52 | # Option 1: Use trusted publishing (recommended) 53 | # Repository must be configured in Test PyPI with GitHub as trusted publisher 54 | # Option 2: Use API token (uncomment the line below and comment out the trusted publishing) 55 | # password: ${{ secrets.TEST_PYPI_API_TOKEN }} 56 | verbose: true 57 | skip-existing: true 58 | 59 | - name: Publish to PyPI 60 | if: ${{ github.event.inputs.test_pypi != 'true' }} 61 | uses: pypa/gh-action-pypi-publish@release/v1 62 | with: 63 | # Option 1: Use trusted publishing (recommended) 64 | # Repository must be configured in PyPI with GitHub as trusted publisher 65 | # Option 2: Use API token (uncomment the line below and comment out the trusted publishing) 66 | # password: ${{ secrets.PYPI_API_TOKEN }} 67 | verbose: true 68 | skip-existing: true 69 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/1.bug_report.yml: -------------------------------------------------------------------------------- 1 | name: Bug Report 2 | description: Please do not use bug reports for support issues. 3 | title: '[Bug]: ' 4 | labels: ['bug'] 5 | assignees: 'bobokun' 6 | 7 | body: 8 | - type: markdown 9 | attributes: 10 | value: > 11 | **THIS IS NOT THE PLACE TO ASK FOR SUPPORT!** 12 | Please use [Notifiarr Discord](https://discord.com/invite/AURf8Yz) and post your question under the `qbit-manage` channel for support issues. 13 | - type: textarea 14 | id: description 15 | attributes: 16 | label: Describe the Bug 17 | description: A clear and concise description of the bug. 18 | validations: 19 | required: true 20 | - type: textarea 21 | id: config 22 | attributes: 23 | label: Config 24 | description: > 25 | Please paste your config.yml here (Remember to remove any sensitive information) 26 | This will be automatically formatted into code, so no need for backticks. 27 | render: yaml 28 | - type: input 29 | id: logs 30 | attributes: 31 | label: Logs 32 | description: > 33 | Please share the relevant log file with the error on [Gist](https://gist.github.com). 34 | placeholder: "https://gist.github.com" 35 | validations: 36 | required: true 37 | - type: textarea 38 | id: screenshots 39 | attributes: 40 | label: Screenshots 41 | description: "[optional] You may add screenshots to further explain your problem." 42 | - type: dropdown 43 | id: installation 44 | attributes: 45 | label: Installation 46 | description: Which installation method did you use? 47 | options: 48 | - Unraid 49 | - Docker 50 | - Local 51 | - Nix 52 | - Other 53 | validations: 54 | required: true 55 | - type: input 56 | id: version 57 | attributes: 58 | label: Version Number 59 | description: Can be found at the beginning of your log file 60 | placeholder: eg. 3.1.3 61 | validations: 62 | required: true 63 | - type: dropdown 64 | id: branch 65 | attributes: 66 | label: What branch are you on? 67 | options: 68 | - master 69 | - develop 70 | validations: 71 | required: true 72 | - type: markdown 73 | attributes: 74 | value: | 75 | Make sure to close your issue when it's solved! If you found the solution yourself please comment so that others benefit from it. 76 | -------------------------------------------------------------------------------- /web-ui/js/config-schemas/nohardlinks.js: -------------------------------------------------------------------------------- 1 | export const nohardlinksSchema = { 2 | title: 'No Hardlinks', 3 | description: 'Configure settings for tagging torrents that are not hardlinked. This is useful for identifying files that can be safely deleted after being processed by applications like Sonarr or Radarr.', 4 | type: 'complex-object', 5 | keyLabel: 'Category', 6 | keyDescription: 'Category to check for torrents without hardlinks.', 7 | useCategoryDropdown: true, // Flag to indicate this should use category dropdown 8 | fields: [ 9 | { 10 | type: 'documentation', 11 | title: 'No Hardlinks Configuration Documentation', 12 | filePath: 'Config-Setup.md', 13 | section: 'nohardlinks', 14 | defaultExpanded: false 15 | } 16 | ], 17 | patternProperties: { 18 | ".*": { // Matches any category name 19 | type: 'object', 20 | properties: { 21 | exclude_tags: { 22 | type: 'array', 23 | label: 'Exclude Tags', 24 | description: 'List of tags to exclude from the check. Torrents with any of these tags will not be processed.', 25 | items: { type: 'string' } 26 | }, 27 | ignore_root_dir: { 28 | type: 'boolean', 29 | label: 'Ignore Root Directory', 30 | description: 'If true, ignore hardlinks found within the same root directory.', 31 | default: true 32 | } 33 | }, 34 | additionalProperties: false 35 | } 36 | }, 37 | additionalProperties: { // Schema for dynamically added properties (new category entries) 38 | type: 'object', 39 | properties: { 40 | exclude_tags: { 41 | type: 'array', 42 | label: 'Exclude Tags', 43 | description: 'List of tags to exclude from the check. Torrents with any of these tags will not be processed.', 44 | items: { type: 'string' } 45 | }, 46 | ignore_root_dir: { 47 | type: 'boolean', 48 | label: 'Ignore Root Directory', 49 | description: 'If true, ignore hardlinks found within the same root directory.', 50 | default: true 51 | } 52 | }, 53 | additionalProperties: false 54 | } 55 | }; 56 | -------------------------------------------------------------------------------- /scripts/remove_cross-seed_tag.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python3 2 | # This script was written by zakkarry ( https://github.com/zakkarry ) 3 | # Simply follow the basic configuration options below to remove all 'cross-seed' 4 | # tags from all torrents from qBittorrent client matching the options below. 5 | # 6 | # If you do not know how to use environmental variables, or do not need to, simply 7 | # configure the second part of the OBIT_* variables, where the actual URL and strings are. 8 | # 9 | # If you need to, you can use this script to remove any tag as well, simply modify CROSS_SEED_TAG 10 | # from 'cross-seed' to whichever tag you wish to remove. 11 | # 12 | 13 | import os 14 | 15 | # USES ENVIRONMENTAL VARIABLES, IF NONE ARE PRESENT WILL FALLBACK TO THE SECOND STRING 16 | QBIT_HOST = os.getenv("QBT_HOST", "http://localhost:8080") 17 | QBIT_USERNAME = os.getenv("QBT_USERNAME", "admin") 18 | QBIT_PASSWORD = os.getenv("QBT_PASSWORD", "YOURPASSWORD") 19 | 20 | CRED = "\033[91m" 21 | CGREEN = "\33[32m" 22 | CEND = "\033[0m" 23 | 24 | CROSS_SEED_TAG = "cross-seed" 25 | 26 | 27 | def split(separator, data): 28 | if data is None: 29 | return None 30 | else: 31 | return [item.strip() for item in str(data).split(separator)] 32 | 33 | 34 | try: 35 | from qbittorrentapi import APIConnectionError 36 | from qbittorrentapi import Client 37 | from qbittorrentapi import LoginFailed 38 | except ModuleNotFoundError: 39 | print('Error: qbittorrent-api not installed. Please install using the command "pip install qbittorrent-api"') 40 | exit(1) 41 | 42 | try: 43 | qbt_client = Client(host=QBIT_HOST, username=QBIT_USERNAME, password=QBIT_PASSWORD) 44 | except LoginFailed: 45 | raise "Qbittorrent Error: Failed to login. Invalid username/password." 46 | except APIConnectionError: 47 | raise "Qbittorrent Error: Unable to connect to the client." 48 | except Exception: 49 | raise "Qbittorrent Error: Unable to connect to the client." 50 | print("qBittorrent:", qbt_client.app_version()) 51 | print("qBittorrent Web API:", qbt_client.app_web_api_version()) 52 | print() 53 | 54 | torrents_list = qbt_client.torrents.info(sort="added_on", reverse=True) 55 | 56 | print("Total torrents:", len(torrents_list)) 57 | print() 58 | 59 | for torrent in torrents_list: 60 | torrent_tags = split(",", torrent.tags) 61 | 62 | if CROSS_SEED_TAG in torrent_tags: 63 | print(CGREEN, "remove cross-seed tag:", torrent.name, CEND) 64 | torrent.remove_tags(tags=CROSS_SEED_TAG) 65 | -------------------------------------------------------------------------------- /web-ui/css/components/_toast.css: -------------------------------------------------------------------------------- 1 | /* Toast Component */ 2 | .toast-container { 3 | position: fixed; 4 | top: 70px; /* Positioned below header */ 5 | right: var(--spacing-lg); 6 | z-index: var(--z-toast); 7 | display: flex; 8 | flex-direction: column; 9 | gap: var(--spacing-sm); 10 | } 11 | 12 | .toast { 13 | display: flex; 14 | align-items: center; 15 | gap: var(--spacing-sm); 16 | padding: var(--spacing-md); 17 | background-color: var(--bg-primary); 18 | border: 1px solid var(--border-color); 19 | border-radius: var(--border-radius); 20 | box-shadow: var(--shadow-lg); 21 | min-width: 20rem; 22 | max-width: 24rem; 23 | opacity: 0; /* Start hidden */ 24 | transform: translateX(100%); /* Start off-screen */ 25 | transition: transform var(--transition-normal), opacity var(--transition-normal); /* Transition both */ 26 | } 27 | 28 | .toast.show { 29 | opacity: 1; /* Fully visible */ 30 | transform: translateX(0); /* Slide into view */ 31 | } 32 | 33 | .toast:not(.hidden) { 34 | transform: translateX(0); 35 | } 36 | 37 | .toast-icon { 38 | display: flex; 39 | align-items: center; 40 | justify-content: center; 41 | width: 1.5rem; 42 | height: 1.5rem; 43 | flex-shrink: 0; 44 | } 45 | 46 | .toast-content { 47 | flex: 1; 48 | } 49 | 50 | .toast-title { 51 | font-weight: 600; 52 | margin-bottom: var(--spacing-xs); 53 | } 54 | 55 | .toast-message { 56 | font-size: var(--font-size-sm); 57 | color: inherit; 58 | } 59 | 60 | .toast-close { 61 | background: none; 62 | border: none; 63 | padding: 0; 64 | cursor: pointer; 65 | color: inherit; 66 | transition: color var(--transition-fast); 67 | } 68 | 69 | .toast-close:hover { 70 | color: inherit; 71 | } 72 | 73 | .toast-success { 74 | border-left: 4px solid var(--success-color); 75 | color: var(--success-color); 76 | } 77 | 78 | .toast-warning { 79 | border-left: 4px solid var(--warning-color); 80 | color: var(--warning-color); 81 | } 82 | 83 | .toast-error { 84 | border-left: 4px solid var(--error-color); 85 | color: var(--error-color); 86 | } 87 | 88 | .toast-info { 89 | border-left: 4px solid var(--info-color); 90 | color: var(--info-color); 91 | } 92 | 93 | .toast-undo { 94 | border-left: 4px solid var(--warning-color); 95 | color: var(--warning-color); 96 | } 97 | 98 | .toast-redo { 99 | border-left: 4px solid var(--info-color); 100 | color: var(--info-color); 101 | } 102 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # Use a multi-stage build to minimize final image size 2 | FROM python:3.13-alpine AS builder 3 | 4 | ARG BRANCH_NAME=master 5 | ENV BRANCH_NAME=${BRANCH_NAME} 6 | ENV QBM_DOCKER=True 7 | 8 | # Install build-time dependencies only 9 | RUN apk add --no-cache \ 10 | gcc \ 11 | g++ \ 12 | libxml2-dev \ 13 | libxslt-dev \ 14 | zlib-dev \ 15 | libffi-dev \ 16 | curl \ 17 | bash 18 | 19 | # Install UV (fast pip alternative) 20 | RUN curl -LsSf https://astral.sh/uv/install.sh | sh 21 | 22 | # Copy only dependency files first (better layer caching) 23 | COPY pyproject.toml setup.py VERSION /app/ 24 | WORKDIR /app 25 | 26 | # Install project in a virtual env (lightweight & reproducible) 27 | RUN /root/.local/bin/uv pip install --system . 28 | 29 | # Final stage: minimal runtime image 30 | FROM python:3.13-alpine 31 | 32 | # Build arguments 33 | ARG APP_VERSION 34 | ARG BUILD_DATE 35 | ARG VCS_REF 36 | 37 | # OCI Image Specification labels 38 | LABEL org.opencontainers.image.title="qbit-manage" 39 | LABEL org.opencontainers.image.description="This tool will help manage tedious tasks in qBittorrent and automate them. Tag, categorize, remove Orphaned data, remove unregistered torrents and much much more." 40 | LABEL org.opencontainers.image.version="$APP_VERSION" 41 | LABEL org.opencontainers.image.created="$BUILD_DATE" 42 | LABEL org.opencontainers.image.revision="$VCS_REF" 43 | LABEL org.opencontainers.image.authors="bobokun" 44 | LABEL org.opencontainers.image.vendor="StuffAnThings" 45 | LABEL org.opencontainers.image.licenses="MIT" 46 | LABEL org.opencontainers.image.url="https://github.com/StuffAnThings/qbit_manage" 47 | LABEL org.opencontainers.image.documentation="https://github.com/StuffAnThings/qbit_manage/wiki" 48 | LABEL org.opencontainers.image.source="https://github.com/StuffAnThings/qbit_manage" 49 | LABEL org.opencontainers.image.base.name="python:3.13-alpine" 50 | 51 | ENV TINI_VERSION=v0.19.0 52 | 53 | 54 | # Runtime dependencies (smaller than build stage) 55 | RUN apk add --no-cache \ 56 | tzdata \ 57 | bash \ 58 | curl \ 59 | jq \ 60 | tini \ 61 | su-exec \ 62 | && rm -rf /var/cache/apk/* 63 | 64 | # Copy installed packages and scripts from builder 65 | COPY --from=builder /usr/local/lib/python3.13/site-packages/ /usr/local/lib/python3.13/site-packages/ 66 | COPY --from=builder /app /app 67 | COPY . /app 68 | COPY entrypoint.sh /app/entrypoint.sh 69 | WORKDIR /app 70 | RUN chmod +x /app/entrypoint.sh 71 | VOLUME /config 72 | 73 | # Expose port 8080 74 | EXPOSE 8080 75 | 76 | ENTRYPOINT ["/sbin/tini", "-s", "/app/entrypoint.sh"] 77 | CMD ["python3", "qbit_manage.py"] 78 | -------------------------------------------------------------------------------- /web-ui/js/utils/toast.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Toast Utility Module 3 | * Manages the display of toast notifications. 4 | */ 5 | 6 | import { get, show, hide } from './dom.js'; 7 | import { CLOSE_ICON_SVG } from './icons.js'; 8 | 9 | const TOAST_CONTAINER_ID = 'toast-container'; 10 | 11 | /** 12 | * Displays a toast notification. 13 | * @param {string} message - The message to display in the toast. 14 | * @param {'success'|'error'|'warning'|'info'} [type='info'] - The type of toast (for styling). 15 | * @param {number} [duration=5000] - How long the toast should be visible in milliseconds. 16 | */ 17 | export function showToast(message, type = 'info', duration = 5000) { 18 | const container = get(TOAST_CONTAINER_ID); 19 | if (!container) { 20 | console.warn('Toast container not found. Cannot display toast message.'); 21 | return; 22 | } 23 | 24 | const toast = document.createElement('div'); 25 | toast.className = `toast toast-${type}`; 26 | 27 | const icons = { 28 | success: '✓', 29 | error: '✕', 30 | warning: '⚠', 31 | info: 'ℹ', 32 | undo: '↶', 33 | redo: '↷' 34 | }; 35 | 36 | // Build static structure with innerHTML, then set message via textContent 37 | toast.innerHTML = ` 38 |
${icons[type] || icons.info}
39 |
40 |
41 |
42 | 45 | `; 46 | // Insert message safely without relying on HTML escaping 47 | const msgNode = toast.querySelector('.toast-message'); 48 | if (msgNode) { 49 | msgNode.textContent = message == null ? '' : String(message); 50 | } 51 | 52 | container.appendChild(toast); 53 | 54 | // Show toast (add 'show' class to trigger transition) 55 | setTimeout(() => { 56 | toast.classList.add('show'); 57 | }, 100); 58 | 59 | // Auto-hide toast 60 | const hideToast = () => { 61 | toast.classList.remove('show'); 62 | // Remove from DOM after transition 63 | toast.addEventListener('transitionend', () => { 64 | if (toast.parentElement) { 65 | toast.remove(); 66 | } 67 | }, { once: true }); 68 | }; 69 | 70 | setTimeout(hideToast, duration); 71 | 72 | // Close button event 73 | const closeButton = toast.querySelector('.toast-close'); 74 | if (closeButton) { 75 | closeButton.addEventListener('click', hideToast); 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /web-ui/css/components/_modal.css: -------------------------------------------------------------------------------- 1 | /* Modal Component */ 2 | .modal-overlay { 3 | position: fixed; 4 | top: 0; 5 | left: 0; 6 | right: 0; 7 | bottom: 0; 8 | background-color: rgba(0, 0, 0, 0.5); 9 | display: flex; 10 | align-items: center; 11 | justify-content: center; 12 | z-index: var(--z-modal-backdrop); 13 | opacity: 0; 14 | transition: opacity var(--transition-normal); 15 | } 16 | 17 | .modal-overlay:not(.hidden) { 18 | opacity: 1; 19 | } 20 | 21 | .modal { 22 | background-color: var(--bg-primary); 23 | border-radius: var(--border-radius-lg); 24 | box-shadow: var(--shadow-lg); 25 | max-width: 32rem; 26 | width: 90%; 27 | max-height: 90vh; 28 | overflow: hidden; 29 | transform: scale(0.95); 30 | transition: transform var(--transition-normal); 31 | position: relative; 32 | z-index: 1; 33 | } 34 | 35 | .modal-overlay:not(.hidden) .modal { 36 | transform: scale(1); 37 | } 38 | 39 | .modal-header { 40 | display: flex; 41 | align-items: center; 42 | justify-content: space-between; 43 | padding: var(--spacing-lg); 44 | border-bottom: 1px solid var(--border-color); 45 | } 46 | 47 | .modal-header h3 { 48 | margin: 0; 49 | font-size: var(--font-size-xl); 50 | font-weight: 600; 51 | color: var(--text-primary); 52 | } 53 | 54 | .modal-content { 55 | padding: var(--spacing-lg); 56 | max-height: 60vh; 57 | overflow-y: auto; 58 | } 59 | 60 | .modal-footer { 61 | display: flex; 62 | align-items: center; 63 | justify-content: flex-end; 64 | gap: var(--spacing-sm); 65 | padding: var(--spacing-lg); 66 | border-top: 1px solid var(--border-color); 67 | background-color: var(--bg-secondary); 68 | } 69 | 70 | /* API Key Modal Styles */ 71 | .api-key-modal { 72 | text-align: center; 73 | } 74 | 75 | .api-key-modal p:first-child { 76 | color: var(--warning-color, #d97706); 77 | font-weight: 500; 78 | margin-bottom: var(--spacing-md); 79 | } 80 | 81 | .api-key-display-modal { 82 | display: flex; 83 | align-items: center; 84 | gap: var(--spacing-sm); 85 | margin: var(--spacing-md) 0; 86 | padding: var(--spacing-md); 87 | background-color: var(--bg-secondary); 88 | border-radius: var(--border-radius-md); 89 | border: 1px solid var(--border-color); 90 | } 91 | 92 | .api-key-display-modal .form-input { 93 | flex: 1; 94 | font-family: 'Courier New', monospace; 95 | font-size: var(--font-size-sm); 96 | letter-spacing: 0.5px; 97 | } 98 | 99 | .api-key-display-modal .btn { 100 | flex-shrink: 0; 101 | } 102 | 103 | .modal-warning { 104 | color: var(--text-secondary); 105 | font-size: var(--font-size-sm); 106 | margin-top: var(--spacing-md) !important; 107 | font-style: italic; 108 | } 109 | -------------------------------------------------------------------------------- /scripts/pre-commit/increase_version.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # Detect if running in CI (e.g., GitHub Actions or pre-commit.ci) 4 | if [[ -n "$GITHUB_ACTIONS" || -n "$CI" || -n "$PRE_COMMIT_CI" ]]; then 5 | IN_CI=true 6 | else 7 | IN_CI=false 8 | fi 9 | # CI: For pull_request events, check if the PR itself changes VERSION. 10 | # If not, run the develop version updater. This avoids relying on staged files. 11 | if [[ "$IN_CI" == "true" ]]; then 12 | # Check if VERSION file contains "develop" 13 | if ! grep -q "develop" VERSION; then 14 | echo "VERSION file does not contain 'develop'. Skipping version update." 15 | exit 0 16 | fi 17 | 18 | # Check if VERSION differs from develop branch 19 | if git diff --quiet origin/develop...HEAD -- VERSION 2>/dev/null; then 20 | echo "VERSION file is the same as in develop branch. User didn't bump version, so updating develop version." 21 | source "$(dirname "$0")/update_develop_version.sh" 22 | else 23 | echo "VERSION file differs from develop branch. User already bumped version. Skipping update." 24 | fi 25 | exit 0 26 | fi 27 | 28 | # When running locally during an actual commit, skip if nothing is staged. 29 | # In CI, pre-commit typically runs outside of a commit with no staged files, 30 | # so we must not early-exit there. 31 | if [[ "$IN_CI" != "true" && -z $(git diff --cached --name-only) ]]; then 32 | echo "There are no changes staged for commit. Skipping version update." 33 | exit 0 34 | fi 35 | 36 | # For local development, check if VERSION contains "develop" 37 | if [[ "$IN_CI" != "true" ]]; then 38 | # Check if VERSION file contains "develop" 39 | if ! grep -q "develop" VERSION; then 40 | echo "VERSION file does not contain 'develop'. Skipping version update." 41 | exit 0 42 | # Check if the VERSION file is staged for modification 43 | elif git diff --cached --name-only | grep -q "VERSION"; then 44 | echo "The VERSION file is already modified. Skipping version update." 45 | exit 0 46 | elif git diff --name-only | grep -q "VERSION"; then 47 | echo "The VERSION file has unstaged changes. Please stage them before committing." 48 | exit 0 49 | fi 50 | fi 51 | 52 | # Check if we should run version update 53 | if ! git diff --quiet origin/develop -- VERSION 2>/dev/null; then 54 | # VERSION differs from develop branch, so we should update it 55 | source "$(dirname "$0")/update_develop_version.sh" 56 | elif [[ -n "$(git diff --cached --name-only)" ]] && ! git diff --cached --name-only | grep -q "VERSION"; then 57 | # There are staged changes but VERSION is not among them 58 | source "$(dirname "$0")/update_develop_version.sh" 59 | elif ! git show --name-only HEAD | grep -q "VERSION"; then 60 | # VERSION doesn't exist in HEAD (new file) 61 | source "$(dirname "$0")/update_develop_version.sh" 62 | fi 63 | -------------------------------------------------------------------------------- /desktop/tauri/src-tauri/build.rs: -------------------------------------------------------------------------------- 1 | // Build script required for tauri::generate_context! macro (generates code into OUT_DIR) 2 | use std::fs; 3 | use std::path::Path; 4 | 5 | fn main() { 6 | // Check which binaries exist for debugging 7 | let bin_dir = std::path::Path::new("bin"); 8 | if bin_dir.exists() { 9 | println!("cargo:rerun-if-changed=bin"); 10 | } 11 | 12 | // Read version from VERSION file and update both Cargo.toml and tauri.conf.json 13 | let version_file_path = Path::new("../../../VERSION"); 14 | let cargo_toml_path = Path::new("Cargo.toml"); 15 | let config_path = Path::new("tauri.conf.json"); 16 | 17 | if let Ok(version_content) = fs::read_to_string(version_file_path) { 18 | let version = version_content.trim(); 19 | 20 | // Update Cargo.toml with the version 21 | if let Ok(cargo_content) = fs::read_to_string(cargo_toml_path) { 22 | if let Ok(mut cargo_toml) = cargo_content.parse::() { 23 | // Update the version field in [package] section 24 | if let Some(package) = cargo_toml.get_mut("package") { 25 | if let Some(package_table) = package.as_table_mut() { 26 | package_table.insert("version".to_string(), toml::Value::String(version.to_string())); 27 | 28 | // Write back the updated Cargo.toml 29 | if let Ok(updated_cargo) = toml::to_string(&cargo_toml) { 30 | let _ = fs::write(cargo_toml_path, updated_cargo); 31 | } 32 | } 33 | } 34 | } 35 | } 36 | 37 | // Update tauri.conf.json with the version 38 | if let Ok(config_content) = fs::read_to_string(config_path) { 39 | // Parse as JSON value 40 | if let Ok(mut config_json) = serde_json::from_str::(&config_content) { 41 | // Update the version field 42 | config_json["version"] = serde_json::Value::String(version.to_string()); 43 | 44 | // Write back the updated configuration 45 | if let Ok(updated_config) = serde_json::to_string_pretty(&config_json) { 46 | let _ = fs::write(config_path, updated_config); 47 | } 48 | } 49 | } 50 | 51 | println!("cargo:rustc-env=TAURI_APP_VERSION={}", version); 52 | } else { 53 | // Fallback to default version if VERSION file not found 54 | println!("cargo:rustc-env=TAURI_APP_VERSION=0.1.0"); 55 | } 56 | 57 | // Tell cargo to rerun this script if VERSION file changes 58 | println!("cargo:rerun-if-changed=../../../VERSION"); 59 | // Also rerun if Cargo.toml changes to prevent infinite loops 60 | println!("cargo:rerun-if-changed=Cargo.toml"); 61 | 62 | tauri_build::build(); 63 | } 64 | -------------------------------------------------------------------------------- /web-ui/js/utils/dom.js: -------------------------------------------------------------------------------- 1 | /** 2 | * DOM Utility Module 3 | * Provides centralized functions for DOM element selection and manipulation. 4 | */ 5 | 6 | /** 7 | * Get an element by its ID. 8 | * @param {string} id - The ID of the element. 9 | * @returns {HTMLElement|null} The element, or null if not found. 10 | */ 11 | export function get(id) { 12 | return document.getElementById(id); 13 | } 14 | 15 | /** 16 | * Get the first element matching a CSS selector. 17 | * @param {string} selector - The CSS selector. 18 | * @param {HTMLElement} [parent=document] - The parent element to search within. 19 | * @returns {HTMLElement|null} The element, or null if not found. 20 | */ 21 | export function query(selector, parent = document) { 22 | return parent.querySelector(selector); 23 | } 24 | 25 | /** 26 | * Get all elements matching a CSS selector. 27 | * @param {string} selector - The CSS selector. 28 | * @param {HTMLElement} [parent=document] - The parent element to search within. 29 | * @returns {NodeListOf} A NodeList of matching elements. 30 | */ 31 | export function queryAll(selector, parent = document) { 32 | return parent.querySelectorAll(selector); 33 | } 34 | 35 | /** 36 | * Show an HTML element by removing the 'hidden' class. 37 | * @param {HTMLElement} element - The element to show. 38 | */ 39 | export function show(element) { 40 | if (element) { 41 | element.classList.remove('hidden'); 42 | } 43 | } 44 | 45 | /** 46 | * Hide an HTML element by adding the 'hidden' class. 47 | * @param {HTMLElement} element - The element to hide. 48 | */ 49 | export function hide(element) { 50 | if (element) { 51 | element.classList.add('hidden'); 52 | } 53 | } 54 | 55 | /** 56 | * Displays a loading spinner in the specified container. 57 | * @param {HTMLElement} container - The container element where the spinner should be displayed. 58 | * @param {string} [message='Loading...'] - The message to display below the spinner. 59 | */ 60 | export function showLoading(container, message = 'Loading...') { 61 | if (container) { 62 | // Create a loading overlay element 63 | const loadingOverlay = document.createElement('div'); 64 | loadingOverlay.id = 'loading-overlay'; // Give it a unique ID 65 | loadingOverlay.className = 'loading-overlay'; // Add a class for styling 66 | loadingOverlay.innerHTML = ` 67 |
68 |
69 |

${message}

70 |
71 | `; 72 | container.appendChild(loadingOverlay); // Append it to the container 73 | } 74 | } 75 | 76 | /** 77 | * Hides the loading spinner. 78 | */ 79 | export function hideLoading() { 80 | const loadingOverlay = get('loading-overlay'); // Get the overlay by its ID 81 | if (loadingOverlay) { 82 | loadingOverlay.remove(); // Remove it from the DOM 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /.github/workflows/update-develop-branch.yml: -------------------------------------------------------------------------------- 1 | name: Update Develop Branch 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | 7 | permissions: 8 | contents: write 9 | 10 | jobs: 11 | 12 | update-develop: 13 | runs-on: ubuntu-latest 14 | 15 | steps: 16 | - name: Check Out Repo 17 | uses: actions/checkout@v6 18 | with: 19 | token: ${{ secrets.PAT || secrets.GITHUB_TOKEN }} 20 | fetch-depth: 0 21 | 22 | - name: Update develop branch 23 | run: | 24 | # Configure git with GitHub Actions bot credentials 25 | git config --local user.email "41898282+github-actions[bot]@users.noreply.github.com" 26 | git config --local user.name "github-actions[bot]" 27 | 28 | # Ensure we have the latest remote refs 29 | git fetch origin 30 | 31 | # Check if develop branch exists locally, if not create it 32 | if git show-ref --verify --quiet refs/heads/develop; then 33 | echo "Local develop branch exists, checking it out" 34 | git checkout develop 35 | else 36 | echo "Local develop branch doesn't exist, creating from remote" 37 | git checkout -b develop origin/develop 38 | fi 39 | 40 | # Reset develop to master 41 | echo "Resetting develop branch to match master..." 42 | git reset --hard origin/master 43 | 44 | # Read current version and bump patch 45 | CURRENT_VERSION=$(cat VERSION) 46 | echo "Current version: $CURRENT_VERSION" 47 | 48 | IFS='.' read -r MAJOR MINOR PATCH <<< "$CURRENT_VERSION" 49 | NEW_PATCH=$((PATCH + 1)) 50 | NEW_VERSION="${MAJOR}.${MINOR}.${NEW_PATCH}-develop1" 51 | 52 | echo "New version: $NEW_VERSION" 53 | 54 | # Update VERSION file 55 | echo "$NEW_VERSION" > VERSION 56 | 57 | # Check if there are changes to commit 58 | if git diff --quiet; then 59 | echo "No changes to commit" 60 | exit 0 61 | fi 62 | 63 | # Commit the change 64 | git add VERSION 65 | git commit -m "Update VERSION to $NEW_VERSION [skip ci]" 66 | 67 | # Push develop branch (force push since we reset to master) 68 | # Note: This requires PAT_TOKEN secret with admin privileges to bypass branch protection 69 | echo "Pushing changes to develop branch..." 70 | if ! git push --force origin develop; then 71 | echo "Failed to push to develop branch. This may be due to branch protection rules." 72 | echo "Ensure PAT_TOKEN secret is set with admin privileges or disable branch protection for this workflow." 73 | exit 1 74 | fi 75 | 76 | echo "Successfully updated develop branch to $NEW_VERSION" 77 | 78 | - name: Trigger develop workflow 79 | if: success() 80 | run: | 81 | echo "Triggering develop workflow..." 82 | gh workflow run develop.yml --ref develop 83 | env: 84 | GH_TOKEN: ${{ secrets.PAT || secrets.GITHUB_TOKEN }} 85 | -------------------------------------------------------------------------------- /scripts/ban_peers.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import logging 3 | import re 4 | import sys 5 | 6 | from qbittorrentapi import Client 7 | from qbittorrentapi.exceptions import APIError 8 | 9 | logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s") 10 | logger = logging.getLogger(__name__) 11 | 12 | 13 | def validate_peers(peers_str): 14 | if not isinstance(peers_str, str): 15 | raise ValueError("Peers must be a string") 16 | peer_addresses = [peer.strip() for peer in peers_str.split("|") if peer.strip()] 17 | valid_peers = [] 18 | peer_pattern = re.compile( 19 | r"^(?:" 20 | r"(?:[0-9]{1,3}\.){3}[0-9]{1,3}|" # IPv4 21 | r"\[[0-9a-fA-F:]+\]|" # IPv6 22 | r"[a-zA-Z0-9.-]+" # Hostname 23 | r"):[0-9]{1,5}$" 24 | ) 25 | for peer in peer_addresses: 26 | if peer_pattern.match(peer): 27 | try: 28 | port = int(peer.split(":")[-1]) 29 | if 1 <= port <= 65535: 30 | valid_peers.append(peer) 31 | else: 32 | logger.warning(f"Invalid port range for peer: {peer}") 33 | except ValueError: 34 | logger.warning(f"Invalid port for peer: {peer}") 35 | else: 36 | logger.warning(f"Invalid peer format: {peer}") 37 | return valid_peers 38 | 39 | 40 | def ban_peers(client, peer_list, dry_run): 41 | if not peer_list: 42 | logger.info("No valid peers to ban") 43 | return 0 44 | logger.info(f"Attempting to ban {len(peer_list)} peer(s): {', '.join(peer_list)}") 45 | if dry_run: 46 | for peer in peer_list: 47 | logger.info(f"[DRY-RUN] - {peer}") 48 | return len(peer_list) 49 | try: 50 | peers_string = "|".join(peer_list) 51 | client.transfer.ban_peers(peers=peers_string) 52 | logger.info(f"Successfully banned {len(peer_list)} peer(s)") 53 | return len(peer_list) 54 | except APIError as e: 55 | logger.error(f"Error banning peers: {str(e)}") 56 | return 0 57 | 58 | 59 | def main(): 60 | parser = argparse.ArgumentParser(description="Ban peers in qBittorrent.") 61 | parser.add_argument("--host", default="localhost", help="qBittorrent host") 62 | parser.add_argument("--port", type=int, default=8080, help="qBittorrent port") 63 | parser.add_argument("--user", help="Username") 64 | parser.add_argument("--pass", dest="password", help="Password") 65 | parser.add_argument("--peers", required=True, help="Peers to ban, separated by |") 66 | parser.add_argument("--dry-run", action="store_true", help="Dry run mode") 67 | args = parser.parse_args() 68 | try: 69 | client = Client( 70 | host=args.host, 71 | port=args.port, 72 | username=args.user, 73 | password=args.password, 74 | ) 75 | client.auth_log_in() 76 | except Exception as e: 77 | logger.error(f"Failed to connect: {e}") 78 | sys.exit(1) 79 | valid_peers = validate_peers(args.peers) 80 | stats = ban_peers(client, valid_peers, args.dry_run) 81 | logger.info(f"Banned {stats} peers") 82 | 83 | 84 | if __name__ == "__main__": 85 | main() 86 | -------------------------------------------------------------------------------- /web-ui/js/utils/utils.js: -------------------------------------------------------------------------------- 1 | /** 2 | * General Utility Module 3 | * Provides common utility functions. 4 | */ 5 | 6 | /** 7 | * Gets a nested value from an object using a dot-separated path. 8 | * @param {object} obj - The object to query. 9 | * @param {string} path - The dot-separated path (e.g., 'parent.child.property'). 10 | * @returns {*} The value at the specified path, or undefined if not found. 11 | */ 12 | export function getNestedValue(obj, path) { 13 | return path.split('.').reduce((current, key) => { 14 | return current && current[key] !== undefined ? current[key] : undefined; 15 | }, obj); 16 | } 17 | 18 | /** 19 | * Escape a string for safe insertion into HTML/attribute context. 20 | * Encodes &, <, >, ", ' to their HTML entities. 21 | * This should be used whenever inserting user-controlled content via innerHTML 22 | * or into attribute values. 23 | * @param {any} str - Input value to escape 24 | * @returns {string} Escaped HTML-safe string 25 | */ 26 | export function escapeHtml(str) { 27 | if (str === null || str === undefined) return ''; 28 | return String(str) 29 | .replace(/&/g, '&') 30 | .replace(//g, '>') 32 | .replace(/"/g, '"') 33 | .replace(/'/g, ''') 34 | .replace(/\//g, '/'); 35 | } 36 | 37 | /** 38 | * Sets a nested value in an object using a dot-separated path. 39 | * Creates intermediate objects if they don't exist. 40 | * If the value is null, undefined, or an empty string, the property is deleted. 41 | * @param {object} obj - The object to modify. 42 | * @param {string} path - The dot-separated path (e.g., 'parent.child.property'). 43 | * @param {*} value - The value to set. 44 | */ 45 | export function setNestedValue(obj, path, value) { 46 | const keys = path.split('.'); 47 | const lastKey = keys.pop(); 48 | const target = keys.reduce((current, key) => { 49 | if (!current[key] || typeof current[key] !== 'object') { 50 | current[key] = {}; 51 | } 52 | return current[key]; 53 | }, obj); 54 | 55 | if (value === null || value === undefined || value === '') { 56 | delete target[lastKey]; 57 | } else { 58 | target[lastKey] = value; 59 | } 60 | } 61 | 62 | /** 63 | * Basic host validation - IP address or hostname 64 | * @param {string} host - The host string to validate. 65 | * @returns {boolean} True if the host is valid, false otherwise. 66 | */ 67 | export function isValidHost(host) { 68 | const ipRegex = /^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/; 69 | const hostnameRegex = /^[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?)*$/; 70 | 71 | return ipRegex.test(host) || hostnameRegex.test(host) || host === 'localhost'; 72 | } 73 | /** 74 | * Debounces a function, so it only runs after a specified delay. 75 | * @param {function} func - The function to debounce. 76 | * @param {number} delay - The delay in milliseconds. 77 | * @returns {function} The debounced function. 78 | */ 79 | export function debounce(func, delay) { 80 | let timeout; 81 | return function(...args) { 82 | const context = this; 83 | clearTimeout(timeout); 84 | timeout = setTimeout(() => func.apply(context, args), delay); 85 | }; 86 | } 87 | -------------------------------------------------------------------------------- /web-ui/js/utils/theme-manager.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Theme Manager - Handles dark/light theme switching 3 | */ 4 | 5 | class ThemeManager { 6 | constructor() { 7 | this.currentTheme = this.getStoredTheme() || 'light'; 8 | this.init(); 9 | } 10 | 11 | init() { 12 | // Apply the current theme 13 | this.applyTheme(this.currentTheme); 14 | 15 | // Set up theme toggle button 16 | this.setupThemeToggle(); 17 | 18 | // Listen for system theme changes 19 | this.setupSystemThemeListener(); 20 | } 21 | 22 | getSystemTheme() { 23 | return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; 24 | } 25 | 26 | getStoredTheme() { 27 | return localStorage.getItem('qbit-manage-theme'); 28 | } 29 | 30 | storeTheme(theme) { 31 | localStorage.setItem('qbit-manage-theme', theme); 32 | } 33 | 34 | applyTheme(theme) { 35 | const root = document.documentElement; 36 | 37 | // Remove existing theme attributes 38 | root.removeAttribute('data-theme'); 39 | 40 | if (theme === 'dark') { 41 | root.setAttribute('data-theme', 'dark'); 42 | } else if (theme === 'light') { 43 | root.setAttribute('data-theme', 'light'); 44 | } 45 | // If theme is 'auto' or not set, let CSS handle it via prefers-color-scheme 46 | 47 | this.currentTheme = theme; 48 | this.updateThemeToggleIcon(); 49 | } 50 | 51 | toggleTheme() { 52 | const newTheme = this.currentTheme === 'light' ? 'dark' : 'light'; 53 | this.applyTheme(newTheme); 54 | this.storeTheme(newTheme); 55 | } 56 | 57 | setupThemeToggle() { 58 | const themeToggle = document.getElementById('theme-toggle'); 59 | if (themeToggle) { 60 | themeToggle.addEventListener('click', () => { 61 | this.toggleTheme(); 62 | }); 63 | } 64 | } 65 | 66 | updateThemeToggleIcon() { 67 | const themeToggle = document.getElementById('theme-toggle'); 68 | if (!themeToggle) return; 69 | 70 | const sunIcon = themeToggle.querySelector('.icon-sun'); 71 | const moonIcon = themeToggle.querySelector('.icon-moon'); 72 | 73 | if (!sunIcon || !moonIcon) return; 74 | 75 | // Update title based on current theme 76 | const title = this.currentTheme === 'light' ? 'Switch to Dark Mode' : 'Switch to Light Mode'; 77 | themeToggle.setAttribute('title', title); 78 | 79 | // The CSS handles showing/hiding icons based on data-theme attribute 80 | // No need to manually toggle visibility here 81 | } 82 | 83 | setupSystemThemeListener() { 84 | const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)'); 85 | mediaQuery.addEventListener('change', () => { 86 | // Only react to system changes if we're in auto mode 87 | if (!this.getStoredTheme()) { 88 | this.updateThemeToggleIcon(); 89 | } 90 | }); 91 | } 92 | 93 | // Public API 94 | setTheme(theme) { 95 | if (['light', 'dark'].includes(theme)) { 96 | this.applyTheme(theme); 97 | this.storeTheme(theme); 98 | } 99 | } 100 | 101 | getCurrentTheme() { 102 | return this.currentTheme; 103 | } 104 | 105 | getEffectiveTheme() { 106 | return this.currentTheme; 107 | } 108 | } 109 | 110 | // Create and export theme manager instance 111 | export const themeManager = new ThemeManager(); 112 | 113 | // Also export the class for potential custom usage 114 | export { ThemeManager }; 115 | -------------------------------------------------------------------------------- /web-ui/js/config-schemas/tracker.js: -------------------------------------------------------------------------------- 1 | export const trackerSchema = { 2 | title: 'Tracker', 3 | description: 'Configure tags and categories based on tracker URLs. Use a keyword from the tracker URL to define rules. The `other` key is a special keyword for trackers that do not match any other entry.', 4 | type: 'complex-object', 5 | fields: [ 6 | { 7 | type: 'documentation', 8 | title: 'Tracker Configuration Documentation', 9 | filePath: 'Config-Setup.md', 10 | section: 'tracker', 11 | defaultExpanded: false 12 | } 13 | ], 14 | patternProperties: { 15 | "^(?!other$).*$": { // Matches any key except 'other' 16 | type: 'object', 17 | properties: { 18 | tag: { 19 | label: 'Tag(s)', 20 | description: 'The tag or tags to apply to torrents from this tracker.', 21 | type: 'array', 22 | items: { type: 'string' } 23 | }, 24 | cat: { 25 | type: 'string', 26 | label: 'Category', 27 | description: 'Set a category for torrents from this tracker. This will override any category set by the `cat` section.', 28 | useCategoryDropdown: true // Flag to indicate this field should use category dropdown 29 | }, 30 | notifiarr: { 31 | type: 'string', 32 | label: 'Notifiarr React Name', 33 | description: 'The Notifiarr "React Name" for this tracker, used for indexer-specific reactions in notifications.', 34 | } 35 | }, 36 | required: ['tag'], 37 | additionalProperties: false 38 | }, 39 | "other": { // Special handling for the 'other' key 40 | type: 'object', 41 | properties: { 42 | tag: { 43 | label: 'Tag(s)', 44 | description: 'The tag or tags to apply to torrents from any tracker not explicitly defined elsewhere.', 45 | type: 'array', 46 | items: { type: 'string' } 47 | } 48 | }, 49 | required: ['tag'], 50 | additionalProperties: false 51 | } 52 | }, 53 | additionalProperties: { // Schema for dynamically added properties (new tracker entries) 54 | type: 'object', 55 | properties: { 56 | tag: { 57 | label: 'Tag(s)', 58 | description: 'The tag or tags to apply to torrents from this tracker.', 59 | type: 'array', 60 | items: { type: 'string' } 61 | }, 62 | cat: { 63 | type: 'string', 64 | label: 'Category', 65 | description: 'Set a category for torrents from this tracker. This will override any category set by the `cat` section.', 66 | useCategoryDropdown: true // Flag to indicate this field should use category dropdown 67 | }, 68 | notifiarr: { 69 | type: 'string', 70 | label: 'Notifiarr React Name', 71 | description: 'The Notifiarr "React Name" for this tracker, used for indexer-specific reactions in notifications.', 72 | } 73 | }, 74 | required: ['tag'], 75 | additionalProperties: false 76 | } 77 | }; 78 | -------------------------------------------------------------------------------- /docs/Home.md: -------------------------------------------------------------------------------- 1 | # qBit_manage Wiki 2 | 3 | This wiki should tell you everything you need to know about the script to get it working. 4 | 5 | ## Getting Started 6 | 7 | 1. **Choose your installation method:** 8 | - **Desktop App** (Recommended): Download and install the GUI application for [Windows, macOS, or Linux](Installation#desktop-app-installation) 9 | - **Standalone Binary**: Download the command-line executable for [Windows, macOS, or Linux](Installation#standalone-binary-installation) 10 | - **Docker**: Follow the [Docker Installation](Docker-Installation) guide for containerized environments 11 | - **Python/Source**: Install from [PyPI or source code](Installation#pythonsource-installation) for development 12 | - **unRAID**: Follow the [unRAID Installation](Unraid-Installation) guide for unRAID systems 13 | 14 | 2. **Configure qbit_manage:** 15 | - Desktop app users: Configuration is handled through the GUI 16 | - Command-line users: [Set up your Configuration](Config-Setup) by creating a [Configuration File](https://github.com/StuffAnThings/qbit_manage/blob/master/config/config.yml.sample) with your qBittorrent connection details 17 | 18 | 3. **Start using qbit_manage:** 19 | - Review the [Commands](Commands) documentation to understand available features 20 | - Try the [Web UI](Web-UI) for an intuitive configuration experience 21 | - Use the [Web API](Web-API) for automation and integration with API key authentication 22 | 23 | ## Support 24 | 25 | * If you have any questions or require support please join the [Notifiarr Discord](https://discord.com/invite/AURf8Yz) and post your question under the `qbit-manage` channel. 26 | * If you're getting an Error or have an Enhancement post in the [Issues](https://github.com/StuffAnThings/qbit_manage/issues/new). 27 | * If you have a configuration question post in the [Discussions](https://github.com/StuffAnThings/qbit_manage/discussions/new). 28 | * Pull Request are welcome but please submit them to the [develop branch](https://github.com/StuffAnThings/qbit_manage/tree/develop). 29 | 30 | ## Table of Contents 31 | 32 | - [Home](Home) 33 | - [Installation](Installation) 34 | - [Desktop App](Installation#desktop-app-installation) 35 | - [Standalone Binary Installation](Installation#standalone-binary-installation) 36 | - [Python/Source Installation](Installation#pythonsource-installation) 37 | - [Docker Installation](Docker-Installation) 38 | - [unRAID Installation](Unraid-Installation) 39 | - [Config Setup](Config-Setup) 40 | - [Sample Config File](Config-Setup#config-file) 41 | - [List of variables](Config-Setup#list-of-variables) 42 | - [commands](Config-Setup#commands) 43 | - [qbt](Config-Setup#qbt) 44 | - [settings](Config-Setup#settings) 45 | - [directory](Config-Setup#directory) 46 | - [cat](Config-Setup#cat) 47 | - [cat_change](Config-Setup#cat_change) 48 | - [tracker](Config-Setup#tracker) 49 | - [nohardlinks](Config-Setup#nohardlinks) 50 | - [share_limits](Config-Setup#share_limits) 51 | - [recyclebin](Config-Setup#recyclebin) 52 | - [orphaned](Config-Setup#orphaned) 53 | - [apprise](Config-Setup#apprise) 54 | - [notifiarr](Config-Setup#notifiarr) 55 | - [webhooks](Config-Setup#webhooks) 56 | - [Commands](Commands) 57 | - [Web API](Web-API) 58 | - [Web UI](Web-UI) 59 | - Extras 60 | - [Standalone Scripts](Standalone-Scripts) 61 | - [V4 Migration Guide](v4-Migration-Guide) 62 | -------------------------------------------------------------------------------- /web-ui/js/config-schemas/commands.js: -------------------------------------------------------------------------------- 1 | export const commandsSchema = { 2 | title: 'Commands', 3 | description: 'Enable or disable specific commands to be executed during a run. This section will override any commands that are defined via environment variable or command line', 4 | fields: [ 5 | { 6 | type: 'documentation', 7 | title: 'Commands Documentation', 8 | filePath: 'Commands.md', 9 | defaultExpanded: false 10 | }, 11 | { 12 | name: 'recheck', 13 | type: 'boolean', 14 | label: 'Recheck Torrents', 15 | description: 'Recheck paused torrents, sorted by lowest size. Resumes the torrent if it has completed.', 16 | default: false 17 | }, 18 | { 19 | name: 'cat_update', 20 | type: 'boolean', 21 | label: 'Update Categories', 22 | description: 'Update torrent categories based on specified rules and move torrents between categories.', 23 | default: false 24 | }, 25 | { 26 | name: 'tag_update', 27 | type: 'boolean', 28 | label: 'Update Tags', 29 | description: 'Update torrent tags, set seed goals, and limit upload speed by tag.', 30 | default: false 31 | }, 32 | { 33 | name: 'rem_unregistered', 34 | type: 'boolean', 35 | label: 'Remove Unregistered', 36 | description: 'Remove torrents that are unregistered with the tracker. Deletes data if not cross-seeded.', 37 | default: false 38 | }, 39 | { 40 | name: 'rem_orphaned', 41 | type: 'boolean', 42 | label: 'Remove Orphaned', 43 | description: 'Scan for and remove orphaned files from your root directory that are not referenced by any torrents.', 44 | default: false 45 | }, 46 | { 47 | name: 'tag_tracker_error', 48 | type: 'boolean', 49 | label: 'Tag Tracker Errors', 50 | description: 'Tag torrents that have a non-working tracker.', 51 | default: false 52 | }, 53 | { 54 | name: 'tag_nohardlinks', 55 | type: 'boolean', 56 | label: 'Tag No Hard Links', 57 | description: 'Tag torrents that do not have any hard links, useful for managing files from Sonarr/Radarr.', 58 | default: false 59 | }, 60 | { 61 | name: 'share_limits', 62 | type: 'boolean', 63 | label: 'Apply Share Limits', 64 | description: 'Apply share limits to torrents based on priority and grouping criteria.', 65 | default: false 66 | }, 67 | { 68 | type: 'section_header', 69 | label: 'Execution Options' 70 | }, 71 | { 72 | name: 'skip_cleanup', 73 | type: 'boolean', 74 | label: 'Skip Cleanup', 75 | description: 'Skip emptying the Recycle Bin and Orphaned directories.', 76 | default: false 77 | }, 78 | { 79 | name: 'dry_run', 80 | type: 'boolean', 81 | label: 'Dry Run', 82 | description: 'Simulate a run without making any actual changes to files, tags, or categories.', 83 | default: true 84 | }, 85 | { 86 | name: 'skip_qb_version_check', 87 | type: 'boolean', 88 | label: 'Skip qBittorrent Version Check', 89 | description: 'Bypass the qBittorrent/libtorrent version compatibility check. Use at your own risk.', 90 | default: false 91 | } 92 | ] 93 | }; 94 | -------------------------------------------------------------------------------- /docs/v4-Migration-Guide.md: -------------------------------------------------------------------------------- 1 | # Qbit-Manage Migration 2 | 3 | Currently the qbit-manage (qbm) config file manages torrents in two ways: via tracker and via hardlinks. The section of the config where you specify your trackers is also where you can specify share limits (duration and ratio) on a per-tracker basis. The section of the config where you address no hardlinks (noHL) is where you specify share limits for files that are not hardlinked. 4 | 5 | Starting with develop version 4.0.0 torrents are no longer configured solely by tracker or noHL status. You now create groups of torrents based on tags and you can set specific share limits for each group. This means max_seeding_time, min_seeding_time and max_ratio are no longer used in the tracker or noHL section of the config, they are used for each group of torrents. 6 | 7 | ## Old config 8 | 9 | ```yml 10 | cat: 11 | movies: “/data/torrents/movies” 12 | tv: “/data/torrents/tv” 13 | tracker: 14 | Tracker-a: 15 | tag: a 16 | max_seeding_time: 100 17 | max_ratio: 5 18 | Tracker-b: 19 | tag: b 20 | max_seeding_time: 100 21 | max_ratio: 5 22 | Tracker-c: 23 | tag: c 24 | max_seeding_time: 50 25 | max_ratio: 3 26 | nohardlinks: 27 | movies: 28 | cleanup: true 29 | max_seeding_time: 75 30 | max_ratio: 2 31 | tv: 32 | cleanup: true 33 | max_seeding_time: 25 34 | max_ratio: 1 35 | ``` 36 | 37 | ### New config 38 | 39 | ```yml 40 | cat: 41 | movies: “/data/torrents/movies” 42 | tv: “/data/torrents/tv” 43 | tracker: 44 | Tracker-a: 45 | tag: a 46 | Tracker-b: 47 | tag: b 48 | Tracker-c: 49 | tag: c 50 | nohardlinks: 51 | - movies 52 | - tv 53 | share_limits: 54 | group1.noHL: 55 | priority: 1 56 | include_any_tags: 57 | - a 58 | - b 59 | include_all_tags: 60 | - noHL 61 | categories: 62 | - movies 63 | max_ratio: 2 64 | max_seeding_time: 75 65 | cleanup: true 66 | group1: 67 | priority: 2 68 | include_any_tags: 69 | - a 70 | - b 71 | categories: 72 | - movies 73 | max_ratio: 5 74 | max_seeding_time: 100 75 | group2.noHL: 76 | priority: 3 77 | include_any_tags: 78 | - c 79 | include_all_tags: 80 | - noHL 81 | categories: 82 | - tv 83 | max_ratio: 1 84 | max_seeding_time: 25 85 | group2: 86 | priority: 4 87 | include_any_tags: 88 | - c 89 | categories: 90 | - tv 91 | max_ratio: 92 | max_seeding_time: 93 | ``` 94 | 95 | The new config will operate as follows: 96 | Torrents from tracker a and tracker b that are tagged as noHL and in the movie category will have a share limit of 75 minutes and a ratio of 2. These same torrents, when not tagged as noHL, will then have a share limit of 100 minutes and a ratio of 5. 97 | 98 | Torrents from tracker c that are tagged as noHL and in the tv category will have a share limit of 50 minutes and a ratio of 3. These same torrents, when not tagged as noHL, will have no share limit applied and will seed indefinitely. 99 | 100 | There is now much greater flexibility to apply different share limits to torrents based on how you group them and which tags and categories are assigned to each group. When assigning priority it is best to determine what limits/restrictions you want based on your preferences and assign the more restrictive limits as higher priority groups since share limits will not transfer when a torrent is moved from one group to another. In the examples above, the settings are more restrictive for noHL torrents so those are listed as higher priority within the group. 101 | -------------------------------------------------------------------------------- /web-ui/js/utils/modal.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Modal Utility Module 3 | * Manages the display and interaction of modal dialogs. 4 | */ 5 | 6 | import { get, query, hide, show } from './dom.js'; 7 | 8 | const MODAL_OVERLAY_ID = 'modal-overlay'; 9 | const MODAL_TITLE_ID = 'modal-title'; 10 | const MODAL_CONTENT_ID = 'modal-content'; 11 | const MODAL_CONFIRM_BTN_ID = 'modal-confirm-btn'; 12 | const MODAL_CANCEL_BTN_ID = 'modal-cancel-btn'; 13 | const MODAL_CLOSE_BTN_ID = 'modal-close-btn'; 14 | 15 | let resolvePromise; 16 | 17 | /** 18 | * Initializes the modal by attaching event listeners. 19 | * This should be called once when the application starts. 20 | */ 21 | export function initModal() { 22 | const modalOverlay = get(MODAL_OVERLAY_ID); 23 | if (modalOverlay) { 24 | modalOverlay.addEventListener('click', (e) => { 25 | if (e.target === modalOverlay) { 26 | hideModal(false); // Pass false to indicate cancellation 27 | } 28 | }); 29 | } 30 | 31 | const modalCloseBtn = get(MODAL_CLOSE_BTN_ID); 32 | if (modalCloseBtn) { 33 | modalCloseBtn.addEventListener('click', () => hideModal(false)); 34 | } 35 | 36 | const modalCancelBtn = get(MODAL_CANCEL_BTN_ID); 37 | if (modalCancelBtn) { 38 | modalCancelBtn.addEventListener('click', () => hideModal(false)); 39 | } 40 | 41 | const modalConfirmBtn = get(MODAL_CONFIRM_BTN_ID); 42 | if (modalConfirmBtn) { 43 | modalConfirmBtn.addEventListener('click', () => hideModal(true)); 44 | } 45 | } 46 | 47 | /** 48 | * Displays a modal dialog. 49 | * @param {string} title - The title of the modal. 50 | * @param {string} content - The HTML content to display inside the modal. 51 | * @param {object} [options={}] - Options for the modal. 52 | * @param {string} [options.confirmText='OK'] - Text for the confirm button. 53 | * @param {string} [options.cancelText='Cancel'] - Text for the cancel button. 54 | * @param {boolean} [options.showCancel=true] - Whether to show the cancel button. 55 | * @returns {Promise} A promise that resolves to true if confirmed, false if cancelled. 56 | */ 57 | export function showModal(title, content, options = {}) { 58 | const { confirmText = 'OK', cancelText = 'Cancel', showCancel = true } = options; 59 | 60 | const modalOverlay = get(MODAL_OVERLAY_ID); 61 | const modalTitle = get(MODAL_TITLE_ID); 62 | const modalContent = get(MODAL_CONTENT_ID); 63 | const confirmBtn = get(MODAL_CONFIRM_BTN_ID); 64 | const cancelBtn = get(MODAL_CANCEL_BTN_ID); 65 | 66 | if (!modalOverlay || !modalTitle || !modalContent || !confirmBtn || !cancelBtn) { 67 | console.error('Modal elements not found. Ensure index.html contains the modal structure.'); 68 | return Promise.resolve(false); 69 | } 70 | 71 | modalTitle.textContent = title; 72 | modalContent.innerHTML = content; 73 | confirmBtn.textContent = confirmText; 74 | cancelBtn.textContent = cancelText; 75 | 76 | if (showCancel) { 77 | show(cancelBtn); 78 | } else { 79 | hide(cancelBtn); 80 | } 81 | 82 | show(modalOverlay); 83 | 84 | return new Promise((resolve) => { 85 | resolvePromise = resolve; 86 | }); 87 | } 88 | 89 | /** 90 | * Hides the modal dialog. 91 | * @param {boolean} [confirmed=false] - Whether the modal was confirmed (true) or cancelled (false). 92 | */ 93 | export function hideModal(confirmed = false) { 94 | const modalOverlay = get(MODAL_OVERLAY_ID); 95 | if (modalOverlay) { 96 | hide(modalOverlay); 97 | } 98 | if (resolvePromise) { 99 | resolvePromise(confirmed); 100 | resolvePromise = null; // Clear the promise resolver 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /.github/workflows/update-supported-versions.yml: -------------------------------------------------------------------------------- 1 | name: Update Supported Versions 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | - develop 8 | paths: 9 | - "pyproject.toml" 10 | workflow_dispatch: 11 | inputs: 12 | targetBranch: 13 | description: "Branch to run the script on (default: develop)" 14 | required: false 15 | default: "develop" 16 | 17 | permissions: 18 | contents: write 19 | pull-requests: write 20 | 21 | jobs: 22 | update-versions: 23 | runs-on: ubuntu-latest 24 | steps: 25 | - name: Checkout repository 26 | uses: actions/checkout@v6 27 | with: 28 | ref: ${{ github.event.inputs.targetBranch || github.ref_name }} 29 | 30 | - name: Set up Python 31 | uses: actions/setup-python@v6 32 | with: 33 | python-version: "3.x" 34 | 35 | - name: Install uv 36 | run: | 37 | curl -LsSf https://astral.sh/uv/install.sh | sh 38 | echo "$HOME/.local/bin" >> $GITHUB_PATH 39 | 40 | - name: Install dependencies with uv 41 | run: | 42 | uv venv .venv 43 | source .venv/bin/activate 44 | uv pip install . 45 | 46 | - name: Run update script 47 | run: | 48 | source .venv/bin/activate 49 | python scripts/update-readme-version.py ${{ github.event.inputs.targetBranch || github.ref_name }} 50 | - name: Check for SUPPORTED_VERSIONS changes 51 | id: detect-changes 52 | run: | 53 | if git diff --name-only | grep -q '^SUPPORTED_VERSIONS\.json$'; then 54 | echo "SUPPORTED_VERSIONS.json changed." 55 | echo "changed=true" >> $GITHUB_OUTPUT 56 | else 57 | echo "No changes to SUPPORTED_VERSIONS.json. Skipping remainder of workflow." 58 | echo "changed=false" >> $GITHUB_OUTPUT 59 | fi 60 | 61 | - name: Update develop versions 62 | if: ${{ steps.detect-changes.outputs.changed == 'true' && (github.event.inputs.targetBranch || github.ref_name) == 'develop' }} 63 | id: get-develop-version 64 | run: | 65 | # Run the script and capture its output 66 | output=$(bash scripts/pre-commit/update_develop_version.sh) 67 | # Extract the last line which contains the version 68 | version=$(echo "$output" | tail -n 1) 69 | # Set the version as an output parameter for later steps 70 | echo "version=$version" >> $GITHUB_OUTPUT 71 | # Debug info 72 | echo "Script output: $output" 73 | echo "Captured Version: $version" 74 | 75 | - name: Create Pull Request 76 | if: ${{ steps.detect-changes.outputs.changed == 'true' }} 77 | id: create-pr 78 | uses: peter-evans/create-pull-request@v7 79 | with: 80 | commit-message: Update SUPPORTED_VERSIONS.json 81 | title: "Update SUPPORTED_VERSIONS.json for ${{ steps.get-develop-version.outputs.version || github.event.inputs.targetBranch || github.ref_name }}" 82 | branch: update-supported-versions-${{ github.event.inputs.targetBranch || github.ref_name }} 83 | base: develop 84 | body: "This PR updates the SUPPORTED_VERSIONS.json to reflect new versions." 85 | 86 | - name: Approve the Pull Request 87 | if: ${{ steps.create-pr.outputs.pull-request-number }} 88 | run: gh pr review ${{ steps.create-pr.outputs.pull-request-number }} --approve 89 | env: 90 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 91 | 92 | - name: Merge the Pull Request 93 | if: ${{ steps.create-pr.outputs.pull-request-number }} 94 | run: gh pr merge ${{ steps.create-pr.outputs.pull-request-number }} --auto --squash 95 | env: 96 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 97 | -------------------------------------------------------------------------------- /web-ui/css/components/_buttons.css: -------------------------------------------------------------------------------- 1 | /* Button Components */ 2 | .btn { 3 | display: inline-flex; 4 | align-items: center; 5 | justify-content: center; 6 | gap: var(--spacing-sm); 7 | padding: var(--spacing-sm) var(--spacing-md); 8 | border: 1px solid transparent; 9 | border-radius: var(--border-radius); 10 | font-family: inherit; 11 | font-size: var(--font-size-sm); 12 | font-weight: 500; 13 | line-height: 1.5; 14 | text-decoration: none; 15 | cursor: pointer; 16 | transition: all var(--transition-fast); 17 | user-select: none; 18 | white-space: nowrap; 19 | } 20 | 21 | .btn:focus { 22 | outline: none; 23 | box-shadow: 0 0 0 3px rgb(59 130 246 / 0.1); 24 | } 25 | 26 | .btn:disabled { 27 | opacity: 0.6; 28 | cursor: not-allowed; 29 | pointer-events: none; 30 | } 31 | 32 | .btn .icon { 33 | width: 1rem; 34 | height: 1rem; 35 | fill: currentColor; 36 | } 37 | 38 | /* Button Variants */ 39 | .btn-primary { 40 | background-color: var(--primary-color); 41 | border-color: var(--primary-color); 42 | color: var(--text-inverse); 43 | } 44 | 45 | .btn-primary:hover:not(:disabled) { 46 | background-color: var(--primary-hover); 47 | border-color: var(--primary-hover); 48 | } 49 | 50 | .btn-secondary { 51 | background-color: var(--bg-secondary); 52 | border-color: var(--border-color); 53 | color: var(--text-primary); 54 | } 55 | 56 | .btn-secondary:hover:not(:disabled) { 57 | background-color: var(--btn-secondary-hover); 58 | border-color: var(--border-hover); 59 | } 60 | 61 | .btn-success { 62 | background-color: var(--success-color); 63 | border-color: var(--success-color); 64 | color: var(--text-inverse); 65 | } 66 | 67 | .btn-success:hover:not(:disabled) { 68 | background-color: #059669; 69 | border-color: #059669; 70 | } 71 | 72 | .btn-warning { 73 | background-color: var(--warning-color); 74 | border-color: var(--warning-color); 75 | color: var(--text-inverse); 76 | } 77 | 78 | .btn-warning:hover:not(:disabled) { 79 | background-color: #d97706; 80 | border-color: #d97706; 81 | } 82 | 83 | .btn-error { 84 | background-color: var(--error-color); 85 | border-color: var(--error-color); 86 | color: var(--text-inverse); 87 | } 88 | 89 | .btn-error:hover:not(:disabled) { 90 | background-color: #dc2626; 91 | border-color: #dc2626; 92 | } 93 | 94 | .btn-ghost { 95 | background-color: transparent; 96 | border-color: transparent; 97 | color: var(--text-secondary); 98 | } 99 | 100 | .btn-ghost:hover:not(:disabled) { 101 | background-color: var(--bg-secondary); 102 | color: var(--text-primary); 103 | } 104 | 105 | .btn-outline { 106 | background-color: var(--bg-secondary); 107 | border-color: var(--border-color); 108 | color: var(--text-primary); 109 | } 110 | 111 | .btn-outline:hover:not(:disabled) { 112 | background-color: var(--btn-secondary-hover); 113 | border-color: var(--border-hover); 114 | color: var(--text-primary); 115 | } 116 | 117 | .btn-icon { 118 | padding: var(--spacing-sm); 119 | min-width: 2rem; 120 | min-height: 2rem; 121 | } 122 | 123 | /* Button Sizes */ 124 | .btn-sm { 125 | padding: var(--spacing-xs) var(--spacing-sm); 126 | font-size: var(--font-size-xs); 127 | } 128 | 129 | .btn-lg { 130 | padding: var(--spacing-md) var(--spacing-lg); 131 | font-size: var(--font-size-lg); 132 | } 133 | 134 | /* History Controls */ 135 | .history-controls { 136 | display: flex; 137 | gap: var(--spacing-xs); 138 | margin-right: var(--spacing-sm); 139 | padding-right: var(--spacing-sm); 140 | border-right: 1px solid var(--border-color); 141 | } 142 | 143 | .history-controls .btn { 144 | min-width: 2.5rem; 145 | padding: var(--spacing-sm); 146 | } 147 | 148 | .history-controls .btn:disabled { 149 | opacity: 0.8; 150 | cursor: not-allowed; 151 | } 152 | 153 | .history-controls .btn:disabled:hover { 154 | background-color: transparent; 155 | transform: none; 156 | } 157 | -------------------------------------------------------------------------------- /web-ui/README.md: -------------------------------------------------------------------------------- 1 | # qBit Manage Web UI 2 | 3 | ## Overview 4 | The qBit Manage Web UI provides a modern interface for configuring and managing qBit Manage. It offers real-time editing of YAML configuration files through an intuitive visual interface, eliminating the need for manual file editing. 5 | 6 | ## Project Structure 7 | ``` 8 | web-ui/ 9 | ├── css/ # Stylesheets 10 | │ ├── components/ # Component-specific styles 11 | │ ├── main.css # Global styles 12 | │ ├── responsive.css # Responsive layouts 13 | │ └── themes.css # Theme definitions 14 | ├── img/ # Application images and icons 15 | ├── js/ # Application logic 16 | │ ├── api.js # Backend communication 17 | │ ├── app.js # Main application 18 | │ ├── components/ # UI components 19 | │ ├── config-schemas/ # Configuration schemas 20 | │ └── utils/ # Helper functions 21 | └── index.html # Application entry point 22 | ``` 23 | 24 | ## Key Features 25 | - **Visual Configuration Editor**: Intuitive forms for editing YAML configurations 26 | - **Real-time Validation**: Instant feedback on configuration errors 27 | - **Undo/Redo History**: Track and revert changes with history management 28 | - **Theme Support**: Light/dark mode with system preference detection 29 | - **Responsive Design**: Works on desktop and mobile devices 30 | - **YAML Preview**: Real-time preview of generated configuration 31 | 32 | ## Configuration Sections 33 | The UI organizes configuration into logical sections: 34 | 1. **Commands**: Manage script execution workflows 35 | 2. **qBittorrent Connection**: Configure qBittorrent API access 36 | 3. **Settings**: Application preferences and behavior 37 | 4. **Directory Paths**: Define important filesystem locations 38 | 5. **Categories**: Torrent category management 39 | 6. **Category Changes**: Bulk category modification rules 40 | 7. **Tracker Configuration**: Per-tracker settings 41 | 8. **No Hard Links**: Category-specific hardlink handling 42 | 9. **Share Limits**: Ratio and seeding time rules 43 | 10. **Recycle Bin**: Deleted torrent management 44 | 11. **Orphaned Files**: Cleanup of unregistered files 45 | 12. **Notifications**: Alert configuration 46 | 13. **Logs**: View application logs 47 | 48 | ## Technical Architecture 49 | 50 | ### Frontend Components 51 | - **ConfigForm.js**: Dynamic form generator based on JSON schemas 52 | - **CommandPanel.js**: Interface for executing management commands 53 | - **LogViewer.js**: Real-time log display component 54 | - **HistoryManager.js**: Undo/Redo functionality implementation 55 | 56 | ### API Integration 57 | The UI communicates with the backend through a RESTful API: 58 | 59 | ```mermaid 60 | sequenceDiagram 61 | participant UI as Web UI 62 | participant API as FastAPI Backend 63 | participant QBM as qBit Manage 64 | 65 | UI->>API: HTTP Request (GET/POST) 66 | API->>QBM: Process Configuration 67 | QBM-->>API: YAML Validation 68 | API-->>UI: Response with Status 69 | ``` 70 | 71 | Key API endpoints: 72 | - `GET /api/configs`: List available configurations 73 | - `PUT /api/configs/{filename}`: Update configuration file 74 | - `POST /api/run-command`: Execute management commands 75 | - `GET /api/logs`: Stream application logs 76 | 77 | Error handling includes: 78 | - JWT authentication 79 | - Automatic retry on network failures 80 | - Websocket support for real-time updates 81 | 82 | ## Usage Notes 83 | 1. Ensure the qBit Manage backend is running 84 | 2. Open `index.html` in a modern browser 85 | 3. Select a configuration file from the dropdown 86 | 4. Navigate sections using the sidebar 87 | 5. Use the preview button to see generated YAML 88 | 6. Save changes when complete 89 | 90 | Keyboard Shortcuts: 91 | - `Ctrl+S`: Save current configuration 92 | - `Ctrl+R`: Toggle Run Commands modal 93 | - `Ctrl+Z`: Undo last change 94 | - `Ctrl+Y`: Redo last change 95 | - `Ctrl+/`: Toggle Help modal 96 | - `Ctrl+P` or `Cmd+P`: Toggle YAML preview 97 | - `Escape`: Close modals/panels 98 | -------------------------------------------------------------------------------- /web-ui/js/utils/categories.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Categories Utility Module 3 | * Provides functions for working with category dropdowns in forms 4 | */ 5 | 6 | /** 7 | * Get available categories from the global app configuration 8 | * @returns {Array} Array of available category names 9 | */ 10 | export function getAvailableCategories() { 11 | try { 12 | // Access categories from the app's global config data 13 | // The app instance should be available through window.app 14 | if (window.app && window.app.configData && window.app.configData.cat) { 15 | const categories = Object.keys(window.app.configData.cat); 16 | return categories; 17 | } 18 | 19 | return []; 20 | } catch (error) { 21 | console.error('Error fetching categories:', error); 22 | return []; 23 | } 24 | } 25 | 26 | /** 27 | * Generate HTML for a category dropdown select element 28 | * @param {string} name - The name attribute for the select element 29 | * @param {string} value - The current selected value 30 | * @param {Array} categories - Array of available categories 31 | * @param {string} className - CSS class names for the select element 32 | * @param {string} fieldName - The field name for data attributes 33 | * @param {number} index - The index for array items 34 | * @returns {string} HTML string for the category dropdown 35 | */ 36 | export function generateCategoryDropdownHTML(name, value, categories, className = '', fieldName = '', index = null) { 37 | const dataAttributes = []; 38 | if (fieldName) { 39 | dataAttributes.push(`data-field="${fieldName}"`); 40 | } 41 | if (index !== null) { 42 | dataAttributes.push(`data-index="${index}"`); 43 | } 44 | 45 | let html = ``; 57 | 58 | return html; 59 | } 60 | 61 | /** 62 | * Populate existing category dropdowns with available categories 63 | * @param {HTMLElement} container - The container element to search for dropdowns 64 | */ 65 | export async function populateCategoryDropdowns(container) { 66 | // Find all category dropdowns in the container 67 | const fieldDropdowns = container.querySelectorAll('.category-dropdown'); 68 | 69 | if (fieldDropdowns.length === 0) return; 70 | 71 | try { 72 | // Get available categories 73 | const categories = getAvailableCategories(); 74 | 75 | // Handle individual field category dropdowns 76 | fieldDropdowns.forEach(dropdown => { 77 | const currentValue = dropdown.value; 78 | 79 | // Clear existing options 80 | dropdown.innerHTML = ''; 81 | 82 | // Add empty option 83 | const emptyOption = document.createElement('option'); 84 | emptyOption.value = ''; 85 | emptyOption.textContent = 'Select Category'; 86 | dropdown.appendChild(emptyOption); 87 | 88 | // Add all available categories 89 | categories.forEach(category => { 90 | const option = document.createElement('option'); 91 | option.value = category; 92 | option.textContent = category; 93 | if (category === currentValue) { 94 | option.selected = true; 95 | } 96 | dropdown.appendChild(option); 97 | }); 98 | }); 99 | } catch (error) { 100 | console.error('Error populating category dropdowns:', error); 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /web-ui/css/components/_command-panel.css: -------------------------------------------------------------------------------- 1 | /* Command Panel Component */ 2 | .command-panel-drawer { 3 | position: fixed; 4 | bottom: 0; /* Changed from var(--footer-height) to remove gap */ 5 | left: 0; 6 | right: 0; 7 | height: 20rem; 8 | background-color: var(--bg-primary); 9 | border-top: 1px solid var(--border-color); 10 | box-shadow: var(--shadow-lg); 11 | z-index: var(--z-fixed); 12 | display: flex; 13 | flex-direction: column; 14 | transform: translateY(100%); 15 | transition: transform var(--transition-normal); 16 | overflow-y: auto; /* Add scroll if content exceeds max-height */ 17 | } 18 | 19 | .command-panel-header { 20 | display: flex; 21 | align-items: center; 22 | justify-content: space-between; 23 | padding: var(--spacing-md) var(--spacing-lg); 24 | border-bottom: 1px solid var(--border-color); 25 | background-color: var(--bg-secondary); 26 | } 27 | 28 | .command-panel-header h3 { 29 | margin: 0; 30 | font-size: var(--font-size-lg); 31 | font-weight: 600; 32 | } 33 | 34 | .command-panel-actions { 35 | display: flex; 36 | align-items: center; 37 | gap: var(--spacing-sm); 38 | } 39 | .dry-run-toggle { 40 | display: flex; 41 | align-items: center; 42 | margin-right: var(--spacing-sm); 43 | gap: var(--spacing-md); 44 | flex-wrap: wrap; 45 | } 46 | 47 | .dry-run-toggle .form-group { 48 | margin-bottom: 0; 49 | } 50 | 51 | .dry-run-toggle .checkbox-label { 52 | margin: 0; 53 | padding: var(--spacing-xs) var(--spacing-sm); 54 | border-radius: var(--border-radius); 55 | transition: background-color var(--transition-fast); 56 | } 57 | 58 | .dry-run-toggle .checkbox-label:hover { 59 | background-color: var(--bg-accent); 60 | } 61 | 62 | .command-panel-drawer.active { 63 | transform: translateY(0); 64 | } 65 | 66 | .command-panel-drawer.hidden { 67 | transform: translateY(100%); /* Hide by moving it off-screen */ 68 | } 69 | 70 | .command-panel-content { 71 | flex: 1; 72 | display: flex; 73 | flex-direction: column; 74 | gap: var(--spacing-md); /* Reduced gap */ 75 | padding: var(--spacing-md); 76 | overflow-y: auto; 77 | } 78 | 79 | .quick-actions { 80 | padding: var(--spacing-md); 81 | border: 1px solid var(--border-color); 82 | border-radius: var(--border-radius); 83 | background-color: var(--bg-secondary); 84 | } 85 | 86 | .quick-actions-header { 87 | display: flex; 88 | align-items: center; 89 | gap: var(--spacing-md); 90 | margin-bottom: var(--spacing-md); 91 | flex-wrap: wrap; 92 | } 93 | 94 | .quick-actions h4 { 95 | margin: 0; 96 | font-size: var(--font-size-lg); 97 | font-weight: 600; 98 | white-space: nowrap; 99 | } 100 | 101 | .quick-action-buttons { 102 | display: flex; 103 | flex-wrap: wrap; 104 | gap: var(--spacing-sm); 105 | } 106 | 107 | .dry-run-toggle { 108 | display: flex; 109 | align-items: center; 110 | } 111 | 112 | .dry-run-toggle .checkbox-label { 113 | margin: 0; 114 | padding: var(--spacing-xs) var(--spacing-sm); 115 | border-radius: var(--border-radius); 116 | transition: background-color var(--transition-fast); 117 | } 118 | 119 | .dry-run-toggle .checkbox-label:hover { 120 | background-color: var(--bg-accent); 121 | } 122 | 123 | .quick-action-btn:hover:not(:disabled) { 124 | background-color: var(--btn-secondary-hover); 125 | border-color: var(--border-hover); 126 | transform: translateY(-1px); 127 | box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); 128 | } 129 | 130 | 131 | .command-panel-toggle { 132 | display: flex; 133 | align-items: center; 134 | gap: var(--spacing-sm); 135 | } 136 | 137 | .command-panel-toggle-btn { 138 | display: flex; 139 | align-items: center; 140 | gap: var(--spacing-sm); 141 | padding: var(--spacing-sm) var(--spacing-md); 142 | background-color: var(--bg-primary); 143 | border: 1px solid var(--border-color); 144 | border-radius: var(--border-radius); 145 | color: var(--text-primary); 146 | font-size: var(--font-size-sm); 147 | cursor: pointer; 148 | transition: all var(--transition-fast); 149 | } 150 | 151 | .command-panel-toggle-btn:hover { 152 | background-color: var(--bg-secondary); 153 | } 154 | 155 | .command-panel-toggle-btn .icon { 156 | width: 1rem; 157 | height: 1rem; 158 | transition: transform var(--transition-fast); 159 | } 160 | 161 | .command-panel-toggle-btn.active .icon { 162 | transform: rotate(180deg); 163 | } 164 | -------------------------------------------------------------------------------- /docs/Unraid-Installation.md: -------------------------------------------------------------------------------- 1 | # Unraid Installation 2 | 3 | ## Docker Installation (Recommended) 4 | 5 | The easiest way to run qbit_manage on Unraid is using the Docker container from Docker Hub. 6 | 7 | ### Prerequisites 8 | 9 | Install [Community Applications](https://forums.unraid.net/topic/38582-plug-in-community-applications/) plugin if you haven't already. 10 | 11 | ### Installation Steps 12 | 13 | 1. **Install the Container** 14 | - Go to the **Apps** tab in Unraid 15 | - Search for "qbit_manage" in the search box 16 | - Select the qbit_manage container and click **Install** 17 | 18 | 2. **Configure Path Mapping** 19 | 20 | > [!IMPORTANT] 21 | > qbit_manage must have the same path mappings as your qBittorrent container to properly access your torrents. 22 | 23 | **Example:** If qBittorrent is mapped as `/mnt/user/data/:/data`, then qbit_manage must also be mapped the same way. 24 | 25 | - Set the `Root_Dir` variable to match your qBittorrent download path 26 | - Ensure both containers can see torrents at the same paths 27 | 28 | 3. **Configure Environment Variables** 29 | - Set `QBT_WEB_SERVER=true` to enable the Web UI (recommended) 30 | - Configure other QBT environment options as needed 31 | 32 | 4. **Apply and Download** 33 | - Click **Apply** to download and create the container 34 | - The container may auto-start - stop it if needed 35 | 36 | 5. **Create Configuration File** 37 | - Navigate to `/mnt/user/appdata/qbit_manage/` on your Unraid server 38 | - Download the [sample config file](https://github.com/StuffAnThings/qbit_manage/blob/master/config/config.yml.sample) 39 | - Rename it to `config.yml` (remove the `.sample` extension) 40 | - Edit the file according to the [Config Setup guide](Config-Setup) 41 | 42 | > [!TIP] 43 | > Make sure the `root_dir` in your config matches how qBittorrent sees your torrents (e.g., `/data/torrents`) 44 | 45 | 6. **Start the Container** 46 | - Start the qbit_manage container from the Docker tab 47 | - Check logs at `/mnt/user/appdata/qbit_manage/logs/` 48 | 49 | ### Web UI Access 50 | 51 | If you enabled the web server, access the Web UI at: 52 | ``` 53 | http://[UNRAID-IP]:8080 54 | ``` 55 | 56 | ## Alternative: User Scripts Installation 57 | 58 | > [!WARNING] 59 | > This method is more complex and not recommended for most users. Use the Docker method above instead. 60 | 61 |
62 | Click to expand User Scripts installation method 63 | 64 | ### Requirements 65 | - [Nerd Pack](https://forums.unraid.net/topic/35866-unraid-6-nerdpack-cli-tools-iftop-iotop-screen-kbd-etc/) plugin 66 | - Python packages: `python-pip`, `python3`, `python-setuptools` 67 | 68 | ### Installation 69 | 1. Install required Python packages via Nerd Pack 70 | 2. Download qbit_manage source to your server (e.g., `/mnt/user/data/scripts/qbit/`) 71 | 3. Create a User Script to install requirements: 72 | ```bash 73 | #!/bin/bash 74 | echo "Installing required packages" 75 | python3 -m pip install /mnt/user/data/scripts/qbit/ 76 | echo "Required packages installed" 77 | ``` 78 | 4. Set the script to run "At First Array Start Only" 79 | 5. Create another User Script to run qbit_manage: 80 | ```bash 81 | #!/bin/bash 82 | echo "Running qBitTorrent Management" 83 | python3 /mnt/user/data/scripts/qbit/qbit_manage.py \ 84 | --config-dir /mnt/user/data/scripts/qbit/ \ 85 | --log-file /mnt/user/data/scripts/qbit/activity.log \ 86 | --run 87 | echo "qBitTorrent Management Completed" 88 | ``` 89 | 6. Set a cron schedule (e.g., `*/30 * * * *` for every 30 minutes) 90 | 91 | > [!TIP] 92 | > Use `--dry-run` flag first to test your configuration before running live. 93 | 94 |
95 | 96 | ## Troubleshooting 97 | 98 | ### Common Issues 99 | 100 | **Path Mapping Problems:** 101 | - Ensure qbit_manage and qBittorrent have identical path mappings 102 | - Check that the `root_dir` in config.yml matches the container's view of torrents 103 | 104 | **Permission Issues:** 105 | - Verify the qbit_manage container has read/write access to your download directories 106 | - Check Unraid user/group permissions 107 | 108 | **Container Won't Start:** 109 | - Review container logs in the Docker tab 110 | - Verify config.yml syntax is correct 111 | - Ensure all required path mappings exist 112 | -------------------------------------------------------------------------------- /docs/Docker-Installation.md: -------------------------------------------------------------------------------- 1 | # Docker Installation 2 | 3 | A simple Dockerfile is available in this repo if you'd like to build it yourself. 4 | The official build on github is available [here](https://ghcr.io/StuffAnThings/qbit_manage):
5 | `docker run -it -v :/config:rw ghcr.io/stuffanthings/qbit_manage:latest` 6 | 7 | * The -v :/config:rw mounts the location you choose as a persistent volume to store your files. 8 | * Change to a folder where your config.yml and other files are. 9 | * The docker image defaults to running the config named config.yml in your persistent volume. 10 | * Use quotes around the whole thing if your path has spaces i.e. -v ":/config:rw" 11 | 12 | * Fill out your location for your downloads downloads folder (`Root_Dir`). 13 | 1. qbit_manage needs to be able to view all torrents the way that your qbittorrent views them. 14 | 1. Example: If you have qbittorrent mapped to `/mnt/user/data/:/data` This means that you **MUST** have qbit_managed mapped the same way. 15 | 2. Furthermore, the config file must map the root directory you wish to monitor. This means that in our example of `/data` (which is how qbittorrent views the torrents) that if in your `/data` directory you drill down to `/torrents` that you'll need to update your config file to `/data/torrents` 16 | 2. This could be different depending on your specific setup. 17 | 3. The key takeaways are 18 | 1. Both qbit_manage needs to have the same mappings as qbittorrent 19 | 2. The config file needs to drill down (if required) further to the desired root dir. 20 | * `remote_dir`: is not required and can be commented out with `#` 21 | 22 | Please see [Commands](https://github.com/StuffAnThings/qbit_manage/wiki/Commands) for a list of arguments and docker environment variables. 23 | 24 | Here is an example of a docker compose 25 | 26 | ```yaml 27 | version: "3.7" 28 | services: 29 | qbit_manage: 30 | container_name: qbit_manage 31 | image: ghcr.io/stuffanthings/qbit_manage:latest 32 | volumes: 33 | - /mnt/user/appdata/qbit_manage/:/config:rw 34 | - /mnt/user/data/torrents/:/data/torrents:rw 35 | - /mnt/user/appdata/qbittorrent/:/qbittorrent/:ro 36 | ports: 37 | - "8080:8080" # Web API port (when enabled) 38 | environment: 39 | # Web API Configuration 40 | - QBT_WEB_SERVER=true # Set to true to enable web API and web UI 41 | - QBT_PORT=8080 # Web API port (default: 8080) 42 | 43 | # Scheduler Configuration 44 | - QBT_RUN=false 45 | - QBT_SCHEDULE=1440 46 | - QBT_CONFIG_DIR=/config 47 | - QBT_LOGFILE=qbit_manage.log 48 | 49 | # Command Flags 50 | - QBT_RECHECK=false 51 | - QBT_CAT_UPDATE=false 52 | - QBT_TAG_UPDATE=false 53 | - QBT_REM_UNREGISTERED=false 54 | - QBT_REM_ORPHANED=false 55 | - QBT_TAG_TRACKER_ERROR=false 56 | - QBT_TAG_NOHARDLINKS=false 57 | - QBT_SHARE_LIMITS=false 58 | - QBT_SKIP_CLEANUP=false 59 | - QBT_DRY_RUN=false 60 | - QBT_STARTUP_DELAY=0 61 | - QBT_SKIP_QB_VERSION_CHECK=false 62 | - QBT_DEBUG=false 63 | - QBT_TRACE=false 64 | 65 | # Logging Configuration 66 | - QBT_LOG_LEVEL=INFO 67 | - QBT_LOG_SIZE=10 68 | - QBT_LOG_COUNT=5 69 | - QBT_DIVIDER== 70 | - QBT_WIDTH=100 71 | restart: on-failure:2 72 | ``` 73 | 74 | ### Web API and Web UI Usage 75 | 76 | The Web API and Web UI are enabled by default in this Docker setup. 77 | 1. Ensure port 8080 (or your chosen `QBT_PORT`) is mapped using the `ports` section. 78 | 2. Access the Web UI at `http://your-host:8080` 79 | 3. Access the Web API at `http://your-host:8080/api/run-command` 80 | 81 | See the [Web API Documentation](Web-API) and [Web UI Documentation](Web-UI) for detailed usage instructions, security features, and examples. 82 | 83 | You will also need to define not just the config volume but the volume to your torrents, this is in order to use the recycling bin, remove orphans and the no hard link options 84 | 85 | Here we have `/mnt/user/data/torrents/` mapped to `/data/torrents/` furthermore in the config file associated with it the root_dir is mapped to `/data/torrents/` 86 | We also have `/mnt/user/appdata/qbittorrent/` mapped to `/qbittorrent` and in the config file we associated torrents_dir to `/qbittorrent/data/BT_backup` to use the save_torrents functionality 87 | -------------------------------------------------------------------------------- /desktop/tauri/src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | qBit Manage 7 | 85 | 86 | 87 |
88 | 91 |

qBit Manage

92 |
93 |
Starting server...
94 |
95 | 96 | 129 | 130 | 131 | -------------------------------------------------------------------------------- /web-ui/css/components/_key-value-list.css: -------------------------------------------------------------------------------- 1 | /* Category Key-Value List */ 2 | .key-value-list .key-value-items { 3 | display: flex; 4 | flex-direction: column; 5 | gap: var(--spacing-md); 6 | margin-top: var(--spacing-lg); /* Increased space below header */ 7 | } 8 | 9 | .category-row { 10 | display: flex; 11 | align-items: flex-start; /* Align items to the top */ 12 | gap: var(--spacing-md); 13 | padding: var(--spacing-md); 14 | border: 1px solid var(--border-color); 15 | border-radius: var(--border-radius); 16 | background-color: var(--bg-secondary); 17 | position: relative; /* For positioning the remove button */ 18 | } 19 | 20 | /* Complex object entry card styling for category configuration */ 21 | .complex-object-entry-card { 22 | position: relative; /* For positioning the remove button */ 23 | } 24 | 25 | .complex-object-entry-card .remove-complex-object-item { 26 | position: absolute; 27 | top: var(--spacing-sm); 28 | right: var(--spacing-sm); 29 | background-color: transparent; 30 | color: var(--text-secondary); 31 | border-radius: var(--border-radius); 32 | width: 28px; 33 | height: 28px; 34 | display: flex; 35 | align-items: center; 36 | justify-content: center; 37 | padding: 0; 38 | cursor: pointer; 39 | transition: all var(--transition-fast); 40 | z-index: 1; /* Ensure it's above inputs */ 41 | opacity: 0.7; 42 | font-size: var(--font-size-sm); 43 | font-weight: 500; 44 | box-shadow: 0 1px 2px 0 rgb(0 0 0 / 0.05); 45 | border: none; 46 | } 47 | 48 | .complex-object-entry-card .remove-complex-object-item:hover { 49 | background-color: var(--bg-secondary); 50 | color: var(--text-primary); 51 | border-color: var(--border-hover); 52 | opacity: 1; 53 | transform: translateY(-1px); 54 | box-shadow: 0 2px 4px 0 rgb(0 0 0 / 0.1); 55 | } 56 | 57 | .complex-object-entry-card .remove-complex-object-item:focus { 58 | outline: none; 59 | border-color: var(--border-focus); 60 | box-shadow: 0 0 0 3px var(--input-focus-ring); 61 | } 62 | 63 | .complex-object-entry-card .remove-complex-object-item:active { 64 | transform: translateY(0); 65 | box-shadow: 0 1px 2px 0 rgb(0 0 0 / 0.05); 66 | } 67 | 68 | .category-inputs { 69 | flex-grow: 1; 70 | display: flex; 71 | flex-direction: column; 72 | gap: var(--spacing-sm); 73 | } 74 | 75 | /* Category configuration specific layout - labels side by side, inputs underneath */ 76 | .category-inputs .category-labels-row { 77 | display: flex; 78 | gap: var(--spacing-md); 79 | margin-bottom: var(--spacing-xs); 80 | } 81 | 82 | .category-inputs .category-inputs-row { 83 | display: flex; 84 | gap: var(--spacing-md); 85 | flex-wrap: wrap; /* Allow wrapping on smaller screens */ 86 | } 87 | 88 | .category-name-group { 89 | flex: 1; /* 1/4 of the available space */ 90 | min-width: 150px; /* Minimum width before wrapping */ 91 | margin-bottom: 0; /* Remove default form-group margin */ 92 | } 93 | 94 | .category-path-group { 95 | flex: 3; /* 3/4 of the available space */ 96 | min-width: 250px; /* Adjust minimum width as needed */ 97 | margin-bottom: 0; /* Remove default form-group margin */ 98 | } 99 | 100 | /* Override form-group layout for category configuration */ 101 | .category-inputs .form-group { 102 | display: flex; 103 | flex-direction: column; 104 | } 105 | 106 | .category-inputs .form-group .form-label { 107 | margin-bottom: 0; /* Remove margin between label and input */ 108 | } 109 | 110 | .category-row .remove-category-btn { 111 | position: absolute; 112 | top: var(--spacing-sm); 113 | right: var(--spacing-sm); 114 | background-color: transparent; 115 | color: var(--text-secondary); 116 | border-radius: var(--border-radius); 117 | width: 28px; 118 | height: 28px; 119 | display: flex; 120 | align-items: center; 121 | justify-content: center; 122 | padding: 0; 123 | cursor: pointer; 124 | transition: all var(--transition-fast); 125 | z-index: 1; /* Ensure it's above inputs */ 126 | opacity: 0.7; 127 | font-size: var(--font-size-sm); 128 | font-weight: 500; 129 | box-shadow: 0 1px 2px 0 rgb(0 0 0 / 0.05); 130 | } 131 | 132 | .category-row .remove-category-btn:hover { 133 | background-color: var(--bg-secondary); 134 | color: var(--text-primary); 135 | border-color: var(--border-hover); 136 | opacity: 1; 137 | transform: translateY(-1px); 138 | box-shadow: 0 2px 4px 0 rgb(0 0 0 / 0.1); 139 | } 140 | 141 | .category-row .remove-category-btn:focus { 142 | outline: none; 143 | border-color: var(--border-focus); 144 | box-shadow: 0 0 0 3px var(--input-focus-ring); 145 | } 146 | 147 | .category-row .remove-category-btn:active { 148 | transform: translateY(0); 149 | box-shadow: 0 1px 2px 0 rgb(0 0 0 / 0.05); 150 | } 151 | 152 | /* Adjust form-group margin within category-row */ 153 | .category-row .form-group { 154 | margin-bottom: 0; 155 | } 156 | -------------------------------------------------------------------------------- /modules/core/tags.py: -------------------------------------------------------------------------------- 1 | import time 2 | 3 | from modules import util 4 | 5 | logger = util.logger 6 | 7 | 8 | class Tags: 9 | def __init__(self, qbit_manager, hashes: list[str] = None): 10 | self.qbt = qbit_manager 11 | self.hashes = hashes 12 | self.config = qbit_manager.config 13 | self.client = qbit_manager.client 14 | self.stats = 0 15 | # suffix tag for share limits 16 | self.share_limits_tag = qbit_manager.config.share_limits_tag 17 | self.torrents_updated = [] # List of torrents updated 18 | self.notify_attr = [] # List of single torrent attributes to send to notifiarr 19 | self.stalled_tag = qbit_manager.config.stalled_tag 20 | self.private_tag = qbit_manager.config.private_tag 21 | self.tag_stalled_torrents = self.config.settings["tag_stalled_torrents"] 22 | 23 | self.tags() 24 | self.config.webhooks_factory.notify(self.torrents_updated, self.notify_attr, group_by="tag") 25 | 26 | def tags(self): 27 | """Update tags for torrents""" 28 | start_time = time.time() 29 | logger.separator("Updating Tags", space=False, border=False) 30 | torrent_list = self.qbt.torrent_list 31 | if self.hashes: 32 | torrent_list = self.qbt.get_torrents({"torrent_hashes": self.hashes}) 33 | for torrent in torrent_list: 34 | tracker = self.qbt.get_tags(self.qbt.get_tracker_urls(torrent.trackers)) 35 | 36 | # Remove stalled_tag if torrent is no longer stalled 37 | if ( 38 | self.tag_stalled_torrents 39 | and util.is_tag_in_torrent(self.stalled_tag, torrent.tags) 40 | and torrent.state != "stalledDL" 41 | ): 42 | t_name = torrent.name 43 | body = [] 44 | body += logger.print_line(logger.insert_space(f"Torrent Name: {t_name}", 3), self.config.loglevel) 45 | body += logger.print_line(logger.insert_space(f"Removing Tag: {self.stalled_tag}", 3), self.config.loglevel) 46 | body += logger.print_line(logger.insert_space(f"Tracker: {tracker['url']}", 8), self.config.loglevel) 47 | if not self.config.dry_run: 48 | torrent.remove_tags(self.stalled_tag) 49 | if ( 50 | torrent.tags == "" 51 | or not util.is_tag_in_torrent(tracker["tag"], torrent.tags) 52 | or ( 53 | self.tag_stalled_torrents 54 | and torrent.state == "stalledDL" 55 | and not util.is_tag_in_torrent(self.stalled_tag, torrent.tags) 56 | ) 57 | or ( 58 | self.private_tag 59 | and not util.is_tag_in_torrent(self.private_tag, torrent.tags) 60 | and self.qbt.is_torrent_private(torrent) 61 | ) 62 | ): 63 | tags_to_add = tracker["tag"].copy() 64 | if self.tag_stalled_torrents and torrent.state == "stalledDL": 65 | tags_to_add.append(self.stalled_tag) 66 | if self.private_tag and self.qbt.is_torrent_private(torrent): 67 | tags_to_add.append(self.private_tag) 68 | if tags_to_add: 69 | t_name = torrent.name 70 | self.stats += len(tags_to_add) 71 | body = [] 72 | body += logger.print_line(logger.insert_space(f"Torrent Name: {t_name}", 3), self.config.loglevel) 73 | body += logger.print_line( 74 | logger.insert_space(f"New Tag{'s' if len(tags_to_add) > 1 else ''}: {', '.join(tags_to_add)}", 8), 75 | self.config.loglevel, 76 | ) 77 | body += logger.print_line(logger.insert_space(f"Tracker: {tracker['url']}", 8), self.config.loglevel) 78 | if not self.config.dry_run: 79 | torrent.add_tags(tags_to_add) 80 | category = self.qbt.get_category(torrent.save_path)[0] if torrent.category == "" else torrent.category 81 | attr = { 82 | "function": "tag_update", 83 | "title": "Updating Tags", 84 | "body": "\n".join(body), 85 | "torrents": [t_name], 86 | "torrent_category": category, 87 | "torrent_tag": ", ".join(tags_to_add), 88 | "torrent_tracker": tracker["url"], 89 | "notifiarr_indexer": tracker["notifiarr"], 90 | } 91 | self.notify_attr.append(attr) 92 | self.torrents_updated.append(t_name) 93 | if self.stats >= 1: 94 | logger.print_line( 95 | f"{'Did not update' if self.config.dry_run else 'Updated'} {self.stats} new tags.", self.config.loglevel 96 | ) 97 | else: 98 | logger.print_line("No new torrents to tag.", self.config.loglevel) 99 | 100 | end_time = time.time() 101 | duration = end_time - start_time 102 | logger.debug(f"Tags command completed in {duration:.2f} seconds") 103 | -------------------------------------------------------------------------------- /docs/Web-UI.md: -------------------------------------------------------------------------------- 1 | # qBit Manage Web UI 2 | 3 | > [!IMPORTANT] 4 | > Below is a summary of the WebUI. For details on the specific features and settings please see the rest of the wiki and sample config file to understand the functionality and description of each setting. The WebUI is effectively a GUI config file editor. You cannot edit environmental or container variables via the WebUI. 5 | 6 | ## Overview 7 | The qBit Manage Web UI provides a modern interface for configuring and managing qBit Manage. It offers real-time editing of YAML configuration files through an intuitive visual interface, eliminating the need for manual file editing. 8 | 9 | ## Key Features 10 | The qBit Manage Web UI offers a range of features designed to simplify your configuration and management tasks: 11 | - **Visual Configuration Editor**: Easily edit your YAML configuration files through intuitive forms, eliminating the need for manual text editing. 12 | - **Command Execution**: Run qBit Manage commands on demand directly from the Web UI, without waiting for scheduled runs. 13 | - **Undo/Redo History**: Track and revert changes with a comprehensive history, ensuring you can always go back to a previous state. 14 | - **Theme Support**: Switch between light and dark modes, with automatic detection of your system's preferred theme. 15 | - **Responsive Design**: Access and manage your qBit Manage instance seamlessly from both desktop and mobile devices. 16 | - **YAML Preview**: See a real-time preview of the YAML configuration as you make changes, ensuring accuracy before saving. 17 | 18 | ## Security Features 19 | The Web UI includes built-in security options to protect your qBit Manage instance: 20 | 21 | ### Authentication Methods 22 | - **None**: No authentication required (default for personal use) 23 | - **Basic Authentication**: Username and password login with browser popup 24 | - **API Only**: No web UI authentication, but API key required for API requests 25 | 26 | ### API Key Usage 27 | When authentication is enabled, you can generate an API key for programmatic access: 28 | 1. Access the Security section in the Web UI 29 | 2. Generate a new API key 30 | 3. Use the key in API requests with the `X-API-Key` header 31 | 32 | Example API call with key: 33 | ```bash 34 | curl -H "X-API-Key: your_api_key_here" http://localhost:8080/api/run-command 35 | ``` 36 | 37 | ### Security Best Practices 38 | - Use strong passwords with at least 8 characters 39 | - Enable authentication when running on public networks 40 | - Keep API keys secure and regenerate them periodically 41 | - Use HTTPS in production environments 42 | 43 | ### Troubleshooting Authentication Issues 44 | 45 | #### Can't Access Web UI After Enabling Authentication 46 | 47 | If you get locked out after enabling authentication: 48 | 49 | 1. Edit the settings file directly (`config/qbm_settings.yml`) 50 | 2. Set `authentication.enabled: false` or `authentication.method: none` 51 | 3. Restart qBit Manage 52 | 4. Reconfigure authentication through the web UI 53 | 54 | #### Basic Authentication Not Working 55 | 56 | - Ensure your browser supports HTTP Basic Authentication 57 | - Check that your credentials are correct 58 | - Try clearing browser cache/cookies 59 | - If rate limited, wait 1 minute before trying again (maximum 10 attempts per minute) 60 | 61 | ## Configuration Sections 62 | The Web UI organizes all configuration options into logical sections for easy navigation and management: 63 | 1. **Commands**: Define and manage the various script execution workflows. 64 | 2. **qBittorrent Connection**: Set up and configure access to your qBittorrent instance. 65 | 3. **Settings**: Adjust general application preferences and behavior. 66 | 4. **Directory Paths**: Specify important file system locations used by qBit Manage. 67 | 5. **Categories**: Manage your torrent categories and their associated save paths. 68 | 6. **Category Changes**: Configure rules for bulk modification of torrent categories. 69 | 7. **Tracker Configuration**: Define settings and tags based on torrent tracker URLs. 70 | 8. **No Hard Links**: Handle torrents that do not have hard links, often used for media management. 71 | 9. **Share Limits**: Apply rules for torrent ratio and seeding time based on custom criteria. 72 | 10. **Recycle Bin**: Configure how deleted torrents and their data are managed. 73 | 11. **Orphaned Files**: Set up cleanup rules for files not associated with any torrents. 74 | 12. **Notifications**: Configure various alert and notification settings. 75 | 13. **Logs**: View application logs for monitoring and troubleshooting. 76 | 77 | ## Usage 78 | To get started with the qBit Manage Web UI: 79 | 1. Ensure the qBit Manage backend is running and accessible. If running in Docker, ensure the web server is enabled and the port is mapped (e.g., `QBT_WEB_SERVER=true` and `ports: - "8080:8080"`). 80 | 2. Access the Web UI through your web browser at the configured address (e.g., `http://localhost:8080` or `http://your-docker-host-ip:8080`). 81 | 3. Select your desired configuration file from the dropdown menu. 82 | 4. Navigate through the different configuration sections using the sidebar. 83 | 5. Use the preview button to review the generated YAML before saving. 84 | 6. Save your changes when you are satisfied with the configuration. 85 | 7. To run commands immediately, open the "Run Commands" modal (using the button in the toolbar or the `Ctrl+R` keyboard shortcut), select the commands you wish to run, and click "Run". 86 | 87 | ### Keyboard Shortcuts 88 | For quicker navigation and actions, the Web UI supports the following keyboard shortcuts: 89 | - `Ctrl+S`: Save the current configuration. 90 | - `Ctrl+R`: Open the "Run Commands" modal to execute qBit Manage operations immediately. 91 | - `Ctrl+Z`: Undo the last change. 92 | - `Ctrl+Y`: Redo the last undone change. 93 | - `Ctrl+/`: Toggle the "Help" modal. 94 | - `Ctrl+P` or `Cmd+P`: Toggle the YAML preview. 95 | - `Escape`: Close any open modals or panels. 96 | -------------------------------------------------------------------------------- /entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -euo pipefail # Exit on error, undefined vars, pipe failures 3 | 4 | # Configuration 5 | readonly SOURCE_FILE="/app/config/config.yml.sample" 6 | readonly DEST_DIR="${QBT_CONFIG_DIR:-/config}" 7 | readonly DEST_FILE="${DEST_DIR}/config.yml.sample" 8 | 9 | # Logging function for consistent output 10 | log() { 11 | echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*" >&2 12 | } 13 | 14 | # Validate numeric environment variables 15 | validate_numeric_env() { 16 | local var_name="$1" 17 | local var_value="$2" 18 | 19 | if [[ -n "$var_value" ]] && ! [[ "$var_value" =~ ^[0-9]+$ ]]; then 20 | log "Warning: $var_name must be numeric. Got $var_name='$var_value' - ignoring PUID/PGID and running as root" 21 | return 1 22 | fi 23 | return 0 24 | } 25 | 26 | # Validate and set PUID/PGID 27 | validate_user_group_ids() { 28 | local puid_valid=0 29 | local pgid_valid=0 30 | 31 | if ! validate_numeric_env "PUID" "${PUID:-}"; then 32 | puid_valid=1 33 | fi 34 | 35 | if ! validate_numeric_env "PGID" "${PGID:-}"; then 36 | pgid_valid=1 37 | fi 38 | 39 | # If either is invalid, clear both 40 | if [[ $puid_valid -eq 1 ]] || [[ $pgid_valid -eq 1 ]]; then 41 | PUID="" 42 | PGID="" 43 | return 1 44 | fi 45 | 46 | return 0 47 | } 48 | 49 | # Safely copy file with atomic operation and error handling 50 | safe_copy() { 51 | local src="$1" 52 | local dest="$2" 53 | local temp_file="${dest}.tmp" 54 | 55 | # Validate source file exists and is readable 56 | if [[ ! -f "$src" ]] || [[ ! -r "$src" ]]; then 57 | log "Error: Source file '$src' does not exist or is not readable" 58 | return 1 59 | fi 60 | 61 | # Create parent directory if it doesn't exist 62 | local dest_dir 63 | dest_dir="$(dirname "$dest")" 64 | if [[ ! -d "$dest_dir" ]]; then 65 | mkdir -p "$dest_dir" || { 66 | log "Error: Could not create destination directory '$dest_dir'" 67 | return 1 68 | } 69 | fi 70 | 71 | # Atomic copy operation 72 | if cp "$src" "$temp_file" && mv "$temp_file" "$dest"; then 73 | log "Successfully copied $src to $dest" 74 | return 0 75 | else 76 | # Clean up temp file on failure 77 | [[ -f "$temp_file" ]] && rm -f "$temp_file" 78 | log "Error: Failed to copy $src to $dest" 79 | return 1 80 | fi 81 | } 82 | 83 | # Optimized permission fixing with better performance 84 | fix_permissions() { 85 | local path="$1" 86 | 87 | # Skip if PUID or PGID are not set 88 | if [[ -z "${PUID:-}" ]] || [[ -z "${PGID:-}" ]]; then 89 | log "Skipping permission fix for $path - PUID or PGID not set" 90 | return 0 91 | fi 92 | 93 | # Check if we're running as root 94 | if [[ "$(id -u)" != "0" ]]; then 95 | log "Skipping permission fix for $path - not running as root" 96 | return 0 97 | fi 98 | 99 | local needs_fix=0 100 | 101 | if [[ -d "$path" ]]; then 102 | # Check if any files in directory need ownership change 103 | if find "$path" -xdev \( -not -user "$PUID" -o -not -group "$PGID" \) -print -quit 2>/dev/null | grep -q .; then 104 | needs_fix=1 105 | fi 106 | elif [[ -e "$path" ]]; then 107 | # Check if file needs ownership change 108 | if [[ "$(stat -c '%u:%g' "$path" 2>/dev/null || echo "0:0")" != "$PUID:$PGID" ]]; then 109 | needs_fix=1 110 | fi 111 | else 112 | log "Warning: Path '$path' does not exist, skipping permission fix" 113 | return 0 114 | fi 115 | 116 | if [[ $needs_fix -eq 1 ]]; then 117 | if chown -R "$PUID:$PGID" "$path" 2>/dev/null; then 118 | local type_msg="file" 119 | [[ -d "$path" ]] && type_msg="directory" 120 | log "Corrected ownership of $type_msg $path to $PUID:$PGID" 121 | return 0 122 | else 123 | log "Warning: Could not change ownership of $path" 124 | return 1 125 | fi 126 | fi 127 | 128 | return 0 129 | } 130 | 131 | # Execute command with appropriate privilege level 132 | execute_command() { 133 | local current_uid 134 | current_uid="$(id -u)" 135 | 136 | if [[ "$current_uid" = "0" ]]; then 137 | if [[ -n "${PUID:-}" ]] && [[ -n "${PGID:-}" ]]; then 138 | log "Changing privileges to PUID:PGID = $PUID:$PGID" 139 | exec /sbin/su-exec "${PUID}:${PGID}" "$@" || { 140 | log "Warning: Could not drop privileges to ${PUID}:${PGID}, continuing as root" 141 | exec "$@" 142 | } 143 | else 144 | log "PUID/PGID not set, running as root" 145 | exec "$@" 146 | fi 147 | else 148 | log "Already running as non-root user (UID: $current_uid), executing command" 149 | exec "$@" 150 | fi 151 | } 152 | 153 | # Main execution 154 | main() { 155 | # Validate user/group IDs 156 | validate_user_group_ids 157 | 158 | # Handle config file setup 159 | if [[ -d "$DEST_DIR" ]]; then 160 | if [[ -f "$SOURCE_FILE" ]] && [[ -s "$SOURCE_FILE" ]]; then 161 | # Check if destination needs updating 162 | if [[ ! -f "$DEST_FILE" ]] || ! cmp -s "$SOURCE_FILE" "$DEST_FILE" 2>/dev/null; then 163 | if safe_copy "$SOURCE_FILE" "$DEST_FILE"; then 164 | # Fix permissions if running as root and IDs are set 165 | if [[ "$(id -u)" = "0" ]] && [[ -n "${PUID:-}" ]] && [[ -n "${PGID:-}" ]]; then 166 | fix_permissions "$DEST_FILE" 167 | fi 168 | fi 169 | fi 170 | elif [[ ! -f "$SOURCE_FILE" ]]; then 171 | log "Warning: Source file $SOURCE_FILE does not exist, skipping config setup" 172 | fi 173 | fi 174 | 175 | # Execute the main command 176 | execute_command "$@" 177 | } 178 | 179 | # Run main function with all arguments 180 | main "$@" 181 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # qBit Manage 2 | 3 | [![GitHub release (latest by date)](https://img.shields.io/github/v/release/StuffAnThings/qbit_manage?style=plastic)](https://github.com/StuffAnThings/qbit_manage/releases) 4 | [![GitHub commits since latest release (by SemVer)](https://img.shields.io/github/commits-since/StuffAnThings/qbit_manage/latest/develop?label=Commits%20in%20Develop&style=plastic)](https://github.com/StuffAnThings/qbit_manage/tree/develop) 5 | [![Docker Image Version (latest semver)](https://img.shields.io/docker/v/bobokun/qbit_manage?label=docker&sort=semver&style=plastic)](https://hub.docker.com/r/bobokun/qbit_manage) 6 | [![PyPi (latest semver)](https://img.shields.io/pypi/v/qbit-manage?label=PyPI&sort=semver&style=plastic)](https://pypi.org/project/qbit-manage) 7 | [![Github Workflow Status](https://img.shields.io/github/actions/workflow/status/StuffAnThings/qbit_manage/version.yml?style=plastic)](https://github.com/StuffAnThings/qbit_manage/actions/workflows/version.yml) 8 | [![pre-commit.ci status](https://results.pre-commit.ci/badge/github/StuffAnThings/qbit_manage/master.svg)](https://results.pre-commit.ci/latest/github/StuffAnThings/qbit_manage/master) 9 | [![Ghcr packages](https://img.shields.io/badge/ghcr.io-packages?style=plastic&label=packages)](https://ghcr.io/StuffAnThings/qbit_manage) 10 | [![Docker Pulls](https://img.shields.io/docker/pulls/bobokun/qbit_manage?style=plastic)](https://hub.docker.com/r/bobokun/qbit_manage) 11 | [![Sponsor or Donate](https://img.shields.io/badge/-Sponsor_or_Donate-blueviolet?style=plastic)](https://github.com/sponsors/bobokun) 12 | [![Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff) 13 | 14 | This is a program used to manage your qBittorrent instance such as: 15 | 16 | * Tag torrents based on tracker URLs 17 | * Apply category based on `save_path` to uncategorized torrents in category's `save_path` 18 | * Change categories based on current category (`cat_change`) 19 | * Remove unregistered torrents (delete data & torrent if it is not being cross-seeded, otherwise it will just remove the torrent) 20 | * Recheck paused torrents sorted by lowest size and resume if completed 21 | * Remove orphaned files from your root directory that are not referenced by qBittorrent 22 | * Tag any torrents that have no hard links outside the root folder (for multi-file torrents the largest file is used) 23 | * Apply share limits based on groups filtered by tags/categories and allows optional cleanup to delete these torrents and contents based on maximum ratio and/or time seeded. Additionally allows for a minimum seed time to ensure tracker rules are respected and minimum number of seeders to keep torrents alive. 24 | * RecycleBin function to move files into a RecycleBin folder instead of deleting the data directly when deleting a torrent 25 | * Built-in scheduler to run the script every x minutes. (Can use `--run` command to run without the scheduler) 26 | * Webhook notifications with [Notifiarr](https://notifiarr.com/) and [Apprise API](https://github.com/caronc/apprise-api) integration 27 | 28 | ## Supported Qbittorrent Versions 29 | 30 | We rely on [qbittorrent-api](https://pypi.org/project/qbittorrent-api/) to interact with Qbittorrent. 31 | 32 | Generally expect new releases of Qbittorrent to not immediately be supported. Support CANNOT be added until qbittorrent-api adds support for the version. Any material changed and impact must then be assessed prior to Qbit Manage supporting it. 33 | 34 | ### Master 35 | 36 | ![master - qBittorrent version](https://img.shields.io/badge/dynamic/json?label=master%20-%20qBittorrent&query=master.qbit&url=https%3A%2F%2Fraw.githubusercontent.com%2FStuffAnThings%2Fqbit_manage%2Fdevelop%2FSUPPORTED_VERSIONS.json&color=brightgreen) 37 | 38 | ![master - qbittorrent-api version](https://img.shields.io/badge/dynamic/json?label=master%20-%20qbittorrent-api&query=master.qbitapi&url=https%3A%2F%2Fraw.githubusercontent.com%2FStuffAnThings%2Fqbit_manage%2Fdevelop%2FSUPPORTED_VERSIONS.json&color=blue) 39 | 40 | ### Develop 41 | 42 | ![develop - qBittorrent version](https://img.shields.io/badge/dynamic/json?label=develop%20-%20qBittorrent&query=develop.qbit&url=https%3A%2F%2Fraw.githubusercontent.com%2FStuffAnThings%2Fqbit_manage%2Fdevelop%2FSUPPORTED_VERSIONS.json&color=brightgreen) 43 | 44 | ![develop - qbittorrent-api version](https://img.shields.io/badge/dynamic/json?label=develop%20-%20qbittorrent-api&query=develop.qbitapi&url=https%3A%2F%2Fraw.githubusercontent.com%2FStuffAnThings%2Fqbit_manage%2Fdevelop%2FSUPPORTED_VERSIONS.json&color=blue) 45 | 46 | ## Getting Started 47 | 48 | Check out the [wiki](https://github.com/StuffAnThings/qbit_manage/wiki) for installation help 49 | 50 | 1. Install qbit_manage either by installing Python 3.9.0+ on the localhost and following the [Local Installation](https://github.com/StuffAnThings/qbit_manage/wiki/Local-Installations) Guide or by installing Docker and following the [Docker Installation](https://github.com/StuffAnThings/qbit_manage/wiki/Docker-Installation) Guide or the [unRAID Installation](https://github.com/StuffAnThings/qbit_manage/wiki/Unraid-Installation) Guide. 51 | 1. Once installed, you have to [set up your Configuration](https://github.com/StuffAnThings/qbit_manage/wiki/Config-Setup) by create a [Configuration File](https://github.com/StuffAnThings/qbit_manage/blob/master/config/config.yml.sample) filled with all your values to connect to your qBittorrent instance. 52 | 1. Please refer to the list of [Commands](https://github.com/StuffAnThings/qbit_manage/wiki/Commands) that can be used with this tool. 53 | 54 | ## Usage 55 | 56 | To run the script in an interactive terminal with a list of possible commands run: 57 | 58 | ```bash 59 | python qbit_manage.py -h 60 | ``` 61 | 62 | ## Support 63 | 64 | * If you have any questions or require support please join the [Notifiarr Discord](https://discord.com/invite/AURf8Yz) and post your question under the `qbit-manage` channel. 65 | * If you're getting an Error or have an Enhancement post in the [Issues](https://github.com/StuffAnThings/qbit_manage/issues/new). 66 | * If you have a configuration question post in the [Discussions](https://github.com/StuffAnThings/qbit_manage/discussions/new). 67 | * Pull Request are welcome but please submit them to the [develop branch](https://github.com/StuffAnThings/qbit_manage/tree/develop). 68 | -------------------------------------------------------------------------------- /scripts/mover.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # This standalone script is used to pause torrents older than last x days, 3 | # run mover (in Unraid) and start torrents again once completed 4 | import argparse 5 | import logging 6 | import os 7 | import sys 8 | import time 9 | from datetime import datetime 10 | from datetime import timedelta 11 | 12 | # Configure logging 13 | logging.basicConfig( 14 | level=logging.DEBUG, 15 | format="%(asctime)s - %(levelname)s - %(message)s", 16 | datefmt="%Y-%m-%d %H:%M:%S", 17 | handlers=[logging.StreamHandler(sys.stdout)], 18 | ) 19 | 20 | parser = argparse.ArgumentParser(prog="Qbit Mover", description="Stop torrents and kick off Unraid mover process") 21 | parser.add_argument("--host", help="qbittorrent host including port", required=True) 22 | parser.add_argument("-u", "--user", help="qbittorrent user", default="admin") 23 | parser.add_argument("-p", "--password", help="qbittorrent password", default="adminadmin") 24 | parser.add_argument( 25 | "--cache-mount", 26 | "--cache_mount", 27 | help="Cache mount point in Unraid. This is used to additionally filter for only torrents that exists on the cache mount." 28 | "Use this option ONLY if you follow TRaSH Guides folder structure. (For default cache drive set this to /mnt/cache)", 29 | default=None, 30 | ) 31 | parser.add_argument( 32 | "--days-from", "--days_from", help="Set Number of Days to stop torrents between two offsets", type=int, default=0 33 | ) 34 | parser.add_argument("--days-to", "--days_to", help="Set Number of Days to stop torrents between two offsets", type=int, default=2) 35 | parser.add_argument( 36 | "--mover-old", 37 | help="Use mover.old instead of mover. Useful if you're using the Mover Tuning Plugin", 38 | action="store_true", 39 | default=False, 40 | ) 41 | parser.add_argument( 42 | "--status-filter", 43 | help="Define a status to limit which torrents to pause. Useful if you want to leave certain torrents unpaused.", 44 | choices=[ 45 | "all", 46 | "downloading", 47 | "seeding", 48 | "completed", 49 | "paused", 50 | "stopped", 51 | "active", 52 | "inactive", 53 | "resumed", 54 | "running", 55 | "stalled", 56 | "stalled_uploading", 57 | "stalled_downloading", 58 | "checking", 59 | "moving", 60 | "errored", 61 | ], 62 | default="completed", 63 | ) 64 | parser.add_argument( 65 | "--pause", 66 | help="Pause torrents matching the specified criteria", 67 | action="store_true", 68 | default=False, 69 | ) 70 | parser.add_argument( 71 | "--resume", 72 | help="Resume torrents matching the specified criteria", 73 | action="store_true", 74 | default=False, 75 | ) 76 | parser.add_argument( 77 | "--move", 78 | help="Start the Unraid mover process", 79 | action="store_true", 80 | default=False, 81 | ) 82 | # --DEFINE VARIABLES--# 83 | 84 | # --START SCRIPT--# 85 | try: 86 | from qbittorrentapi import APIConnectionError 87 | from qbittorrentapi import Client 88 | from qbittorrentapi import LoginFailed 89 | except ModuleNotFoundError: 90 | logging.error( 91 | 'Requirements Error: qbittorrent-api not installed. Please install using the command "pip install qbittorrent-api"' 92 | ) 93 | sys.exit(1) 94 | 95 | 96 | def filter_torrents(torrent_list, timeoffset_from, timeoffset_to, cache_mount): 97 | result = [] 98 | for torrent in torrent_list: 99 | if torrent.added_on >= timeoffset_to and torrent.added_on <= timeoffset_from: 100 | if not cache_mount or exists_in_cache(cache_mount, torrent.content_path): 101 | result.append(torrent) 102 | elif torrent.added_on < timeoffset_to: 103 | break 104 | return result 105 | 106 | 107 | def exists_in_cache(cache_mount, content_path): 108 | cache_path = os.path.join(cache_mount, content_path.lstrip("/")) 109 | return os.path.exists(cache_path) 110 | 111 | 112 | def stop_start_torrents(torrent_list, pause=True): 113 | for torrent in torrent_list: 114 | if pause: 115 | logging.info(f"Pausing: {torrent.name} [{torrent.added_on}]") 116 | torrent.pause() 117 | else: 118 | logging.info(f"Resuming: {torrent.name} [{torrent.added_on}]") 119 | torrent.resume() 120 | 121 | 122 | if __name__ == "__main__": 123 | current = datetime.now() 124 | args = parser.parse_args() 125 | 126 | # If no specific operation is requested, default to all operations (original behavior) 127 | if not any([args.pause, args.resume, args.move]): 128 | args.pause = True 129 | args.resume = True 130 | args.move = True 131 | 132 | if args.days_from > args.days_to: 133 | raise ("Config Error: days_from must be set lower than days_to") 134 | 135 | # Initialize client and torrents only if pause or resume operations are requested 136 | client = None 137 | torrents = [] 138 | 139 | if args.pause or args.resume: 140 | try: 141 | client = Client(host=args.host, username=args.user, password=args.password) 142 | except LoginFailed: 143 | raise ("Qbittorrent Error: Failed to login. Invalid username/password.") 144 | except APIConnectionError: 145 | raise ("Qbittorrent Error: Unable to connect to the client.") 146 | except Exception: 147 | raise ("Qbittorrent Error: Unable to connect to the client.") 148 | 149 | timeoffset_from = current - timedelta(days=args.days_from) 150 | timeoffset_to = current - timedelta(days=args.days_to) 151 | torrent_list = client.torrents.info(status_filter=args.status_filter, sort="added_on", reverse=True) 152 | torrents = filter_torrents(torrent_list, timeoffset_from.timestamp(), timeoffset_to.timestamp(), args.cache_mount) 153 | 154 | # Pause Torrents 155 | if args.pause: 156 | logging.info(f"Pausing [{len(torrents)}] torrents from {args.days_from} - {args.days_to} days ago") 157 | stop_start_torrents(torrents, True) 158 | if args.move: # Only sleep if mover will run next 159 | time.sleep(10) 160 | 161 | # Run mover 162 | if args.move: 163 | if args.mover_old: 164 | # Start mover 165 | logging.info("Starting mover.old to move files in to array disks.") 166 | os.system("/usr/local/sbin/mover.old start") 167 | else: 168 | # Start mover 169 | logging.info("Starting mover to move files in to array disks based on mover tuning preferences.") 170 | os.system("/usr/local/sbin/mover start") 171 | 172 | # Resume Torrents 173 | if args.resume: 174 | logging.info(f"Resuming [{len(torrents)}] torrents from {args.days_from} - {args.days_to} days ago") 175 | stop_start_torrents(torrents, False) 176 | -------------------------------------------------------------------------------- /web-ui/js/config-schemas/settings.js: -------------------------------------------------------------------------------- 1 | export const settingsSchema = { 2 | title: 'General Settings', 3 | description: 'Configure general application settings and default behaviors.', 4 | fields: [ 5 | { 6 | type: 'documentation', 7 | title: 'Settings Configuration Guide', 8 | filePath: 'Config-Setup.md', 9 | section: 'settings', 10 | defaultExpanded: false 11 | }, 12 | { 13 | name: 'force_auto_tmm', 14 | type: 'boolean', 15 | label: 'Force Auto TMM', 16 | description: 'Force qBittorrent to enable Automatic Torrent Management (ATM) for each torrent.', 17 | default: false 18 | }, 19 | { 20 | name: 'force_auto_tmm_ignore_tags', 21 | type: 'array', 22 | label: 'Force Auto TMM Ignore Tags', 23 | description: 'Torrents with these tags will be ignored when force_auto_tmm is enabled.', 24 | items: { type: 'text' } 25 | }, 26 | { 27 | name: 'tracker_error_tag', 28 | type: 'text', 29 | label: 'Tracker Error Tag', 30 | description: 'The tag to apply to torrents that have a tracker error. Used by the `tag_tracker_error` command.', 31 | default: 'issue' 32 | }, 33 | { 34 | name: 'nohardlinks_tag', 35 | type: 'text', 36 | label: 'No Hard Links Tag', 37 | description: 'The tag to apply to torrents that do not have any hardlinks. Used by the `tag_nohardlinks` command.', 38 | default: 'noHL' 39 | }, 40 | { 41 | name: 'stalled_tag', 42 | type: 'text', 43 | label: 'Stalled Tag', 44 | description: 'The tag to apply to torrents that are stalled during download.', 45 | default: 'stalledDL' 46 | }, 47 | { 48 | name: 'private_tag', 49 | type: 'text', 50 | label: 'Private Tag', 51 | description: 'The tag to apply to private torrents.', 52 | default: null 53 | }, 54 | { 55 | name: 'share_limits_tag', 56 | type: 'text', 57 | label: 'Share Limits Tag', 58 | description: 'The prefix for tags created by share limit groups. For example, a group named "group1" with priority 1 would get the tag "~share_limit_1.group1".', 59 | default: '~share_limit' 60 | }, 61 | { 62 | name: 'share_limits_min_seeding_time_tag', 63 | type: 'text', 64 | label: 'Min Seeding Time Tag', 65 | description: 'The tag to apply to torrents that have not met their minimum seeding time requirement in a share limit group.', 66 | default: 'MinSeedTimeNotReached' 67 | }, 68 | { 69 | name: 'share_limits_min_num_seeds_tag', 70 | type: 'text', 71 | label: 'Min Num Seeds Tag', 72 | description: 'The tag to apply to torrents that have not met their minimum number of seeds requirement in a share limit group.', 73 | default: 'MinSeedsNotMet' 74 | }, 75 | { 76 | name: 'share_limits_last_active_tag', 77 | type: 'text', 78 | label: 'Last Active Tag', 79 | description: 'The tag to apply to torrents that have not met their last active time requirement in a share limit group.', 80 | default: 'LastActiveLimitNotReached' 81 | }, 82 | { 83 | name: 'cat_filter_completed', 84 | type: 'boolean', 85 | label: 'Category Filter Completed', 86 | description: 'If true, the `cat_update` command will only process completed torrents.', 87 | default: true 88 | }, 89 | { 90 | name: 'share_limits_filter_completed', 91 | type: 'boolean', 92 | label: 'Share Limits Filter Completed', 93 | description: 'If true, the `share_limits` command will only process completed torrents.', 94 | default: true 95 | }, 96 | { 97 | name: 'tag_nohardlinks_filter_completed', 98 | type: 'boolean', 99 | label: 'Tag No Hardlinks Filter Completed', 100 | description: 'If true, the `tag_nohardlinks` command will only process completed torrents.', 101 | default: true 102 | }, 103 | { 104 | name: 'rem_unregistered_filter_completed', 105 | type: 'boolean', 106 | label: 'Remove Unregistered Filter Completed', 107 | description: 'If true, the `rem_unregistered` command will only process completed torrents.', 108 | default: false 109 | }, 110 | { 111 | name: 'cat_update_all', 112 | type: 'boolean', 113 | label: 'Category Update All', 114 | description: 'If true, `cat_update` will update all torrents; otherwise, it will only update uncategorized torrents.', 115 | default: true 116 | }, 117 | { 118 | name: 'disable_qbt_default_share_limits', 119 | type: 'boolean', 120 | label: 'Disable qBittorrent Default Share Limits', 121 | description: 'If true, qBittorrent\'s default share limits will be disabled, allowing qbit_manage to handle them exclusively.', 122 | default: true 123 | }, 124 | { 125 | name: 'tag_stalled_torrents', 126 | type: 'boolean', 127 | label: 'Tag Stalled Torrents', 128 | description: 'If true, the `tag_update` command will tag stalled downloading torrents with the `stalled_tag`.', 129 | default: true 130 | }, 131 | { 132 | name: 'rem_unregistered_ignore_list', 133 | type: 'array', 134 | label: 'Remove Unregistered Ignore List', 135 | description: 'A list of keywords. If any of these are found in a tracker\'s status message, the torrent will not be removed by the `rem_unregistered` command.', 136 | items: { type: 'text' } 137 | }, 138 | { 139 | name: 'rem_unregistered_grace_minutes', 140 | type: 'number', 141 | label: 'Remove Unregistered Grace Period (minutes)', 142 | description: 'Minimum age in minutes to protect newly added torrents from removal when a tracker reports unregistered. Set to 0 to disable.', 143 | default: 10, 144 | min: 0 145 | }, 146 | { 147 | name: 'rem_unregistered_max_torrents', 148 | type: 'number', 149 | label: 'Remove Unregistered Max Torrents', 150 | description: 'Maximum number of torrents to remove per tracker per run. Set to 0 to disable.', 151 | default: 10, 152 | min: 0 153 | } 154 | ] 155 | }; 156 | -------------------------------------------------------------------------------- /modules/core/category.py: -------------------------------------------------------------------------------- 1 | import time 2 | 3 | from modules import util 4 | from modules.qbit_error_handler import handle_qbit_api_errors 5 | 6 | logger = util.logger 7 | 8 | 9 | class Category: 10 | def __init__(self, qbit_manager, hashes: list[str] = None): 11 | self.qbt = qbit_manager 12 | self.config = qbit_manager.config 13 | self.hashes = hashes 14 | self.client = qbit_manager.client 15 | self.stats = 0 16 | self.torrents_updated = [] # List of torrents updated 17 | self.notify_attr = [] # List of single torrent attributes to send to notifiarr 18 | self.uncategorized_mapping = "Uncategorized" 19 | self.status_filter = "completed" if self.config.settings["cat_filter_completed"] else "all" 20 | self.cat_update_all = self.config.settings["cat_update_all"] 21 | self.category() 22 | self.change_categories() 23 | self.config.webhooks_factory.notify(self.torrents_updated, self.notify_attr, group_by="category") 24 | 25 | def category(self): 26 | """Update category for torrents that don't have any category defined and returns total number categories updated""" 27 | start_time = time.time() 28 | logger.separator("Updating Categories", space=False, border=False) 29 | torrent_list_filter = {"status_filter": self.status_filter} 30 | if self.hashes: 31 | torrent_list_filter["torrent_hashes"] = self.hashes 32 | if not self.cat_update_all and not self.hashes: 33 | torrent_list_filter["category"] = "" 34 | torrent_list = self.qbt.get_torrents(torrent_list_filter) 35 | for torrent in torrent_list: 36 | torrent_category = torrent.category 37 | new_cat = [] 38 | new_cat.extend(self.get_tracker_cat(torrent) or self.qbt.get_category(torrent.save_path)) 39 | if not torrent.auto_tmm and torrent_category: 40 | logger.print_line( 41 | f"{torrent.name} has Automatic Torrent Management disabled and already has the category" 42 | f" {torrent_category}. Skipping..", 43 | "DEBUG", 44 | ) 45 | continue 46 | if new_cat[0] == self.uncategorized_mapping: 47 | logger.print_line(f"{torrent.name} remains uncategorized.", "DEBUG") 48 | continue 49 | if torrent_category not in new_cat: 50 | self.update_cat(torrent, new_cat[0], False) 51 | 52 | if self.stats >= 1: 53 | logger.print_line( 54 | f"{'Did not update' if self.config.dry_run else 'Updated'} {self.stats} new categories.", self.config.loglevel 55 | ) 56 | else: 57 | logger.print_line("No new torrents to categorize.", self.config.loglevel) 58 | 59 | end_time = time.time() 60 | duration = end_time - start_time 61 | logger.debug(f"Category command completed in {duration:.2f} seconds") 62 | 63 | def change_categories(self): 64 | """Handle category changes separately after main categorization""" 65 | if not self.config.cat_change: 66 | return 67 | 68 | logger.separator("Changing Categories", space=False, border=False) 69 | start_time = time.time() 70 | 71 | for torrent_category, updated_cat in self.config.cat_change.items(): 72 | # Get torrents with the specific category to be changed 73 | torrent_list_filter = {"status_filter": self.status_filter, "category": torrent_category} 74 | if self.hashes: 75 | torrent_list_filter["torrent_hashes"] = self.hashes 76 | 77 | torrent_list = self.qbt.get_torrents(torrent_list_filter) 78 | 79 | for torrent in torrent_list: 80 | self.update_cat(torrent, updated_cat, True) 81 | 82 | end_time = time.time() 83 | duration = end_time - start_time 84 | logger.debug(f"Category change command completed in {duration:.2f} seconds") 85 | 86 | def get_tracker_cat(self, torrent): 87 | tracker = self.qbt.get_tags(self.qbt.get_tracker_urls(torrent.trackers)) 88 | return [tracker["cat"]] if tracker["cat"] else None 89 | 90 | def update_cat(self, torrent, new_cat, cat_change): 91 | """Update category based on the torrent information""" 92 | tracker = self.qbt.get_tags(self.qbt.get_tracker_urls(torrent.trackers)) 93 | t_name = torrent.name 94 | old_cat = torrent.category 95 | if not self.config.dry_run: 96 | 97 | @handle_qbit_api_errors(context="set_category", retry_attempts=2) 98 | def set_category_with_creation(): 99 | try: 100 | torrent.set_category(category=new_cat) 101 | if ( 102 | torrent.auto_tmm is False 103 | and self.config.settings["force_auto_tmm"] 104 | and not any(tag in torrent.tags for tag in self.config.settings.get("force_auto_tmm_ignore_tags", [])) 105 | ): 106 | torrent.set_auto_management(True) 107 | except Exception as e: 108 | # Check if it's a category creation issue 109 | if "not found" in str(e).lower() or "409" in str(e): 110 | ex = logger.print_line( 111 | f'Existing category "{new_cat}" not found for save path ' 112 | f"{torrent.save_path}, category will be created.", 113 | self.config.loglevel, 114 | ) 115 | self.config.notify(ex, "Update Category", False) 116 | self.client.torrent_categories.create_category(name=new_cat, save_path=torrent.save_path) 117 | torrent.set_category(category=new_cat) 118 | else: 119 | raise 120 | 121 | set_category_with_creation() 122 | body = [] 123 | body += logger.print_line(logger.insert_space(f"Torrent Name: {t_name}", 3), self.config.loglevel) 124 | if cat_change: 125 | body += logger.print_line(logger.insert_space(f"Old Category: {old_cat}", 3), self.config.loglevel) 126 | title = "Moving Categories" 127 | else: 128 | title = "Updating Categories" 129 | body += logger.print_line(logger.insert_space(f"New Category: {new_cat}", 3), self.config.loglevel) 130 | body += logger.print_line(logger.insert_space(f"Tracker: {tracker['url']}", 8), self.config.loglevel) 131 | attr = { 132 | "function": "cat_update", 133 | "title": title, 134 | "body": "\n".join(body), 135 | "torrents": [t_name], 136 | "torrent_category": new_cat, 137 | "torrent_tag": ", ".join(tracker["tag"]), 138 | "torrent_tracker": tracker["url"], 139 | "notifiarr_indexer": tracker["notifiarr"], 140 | } 141 | self.notify_attr.append(attr) 142 | self.torrents_updated.append(t_name) 143 | self.stats += 1 144 | -------------------------------------------------------------------------------- /docs/Installation.md: -------------------------------------------------------------------------------- 1 | # Installation Options 2 | 3 | qbit_manage offers multiple installation methods to suit different use cases: 4 | 5 | ## Installation Methods 6 | 7 | ### 1. Desktop App (Recommended for most users) 8 | - **Windows**: Download and run the `.exe` installer 9 | - **macOS**: Download and install the `.dmg` package 10 | - **Linux**: Download and install the `.deb` package 11 | 12 | The desktop app provides a graphical interface and automatically handles configuration file setup. 13 | 14 | ### 2. Standalone Binary (Command-line) 15 | - **Windows**: `qbit-manage-windows-amd64.exe` 16 | - **macOS**: `qbit-manage-macos-arm64` (Apple Silicon) or `qbit-manage-macos-x86_64` (Intel) 17 | - **Linux**: `qbit-manage-linux-amd64` 18 | 19 | Perfect for server environments, automation, or users who prefer command-line tools. 20 | 21 | ### 3. Docker Container 22 | - Multi-architecture support (amd64, arm64, arm/v7) 23 | - Ideal for containerized environments and NAS systems 24 | 25 | ### 4. Python Installation 26 | - Install from source or PyPI 27 | - For developers or users who want to modify the code 28 | 29 | ## Detailed Installation Guides 30 | 31 | - [Desktop App Installation](#desktop-app-installation) 32 | - [Standalone Binary Installation](#standalone-binary-installation) 33 | - [Python/Source Installation](#pythonsource-installation) 34 | - [Docker Installation](Docker-Installation) 35 | - [unRAID Installation](Unraid-Installation) 36 | 37 | ## Desktop App Installation 38 | 39 | ### Windows 40 | 1. Download `qbit-manage-*-desktop-installer-setup.exe` from the [releases page](https://github.com/StuffAnThings/qbit_manage/releases) 41 | 2. Run the installer and follow the setup wizard 42 | 3. Launch qbit_manage from the Start Menu or desktop shortcut 43 | 4. The app will automatically create the configuration directory and files 44 | 45 | ### macOS 46 | 1. Download `qbit-manage-*-desktop-installer.dmg` from the [releases page](https://github.com/StuffAnThings/qbit_manage/releases) 47 | 2. Open the DMG file and drag qbit_manage to your Applications folder 48 | 3. Launch qbit_manage from Applications (you may need to allow it in System Preferences > Security & Privacy) 49 | 4. The app will automatically create the configuration directory and files 50 | 51 | ### Linux 52 | 1. Download `qbit-manage-*-desktop-installer.deb` from the [releases page](https://github.com/StuffAnThings/qbit_manage/releases) 53 | 2. Install using your package manager: 54 | ```bash 55 | sudo dpkg -i qbit-manage-*-desktop-installer.deb 56 | sudo apt-get install -f # Fix any dependency issues 57 | ``` 58 | 3. Launch qbit_manage from your applications menu or run `qbit-manage` in terminal 59 | 4. The app will automatically create the configuration directory and files 60 | 61 | ## Standalone Binary Installation 62 | 63 | ### Windows 64 | 1. Download `qbit-manage-windows-amd64.exe` from the [releases page](https://github.com/StuffAnThings/qbit_manage/releases) 65 | 2. Place the executable in a directory of your choice (e.g., `C:\Program Files\qbit-manage\`) 66 | 3. Add the directory to your PATH environment variable (optional) 67 | 4. Run from Command Prompt or PowerShell: 68 | ```cmd 69 | qbit-manage-windows-amd64.exe --help 70 | ``` 71 | 72 | ### macOS 73 | 1. Download the appropriate binary from the [releases page](https://github.com/StuffAnThings/qbit_manage/releases): 74 | - `qbit-manage-macos-arm64` for Apple Silicon Macs (M1, M2, M3, etc.) 75 | - `qbit-manage-macos-x86_64` for Intel Macs 76 | 2. Make the binary executable: 77 | ```bash 78 | chmod +x qbit-manage-macos-* 79 | ``` 80 | 3. Move to a directory in your PATH (optional): 81 | ```bash 82 | sudo mv qbit-manage-macos-* /usr/local/bin/qbit-manage 83 | ``` 84 | 4. Run the binary: 85 | ```bash 86 | ./qbit-manage-macos-* --help 87 | ``` 88 | 89 | ### Linux 90 | 1. Download `qbit-manage-linux-amd64` from the [releases page](https://github.com/StuffAnThings/qbit_manage/releases) 91 | 2. Make the binary executable: 92 | ```bash 93 | chmod +x qbit-manage-linux-amd64 94 | ``` 95 | 3. Move to a directory in your PATH (optional): 96 | ```bash 97 | sudo mv qbit-manage-linux-amd64 /usr/local/bin/qbit-manage 98 | ``` 99 | 4. Run the binary: 100 | ```bash 101 | ./qbit-manage-linux-amd64 --help 102 | ``` 103 | 104 | ## Python/Source Installation 105 | 106 | For developers or users who want to modify the code, you can install from source or PyPI. 107 | 108 | ### Prerequisites 109 | - Python 3.9 or higher 110 | - Git (for source installation) 111 | 112 | ### Method 1: Install from PyPI 113 | 114 | ```bash 115 | # Install uv first 116 | curl -LsSf https://astral.sh/uv/install.sh | sh 117 | 118 | # Install qbit-manage 119 | uv tool install qbit-manage 120 | ``` 121 | 122 | ### Method 2: Install from Source 123 | 124 | ```bash 125 | # Clone the repository 126 | git clone https://github.com/StuffAnThings/qbit_manage.git 127 | cd qbit_manage 128 | 129 | # Install uv if not already installed 130 | curl -LsSf https://astral.sh/uv/install.sh | sh 131 | 132 | # Install the package 133 | uv tool install . 134 | ``` 135 | 136 | ### Running qbit-manage 137 | 138 | After installation, you can run qbit-manage from anywhere: 139 | 140 | ```bash 141 | # Show help and available options 142 | qbit-manage --help 143 | 144 | # Run once (without scheduler) 145 | qbit-manage --run 146 | 147 | # Run with web UI (default on desktop) 148 | qbit-manage --web-server 149 | 150 | # Run without web UI (force disable) 151 | qbit-manage --web-server=False 152 | ``` 153 | 154 | ### Usage 155 | 156 | After installation, you can run qbit_manage using: 157 | 158 | ```bash 159 | qbit-manage --help 160 | ``` 161 | 162 | > [!TIP] 163 | > For Python installations, it's recommended to use a virtual environment to avoid conflicts with other packages. 164 | 165 | ### Development Installation 166 | 167 | For development work or to contribute to the project: 168 | 169 | ```bash 170 | # Clone the repository 171 | git clone https://github.com/StuffAnThings/qbit_manage.git 172 | cd qbit_manage 173 | 174 | # Install uv if not already installed 175 | curl -LsSf https://astral.sh/uv/install.sh | sh 176 | 177 | # Create virtual environment and install dependencies 178 | uv venv 179 | source .venv/bin/activate # Linux/macOS 180 | # .venv\Scripts\activate # Windows 181 | 182 | # Install in development mode 183 | uv pip install -e . 184 | ``` 185 | 186 | ### Updating 187 | 188 | **Tool installation:** 189 | ```bash 190 | uv tool upgrade qbit-manage 191 | ``` 192 | 193 | **Development installation:** 194 | ```bash 195 | cd qbit_manage 196 | git pull 197 | uv pip install -e . --upgrade 198 | ``` 199 | 200 | ## Quick Reference: Default Configuration File Locations 201 | 202 | ### Desktop App & Standalone Binary 203 | - **Windows**: `%APPDATA%\qbit-manage\config.yml` 204 | - **macOS**: `~/Library/Application Support/qbit-manage/config.yml` 205 | - **Linux**: `~/.config/qbit-manage/config.yml` 206 | 207 | ### Docker Installation 208 | - **Container Path**: `/app/config.yml` 209 | - **Host Mount**: Typically mounted from `/path/to/your/config:/config` 210 | 211 | ### Custom Location 212 | You can override the default location using the `--config-dir` or `-cd` command line option: 213 | ```bash 214 | qbit-manage --config-dir /path/to/your/config/directory 215 | ``` 216 | --------------------------------------------------------------------------------