# Requirements Updated
- "GitPython==3.1.45"
- "retrying==1.4.1",


# New Features
- **Remove Orphaned**: Adds new `min_file_age_minutes` flag to prevent
files newer than a certain time from being deleted (Thanks to @H2OKing89
#859)
- Adds new standalone script `ban_peers.py` for banning selected peers
(Thanks to @tboy1337 #888)

# Improvements
- Adds timeout detectiono for stuck runs for web API rqeeusts

# Bug Fixes
- Fix bug in webUI deleting nohardlink section (Fixes #884)


**Full Changelog**:
https://github.com/StuffAnThings/qbit_manage/compare/v4.5.1...v4.5.2

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: cat-of-wisdom <217637421+cat-of-wisdom@users.noreply.github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Quentin <qking.dev@gmail.com>
Co-authored-by: ineednewpajamas <73252768+ineednewpajamas@users.noreply.github.com>
Co-authored-by: tboy1337 <30571311+tboy1337@users.noreply.github.com>
Co-authored-by: tboy1337 <tboy1337.unchanged733@aleeas.com>
Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
This commit is contained in:
bobokun 2025-08-03 15:09:08 -04:00 committed by GitHub
parent ca4819bc0b
commit 13fab64d3c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 342 additions and 106 deletions

View file

@ -42,7 +42,7 @@ jobs:
password: ${{ secrets.GHCR_TOKEN }} password: ${{ secrets.GHCR_TOKEN }}
- name: Set up QEMU - name: Set up QEMU
uses: docker/setup-qemu-action@master uses: docker/setup-qemu-action@v3
with: with:
platforms: all platforms: all

View file

@ -4,6 +4,9 @@ on:
push: push:
branches: [ master ] branches: [ master ]
permissions:
contents: read
jobs: jobs:
docker-latest: docker-latest:
@ -19,13 +22,6 @@ jobs:
- name: Check Out Repo - name: Check Out Repo
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Trigger Hotio Webhook
uses: joelwmale/webhook-action@master
with:
url: ${{ secrets.HOTIO_WEBHOOK_URL }}
headers: '{"Authorization": "Bearer ${{ secrets.HOTIO_WEBHOOK_SECRET }}"}'
body: '{ "application": "qbitmanage", "branch": "latest" }'
- name: Login to Docker Hub - name: Login to Docker Hub
uses: docker/login-action@v3 uses: docker/login-action@v3
with: with:
@ -40,7 +36,7 @@ jobs:
password: ${{ secrets.GHCR_TOKEN }} password: ${{ secrets.GHCR_TOKEN }}
- name: Set up QEMU - name: Set up QEMU
uses: docker/setup-qemu-action@master uses: docker/setup-qemu-action@v3
with: with:
platforms: all platforms: all
@ -59,3 +55,43 @@ jobs:
tags: | tags: |
${{ secrets.DOCKER_HUB_USERNAME }}/qbit_manage:latest ${{ secrets.DOCKER_HUB_USERNAME }}/qbit_manage:latest
ghcr.io/${{ env.OWNER_LC }}/qbit_manage:latest ghcr.io/${{ env.OWNER_LC }}/qbit_manage:latest
update-develop:
runs-on: ubuntu-latest
needs: docker-latest
permissions:
contents: write
steps:
- name: Check Out Repo
uses: actions/checkout@v4
with:
token: ${{ secrets.GITHUB_TOKEN }}
fetch-depth: 0
- name: Update develop branch
run: |
git config --local user.email "action@github.com"
git config --local user.name "GitHub Action"
# Checkout develop branch
git checkout develop
# Reset develop to master
git reset --hard origin/master
# Read current version and bump minor patch
CURRENT_VERSION=$(cat VERSION)
IFS='.' read -r MAJOR MINOR PATCH <<< "$CURRENT_VERSION"
NEW_PATCH=$((PATCH + 1))
NEW_VERSION="${MAJOR}.${MINOR}.${NEW_PATCH}-develop1"
# Update VERSION file
echo "$NEW_VERSION" > VERSION
# Commit the change
git add VERSION
git commit -m "Update VERSION to $NEW_VERSION"
# Force push develop branch
git push --force origin develop

View file

@ -1,18 +1,20 @@
name: Tag name: Tag New Version
on: on:
push: push:
branches: [ master ] branches: [ master ]
jobs: jobs:
tag-new-versions: tag:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
with: with:
token: ${{ secrets.PAT }} token: ${{ secrets.PAT }}
fetch-depth: 2 fetch-depth: 2
- uses: salsify/action-detect-and-tag-new-version@v1.0.3
- uses: Kometa-Team/tag-new-version@master
with: with:
version-command: | version-command: |
cat VERSION cat VERSION

View file

@ -36,7 +36,7 @@ jobs:
password: ${{ secrets.GHCR_TOKEN }} password: ${{ secrets.GHCR_TOKEN }}
- name: Set up QEMU - name: Set up QEMU
uses: docker/setup-qemu-action@master uses: docker/setup-qemu-action@v3
with: with:
platforms: all platforms: all
@ -59,6 +59,7 @@ jobs:
tags: | tags: |
${{ secrets.DOCKER_HUB_USERNAME }}/qbit_manage:${{ steps.get_version.outputs.VERSION }} ${{ secrets.DOCKER_HUB_USERNAME }}/qbit_manage:${{ steps.get_version.outputs.VERSION }}
ghcr.io/${{ env.OWNER_LC }}/qbit_manage:${{ steps.get_version.outputs.VERSION }} ghcr.io/${{ env.OWNER_LC }}/qbit_manage:${{ steps.get_version.outputs.VERSION }}
- name: Create release - name: Create release
id: create_release id: create_release
uses: softprops/action-gh-release@v2 uses: softprops/action-gh-release@v2
@ -66,3 +67,10 @@ jobs:
body_path: CHANGELOG body_path: CHANGELOG
token: ${{ secrets.PAT }} token: ${{ secrets.PAT }}
tag_name: ${{ steps.get_version.outputs.VERSION }} tag_name: ${{ steps.get_version.outputs.VERSION }}
- name: Trigger Hotio Webhook
uses: joelwmale/webhook-action@master
with:
url: ${{ secrets.HOTIO_WEBHOOK_URL }}
headers: '{"Authorization": "Bearer ${{ secrets.HOTIO_WEBHOOK_SECRET }}"}'
body: '{ "application": "qbitmanage", "branch": "release" }'

View file

@ -25,7 +25,7 @@ repos:
exclude: ^.github/ exclude: ^.github/
- repo: https://github.com/astral-sh/ruff-pre-commit - repo: https://github.com/astral-sh/ruff-pre-commit
# Ruff version. # Ruff version.
rev: v0.12.3 rev: v0.12.5
hooks: hooks:
# Run the linter. # Run the linter.
- id: ruff - id: ruff

View file

@ -1,22 +1,17 @@
# Requirements Updated # Requirements Updated
- qbittorrent-api==2025.7.0 - "GitPython==3.1.45"
- fastapi==0.116.1 - "retrying==1.4.1",
# New Features # New Features
- **Uncategorized Category**: Allow multiple paths for Uncategorized category and add error handling (Thanks to @cat-of-wisdom #849) - **Remove Orphaned**: Adds new `min_file_age_minutes` flag to prevent files newer than a certain time from being deleted (Thanks to @H2OKing89 #859)
- **Config Auto Backup and Cleanup**: implement automatic backup rotation (30 most recent backups per config) and cleanup - Adds new standalone script `ban_peers.py` for banning selected peers (Thanks to @tboy1337 #888)
- **Web UI**: add base URL support for reverse proxy deployments (Fixes #871)
- **Share Limits**: add option to preserve upload speed limits when minimums unmet (New config option `reset_upload_speed_on_unmet_minimums`) (Fixes #835, #791)
# Improvements # Improvements
- Optimize webUI form rendering - Adds timeout detectiono for stuck runs for web API rqeeusts
- Better centralized error handling for qbitorrent API operations
- **Web UI**: add editable group names to share limit modal
# Bug Fixes # Bug Fixes
- Fix bug in remove orphaned to notify when there are 0 orphaned files - Fix bug in webUI deleting nohardlink section (Fixes #884)
- Fixes [Bug]: Cannot run on Python 3.9.18 #864
- fix(qbit): add error handling for qBittorrent API operations
**Full Changelog**: https://github.com/StuffAnThings/qbit_manage/compare/v4.5.0...v4.5.1
**Full Changelog**: https://github.com/StuffAnThings/qbit_manage/compare/v4.5.1...v4.5.2

View file

@ -1,7 +1,7 @@
{ {
"master": { "master": {
"qbit": "v5.1.0", "qbit": "v5.1.2",
"qbitapi": "2025.5.0" "qbitapi": "2025.7.0"
}, },
"develop": { "develop": {
"qbit": "v5.1.2", "qbit": "v5.1.2",

View file

@ -1 +1 @@
4.5.1 4.5.2

View file

@ -279,6 +279,10 @@ orphaned:
# If this variable is not defined it, the orphaned data will never be emptied. # If this variable is not defined it, the orphaned data will never be emptied.
# WARNING: Setting this variable to 0 will delete all files immediately upon script run! # WARNING: Setting this variable to 0 will delete all files immediately upon script run!
empty_after_x_days: 60 empty_after_x_days: 60
# Minimum age in minutes for files to be considered orphaned. Files newer than this will be protected from deletion.
# This helps prevent removal of files that are actively being uploaded or processed.
# Default: 0 (disabled age protection)
min_file_age_minutes: 30
# File patterns that will not be considered orphaned files. Handy for generated files that aren't part of the torrent but belong with the torrent's files # File patterns that will not be considered orphaned files. Handy for generated files that aren't part of the torrent but belong with the torrent's files
exclude_patterns: exclude_patterns:
- "**/.DS_Store" - "**/.DS_Store"

View file

@ -211,6 +211,9 @@ This is handy when you have automatically generated files that certain OSs decid
| `empty_after_x_days` | Will delete Orphaned data contents if the files have been in the Orphaned data for more than x days. (Uses date modified to track the time) | None | <center></center> | | `empty_after_x_days` | Will delete Orphaned data contents if the files have been in the Orphaned data for more than x days. (Uses date modified to track the time) | None | <center></center> |
| `exclude_patterns` | List of [patterns](https://commandbox.ortusbooks.com/usage/parameters/globbing-patterns) to exclude certain files from orphaned | None | <center></center> | | `exclude_patterns` | List of [patterns](https://commandbox.ortusbooks.com/usage/parameters/globbing-patterns) to exclude certain files from orphaned | None | <center></center> |
| `max_orphaned_files_to_delete` | This will help reduce the number of accidental large amount orphaned deletions in a single run. Set your desired threshold for the maximum number of orphaned files qbm will delete in a single run. (-1 to disable safeguards) | 50 | <center></center> | | `max_orphaned_files_to_delete` | This will help reduce the number of accidental large amount orphaned deletions in a single run. Set your desired threshold for the maximum number of orphaned files qbm will delete in a single run. (-1 to disable safeguards) | 50 | <center></center> |
| `min_file_age_minutes` | Minimum age in minutes for files to be considered orphaned. Files newer than this will be protected from deletion to prevent removal of actively uploading files. Set to 0 to disable age protection. | 0 | <center></center> |
> Note: The more time you place for the `empty_after_x_days:` variable the better, allowing you more time to catch any mistakes by the script. If the variable is set to `0` it will delete contents immediately after every script run. If the variable is not set it will never delete the contents of the Orphaned Data. > Note: The more time you place for the `empty_after_x_days:` variable the better, allowing you more time to catch any mistakes by the script. If the variable is set to `0` it will delete contents immediately after every script run. If the variable is not set it will never delete the contents of the Orphaned Data.
@ -316,7 +319,6 @@ Payload will be sent at the end of the run
} }
``` ```
### **Recheck Notifications** ### **Recheck Notifications**
Payload will be sent when rechecking/resuming a torrent that is paused Payload will be sent when rechecking/resuming a torrent that is paused

View file

@ -123,3 +123,19 @@ python scripts/update-readme-version.py <branch_name>
**Configuration:** **Configuration:**
- `versions_file_path`: Path to the `SUPPORTED_VERSIONS.json` file (default: `"SUPPORTED_VERSIONS.json"`). - `versions_file_path`: Path to the `SUPPORTED_VERSIONS.json` file (default: `"SUPPORTED_VERSIONS.json"`).
- The script automatically extracts the `qbittorrent-api` version from `pyproject.toml`. - The script automatically extracts the `qbittorrent-api` version from `pyproject.toml`.
### [`ban_peers.py`](scripts/ban_peers.py)
This script bans one or more peers from qBittorrent using the provided peer addresses in 'host:port' format or multiple separated by '|'.
**Usage:**
```bash
python scripts/ban_peers.py --peers "127.0.0.1:8080|example.com:80" [options]
```
**Arguments:**
- `--host`: qBittorrent host (default: "localhost").
- `--port`: qBittorrent port (default: 8080).
- `--user`: Username.
- `--pass`: Password.
- `--peers`: Peers to ban, separated by '|' (required).
- `--dry-run`: Dry run mode without banning.

View file

@ -918,6 +918,14 @@ class Config:
default=50, default=50,
min_int=-1, min_int=-1,
) )
self.orphaned["min_file_age_minutes"] = self.util.check_for_attribute(
self.data,
"min_file_age_minutes",
parent="orphaned",
var_type="int",
default=0,
min_int=0,
)
if self.commands["rem_orphaned"]: if self.commands["rem_orphaned"]:
exclude_orphaned = f"**{os.sep}{os.path.basename(self.orphaned_dir.rstrip(os.sep))}{os.sep}*" exclude_orphaned = f"**{os.sep}{os.path.basename(self.orphaned_dir.rstrip(os.sep))}{os.sep}*"
( (

View file

@ -19,7 +19,7 @@ class RemoveOrphaned:
self.root_dir = qbit_manager.config.root_dir self.root_dir = qbit_manager.config.root_dir
self.orphaned_dir = qbit_manager.config.orphaned_dir self.orphaned_dir = qbit_manager.config.orphaned_dir
max_workers = max(os.cpu_count() * 2, 4) # Increased workers for I/O bound operations max_workers = max((os.cpu_count() or 1) * 2, 4) # Increased workers for I/O bound operations
with ThreadPoolExecutor(max_workers=max_workers) as executor: with ThreadPoolExecutor(max_workers=max_workers) as executor:
self.executor = executor self.executor = executor
self.rem_orphaned() self.rem_orphaned()
@ -68,6 +68,38 @@ class RemoveOrphaned:
logger.print_line("No Orphaned Files found.", self.config.loglevel) logger.print_line("No Orphaned Files found.", self.config.loglevel)
return return
# === AGE PROTECTION: Don't touch files that are "too new" (likely being created/uploaded) ===
min_file_age_minutes = self.config.orphaned.get("min_file_age_minutes", 0) # Pull from config, default to 0 (disabled)
now = time.time()
protected_files = set()
if min_file_age_minutes > 0: # Only apply age protection if configured
for file in orphaned_files:
try:
# Get file modification time
file_mtime = os.path.getmtime(file)
file_age_minutes = (now - file_mtime) / 60
if file_age_minutes < min_file_age_minutes:
protected_files.add(file)
logger.print_line(
f"Skipping orphaned file (too new): {os.path.basename(file)} "
f"(age {file_age_minutes:.1f} mins < {min_file_age_minutes} mins)",
self.config.loglevel,
)
except Exception as e:
logger.error(f"Error checking file age for {file}: {e}")
# Remove protected files from orphaned files
orphaned_files -= protected_files
if protected_files:
logger.print_line(
f"Protected {len(protected_files)} orphaned files from deletion due to age filter "
f"(min_file_age_minutes={min_file_age_minutes})",
self.config.loglevel,
)
# Check threshold # Check threshold
max_orphaned_files_to_delete = self.config.orphaned.get("max_orphaned_files_to_delete") max_orphaned_files_to_delete = self.config.orphaned.get("max_orphaned_files_to_delete")
if len(orphaned_files) > max_orphaned_files_to_delete and max_orphaned_files_to_delete != -1: if len(orphaned_files) > max_orphaned_files_to_delete and max_orphaned_files_to_delete != -1:

View file

@ -20,6 +20,7 @@ from typing import Any
from typing import Optional from typing import Optional
import ruamel.yaml import ruamel.yaml
from fastapi import APIRouter
from fastapi import FastAPI from fastapi import FastAPI
from fastapi import HTTPException from fastapi import HTTPException
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
@ -185,32 +186,41 @@ class WebAPI:
allow_headers=["*"], allow_headers=["*"],
) )
# Initialize routes # Create API router with clean route definitions
self.app.post("/api/run-command")(self.run_command) api_router = APIRouter()
# Define all API routes on the router
api_router.post("/run-command")(self.run_command)
# Configuration management routes # Configuration management routes
self.app.get("/api/configs")(self.list_configs) api_router.get("/configs")(self.list_configs)
self.app.get("/api/configs/{filename}")(self.get_config) api_router.get("/configs/{filename}")(self.get_config)
self.app.post("/api/configs/{filename}")(self.create_config) api_router.post("/configs/{filename}")(self.create_config)
self.app.put("/api/configs/{filename}")(self.update_config) api_router.put("/configs/{filename}")(self.update_config)
self.app.delete("/api/configs/{filename}")(self.delete_config) api_router.delete("/configs/{filename}")(self.delete_config)
self.app.post("/api/configs/{filename}/validate")(self.validate_config) api_router.post("/configs/{filename}/validate")(self.validate_config)
self.app.post("/api/configs/{filename}/backup")(self.backup_config) api_router.post("/configs/{filename}/backup")(self.backup_config)
self.app.get("/api/configs/{filename}/backups")(self.list_config_backups) api_router.get("/configs/{filename}/backups")(self.list_config_backups)
self.app.post("/api/configs/{filename}/restore")(self.restore_config_from_backup) api_router.post("/configs/{filename}/restore")(self.restore_config_from_backup)
self.app.get("/api/logs")(self.get_logs) api_router.get("/logs")(self.get_logs)
self.app.get("/api/log_files")(self.list_log_files) api_router.get("/log_files")(self.list_log_files)
self.app.get("/api/version")(self.get_version) api_router.get("/version")(self.get_version)
self.app.get("/api/health")(self.health_check) api_router.get("/health")(self.health_check)
self.app.get("/api/get_base_url")(self.get_base_url) api_router.get("/get_base_url")(self.get_base_url)
# Include the API router with the appropriate prefix
api_prefix = base_url + "/api" if base_url else "/api"
self.app.include_router(api_router, prefix=api_prefix)
# Mount static files for web UI # Mount static files for web UI
web_ui_dir = Path(__file__).parent.parent / "web-ui" web_ui_dir = Path(__file__).parent.parent / "web-ui"
if web_ui_dir.exists(): if web_ui_dir.exists():
self.app.mount("/static", StaticFiles(directory=str(web_ui_dir)), name="static")
# If base URL is configured, also mount static files at the base URL path
if base_url: if base_url:
self.app.mount(base_url + "/static", StaticFiles(directory=str(web_ui_dir)), name="base_static") # When base URL is configured, mount static files at the base URL path
self.app.mount(f"{base_url}/static", StaticFiles(directory=str(web_ui_dir)), name="base_static")
else:
# Default static file mounting
self.app.mount("/static", StaticFiles(directory=str(web_ui_dir)), name="static")
# Root route to serve web UI # Root route to serve web UI
@self.app.get("/") @self.app.get("/")
@ -240,11 +250,16 @@ class WebAPI:
# Catch-all route for SPA routing (must be last) # Catch-all route for SPA routing (must be last)
@self.app.get("/{full_path:path}") @self.app.get("/{full_path:path}")
async def catch_all(full_path: str): async def catch_all(full_path: str):
# For any non-API route that doesn't start with static/, serve the index.html (SPA routing) # Determine what paths should be excluded from SPA routing
if not full_path.startswith("api/") and not full_path.startswith("static/"): api_path = f"{base_url.lstrip('/')}/api" if base_url else "api"
static_path = f"{base_url.lstrip('/')}/static" if base_url else "static"
# For any non-API route that doesn't start with api/ or static/, serve the index.html (SPA routing)
if not full_path.startswith(f"{api_path}/") and not full_path.startswith(f"{static_path}/"):
web_ui_path = Path(__file__).parent.parent / "web-ui" / "index.html" web_ui_path = Path(__file__).parent.parent / "web-ui" / "index.html"
if web_ui_path.exists(): if web_ui_path.exists():
return FileResponse(str(web_ui_path)) return FileResponse(str(web_ui_path))
raise HTTPException(status_code=404, detail="Not found") raise HTTPException(status_code=404, detail="Not found")
# Note: Lifespan events are now handled in the lifespan context manager above # Note: Lifespan events are now handled in the lifespan context manager above
@ -789,6 +804,11 @@ class WebAPI:
if self.is_running_lock.acquire(timeout=0.1): if self.is_running_lock.acquire(timeout=0.1):
try: try:
if self.is_running.value: if self.is_running.value:
# Check if the process has been stuck for too long
if hasattr(self, "_last_run_start") and (datetime.now() - self._last_run_start).total_seconds() > 3600:
logger.warning("Previous run appears to be stuck. Forcing reset of is_running flag.")
self.is_running.value = False
else:
logger.info("Another run is in progress. Queuing web API request...") logger.info("Another run is in progress. Queuing web API request...")
self.web_api_queue.put(request) self.web_api_queue.put(request)
return { return {
@ -799,6 +819,7 @@ class WebAPI:
} }
# Atomic operation: set flag to True # Atomic operation: set flag to True
self.is_running.value = True self.is_running.value = True
self._last_run_start = datetime.now() # Track when this run started
finally: finally:
# Release lock immediately after atomic operation # Release lock immediately after atomic operation
self.is_running_lock.release() self.is_running_lock.release()
@ -824,7 +845,11 @@ class WebAPI:
# Execute the command outside the lock # Execute the command outside the lock
try: try:
return await self._execute_command(request) result = await self._execute_command(request)
# Ensure is_running is reset after successful execution
with self.is_running_lock:
self.is_running.value = False
return result
except HTTPException as e: except HTTPException as e:
# Ensure is_running is reset if an HTTPException occurs # Ensure is_running is reset if an HTTPException occurs
with self.is_running_lock: with self.is_running_lock:

View file

@ -20,12 +20,12 @@ dependencies = [
"bencodepy==0.9.5", "bencodepy==0.9.5",
"croniter==6.0.0", "croniter==6.0.0",
"fastapi==0.116.1", "fastapi==0.116.1",
"GitPython==3.1.44", "GitPython==3.1.45",
"humanize==4.12.3", "humanize==4.12.3",
"pytimeparse2==1.7.1", "pytimeparse2==1.7.1",
"qbittorrent-api==2025.7.0", "qbittorrent-api==2025.7.0",
"requests==2.32.4", "requests==2.32.4",
"retrying==1.4.0", "retrying==1.4.1",
"ruamel.yaml==0.18.14", "ruamel.yaml==0.18.14",
"schedule==1.2.2", "schedule==1.2.2",
"uvicorn==0.35.0", "uvicorn==0.35.0",
@ -38,7 +38,7 @@ Repository = "https://github.com/StuffAnThings/qbit_manage"
[project.optional-dependencies] [project.optional-dependencies]
dev = [ dev = [
"pre-commit==4.2.0", "pre-commit==4.2.0",
"ruff==0.12.4", "ruff==0.12.7",
] ]
[tool.ruff] [tool.ruff]

85
scripts/ban_peers.py Normal file
View file

@ -0,0 +1,85 @@
import argparse
import logging
import re
import sys
from qbittorrentapi import Client
from qbittorrentapi.exceptions import APIError
logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s")
logger = logging.getLogger(__name__)
def validate_peers(peers_str):
if not isinstance(peers_str, str):
raise ValueError("Peers must be a string")
peer_addresses = [peer.strip() for peer in peers_str.split("|") if peer.strip()]
valid_peers = []
peer_pattern = re.compile(
r"^(?:"
r"(?:[0-9]{1,3}\.){3}[0-9]{1,3}|" # IPv4
r"\[[0-9a-fA-F:]+\]|" # IPv6
r"[a-zA-Z0-9.-]+" # Hostname
r"):[0-9]{1,5}$"
)
for peer in peer_addresses:
if peer_pattern.match(peer):
try:
port = int(peer.split(":")[-1])
if 1 <= port <= 65535:
valid_peers.append(peer)
else:
logger.warning(f"Invalid port range for peer: {peer}")
except ValueError:
logger.warning(f"Invalid port for peer: {peer}")
else:
logger.warning(f"Invalid peer format: {peer}")
return valid_peers
def ban_peers(client, peer_list, dry_run):
if not peer_list:
logger.info("No valid peers to ban")
return 0
logger.info(f"Attempting to ban {len(peer_list)} peer(s): {', '.join(peer_list)}")
if dry_run:
for peer in peer_list:
logger.info(f"[DRY-RUN] - {peer}")
return len(peer_list)
try:
peers_string = "|".join(peer_list)
client.transfer.ban_peers(peers=peers_string)
logger.info(f"Successfully banned {len(peer_list)} peer(s)")
return len(peer_list)
except APIError as e:
logger.error(f"Error banning peers: {str(e)}")
return 0
def main():
parser = argparse.ArgumentParser(description="Ban peers in qBittorrent.")
parser.add_argument("--host", default="localhost", help="qBittorrent host")
parser.add_argument("--port", type=int, default=8080, help="qBittorrent port")
parser.add_argument("--user", help="Username")
parser.add_argument("--pass", dest="password", help="Password")
parser.add_argument("--peers", required=True, help="Peers to ban, separated by |")
parser.add_argument("--dry-run", action="store_true", help="Dry run mode")
args = parser.parse_args()
try:
client = Client(
host=args.host,
port=args.port,
username=args.user,
password=args.password,
)
client.auth_log_in()
except Exception as e:
logger.error(f"Failed to connect: {e}")
sys.exit(1)
valid_peers = validate_peers(args.peers)
stats = ban_peers(client, valid_peers, args.dry_run)
logger.info(f"Banned {stats} peers")
if __name__ == "__main__":
main()

81
uv.lock generated
View file

@ -38,11 +38,11 @@ sdist = { url = "https://files.pythonhosted.org/packages/d8/72/e2ee9f8a93c92af1b
[[package]] [[package]]
name = "certifi" name = "certifi"
version = "2025.7.9" version = "2025.7.14"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/de/8a/c729b6b60c66a38f590c4e774decc4b2ec7b0576be8f1aa984a53ffa812a/certifi-2025.7.9.tar.gz", hash = "sha256:c1d2ec05395148ee10cf672ffc28cd37ea0ab0d99f9cc74c43e588cbd111b079", size = 160386, upload-time = "2025-07-09T02:13:58.874Z" } sdist = { url = "https://files.pythonhosted.org/packages/b3/76/52c535bcebe74590f296d6c77c86dabf761c41980e1347a2422e4aa2ae41/certifi-2025.7.14.tar.gz", hash = "sha256:8ea99dbdfaaf2ba2f9bac77b9249ef62ec5218e7c2b2e903378ed5fccf765995", size = 163981, upload-time = "2025-07-14T03:29:28.449Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/66/f3/80a3f974c8b535d394ff960a11ac20368e06b736da395b551a49ce950cce/certifi-2025.7.9-py3-none-any.whl", hash = "sha256:d842783a14f8fdd646895ac26f719a061408834473cfc10203f6a575beb15d39", size = 159230, upload-time = "2025-07-09T02:13:57.007Z" }, { url = "https://files.pythonhosted.org/packages/4f/52/34c6cf5bb9285074dc3531c437b3919e825d976fde097a7a73f79e726d03/certifi-2025.7.14-py3-none-any.whl", hash = "sha256:6b31f564a415d79ee77df69d757bb49a5bb53bd9f756cbbe24394ffd6fc1f4b2", size = 162722, upload-time = "2025-07-14T03:29:26.863Z" },
] ]
[[package]] [[package]]
@ -182,11 +182,11 @@ wheels = [
[[package]] [[package]]
name = "distlib" name = "distlib"
version = "0.3.9" version = "0.4.0"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/0d/dd/1bec4c5ddb504ca60fc29472f3d27e8d4da1257a854e1d96742f15c1d02d/distlib-0.3.9.tar.gz", hash = "sha256:a60f20dea646b8a33f3e7772f74dc0b2d0772d2837ee1342a00645c81edf9403", size = 613923, upload-time = "2024-10-09T18:35:47.551Z" } sdist = { url = "https://files.pythonhosted.org/packages/96/8e/709914eb2b5749865801041647dc7f4e6d00b549cfe88b65ca192995f07c/distlib-0.4.0.tar.gz", hash = "sha256:feec40075be03a04501a973d81f633735b4b69f98b05450592310c0f401a4e0d", size = 614605, upload-time = "2025-07-17T16:52:00.465Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/91/a1/cf2472db20f7ce4a6be1253a81cfdf85ad9c7885ffbed7047fb72c24cf87/distlib-0.3.9-py2.py3-none-any.whl", hash = "sha256:47f8c22fd27c27e25a65601af709b38e4f0a45ea4fc2e710f65755fa8caaaf87", size = 468973, upload-time = "2024-10-09T18:35:44.272Z" }, { url = "https://files.pythonhosted.org/packages/33/6b/e0547afaf41bf2c42e52430072fa5658766e3d65bd4b03a563d1b6336f57/distlib-0.4.0-py2.py3-none-any.whl", hash = "sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16", size = 469047, upload-time = "2025-07-17T16:51:58.613Z" },
] ]
[[package]] [[package]]
@ -238,14 +238,15 @@ wheels = [
[[package]] [[package]]
name = "gitpython" name = "gitpython"
version = "3.1.44" version = "3.1.45"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "gitdb" }, { name = "gitdb" },
{ name = "typing-extensions", marker = "python_full_version < '3.10'" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/c0/89/37df0b71473153574a5cdef8f242de422a0f5d26d7a9e231e6f169b4ad14/gitpython-3.1.44.tar.gz", hash = "sha256:c87e30b26253bf5418b01b0660f818967f3c503193838337fe5e573331249269", size = 214196, upload-time = "2025-01-02T07:32:43.59Z" } sdist = { url = "https://files.pythonhosted.org/packages/9a/c8/dd58967d119baab745caec2f9d853297cec1989ec1d63f677d3880632b88/gitpython-3.1.45.tar.gz", hash = "sha256:85b0ee964ceddf211c41b9f27a49086010a190fd8132a24e21f362a4b36a791c", size = 215076, upload-time = "2025-07-24T03:45:54.871Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/1d/9a/4114a9057db2f1462d5c8f8390ab7383925fe1ac012eaa42402ad65c2963/GitPython-3.1.44-py3-none-any.whl", hash = "sha256:9e0e10cda9bed1ee64bc9a6de50e7e38a9c9943241cd7f585f6df3ed28011110", size = 207599, upload-time = "2025-01-02T07:32:40.731Z" }, { url = "https://files.pythonhosted.org/packages/01/61/d4b89fec821f72385526e1b9d9a3a0385dda4a72b206d28049e2c7cd39b8/gitpython-3.1.45-py3-none-any.whl", hash = "sha256:8908cb2e02fb3b93b7eb0f2827125cb699869470432cc885f019b8fd0fccff77", size = 208168, upload-time = "2025-07-24T03:45:52.517Z" },
] ]
[[package]] [[package]]
@ -563,15 +564,15 @@ requires-dist = [
{ name = "bencodepy", specifier = "==0.9.5" }, { name = "bencodepy", specifier = "==0.9.5" },
{ name = "croniter", specifier = "==6.0.0" }, { name = "croniter", specifier = "==6.0.0" },
{ name = "fastapi", specifier = "==0.116.1" }, { name = "fastapi", specifier = "==0.116.1" },
{ name = "gitpython", specifier = "==3.1.44" }, { name = "gitpython", specifier = "==3.1.45" },
{ name = "humanize", specifier = "==4.12.3" }, { name = "humanize", specifier = "==4.12.3" },
{ name = "pre-commit", marker = "extra == 'dev'", specifier = "==4.2.0" }, { name = "pre-commit", marker = "extra == 'dev'", specifier = "==4.2.0" },
{ name = "pytimeparse2", specifier = "==1.7.1" }, { name = "pytimeparse2", specifier = "==1.7.1" },
{ name = "qbittorrent-api", specifier = "==2025.7.0" }, { name = "qbittorrent-api", specifier = "==2025.7.0" },
{ name = "requests", specifier = "==2.32.4" }, { name = "requests", specifier = "==2.32.4" },
{ name = "retrying", specifier = "==1.4.0" }, { name = "retrying", specifier = "==1.4.1" },
{ name = "ruamel-yaml", specifier = "==0.18.14" }, { name = "ruamel-yaml", specifier = "==0.18.14" },
{ name = "ruff", marker = "extra == 'dev'", specifier = "==0.12.3" }, { name = "ruff", marker = "extra == 'dev'", specifier = "==0.12.5" },
{ name = "schedule", specifier = "==1.2.2" }, { name = "schedule", specifier = "==1.2.2" },
{ name = "uvicorn", specifier = "==0.35.0" }, { name = "uvicorn", specifier = "==0.35.0" },
] ]
@ -608,11 +609,11 @@ wheels = [
[[package]] [[package]]
name = "retrying" name = "retrying"
version = "1.4.0" version = "1.4.1"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/4e/e5/986cabb44cc073a8bf50c3d8d4c85514c6741fff78ebf853a0ebcd441a97/retrying-1.4.0.tar.gz", hash = "sha256:efa99c78bf4fbdbe6f0cba4101470fbc684b93d30ca45ffa1288443a9805172f", size = 11202, upload-time = "2025-06-24T10:08:59.091Z" } sdist = { url = "https://files.pythonhosted.org/packages/6a/2e/90b236e496810c23eb428a1f3e2723849eb219d6196a4f7afe16f4981b5c/retrying-1.4.1.tar.gz", hash = "sha256:4d206e0ed2aff5ef2f3cd867abb9511e9e8f31127c5aca20f1d5246e476903b0", size = 11344, upload-time = "2025-07-19T09:39:01.906Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/96/7e/5a83e2c56761d347128e58b3f23af829fd145bfb1afeff18927bc5915459/retrying-1.4.0-py3-none-any.whl", hash = "sha256:6509d829c70271937605bce361c8f76e91f9123d355d14df7dc6972b1518064a", size = 11972, upload-time = "2025-06-24T10:08:57.794Z" }, { url = "https://files.pythonhosted.org/packages/7f/25/f3b628e123699139b959551ed922f35af97fa1505e195ae3e6537a14fbc3/retrying-1.4.1-py3-none-any.whl", hash = "sha256:d736050c1adfc0a71fa022d9198ee130b0e66be318678a3fdd8b1b8872dc0997", size = 12184, upload-time = "2025-07-19T09:39:00.574Z" },
] ]
[[package]] [[package]]
@ -682,27 +683,27 @@ wheels = [
[[package]] [[package]]
name = "ruff" name = "ruff"
version = "0.12.3" version = "0.12.5"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/c3/2a/43955b530c49684d3c38fcda18c43caf91e99204c2a065552528e0552d4f/ruff-0.12.3.tar.gz", hash = "sha256:f1b5a4b6668fd7b7ea3697d8d98857390b40c1320a63a178eee6be0899ea2d77", size = 4459341, upload-time = "2025-07-11T13:21:16.086Z" } sdist = { url = "https://files.pythonhosted.org/packages/30/cd/01015eb5034605fd98d829c5839ec2c6b4582b479707f7c1c2af861e8258/ruff-0.12.5.tar.gz", hash = "sha256:b209db6102b66f13625940b7f8c7d0f18e20039bb7f6101fbdac935c9612057e", size = 5170722, upload-time = "2025-07-24T13:26:37.456Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/e2/fd/b44c5115539de0d598d75232a1cc7201430b6891808df111b8b0506aae43/ruff-0.12.3-py3-none-linux_armv6l.whl", hash = "sha256:47552138f7206454eaf0c4fe827e546e9ddac62c2a3d2585ca54d29a890137a2", size = 10430499, upload-time = "2025-07-11T13:20:26.321Z" }, { url = "https://files.pythonhosted.org/packages/d4/de/ad2f68f0798ff15dd8c0bcc2889558970d9a685b3249565a937cd820ad34/ruff-0.12.5-py3-none-linux_armv6l.whl", hash = "sha256:1de2c887e9dec6cb31fcb9948299de5b2db38144e66403b9660c9548a67abd92", size = 11819133, upload-time = "2025-07-24T13:25:56.369Z" },
{ url = "https://files.pythonhosted.org/packages/43/c5/9eba4f337970d7f639a37077be067e4ec80a2ad359e4cc6c5b56805cbc66/ruff-0.12.3-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:0a9153b000c6fe169bb307f5bd1b691221c4286c133407b8827c406a55282041", size = 11213413, upload-time = "2025-07-11T13:20:30.017Z" }, { url = "https://files.pythonhosted.org/packages/f8/fc/c6b65cd0e7fbe60f17e7ad619dca796aa49fbca34bb9bea5f8faf1ec2643/ruff-0.12.5-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:d1ab65e7d8152f519e7dea4de892317c9da7a108da1c56b6a3c1d5e7cf4c5e9a", size = 12501114, upload-time = "2025-07-24T13:25:59.471Z" },
{ url = "https://files.pythonhosted.org/packages/e2/2c/fac3016236cf1fe0bdc8e5de4f24c76ce53c6dd9b5f350d902549b7719b2/ruff-0.12.3-py3-none-macosx_11_0_arm64.whl", hash = "sha256:fa6b24600cf3b750e48ddb6057e901dd5b9aa426e316addb2a1af185a7509882", size = 10586941, upload-time = "2025-07-11T13:20:33.046Z" }, { url = "https://files.pythonhosted.org/packages/c5/de/c6bec1dce5ead9f9e6a946ea15e8d698c35f19edc508289d70a577921b30/ruff-0.12.5-py3-none-macosx_11_0_arm64.whl", hash = "sha256:962775ed5b27c7aa3fdc0d8f4d4433deae7659ef99ea20f783d666e77338b8cf", size = 11716873, upload-time = "2025-07-24T13:26:01.496Z" },
{ url = "https://files.pythonhosted.org/packages/c5/0f/41fec224e9dfa49a139f0b402ad6f5d53696ba1800e0f77b279d55210ca9/ruff-0.12.3-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e2506961bf6ead54887ba3562604d69cb430f59b42133d36976421bc8bd45901", size = 10783001, upload-time = "2025-07-11T13:20:35.534Z" }, { url = "https://files.pythonhosted.org/packages/a1/16/cf372d2ebe91e4eb5b82a2275c3acfa879e0566a7ac94d331ea37b765ac8/ruff-0.12.5-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:73b4cae449597e7195a49eb1cdca89fd9fbb16140c7579899e87f4c85bf82f73", size = 11958829, upload-time = "2025-07-24T13:26:03.721Z" },
{ url = "https://files.pythonhosted.org/packages/0d/ca/dd64a9ce56d9ed6cad109606ac014860b1c217c883e93bf61536400ba107/ruff-0.12.3-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c4faaff1f90cea9d3033cbbcdf1acf5d7fb11d8180758feb31337391691f3df0", size = 10269641, upload-time = "2025-07-11T13:20:38.459Z" }, { url = "https://files.pythonhosted.org/packages/25/bf/cd07e8f6a3a6ec746c62556b4c4b79eeb9b0328b362bb8431b7b8afd3856/ruff-0.12.5-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8b13489c3dc50de5e2d40110c0cce371e00186b880842e245186ca862bf9a1ac", size = 11626619, upload-time = "2025-07-24T13:26:06.118Z" },
{ url = "https://files.pythonhosted.org/packages/63/5c/2be545034c6bd5ce5bb740ced3e7014d7916f4c445974be11d2a406d5088/ruff-0.12.3-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:40dced4a79d7c264389de1c59467d5d5cefd79e7e06d1dfa2c75497b5269a5a6", size = 11875059, upload-time = "2025-07-11T13:20:41.517Z" }, { url = "https://files.pythonhosted.org/packages/d8/c9/c2ccb3b8cbb5661ffda6925f81a13edbb786e623876141b04919d1128370/ruff-0.12.5-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f1504fea81461cf4841778b3ef0a078757602a3b3ea4b008feb1308cb3f23e08", size = 13221894, upload-time = "2025-07-24T13:26:08.292Z" },
{ url = "https://files.pythonhosted.org/packages/8e/d4/a74ef1e801ceb5855e9527dae105eaff136afcb9cc4d2056d44feb0e4792/ruff-0.12.3-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:0262d50ba2767ed0fe212aa7e62112a1dcbfd46b858c5bf7bbd11f326998bafc", size = 12658890, upload-time = "2025-07-11T13:20:44.442Z" }, { url = "https://files.pythonhosted.org/packages/6b/58/68a5be2c8e5590ecdad922b2bcd5583af19ba648f7648f95c51c3c1eca81/ruff-0.12.5-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:c7da4129016ae26c32dfcbd5b671fe652b5ab7fc40095d80dcff78175e7eddd4", size = 14163909, upload-time = "2025-07-24T13:26:10.474Z" },
{ url = "https://files.pythonhosted.org/packages/13/c8/1057916416de02e6d7c9bcd550868a49b72df94e3cca0aeb77457dcd9644/ruff-0.12.3-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:12371aec33e1a3758597c5c631bae9a5286f3c963bdfb4d17acdd2d395406687", size = 12232008, upload-time = "2025-07-11T13:20:47.374Z" }, { url = "https://files.pythonhosted.org/packages/bd/d1/ef6b19622009ba8386fdb792c0743f709cf917b0b2f1400589cbe4739a33/ruff-0.12.5-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ca972c80f7ebcfd8af75a0f18b17c42d9f1ef203d163669150453f50ca98ab7b", size = 13583652, upload-time = "2025-07-24T13:26:13.381Z" },
{ url = "https://files.pythonhosted.org/packages/f5/59/4f7c130cc25220392051fadfe15f63ed70001487eca21d1796db46cbcc04/ruff-0.12.3-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:560f13b6baa49785665276c963edc363f8ad4b4fc910a883e2625bdb14a83a9e", size = 11499096, upload-time = "2025-07-11T13:20:50.348Z" }, { url = "https://files.pythonhosted.org/packages/62/e3/1c98c566fe6809a0c83751d825a03727f242cdbe0d142c9e292725585521/ruff-0.12.5-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8dbbf9f25dfb501f4237ae7501d6364b76a01341c6f1b2cd6764fe449124bb2a", size = 12700451, upload-time = "2025-07-24T13:26:15.488Z" },
{ url = "https://files.pythonhosted.org/packages/d4/01/a0ad24a5d2ed6be03a312e30d32d4e3904bfdbc1cdbe63c47be9d0e82c79/ruff-0.12.3-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:023040a3499f6f974ae9091bcdd0385dd9e9eb4942f231c23c57708147b06311", size = 11688307, upload-time = "2025-07-11T13:20:52.945Z" }, { url = "https://files.pythonhosted.org/packages/24/ff/96058f6506aac0fbc0d0fc0d60b0d0bd746240a0594657a2d94ad28033ba/ruff-0.12.5-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2c47dea6ae39421851685141ba9734767f960113d51e83fd7bb9958d5be8763a", size = 12937465, upload-time = "2025-07-24T13:26:17.808Z" },
{ url = "https://files.pythonhosted.org/packages/93/72/08f9e826085b1f57c9a0226e48acb27643ff19b61516a34c6cab9d6ff3fa/ruff-0.12.3-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:883d844967bffff5ab28bba1a4d246c1a1b2933f48cb9840f3fdc5111c603b07", size = 10661020, upload-time = "2025-07-11T13:20:55.799Z" }, { url = "https://files.pythonhosted.org/packages/eb/d3/68bc5e7ab96c94b3589d1789f2dd6dd4b27b263310019529ac9be1e8f31b/ruff-0.12.5-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:c5076aa0e61e30f848846f0265c873c249d4b558105b221be1828f9f79903dc5", size = 11771136, upload-time = "2025-07-24T13:26:20.422Z" },
{ url = "https://files.pythonhosted.org/packages/80/a0/68da1250d12893466c78e54b4a0ff381370a33d848804bb51279367fc688/ruff-0.12.3-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:2120d3aa855ff385e0e562fdee14d564c9675edbe41625c87eeab744a7830d12", size = 10246300, upload-time = "2025-07-11T13:20:58.222Z" }, { url = "https://files.pythonhosted.org/packages/52/75/7356af30a14584981cabfefcf6106dea98cec9a7af4acb5daaf4b114845f/ruff-0.12.5-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:a5a4c7830dadd3d8c39b1cc85386e2c1e62344f20766be6f173c22fb5f72f293", size = 11601644, upload-time = "2025-07-24T13:26:22.928Z" },
{ url = "https://files.pythonhosted.org/packages/6a/22/5f0093d556403e04b6fd0984fc0fb32fbb6f6ce116828fd54306a946f444/ruff-0.12.3-py3-none-musllinux_1_2_i686.whl", hash = "sha256:6b16647cbb470eaf4750d27dddc6ebf7758b918887b56d39e9c22cce2049082b", size = 11263119, upload-time = "2025-07-11T13:21:01.503Z" }, { url = "https://files.pythonhosted.org/packages/c2/67/91c71d27205871737cae11025ee2b098f512104e26ffd8656fd93d0ada0a/ruff-0.12.5-py3-none-musllinux_1_2_i686.whl", hash = "sha256:46699f73c2b5b137b9dc0fc1a190b43e35b008b398c6066ea1350cce6326adcb", size = 12478068, upload-time = "2025-07-24T13:26:26.134Z" },
{ url = "https://files.pythonhosted.org/packages/92/c9/f4c0b69bdaffb9968ba40dd5fa7df354ae0c73d01f988601d8fac0c639b1/ruff-0.12.3-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:e1417051edb436230023575b149e8ff843a324557fe0a265863b7602df86722f", size = 11746990, upload-time = "2025-07-11T13:21:04.524Z" }, { url = "https://files.pythonhosted.org/packages/34/04/b6b00383cf2f48e8e78e14eb258942fdf2a9bf0287fbf5cdd398b749193a/ruff-0.12.5-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:5a655a0a0d396f0f072faafc18ebd59adde8ca85fb848dc1b0d9f024b9c4d3bb", size = 12991537, upload-time = "2025-07-24T13:26:28.533Z" },
{ url = "https://files.pythonhosted.org/packages/fe/84/7cc7bd73924ee6be4724be0db5414a4a2ed82d06b30827342315a1be9e9c/ruff-0.12.3-py3-none-win32.whl", hash = "sha256:dfd45e6e926deb6409d0616078a666ebce93e55e07f0fb0228d4b2608b2c248d", size = 10589263, upload-time = "2025-07-11T13:21:07.148Z" }, { url = "https://files.pythonhosted.org/packages/3e/b9/053d6445dc7544fb6594785056d8ece61daae7214859ada4a152ad56b6e0/ruff-0.12.5-py3-none-win32.whl", hash = "sha256:dfeb2627c459b0b78ca2bbdc38dd11cc9a0a88bf91db982058b26ce41714ffa9", size = 11751575, upload-time = "2025-07-24T13:26:30.835Z" },
{ url = "https://files.pythonhosted.org/packages/07/87/c070f5f027bd81f3efee7d14cb4d84067ecf67a3a8efb43aadfc72aa79a6/ruff-0.12.3-py3-none-win_amd64.whl", hash = "sha256:a946cf1e7ba3209bdef039eb97647f1c77f6f540e5845ec9c114d3af8df873e7", size = 11695072, upload-time = "2025-07-11T13:21:11.004Z" }, { url = "https://files.pythonhosted.org/packages/bc/0f/ab16e8259493137598b9149734fec2e06fdeda9837e6f634f5c4e35916da/ruff-0.12.5-py3-none-win_amd64.whl", hash = "sha256:ae0d90cf5f49466c954991b9d8b953bd093c32c27608e409ae3564c63c5306a5", size = 12882273, upload-time = "2025-07-24T13:26:32.929Z" },
{ url = "https://files.pythonhosted.org/packages/e0/30/f3eaf6563c637b6e66238ed6535f6775480db973c836336e4122161986fc/ruff-0.12.3-py3-none-win_arm64.whl", hash = "sha256:5f9c7c9c8f84c2d7f27e93674d27136fbf489720251544c4da7fb3d742e011b1", size = 10805855, upload-time = "2025-07-11T13:21:13.547Z" }, { url = "https://files.pythonhosted.org/packages/00/db/c376b0661c24cf770cb8815268190668ec1330eba8374a126ceef8c72d55/ruff-0.12.5-py3-none-win_arm64.whl", hash = "sha256:48cdbfc633de2c5c37d9f090ba3b352d1576b0015bfc3bc98eaf230275b7e805", size = 11951564, upload-time = "2025-07-24T13:26:34.994Z" },
] ]
[[package]] [[package]]
@ -743,15 +744,15 @@ wheels = [
[[package]] [[package]]
name = "starlette" name = "starlette"
version = "0.47.1" version = "0.47.2"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "anyio" }, { name = "anyio" },
{ name = "typing-extensions", marker = "python_full_version < '3.13'" }, { name = "typing-extensions", marker = "python_full_version < '3.13'" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/0a/69/662169fdb92fb96ec3eaee218cf540a629d629c86d7993d9651226a6789b/starlette-0.47.1.tar.gz", hash = "sha256:aef012dd2b6be325ffa16698f9dc533614fb1cebd593a906b90dc1025529a79b", size = 2583072, upload-time = "2025-06-21T04:03:17.337Z" } sdist = { url = "https://files.pythonhosted.org/packages/04/57/d062573f391d062710d4088fa1369428c38d51460ab6fedff920efef932e/starlette-0.47.2.tar.gz", hash = "sha256:6ae9aa5db235e4846decc1e7b79c4f346adf41e9777aebeb49dfd09bbd7023d8", size = 2583948, upload-time = "2025-07-20T17:31:58.522Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/82/95/38ef0cd7fa11eaba6a99b3c4f5ac948d8bc6ff199aabd327a29cc000840c/starlette-0.47.1-py3-none-any.whl", hash = "sha256:5e11c9f5c7c3f24959edbf2dffdc01bba860228acf657129467d8a7468591527", size = 72747, upload-time = "2025-06-21T04:03:15.705Z" }, { url = "https://files.pythonhosted.org/packages/f7/1f/b876b1f83aef204198a42dc101613fefccb32258e5428b5f9259677864b4/starlette-0.47.2-py3-none-any.whl", hash = "sha256:c5847e96134e5c5371ee9fac6fdf1a67336d5815e09eb2a01fdb57a351ef915b", size = 72984, upload-time = "2025-07-20T17:31:56.738Z" },
] ]
[[package]] [[package]]
@ -801,14 +802,14 @@ wheels = [
[[package]] [[package]]
name = "virtualenv" name = "virtualenv"
version = "20.31.2" version = "20.32.0"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "distlib" }, { name = "distlib" },
{ name = "filelock" }, { name = "filelock" },
{ name = "platformdirs" }, { name = "platformdirs" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/56/2c/444f465fb2c65f40c3a104fd0c495184c4f2336d65baf398e3c75d72ea94/virtualenv-20.31.2.tar.gz", hash = "sha256:e10c0a9d02835e592521be48b332b6caee6887f332c111aa79a09b9e79efc2af", size = 6076316, upload-time = "2025-05-08T17:58:23.811Z" } sdist = { url = "https://files.pythonhosted.org/packages/a9/96/0834f30fa08dca3738614e6a9d42752b6420ee94e58971d702118f7cfd30/virtualenv-20.32.0.tar.gz", hash = "sha256:886bf75cadfdc964674e6e33eb74d787dff31ca314ceace03ca5810620f4ecf0", size = 6076970, upload-time = "2025-07-21T04:09:50.985Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/f3/40/b1c265d4b2b62b58576588510fc4d1fe60a86319c8de99fd8e9fec617d2c/virtualenv-20.31.2-py3-none-any.whl", hash = "sha256:36efd0d9650ee985f0cad72065001e66d49a6f24eb44d98980f630686243cf11", size = 6057982, upload-time = "2025-05-08T17:58:21.15Z" }, { url = "https://files.pythonhosted.org/packages/5c/c6/f8f28009920a736d0df434b52e9feebfb4d702ba942f15338cb4a83eafc1/virtualenv-20.32.0-py3-none-any.whl", hash = "sha256:2c310aecb62e5aa1b06103ed7c2977b81e042695de2697d01017ff0f1034af56", size = 6057761, upload-time = "2025-07-21T04:09:48.059Z" },
] ]

View file

@ -400,6 +400,19 @@ class QbitManageApp {
const fullConfigData = { ...this.configData, ...dataToSave }; const fullConfigData = { ...this.configData, ...dataToSave };
// Preprocess nohardlinks before saving to fix null/empty values
if (fullConfigData.nohardlinks && typeof fullConfigData.nohardlinks === 'object' && fullConfigData.nohardlinks !== null) {
Object.keys(fullConfigData.nohardlinks).forEach(key => {
const value = fullConfigData.nohardlinks[key];
if (value === null || (typeof value === 'object' && Object.keys(value).length === 0)) {
fullConfigData.nohardlinks[key] = {
ignore_root_dir: true,
exclude_tags: []
};
}
});
}
// Remove UI-only fields that should never be saved // Remove UI-only fields that should never be saved
delete fullConfigData.apply_to_all_value; delete fullConfigData.apply_to_all_value;

View file

@ -1160,7 +1160,8 @@ class ConfigForm {
} else if (typeof processedData === 'object' && processedData !== null) { } else if (typeof processedData === 'object' && processedData !== null) {
// Handle object format: ensure all entries have proper structure // Handle object format: ensure all entries have proper structure
Object.entries(processedData).forEach(([categoryName, categoryProps]) => { Object.entries(processedData).forEach(([categoryName, categoryProps]) => {
if (categoryProps === null || categoryProps === undefined) { // Handle cases where category props are null, undefined, or an empty object
if (categoryProps === null || categoryProps === undefined || (typeof categoryProps === 'object' && Object.keys(categoryProps).length === 0)) {
processedData[categoryName] = { processedData[categoryName] = {
exclude_tags: [], exclude_tags: [],
ignore_root_dir: true ignore_root_dir: true

View file

@ -23,6 +23,14 @@ export const orphanedSchema = {
description: 'The maximum number of orphaned files to delete in a single run. This is a safeguard to prevent accidental mass deletions. Set to -1 to disable.', description: 'The maximum number of orphaned files to delete in a single run. This is a safeguard to prevent accidental mass deletions. Set to -1 to disable.',
default: 50, default: 50,
min: -1 min: -1
},
{
name: 'min_file_age_minutes',
type: 'number',
label: 'Minimum File Age (Minutes)',
description: 'Minimum age in minutes for files to be considered orphaned. Files newer than this will be protected from deletion to prevent removal of actively uploading files. Set to 0 to disable age protection.',
default: 0,
min: 0
} }
] ]
}; };