# 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 }}
- name: Set up QEMU
uses: docker/setup-qemu-action@master
uses: docker/setup-qemu-action@v3
with:
platforms: all

View file

@ -4,6 +4,9 @@ on:
push:
branches: [ master ]
permissions:
contents: read
jobs:
docker-latest:
@ -19,13 +22,6 @@ jobs:
- name: Check Out Repo
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
uses: docker/login-action@v3
with:
@ -40,7 +36,7 @@ jobs:
password: ${{ secrets.GHCR_TOKEN }}
- name: Set up QEMU
uses: docker/setup-qemu-action@master
uses: docker/setup-qemu-action@v3
with:
platforms: all
@ -59,3 +55,43 @@ jobs:
tags: |
${{ secrets.DOCKER_HUB_USERNAME }}/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:
push:
branches: [ master ]
jobs:
tag-new-versions:
tag:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
token: ${{ secrets.PAT }}
fetch-depth: 2
- uses: salsify/action-detect-and-tag-new-version@v1.0.3
- uses: Kometa-Team/tag-new-version@master
with:
version-command: |
cat VERSION

View file

@ -36,7 +36,7 @@ jobs:
password: ${{ secrets.GHCR_TOKEN }}
- name: Set up QEMU
uses: docker/setup-qemu-action@master
uses: docker/setup-qemu-action@v3
with:
platforms: all
@ -59,6 +59,7 @@ jobs:
tags: |
${{ secrets.DOCKER_HUB_USERNAME }}/qbit_manage:${{ steps.get_version.outputs.VERSION }}
ghcr.io/${{ env.OWNER_LC }}/qbit_manage:${{ steps.get_version.outputs.VERSION }}
- name: Create release
id: create_release
uses: softprops/action-gh-release@v2
@ -66,3 +67,10 @@ jobs:
body_path: CHANGELOG
token: ${{ secrets.PAT }}
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/
- repo: https://github.com/astral-sh/ruff-pre-commit
# Ruff version.
rev: v0.12.3
rev: v0.12.5
hooks:
# Run the linter.
- id: ruff

View file

@ -1,22 +1,17 @@
# Requirements Updated
- qbittorrent-api==2025.7.0
- fastapi==0.116.1
- "GitPython==3.1.45"
- "retrying==1.4.1",
# New Features
- **Uncategorized Category**: Allow multiple paths for Uncategorized category and add error handling (Thanks to @cat-of-wisdom #849)
- **Config Auto Backup and Cleanup**: implement automatic backup rotation (30 most recent backups per config) and cleanup
- **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)
- **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
- Optimize webUI form rendering
- Better centralized error handling for qbitorrent API operations
- **Web UI**: add editable group names to share limit modal
- Adds timeout detectiono for stuck runs for web API rqeeusts
# Bug Fixes
- Fix bug in remove orphaned to notify when there are 0 orphaned files
- Fixes [Bug]: Cannot run on Python 3.9.18 #864
- fix(qbit): add error handling for qBittorrent API operations
- Fix bug in webUI deleting nohardlink section (Fixes #884)
**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": {
"qbit": "v5.1.0",
"qbitapi": "2025.5.0"
"qbit": "v5.1.2",
"qbitapi": "2025.7.0"
},
"develop": {
"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.
# WARNING: Setting this variable to 0 will delete all files immediately upon script run!
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
exclude_patterns:
- "**/.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> |
| `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> |
| `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.
@ -316,7 +319,6 @@ Payload will be sent at the end of the run
}
```
### **Recheck Notifications**
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:**
- `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`.
### [`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,
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"]:
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.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:
self.executor = executor
self.rem_orphaned()
@ -68,6 +68,38 @@ class RemoveOrphaned:
logger.print_line("No Orphaned Files found.", self.config.loglevel)
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
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:

View file

