├── .bumpversion.cfg ├── .dockerignore ├── .github ├── dependabot.yml └── workflows │ ├── codeql-analysis.yml │ ├── pull_requests.yml │ └── release.yml ├── .gitignore ├── .grenrc.yml ├── .pre-commit-config.yaml ├── CHANGELOG.md ├── Dockerfile ├── LICENSE ├── Makefile ├── README.md ├── build.spec ├── config.example.toml ├── pyproject.toml ├── qBitrr ├── __init__.py ├── arr_tables.py ├── arss.py ├── bundled_data.py ├── config.py ├── env_config.py ├── errors.py ├── ffprobe.py ├── gen_config.py ├── home_path.py ├── logger.py ├── main.py ├── tables.py └── utils.py ├── requirements.all.txt ├── requirements.dev.txt ├── requirements.fast.txt ├── requirements.txt ├── setup.cfg └── setup.py /.bumpversion.cfg: -------------------------------------------------------------------------------- 1 | [bumpversion] 2 | current_version = 2.6.0 3 | tag = false 4 | 5 | [bumpversion:file:setup.cfg] 6 | 7 | [bumpversion:file:qBitrr/bundled_data.py] 8 | 9 | [bumpversion:file:Dockerfile] 10 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | .git 2 | .gitignore 3 | .github 4 | .gitattributes 5 | 6 | .bumpversion.cfg 7 | .grenrc.yml 8 | .pre-commit-config.yaml 9 | 10 | CHANGELOG.md 11 | README.md 12 | config.example.toml 13 | requirements.all.txt 14 | requirements.dev.txt 15 | requirements.txt 16 | 17 | dist/ 18 | build/ 19 | qBitrr.egg-info/ 20 | venv/ 21 | .venv/ 22 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | # Maintain dependencies for GitHub Actions 4 | - package-ecosystem: github-actions 5 | directory: / 6 | schedule: 7 | interval: daily 8 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | name: CodeQL 2 | 3 | on: 4 | push: 5 | branches: [master] 6 | pull_request: 7 | branches: [master] 8 | schedule: 9 | - cron: 15 0 * * 5 10 | 11 | jobs: 12 | analyze: 13 | name: Analyze 14 | runs-on: ubuntu-latest 15 | permissions: 16 | actions: read 17 | contents: read 18 | security-events: write 19 | strategy: 20 | fail-fast: false 21 | matrix: 22 | language: [python] 23 | steps: 24 | - name: Checkout repository 25 | uses: actions/checkout@v3 26 | - name: Initialize CodeQL 27 | uses: github/codeql-action/init@v2 28 | with: 29 | languages: ${{ matrix.language }} 30 | - name: Autobuild 31 | uses: github/codeql-action/autobuild@v2 32 | 33 | - name: Perform CodeQL Analysis 34 | uses: github/codeql-action/analyze@v2 35 | -------------------------------------------------------------------------------- /.github/workflows/pull_requests.yml: -------------------------------------------------------------------------------- 1 | name: Build x64 Binaries 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - master 7 | - releases/** 8 | 9 | defaults: 10 | run: 11 | shell: bash 12 | env: 13 | project-name: qBitrr 14 | 15 | jobs: 16 | package: 17 | if: github.event.pull_request.draft == false 18 | runs-on: ${{ matrix.os }} 19 | strategy: 20 | fail-fast: false 21 | matrix: 22 | python: 23 | - '3.10' 24 | os: 25 | - windows-latest 26 | - ubuntu-latest 27 | arch: 28 | - x64 29 | steps: 30 | - name: Checkout code 31 | uses: actions/checkout@v3 32 | with: 33 | fetch-depth: 0 34 | ref: ${{ github.event.pull_request.head.sha }} 35 | - name: Set up Python ${{ matrix.python }} 36 | uses: actions/setup-python@v4 37 | with: 38 | python-version: ${{ matrix.python}} 39 | architecture: ${{ matrix.arch }} 40 | - name: Install APT dependencies 41 | if: runner.os == 'Linux' 42 | run: | 43 | sudo apt-get update 44 | sudo apt-get install libsdl2-dev 45 | - name: Get git hash 46 | run: | 47 | echo "Current Hash: $(git rev-parse --short HEAD)" 48 | echo "::set-output name=HASH::$(git rev-parse --short HEAD)" 49 | id: git_hash 50 | - name: Set archive name 51 | run: | 52 | ARCHIVE_NAME=${{ env.project-name }}-${{ steps.git_hash.outputs.HASH }}-${{ matrix.os }}-${{ matrix.arch }} 53 | echo "Archive name set to: $ARCHIVE_NAME" 54 | echo "::set-output name=NAME::$ARCHIVE_NAME" 55 | id: archieve 56 | - name: Update git hash 57 | run: | 58 | sed -i -e 's/git_hash = \".*\"/git_hash = \"${{ steps.git_hash.outputs.HASH }}\"/g' ./qBitrr/bundled_data.py 59 | - name: Retrieve current version 60 | run: | 61 | echo "Current version: $(python setup.py --version)" 62 | echo "::set-output name=VERSION::$(python setup.py --version)" 63 | id: current_version 64 | - name: Install Python dependencies 65 | run: | 66 | python -m pip install -U pip setuptools wheel 67 | python -m pip install -r requirements.dev.txt 68 | - name: Run PyInstaller 69 | env: 70 | PYTHONOPTIMIZE: 1 # Enable optimizations as if the -O flag is given. 71 | PYTHONHASHSEED: 42 # Try to ensure deterministic results. 72 | PYTHONUNBUFFERED: 1 73 | run: | 74 | pyinstaller build.spec 75 | # This step exists for debugging. Such as checking if data files were included correctly by PyInstaller. 76 | - name: List distribution files 77 | run: | 78 | find dist 79 | # Archive the PyInstaller build using the appropriate tool for the platform. 80 | - name: Tar files 81 | if: runner.os != 'Windows' 82 | run: | 83 | tar --format=ustar -czvf ${{ steps.archieve.outputs.NAME }}.tar.gz dist/ 84 | - name: Archive files 85 | if: runner.os == 'Windows' 86 | shell: pwsh 87 | run: | 88 | Compress-Archive dist/* ${{ steps.archieve.outputs.NAME }}.zip 89 | # Upload archives as artifacts, these can be downloaded from the GitHub actions page. 90 | - name: Upload Artifact 91 | uses: actions/upload-artifact@v3 92 | with: 93 | name: automated-build-${{ steps.archieve.outputs.NAME }} 94 | path: ${{ steps.archieve.outputs.NAME }}.* 95 | if-no-files-found: error 96 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Create a Release 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | 8 | defaults: 9 | run: 10 | shell: bash 11 | env: 12 | project-name: qBitrr 13 | GREN_GITHUB_TOKEN: ${{ secrets.PAT }} 14 | 15 | jobs: 16 | bump_version: 17 | name: Bump release version 18 | runs-on: ubuntu-latest 19 | if: ${{ startsWith(github.event.head_commit.message, '[patch]') || startsWith(github.event.head_commit.message, '[minor]') || startsWith(github.event.head_commit.message, '[major]') }} 20 | env: 21 | RELEASE_TYPE: ${{ startsWith(github.event.head_commit.message, '[patch]') && 'patch' || startsWith(github.event.head_commit.message, '[minor]') && 'minor' || startsWith(github.event.head_commit.message, '[major]') && 'major' }} 22 | steps: 23 | - uses: actions/checkout@v3 24 | with: 25 | token: ${{ secrets.PAT }} 26 | - name: Set up Python 27 | uses: actions/setup-python@v4 28 | with: 29 | python-version: 3.x 30 | - name: Install dependencies 31 | run: | 32 | python -m pip install --upgrade pip 33 | pip install bump2version setuptools wheel twine 34 | - name: Retrieve current version 35 | run: | 36 | echo "Current version: $(python setup.py --version)" 37 | echo "::set-output name=VERSION::$(python setup.py --version)" 38 | id: current_version 39 | - name: Bump Patch Version 40 | run: | 41 | bump2version --current-version $(python setup.py --version) ${{ env.RELEASE_TYPE }} 42 | - name: Retrieve new version 43 | run: | 44 | echo "::set-output name=VERSION::$(python setup.py --version)" 45 | id: new_version 46 | - name: Import GPG key 47 | uses: crazy-max/ghaction-import-gpg@v5 48 | with: 49 | gpg_private_key: ${{ secrets.GPG_PRIVATE_KEY }} 50 | git_user_signingkey: true 51 | git_commit_gpgsign: true 52 | git_tag_gpgsign: true 53 | id: import_gpg 54 | - name: Git Auto Commit 55 | uses: stefanzweifel/git-auto-commit-action@v4.15.0 56 | with: 57 | commit_message: '[skip ci]Automated version bump: v${{ steps.current_version.outputs.VERSION }} >> v${{ steps.new_version.outputs.VERSION }}' 58 | tagging_message: v${{ steps.new_version.outputs.VERSION }} 59 | commit_options: -S 60 | commit_user_name: ${{ steps.import_gpg.outputs.name }} 61 | commit_user_email: ${{ steps.import_gpg.outputs.email }} 62 | commit_author: ${{ steps.import_gpg.outputs.name }} <${{ steps.import_gpg.outputs.email }}> 63 | outputs: 64 | RELEASE_TYPE: ${{ env.RELEASE_TYPE }} 65 | CURRENT_RELEASE: ${{ steps.current_version.outputs.VERSION }} 66 | NEW_RELEASE: ${{ steps.new_version.outputs.VERSION }} 67 | release: 68 | name: Create a GitHub Release 69 | needs: [bump_version] 70 | runs-on: ubuntu-latest 71 | steps: 72 | - uses: actions/checkout@v3 73 | with: 74 | ref: master 75 | token: ${{ secrets.PAT }} 76 | - name: Set up Python 77 | uses: actions/setup-python@v4 78 | with: 79 | python-version: 3.x 80 | - name: Install dependencies 81 | run: | 82 | python -m pip install --upgrade pip 83 | pip install setuptools wheel twine 84 | - name: Import GPG key 85 | uses: crazy-max/ghaction-import-gpg@v5 86 | with: 87 | gpg_private_key: ${{ secrets.GPG_PRIVATE_KEY }} 88 | git_user_signingkey: true 89 | git_commit_gpgsign: true 90 | git_tag_gpgsign: true 91 | id: import_gpg 92 | - uses: marvinpinto/action-automatic-releases@latest 93 | with: 94 | repo_token: ${{ secrets.PAT }} 95 | prerelease: false 96 | automatic_release_tag: v${{needs.bump_version.outputs.NEW_RELEASE}} 97 | title: v${{needs.bump_version.outputs.NEW_RELEASE}} 98 | release_hash: 99 | name: Update The Version Hash 100 | needs: [bump_version, release] 101 | runs-on: ubuntu-latest 102 | steps: 103 | - uses: actions/checkout@v3 104 | with: 105 | token: ${{ secrets.PAT }} 106 | fetch-depth: 0 107 | ref: master 108 | - name: Set up Python 109 | uses: actions/setup-python@v4 110 | with: 111 | python-version: 3.x 112 | - name: Install dependencies 113 | run: | 114 | python -m pip install --upgrade pip 115 | pip install setuptools wheel twine 116 | - name: Get git hash 117 | run: | 118 | echo "Current Hash: $(git rev-parse --short HEAD)" 119 | echo "::set-output name=HASH::$(git rev-parse --short HEAD)" 120 | id: git_hash 121 | - name: Update git hash 122 | run: | 123 | sed -i -e 's/git_hash = \".*\"/git_hash = \"${{ steps.git_hash.outputs.HASH }}\"/g' ./qBitrr/bundled_data.py 124 | - name: Import GPG key 125 | uses: crazy-max/ghaction-import-gpg@v5 126 | with: 127 | gpg_private_key: ${{ secrets.GPG_PRIVATE_KEY }} 128 | git_user_signingkey: true 129 | git_commit_gpgsign: true 130 | git_tag_gpgsign: true 131 | id: import_gpg 132 | - name: Git Auto Commit 133 | uses: stefanzweifel/git-auto-commit-action@v4.15.0 134 | with: 135 | commit_message: '[skip ci] Update Release Hash for v${{needs.bump_version.outputs.NEW_RELEASE}}' 136 | commit_options: -S 137 | commit_user_name: ${{ steps.import_gpg.outputs.name }} 138 | commit_user_email: ${{ steps.import_gpg.outputs.email }} 139 | commit_author: ${{ steps.import_gpg.outputs.name }} <${{ steps.import_gpg.outputs.email }}> 140 | outputs: 141 | RELEASE_HASH: ${{ steps.git_hash.outputs.HASH }} 142 | publish: 143 | name: Publish to PyPi 144 | needs: [bump_version, release_hash] 145 | runs-on: ubuntu-latest 146 | steps: 147 | - uses: actions/checkout@v3 148 | with: 149 | fetch-depth: 0 150 | ref: master 151 | - name: Set up Python 152 | uses: actions/setup-python@v4 153 | with: 154 | python-version: 3.x 155 | - name: Install dependencies 156 | run: | 157 | python -m pip install --upgrade pip 158 | pip install build wheel 159 | - name: Build package 160 | run: python -m build 161 | - name: Publish package 162 | uses: pypa/gh-action-pypi-publish@release/v1 163 | with: 164 | user: __token__ 165 | password: ${{ secrets.PYPI_API_TOKEN }} 166 | package: 167 | name: Build Binaries 168 | needs: [bump_version, release_hash] 169 | runs-on: ${{ matrix.os }} 170 | strategy: 171 | fail-fast: false 172 | matrix: 173 | python: 174 | - '3.10' 175 | os: 176 | - windows-latest 177 | - macos-latest 178 | - ubuntu-latest 179 | arch: 180 | - x86 181 | - x64 182 | exclude: 183 | - os: macOS-latest 184 | arch: x86 185 | - os: ubuntu-latest 186 | arch: x86 187 | steps: 188 | - name: Checkout code 189 | uses: actions/checkout@v3 190 | with: 191 | ref: master 192 | fetch-depth: 0 193 | - name: Set up Python ${{ matrix.python }} 194 | uses: actions/setup-python@v4 195 | with: 196 | python-version: ${{ matrix.python}} 197 | architecture: ${{ matrix.arch }} 198 | - name: Install APT dependencies 199 | if: runner.os == 'Linux' 200 | run: | 201 | sudo apt-get update 202 | sudo apt-get install libsdl2-dev 203 | - name: Set archive name 204 | run: | 205 | ARCHIVE_NAME=${{ env.project-name }}-${{ needs.release_hash.outputs.RELEASE_HASH }}-${{ matrix.os }}-${{ matrix.arch }} 206 | echo "Archive name set to: $ARCHIVE_NAME" 207 | echo "::set-output name=NAME::$ARCHIVE_NAME" 208 | id: archieve 209 | - name: Install Python dependencies 210 | run: | 211 | python -m pip install -U pip setuptools wheel 212 | python -m pip install -r requirements.dev.txt 213 | - name: Run PyInstaller 214 | env: 215 | PYTHONOPTIMIZE: 1 # Enable optimizations as if the -O flag is given. 216 | PYTHONHASHSEED: 42 # Try to ensure deterministic results. 217 | PYTHONUNBUFFERED: 1 218 | run: | 219 | pyinstaller build.spec 220 | # This step exists for debugging. Such as checking if data files were included correctly by PyInstaller. 221 | - name: List distribution files 222 | run: | 223 | find dist 224 | # Archive the PyInstaller build using the appropriate tool for the platform. 225 | - name: Tar files 226 | if: runner.os != 'Windows' 227 | run: | 228 | tar --format=ustar -czvf ${{ steps.archieve.outputs.NAME }}.tar.gz dist/ 229 | - name: Archive files 230 | if: runner.os == 'Windows' 231 | shell: pwsh 232 | run: | 233 | Compress-Archive dist/* ${{ steps.archieve.outputs.NAME }}.zip 234 | # Upload archives as artifacts, these can be downloaded from the GitHub actions page. 235 | - name: Upload Artifact 236 | uses: actions/upload-artifact@v3 237 | with: 238 | name: automated-build-${{ steps.archieve.outputs.NAME }} 239 | path: ${{ steps.archieve.outputs.NAME }}.* 240 | retention-days: 7 241 | if-no-files-found: error 242 | - name: Upload release 243 | uses: svenstaro/upload-release-action@v2 244 | with: 245 | repo_token: ${{ secrets.GITHUB_TOKEN }} 246 | file: ${{ steps.archieve.outputs.NAME }}.* 247 | file_glob: true 248 | tag: v${{needs.bump_version.outputs.NEW_RELEASE}} 249 | overwrite: true 250 | change_logs: 251 | name: Generate Change Logs and Release Notes 252 | needs: [bump_version, release_hash, publish] 253 | runs-on: ubuntu-latest 254 | steps: 255 | - uses: actions/checkout@v3 256 | with: 257 | fetch-depth: 0 258 | ref: master 259 | - name: Set up Python 260 | uses: actions/setup-python@v4 261 | with: 262 | python-version: 3.x 263 | - uses: actions/setup-node@v3 264 | with: 265 | node-version: '14' 266 | - run: npm install github-release-notes -g 267 | - name: Release Notes and Change logs 268 | run: | 269 | gren release 270 | gren changelog 271 | - name: Import GPG key 272 | uses: crazy-max/ghaction-import-gpg@v5 273 | with: 274 | gpg_private_key: ${{ secrets.GPG_PRIVATE_KEY }} 275 | git_user_signingkey: true 276 | git_commit_gpgsign: true 277 | git_tag_gpgsign: true 278 | id: import_gpg 279 | - name: Git Auto Commit 280 | uses: stefanzweifel/git-auto-commit-action@v4.15.0 281 | with: 282 | commit_message: '[skip ci] Update CHANGELOG.md and Release Notes for v${{needs.bump_version.outputs.NEW_RELEASE}}' 283 | commit_options: -S 284 | commit_user_name: ${{ steps.import_gpg.outputs.name }} 285 | commit_user_email: ${{ steps.import_gpg.outputs.email }} 286 | commit_author: ${{ steps.import_gpg.outputs.name }} <${{ steps.import_gpg.outputs.email }}> 287 | docker_image: 288 | name: Build Docker Image 289 | needs: [bump_version, release_hash] 290 | runs-on: ubuntu-latest 291 | steps: 292 | - name: Checkout 293 | uses: actions/checkout@v3 294 | with: 295 | fetch-depth: 0 296 | ref: master 297 | - name: Set up QEMU 298 | uses: docker/setup-qemu-action@v2 299 | - name: Set up Docker Buildx 300 | uses: docker/setup-buildx-action@v2 301 | - name: Login to DockerHub 302 | uses: docker/login-action@v2 303 | with: 304 | username: ${{ secrets.DOCKERHUB_USERNAME }} 305 | password: ${{ secrets.DOCKERHUB_TOKEN }} 306 | - name: Build and push 307 | env: 308 | DOCKER_BUILDKIT: 1 309 | uses: docker/build-push-action@v3 310 | with: 311 | context: . 312 | platforms: linux/amd64 313 | push: true 314 | tags: drapersniper/qbitrr:latest,drapersniper/qbitrr:v${{needs.bump_version.outputs.NEW_RELEASE}} 315 | cache-from: type=gha 316 | cache-to: type=gha,mode=max 317 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /config.toml 2 | .idea 3 | /qBitrr/testing/test.py 4 | /qBitrr.egg-info/ 5 | /build/ 6 | /dist/ 7 | .env 8 | .venv/ 9 | venv/ 10 | /.github/workflows/test.yml 11 | __pycache__/ 12 | *.py[cod] 13 | /qBitrr/testing/ 14 | build.bat 15 | /main.*/ 16 | /main.exe 17 | .run/ 18 | -------------------------------------------------------------------------------- /.grenrc.yml: -------------------------------------------------------------------------------- 1 | dataSource: commits 2 | ignoreIssuesWith: 3 | - wontfix 4 | - duplicate 5 | prefix: '' 6 | includeMessages: all 7 | ignoreCommitsWith: 8 | - \[auto-changelogs\] 9 | - \[auto-release\] 10 | - \[auto-publish\] 11 | - \[skip ci\] 12 | - \[ci skip\] 13 | - \[pre-commit.ci\] 14 | changelogFilename: CHANGELOG.md 15 | override: true 16 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | exclude: ^(CHANGELOG.md|bundled_data.py) 2 | repos: 3 | - repo: https://github.com/pre-commit/pre-commit-hooks 4 | rev: v4.3.0 5 | hooks: 6 | - id: check-yaml 7 | - id: end-of-file-fixer 8 | - id: requirements-txt-fixer 9 | - id: trailing-whitespace 10 | - id: mixed-line-ending 11 | args: [--fix, lf] 12 | - id: detect-private-key 13 | - id: check-toml 14 | - id: check-json 15 | - id: pretty-format-json 16 | args: [--autofix, --indent, '2'] 17 | - repo: https://github.com/asottile/pyupgrade 18 | rev: v2.38.2 19 | hooks: 20 | - id: pyupgrade 21 | args: [--py38-plus] 22 | - repo: https://github.com/pycqa/isort 23 | rev: 5.10.1 24 | hooks: 25 | - id: isort 26 | - repo: https://github.com/psf/black 27 | rev: 22.8.0 28 | hooks: 29 | - id: black 30 | - repo: https://github.com/macisamuele/language-formatters-pre-commit-hooks 31 | rev: v2.4.0 32 | hooks: 33 | - id: pretty-format-yaml 34 | args: [--autofix, --indent, '2'] 35 | - repo: https://github.com/sirosen/texthooks 36 | rev: 0.4.0 37 | hooks: 38 | - id: fix-smartquotes 39 | - id: fix-ligatures 40 | - repo: https://github.com/pre-commit/mirrors-autopep8 41 | rev: v1.7.0 # Use the sha / tag you want to point at 42 | hooks: 43 | - id: autopep8 44 | - repo: https://github.com/PyCQA/autoflake 45 | rev: v1.6.1 46 | hooks: 47 | - id: autoflake 48 | args: [--remove-all-unused-imports, --recursive, --in-place, --remove-unused-variables, --ignore-init-module-imports, --remove-duplicate-keys] 49 | - repo: https://github.com/MarcoGorelli/absolufy-imports 50 | rev: v0.3.1 51 | hooks: 52 | - id: absolufy-imports 53 | ci: 54 | autofix_commit_msg: | 55 | [pre-commit.ci] auto fixes from pre-commit.com hooks 56 | 57 | for more information, see https://pre-commit.ci 58 | autofix_prs: true 59 | autoupdate_commit_msg: '[pre-commit.ci] pre-commit autoupdate' 60 | autoupdate_schedule: weekly 61 | submodules: false 62 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## v2.6.0 (09/10/2022) 4 | - [[minor] Update to work with newer qbittorrent version](https://github.com/Drapersniper/Qbitrr/commit/c02c36d7d924cd8e54416568115ec495544c5136) - @Drapersniper 5 | - [Bump to qbittorrent-api==2022.8.38](https://github.com/Drapersniper/Qbitrr/commit/bfe3af3f432e8870a89e616263ec8a3b2a9dc883) - @Drapersniper 6 | - [Bump stefanzweifel/git-auto-commit-action from 4.14.1 to 4.15.0](https://github.com/Drapersniper/Qbitrr/commit/76cc5a1d5b1a1d6aa10eda625621cff62b25ae84) - @dependabot[bot] 7 | - [De Morgan's laws](https://github.com/Drapersniper/Qbitrr/commit/e7cef49b912b611a934c679340ba7e4f2db00502) - @Drapersniper 8 | - [Bump ujson from 5.2.0 to 5.4.0](https://github.com/Drapersniper/Qbitrr/commit/39cdf4c966bb349d1d3e896ab27db962fa5609c2) - @dependabot[bot] 9 | - [Update README.md](https://github.com/Drapersniper/Qbitrr/commit/9c66b02ce131414aa51ac842ea43f717c41d4a24) - @Drapersniper 10 | 11 | --- 12 | 13 | ## v2.5.5 (19/06/2022) 14 | - [[patch] allow qbitrr to run agaisnt qBitTorrent 4.4+](https://github.com/Drapersniper/Qbitrr/commit/542ba3b5557aa8a37759f7ea2d89e96eb447da92) - @Drapersniper 15 | - [Closes #58 by allowing qbitrr to run against qBitTorrent 4.4+ - <4.5, Historically this has broken several things as the API for qbittorrent was returning bad values.](https://github.com/Drapersniper/Qbitrr/commit/7e0178eabfe7690fb06cf9fe9c12f5a8955c4561) - @Drapersniper 16 | - [Bump ujson from 5.1.0 to 5.2.0](https://github.com/Drapersniper/Qbitrr/commit/e3483d7f9de4f23619e0387ae64c986c4290cb1b) - @dependabot[bot] 17 | - [Bump crazy-max/ghaction-import-gpg from 4 to 5](https://github.com/Drapersniper/Qbitrr/commit/9b01efbdee7b3ebe02bc36dde5ea6bd7db79380d) - @dependabot[bot] 18 | - [Bump actions/setup-python from 3 to 4](https://github.com/Drapersniper/Qbitrr/commit/cd24e366dee8a8e065883876577def8d74db76be) - @dependabot[bot] 19 | - [Bump docker/login-action from 1 to 2](https://github.com/Drapersniper/Qbitrr/commit/146b18525d64bcde1443dbec484296bbb04d06a1) - @dependabot[bot] 20 | - [Bump stefanzweifel/git-auto-commit-action from 4.13.1 to 4.14.1](https://github.com/Drapersniper/Qbitrr/commit/2c7ea60822f6032f6849b68adf3cbb24f196920b) - @dependabot[bot] 21 | - [Bump actions/upload-artifact from 2 to 3](https://github.com/Drapersniper/Qbitrr/commit/05ca15f7d7c3a85dfcad2c6aa187a0e0068967ad) - @dependabot[bot] 22 | - [Bump docker/setup-buildx-action from 1 to 2](https://github.com/Drapersniper/Qbitrr/commit/97933c295966d730a99f8bf0175d3d59393c6ec7) - @dependabot[bot] 23 | - [Bump docker/setup-qemu-action from 1 to 2](https://github.com/Drapersniper/Qbitrr/commit/ec8a415f38643481da8a165c1149988189484e6d) - @dependabot[bot] 24 | - [Bump github/codeql-action from 1 to 2](https://github.com/Drapersniper/Qbitrr/commit/a92376d60c9ab0091e697261bae9ba2035c59f44) - @dependabot[bot] 25 | - [Bump docker/build-push-action from 2 to 3](https://github.com/Drapersniper/Qbitrr/commit/e6e718a52f2dcce278a7614dc17ae586d332e98a) - @dependabot[bot] 26 | - [Bump actions/setup-python from 2 to 3](https://github.com/Drapersniper/Qbitrr/commit/cd4b2259bcfd7284f7e81d958493ffc232ba3d9e) - @dependabot[bot] 27 | - [Bump actions/checkout from 2 to 3](https://github.com/Drapersniper/Qbitrr/commit/5913935447dad075fb9b45c0f1f98ca4cc4b1e13) - @dependabot[bot] 28 | - [Bump actions/setup-node from 2 to 3](https://github.com/Drapersniper/Qbitrr/commit/bb548bbe9955357ed24353fbf0497b0f7ecf4750) - @dependabot[bot] 29 | 30 | --- 31 | 32 | ## v2.5.4 (26/02/2022) 33 | - [[patch] properly accept empty `FolderExclusionRegex` and `FileNameExclusionRegex`](https://github.com/Drapersniper/Qbitrr/commit/a4eb4079599b04fd5ddee6fa5c19a4d69f503c30) - @Drapersniper 34 | 35 | --- 36 | 37 | ## v2.5.3 (26/02/2022) 38 | - [[patch] allow setting `FileExtensionAllowlist` to an empty list to allow all file extensions](https://github.com/Drapersniper/Qbitrr/commit/e1f278533f2857dbc87dcbac4dcd9dcddfb2639b) - @Drapersniper 39 | 40 | --- 41 | 42 | ## v2.5.2 (18/02/2022) 43 | - [[patch] hotfix stop crashing the script on an invalid config key for Arrs (it will still crash on invalid global values, i.e Log level), provide better logging when unable to load the config file.](https://github.com/Drapersniper/Qbitrr/commit/f7abf79cc0c86213fb01a07f3a2f3243aad66cd0) - @Drapersniper 44 | 45 | --- 46 | 47 | ## v2.5.1 (18/02/2022) 48 | - [[patch] hotfix to handle edge case](https://github.com/Drapersniper/Qbitrr/commit/889ffbdb1b31f4d75300df56fb27f2445cafcf27) - @Drapersniper 49 | 50 | --- 51 | 52 | ## v2.5.0 (18/02/2022) 53 | - [[minor] Catch `TypeError` when building an Arr instance and build an invalid path when required](https://github.com/Drapersniper/Qbitrr/commit/fef795d067f7a02ba2939389f104c0845e995214) - @Drapersniper 54 | 55 | --- 56 | 57 | ## v2.4.2 (01/02/2022) 58 | - [[patch] apply "fix" in all relevant locations](https://github.com/Drapersniper/Qbitrr/commit/bb8d8919e3b89ab09a130e43a6776c59dfca93ef) - @Drapersniper 59 | 60 | --- 61 | 62 | ## v2.4.1 (01/02/2022) 63 | - [[patch] the qbittorrent api is being weird af ... why is it returning non existing torrents with missing attributes](https://github.com/Drapersniper/Qbitrr/commit/cace1010a757868fff7cf951e35aee0204e6e771) - @Drapersniper 64 | 65 | --- 66 | 67 | ## v2.4.0 (01/02/2022) 68 | - [[minor] Add Environment variable support for some config as well as overrides for some variables](https://github.com/Drapersniper/Qbitrr/commit/56e77bb606d215ef8c9d4b30935824c4686174ab) - @Drapersniper 69 | 70 | --- 71 | 72 | ## v2.3.4 (31/01/2022) 73 | - [[patch] Fix broken log line](https://github.com/Drapersniper/Qbitrr/commit/28475f3be23e6f44162ec031492db25c88f94c25) - @Drapersniper 74 | - [[patch] Fix indentation which was causing a crash](https://github.com/Drapersniper/Qbitrr/commit/11aac0db33559abc785bedbc6d66907742fcf25f) - @Drapersniper 75 | - [[patch] Improvements for docker build](https://github.com/Drapersniper/Qbitrr/commit/d5ede08c859a647432910d5988597b0d57589c37) - @Drapersniper 76 | - [[Feature] Add the ability to run the search functionality without a running instant of qBitTorrent](https://github.com/Drapersniper/Qbitrr/commit/bb918d95d7a262cecd05a36ccbd7fdd770c0245e) - @Drapersniper 77 | - [[Dep] Update black dev dependency to 22.1.0](https://github.com/Drapersniper/Qbitrr/commit/578a92266030a28479a8b9e8f2584623ee6ebf45) - @Drapersniper 78 | - [[enhancement] a Significant improvement for missing episode searches](https://github.com/Drapersniper/Qbitrr/commit/6b825a4bb3e33547425aceee2d592feac599cc04) - @Drapersniper 79 | 80 | --- 81 | 82 | ## v2.3.3 (30/01/2022) 83 | - [[patch] disable arm builds until i figure out why the wheels are not pre-build](https://github.com/Drapersniper/Qbitrr/commit/78f7e6fdb6024481800bc688aaa419ef088ba060) - @Drapersniper 84 | 85 | --- 86 | 87 | ## v2.3.2 (30/01/2022) 88 | 89 | 90 | --- 91 | 92 | ## v2.3.1 (30/01/2022) 93 | 94 | --- 95 | 96 | ## v2.3.0 (30/01/2022) 97 | - [[minor] ensure the script does not run on unsupported version of qbittorrent.](https://github.com/Drapersniper/Qbitrr/commit/e07e8bcd78a7c810c56e396861fe7a9e7694264f) - @Drapersniper 98 | - [[dev] Improve maintainability of requirements](https://github.com/Drapersniper/Qbitrr/commit/8bf53c1bf1780c70ae3824bdeead562d5300f1ba) - @Drapersniper 99 | 100 | --- 101 | 102 | ## v2.2.5 (28/01/2022) 103 | - [[patch] Fixed seeding logic finally?](https://github.com/Drapersniper/Qbitrr/commit/2fcaa00d892ecd6c4f7eee3a606f00feb73763c1) - @Drapersniper 104 | - [Merge remote-tracking branch 'origin/master'](https://github.com/Drapersniper/Qbitrr/commit/2c9b4576071e12cdf54899783c90003fede5e585) - @Drapersniper 105 | - [[patch] Fix seeding logic to avoid these stupid ass conflicts](https://github.com/Drapersniper/Qbitrr/commit/ea397d8ea45fad8d7c974fce2f51d2cd33efeaed) - @Drapersniper 106 | 107 | --- 108 | 109 | ## v2.2.4 (28/01/2022) 110 | - [[patch] Fixed both bugs reported by stats on discord](https://github.com/Drapersniper/Qbitrr/commit/4c67f5f92d78cfaac3b5f9151374a5dac8a3b3fb) - @Drapersniper 111 | - [Ensure that binaries and docker contain the frozen version requirements](https://github.com/Drapersniper/Qbitrr/commit/a8ff848a27f58657c44c1490eef76bbfe4663256) - @Drapersniper 112 | - [Fix a crash caused when attempting to get release data but the API didn't return a string as documented](https://github.com/Drapersniper/Qbitrr/commit/2a140fe9bd8c043e29d18cc49ba758e2d9449600) - @Drapersniper 113 | - [[dep] Better docker env detection](https://github.com/Drapersniper/Qbitrr/commit/59c2824ad5f15f4bababb59ea8e9817b48ac2164) - @Drapersniper 114 | 115 | --- 116 | 117 | ## v2.2.3 (27/01/2022) 118 | - [[patch] Push a tag with version to keep historical versions](https://github.com/Drapersniper/Qbitrr/commit/c340fefabe7271da1862908c3c7349f8b87d65f0) - @Drapersniper 119 | 120 | --- 121 | 122 | ## v2.2.2 (27/01/2022) 123 | - [[patch] Hotfix a crash introduced by the last update and improve the seeding logic](https://github.com/Drapersniper/Qbitrr/commit/0ad0e4d8074ee7f0e885339717a8247c32df5d17) - @Drapersniper 124 | 125 | --- 126 | 127 | ## v2.2.1 (27/01/2022) 128 | - [[patch] Query release date from overseerr to avoid searching movies/series that have not been released](https://github.com/Drapersniper/Qbitrr/commit/0d21cca8fc7f1105f32601a61702ca00deb838bc) - @Drapersniper 129 | - [improve the code around GET requests to make it more maintainable](https://github.com/Drapersniper/Qbitrr/commit/a7d55ce9cca7a0bb458df006baba5d6cba282c92) - @Drapersniper 130 | - [[Fix] Fix an issue where torrents were not allowed to seed regardless of the setting in config](https://github.com/Drapersniper/Qbitrr/commit/a32e7ff8fb6124f6939b83719122380b1f05011a) - @Drapersniper 131 | - [[docs] Add tty:true key to docker compose example to ensure correct logs colour when using `docker-compose logs`](https://github.com/Drapersniper/Qbitrr/commit/67da2992fd50f1481b7e952662221072ef84db0e) - @Drapersniper 132 | - [Bump stefanzweifel/git-auto-commit-action from 4.12.0 to 4.13.1](https://github.com/Drapersniper/Qbitrr/commit/ca61fce6d75ec1f66a5c657a264f7994380ce243) - @dependabot[bot] 133 | 134 | --- 135 | 136 | ## v2.2.0 (25/01/2022) 137 | - [[minor] Add support for Docker and create a docker image (#26)](https://github.com/Drapersniper/Qbitrr/commit/635fd14c9cb7b7672ec301af3c388f62d78c051c) - @Drapersniper 138 | 139 | --- 140 | 141 | ## v2.1.20 (30/12/2021) 142 | - [[patch] Fix yet another edge case around marking torrents as failed when they aren't actually failed](https://github.com/Drapersniper/Qbitrr/commit/d6752d1587fd98064b3c94c502587ae9458eb0b4) - @Drapersniper 143 | 144 | --- 145 | 146 | ## v2.1.19 (30/12/2021) 147 | 148 | 149 | --- 150 | 151 | ## v2.1.18 (30/12/2021) 152 | - [[patch] Deploy release](https://github.com/Drapersniper/Qbitrr/commit/b102f849ecb4b2787b65c60bb87741703ad77188) - @Drapersniper 153 | - [[Admin] Optimize GitHub workflows to reuse variables and depend on one another also add the binary workflow to pull requests](https://github.com/Drapersniper/Qbitrr/commit/aead207d3bc93f7cdb16d044f401b229986fe4af) - @Drapersniper 154 | - [[fix] Make the loops sleep if qBitTorrent is unresponsive and raises an api error](https://github.com/Drapersniper/Qbitrr/commit/decc9492531042a103212aaca611df134d5897a0) - @Drapersniper 155 | 156 | --- 157 | 158 | ## v2.1.17 (30/12/2021) 159 | - [[patch] Fix for #6](https://github.com/Drapersniper/Qbitrr/commit/45154415d52eb19a36fa63997a098bae5f7f954d) - @Drapersniper 160 | - [[fix] Fix an issue that causes a specific log line to fail.](https://github.com/Drapersniper/Qbitrr/commit/e66ed28a2d4b22f5fd0eb0f2913a5c405ec0064d) - @Drapersniper 161 | - [[fix] Fix an issue where an old table field was causing crashes with Radarr file searches](https://github.com/Drapersniper/Qbitrr/commit/6da841636059aeac3f89ab5c4b870373eac64ae4) - @Drapersniper 162 | 163 | --- 164 | 165 | ## v2.1.16 (23/12/2021) 166 | - [[patch] Fix the previous issue properly](https://github.com/Drapersniper/Qbitrr/commit/34f231b2091f14c88c75a9274bcf7e9b1c671b6e) - @Drapersniper 167 | 168 | --- 169 | 170 | ## v2.1.15 (23/12/2021) 171 | - [[patch] Resolve bad conflict resolution](https://github.com/Drapersniper/Qbitrr/commit/3448dd29c90b74bf81d1b3c3962ec8eba000c5ca) - @Drapersniper 172 | - [[patch] Full fix for https://github.com/Drapersniper/Qbitrr/issues/19#issuecomment-999970944](https://github.com/Drapersniper/Qbitrr/commit/7c8f46d56701df7b938e5def82bd0b40b37e468e) - @Drapersniper 173 | 174 | --- 175 | 176 | ## v2.1.14 (23/12/2021) 177 | - [[patch] Temp fix for https://github.com/Drapersniper/Qbitrr/issues/19#issuecomment-999970944](https://github.com/Drapersniper/Qbitrr/commit/746a769d44fc3ed8256adc3ca0f4a31599b07706) - @Drapersniper 178 | - [Update setup instructions](https://github.com/Drapersniper/Qbitrr/commit/d59424239516be1c5513f2e4cea6c32e1eabba40) - @Drapersniper 179 | 180 | --- 181 | 182 | ## v2.1.13 (20/12/2021) 183 | - [[patch] Hotfix - Remove ujson support for any python implementation that is not CPython due to the SystemError crash that occurred on PyPy](https://github.com/Drapersniper/Qbitrr/commit/0a3ff2da674076630e03a73e7f7782fbfa673697) - @Drapersniper 184 | 185 | --- 186 | 187 | ## v2.1.12 (20/12/2021) 188 | - [[patch] replace requests complexjson with ujson if it is available (This affects the whole runtime meaning assuming the response isn't unsupported it should give a significant boost to requests performance.](https://github.com/Drapersniper/Qbitrr/commit/1ee15b5d3182251c6dcb127c7555205e95809ba1) - @Drapersniper 189 | - [[deps] Add ujson as an optional dep](https://github.com/Drapersniper/Qbitrr/commit/320f785e9c79e18772355e064650cba0a93a15d3) - @Drapersniper 190 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # Pin Python to the latest supported version 2 | # (This avoid it auto updating to a higher untested version) 3 | FROM python:3.10 4 | 5 | LABEL Name="qBitrr" 6 | LABEL Maintainer="Draper" 7 | LABEL Version="2.6.0" 8 | 9 | # Env used by the script to determine if its inside a docker - 10 | # if this is set to 69420 it will change the working dir for docker specific values 11 | ENV QBITRR_DOCKER_RUNNING=69420 12 | ENV PYTHONDONTWRITEBYTECODE=1 13 | ENV PYTHONUNBUFFERED=1 14 | ENV PYTHONOPTIMIZE=1 15 | 16 | RUN pip install --quiet -U pip wheel 17 | WORKDIR /app 18 | ADD ./requirements.fast.txt /app/requirements.fast.txt 19 | RUN pip install --quiet -r requirements.fast.txt 20 | COPY . /app 21 | RUN pip install --quiet . 22 | 23 | WORKDIR /config 24 | 25 | ENTRYPOINT ["python", "-m", "qBitrr.main"] 26 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Draper 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 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .DEFAULT_GOAL := help 2 | 3 | PYTHON ?= python 4 | 5 | ROOT_DIR:=$(shell dirname $(realpath $(firstword $(MAKEFILE_LIST)))) 6 | 7 | ifneq ($(wildcard $(ROOT_DIR)/.venv/.),) 8 | VENV_PYTHON = $(ROOT_DIR)/.venv/bin/python 9 | else 10 | VENV_PYTHON = $(PYTHON) 11 | endif 12 | 13 | define HELP_BODY 14 | Usage: 15 | make 16 | 17 | Commands: 18 | reformat Reformat all .py files being tracked by git. 19 | bumpdeps Run script bumping dependencies. 20 | newenv Create or replace this project's virtual environment. 21 | syncenv Sync this project's virtual environment to Red's latest 22 | dependencies. 23 | endef 24 | export HELP_BODY 25 | 26 | # Python Code Style 27 | reformat: 28 | pre-commit 29 | 30 | # Dependencies 31 | bumpdeps: 32 | pip-compile -o requirements.txt --upgrade 33 | pip-compile -o requirements.dev.txt --extra dev --upgrade 34 | pip-compile -o requirements.fast.txt --extra fast --upgrade 35 | pip-compile -o requirements.all.txt --extra all --upgrade 36 | 37 | # Development environment 38 | newenv: 39 | $(PYTHON) -m venv --clear .venv 40 | .venv/bin/pip install -U pip setuptools wheel 41 | $(MAKE) syncenv 42 | syncenv: 43 | pip install -Ur requirements.all.txt 44 | help: 45 | @echo "$$HELP_BODY" 46 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![PyPI](https://img.shields.io/pypi/v/qBitrr)](https://pypi.org/project/qBitrr/) 2 | [![PyPI](https://img.shields.io/pypi/dm/qbitrr)](https://pypi.org/project/qBitrr/) 3 | [![PyPI - License](https://img.shields.io/pypi/l/qbitrr)](https://github.com/Drapersniper/Qbitrr/blob/master/LICENSE) 4 | [![Pulls](https://img.shields.io/docker/pulls/drapersniper/qbitrr.svg)](https://hub.docker.com/r/drapersniper/qbitrr) 5 | 6 | ![PyPI - Python Version](https://img.shields.io/pypi/pyversions/qbitrr) 7 | ![Platforms](https://img.shields.io/badge/platform-linux--64%20%7C%20osx--64%20%7C%20win--32%20%7C%20win--64-lightgrey) 8 | 9 | [![pre-commit.ci status](https://results.pre-commit.ci/badge/github/Drapersniper/Qbitrr/master.svg)](https://results.pre-commit.ci/latest/github/Drapersniper/Qbitrr/master) 10 | [![CodeQL](https://github.com/Drapersniper/Qbitrr/actions/workflows/codeql-analysis.yml/badge.svg?branch=master)](https://github.com/Drapersniper/Qbitrr/actions/workflows/codeql-analysis.yml) 11 | [![Create a Release](https://github.com/Drapersniper/Qbitrr/actions/workflows/release.yml/badge.svg?branch=master)](https://github.com/Drapersniper/Qbitrr/actions/workflows/release.yml) 12 | 13 | [![Code Style: Black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black) 14 | [![Imports: isort](https://img.shields.io/badge/%20imports-isort-%231674b1?style=flat&labelColor=ef8336)](https://pycqa.github.io/isort/) 15 | 16 | A simple script to monitor [Qbit](https://github.com/qbittorrent/qBittorrent) and communicate with [Radarr](https://github.com/Radarr/Radarr) and [Sonarr](https://github.com/Sonarr/Sonarr) 17 | 18 | Join the [Official Discord Server](https://discord.gg/FT3puape2A) for help. 19 | 20 | ### Features 21 | 22 | - Monitor qBit for Stalled/bad entries and delete them then blacklist them on Arrs (Option to also trigger a re-search action). 23 | - Monitor qBit for completed entries and tell the appropriate Arr instance to import it ( 'DownloadedMoviesScan' or 'DownloadedEpisodesScan' commands). 24 | - Skip files in qBit entries by extension, folder or regex. 25 | - Monitor completed folder and cleans it up. 26 | - Uses [ffprobe](https://github.com/FFmpeg/FFmpeg) to ensure downloaded entries are valid media. 27 | - Trigger periodic Rss Syncs on the appropriate Arr instances. 28 | - Trigger Queue update on appropriate Arr instances. 29 | - Search requests from [Overseerr](https://github.com/sct/overseerr) or [Ombi](https://github.com/Ombi-app/Ombi). 30 | - Auto add/remove trackers 31 | - Set per tracker values 32 | 33 | **This section requires the Arr databases to be locally available.** 34 | 35 | - Monitor Arr's databases to trigger missing episode searches. 36 | - Customizable year range to search for (at a later point will add more option here, for example search whole series/season instead of individual episodes, search by name, category etc). 37 | 38 | ### Important mentions 39 | 40 | Some things to know before using it. 41 | 42 | - 1. Qbitrr works best with qBittorrent 4.3.9 43 | - 2. You need to run the `qbitrr --gen-config` move the generated file to `~/.config/qBitManager/config.toml` (~ is your home directory, i.e `C:\Users\{User}`) 44 | - 3. I have [Sonarr](https://github.com/Sonarr/Sonarr) and [Radarr](https://github.com/Radarr/Radarr) both setup to add tags to all downloads. 45 | - 4. I have qBit setup to have to create sub-folder for downloads and for the download folder to 46 | use subcategories. 47 | 48 | ![image](https://user-images.githubusercontent.com/27962761/139117102-ec1d321a-1e64-4880-8ad1-ee2c9b805f92.png) 49 | 50 | #### Install the requirements run 51 | 52 | - `python -m pip install qBitrr` (I would recommend in a dedicated [venv](https://docs.python.org/3.3/library/venv.html) but that's out of scope. 53 | 54 | Alternatively: 55 | - Download on the [latest release](https://github.com/Drapersniper/Qbitrr/releases/latest) 56 | 57 | #### Run the script 58 | 59 | - Make sure to update the settings in `~/.config/qBitManager/config.toml` 60 | - Activate your venv 61 | - Run `qbitrr` 62 | 63 | Alternatively: 64 | - Unzip the downloaded release and run it 65 | 66 | #### How to update the script 67 | 68 | - Activate your venv 69 | - Run `python -m pip install -U qBitrr` 70 | 71 | Alternatively: 72 | - Download on the [latest release](https://github.com/Drapersniper/Qbitrr/releases/latest) 73 | 74 | #### Contributions 75 | 76 | - I'm happy with any PRs and suggested changes to the logic I just put it together dirty for my own use case. 77 | 78 | #### Example behaviour 79 | 80 | ![image](https://user-images.githubusercontent.com/27962761/146447714-5309d3e6-51fd-472c-9587-9df491f121b3.png) 81 | 82 | 83 | #### Docker Image 84 | - The docker image can be found [here](https://hub.docker.com/r/drapersniper/qbitrr) 85 | 86 | #### Docker Compose 87 | ```json 88 | version: "3" 89 | services: 90 | qbitrr: 91 | image: qbitrr 92 | user: 1000:1000 # Required to ensure teh container is run as the user who has perms to see the 2 mount points and the ability to write to the CompletedDownloadFolder mount 93 | tty: true # Ensure the output of docker-compose logs qbitrr are properly colored. 94 | restart: unless-stopped 95 | # networks: This container MUST share a network with your Sonarr/Radarr instances 96 | enviroment: 97 | TZ: Europe/London 98 | volumes: 99 | - /etc/localtime:/etc/localtime:ro 100 | - /path/to/appdata/qbitrr:/config # All qbitrr files are stored in the `/config` folder when using a docker container 101 | - /path/to/sonarr/db:/sonarr.db/path/in/container:ro # This is only needed if you want episode search handling :ro means it is only ever mounted as a read-only folder, the script never needs more than read access 102 | - /path/to/radarr/db:/radarr.db/path/in/container:ro # This is only needed if you want movie search handling, :ro means it is only ever mounted as a read-only folder, the script never needs more than read access 103 | - /path/to/completed/downloads/folder:/completed_downloads/folder/in/container:rw # The script will ALWAYS require write permission in this folder if mounted, this folder is used to monitor completed downloads and if not present will cause the script to ignore downloaded file monitoring. 104 | # Now just to make sure it is clean, when using this script in a docker you will need to ensure you config.toml values reflect the mounted folders.# 105 | # For example, for your Sonarr.DatabaseFile value using the values above you'd add 106 | # DatabaseFile = /sonarr.db/path/in/container/sonarr.db 107 | # Because this is where you mounted it to 108 | # The same would apply to Settings.CompletedDownloadFolder 109 | # e.g CompletedDownloadFolder = /completed_downloads/folder/in/container 110 | 111 | logging: # this script will generate a LOT of logs - so it is up to you to decide how much of it you want to store 112 | driver: "json-file" 113 | options: 114 | max-size: "50m" 115 | max-file: 3 116 | depends_on: # Not needed but this ensures qBitrr only starts if the dependencies are up and running 117 | - qbittorrent 118 | - radarr-1080p 119 | - sonarr-1080p 120 | - animarr-1080p 121 | - overseerr 122 | ``` 123 | ##### Important mentions for docker 124 | - The script will always expect a completed config.toml file 125 | - When you first start the container a "config.rename_me.toml" will be added to `/path/to/appdata/qbitrr` 126 | - Make sure to rename it to 'config.toml' then edit it to your desired values 127 | -------------------------------------------------------------------------------- /build.spec: -------------------------------------------------------------------------------- 1 | # -*- mode: python ; coding: utf-8 -*- 2 | 3 | 4 | block_cipher = None 5 | PROJECT_NAME = "qBitrr" 6 | 7 | 8 | a = Analysis(['qBitrr/main.py'], 9 | pathex=[], 10 | binaries=[], 11 | datas=[], 12 | hiddenimports=[], 13 | hookspath=[], 14 | hooksconfig={}, 15 | runtime_hooks=[], 16 | excludes=[], 17 | win_no_prefer_redirects=False, 18 | win_private_assemblies=False, 19 | cipher=block_cipher, 20 | noarchive=False) 21 | pyz = PYZ(a.pure, a.zipped_data, 22 | cipher=block_cipher) 23 | 24 | exe = EXE(pyz, 25 | a.scripts, 26 | a.binaries, 27 | a.zipfiles, 28 | a.datas, 29 | [], 30 | name=PROJECT_NAME, 31 | debug=False, 32 | bootloader_ignore_signals=False, 33 | strip=False, 34 | upx=True, 35 | upx_exclude=[], 36 | runtime_tmpdir=None, 37 | console=True, 38 | disable_windowed_traceback=False 39 | ) 40 | -------------------------------------------------------------------------------- /config.example.toml: -------------------------------------------------------------------------------- 1 | # This is a config file for the qBitrr Script - Make sure to change all entries of "CHANGE_ME". 2 | # This is a config file should be moved to "C:\Users\{User}\.config\qBitManager\config.toml". 3 | 4 | 5 | [Settings] 6 | # Level of logging; One of CRITICAL, ERROR, WARNING, NOTICE, INFO, DEBUG, TRACE 7 | ConsoleLevel = "NOTICE" 8 | 9 | # Folder where your completed downloads are put into. Can be found in qBitTorrent -> Options -> Downloads -> Default Save Path 10 | CompletedDownloadFolder = "CHANGE_ME" 11 | 12 | # Time to sleep for if there is no internet (in seconds: 600 = 10 Minutes) 13 | NoInternetSleepTimer = 15 14 | 15 | # Time to sleep between reprocessing torrents (in seconds: 600 = 10 Minutes) 16 | LoopSleepTimer = 5 17 | 18 | # Add torrents to this category to mark them as failed 19 | FailedCategory = "failed" 20 | 21 | # Add torrents to this category to trigger them to be rechecked properly 22 | RecheckCategory = "recheck" 23 | 24 | # Ignore Torrents which are younger than this value (in seconds: 600 = 10 Minutes) 25 | # Only applicable to Re-check and failed categories 26 | IgnoreTorrentsYoungerThan = 180 27 | 28 | # URL to be pinged to check if you have a valid internet connection 29 | # These will be pinged a **LOT** make sure the service is okay with you sending all the continuous pings. 30 | PingURLS = ["one.one.one.one", "dns.google.com"] 31 | 32 | # FFprobe auto updates, binaries are downloaded from https://ffbinaries.com/downloads 33 | # If this is disabled and you want ffprobe to work 34 | # Ensure that you add the binary for your platform into ~/.config/qBitManager i.e "C:\Users\{User}\.config\qBitManager\ffprobe.exe" 35 | # If no `ffprobe` binary is found in the folder above all ffprobe functionality will be disabled. 36 | # By default this will always be on even if config does not have these key - to disable you need to explicitly set it to `False` 37 | FFprobeAutoUpdate = true 38 | 39 | [QBit] 40 | ## If this is enable qBitrr can run in a headless mode where it will only process searches. 41 | # If media search is enabled in their individual categories 42 | # This is useful if you use for example Sabnzbd for downloading content but still want the faster media searches provided by qbit 43 | Disabled = false 44 | 45 | # Qbit WebUI URL - Can be found in Options > Web UI (called "IP Address") 46 | Host = "localhost" 47 | 48 | # Qbit WebUI Port - Can be found in Options > Web UI (called "Port" on top right corner of the window) 49 | Port = 8105 50 | 51 | # Qbit WebUI Authentication - Can be found in Options > Web UI > Authentication 52 | UserName = "CHANGE_ME" 53 | 54 | # If you set "Bypass authentication on localhost or whitelisted IPs" remove this field. 55 | Password = "CHANGE_ME" 56 | 57 | 58 | [Sonarr-TV] 59 | # Toggle whether to manage the Servarr instance torrents. 60 | Managed = true 61 | 62 | # The URL used to access Servarr interface (if you use a domain enter the domain without a port) 63 | URI = "CHANGE_ME" 64 | 65 | # The Servarr API Key, Can be found it Settings > General > Security 66 | APIKey = "CHANGE_ME" 67 | 68 | # Category applied by Servarr to torrents in qBitTorrent, can be found in Settings > Download Clients > qBit > Category 69 | Category = "sonarr-tv" 70 | 71 | # Toggle whether to send a query to Servarr to search any failed torrents 72 | ReSearch = true 73 | 74 | # The Servarr's Import Mode(one of Move, Copy or Hardlink) 75 | importMode = "Move" 76 | 77 | # Timer to call RSSSync (In minutes) - Set to 0 to disable 78 | RssSyncTimer = 0 79 | 80 | # Timer to call RefreshDownloads tp update the queue. (In minutes) - Set to 0 to disable 81 | RefreshDownloadsTimer = 0 82 | 83 | # Error messages shown my the Arr instance which should be considered failures. 84 | # This entry should be a list, leave it empty if you want to disable this error handling. 85 | # If enabled qBitrr will remove the failed files and tell the Arr instance the download failed 86 | ArrErrorCodesToBlocklist = ["Not an upgrade for existing episode file(s)", "Not a preferred word upgrade for existing episode file(s)", "Unable to determine if file is a sample"] 87 | 88 | 89 | [Sonarr-TV.EntrySearch] 90 | # All these settings depends on SearchMissing being True and access to the Servarr database file. 91 | 92 | # Should search for Missing files? 93 | SearchMissing = false 94 | 95 | # Should search for specials episodes? (Season 00) 96 | AlsoSearchSpecials = false 97 | 98 | # Maximum allowed Searches at any one points (I wouldn't recommend settings this too high) 99 | # Sonarr has a hardcoded cap of 3 simultaneous tasks 100 | SearchLimit = 5 101 | 102 | # Servarr Datapath file path 103 | # This is required for any of the search functionality to work 104 | # The only exception for this is the "ReSearch" setting as that is done via an API call. 105 | DatabaseFile = "CHANGE_ME/sonarr.db" 106 | 107 | # It will order searches by the year the EPISODE was first aired 108 | SearchByYear = true 109 | 110 | # First year to search; Remove this field to set it to the current year. 111 | StartYear = 2021 112 | 113 | # Last Year to Search 114 | LastYear = 1990 115 | 116 | # Reverse search order (Start searching in "LastYear" and finish in "StartYear") 117 | SearchInReverse = false 118 | 119 | # Delay between request searches in seconds 120 | SearchRequestsEvery = 1800 121 | 122 | # Search movies which already have a file in the database in hopes of finding a better quality version. 123 | DoUpgradeSearch = false 124 | 125 | # Do a quality unmet search for existing entries. 126 | QualityUnmetSearch = false 127 | 128 | # Once you have search all files on your specified year range restart the loop and search again. 129 | SearchAgainOnSearchCompletion = false 130 | 131 | # Search by series instead of by episode 132 | SearchBySeries = true 133 | 134 | # Prioritize Today's releases (Similar effect as RSS Sync, where it searches today's release episodes first, only works on Sonarr). 135 | PrioritizeTodaysReleases = true 136 | 137 | 138 | [Sonarr-TV.EntrySearch.Ombi] 139 | # Search Ombi for pending requests (Will only work if 'SearchMissing' is enabled.) 140 | SearchOmbiRequests = false 141 | 142 | # Ombi URI (Note that this has to be the instance of Ombi which manage the Arr instance request (If you have multiple Ombi instances) 143 | OmbiURI = "http://localhost:5000" 144 | 145 | # Ombi's API Key 146 | OmbiAPIKey = "CHANGE_ME" 147 | 148 | # Only process approved requests 149 | ApprovedOnly = true 150 | 151 | 152 | [Sonarr-TV.EntrySearch.Overseerr] 153 | # Search Overseerr for pending requests (Will only work if 'SearchMissing' is enabled.) 154 | # If this and Ombi are both enable, Ombi will be ignored 155 | SearchOverseerrRequests = false 156 | 157 | # Overseerr's URI 158 | OverseerrURI = "http://localhost:5055" 159 | 160 | # Overseerr's API Key 161 | OverseerrAPIKey = "CHANGE_ME" 162 | 163 | # Only process approved requests 164 | ApprovedOnly = true 165 | 166 | 167 | [Sonarr-TV.Torrent] 168 | # Set it to regex matches to respect/ignore case. 169 | CaseSensitiveMatches = false 170 | 171 | # These regex values will match any folder where the full name matches the specified values here, comma separated strings. 172 | # These regex need to be escaped, that's why you see so many backslashes. 173 | FolderExclusionRegex = ["\\bextras?\\b", "\\bfeaturettes?\\b", "\\bsamples?\\b", "\\bscreens?\\b", "\\bspecials?\\b", "\\bova\\b", "\\bnc(ed|op)?(\\\\d+)?\\b"] 174 | 175 | # These regex values will match any folder where the full name matches the specified values here, comma separated strings. 176 | # These regex need to be escaped, that's why you see so many backslashes. 177 | FileNameExclusionRegex = ["\\bncop\\\\d+?\\b", "\\bnced\\\\d+?\\b", "\\bsample\\b", "brarbg.com\\b", "\\btrailer\\b", "music video", "comandotorrents.com"] 178 | 179 | # Only files with these extensions will be allowed to be downloaded, comma separated strings. 180 | FileExtensionAllowlist = [".mp4", ".mkv", ".sub", ".ass", ".srt", ".!qB", ".parts"] 181 | 182 | # Auto delete files that can't be playable (i.e .exe, .png) 183 | AutoDelete = false 184 | 185 | # Ignore Torrents which are younger than this value (in seconds: 600 = 10 Minutes) 186 | IgnoreTorrentsYoungerThan = 180 187 | 188 | # Maximum allowed remaining ETA for torrent completion (in seconds: 3600 = 1 Hour) 189 | # Note that if you set the MaximumETA on a tracker basis that value is favoured over this value 190 | MaximumETA = 18000 191 | 192 | # Do not delete torrents with higher completion percentage than this setting (0.5 = 50%, 1.0 = 100%) 193 | MaximumDeletablePercentage = 0.99 194 | 195 | # Ignore slow torrents. 196 | DoNotRemoveSlow = false 197 | 198 | Trackers = [] 199 | 200 | [Sonarr-TV.Torrent.SeedingMode] 201 | # Set the maximum allowed download rate for torrents 202 | # Set this value to -1 to disabled it 203 | # Note that if you set the DownloadRateLimit on a tracker basis that value is favoured over this value 204 | DownloadRateLimitPerTorrent = -1 205 | 206 | # Set the maximum allowed upload rate for torrents 207 | # Set this value to -1 to disabled it 208 | # Note that if you set the UploadRateLimit on a tracker basis that value is favoured over this value 209 | UploadRateLimitPerTorrent = -1 210 | 211 | # Set the maximum allowed download rate for torrents 212 | # Set this value to -1 to disabled it 213 | # Note that if you set the MaxUploadRatio on a tracker basis that value is favoured over this value 214 | MaxUploadRatio = -1 215 | 216 | # Set the maximum allowed download rate for torrents 217 | # Set this value to -1 to disabled it 218 | # Note that if you set the MaxSeedingTime on a tracker basis that value is favoured over this value 219 | MaxSeedingTime = -1 220 | 221 | # Set the Maximum allowed download rate for torrents 222 | RemoveDeadTrackers = false 223 | 224 | # If "RemoveDeadTrackers" is set to true then remove trackers with the following messages 225 | RemoveTrackerWithMessage = ["skipping tracker announce (unreachable)", "No such host is known", "unsupported URL protocol", "info hash is not authorized with this tracker"] 226 | 227 | # You can have multiple trackers set here or none just add more subsections. 228 | 229 | [Sonarr-Anime] 230 | # Toggle whether to manage the Servarr instance torrents. 231 | Managed = true 232 | 233 | # The URL used to access Servarr interface (if you use a domain enter the domain without a port) 234 | URI = "CHANGE_ME" 235 | 236 | # The Servarr API Key, Can be found it Settings > General > Security 237 | APIKey = "CHANGE_ME" 238 | 239 | # Category applied by Servarr to torrents in qBitTorrent, can be found in Settings > Download Clients > qBit > Category 240 | Category = "sonarr-anime" 241 | 242 | # Toggle whether to send a query to Servarr to search any failed torrents 243 | ReSearch = true 244 | 245 | # The Servarr's Import Mode(one of Move, Copy or Hardlink) 246 | importMode = "Move" 247 | 248 | # Timer to call RSSSync (In minutes) - Set to 0 to disable 249 | RssSyncTimer = 0 250 | 251 | # Timer to call RefreshDownloads tp update the queue. (In minutes) - Set to 0 to disable 252 | RefreshDownloadsTimer = 0 253 | 254 | # Error messages shown my the Arr instance which should be considered failures. 255 | # This entry should be a list, leave it empty if you want to disable this error handling. 256 | # If enabled qBitrr will remove the failed files and tell the Arr instance the download failed 257 | ArrErrorCodesToBlocklist = ["Not an upgrade for existing movie file(s)", "Not a preferred word upgrade for existing movie file(s)", "Unable to determine if file is a sample"] 258 | 259 | [Sonarr-Anime.EntrySearch] 260 | # All these settings depends on SearchMissing being True and access to the Servarr database file. 261 | 262 | # Should search for Missing files? 263 | SearchMissing = false 264 | 265 | # Should search for specials episodes? (Season 00) 266 | AlsoSearchSpecials = false 267 | 268 | # Maximum allowed Searches at any one points (I wouldn't recommend settings this too high) 269 | # Sonarr has a hardcoded cap of 3 simultaneous tasks 270 | SearchLimit = 5 271 | 272 | # Servarr Datapath file path 273 | # This is required for any of the search functionality to work 274 | # The only exception for this is the "ReSearch" setting as that is done via an API call. 275 | DatabaseFile = "CHANGE_ME/sonarr.db" 276 | 277 | # It will order searches by the year the EPISODE was first aired 278 | SearchByYear = true 279 | 280 | # First year to search; Remove this field to set it to the current year. 281 | StartYear = 2021 282 | 283 | # Last Year to Search 284 | LastYear = 1990 285 | 286 | # Reverse search order (Start searching in "LastYear" and finish in "StartYear") 287 | SearchInReverse = false 288 | 289 | # Delay between request searches in seconds 290 | SearchRequestsEvery = 1800 291 | 292 | # Search movies which already have a file in the database in hopes of finding a better quality version. 293 | DoUpgradeSearch = false 294 | 295 | # Do a quality unmet search for existing entries. 296 | QualityUnmetSearch = false 297 | 298 | # Once you have search all files on your specified year range restart the loop and search again. 299 | SearchAgainOnSearchCompletion = false 300 | 301 | # Search by series instead of by episode 302 | SearchBySeries = true 303 | 304 | # Prioritize Today's releases (Similar effect as RSS Sync, where it searches today's release episodes first, only works on Sonarr). 305 | PrioritizeTodaysReleases = true 306 | 307 | 308 | [Sonarr-Anime.EntrySearch.Ombi] 309 | # Search Ombi for pending requests (Will only work if 'SearchMissing' is enabled.) 310 | SearchOmbiRequests = false 311 | 312 | # Ombi URI (Note that this has to be the instance of Ombi which manage the Arr instance request (If you have multiple Ombi instances) 313 | OmbiURI = "http://localhost:5000" 314 | 315 | # Ombi's API Key 316 | OmbiAPIKey = "CHANGE_ME" 317 | 318 | # Only process approved requests 319 | ApprovedOnly = true 320 | 321 | 322 | [Sonarr-Anime.EntrySearch.Overseerr] 323 | # Search Overseerr for pending requests (Will only work if 'SearchMissing' is enabled.) 324 | # If this and Ombi are both enable, Ombi will be ignored 325 | SearchOverseerrRequests = false 326 | 327 | # Overseerr's URI 328 | OverseerrURI = "http://localhost:5055" 329 | 330 | # Overseerr's API Key 331 | OverseerrAPIKey = "CHANGE_ME" 332 | 333 | # Only process approved requests 334 | ApprovedOnly = true 335 | 336 | 337 | [Sonarr-Anime.Torrent] 338 | # Set it to regex matches to respect/ignore case. 339 | CaseSensitiveMatches = false 340 | 341 | # These regex values will match any folder where the full name matches the specified values here, comma separated strings. 342 | # These regex need to be escaped, that's why you see so many backslashes. 343 | FolderExclusionRegex = ["\\bextras?\\b", "\\bfeaturettes?\\b", "\\bsamples?\\b", "\\bscreens?\\b", "\\bnc(ed|op)?(\\\\d+)?\\b"] 344 | 345 | # These regex values will match any folder where the full name matches the specified values here, comma separated strings. 346 | # These regex need to be escaped, that's why you see so many backslashes. 347 | FileNameExclusionRegex = ["\\bncop\\\\d+?\\b", "\\bnced\\\\d+?\\b", "\\bsample\\b", "brarbg.com\\b", "\\btrailer\\b", "music video", "comandotorrents.com"] 348 | 349 | # Only files with these extensions will be allowed to be downloaded, comma separated strings. 350 | FileExtensionAllowlist = [".mp4", ".mkv", ".sub", ".ass", ".srt", ".!qB", ".parts"] 351 | 352 | # Auto delete files that can't be playable (i.e .exe, .png) 353 | AutoDelete = false 354 | 355 | # Ignore Torrents which are younger than this value (in seconds: 600 = 10 Minutes) 356 | IgnoreTorrentsYoungerThan = 180 357 | 358 | # Maximum allowed remaining ETA for torrent completion (in seconds: 3600 = 1 Hour) 359 | # Note that if you set the MaximumETA on a tracker basis that value is favoured over this value 360 | MaximumETA = 18000 361 | 362 | # Do not delete torrents with higher completion percentage than this setting (0.5 = 50%, 1.0 = 100%) 363 | MaximumDeletablePercentage = 0.99 364 | 365 | # Ignore slow torrents. 366 | DoNotRemoveSlow = false 367 | 368 | 369 | [Sonarr-Anime.Torrent.SeedingMode] 370 | # Set the maximum allowed download rate for torrents 371 | # Set this value to -1 to disabled it 372 | # Note that if you set the DownloadRateLimit on a tracker basis that value is favoured over this value 373 | DownloadRateLimitPerTorrent = -1 374 | 375 | # Set the maximum allowed upload rate for torrents 376 | # Set this value to -1 to disabled it 377 | # Note that if you set the UploadRateLimit on a tracker basis that value is favoured over this value 378 | UploadRateLimitPerTorrent = -1 379 | 380 | # Set the maximum allowed download rate for torrents 381 | # Set this value to -1 to disabled it 382 | # Note that if you set the MaxUploadRatio on a tracker basis that value is favoured over this value 383 | MaxUploadRatio = -1 384 | 385 | # Set the maximum allowed download rate for torrents 386 | # Set this value to -1 to disabled it 387 | # Note that if you set the MaxSeedingTime on a tracker basis that value is favoured over this value 388 | MaxSeedingTime = -1 389 | 390 | # Set the Maximum allowed download rate for torrents 391 | RemoveDeadTrackers = false 392 | 393 | # If "RemoveDeadTrackers" is set to true then remove trackers with the following messages 394 | RemoveTrackerWithMessage = ["skipping tracker announce (unreachable)", "No such host is known", "unsupported URL protocol", "info hash is not authorized with this tracker"] 395 | 396 | # You can have multiple trackers set here or none just add more subsections. 397 | 398 | [[Sonarr-Anime.Torrent.Trackers]] 399 | Name = "Nyaa" 400 | Priority = 10 401 | URI = "http://nyaa.tracker.wf:7777/announce" 402 | MaximumETA = 18000 403 | 404 | # For all Rates 0 disables the feature, -1 sets it to no limits 405 | DownloadRateLimit = -1 406 | UploadRateLimit = -1 407 | MaxUploadRatio = -1 408 | MaxSeedingTime = -1 409 | AddTrackerIfMissing = false 410 | RemoveIfExists = false 411 | SuperSeedMode = false 412 | AddTags = ["qbitrr-anime"] 413 | 414 | [Radarr-1080] 415 | # Toggle whether to manage the Servarr instance torrents. 416 | Managed = true 417 | 418 | # The URL used to access Servarr interface (if you use a domain enter the domain without a port) 419 | URI = "CHANGE_ME" 420 | 421 | # The Servarr API Key, Can be found it Settings > General > Security 422 | APIKey = "CHANGE_ME" 423 | 424 | # Category applied by Servarr to torrents in qBitTorrent, can be found in Settings > Download Clients > qBit > Category 425 | Category = "radarr-1080" 426 | 427 | # Toggle whether to send a query to Servarr to search any failed torrents 428 | ReSearch = true 429 | 430 | # The Servarr's Import Mode(one of Move, Copy or Hardlink) 431 | importMode = "Move" 432 | 433 | # Timer to call RSSSync (In minutes) - Set to 0 to disable 434 | RssSyncTimer = 0 435 | 436 | # Timer to call RefreshDownloads tp update the queue. (In minutes) - Set to 0 to disable 437 | RefreshDownloadsTimer = 0 438 | 439 | # Error messages shown my the Arr instance which should be considered failures. 440 | # This entry should be a list, leave it empty if you want to disable this error handling. 441 | # If enabled qBitrr will remove the failed files and tell the Arr instance the download failed 442 | ArrErrorCodesToBlocklist = ["Not an upgrade for existing movie file(s)", "Not a preferred word upgrade for existing movie file(s)", "Unable to determine if file is a sample"] 443 | 444 | [Radarr-1080.EntrySearch] 445 | # All these settings depends on SearchMissing being True and access to the Servarr database file. 446 | 447 | # Should search for Missing files? 448 | SearchMissing = false 449 | 450 | # Should search for specials episodes? (Season 00) 451 | AlsoSearchSpecials = false 452 | 453 | # Maximum allowed Searches at any one points (I wouldn't recommend settings this too high) 454 | # Radarr has a default of 3 simultaneous tasks, which can be increased up to 10 tasks 455 | # If you set the environment variable of "THREAD_LIMIT" to a number between and including 2-10 456 | # Radarr devs have stated that this is an unsupported feature so you will not get any support for doing so from them. 457 | # That being said I've been daily driving 10 simultaneous tasks for quite a while now with no issues. 458 | SearchLimit = 5 459 | 460 | # Servarr Datapath file path 461 | # This is required for any of the search functionality to work 462 | # The only exception for this is the "ReSearch" setting as that is done via an API call. 463 | DatabaseFile = "CHANGE_ME/radarr.db" 464 | 465 | # It will order searches by the year the EPISODE was first aired 466 | SearchByYear = true 467 | 468 | # First year to search; Remove this field to set it to the current year. 469 | StartYear = 2021 470 | 471 | # Last Year to Search 472 | LastYear = 1990 473 | 474 | # Reverse search order (Start searching in "LastYear" and finish in "StartYear") 475 | SearchInReverse = false 476 | 477 | # Delay between request searches in seconds 478 | SearchRequestsEvery = 1800 479 | 480 | # Search movies which already have a file in the database in hopes of finding a better quality version. 481 | DoUpgradeSearch = false 482 | 483 | # Do a quality unmet search for existing entries. 484 | QualityUnmetSearch = false 485 | 486 | # Once you have search all files on your specified year range restart the loop and search again. 487 | SearchAgainOnSearchCompletion = false 488 | 489 | 490 | [Radarr-1080.EntrySearch.Ombi] 491 | # Search Ombi for pending requests (Will only work if 'SearchMissing' is enabled.) 492 | SearchOmbiRequests = false 493 | 494 | # Ombi URI (Note that this has to be the instance of Ombi which manage the Arr instance request (If you have multiple Ombi instances) 495 | OmbiURI = "http://localhost:5000" 496 | 497 | # Ombi's API Key 498 | OmbiAPIKey = "CHANGE_ME" 499 | 500 | # Only process approved requests 501 | ApprovedOnly = true 502 | 503 | 504 | [Radarr-1080.EntrySearch.Overseerr] 505 | # Search Overseerr for pending requests (Will only work if 'SearchMissing' is enabled.) 506 | # If this and Ombi are both enable, Ombi will be ignored 507 | SearchOverseerrRequests = false 508 | 509 | # Overseerr's URI 510 | OverseerrURI = "http://localhost:5055" 511 | 512 | # Overseerr's API Key 513 | OverseerrAPIKey = "CHANGE_ME" 514 | 515 | # Only process approved requests 516 | ApprovedOnly = true 517 | 518 | 519 | [Radarr-1080.Torrent] 520 | # Set it to regex matches to respect/ignore case. 521 | CaseSensitiveMatches = false 522 | 523 | # These regex values will match any folder where the full name matches the specified values here, comma separated strings. 524 | # These regex need to be escaped, that's why you see so many backslashes. 525 | FolderExclusionRegex = ["\\bextras?\\b", "\\bfeaturettes?\\b", "\\bsamples?\\b", "\\bscreens?\\b", "\\bspecials?\\b", "\\bova\\b", "\\bnc(ed|op)?(\\\\d+)?\\b"] 526 | 527 | # These regex values will match any folder where the full name matches the specified values here, comma separated strings. 528 | # These regex need to be escaped, that's why you see so many backslashes. 529 | FileNameExclusionRegex = ["\\bncop\\\\d+?\\b", "\\bnced\\\\d+?\\b", "\\bsample\\b", "brarbg.com\\b", "\\btrailer\\b", "music video", "comandotorrents.com"] 530 | 531 | # Only files with these extensions will be allowed to be downloaded, comma separated strings. 532 | FileExtensionAllowlist = [".mp4", ".mkv", ".sub", ".ass", ".srt", ".!qB", ".parts"] 533 | 534 | # Auto delete files that can't be playable (i.e .exe, .png) 535 | AutoDelete = false 536 | 537 | # Ignore Torrents which are younger than this value (in seconds: 600 = 10 Minutes) 538 | IgnoreTorrentsYoungerThan = 180 539 | 540 | # Maximum allowed remaining ETA for torrent completion (in seconds: 3600 = 1 Hour) 541 | # Note that if you set the MaximumETA on a tracker basis that value is favoured over this value 542 | MaximumETA = 18000 543 | 544 | # Do not delete torrents with higher completion percentage than this setting (0.5 = 50%, 1.0 = 100%) 545 | MaximumDeletablePercentage = 0.99 546 | 547 | # Ignore slow torrents. 548 | DoNotRemoveSlow = false 549 | 550 | 551 | [Radarr-1080.Torrent.SeedingMode] 552 | # Set the maximum allowed download rate for torrents 553 | # Set this value to -1 to disabled it 554 | # Note that if you set the DownloadRateLimit on a tracker basis that value is favoured over this value 555 | DownloadRateLimitPerTorrent = -1 556 | 557 | # Set the maximum allowed upload rate for torrents 558 | # Set this value to -1 to disabled it 559 | # Note that if you set the UploadRateLimit on a tracker basis that value is favoured over this value 560 | UploadRateLimitPerTorrent = -1 561 | 562 | # Set the maximum allowed download rate for torrents 563 | # Set this value to -1 to disabled it 564 | # Note that if you set the MaxUploadRatio on a tracker basis that value is favoured over this value 565 | MaxUploadRatio = -1 566 | 567 | # Set the maximum allowed download rate for torrents 568 | # Set this value to -1 to disabled it 569 | # Note that if you set the MaxSeedingTime on a tracker basis that value is favoured over this value 570 | MaxSeedingTime = -1 571 | 572 | # Set the Maximum allowed download rate for torrents 573 | RemoveDeadTrackers = false 574 | 575 | # If "RemoveDeadTrackers" is set to true then remove trackers with the following messages 576 | RemoveTrackerWithMessage = ["skipping tracker announce (unreachable)", "No such host is known", "unsupported URL protocol", "info hash is not authorized with this tracker"] 577 | 578 | # You can have multiple trackers set here or none just add more subsections. 579 | 580 | [[Radarr-1080.Torrent.Trackers]] 581 | Name = "Rarbg-2810" 582 | Priority = 1 583 | URI = "udp://9.rarbg.com:2810/announce" 584 | MaximumETA = 18000 585 | # For all Rates 0 disables the feature, -1 sets it to no limits 586 | DownloadRateLimit = -1 587 | UploadRateLimit = -1 588 | MaxUploadRatio = -1 589 | MaxSeedingTime = -1 590 | AddTrackerIfMissing = false 591 | RemoveIfExists = false 592 | SuperSeedMode = false 593 | AddTags = ["qbitrr-Rarbg", "Movies and TV"] 594 | 595 | [[Radarr-1080.Torrent.Trackers]] 596 | Name = "Rarbg-2740" 597 | Priority = 2 598 | URI = "udp://9.rarbg.to:2740/announce" 599 | MaximumETA = 18000 600 | # For all Rates 0 disables the feature, -1 sets it to no limits 601 | DownloadRateLimit = -1 602 | UploadRateLimit = -1 603 | MaxUploadRatio = -1 604 | MaxSeedingTime = -1 605 | AddTrackerIfMissing = false 606 | RemoveIfExists = false 607 | SuperSeedMode = false 608 | 609 | [Radarr-4K] 610 | # Toggle whether to manage the Servarr instance torrents. 611 | Managed = true 612 | 613 | # The URL used to access Servarr interface (if you use a domain enter the domain without a port) 614 | URI = "CHANGE_ME" 615 | 616 | # The Servarr API Key, Can be found it Settings > General > Security 617 | APIKey = "CHANGE_ME" 618 | 619 | # Category applied by Servarr to torrents in qBitTorrent, can be found in Settings > Download Clients > qBit > Category 620 | Category = "radarr-4k" 621 | 622 | # Toggle whether to send a query to Servarr to search any failed torrents 623 | ReSearch = true 624 | 625 | # The Servarr's Import Mode(one of Move, Copy or Hardlink) 626 | importMode = "Move" 627 | 628 | # Timer to call RSSSync (In minutes) - Set to 0 to disable 629 | RssSyncTimer = 0 630 | 631 | # Timer to call RefreshDownloads tp update the queue. (In minutes) - Set to 0 to disable 632 | RefreshDownloadsTimer = 0 633 | 634 | # Error messages shown my the Arr instance which should be considered failures. 635 | # This entry should be a list, leave it empty if you want to disable this error handling. 636 | # If enabled qBitrr will remove the failed files and tell the Arr instance the download failed 637 | ArrErrorCodesToBlocklist = ["Not an upgrade for existing movie file(s)", "Not a preferred word upgrade for existing movie file(s)", "Unable to determine if file is a sample"] 638 | 639 | [Radarr-4K.EntrySearch] 640 | # All these settings depends on SearchMissing being True and access to the Servarr database file. 641 | 642 | # Should search for Missing files? 643 | SearchMissing = false 644 | 645 | # Should search for specials episodes? (Season 00) 646 | AlsoSearchSpecials = false 647 | 648 | # Maximum allowed Searches at any one points (I wouldn't recommend settings this too high) 649 | # Radarr has a default of 3 simultaneous tasks, which can be increased up to 10 tasks 650 | # If you set the environment variable of "THREAD_LIMIT" to a number between and including 2-10 651 | # Radarr devs have stated that this is an unsupported feature so you will not get any support for doing so from them. 652 | # That being said I've been daily driving 10 simultaneous tasks for quite a while now with no issues. 653 | SearchLimit = 5 654 | 655 | # Servarr Datapath file path 656 | # This is required for any of the search functionality to work 657 | # The only exception for this is the "ReSearch" setting as that is done via an API call. 658 | DatabaseFile = "CHANGE_ME/radarr.db" 659 | 660 | # It will order searches by the year the EPISODE was first aired 661 | SearchByYear = true 662 | 663 | # First year to search; Remove this field to set it to the current year. 664 | StartYear = 2021 665 | 666 | # Last Year to Search 667 | LastYear = 1990 668 | 669 | # Reverse search order (Start searching in "LastYear" and finish in "StartYear") 670 | SearchInReverse = false 671 | 672 | # Delay between request searches in seconds 673 | SearchRequestsEvery = 1800 674 | 675 | # Search movies which already have a file in the database in hopes of finding a better quality version. 676 | DoUpgradeSearch = false 677 | 678 | # Do a quality unmet search for existing entries. 679 | QualityUnmetSearch = false 680 | 681 | # Once you have search all files on your specified year range restart the loop and search again. 682 | SearchAgainOnSearchCompletion = false 683 | 684 | 685 | [Radarr-4K.EntrySearch.Ombi] 686 | # Search Ombi for pending requests (Will only work if 'SearchMissing' is enabled.) 687 | SearchOmbiRequests = false 688 | 689 | # Ombi URI (Note that this has to be the instance of Ombi which manage the Arr instance request (If you have multiple Ombi instances) 690 | OmbiURI = "http://localhost:5000" 691 | 692 | # Ombi's API Key 693 | OmbiAPIKey = "CHANGE_ME" 694 | 695 | # Only process approved requests 696 | ApprovedOnly = true 697 | 698 | 699 | [Radarr-4K.EntrySearch.Overseerr] 700 | # Search Overseerr for pending requests (Will only work if 'SearchMissing' is enabled.) 701 | # If this and Ombi are both enable, Ombi will be ignored 702 | SearchOverseerrRequests = false 703 | 704 | # Overseerr's URI 705 | OverseerrURI = "http://localhost:5055" 706 | 707 | # Overseerr's API Key 708 | OverseerrAPIKey = "CHANGE_ME" 709 | 710 | # Only process approved requests 711 | ApprovedOnly = true 712 | 713 | 714 | [Radarr-4K.Torrent] 715 | # Set it to regex matches to respect/ignore case. 716 | CaseSensitiveMatches = false 717 | 718 | # These regex values will match any folder where the full name matches the specified values here, comma separated strings. 719 | # These regex need to be escaped, that's why you see so many backslashes. 720 | FolderExclusionRegex = ["\\bextras?\\b", "\\bfeaturettes?\\b", "\\bsamples?\\b", "\\bscreens?\\b", "\\bspecials?\\b", "\\bova\\b", "\\bnc(ed|op)?(\\\\d+)?\\b"] 721 | 722 | # These regex values will match any folder where the full name matches the specified values here, comma separated strings. 723 | # These regex need to be escaped, that's why you see so many backslashes. 724 | FileNameExclusionRegex = ["\\bncop\\\\d+?\\b", "\\bnced\\\\d+?\\b", "\\bsample\\b", "brarbg.com\\b", "\\btrailer\\b", "music video", "comandotorrents.com"] 725 | 726 | # Only files with these extensions will be allowed to be downloaded, comma separated strings. 727 | FileExtensionAllowlist = [".mp4", ".mkv", ".sub", ".ass", ".srt", ".!qB", ".parts"] 728 | 729 | # Auto delete files that can't be playable (i.e .exe, .png) 730 | AutoDelete = false 731 | 732 | # Ignore Torrents which are younger than this value (in seconds: 600 = 10 Minutes) 733 | IgnoreTorrentsYoungerThan = 180 734 | 735 | # Maximum allowed remaining ETA for torrent completion (in seconds: 3600 = 1 Hour) 736 | # Note that if you set the MaximumETA on a tracker basis that value is favoured over this value 737 | MaximumETA = 18000 738 | 739 | # Do not delete torrents with higher completion percentage than this setting (0.5 = 50%, 1.0 = 100%) 740 | MaximumDeletablePercentage = 0.99 741 | 742 | # Ignore slow torrents. 743 | DoNotRemoveSlow = false 744 | 745 | 746 | [Radarr-4K.Torrent.SeedingMode] 747 | # Set the maximum allowed download rate for torrents 748 | # Set this value to -1 to disabled it 749 | # Note that if you set the DownloadRateLimit on a tracker basis that value is favoured over this value 750 | DownloadRateLimitPerTorrent = -1 751 | 752 | # Set the maximum allowed upload rate for torrents 753 | # Set this value to -1 to disabled it 754 | # Note that if you set the UploadRateLimit on a tracker basis that value is favoured over this value 755 | UploadRateLimitPerTorrent = -1 756 | 757 | # Set the maximum allowed download rate for torrents 758 | # Set this value to -1 to disabled it 759 | # Note that if you set the MaxUploadRatio on a tracker basis that value is favoured over this value 760 | MaxUploadRatio = -1 761 | 762 | # Set the maximum allowed download rate for torrents 763 | # Set this value to -1 to disabled it 764 | # Note that if you set the MaxSeedingTime on a tracker basis that value is favoured over this value 765 | MaxSeedingTime = -1 766 | 767 | # Set the Maximum allowed download rate for torrents 768 | RemoveDeadTrackers = false 769 | 770 | # If "RemoveDeadTrackers" is set to true then remove trackers with the following messages 771 | RemoveTrackerWithMessage = ["skipping tracker announce (unreachable)", "No such host is known", "unsupported URL protocol", "info hash is not authorized with this tracker"] 772 | 773 | # You can have multiple trackers set here or none just add more subsections. 774 | 775 | [[Radarr-4K.Torrent.Trackers]] 776 | Name = "Rarbg-2810" 777 | Priority = 1 778 | URI = "udp://9.rarbg.com:2810/announce" 779 | MaximumETA = 18000 780 | # For all Rates 0 disables the feature, -1 sets it to no limits 781 | DownloadRateLimit = -1 782 | UploadRateLimit = -1 783 | MaxUploadRatio = -1 784 | MaxSeedingTime = -1 785 | AddTrackerIfMissing = false 786 | RemoveIfExists = false 787 | SuperSeedMode = false 788 | AddTags = ["qbitrr-Rarbg", "Movies and TV", "4K"] 789 | 790 | [[Radarr-4K.Torrent.Trackers]] 791 | Name = "Rarbg-2740" 792 | Priority = 2 793 | URI = "udp://9.rarbg.to:2740/announce" 794 | MaximumETA = 18000 795 | # For all Rates 0 disables the feature, -1 sets it to no limits 796 | DownloadRateLimit = -1 797 | UploadRateLimit = -1 798 | MaxUploadRatio = -1 799 | MaxSeedingTime = -1 800 | AddTrackerIfMissing = false 801 | RemoveIfExists = false 802 | SuperSeedMode = false 803 | AddTags = ["4K"] 804 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.isort] 2 | profile = "black" 3 | line_length = 99 4 | py_version = 38 5 | known_third_party=["cachetools", "colorama", "coloredlogs", "environ", "ffmpeg", "jaraco.docker", "packaging", "pathos", "peewee", "ping3", "pyarr", "qbittorrentapi", "requests", "tomlkit", "ujson"] 6 | known_local_folder=["qBitrr"] 7 | 8 | [tool.black] 9 | line-length = 99 10 | target-version = ['py38'] 11 | 12 | 13 | [tool.autopep8] 14 | max_line_length = 99 15 | ignore = "E712" 16 | in-place = true 17 | recursive = true 18 | aggressive = 3 19 | -------------------------------------------------------------------------------- /qBitrr/__init__.py: -------------------------------------------------------------------------------- 1 | import platform 2 | 3 | import qBitrr.logger # noqa 4 | 5 | try: 6 | if platform.python_implementation() == "CPython": 7 | # Only replace complexjson on CPython 8 | # On PyPy it shows a SystemError when attempting to 9 | # decode the responses from qbittorrentapi 10 | import requests 11 | import ujson 12 | 13 | requests.models.complexjson = ujson 14 | except ImportError: 15 | pass 16 | -------------------------------------------------------------------------------- /qBitrr/arr_tables.py: -------------------------------------------------------------------------------- 1 | from peewee import BooleanField, DateTimeField, IntegerField, Model, TextField 2 | 3 | 4 | class CommandsModel(Model): 5 | Id = IntegerField() 6 | Name = TextField() 7 | Body = TextField() 8 | Priority = IntegerField() 9 | Status = IntegerField() 10 | QueuedAt = DateTimeField(formats=["%Y-%m-%d %H:%M:%S.%f"]) 11 | StartedAt = DateTimeField(formats=["%Y-%m-%d %H:%M:%S.%f"]) 12 | EndedAt = DateTimeField(formats=["%Y-%m-%d %H:%M:%S.%f"]) 13 | Duration = TextField() 14 | Exception = TextField() 15 | Trigger = TextField() 16 | 17 | 18 | class MoviesModel(Model): 19 | Id = IntegerField() 20 | ImdbId = IntegerField() 21 | Title = TextField() 22 | TitleSlug = TextField() 23 | SortTitle = TextField() 24 | CleanTitle = TextField() 25 | Status = IntegerField() 26 | Overview = TextField() 27 | Images = TextField() 28 | Path = TextField() 29 | Monitored = BooleanField() 30 | ProfileId = IntegerField() 31 | LastInfoSync = DateTimeField(formats=["%Y-%m-%d %H:%M:%S.%f"]) 32 | LastDiskSync = DateTimeField(formats=["%Y-%m-%d %H:%M:%S.%f"]) 33 | Runtime = IntegerField() 34 | InCinemas = DateTimeField(formats=["%Y-%m-%d %H:%M:%S.%f"]) 35 | Year = IntegerField() 36 | Added = DateTimeField(formats=["%Y-%m-%d %H:%M:%S.%f"]) 37 | Ratings = TextField() 38 | Genres = TextField() 39 | Tags = TextField() 40 | Certification = TextField() 41 | AddOptions = TextField() 42 | MovieFileId = IntegerField() 43 | TmdbId = IntegerField() 44 | PhysicalRelease = DateTimeField(formats=["%Y-%m-%d %H:%M:%S.%f"]) 45 | YouTubeTrailerId = TextField() 46 | Studio = TextField() 47 | MinimumAvailability = IntegerField() 48 | # HasPreDBEntry = IntegerField() 49 | SecondaryYear = IntegerField() 50 | Collection = TextField() 51 | Recommendations = TextField() 52 | OriginalTitle = IntegerField() 53 | DigitalRelease = DateTimeField(formats=["%Y-%m-%d %H:%M:%S.%f"]) 54 | 55 | 56 | class EpisodesModel(Model): 57 | 58 | Id = IntegerField(null=False, primary_key=True) 59 | SeriesId = IntegerField(null=False) 60 | SeasonNumber = IntegerField(null=False) 61 | EpisodeNumber = IntegerField(null=False) 62 | 63 | Title = TextField() 64 | Overview = TextField() 65 | 66 | EpisodeFileId = IntegerField() 67 | AbsoluteEpisodeNumber = IntegerField() 68 | SceneAbsoluteEpisodeNumber = IntegerField() 69 | SceneEpisodeNumber = IntegerField() 70 | SceneSeasonNumber = IntegerField() 71 | Monitored = BooleanField() 72 | 73 | AirDateUtc = DateTimeField(formats=["%Y-%m-%d %H:%M:%S.%f"]) 74 | 75 | AirDate = TextField() 76 | Ratings = TextField() 77 | Images = TextField() 78 | UnverifiedSceneNumbering = BooleanField(null=False, default=False) 79 | LastSearchTime = DateTimeField(formats=["%Y-%m-%d %H:%M:%S.%f"]) 80 | 81 | AiredAfterSeasonNumber = IntegerField() 82 | AiredBeforeSeasonNumber = IntegerField() 83 | AiredBeforeEpisodeNumber = IntegerField() 84 | 85 | 86 | class SeriesModel(Model): 87 | Id = IntegerField() 88 | TvdbId = IntegerField() 89 | TvRageId = IntegerField() 90 | ImdbId = TextField() 91 | Title = TextField() 92 | TitleSlug = TextField() 93 | CleanTitle = TextField() 94 | Status = IntegerField() 95 | Overview = TextField() 96 | AirTime = TextField() 97 | Images = TextField() 98 | Path = TextField() 99 | Monitored = BooleanField() 100 | SeasonFolder = TextField() 101 | LastInfoSync = DateTimeField(formats=["%Y-%m-%d %H:%M:%S.%f"]) 102 | LastDiskSync = DateTimeField(formats=["%Y-%m-%d %H:%M:%S.%f"]) 103 | Runtime = IntegerField() 104 | SeriesType = IntegerField() 105 | Network = IntegerField() 106 | UseSceneNumbering = BooleanField() 107 | FirstAired = DateTimeField(formats=["%Y-%m-%d %H:%M:%S.%f"]) 108 | NextAiring = DateTimeField(formats=["%Y-%m-%d %H:%M:%S.%f"]) 109 | Year = IntegerField() 110 | Seasons = TextField() 111 | Actors = TextField() 112 | Ratings = TextField() 113 | Genres = TextField() 114 | Certification = TextField() 115 | SortTitle = TextField() 116 | QualityProfileId = IntegerField() 117 | Tags = TextField() 118 | Added = DateTimeField(formats=["%Y-%m-%d %H:%M:%S.%f"]) 119 | AddOptions = TextField() 120 | TvMazeId = IntegerField() 121 | LanguageProfileId = IntegerField() 122 | -------------------------------------------------------------------------------- /qBitrr/bundled_data.py: -------------------------------------------------------------------------------- 1 | version = "2.6.0" 2 | git_hash = "95345b0" 3 | license_text = ( 4 | "Licence can be found on:\n\nhttps://github.com/Drapersniper/Qbitrr/blob/master/LICENSE" 5 | ) 6 | patched_version = f"{version}-{git_hash}" 7 | -------------------------------------------------------------------------------- /qBitrr/config.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import argparse 4 | import contextlib 5 | import pathlib 6 | import shutil 7 | import sys 8 | 9 | from qBitrr.bundled_data import license_text, patched_version 10 | from qBitrr.env_config import ENVIRO_CONFIG 11 | from qBitrr.gen_config import MyConfig, generate_doc 12 | from qBitrr.home_path import HOME_PATH, ON_DOCKER 13 | 14 | APPDATA_FOLDER = HOME_PATH.joinpath(".config", "qBitManager") 15 | APPDATA_FOLDER.mkdir(parents=True, exist_ok=True) 16 | 17 | 18 | def process_flags() -> argparse.Namespace | bool: 19 | parser = argparse.ArgumentParser(description="An interface to interact with qBit and *arrs.") 20 | parser.add_argument( 21 | "--gen-config", 22 | "-gc", 23 | dest="gen_config", 24 | help="Generate a config file in the current working directory", 25 | action="store_true", 26 | ) 27 | parser.add_argument( 28 | "-v", "--version", action="version", version=f"qBitrr version: {patched_version}" 29 | ) 30 | 31 | parser.add_argument( 32 | "-l", 33 | "--license", 34 | dest="license", 35 | action="store_const", 36 | const=license_text, 37 | help="Show the qBitrr's licence", 38 | ) 39 | parser.add_argument( 40 | "-s", 41 | "--source", 42 | action="store_const", 43 | dest="source", 44 | const="Source code can be found on: https://github.com/Drapersniper/Qbitrr", 45 | help="Shows a link to qBitrr's source", 46 | ) 47 | 48 | args = parser.parse_args() 49 | 50 | if args.gen_config: 51 | from qBitrr.gen_config import _write_config_file 52 | 53 | _write_config_file() 54 | return True 55 | elif args.license: 56 | print(args.license) 57 | return True 58 | elif args.source: 59 | print(args.source) 60 | return True 61 | return args 62 | 63 | 64 | COPIED_TO_NEW_DIR = False 65 | file = "config.toml" 66 | CONFIG_FILE = APPDATA_FOLDER.joinpath(file) 67 | CONFIG_PATH = pathlib.Path(f"./{file}") 68 | if any( 69 | a in sys.argv 70 | for a in [ 71 | "--gen-config", 72 | "-gc", 73 | "--version", 74 | "-v", 75 | "--license", 76 | "-l", 77 | "--source", 78 | "-s", 79 | "-h", 80 | "--help", 81 | ] 82 | ): 83 | CONFIG = MyConfig(CONFIG_FILE, config=generate_doc()) 84 | COPIED_TO_NEW_DIR = None 85 | elif (not CONFIG_FILE.exists()) and (not CONFIG_PATH.exists()): 86 | if ON_DOCKER: 87 | print(f"{file} has not been found") 88 | from qBitrr.gen_config import _write_config_file 89 | 90 | CONFIG_FILE = _write_config_file(docker=True) 91 | print(f"'{CONFIG_FILE.name}' has been generated") 92 | print('Rename it to "config.toml" then edit it and restart the container') 93 | else: 94 | print(f"{file} has not been found") 95 | print(f"{file} must be added to {CONFIG_FILE}") 96 | print( 97 | "You can run me with the `--gen-config` flag to generate a " 98 | "template config file which you can then edit." 99 | ) 100 | sys.exit(1) 101 | 102 | elif CONFIG_FILE.exists(): 103 | CONFIG = MyConfig(CONFIG_FILE) 104 | else: 105 | with contextlib.suppress( 106 | Exception 107 | ): # If file already exist or can't copy to APPDATA_FOLDER ignore the exception 108 | shutil.copy(CONFIG_PATH, CONFIG_FILE) 109 | COPIED_TO_NEW_DIR = True 110 | CONFIG = MyConfig("./config.toml") 111 | 112 | if COPIED_TO_NEW_DIR is not None: 113 | print(f"STARTUP | {CONFIG.path} |\n{CONFIG}") 114 | else: 115 | print(f"STARTUP | CONFIG_FILE={CONFIG_FILE} | CONFIG_PATH={CONFIG_PATH}") 116 | 117 | FFPROBE_AUTO_UPDATE = ( 118 | CONFIG.get("Settings.FFprobeAutoUpdate", fallback=True) 119 | if ENVIRO_CONFIG.settings.ffprobe_auto_update is None 120 | else ENVIRO_CONFIG.settings.ffprobe_auto_update 121 | ) 122 | FAILED_CATEGORY = ENVIRO_CONFIG.settings.failed_category or CONFIG.get( 123 | "Settings.FailedCategory", fallback="failed" 124 | ) 125 | RECHECK_CATEGORY = ENVIRO_CONFIG.settings.recheck_category or CONFIG.get( 126 | "Settings.RecheckCategory", fallback="recheck" 127 | ) 128 | CONSOLE_LOGGING_LEVEL_STRING = ENVIRO_CONFIG.settings.console_level or CONFIG.get_or_raise( 129 | "Settings.ConsoleLevel" 130 | ) 131 | COMPLETED_DOWNLOAD_FOLDER = ( 132 | ENVIRO_CONFIG.settings.completed_download_folder 133 | or CONFIG.get_or_raise("Settings.CompletedDownloadFolder") 134 | ) 135 | NO_INTERNET_SLEEP_TIMER = ENVIRO_CONFIG.settings.no_internet_sleep_timer or CONFIG.get( 136 | "Settings.NoInternetSleepTimer", fallback=60 137 | ) 138 | LOOP_SLEEP_TIMER = ENVIRO_CONFIG.settings.loop_sleep_timer or CONFIG.get( 139 | "Settings.LoopSleepTimer", fallback=5 140 | ) 141 | PING_URLS = ENVIRO_CONFIG.settings.ping_urls or CONFIG.get( 142 | "Settings.PingURLS", fallback=["one.one.one.one", "dns.google.com"] 143 | ) 144 | IGNORE_TORRENTS_YOUNGER_THAN = ENVIRO_CONFIG.settings.ignore_torrents_younger_than or CONFIG.get( 145 | "Settings.IgnoreTorrentsYoungerThan", fallback=600 146 | ) 147 | QBIT_DISABLED = ( 148 | CONFIG.get("QBit.Disabled", fallback=False) 149 | if ENVIRO_CONFIG.qbit.disabled is None 150 | else ENVIRO_CONFIG.qbit.disabled 151 | ) 152 | SEARCH_ONLY = ENVIRO_CONFIG.overrides.search_only 153 | PROCESS_ONLY = ENVIRO_CONFIG.overrides.processing_only 154 | 155 | if QBIT_DISABLED and PROCESS_ONLY: 156 | print("qBittorrent is disabled yet QBITRR_OVERRIDES_PROCESSING_ONLY is enabled") 157 | print( 158 | "Processing monitors qBitTorrents downloads " 159 | "therefore it depends on a health qBitTorrent connection" 160 | ) 161 | print("Exiting...") 162 | sys.exit(1) 163 | 164 | if SEARCH_ONLY and QBIT_DISABLED is False: 165 | QBIT_DISABLED = True 166 | print("QBITRR_OVERRIDES_SEARCH_ONLY is enabled, forcing qBitTorrent setting off") 167 | 168 | # Settings Config Values 169 | FF_VERSION = APPDATA_FOLDER.joinpath("ffprobe_info.json") 170 | FF_PROBE = APPDATA_FOLDER.joinpath("ffprobe") 171 | -------------------------------------------------------------------------------- /qBitrr/env_config.py: -------------------------------------------------------------------------------- 1 | from distutils.util import strtobool 2 | from typing import Optional 3 | 4 | import environ 5 | 6 | 7 | class Converter: 8 | @staticmethod 9 | def int(value: Optional[str]) -> Optional[int]: 10 | if value is None: 11 | return None 12 | return int(value) 13 | 14 | @staticmethod 15 | def list(value: Optional[str], delimiter=",", converter=str) -> Optional[list]: 16 | if value is None: 17 | return None 18 | return list(map(converter, value.split(delimiter))) 19 | 20 | @staticmethod 21 | def bool(value: Optional[str]) -> Optional[bool]: 22 | if value is None: 23 | return None 24 | return strtobool(value) 25 | 26 | 27 | @environ.config(prefix="QBITRR", frozen=True) 28 | class AppConfig: 29 | @environ.config(prefix="OVERRIDES", frozen=True) 30 | class Overrides: 31 | search_only = environ.var(None, converter=Converter.bool) 32 | processing_only = environ.var(None, converter=Converter.bool) 33 | data_path = environ.var(None) 34 | 35 | @environ.config(prefix="SETTINGS", frozen=True) 36 | class Settings: 37 | console_level = environ.var(None) 38 | completed_download_folder = environ.var(None) 39 | no_internet_sleep_timer = environ.var(None, converter=Converter.int) 40 | loop_sleep_timer = environ.var(None, converter=Converter.int) 41 | failed_category = environ.var(None) 42 | recheck_category = environ.var(None) 43 | ignore_torrents_younger_than = environ.var(None, converter=Converter.int) 44 | ping_urls = environ.var(None, converter=Converter.list) 45 | ffprobe_auto_update = environ.var(None, converter=Converter.bool) 46 | 47 | @environ.config(prefix="QBIT", frozen=True) 48 | class Qbit: 49 | disabled = environ.var(None, converter=Converter.bool) 50 | host = environ.var(None) 51 | port = environ.var(None, converter=Converter.int) 52 | username = environ.var(None) 53 | password = environ.var(None) 54 | 55 | overrides: Overrides = environ.group(Overrides) 56 | settings: Settings = environ.group(Settings) 57 | qbit: Qbit = environ.group(Qbit) 58 | 59 | 60 | ENVIRO_CONFIG: AppConfig = environ.to_config(AppConfig) 61 | -------------------------------------------------------------------------------- /qBitrr/errors.py: -------------------------------------------------------------------------------- 1 | class QBitManagerExceptions(Exception): 2 | """Base Exception""" 3 | 4 | 5 | class UnhandledError(QBitManagerExceptions): 6 | """Use to raise when there an unhandled edge case""" 7 | 8 | 9 | class ConfigException(QBitManagerExceptions): 10 | """Base Exception for Config related exceptions""" 11 | 12 | 13 | class ArrManagerException(QBitManagerExceptions): 14 | """Base Exception for Arr related Exceptions""" 15 | 16 | 17 | class SkipException(QBitManagerExceptions): 18 | """Dummy error to skip actions""" 19 | 20 | 21 | class RequireConfigValue(QBitManagerExceptions): 22 | """Exception raised when a config value requires a value.""" 23 | 24 | def __init__(self, config_class: str, config_key: str): 25 | self.message = f"Config key '{config_key}' in '{config_class}' requires a value." 26 | 27 | 28 | class NoConnectionrException(QBitManagerExceptions): 29 | def __init__(self, message: str, type: str = "delay"): 30 | self.message = message 31 | self.type = type 32 | 33 | 34 | class DelayLoopException(QBitManagerExceptions): 35 | def __init__(self, length: int, type: str): 36 | self.type = type 37 | self.length = length 38 | 39 | 40 | class RestartLoopException(ArrManagerException): 41 | """Exception to trigger a loop restart""" 42 | -------------------------------------------------------------------------------- /qBitrr/ffprobe.py: -------------------------------------------------------------------------------- 1 | import io 2 | import json 3 | import logging 4 | import os 5 | import platform 6 | import sys 7 | import zipfile 8 | 9 | import requests 10 | 11 | from qBitrr.config import FF_PROBE, FF_VERSION, FFPROBE_AUTO_UPDATE 12 | from qBitrr.logger import run_logs 13 | 14 | 15 | class FFprobeDownloader: 16 | def __init__(self): 17 | self.api = "https://ffbinaries.com/api/v1/version/latest" 18 | self.version_file = FF_VERSION 19 | self.logger = logging.getLogger("qBitrr.FFprobe") 20 | run_logs(self.logger) 21 | self.platform = platform.system() 22 | if self.platform == "Windows": 23 | self.probe_path = FF_PROBE.with_suffix(".exe") 24 | else: 25 | self.probe_path = FF_PROBE 26 | 27 | def get_upstream_version(self) -> dict: 28 | with requests.Session() as session: 29 | with session.get(self.api) as response: 30 | if response.status_code != 200: 31 | self.logger.warning("Failed to retrieve ffprobe version from API.") 32 | return {} 33 | return response.json() 34 | 35 | def get_current_version(self): 36 | try: 37 | with self.version_file.open(mode="r") as file: 38 | data = json.load(file) 39 | return data.get("version") 40 | except Exception: # If file can't be found or read or parsed 41 | self.logger.warning("Failed to retrieve current ffprobe version.") 42 | return "" 43 | 44 | def update(self): 45 | if not FFPROBE_AUTO_UPDATE: 46 | return 47 | current_version = self.get_current_version() 48 | upstream_data = self.get_upstream_version() 49 | upstream_version = upstream_data.get("version") 50 | if upstream_version is None: 51 | self.logger.debug( 52 | "Failed to retrieve ffprobe version from API.'upstream_version' is None" 53 | ) 54 | return 55 | probe_file_exists = self.probe_path.exists() 56 | if current_version == upstream_version and probe_file_exists: 57 | self.logger.debug("Current FFprobe is up to date.") 58 | return 59 | arch_key = self.get_arch() 60 | urls = upstream_data.get("bin", {}).get(arch_key) 61 | if urls is None: 62 | self.logger.debug("Failed to retrieve ffprobe version from API.'urls' is None") 63 | return 64 | ffprobe_url = urls.get("ffprobe") 65 | self.logger.debug("Downloading newer FFprobe: %s", ffprobe_url) 66 | self.download_and_extract(ffprobe_url) 67 | self.logger.debug("Updating local version of FFprobe: %s", upstream_version) 68 | self.version_file.write_text(json.dumps({"version": upstream_version})) 69 | try: 70 | os.chmod(self.probe_path, 0o777) 71 | self.logger.debug("Successfully changed permissions for ffprobe") 72 | except Exception as e: 73 | self.logger.debug("Failed to change permissions for ffprobe, %s", e) 74 | 75 | def download_and_extract(self, ffprobe_url): 76 | r = requests.get(ffprobe_url) 77 | z = zipfile.ZipFile(io.BytesIO(r.content)) 78 | self.logger.debug("Extracting downloaded FFprobe to: %s", FF_PROBE.parent) 79 | z.extract(member=self.probe_path.name, path=FF_PROBE.parent) 80 | 81 | def get_arch(self): 82 | part1 = None 83 | is_64bits = sys.maxsize > 2**32 84 | part2 = "64" if is_64bits else "32" 85 | if self.platform == "Windows": 86 | part1 = "windows-" 87 | elif self.platform == "Linux": 88 | part1 = "linux-" 89 | machine = platform.machine() 90 | if machine == "armv6l": 91 | part2 = "armhf" 92 | elif ("arm" in machine and is_64bits) or machine == "aarch64": 93 | part2 = "arm64" 94 | # Else just 32/64, Not armel - because just no 95 | elif self.platform == "Darwin": 96 | part1 = "osx-" 97 | part2 = "64" 98 | if part1 is None: 99 | raise RuntimeError( 100 | "You are running in an unsupported platform, " 101 | "if you expect this to be supported please open an issue on GitHub " 102 | "https://github.com/Drapersniper/Qbitrr." 103 | ) 104 | 105 | return part1 + part2 106 | -------------------------------------------------------------------------------- /qBitrr/gen_config.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import pathlib 4 | from datetime import datetime 5 | from functools import reduce 6 | from typing import Any, TypeVar 7 | 8 | from tomlkit import comment, document, nl, parse, table 9 | from tomlkit.items import Table 10 | from tomlkit.toml_document import TOMLDocument 11 | 12 | from qBitrr.env_config import ENVIRO_CONFIG 13 | from qBitrr.home_path import HOME_PATH 14 | 15 | T = TypeVar("T") 16 | 17 | 18 | def generate_doc() -> TOMLDocument: 19 | config = document() 20 | config.add( 21 | comment( 22 | "This is a config file for the qBitrr Script - " 23 | 'Make sure to change all entries of "CHANGE_ME".' 24 | ) 25 | ) 26 | config.add( 27 | comment( 28 | 'This is a config file should be moved to "' 29 | f"{HOME_PATH.joinpath('.config', 'qBitManager', 'config.toml')}\"." 30 | ) 31 | ) 32 | config.add(nl()) 33 | _add_settings_section(config) 34 | _add_qbit_section(config) 35 | _add_category_sections(config) 36 | return config 37 | 38 | 39 | def _add_settings_section(config: TOMLDocument): 40 | settings = table() 41 | settings.add( 42 | comment("Level of logging; One of CRITICAL, ERROR, WARNING, NOTICE, INFO, DEBUG, TRACE") 43 | ) 44 | settings.add("ConsoleLevel", ENVIRO_CONFIG.settings.console_level or "INFO") 45 | settings.add(nl()) 46 | settings.add( 47 | comment( 48 | "Folder where your completed downloads are put into. " 49 | "Can be found in qBitTorrent -> Options -> Downloads -> Default Save Path" 50 | ) 51 | ) 52 | settings.add( 53 | "CompletedDownloadFolder", ENVIRO_CONFIG.settings.completed_download_folder or "CHANGE_ME" 54 | ) 55 | settings.add(nl()) 56 | settings.add( 57 | comment("Time to sleep for if there is no internet (in seconds: 600 = 10 Minutes)") 58 | ) 59 | settings.add("NoInternetSleepTimer", ENVIRO_CONFIG.settings.no_internet_sleep_timer or 15) 60 | settings.add(nl()) 61 | settings.add( 62 | comment("Time to sleep between reprocessing torrents (in seconds: 600 = 10 Minutes)") 63 | ) 64 | settings.add("LoopSleepTimer", ENVIRO_CONFIG.settings.loop_sleep_timer or 5) 65 | settings.add(nl()) 66 | settings.add(comment("Add torrents to this category to mark them as failed")) 67 | settings.add("FailedCategory", ENVIRO_CONFIG.settings.failed_category or "failed") 68 | settings.add(nl()) 69 | settings.add(comment("Add torrents to this category to trigger them to be rechecked properly")) 70 | settings.add("RecheckCategory", ENVIRO_CONFIG.settings.recheck_category or "recheck") 71 | settings.add(nl()) 72 | settings.add( 73 | comment("Ignore Torrents which are younger than this value (in seconds: 600 = 10 Minutes)") 74 | ) 75 | settings.add(comment("Only applicable to Re-check and failed categories")) 76 | settings.add( 77 | "IgnoreTorrentsYoungerThan", ENVIRO_CONFIG.settings.ignore_torrents_younger_than or 180 78 | ) 79 | settings.add(nl()) 80 | settings.add(comment("URL to be pinged to check if you have a valid internet connection")) 81 | settings.add( 82 | comment( 83 | "These will be pinged a **LOT** make sure the service is okay " 84 | "with you sending all the continuous pings." 85 | ) 86 | ) 87 | settings.add( 88 | "PingURLS", ENVIRO_CONFIG.settings.ping_urls or ["one.one.one.one", "dns.google.com"] 89 | ) 90 | settings.add(nl()) 91 | settings.add( 92 | comment( 93 | "FFprobe auto updates, binaries are downloaded from https://ffbinaries.com/downloads" 94 | ) 95 | ) 96 | settings.add(comment("If this is disabled and you want ffprobe to work")) 97 | settings.add( 98 | comment( 99 | "Ensure that you add the binary for your platform into ~/.config/qBitManager " 100 | f"i.e \"{HOME_PATH.joinpath('.config', 'qBitManager', 'ffprobe.exe')}\"" 101 | ) 102 | ) 103 | settings.add( 104 | comment( 105 | "If no `ffprobe` binary is found in the folder above all " 106 | "ffprobe functionality will be disabled." 107 | ) 108 | ) 109 | settings.add( 110 | comment( 111 | "By default this will always be on even if config does not have these key - " 112 | "to disable you need to explicitly set it to `False`" 113 | ) 114 | ) 115 | settings.add( 116 | "FFprobeAutoUpdate", 117 | True if ENVIRO_CONFIG.settings.ping_urls is None else ENVIRO_CONFIG.settings.ping_urls, 118 | ) 119 | config.add("Settings", settings) 120 | 121 | 122 | def _add_qbit_section(config: TOMLDocument): 123 | qbit = table() 124 | qbit.add( 125 | comment( 126 | "If this is enable qBitrr can run in a headless " 127 | "mode where it will only process searches." 128 | ) 129 | ) 130 | qbit.add(comment("If media search is enabled in their individual categories")) 131 | qbit.add( 132 | comment( 133 | "This is useful if you use for example Sabnzbd/NZBGet for downloading content " 134 | "but still want the faster media searches provided by qbit" 135 | ) 136 | ) 137 | qbit.add( 138 | "Disabled", False if ENVIRO_CONFIG.qbit.disabled is None else ENVIRO_CONFIG.qbit.disabled 139 | ) 140 | qbit.add(nl()) 141 | qbit.add(comment('Qbit WebUI Port - Can be found in Options > Web UI (called "IP Address")')) 142 | qbit.add("Host", ENVIRO_CONFIG.qbit.host or "localhost") 143 | qbit.add(nl()) 144 | qbit.add( 145 | comment( 146 | 'Qbit WebUI Port - Can be found in Options > Web UI (called "Port" ' 147 | "on top right corner of the window)" 148 | ) 149 | ) 150 | qbit.add("Port", ENVIRO_CONFIG.qbit.port or 8105) 151 | qbit.add(nl()) 152 | qbit.add( 153 | comment("Qbit WebUI Authentication - Can be found in Options > Web UI > Authentication") 154 | ) 155 | qbit.add("UserName", ENVIRO_CONFIG.qbit.username or "CHANGE_ME") 156 | qbit.add(nl()) 157 | qbit.add( 158 | comment( 159 | 'If you set "Bypass authentication on localhost or whitelisted IPs" remove this field.' 160 | ) 161 | ) 162 | qbit.add("Password", ENVIRO_CONFIG.qbit.password or "CHANGE_ME") 163 | qbit.add(nl()) 164 | config.add("QBit", qbit) 165 | 166 | 167 | def _add_category_sections(config: TOMLDocument): 168 | for c in ["Sonarr-TV", "Sonarr-Anime", "Radarr-1080", "Radarr-4K"]: 169 | _gen_default_cat(c, config) 170 | 171 | 172 | def _gen_default_cat(category: str, config: TOMLDocument): 173 | cat_default = table() 174 | cat_default.add(comment("Toggle whether to manage the Servarr instance torrents.")) 175 | cat_default.add("Managed", True) 176 | cat_default.add(nl()) 177 | cat_default.add( 178 | comment( 179 | "The URL used to access Servarr interface " 180 | "(if you use a domain enter the domain without a port)" 181 | ) 182 | ) 183 | cat_default.add("URI", "CHANGE_ME") 184 | cat_default.add(nl()) 185 | cat_default.add(comment("The Servarr API Key, Can be found it Settings > General > Security")) 186 | cat_default.add("APIKey", "CHANGE_ME") 187 | cat_default.add(nl()) 188 | cat_default.add( 189 | comment( 190 | "Category applied by Servarr to torrents in qBitTorrent, " 191 | "can be found in Settings > Download Clients > qBit > Category" 192 | ) 193 | ) 194 | cat_default.add("Category", category.lower()) 195 | cat_default.add(nl()) 196 | cat_default.add( 197 | comment("Toggle whether to send a query to Servarr to search any failed torrents") 198 | ) 199 | cat_default.add("ReSearch", True) 200 | cat_default.add(nl()) 201 | cat_default.add(comment("The Servarr's Import Mode(one of Move, Copy or Hardlink)")) 202 | cat_default.add("importMode", "Move") 203 | cat_default.add(nl()) 204 | cat_default.add(comment("Timer to call RSSSync (In minutes) - Set to 0 to disable")) 205 | cat_default.add("RssSyncTimer", 0) 206 | cat_default.add(nl()) 207 | cat_default.add( 208 | comment( 209 | "Timer to call RefreshDownloads tp update the queue. (In minutes) - " 210 | "Set to 0 to disable" 211 | ) 212 | ) 213 | cat_default.add("RefreshDownloadsTimer", 0) 214 | cat_default.add(nl()) 215 | 216 | messages = [] 217 | cat_default.add( 218 | comment("Error messages shown my the Arr instance which should be considered failures.") 219 | ) 220 | cat_default.add( 221 | comment( 222 | "This entry should be a list, " 223 | "leave it empty if you want to disable this error handling." 224 | ) 225 | ) 226 | cat_default.add( 227 | comment( 228 | "If enabled qBitrr will remove the failed files and " 229 | "tell the Arr instance the download failed" 230 | ) 231 | ) 232 | 233 | if "radarr" in category.lower(): 234 | messages.extend( 235 | [ 236 | "Not a preferred word upgrade for existing movie file(s)", 237 | "Not an upgrade for existing movie file(s)", 238 | "Unable to determine if file is a sample", 239 | ] 240 | ) 241 | elif "sonarr" in category.lower(): 242 | messages.extend( 243 | [ 244 | "Not a preferred word upgrade for existing episode file(s)", 245 | "Not an upgrade for existing episode file(s)", 246 | "Unable to determine if file is a sample", 247 | ] 248 | ) 249 | 250 | cat_default.add("ArrErrorCodesToBlocklist", list(set(messages))) 251 | cat_default.add(nl()) 252 | 253 | _gen_default_search_table(category, cat_default) 254 | _gen_default_torrent_table(category, cat_default) 255 | config.add(category, cat_default) 256 | 257 | 258 | def _gen_default_torrent_table(category: str, cat_default: Table): 259 | torrent_table = table() 260 | torrent_table.add(comment("Set it to regex matches to respect/ignore case.")) 261 | torrent_table.add("CaseSensitiveMatches", False) 262 | torrent_table.add(nl()) 263 | torrent_table.add( 264 | comment( 265 | "These regex values will match any folder where the full name matches " 266 | "the specified values here, comma separated strings." 267 | ) 268 | ) 269 | torrent_table.add( 270 | comment("These regex need to be escaped, that's why you see so many backslashes.") 271 | ) 272 | if "anime" in category.lower(): 273 | torrent_table.add( 274 | "FolderExclusionRegex", 275 | [ 276 | r"\bextras?\b", 277 | r"\bfeaturettes?\b", 278 | r"\bsamples?\b", 279 | r"\bscreens?\b", 280 | r"\bnc(ed|op)?(\\d+)?\b", 281 | ], 282 | ) 283 | else: 284 | torrent_table.add( 285 | "FolderExclusionRegex", 286 | [ 287 | r"\bextras?\b", 288 | r"\bfeaturettes?\b", 289 | r"\bsamples?\b", 290 | r"\bscreens?\b", 291 | r"\bspecials?\b", 292 | r"\bova\b", 293 | r"\bnc(ed|op)?(\\d+)?\b", 294 | ], 295 | ) 296 | torrent_table.add(nl()) 297 | torrent_table.add( 298 | comment( 299 | "These regex values will match any folder where the full name matches " 300 | "the specified values here, comma separated strings." 301 | ) 302 | ) 303 | torrent_table.add( 304 | comment("These regex need to be escaped, that's why you see so many backslashes.") 305 | ) 306 | torrent_table.add( 307 | "FileNameExclusionRegex", 308 | [ 309 | r"\bncop\\d+?\b", 310 | r"\bnced\\d+?\b", 311 | r"\bsample\b", 312 | r"brarbg.com\b", 313 | r"\btrailer\b", 314 | r"music video", 315 | r"comandotorrents.com", 316 | ], 317 | ) 318 | torrent_table.add(nl()) 319 | torrent_table.add( 320 | comment( 321 | "Only files with these extensions will be allowed to be downloaded, " 322 | "comma separated strings, leave it empty to allow all extensions" 323 | ) 324 | ) 325 | torrent_table.add( 326 | "FileExtensionAllowlist", [".mp4", ".mkv", ".sub", ".ass", ".srt", ".!qB", ".parts"] 327 | ) 328 | torrent_table.add(nl()) 329 | torrent_table.add(comment("Auto delete files that can't be playable (i.e .exe, .png)")) 330 | torrent_table.add("AutoDelete", False) 331 | torrent_table.add(nl()) 332 | torrent_table.add( 333 | comment("Ignore Torrents which are younger than this value (in seconds: 600 = 10 Minutes)") 334 | ) 335 | torrent_table.add("IgnoreTorrentsYoungerThan", 180) 336 | torrent_table.add(nl()) 337 | torrent_table.add( 338 | comment("Maximum allowed remaining ETA for torrent completion (in seconds: 3600 = 1 Hour)") 339 | ) 340 | torrent_table.add( 341 | comment( 342 | "Note that if you set the MaximumETA on a tracker basis that value is " 343 | "favoured over this value" 344 | ) 345 | ) 346 | torrent_table.add("MaximumETA", 18000) 347 | torrent_table.add(nl()) 348 | torrent_table.add( 349 | comment( 350 | "Do not delete torrents with higher completion percentage than this setting " 351 | "(0.5 = 50%, 1.0 = 100%)" 352 | ) 353 | ) 354 | torrent_table.add("MaximumDeletablePercentage", 0.99) 355 | torrent_table.add(nl()) 356 | torrent_table.add(comment("Ignore slow torrents.")) 357 | torrent_table.add("DoNotRemoveSlow", False) 358 | torrent_table.add(nl()) 359 | _gen_default_seeding_table(category, torrent_table) 360 | _gen_default_tracker_tables(category, torrent_table) 361 | 362 | cat_default.add("Torrent", torrent_table) 363 | 364 | 365 | def _gen_default_seeding_table(category: str, torrent_table: Table): 366 | seeding_table = table() 367 | seeding_table.add(comment("Set the maximum allowed download rate for torrents")) 368 | seeding_table.add(comment("Set this value to -1 to disabled it")) 369 | seeding_table.add( 370 | comment( 371 | "Note that if you set the DownloadRateLimit on a tracker basis that value is " 372 | "avoured over this value" 373 | ) 374 | ) 375 | seeding_table.add("DownloadRateLimitPerTorrent", -1) 376 | seeding_table.add(nl()) 377 | seeding_table.add(comment("Set the maximum allowed upload rate for torrents")) 378 | seeding_table.add(comment("Set this value to -1 to disabled it")) 379 | seeding_table.add( 380 | comment( 381 | "Note that if you set the UploadRateLimit on a tracker basis that value is " 382 | "favoured over this value" 383 | ) 384 | ) 385 | seeding_table.add("UploadRateLimitPerTorrent", -1) 386 | seeding_table.add(nl()) 387 | seeding_table.add(comment("Set the maximum allowed download rate for torrents")) 388 | seeding_table.add(comment("Set this value to -1 to disabled it")) 389 | seeding_table.add( 390 | comment( 391 | "Note that if you set the MaxUploadRatio on a tracker basis that value is " 392 | "favoured over this value" 393 | ) 394 | ) 395 | seeding_table.add("MaxUploadRatio", -1) 396 | seeding_table.add(nl()) 397 | seeding_table.add(comment("Set the maximum allowed download rate for torrents")) 398 | seeding_table.add(comment("Set this value to -1 to disabled it")) 399 | seeding_table.add( 400 | comment( 401 | "Note that if you set the MaxSeedingTime on a tracker basis that value is " 402 | "favoured over this value" 403 | ) 404 | ) 405 | seeding_table.add("MaxSeedingTime", -1) 406 | seeding_table.add(nl()) 407 | seeding_table.add(comment("Set the Maximum allowed download rate for torrents")) 408 | seeding_table.add("RemoveDeadTrackers", False) 409 | seeding_table.add(nl()) 410 | seeding_table.add( 411 | comment( 412 | 'If "RemoveDeadTrackers" is set to true then remove trackers with the ' 413 | "following messages" 414 | ) 415 | ) 416 | seeding_table.add( 417 | "RemoveTrackerWithMessage", 418 | [ 419 | "skipping tracker announce (unreachable)", 420 | "No such host is known", 421 | "unsupported URL protocol", 422 | "info hash is not authorized with this tracker", 423 | ], 424 | ) 425 | seeding_table.add(nl()) 426 | 427 | torrent_table.add("SeedingMode", seeding_table) 428 | 429 | 430 | def _gen_default_tracker_tables(category: str, torrent_table: Table): 431 | tracker_table_list = [] 432 | tracker_list = [] 433 | if "anime" in category.lower(): 434 | tracker_list.append(("Nyaa", "http://nyaa.tracker.wf:7777/announce", ["qbitrr-anime"], 10)) 435 | elif "radarr" in category.lower(): 436 | t = ["qbitrr-Rarbg", "Movies and TV"] 437 | t2 = [] 438 | if "4k" in category.lower(): 439 | t.append("4K") 440 | t2.append("4K") 441 | tracker_list.append(("Rarbg-2810", "udp://9.rarbg.com:2810/announce", t, 1)) 442 | tracker_list.append(("Rarbg-2740", "udp://9.rarbg.to:2740/announce", t2, 2)) 443 | 444 | for name, url, tags, priority in tracker_list: 445 | tracker_table = table() 446 | tracker_table.add( 447 | comment( 448 | "This is only for your own benefit, it is not currently used anywhere, " 449 | "but one day it may be." 450 | ) 451 | ) 452 | tracker_table.add("Name", name) 453 | tracker_table.add(nl()) 454 | tracker_table.add( 455 | comment("This is used when multiple trackers are in one single torrent.") 456 | ) 457 | tracker_table.add( 458 | comment( 459 | "the tracker with the highest priority will have all its settings applied to " 460 | "the torrent." 461 | ) 462 | ) 463 | tracker_table.add("Priority", priority) 464 | tracker_table.add(nl()) 465 | tracker_table.add(comment("The tracker URI used by qBit.")) 466 | tracker_table.add("URI", url) 467 | tracker_table.add(nl()) 468 | tracker_table.add( 469 | comment( 470 | "Maximum allowed remaining ETA for torrent completion (in seconds: 3600 = 1 Hour)." 471 | ) 472 | ) 473 | tracker_table.add("MaximumETA", 18000) 474 | tracker_table.add(nl()) 475 | 476 | tracker_table.add(comment("Set the maximum allowed download rate for torrents")) 477 | tracker_table.add(comment("Set this value to -1 to disabled it")) 478 | tracker_table.add("DownloadRateLimit", -1) 479 | tracker_table.add(nl()) 480 | tracker_table.add(comment("Set the maximum allowed upload rate for torrents")) 481 | tracker_table.add(comment("Set this value to -1 to disabled it")) 482 | tracker_table.add("UploadRateLimit", -1) 483 | tracker_table.add(nl()) 484 | tracker_table.add(comment("Set the maximum allowed download rate for torrents")) 485 | tracker_table.add(comment("Set this value to -1 to disabled it")) 486 | tracker_table.add("MaxUploadRatio", -1) 487 | tracker_table.add(nl()) 488 | tracker_table.add(comment("Set the maximum allowed download rate for torrents")) 489 | tracker_table.add(comment("Set this value to -1 to disabled it")) 490 | tracker_table.add("MaxSeedingTime", -1) 491 | tracker_table.add(nl()) 492 | 493 | tracker_table.add(comment("Add this tracker from any torrent that does not contains it.")) 494 | tracker_table.add(comment("This setting does not respect priority.")) 495 | tracker_table.add(comment("Meaning it always be applies.")) 496 | tracker_table.add("AddTrackerIfMissing", False) 497 | tracker_table.add(nl()) 498 | tracker_table.add(comment("Remove this tracker from any torrent that contains it.")) 499 | tracker_table.add(comment("This setting does not respect priority.")) 500 | tracker_table.add(comment("Meaning it always be applies.")) 501 | tracker_table.add("RemoveIfExists", False) 502 | tracker_table.add(nl()) 503 | tracker_table.add(comment("Enable Super Seeding setting for torrents with this tracker.")) 504 | tracker_table.add("SuperSeedMode", False) 505 | tracker_table.add(nl()) 506 | if tags: 507 | tracker_table.add(comment("Adds these tags to any torrents containing this tracker.")) 508 | tracker_table.add(comment("This setting does not respect priority.")) 509 | tracker_table.add(comment("Meaning it always be applies.")) 510 | tracker_table.add("AddTags", tags) 511 | tracker_table.add(nl()) 512 | 513 | tracker_table_list.append(tracker_table) 514 | torrent_table.add( 515 | comment("You can have multiple trackers set here or none just add more subsections.") 516 | ) 517 | torrent_table.add("Trackers", tracker_table_list) 518 | 519 | 520 | def _gen_default_search_table(category: str, cat_default: Table): 521 | search_table = table() 522 | search_table.add( 523 | comment( 524 | "All these settings depends on SearchMissing being True and access to the Servarr " 525 | "database file." 526 | ) 527 | ) 528 | search_table.add(nl()) 529 | search_table.add(comment("Should search for Missing files?")) 530 | search_table.add("SearchMissing", False) 531 | search_table.add(nl()) 532 | search_table.add(comment("Should search for specials episodes? (Season 00)")) 533 | search_table.add("AlsoSearchSpecials", False) 534 | search_table.add(nl()) 535 | search_table.add( 536 | comment( 537 | "Maximum allowed Searches at any one points (I wouldn't recommend settings " 538 | "this too high)" 539 | ) 540 | ) 541 | if "sonarr" in category.lower(): 542 | search_table.add(comment("Sonarr has a hardcoded cap of 3 simultaneous tasks")) 543 | elif "radarr" in category.lower(): 544 | search_table.add( 545 | comment( 546 | "Radarr has a default of 3 simultaneous tasks, which can be increased up to " 547 | "10 tasks" 548 | ) 549 | ) 550 | search_table.add( 551 | comment( 552 | 'If you set the environment variable of "THREAD_LIMIT" to a number between and ' 553 | "including 2-10" 554 | ) 555 | ) 556 | search_table.add( 557 | comment( 558 | "Radarr devs have stated that this is an unsupported feature so you will " 559 | "not get any support for doing so from them." 560 | ) 561 | ) 562 | search_table.add( 563 | comment( 564 | "That being said I've been daily driving 10 simultaneous tasks for quite a " 565 | "while now with no issues." 566 | ) 567 | ) 568 | search_table.add("SearchLimit", 5) 569 | search_table.add(nl()) 570 | search_table.add(comment("Servarr Datapath file path")) 571 | search_table.add(comment("This is required for any of the search functionality to work")) 572 | search_table.add( 573 | comment( 574 | 'The only exception for this is the "ReSearch" setting as that is done via an ' 575 | "API call." 576 | ) 577 | ) 578 | if "sonarr" in category.lower(): 579 | search_table.add("DatabaseFile", "CHANGE_ME/sonarr.db") 580 | elif "radarr" in category.lower(): 581 | search_table.add("DatabaseFile", "CHANGE_ME/radarr.db") 582 | search_table.add(nl()) 583 | search_table.add(comment("It will order searches by the year the EPISODE was first aired")) 584 | search_table.add("SearchByYear", True) 585 | search_table.add(nl()) 586 | search_table.add( 587 | comment("First year to search; Remove this field to set it to the current year.") 588 | ) 589 | search_table.add("StartYear", datetime.now().year) 590 | search_table.add(nl()) 591 | search_table.add(comment("Last Year to Search")) 592 | search_table.add("LastYear", 1990) 593 | search_table.add(nl()) 594 | search_table.add( 595 | comment('Reverse search order (Start searching in "LastYear" and finish in "StartYear")') 596 | ) 597 | search_table.add("SearchInReverse", False) 598 | search_table.add(nl()) 599 | search_table.add(comment("Delay between request searches in seconds")) 600 | search_table.add("SearchRequestsEvery", 1800) 601 | search_table.add(nl()) 602 | search_table.add( 603 | comment( 604 | "Search movies which already have a file in the database in hopes of finding a " 605 | "better quality version." 606 | ) 607 | ) 608 | search_table.add("DoUpgradeSearch", False) 609 | search_table.add(nl()) 610 | search_table.add(comment("Do a quality unmet search for existing entries.")) 611 | search_table.add("QualityUnmetSearch", False) 612 | search_table.add(nl()) 613 | search_table.add( 614 | comment( 615 | "Once you have search all files on your specified year range restart the loop and " 616 | "search again." 617 | ) 618 | ) 619 | search_table.add("SearchAgainOnSearchCompletion", False) 620 | search_table.add(nl()) 621 | 622 | if "sonarr" in category.lower(): 623 | search_table.add(comment("Search by series instead of by episode")) 624 | search_table.add("SearchBySeries", True) 625 | search_table.add(nl()) 626 | 627 | search_table.add( 628 | comment( 629 | "Prioritize Today's releases (Similar effect as RSS Sync, where it searches " 630 | "today's release episodes first, only works on Sonarr)." 631 | ) 632 | ) 633 | search_table.add("PrioritizeTodaysReleases", True) 634 | search_table.add(nl()) 635 | _gen_default_ombi_table(category, search_table) 636 | _gen_default_overseerr_table(category, search_table) 637 | cat_default.add("EntrySearch", search_table) 638 | 639 | 640 | def _gen_default_ombi_table(category: str, search_table: Table): 641 | ombi_table = table() 642 | ombi_table.add( 643 | comment("Search Ombi for pending requests (Will only work if 'SearchMissing' is enabled.)") 644 | ) 645 | ombi_table.add("SearchOmbiRequests", False) 646 | ombi_table.add(nl()) 647 | ombi_table.add( 648 | comment( 649 | "Ombi URI (Note that this has to be the instance of Ombi which manage the Arr " 650 | "instance request (If you have multiple Ombi instances)" 651 | ) 652 | ) 653 | ombi_table.add("OmbiURI", "http://localhost:5000") 654 | ombi_table.add(nl()) 655 | ombi_table.add(comment("Ombi's API Key")) 656 | ombi_table.add("OmbiAPIKey", "CHANGE_ME") 657 | ombi_table.add(nl()) 658 | ombi_table.add(comment("Only process approved requests")) 659 | ombi_table.add("ApprovedOnly", True) 660 | ombi_table.add(nl()) 661 | 662 | search_table.add("Ombi", ombi_table) 663 | 664 | 665 | def _gen_default_overseerr_table(category: str, search_table: Table): 666 | overseerr_table = table() 667 | overseerr_table.add( 668 | comment( 669 | "Search Overseerr for pending requests (Will only work if 'SearchMissing' is enabled.)" 670 | ) 671 | ) 672 | overseerr_table.add(comment("If this and Ombi are both enable, Ombi will be ignored")) 673 | overseerr_table.add("SearchOverseerrRequests", False) 674 | overseerr_table.add(nl()) 675 | overseerr_table.add(comment("Overseerr's URI")) 676 | overseerr_table.add("OverseerrURI", "http://localhost:5055") 677 | overseerr_table.add(nl()) 678 | overseerr_table.add(comment("Overseerr's API Key")) 679 | overseerr_table.add("OverseerrAPIKey", "CHANGE_ME") 680 | overseerr_table.add(nl()) 681 | overseerr_table.add(comment("Only process approved requests")) 682 | overseerr_table.add("ApprovedOnly", True) 683 | overseerr_table.add(nl()) 684 | search_table.add("Overseerr", overseerr_table) 685 | 686 | 687 | class MyConfig: 688 | # Original code taken from https://github.com/SemenovAV/toml_config 689 | # Licence is MIT, can be located at 690 | # https://github.com/SemenovAV/toml_config/blob/master/LICENSE.txt 691 | 692 | path: pathlib.Path 693 | config: TOMLDocument 694 | defaults_config: TOMLDocument 695 | 696 | def __init__(self, path: pathlib.Path | str, config: TOMLDocument | None = None): 697 | self.path = pathlib.Path(path) 698 | self._giving_data = bool(config) 699 | self.config = config or document() 700 | self.defaults_config = generate_doc() 701 | self.err = None 702 | self.state = True 703 | self.load() 704 | 705 | def __str__(self): 706 | return self.config.as_string() 707 | 708 | def load(self) -> MyConfig: 709 | if self.state: 710 | try: 711 | if self._giving_data: 712 | return self 713 | with self.path.open() as file: 714 | self.config = parse(file.read()) 715 | return self 716 | except OSError as err: 717 | self.state = False 718 | self.err = err 719 | except TypeError as err: 720 | self.state = False 721 | self.err = err 722 | return self 723 | 724 | def save(self) -> MyConfig: 725 | if self.state: 726 | try: 727 | with open(self.path, "w", encoding="utf8") as file: 728 | file.write(self.config.as_string()) 729 | return self 730 | except OSError as err: 731 | self.state = False 732 | self.err = err 733 | raise ValueError( 734 | f"Possible permissions while attempting to read the config file.\n{err}" 735 | ) 736 | except TypeError as err: 737 | self.state = False 738 | self.err = err 739 | raise ValueError(f"While attempting to read the config file.\n{err}") 740 | return self 741 | 742 | def get(self, section: str, fallback: Any = None) -> T: 743 | return self._deep_get(section, default=fallback) 744 | 745 | def get_or_raise(self, section: str) -> T: 746 | if (r := self._deep_get(section, default=KeyError)) is KeyError: 747 | raise KeyError(f"{section} does not exist") 748 | return r 749 | 750 | def sections(self): 751 | return self.config.keys() 752 | 753 | def _deep_get(self, keys, default=...): 754 | values = reduce( 755 | lambda d, key: d.get(key, ...) if isinstance(d, dict) else ..., 756 | keys.split("."), 757 | self.config, 758 | ) 759 | 760 | return values if values is not ... else default 761 | 762 | 763 | def _write_config_file(docker=False) -> pathlib.Path: 764 | doc = generate_doc() 765 | if docker: 766 | file_name = "config.rename_me.toml" 767 | else: 768 | file_name = "config.toml" 769 | CONFIG_FILE = HOME_PATH.joinpath(".config", "qBitManager", file_name) 770 | if CONFIG_FILE.exists() and not docker: 771 | print(f"{CONFIG_FILE} already exists, File is not being replaced.") 772 | CONFIG_FILE = pathlib.Path.cwd().joinpath("config_new.toml") 773 | config = MyConfig(CONFIG_FILE, config=doc) 774 | config.save() 775 | print(f'New config file has been saved to "{CONFIG_FILE}"') 776 | return CONFIG_FILE 777 | -------------------------------------------------------------------------------- /qBitrr/home_path.py: -------------------------------------------------------------------------------- 1 | import pathlib 2 | 3 | from jaraco.docker import is_docker 4 | 5 | from qBitrr.env_config import ENVIRO_CONFIG 6 | 7 | if ( 8 | ENVIRO_CONFIG.overrides.data_path is None 9 | or not (p := pathlib.Path(ENVIRO_CONFIG.overrides.data_path)).exists() 10 | ): 11 | if is_docker(): 12 | ON_DOCKER = True 13 | HOME_PATH = pathlib.Path("/config") 14 | HOME_PATH.mkdir(parents=True, exist_ok=True) 15 | else: 16 | ON_DOCKER = False 17 | HOME_PATH = pathlib.Path().home() 18 | else: 19 | HOME_PATH = p 20 | -------------------------------------------------------------------------------- /qBitrr/logger.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import logging 4 | import time 5 | from logging import Logger 6 | 7 | import coloredlogs 8 | 9 | from qBitrr.config import ( 10 | APPDATA_FOLDER, 11 | COMPLETED_DOWNLOAD_FOLDER, 12 | CONFIG, 13 | CONSOLE_LOGGING_LEVEL_STRING, 14 | COPIED_TO_NEW_DIR, 15 | FAILED_CATEGORY, 16 | IGNORE_TORRENTS_YOUNGER_THAN, 17 | LOOP_SLEEP_TIMER, 18 | NO_INTERNET_SLEEP_TIMER, 19 | PING_URLS, 20 | RECHECK_CATEGORY, 21 | ) 22 | 23 | __all__ = ("run_logs",) 24 | 25 | TRACE = 5 26 | VERBOSE = 7 27 | NOTICE = 23 28 | HNOTICE = 24 29 | SUCCESS = 25 30 | 31 | 32 | class VerboseLogger(Logger): 33 | def _init__(self, *args, **kwargs): 34 | super().__init__(*args, **kwargs) 35 | if self.name.startswith("qBitrr"): 36 | self.set_config_level() 37 | 38 | def success(self, message, *args, **kwargs): 39 | if self.isEnabledFor(SUCCESS): 40 | self._log(SUCCESS, message, args, **kwargs) 41 | 42 | def hnotice(self, message, *args, **kwargs): 43 | if self.isEnabledFor(HNOTICE): 44 | self._log(HNOTICE, message, args, **kwargs) 45 | 46 | def notice(self, message, *args, **kwargs): 47 | if self.isEnabledFor(NOTICE): 48 | self._log(NOTICE, message, args, **kwargs) 49 | 50 | def verbose(self, message, *args, **kwargs): 51 | if self.isEnabledFor(VERBOSE): 52 | self._log(VERBOSE, message, args, **kwargs) 53 | 54 | def trace(self, message, *args, **kwargs): 55 | if self.isEnabledFor(TRACE): 56 | self._log(TRACE, message, args, **kwargs) 57 | 58 | def set_config_level(self): 59 | self.setLevel(CONSOLE_LOGGING_LEVEL_STRING) 60 | 61 | 62 | logging.addLevelName(SUCCESS, "SUCCESS") 63 | logging.addLevelName(HNOTICE, "HNOTICE") 64 | logging.addLevelName(NOTICE, "NOTICE") 65 | logging.addLevelName(VERBOSE, "VERBOSE") 66 | logging.addLevelName(TRACE, "TRACE") 67 | logging.setLoggerClass(VerboseLogger) 68 | 69 | 70 | def getLogger(name: str | None = None) -> VerboseLogger: 71 | if name: 72 | return VerboseLogger.manager.getLogger(name) 73 | else: 74 | return logging.root 75 | 76 | 77 | logging.getLogger = getLogger 78 | 79 | 80 | logger = logging.getLogger("qBitrr.Misc") 81 | 82 | 83 | HAS_RUN = False 84 | 85 | 86 | def run_logs(logger: Logger) -> None: 87 | global HAS_RUN 88 | try: 89 | configkeys = {f"qBitrr.{i}" for i in CONFIG.sections()} 90 | key_length = max(len(max(configkeys, key=len)), 10) 91 | except BaseException: 92 | key_length = 10 93 | coloredlogs.install( 94 | logger=logger, 95 | level=logging._nameToLevel.get(CONSOLE_LOGGING_LEVEL_STRING), 96 | fmt="[%(asctime)-15s] [pid:%(process)8d][tid:%(thread)8d] " 97 | f"%(levelname)-8s: %(name)-{key_length}s: %(message)s", 98 | level_styles=dict( 99 | trace=dict(color="black", bold=True), 100 | debug=dict(color="magenta", bold=True), 101 | verbose=dict(color="blue", bold=True), 102 | info=dict(color="white"), 103 | notice=dict(color="cyan"), 104 | hnotice=dict(color="cyan", bold=True), 105 | warning=dict(color="yellow", bold=True), 106 | success=dict(color="green", bold=True), 107 | error=dict(color="red"), 108 | critical=dict(color="red", bold=True), 109 | ), 110 | field_styles=dict( 111 | asctime=dict(color="green"), 112 | process=dict(color="magenta"), 113 | levelname=dict(color="red", bold=True), 114 | name=dict(color="blue", bold=True), 115 | thread=dict(color="cyan"), 116 | ), 117 | reconfigure=True, 118 | ) 119 | if HAS_RUN is False: 120 | logger.debug("Log Level: %s", CONSOLE_LOGGING_LEVEL_STRING) 121 | logger.debug("Ping URLs: %s", PING_URLS) 122 | logger.debug("Script Config: FailedCategory=%s", FAILED_CATEGORY) 123 | logger.debug("Script Config: RecheckCategory=%s", RECHECK_CATEGORY) 124 | logger.debug("Script Config: CompletedDownloadFolder=%s", COMPLETED_DOWNLOAD_FOLDER) 125 | logger.debug("Script Config: LoopSleepTimer=%s", LOOP_SLEEP_TIMER) 126 | logger.debug( 127 | "Script Config: NoInternetSleepTimer=%s", 128 | NO_INTERNET_SLEEP_TIMER, 129 | ) 130 | logger.debug( 131 | "Script Config: IgnoreTorrentsYoungerThan=%s", 132 | IGNORE_TORRENTS_YOUNGER_THAN, 133 | ) 134 | HAS_RUN = True 135 | 136 | 137 | if COPIED_TO_NEW_DIR is False and not APPDATA_FOLDER.joinpath("config.toml").exists(): 138 | logger.warning( 139 | "Config.toml should exist in '%s', in a future update this will be a requirement.", 140 | APPDATA_FOLDER, 141 | ) 142 | time.sleep(5) 143 | if COPIED_TO_NEW_DIR: 144 | logger.warning("Config.toml new location is %s", APPDATA_FOLDER) 145 | time.sleep(5) 146 | run_logs(logger) 147 | -------------------------------------------------------------------------------- /qBitrr/main.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import atexit 4 | import logging 5 | import sys 6 | import time 7 | from multiprocessing import freeze_support 8 | 9 | import pathos 10 | import qbittorrentapi 11 | import requests 12 | from packaging import version as version_parser 13 | from packaging.version import Version as VersionClass 14 | from qbittorrentapi import APINames 15 | from qbittorrentapi.decorators import login_required, response_text 16 | 17 | from qBitrr.arss import ArrManager 18 | from qBitrr.bundled_data import patched_version 19 | from qBitrr.config import CONFIG, QBIT_DISABLED, SEARCH_ONLY, process_flags 20 | from qBitrr.env_config import ENVIRO_CONFIG 21 | from qBitrr.ffprobe import FFprobeDownloader 22 | from qBitrr.logger import run_logs 23 | from qBitrr.utils import ExpiringSet 24 | 25 | CHILD_PROCESSES = [] 26 | 27 | logger = logging.getLogger("qBitrr") 28 | run_logs(logger) 29 | 30 | 31 | class qBitManager: 32 | min_supported_version = VersionClass("4.3.4") 33 | soft_not_supported_supported_version = VersionClass("4.5") 34 | max_supported_version = VersionClass("4.5") 35 | _head_less_mode = False 36 | 37 | def __init__(self): 38 | self.qBit_Host = CONFIG.get("QBit.Host", fallback="localhost") 39 | self.qBit_Port = CONFIG.get("QBit.Port", fallback=8105) 40 | self.qBit_UserName = CONFIG.get("QBit.UserName", fallback=None) 41 | self.qBit_Password = CONFIG.get("QBit.Password", fallback=None) 42 | self.logger = logging.getLogger( 43 | "qBitrr.Manager", 44 | ) 45 | run_logs(self.logger) 46 | self.logger.debug( 47 | "QBitTorrent Config: Host: %s Port: %s, Username: %s, Password: %s", 48 | self.qBit_Host, 49 | self.qBit_Port, 50 | self.qBit_UserName, 51 | self.qBit_Password, 52 | ) 53 | self._validated_version = False 54 | self.client = None 55 | self.current_qbit_version = None 56 | if not any([QBIT_DISABLED, SEARCH_ONLY]): 57 | self.client = qbittorrentapi.Client( 58 | host=self.qBit_Host, 59 | port=self.qBit_Port, 60 | username=self.qBit_UserName, 61 | password=self.qBit_Password, 62 | SIMPLE_RESPONSES=False, 63 | ) 64 | try: 65 | self.current_qbit_version = version_parser.parse(self.client.app_version()) 66 | self._validated_version = True 67 | except BaseException: 68 | self.current_qbit_version = self.min_supported_version 69 | self.logger.error( 70 | "Could not establish qBitTorrent version, " 71 | "you may experience errors, please report this error." 72 | ) 73 | self._version_validator() 74 | self.expiring_bool = ExpiringSet(max_age_seconds=10) 75 | self.cache = {} 76 | self.name_cache = {} 77 | self.should_delay_torrent_scan = False # If true torrent scan is delayed by 5 minutes. 78 | self.child_processes = [] 79 | self.ffprobe_downloader = FFprobeDownloader() 80 | try: 81 | if not any([QBIT_DISABLED, SEARCH_ONLY]): 82 | self.ffprobe_downloader.update() 83 | except Exception as e: 84 | self.logger.error( 85 | "FFprobe manager error: %s while attempting to download/update FFprobe", e 86 | ) 87 | self.arr_manager = ArrManager(self).build_arr_instances() 88 | run_logs(self.logger) 89 | 90 | def _version_validator(self): 91 | if self.min_supported_version <= self.current_qbit_version < self.max_supported_version: 92 | if self.soft_not_supported_supported_version <= self.current_qbit_version: 93 | self.logger.warning( 94 | "Current qBitTorrent version is not fully supported: %s, " 95 | "historically there's been some issued with qBitTorrent 4.4+ and " 96 | "qBitrr worked best with 4.3.9", 97 | self.current_qbit_version, 98 | ) 99 | elif self._validated_version: 100 | self.logger.hnotice( 101 | "Current qBitTorrent version is supported: %s", 102 | self.current_qbit_version, 103 | ) 104 | else: 105 | self.logger.hnotice( 106 | "Could not validate current qBitTorrent version, assuming: %s", 107 | self.current_qbit_version, 108 | ) 109 | time.sleep(10) 110 | else: 111 | self.logger.critical( 112 | "You are currently running qBitTorrent version %s, " 113 | "Supported version range is %s to < %s", 114 | self.current_qbit_version, 115 | self.min_supported_version, 116 | self.max_supported_version, 117 | ) 118 | sys.exit(1) 119 | 120 | @response_text(str) 121 | @login_required 122 | def app_version(self, **kwargs): 123 | return self.client._get( 124 | _name=APINames.Application, 125 | _method="version", 126 | _retries=0, 127 | _retry_backoff_factor=0, 128 | **kwargs, 129 | ) 130 | 131 | @property 132 | def is_alive(self) -> bool: 133 | try: 134 | if 1 in self.expiring_bool or self.client is None: 135 | return True 136 | self.client.app_version() 137 | self.logger.trace("Successfully connected to %s:%s", self.qBit_Host, self.qBit_Port) 138 | self.expiring_bool.add(1) 139 | return True 140 | except requests.RequestException: 141 | self.logger.warning("Could not connect to %s:%s", self.qBit_Host, self.qBit_Port) 142 | self.should_delay_torrent_scan = True 143 | return False 144 | 145 | def get_child_processes(self) -> list[pathos.helpers.mp.Process]: 146 | run_logs(self.logger) 147 | self.logger.hnotice("Managing %s categories", len(self.arr_manager.managed_objects)) 148 | count = 0 149 | procs = [] 150 | for arr in self.arr_manager.managed_objects.values(): 151 | numb, processes = arr.spawn_child_processes() 152 | count += numb 153 | procs.extend(processes) 154 | return procs 155 | 156 | def run(self): 157 | try: 158 | self.logger.notice("Starting %s child processes", len(self.child_processes)) 159 | [p.start() for p in self.child_processes] 160 | [p.join() for p in self.child_processes] 161 | except KeyboardInterrupt: 162 | self.logger.hnotice("Detected Ctrl+C - Terminating process") 163 | sys.exit(0) 164 | except BaseException as e: 165 | self.logger.hnotice("Detected Ctrl+C - Terminating process: %r", e) 166 | sys.exit(1) 167 | 168 | 169 | def run(): 170 | global CHILD_PROCESSES 171 | early_exit = process_flags() 172 | if early_exit is True: 173 | sys.exit(0) 174 | logger.notice("Starting qBitrr: Version: %s.", patched_version) 175 | manager = qBitManager() 176 | run_logs(logger) 177 | logger.debug("Environment variables: %r", ENVIRO_CONFIG) 178 | try: 179 | if CHILD_PROCESSES := manager.get_child_processes(): 180 | manager.run() 181 | else: 182 | logger.warning( 183 | "No tasks to perform, if this is unintended double check your config file." 184 | ) 185 | except KeyboardInterrupt: 186 | logger.hnotice("Detected Ctrl+C - Terminating process") 187 | sys.exit(0) 188 | except Exception: 189 | logger.notice("Attempting to terminate child processes, please wait a moment.") 190 | for child in manager.child_processes: 191 | child.kill() 192 | 193 | 194 | def cleanup(): 195 | for p in CHILD_PROCESSES: 196 | p.kill() 197 | p.terminate() 198 | 199 | 200 | atexit.register(cleanup) 201 | 202 | 203 | if __name__ == "__main__": 204 | freeze_support() 205 | run() 206 | -------------------------------------------------------------------------------- /qBitrr/tables.py: -------------------------------------------------------------------------------- 1 | from peewee import BooleanField, CharField, DateTimeField, IntegerField, Model, TextField 2 | 3 | 4 | class FilesQueued(Model): 5 | EntryId = IntegerField(primary_key=True, null=False, unique=True) 6 | 7 | 8 | class MoviesFilesModel(Model): 9 | Title = CharField() 10 | Monitored = BooleanField() 11 | TmdbId = IntegerField() 12 | Year = IntegerField() 13 | EntryId = IntegerField(unique=True) 14 | Searched = BooleanField(default=False) 15 | MovieFileId = IntegerField() 16 | IsRequest = BooleanField(default=False) 17 | QualityMet = BooleanField(default=False) 18 | 19 | 20 | class EpisodeFilesModel(Model): 21 | EntryId = IntegerField(primary_key=True) 22 | SeriesTitle = TextField(null=True) 23 | Title = TextField(null=True) 24 | SeriesId = IntegerField(null=False) 25 | EpisodeFileId = IntegerField(null=True) 26 | EpisodeNumber = IntegerField(null=False) 27 | SeasonNumber = IntegerField(null=False) 28 | AbsoluteEpisodeNumber = IntegerField(null=True) 29 | SceneAbsoluteEpisodeNumber = IntegerField(null=True) 30 | LastSearchTime = DateTimeField(formats=["%Y-%m-%d %H:%M:%S.%f"], null=True) 31 | AirDateUtc = DateTimeField(formats=["%Y-%m-%d %H:%M:%S.%f"], null=True) 32 | Monitored = BooleanField(null=True) 33 | Searched = BooleanField(default=False) 34 | IsRequest = BooleanField(default=False) 35 | QualityMet = BooleanField(default=False) 36 | 37 | 38 | class SeriesFilesModel(Model): 39 | EntryId = IntegerField(primary_key=True) 40 | Title = TextField(null=True) 41 | Monitored = BooleanField(null=True) 42 | Searched = BooleanField(default=False) 43 | 44 | 45 | class MovieQueueModel(Model): 46 | EntryId = IntegerField(unique=True) 47 | Completed = BooleanField(default=False) 48 | 49 | 50 | class EpisodeQueueModel(Model): 51 | EntryId = IntegerField(unique=True) 52 | Completed = BooleanField(default=False) 53 | -------------------------------------------------------------------------------- /qBitrr/utils.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import logging 4 | import pathlib 5 | import random 6 | import socket 7 | import time 8 | from typing import Iterator 9 | 10 | import ping3 11 | from cachetools import TTLCache 12 | 13 | ping3.EXCEPTIONS = True 14 | 15 | logger = logging.getLogger("qBitrr.Utils") 16 | 17 | CACHE = TTLCache(maxsize=50, ttl=60) 18 | 19 | 20 | def absolute_file_paths(directory: pathlib.Path | str) -> Iterator[pathlib.Path]: 21 | error = True 22 | while error is True: 23 | try: 24 | yield from pathlib.Path(directory).glob("**/*") 25 | error = False 26 | except FileNotFoundError as e: 27 | logger.warning("%s - %s", e.strerror, e.filename) 28 | 29 | 30 | def validate_and_return_torrent_file(file: str) -> pathlib.Path: 31 | path = pathlib.Path(file) 32 | if path.is_file(): 33 | path = path.parent.absolute() 34 | count = 9 35 | while not path.exists(): 36 | logger.trace( 37 | "Attempt %s/10: File does not yet exists! (Possibly being moved?) | " 38 | "%s | Sleeping for 0.1s", 39 | path, 40 | 10 - count, 41 | ) 42 | time.sleep(0.1) 43 | if count == 0: 44 | break 45 | count -= 1 46 | else: 47 | count = 0 48 | while str(path) == ".": 49 | path = pathlib.Path(file) 50 | if path.is_file(): 51 | path = path.parent.absolute() 52 | while not path.exists(): 53 | logger.trace( 54 | "Attempt %s/10:File does not yet exists! (Possibly being moved?) | " 55 | "%s | Sleeping for 0.1s", 56 | path, 57 | 10 - count, 58 | ) 59 | time.sleep(0.1) 60 | if count == 0: 61 | break 62 | count -= 1 63 | else: 64 | count = 0 65 | if count == 0: 66 | break 67 | count -= 1 68 | return path 69 | 70 | 71 | def has_internet(): 72 | from qBitrr.config import PING_URLS 73 | 74 | url = random.choice(PING_URLS) 75 | if not is_connected(url): 76 | return False 77 | logger.trace("Successfully connected to %s", url) 78 | return True 79 | 80 | 81 | def _basic_ping(hostname): 82 | host = "N/A" 83 | try: 84 | # if this hostname was called within the last 10 seconds skip it 85 | # if it was previous successful 86 | # Reducing the number of call to it and the likelihood of rate-limits. 87 | if hostname in CACHE: 88 | return CACHE[hostname] 89 | # see if we can resolve the host name -- tells us if there is 90 | # a DNS listening 91 | host = socket.gethostbyname(hostname) 92 | # connect to the host -- tells us if the host is actually 93 | # reachable 94 | s = socket.create_connection((host, 80), 5) 95 | s.close() 96 | CACHE[hostname] = True 97 | return True 98 | except Exception as e: 99 | logger.trace( 100 | "Error when connecting to host: %s %s %s", 101 | hostname, 102 | host, 103 | e, 104 | ) 105 | return False 106 | 107 | 108 | def is_connected(hostname): 109 | try: 110 | # if this hostname was called within the last 10 seconds skip it 111 | # if it was previous successful 112 | # Reducing the number of call to it and the likelihood of rate-limits. 113 | if hostname in CACHE: 114 | return CACHE[hostname] 115 | ping3.ping(hostname, timeout=5) 116 | CACHE[hostname] = True 117 | return True 118 | except ping3.errors.PingError as e: # All ping3 errors are subclasses of `PingError`. 119 | logger.debug( 120 | "Error when connecting to host: %s %s", 121 | hostname, 122 | e, 123 | ) 124 | except Exception: # Ping3 is far more robust but may requite root access, if root access is not available then run the basic mode 125 | return _basic_ping(hostname) 126 | 127 | 128 | class ExpiringSet: 129 | def __init__(self, *args, **kwargs): 130 | max_age_seconds = kwargs.get("max_age_seconds", 0) 131 | assert max_age_seconds > 0 132 | self.age = max_age_seconds 133 | self.container = {} 134 | for arg in args: 135 | self.add(arg) 136 | 137 | def __repr__(self): 138 | self.__update__() 139 | return f"{self.__class__.__name__}({', '.join(self.container.keys())})" 140 | 141 | def extend(self, args): 142 | """Add several items at once.""" 143 | for arg in args: 144 | self.add(arg) 145 | 146 | def add(self, value): 147 | self.container[value] = time.time() 148 | 149 | def remove(self, item): 150 | del self.container[item] 151 | 152 | def contains(self, value): 153 | if value not in self.container: 154 | return False 155 | if time.time() - self.container[value] > self.age: 156 | del self.container[value] 157 | return False 158 | return True 159 | 160 | __contains__ = contains 161 | 162 | def __getitem__(self, index): 163 | self.__update__() 164 | return list(self.container.keys())[index] 165 | 166 | def __iter__(self): 167 | self.__update__() 168 | return iter(self.container.copy()) 169 | 170 | def __len__(self): 171 | self.__update__() 172 | return len(self.container) 173 | 174 | def __copy__(self): 175 | self.__update__() 176 | temp = ExpiringSet(max_age_seconds=self.age) 177 | temp.container = self.container.copy() 178 | return temp 179 | 180 | def __update__(self): 181 | for k, b in self.container.copy().items(): 182 | if time.time() - b > self.age: 183 | del self.container[k] 184 | return False 185 | 186 | def __hash__(self): 187 | return hash(*(self.container.keys())) 188 | 189 | def __eq__(self, other): 190 | return self.__hash__() == other.__hash__() 191 | -------------------------------------------------------------------------------- /requirements.all.txt: -------------------------------------------------------------------------------- 1 | # 2 | # This file is autogenerated by pip-compile with python 3.8 3 | # To update, run: 4 | # 5 | # pip-compile --extra=all --output-file=requirements.all.txt 6 | # 7 | altgraph==0.17.2 8 | # via pyinstaller 9 | attrs==21.4.0 10 | # via environ-config 11 | black==22.1.0 12 | # via qBitrr (setup.py) 13 | bleach==4.1.0 14 | # via readme-renderer 15 | bump2version==1.0.1 16 | # via qBitrr (setup.py) 17 | cachetools==4.2.4 18 | # via qBitrr (setup.py) 19 | certifi==2021.10.8 20 | # via requests 21 | cffi==1.15.0 22 | # via cryptography 23 | cfgv==3.3.1 24 | # via pre-commit 25 | charset-normalizer==2.0.11 26 | # via requests 27 | click==8.0.3 28 | # via 29 | # black 30 | # pip-tools 31 | colorama==0.4.4 32 | # via 33 | # qBitrr (setup.py) 34 | # twine 35 | coloredlogs==15.0.1 36 | # via qBitrr (setup.py) 37 | cryptography==36.0.1 38 | # via secretstorage 39 | dill==0.3.4 40 | # via 41 | # multiprocess 42 | # pathos 43 | distlib==0.3.4 44 | # via virtualenv 45 | docutils==0.18.1 46 | # via readme-renderer 47 | environ-config==21.2.0 48 | # via qBitrr (setup.py) 49 | ffmpeg-python==0.2.0 50 | # via qBitrr (setup.py) 51 | filelock==3.4.2 52 | # via virtualenv 53 | future==0.18.2 54 | # via ffmpeg-python 55 | humanfriendly==10.0 56 | # via coloredlogs 57 | identify==2.4.7 58 | # via pre-commit 59 | idna==3.3 60 | # via requests 61 | importlib-metadata==4.10.1 62 | # via 63 | # keyring 64 | # twine 65 | isort==5.10.1 66 | # via qBitrr (setup.py) 67 | jaraco.context==4.1.1 68 | # via jaraco.docker 69 | jaraco.docker==2.0 70 | # via qBitrr (setup.py) 71 | jaraco.functools==3.5.0 72 | # via jaraco.docker 73 | jeepney==0.7.1 74 | # via 75 | # keyring 76 | # secretstorage 77 | keyring==23.5.0 78 | # via twine 79 | more-itertools==8.12.0 80 | # via jaraco.functools 81 | multiprocess==0.70.12.2 82 | # via pathos 83 | mypy-extensions==0.4.3 84 | # via black 85 | nodeenv==1.6.0 86 | # via pre-commit 87 | packaging==21.3 88 | # via 89 | # bleach 90 | # qBitrr (setup.py) 91 | pathos==0.2.8 92 | # via qBitrr (setup.py) 93 | pathspec==0.9.0 94 | # via black 95 | peewee==3.14.7 96 | # via qBitrr (setup.py) 97 | pep517==0.12.0 98 | # via pip-tools 99 | ping3==3.0.2 100 | # via qBitrr (setup.py) 101 | pip-tools==6.4.0 102 | # via qBitrr (setup.py) 103 | pkginfo==1.8.2 104 | # via twine 105 | platformdirs==2.4.1 106 | # via 107 | # black 108 | # virtualenv 109 | pox==0.3.0 110 | # via pathos 111 | ppft==1.6.6.4 112 | # via pathos 113 | pre-commit==2.17.0 114 | # via qBitrr (setup.py) 115 | pyarr==2.0.6 116 | # via qBitrr (setup.py) 117 | pycparser==2.21 118 | # via cffi 119 | pygments==2.11.2 120 | # via readme-renderer 121 | pyinstaller==4.8 122 | # via qBitrr (setup.py) 123 | pyinstaller-hooks-contrib==2022.0 124 | # via pyinstaller 125 | pyparsing==3.0.7 126 | # via packaging 127 | pyupgrade==2.31.0 128 | # via qBitrr (setup.py) 129 | pyyaml==6.0 130 | # via pre-commit 131 | qbittorrent-api==2022.8.38 132 | # via qBitrr (setup.py) 133 | readme-renderer==32.0 134 | # via twine 135 | requests==2.26.0 136 | # via 137 | # pyarr 138 | # qBitrr (setup.py) 139 | # qbittorrent-api 140 | # requests-toolbelt 141 | # twine 142 | requests-toolbelt==0.9.1 143 | # via twine 144 | rfc3986==2.0.0 145 | # via twine 146 | secretstorage==3.3.1 147 | # via keyring 148 | six==1.16.0 149 | # via 150 | # bleach 151 | # ppft 152 | # qbittorrent-api 153 | # virtualenv 154 | tokenize-rt==4.2.1 155 | # via pyupgrade 156 | toml==0.10.2 157 | # via pre-commit 158 | tomli==2.0.0 159 | # via 160 | # black 161 | # pep517 162 | tomlkit==0.7.2 163 | # via qBitrr (setup.py) 164 | tqdm==4.62.3 165 | # via twine 166 | twine==3.7.1 167 | # via qBitrr (setup.py) 168 | typing-extensions==4.0.1 169 | # via black 170 | ujson==5.4.0 171 | # via qBitrr (setup.py) 172 | urllib3==1.26.8 173 | # via 174 | # qbittorrent-api 175 | # requests 176 | virtualenv==20.13.0 177 | # via pre-commit 178 | webencodings==0.5.1 179 | # via bleach 180 | wheel==0.37.1 181 | # via pip-tools 182 | zipp==3.7.0 183 | # via importlib-metadata 184 | 185 | # The following packages are considered to be unsafe in a requirements file: 186 | # pip 187 | # setuptools 188 | -------------------------------------------------------------------------------- /requirements.dev.txt: -------------------------------------------------------------------------------- 1 | # 2 | # This file is autogenerated by pip-compile with python 3.8 3 | # To update, run: 4 | # 5 | # pip-compile --extra=dev --output-file=requirements.dev.txt 6 | # 7 | altgraph==0.17.2 8 | # via pyinstaller 9 | attrs==21.4.0 10 | # via environ-config 11 | black==22.1.0 12 | # via qBitrr (setup.py) 13 | bleach==4.1.0 14 | # via readme-renderer 15 | bump2version==1.0.1 16 | # via qBitrr (setup.py) 17 | cachetools==4.2.4 18 | # via qBitrr (setup.py) 19 | certifi==2021.10.8 20 | # via requests 21 | cffi==1.15.0 22 | # via cryptography 23 | cfgv==3.3.1 24 | # via pre-commit 25 | charset-normalizer==2.0.11 26 | # via requests 27 | click==8.0.3 28 | # via 29 | # black 30 | # pip-tools 31 | colorama==0.4.4 32 | # via 33 | # qBitrr (setup.py) 34 | # twine 35 | coloredlogs==15.0.1 36 | # via qBitrr (setup.py) 37 | cryptography==36.0.1 38 | # via secretstorage 39 | dill==0.3.4 40 | # via 41 | # multiprocess 42 | # pathos 43 | distlib==0.3.4 44 | # via virtualenv 45 | docutils==0.18.1 46 | # via readme-renderer 47 | environ-config==21.2.0 48 | # via qBitrr (setup.py) 49 | ffmpeg-python==0.2.0 50 | # via qBitrr (setup.py) 51 | filelock==3.4.2 52 | # via virtualenv 53 | future==0.18.2 54 | # via ffmpeg-python 55 | humanfriendly==10.0 56 | # via coloredlogs 57 | identify==2.4.7 58 | # via pre-commit 59 | idna==3.3 60 | # via requests 61 | importlib-metadata==4.10.1 62 | # via 63 | # keyring 64 | # twine 65 | isort==5.10.1 66 | # via qBitrr (setup.py) 67 | jaraco.context==4.1.1 68 | # via jaraco.docker 69 | jaraco.docker==2.0 70 | # via qBitrr (setup.py) 71 | jaraco.functools==3.5.0 72 | # via jaraco.docker 73 | jeepney==0.7.1 74 | # via 75 | # keyring 76 | # secretstorage 77 | keyring==23.5.0 78 | # via twine 79 | more-itertools==8.12.0 80 | # via jaraco.functools 81 | multiprocess==0.70.12.2 82 | # via pathos 83 | mypy-extensions==0.4.3 84 | # via black 85 | nodeenv==1.6.0 86 | # via pre-commit 87 | packaging==21.3 88 | # via 89 | # bleach 90 | # qBitrr (setup.py) 91 | pathos==0.2.8 92 | # via qBitrr (setup.py) 93 | pathspec==0.9.0 94 | # via black 95 | peewee==3.14.7 96 | # via qBitrr (setup.py) 97 | pep517==0.12.0 98 | # via pip-tools 99 | ping3==3.0.2 100 | # via qBitrr (setup.py) 101 | pip-tools==6.4.0 102 | # via qBitrr (setup.py) 103 | pkginfo==1.8.2 104 | # via twine 105 | platformdirs==2.4.1 106 | # via 107 | # black 108 | # virtualenv 109 | pox==0.3.0 110 | # via pathos 111 | ppft==1.6.6.4 112 | # via pathos 113 | pre-commit==2.17.0 114 | # via qBitrr (setup.py) 115 | pyarr==2.0.6 116 | # via qBitrr (setup.py) 117 | pycparser==2.21 118 | # via cffi 119 | pygments==2.11.2 120 | # via readme-renderer 121 | pyinstaller==4.8 122 | # via qBitrr (setup.py) 123 | pyinstaller-hooks-contrib==2022.0 124 | # via pyinstaller 125 | pyparsing==3.0.7 126 | # via packaging 127 | pyupgrade==2.31.0 128 | # via qBitrr (setup.py) 129 | pyyaml==6.0 130 | # via pre-commit 131 | qbittorrent-api==2022.8.38 132 | # via qBitrr (setup.py) 133 | readme-renderer==32.0 134 | # via twine 135 | requests==2.26.0 136 | # via 137 | # pyarr 138 | # qBitrr (setup.py) 139 | # qbittorrent-api 140 | # requests-toolbelt 141 | # twine 142 | requests-toolbelt==0.9.1 143 | # via twine 144 | rfc3986==2.0.0 145 | # via twine 146 | secretstorage==3.3.1 147 | # via keyring 148 | six==1.16.0 149 | # via 150 | # bleach 151 | # ppft 152 | # qbittorrent-api 153 | # virtualenv 154 | tokenize-rt==4.2.1 155 | # via pyupgrade 156 | toml==0.10.2 157 | # via pre-commit 158 | tomli==2.0.0 159 | # via 160 | # black 161 | # pep517 162 | tomlkit==0.7.2 163 | # via qBitrr (setup.py) 164 | tqdm==4.62.3 165 | # via twine 166 | twine==3.7.1 167 | # via qBitrr (setup.py) 168 | typing-extensions==4.0.1 169 | # via black 170 | ujson==5.4.0 171 | # via qBitrr (setup.py) 172 | urllib3==1.26.8 173 | # via 174 | # qbittorrent-api 175 | # requests 176 | virtualenv==20.13.0 177 | # via pre-commit 178 | webencodings==0.5.1 179 | # via bleach 180 | wheel==0.37.1 181 | # via pip-tools 182 | zipp==3.7.0 183 | # via importlib-metadata 184 | 185 | # The following packages are considered to be unsafe in a requirements file: 186 | # pip 187 | # setuptools 188 | -------------------------------------------------------------------------------- /requirements.fast.txt: -------------------------------------------------------------------------------- 1 | # 2 | # This file is autogenerated by pip-compile with python 3.8 3 | # To update, run: 4 | # 5 | # pip-compile --extra=fast --output-file=requirements.fast.txt 6 | # 7 | attrs==21.4.0 8 | # via environ-config 9 | cachetools==4.2.4 10 | # via qBitrr (setup.py) 11 | certifi==2021.10.8 12 | # via requests 13 | charset-normalizer==2.0.11 14 | # via requests 15 | colorama==0.4.4 16 | # via qBitrr (setup.py) 17 | coloredlogs==15.0.1 18 | # via qBitrr (setup.py) 19 | dill==0.3.4 20 | # via 21 | # multiprocess 22 | # pathos 23 | environ-config==21.2.0 24 | # via qBitrr (setup.py) 25 | ffmpeg-python==0.2.0 26 | # via qBitrr (setup.py) 27 | future==0.18.2 28 | # via ffmpeg-python 29 | humanfriendly==10.0 30 | # via coloredlogs 31 | idna==3.3 32 | # via requests 33 | jaraco.context==4.1.1 34 | # via jaraco.docker 35 | jaraco.docker==2.0 36 | # via qBitrr (setup.py) 37 | jaraco.functools==3.5.0 38 | # via jaraco.docker 39 | more-itertools==8.12.0 40 | # via jaraco.functools 41 | multiprocess==0.70.12.2 42 | # via pathos 43 | packaging==21.3 44 | # via qBitrr (setup.py) 45 | pathos==0.2.8 46 | # via qBitrr (setup.py) 47 | peewee==3.14.7 48 | # via qBitrr (setup.py) 49 | ping3==3.0.2 50 | # via qBitrr (setup.py) 51 | pox==0.3.0 52 | # via pathos 53 | ppft==1.6.6.4 54 | # via pathos 55 | pyarr==2.0.6 56 | # via qBitrr (setup.py) 57 | pyparsing==3.0.7 58 | # via packaging 59 | qbittorrent-api==2022.8.38 60 | # via qBitrr (setup.py) 61 | requests==2.26.0 62 | # via 63 | # pyarr 64 | # qBitrr (setup.py) 65 | # qbittorrent-api 66 | six==1.16.0 67 | # via 68 | # ppft 69 | # qbittorrent-api 70 | tomlkit==0.7.2 71 | # via qBitrr (setup.py) 72 | ujson==5.4.0 73 | # via qBitrr (setup.py) 74 | urllib3==1.26.8 75 | # via 76 | # qbittorrent-api 77 | # requests 78 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | # 2 | # This file is autogenerated by pip-compile with python 3.8 3 | # To update, run: 4 | # 5 | # pip-compile --output-file=requirements.txt 6 | # 7 | attrs==21.4.0 8 | # via environ-config 9 | cachetools==4.2.4 10 | # via qBitrr (setup.py) 11 | certifi==2021.10.8 12 | # via requests 13 | charset-normalizer==2.0.11 14 | # via requests 15 | colorama==0.4.4 16 | # via qBitrr (setup.py) 17 | coloredlogs==15.0.1 18 | # via qBitrr (setup.py) 19 | dill==0.3.4 20 | # via 21 | # multiprocess 22 | # pathos 23 | environ-config==21.2.0 24 | # via qBitrr (setup.py) 25 | ffmpeg-python==0.2.0 26 | # via qBitrr (setup.py) 27 | future==0.18.2 28 | # via ffmpeg-python 29 | humanfriendly==10.0 30 | # via coloredlogs 31 | idna==3.3 32 | # via requests 33 | jaraco.context==4.1.1 34 | # via jaraco.docker 35 | jaraco.docker==2.0 36 | # via qBitrr (setup.py) 37 | jaraco.functools==3.5.0 38 | # via jaraco.docker 39 | more-itertools==8.12.0 40 | # via jaraco.functools 41 | multiprocess==0.70.12.2 42 | # via pathos 43 | packaging==21.3 44 | # via qBitrr (setup.py) 45 | pathos==0.2.8 46 | # via qBitrr (setup.py) 47 | peewee==3.14.7 48 | # via qBitrr (setup.py) 49 | ping3==3.0.2 50 | # via qBitrr (setup.py) 51 | pox==0.3.0 52 | # via pathos 53 | ppft==1.6.6.4 54 | # via pathos 55 | pyarr==2.0.6 56 | # via qBitrr (setup.py) 57 | pyparsing==3.0.7 58 | # via packaging 59 | qbittorrent-api==2022.8.38 60 | # via qBitrr (setup.py) 61 | requests==2.26.0 62 | # via 63 | # pyarr 64 | # qBitrr (setup.py) 65 | # qbittorrent-api 66 | six==1.16.0 67 | # via 68 | # ppft 69 | # qbittorrent-api 70 | tomlkit==0.7.2 71 | # via qBitrr (setup.py) 72 | urllib3==1.26.8 73 | # via 74 | # qbittorrent-api 75 | # requests 76 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | name = qBitrr 3 | version = 2.6.0 4 | description = A simple script to monitor Qbit and communicate with Radarr and Sonarr 5 | long_description = file: README.md 6 | long_description_content_type = text/markdown 7 | url = https://github.com/Drapersniper/Qbitrr 8 | author = Draper 9 | author_email = draper@draper.wtf 10 | license = MIT 11 | license_file = LICENSE 12 | license_files = 13 | LICENSE 14 | classifiers = 15 | Development Status :: 5 - Production/Stable 16 | Intended Audience :: Developers 17 | Intended Audience :: End Users/Desktop 18 | License :: OSI Approved :: MIT License 19 | Natural Language :: English 20 | Operating System :: MacOS :: MacOS X 21 | Operating System :: Microsoft :: Windows 22 | Operating System :: POSIX :: Linux 23 | Programming Language :: Python :: 3 :: Only 24 | Programming Language :: Python :: 3.8 25 | Programming Language :: Python :: 3.9 26 | Programming Language :: Python :: 3.10 27 | Programming Language :: Python :: Implementation :: CPython 28 | Programming Language :: Python :: Implementation :: PyPy 29 | Topic :: Terminals 30 | Topic :: Utilities 31 | Typing :: Typed 32 | description_file = README.md 33 | project_urls = 34 | Discord Server = https://discord.gg/FT3puape2A 35 | Issue Tracker = https://github.com/Drapersniper/Qbitrr/issues 36 | Source Code = https://github.com/Drapersniper/Qbitrr 37 | 38 | [options] 39 | packages = find_namespace: 40 | install_requires = 41 | cachetools==4.2.4 42 | colorama==0.4.4 43 | coloredlogs==15.0.1 44 | environ-config==21.2.0 45 | ffmpeg-python==0.2.0 46 | jaraco.docker==2.0 47 | packaging==21.3 48 | pathos==0.2.8 49 | peewee==3.14.7 50 | ping3==3.0.2 51 | pyarr==2.0.6 52 | qbittorrent-api==2022.8.38 53 | requests==2.26.0 54 | tomlkit==0.7.2 55 | python_requires = >=3.8.1,<4 56 | include_package_data = True 57 | 58 | [options.packages.find] 59 | include = 60 | qBitrr 61 | config.example.toml 62 | 63 | [options.entry_points] 64 | console_scripts = 65 | qbitrr=qBitrr.main:run 66 | 67 | [options.extras_require] 68 | dev = 69 | black==22.1.0 70 | bump2version==1.0.1 71 | isort==5.10.1 72 | pip-tools==6.4.0 73 | pre-commit==2.17.0 74 | pyinstaller==4.8 75 | pyupgrade==2.31.0 76 | twine==3.7.1 77 | ujson==5.4.0 78 | fast = 79 | ujson==5.4.0 80 | all = 81 | %(dev)s 82 | %(fast)s 83 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup 2 | 3 | setup() 4 | --------------------------------------------------------------------------------