@ -20,6 +20,7 @@ from typing import Any
from typing import Optional
import ruamel.yaml
from fastapi import APIRouter
from fastapi import FastAPI
from fastapi import HTTPException
from fastapi.middleware.cors import CORSMiddleware
@ -185,32 +186,41 @@ class WebAPI:
allow_headers=["*"],
)
# Initialize routes
self.app.post("/api/run-command")(self.run_command)
# Create API router with clean route definitions
api_router = APIRouter()
# Define all API routes on the router
api_router.post("/run-command")(self.run_command)
# Configuration management routes
self.app.get("/api/configs")(self.list_configs)
self.app.get("/api/configs/{filename}")(self.get_config)
self.app.post("/api/configs/{filename}")(self.create_config)
self.app.put("/api/configs/{filename}")(self.update_config)
self.app.delete("/api/configs/{filename}")(self.delete_config)
self.app.post("/api/configs/{filename}/validate")(self.validate_config)
self.app.post("/api/configs/{filename}/backup")(self.backup_config)
self.app.get("/api/configs/{filename}/backups")(self.list_config_backups)
self.app.post("/api/configs/{filename}/restore")(self.restore_config_from_backup)
self.app.get("/api/logs")(self.get_logs)
self.app.get("/api/log_files")(self.list_log_files)
self.app.get("/api/version")(self.get_version)
self.app.get("/api/health")(self.health_check)
self.app.get("/api/get_base_url")(self.get_base_url)
api_router.get("/configs")(self.list_configs)
api_router.get("/configs/{filename}")(self.get_config)
api_router.post("/configs/{filename}")(self.create_config)
api_router.put("/configs/{filename}")(self.update_config)
api_router.delete("/configs/{filename}")(self.delete_config)
api_router.post("/configs/{filename}/validate")(self.validate_config)
api_router.post("/configs/{filename}/backup")(self.backup_config)
api_router.get("/configs/{filename}/backups")(self.list_config_backups)
api_router.post("/configs/{filename}/restore")(self.restore_config_from_backup)
api_router.get("/logs")(self.get_logs)
api_router.get("/log_files")(self.list_log_files)
api_router.get("/version")(self.get_version)
api_router.get("/health")(self.health_check)
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
web_ui_dir = Path(__file__).parent.parent / "web-ui"
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:
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
@self.app.get("/")
@ -240,11 +250,16 @@ class WebAPI:
# Catch-all route for SPA routing (must be last)
@self.app.get("/{full_path:path}")
async def catch_all(full_path: str):
# For any non-API route that doesn't start with static/, serve the index.html (SPA routing)
if not full_path.startswith("api/") and not full_path.startswith("static/"):
# Determine what paths should be excluded from SPA routing
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"
if web_ui_path.exists():
return FileResponse(str(web_ui_path))
raise HTTPException(status_code=404, detail="Not found")
# Note: Lifespan events are now handled in the lifespan context manager above
@ -789,16 +804,22 @@ class WebAPI:
if self.is_running_lock.acquire(timeout=0.1):
try:
if self.is_running.value:
logger.info("Another run is in progress. Queuing web API request...")
self.web_api_queue.put(request)
return {
"status": "queued",
"message": "Another run is in progress. Request queued.",
"config_file": request.config_file,
"commands": request.commands,
}
# 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...")
self.web_api_queue.put(request)
return {
"status": "queued",
"message": "Another run is in progress. Request queued.",
"config_file": request.config_file,
"commands": request.commands,
}
# Atomic operation: set flag to True
self.is_running.value = True
self._last_run_start = datetime.now() # Track when this run started
finally:
# Release lock immediately after atomic operation
self.is_running_lock.release()
@ -824,7 +845,11 @@ class WebAPI:
# Execute the command outside the lock
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:
# Ensure is_running is reset if an HTTPException occurs
with self.is_running_lock:

View file

@ -20,12 +20,12 @@ dependencies = [
"bencodepy==0.9.5",
"croniter==6.0.0",
"fastapi==0.116.1",
"GitPython==3.1.44",
"GitPython==3.1.45",
"humanize==4.12.3",
"pytimeparse2==1.7.1",
"qbittorrent-api==2025.7.0",
"requests==2.32.4",
"retrying==1.4.0",
"retrying==1.4.1",
"ruamel.yaml==0.18.14",
"schedule==1.2.2",
"uvicorn==0.35.0",
@ -38,7 +38,7 @@ Repository = "https://github.com/StuffAnThings/qbit_manage"
[project.optional-dependencies]
dev = [
"pre-commit==4.2.0",
"ruff==0.12.4",
"ruff==0.12.7",
]
[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]]
name = "certifi"
version = "2025.7.9"
version = "2025.7.14"
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 = [
{ 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]]
@ -182,11 +182,11 @@ wheels = [
[[package]]
name = "distlib"
version = "0.3.9"
version = "0.4.0"
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 = [
{ 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]]
@ -238,14 +238,15 @@ wheels = [
[[package]]
name = "gitpython"
version = "3.1.44"
version = "3.1.45"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ 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 = [
{ 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]]
@ -563,15 +564,15 @@ requires-dist = [
{ name = "bencodepy", specifier = "==0.9.5" },
{ name = "croniter", specifier = "==6.0.0" },
{ 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 = "pre-commit", marker = "extra == 'dev'", specifier = "==4.2.0" },
{ name = "pytimeparse2", specifier = "==1.7.1" },
{ name = "qbittorrent-api", specifier = "==2025.7.0" },
{ 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 = "ruff", marker = "extra == 'dev'", specifier = "==0.12.3" },
{ name = "ruff", marker = "extra == 'dev'", specifier = "==0.12.5" },
{ name = "schedule", specifier = "==1.2.2" },
{ name = "uvicorn", specifier = "==0.35.0" },
]
@ -608,11 +609,11 @@ wheels = [
[[package]]
name = "retrying"
version = "1.4.0"
version = "1.4.1"
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 = [
{ 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]]
@ -682,27 +683,27 @@ wheels = [
[[package]]
name = "ruff"
version = "0.12.3"
version = "0.12.5"
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 = [
{ 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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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]]
@ -743,15 +744,15 @@ wheels = [
[[package]]
name = "starlette"
version = "0.47.1"
version = "0.47.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
{ 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 = [
{ 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]]
@ -801,14 +802,14 @@ wheels = [
[[package]]
name = "virtualenv"
version = "20.31.2"
version = "20.32.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "distlib" },
{ name = "filelock" },
{ 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 = [
{ 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 };
// 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
delete fullConfigData.apply_to_all_value;

View file

@ -1160,7 +1160,8 @@ class ConfigForm {
} else if (typeof processedData === 'object' && processedData !== null) {
// Handle object format: ensure all entries have proper structure
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] = {
exclude_tags: [],
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.',
default: 50,
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
}
]
};