Compare commits
182 Commits
ac5113055d
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| f3bf67a641 | |||
| b98505fd31 | |||
| 99692ef217 | |||
| 48f68cf125 | |||
| 03ce088d24 | |||
| d5fa689dad | |||
| a73aeb5544 | |||
| c796a740d1 | |||
| 5195809869 | |||
| 0f1ffc1c3d | |||
| fc7ca12a01 | |||
| 3533b694f7 | |||
| a1cc5ac0f9 | |||
| ef8585fe66 | |||
| 337259a099 | |||
| f173cc0a62 | |||
| aa87ced07b | |||
| b710f6e6f9 | |||
| 7fea2bfef8 | |||
| 005867999f | |||
| fc76ca19a1 | |||
| 4c30e45e73 | |||
| d48c3331c6 | |||
| d0ec2fe820 | |||
| 8bc2924bea | |||
| bfa8a28add | |||
| 630521778d | |||
| f1129fd3c4 | |||
| a2cdc203c2 | |||
| 90989e81ad | |||
| 85512a7ba0 | |||
| c83b817456 | |||
| 35bbaae704 | |||
| 16e3917fce | |||
| 8517c504fb | |||
| 348d7b8119 | |||
| b32408562a | |||
| 5191479cff | |||
| 1b30d05620 | |||
| 31c7952f32 | |||
| c3969a5f28 | |||
| 90055b415c | |||
| c21a3ad944 | |||
| 8990f2b404 | |||
| 51e00a4e37 | |||
| 79e0ce8051 | |||
| 30f3f97f56 | |||
| d00eb6ee2b | |||
| d01af5e5c1 | |||
| c261ab9ea2 | |||
| 648291e5d8 | |||
| 151546d3f5 | |||
| 4133a478e4 | |||
| ee4c1bfece | |||
| 6db15dbe3b | |||
| eaaa02b122 | |||
| 88d956676e | |||
| fefe3602eb | |||
| 9ee65cbf07 | |||
| 7bf24e39f9 | |||
| f62a032566 | |||
| 18331702f4 | |||
| 02e70194b0 | |||
| 8f879bfca7 | |||
| 494aad457c | |||
| 15a4c8e6a6 | |||
| 369212e1e5 | |||
| 2fb1335bb7 | |||
| 0877dbdf0f | |||
| 6365ee3a88 | |||
| 434daa1f21 | |||
| 67f01e750e | |||
| 9b71b01e97 | |||
| 3b3bdcf47b | |||
| 8e4ec823d2 | |||
| 36a1159b98 | |||
| 4628ea653d | |||
| 4dcddfd8b7 | |||
| f29710b24f | |||
| 2b36e8e8af | |||
| 6451afa5bc | |||
| 81a47c5fea | |||
| fc182966bd | |||
| 62572ec273 | |||
| 68d8ddc8d7 | |||
| 91e91e7e47 | |||
| 63c2a8f3ba | |||
| 0477754249 | |||
| 1068aba11c | |||
| 973d8d4774 | |||
| c48a467b3d | |||
| a49133de8d | |||
| 6b8321e6e6 | |||
| b6a5003f2c | |||
| 5fbc2428b6 | |||
| 62bc76e806 | |||
| 37d64079e9 | |||
| 4fd18e3216 | |||
| f04eb7016f | |||
| ef851d82c3 | |||
| 0612b5129d | |||
| 27dec2ee32 | |||
| 50c7bba9e5 | |||
| 75f6c61877 | |||
| ce0edc2e39 | |||
| dd5b3070f0 | |||
| 6f2c266e7c | |||
| 3256ae34fe | |||
| 56a29c7a97 | |||
| 4c8debb103 | |||
| 15078c30da | |||
| 5eeb0da092 | |||
| 8c1cc23a8d | |||
| 0408f7859e | |||
| 05a26a6cfe | |||
| 76ffe32319 | |||
| e4310797c8 | |||
| 1651075f40 | |||
| a611113d2a | |||
| 46fec57ab8 | |||
| 1768b30df6 | |||
| 31895f9783 | |||
| 01c5c54c10 | |||
| 80c71c8d79 | |||
| a8adee0f2f | |||
| 054c9122f8 | |||
| 4075e934eb | |||
| 1eb3aeff6c | |||
| 869af8756f | |||
| f0da24f484 | |||
| 514482f0b5 | |||
| 70a9344cdd | |||
| 8268ad87cf | |||
| 32c780793b | |||
| 92d870878f | |||
| 629b06a9df | |||
| 5ab750226a | |||
| 77a161a7f6 | |||
| 81d9556443 | |||
| 0ee0f3424c | |||
| 680a673a9a | |||
| 1df01e8cc6 | |||
| 109811c024 | |||
| 2e2d747fa2 | |||
| ff7d836b77 | |||
| 9021b09bc5 | |||
| 58d1c7a761 | |||
| 93aaca553b | |||
| 352c53617c | |||
| f79e072610 | |||
| e298edd1e3 | |||
| 17b497a32b | |||
| 80bb921148 | |||
| f8eddd6fd5 | |||
| 778717d8b3 | |||
| d44cbe2429 | |||
| 0398dd9d39 | |||
| a9ebf901ab | |||
| 173ac3951a | |||
| 5a11730ee0 | |||
| 9caa155324 | |||
| 8553615fbf | |||
| 953616e126 | |||
| 57e45ea858 | |||
| c69142e328 | |||
| 7c0a4ff703 | |||
| 00a3831386 | |||
| 6aea0c1ad9 | |||
| 0a0ee9e8e5 | |||
| d383d89994 | |||
| cae6d4163b | |||
| 4956322677 | |||
| c62640ba99 | |||
| e27ffbb6e2 | |||
| b772c97d50 | |||
| cb48735178 | |||
| 9142590c79 | |||
| c2948ea277 | |||
| d0026ab7f9 | |||
| b0b3497eec | |||
| 4e009ccf05 | |||
| bd9be0d11c |
+44
-10
@@ -3,8 +3,8 @@ PYTORRENT_DB_PATH=data/pytorrent.sqlite3
|
||||
PYTORRENT_HOST=0.0.0.0
|
||||
PYTORRENT_PORT=8090
|
||||
PYTORRENT_DEBUG=0
|
||||
PYTORRENT_POLL_INTERVAL=0.5
|
||||
MIN_POLL_INTERVAL_SECONDS=0.5
|
||||
PYTORRENT_POLL_INTERVAL=1
|
||||
MIN_POLL_INTERVAL_SECONDS=1
|
||||
PYTORRENT_WORKERS=16
|
||||
PYTORRENT_GEOIP_DB=data/GeoLite2-City.mmdb
|
||||
PYTORRENT_ALLOW_UNSAFE_WERKZEUG=0
|
||||
@@ -13,14 +13,6 @@ PYTORRENT_SCGI_RETRIES=8
|
||||
# css/js libs
|
||||
PYTORRENT_USE_OFFLINE_LIBS=true
|
||||
|
||||
# python -m pytorrent.cli reset-password admin new_Pass
|
||||
PYTORRENT_AUTH_ENABLE=false
|
||||
|
||||
# Reverse proxy / HTTPS
|
||||
PYTORRENT_PROXY_FIX_ENABLE=false
|
||||
PYTORRENT_SESSION_COOKIE_SECURE=false
|
||||
# PYTORRENT_SOCKETIO_CORS_ALLOWED_ORIGINS=https://your-domain.com
|
||||
|
||||
# Retention / Smart Queue
|
||||
PYTORRENT_TRAFFIC_HISTORY_RETENTION_DAYS=90
|
||||
PYTORRENT_JOBS_RETENTION_DAYS=30
|
||||
@@ -37,8 +29,50 @@ PYTORRENT_SMART_QUEUE_DIAGNOSTICS=none
|
||||
PYTORRENT_SMART_QUEUE_DIAGNOSTICS_MAX_ITEMS=200
|
||||
|
||||
# Logs
|
||||
PYTORRENT_LOG_ENABLE=false
|
||||
PYTORRENT_LOG_DIR=data/logs
|
||||
PYTORRENT_LOG_RETENTION_HOURS=24
|
||||
PYTORRENT_GUNICORN_ACCESS_LOG=data/logs/gunicorn-access.log
|
||||
PYTORRENT_GUNICORN_ERROR_LOG=data/logs/gunicorn-error.log
|
||||
PYTORRENT_GUNICORN_LOG_LEVEL=info
|
||||
|
||||
#### AUTH
|
||||
|
||||
# python -m pytorrent.cli reset-password admin new_Pass
|
||||
PYTORRENT_AUTH_ENABLE=false
|
||||
|
||||
# Authentication provider
|
||||
# Available variants:
|
||||
# - local = built-in login screen with username/password
|
||||
# - tinyauth = external auth via Tinyauth / reverse proxy headers
|
||||
# - proxy = generic external reverse proxy auth
|
||||
PYTORRENT_AUTH_PROVIDER=tinyauth
|
||||
|
||||
# Headers passed by Tinyauth
|
||||
PYTORRENT_AUTH_PROXY_USER_HEADER=Remote-User
|
||||
|
||||
# Headers passed by external reverse proxy
|
||||
#PYTORRENT_AUTH_PROXY_USER_HEADER=X-Forwarded-User
|
||||
|
||||
# Auto-create user when authenticated externally but missing in DB
|
||||
PYTORRENT_AUTH_PROXY_AUTO_CREATE=true
|
||||
|
||||
# Defaults for auto-created users
|
||||
PYTORRENT_AUTH_PROXY_AUTO_CREATE_ROLE=admin
|
||||
PYTORRENT_AUTH_PROXY_AUTO_CREATE_PERMISSION=rw
|
||||
|
||||
# Reverse proxy / HTTPS
|
||||
PYTORRENT_PROXY_FIX_ENABLE=true
|
||||
PYTORRENT_SESSION_COOKIE_SECURE=false
|
||||
#PYTORRENT_SOCKETIO_CORS_ALLOWED_ORIGINS=https://pytorrent.domain.com
|
||||
#PYTORRENT_API_ALLOWED_ORIGINS=https://pytorrent.domain.com
|
||||
|
||||
# bypass auth on specific hosts (ex. local ip)
|
||||
PYTORRENT_AUTH_BYPASS_HOSTS=10.11.1.11:8090,10.11.1.11
|
||||
PYTORRENT_AUTH_BYPASS_USER=admin
|
||||
|
||||
# db vacuum
|
||||
PYTORRENT_DB_VACUUM_ENABLE=true
|
||||
PYTORRENT_DB_VACUUM_EVERY_SECONDS=86400
|
||||
PYTORRENT_DB_VACUUM_MIN_FREE_MB=512
|
||||
PYTORRENT_DB_VACUUM_MIN_FREE_RATIO=0.25
|
||||
+5
-2
@@ -40,6 +40,9 @@ data/logs/*
|
||||
!data/logs/
|
||||
!data/logs/README.md
|
||||
|
||||
|
||||
todo.txt
|
||||
pytorrent/static/libs/*
|
||||
!pytorrent/static/libs/pytorrent-themes/
|
||||
!pytorrent/static/libs/pytorrent-themes/**
|
||||
*/static/libs/
|
||||
smart_queue_scoring_todo.md
|
||||
data/mock_rtorrent_state.json
|
||||
-198
@@ -1,198 +0,0 @@
|
||||
# pyTorrent stack installer
|
||||
|
||||
This document describes the one-command installer for installing **rTorrent + pyTorrent** from a clean server.
|
||||
|
||||
The installer is split into two layers:
|
||||
|
||||
- `scripts/install_stack.sh` - public bootstrap script intended to be downloaded directly from Git.
|
||||
- `scripts/stack_installers/` - OS-specific installers and helper scripts used by the bootstrap script.
|
||||
|
||||
## Quick install
|
||||
|
||||
Run as root or through `sudo`:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://git.linuxiarz.pl/gru/pyTorrent/raw/branch/master/scripts/install_stack.sh | sudo bash
|
||||
```
|
||||
|
||||
The bootstrap script downloads the current pyTorrent repository, detects the operating system family, and runs the matching installer:
|
||||
|
||||
- Debian / Ubuntu: `scripts/stack_installers/install_stack_debian_ubuntu.sh`
|
||||
- RHEL-compatible systems: `scripts/stack_installers/install_stack_rhel.sh`
|
||||
|
||||
Supported RHEL-compatible systems include RHEL, Rocky Linux, AlmaLinux, CentOS Stream, and Fedora-like systems where `dnf` or `yum` is available.
|
||||
|
||||
## What gets installed
|
||||
|
||||
Default installation includes:
|
||||
|
||||
- rTorrent `v0.16.11`
|
||||
- libtorrent `v0.16.11`
|
||||
- minimal rTorrent build without c-ares/custom curl
|
||||
- rTorrent system user: `rtorrent`
|
||||
- rTorrent SCGI endpoint: `scgi://127.0.0.1:5000`
|
||||
- rTorrent incoming BitTorrent port: `51300`
|
||||
- pyTorrent application directory: `/opt/pytorrent`
|
||||
- pyTorrent HTTP port: `8090`
|
||||
- pyTorrent profile configured through the HTTP API
|
||||
|
||||
The installer creates or updates a pyTorrent rTorrent profile through API after both services are installed.
|
||||
|
||||
## Recommended usage with overrides
|
||||
|
||||
Environment variables must be passed to the `sudo bash` process.
|
||||
|
||||
Example:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://git.linuxiarz.pl/gru/pyTorrent/raw/branch/master/scripts/install_stack.sh \
|
||||
| sudo PYTORRENT_PORT=8091 RTORRENT_SCGI_PORT=5001 bash
|
||||
```
|
||||
|
||||
Another example with a custom profile name:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://git.linuxiarz.pl/gru/pyTorrent/raw/branch/master/scripts/install_stack.sh \
|
||||
| sudo PYTORRENT_PROFILE_NAME="Local rTorrent" PYTORRENT_PORT=8090 bash
|
||||
```
|
||||
|
||||
## Bootstrap parameters
|
||||
|
||||
These variables are used by `scripts/install_stack.sh`.
|
||||
|
||||
| Variable | Default | Description |
|
||||
| --- | --- | --- |
|
||||
| `PYTORRENT_REPO_URL` | `https://git.linuxiarz.pl/gru/pyTorrent` | Git repository base URL. |
|
||||
| `PYTORRENT_REPO_BRANCH` | `master` | Branch used to download the repository archive. |
|
||||
| `PYTORRENT_ARCHIVE_URL` | derived from repo URL and branch | Custom repository archive URL. |
|
||||
| `PYTORRENT_BOOTSTRAP_DIR` | `/tmp/pytorrent-stack-installer` | Temporary directory used by the bootstrap script. |
|
||||
| `PYTORRENT_KEEP_BOOTSTRAP_DIR` | `0` | Set to `1` to keep the temporary directory after installation. |
|
||||
|
||||
Example using a different branch:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://git.linuxiarz.pl/gru/pyTorrent/raw/branch/master/scripts/install_stack.sh \
|
||||
| sudo PYTORRENT_REPO_BRANCH=develop bash
|
||||
```
|
||||
|
||||
## rTorrent parameters
|
||||
|
||||
These variables are used by both stack installers.
|
||||
|
||||
| Variable | Default | Description |
|
||||
| --- | --- | --- |
|
||||
| `RTORRENT_USER` | `rtorrent` | System user used to run rTorrent. |
|
||||
| `RTORRENT_HOME` | `/home/${RTORRENT_USER}` | Home directory for the rTorrent user. |
|
||||
| `RTORRENT_BASE_DIR` | `/opt/rtorrent_build` | Build and install directory for xmlrpc-c, libtorrent and rTorrent. |
|
||||
| `RTORRENT_SCGI_PORT` | `5000` | Local SCGI port for rTorrent XMLRPC/SCGI. |
|
||||
| `RTORRENT_TORRENT_PORT` | `51300` | Incoming BitTorrent listen port. |
|
||||
| `RTORRENT_REF` | `v0.16.11` | rTorrent Git tag, branch, or commit. |
|
||||
| `LIBTORRENT_REF` | `v0.16.11` | libtorrent Git tag, branch, or commit. |
|
||||
|
||||
Example:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://git.linuxiarz.pl/gru/pyTorrent/raw/branch/master/scripts/install_stack.sh \
|
||||
| sudo RTORRENT_USER=rtorrent RTORRENT_SCGI_PORT=5001 RTORRENT_TORRENT_PORT=51400 bash
|
||||
```
|
||||
|
||||
## pyTorrent parameters
|
||||
|
||||
| Variable | Default | Description |
|
||||
| --- | --- | --- |
|
||||
| `PYTORRENT_APP_DIR` | `/opt/pytorrent` | pyTorrent installation directory. |
|
||||
| `PYTORRENT_PORT` | `8090` | HTTP port used by the pyTorrent service. |
|
||||
| `PYTORRENT_BASE_URL` | `http://127.0.0.1:${PYTORRENT_PORT}` | Base URL used by the API configurator. |
|
||||
| `PYTORRENT_PROFILE_NAME` | `Local rTorrent` | Name of the rTorrent profile created in pyTorrent. |
|
||||
| `PYTORRENT_API_TOKEN` | empty | Bearer token used when pyTorrent API authentication is enabled. |
|
||||
| `PYTORRENT_SERVICE_NAME` | `pytorrent` | systemd service name for pyTorrent. |
|
||||
| `PYTORRENT_RTORRENT_SCGI_URL` | `scgi://127.0.0.1:${RTORRENT_SCGI_PORT}` | SCGI URL saved in the pyTorrent rTorrent profile. |
|
||||
|
||||
Example with API token:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://git.linuxiarz.pl/gru/pyTorrent/raw/branch/master/scripts/install_stack.sh \
|
||||
| sudo PYTORRENT_API_TOKEN="pt_xxx" bash
|
||||
```
|
||||
|
||||
## API configurator parameters
|
||||
|
||||
The API configurator can be run manually:
|
||||
|
||||
```bash
|
||||
/opt/pytorrent/venv/bin/python /opt/pytorrent/scripts/stack_installers/configure_pytorrent_api.py \
|
||||
--base-url http://127.0.0.1:8090 \
|
||||
--profile-name "Local rTorrent" \
|
||||
--scgi-url scgi://127.0.0.1:5000
|
||||
```
|
||||
|
||||
CLI options:
|
||||
|
||||
| Option | Environment variable | Default | Description |
|
||||
| --- | --- | --- | --- |
|
||||
| `--base-url` | `PYTORRENT_BASE_URL` | `http://127.0.0.1:8090` | pyTorrent API base URL. |
|
||||
| `--api-token` | `PYTORRENT_API_TOKEN` | empty | Bearer token for authenticated API calls. |
|
||||
| `--profile-name` | `PYTORRENT_RTORRENT_PROFILE_NAME` | `Local rTorrent` | Profile name to create or update. |
|
||||
| `--scgi-url` | `PYTORRENT_RTORRENT_SCGI_URL` | `scgi://127.0.0.1:5000` | rTorrent SCGI URL. |
|
||||
| `--timeout` | `PYTORRENT_RTORRENT_TIMEOUT` | `10` | rTorrent request timeout in seconds. |
|
||||
| `--wait` | `PYTORRENT_API_WAIT_SECONDS` | `90` | Time to wait for the pyTorrent API to become available. |
|
||||
| `--remote` | `PYTORRENT_RTORRENT_REMOTE` | `0` | Mark profile as remote. Accepts `1`, `true`, `yes`, `on`. |
|
||||
|
||||
## Local installation without bootstrap
|
||||
|
||||
If the repository is already cloned:
|
||||
|
||||
Debian / Ubuntu:
|
||||
|
||||
```bash
|
||||
sudo bash scripts/stack_installers/install_stack_debian_ubuntu.sh
|
||||
```
|
||||
|
||||
RHEL-compatible systems:
|
||||
|
||||
```bash
|
||||
sudo bash scripts/stack_installers/install_stack_rhel.sh
|
||||
```
|
||||
|
||||
## Installed service hints
|
||||
|
||||
Check services:
|
||||
|
||||
```bash
|
||||
systemctl status pytorrent
|
||||
systemctl status rtorrent@rtorrent.service
|
||||
```
|
||||
|
||||
Check logs:
|
||||
|
||||
```bash
|
||||
tail -f /data/logs/app.log /data/logs/error.log
|
||||
journalctl -u pytorrent -f
|
||||
journalctl -u rtorrent@rtorrent.service -f
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- The default rTorrent build is intentionally minimal.
|
||||
- c-ares and custom curl are not enabled by the stack installer defaults.
|
||||
- The rTorrent installer overwrites the generated `.rtorrent.rc` because the stack installer passes `--force-config`.
|
||||
- pyTorrent is configured through the HTTP API after the service starts.
|
||||
- If API authentication is enabled before profile configuration, pass `PYTORRENT_API_TOKEN`.
|
||||
|
||||
|
||||
## Build logs and troubleshooting
|
||||
|
||||
The stack installer writes quiet build output to `/var/log/pytorrent-installer` by default.
|
||||
Override it with:
|
||||
|
||||
```bash
|
||||
PYTORRENT_STACK_LOG_DIR=/tmp/pytorrent-build-logs
|
||||
```
|
||||
|
||||
For full command output during rTorrent/libtorrent compilation, run with:
|
||||
|
||||
```bash
|
||||
PYTORRENT_DEBUG_INSTALL=1
|
||||
```
|
||||
|
||||
On RHEL-compatible systems the installer also tries to enable CRB/PowerTools and installs `libcurl-devel`, `redhat-rpm-config`, `patch`, `diffutils`, `findutils`, `file`, and `libstdc++-devel`, because minimal Alma/Rocky images often do not include enough build tooling.
|
||||
@@ -1,124 +1,346 @@
|
||||
# pyTorrent
|
||||
|
||||
Single-page web UI for rTorrent inspired by the ruTorrent workflow.
|
||||
Modern single-page web UI for managing rTorrent through SCGI/XML-RPC. pyTorrent focuses on fast live updates, multi-profile support, automation, diagnostics and a clean browser-based workflow inspired by ruTorrent.
|
||||
|
||||
## Features
|
||||
> pyTorrent is a controller for your own rTorrent instance. It does not include a BitTorrent engine and does not bypass tracker, copyright or network rules.
|
||||
|
||||
- Flask + Flask-SocketIO.
|
||||
- SQLite storage for preferences, SCGI profiles, Bootstrap theme and UI font.
|
||||
- Multiple rTorrent profiles per user.
|
||||
- Profiles can be added and edited from the UI; the remote profile flag hides local CPU/RAM usage to avoid confusing it with remote rTorrent host resources.
|
||||
- Active rTorrent profile switching from the UI.
|
||||
- Live torrent list over WebSocket.
|
||||
- Application-side cache with patch updates instead of full table reloads.
|
||||
- User operations executed through ThreadPoolExecutor.
|
||||
- `move` and `remove` actions are executed per profile in request order, so later deletes wait for earlier moves.
|
||||
- Job log shows a short date/time in the table and the full timestamp in the tooltip.
|
||||
- Bulk start, pause, stop, resume, recheck, remove and move.
|
||||
- Move supports `move_data=true`; data is physically moved on the rTorrent side in the background and status is polled from a marker file, so long `mv` operations do not hit the SCGI timeout.
|
||||
- Multi-magnet add modal.
|
||||
- Bottom status bar with CPU, RAM, rTorrent version, speeds, limits, total DL/UP and port-check status when enabled.
|
||||
- Torrent context menu.
|
||||
- Keyboard shortcuts.
|
||||
- Details tabs: General, Files, Peers, Trackers and Log.
|
||||
- Smart Queue shows the last 10 operations by default and can expand history to 100 rows.
|
||||
- Peer GeoIP with MaxMind GeoLite2-City.mmdb and IP cache.
|
||||
- Static cache busting with MD5 and cache headers.
|
||||
- Appearance preferences: default Bootstrap or Bootswatch themes Flatly, Litera, Lumen, Minty, Sketchy, Solar, Spacelab, United and Zephyr.
|
||||
- Font preferences: default theme font, Adwaita Mono and additional matching fonts.
|
||||
## Install pyTorrent only - recommended first path
|
||||
|
||||
## Complete Debian / Ubuntu install
|
||||
Use this when rTorrent already exists and only the pyTorrent web UI should be installed. The installer creates the pyTorrent service, virtualenv, `.env`, database and a default rTorrent profile. It does **not** install or reconfigure rTorrent.
|
||||
|
||||
The repository includes a full installer for Debian and Ubuntu:
|
||||
Supported systems for `scripts/install_pytorrent_only.sh`:
|
||||
|
||||
- Debian / Ubuntu
|
||||
- RHEL-compatible distributions: RHEL, Rocky Linux, AlmaLinux, CentOS Stream and Fedora-like systems with `dnf` or `yum`
|
||||
- Arch Linux
|
||||
|
||||
One-line install from the repository:
|
||||
|
||||
```bash
|
||||
wget -qO /tmp/install_debian_ubuntu.sh "https://git.linuxiarz.pl/gru/pyTorrent/raw/branch/master/scripts/install_debian_ubuntu.sh" && sudo bash /tmp/install_debian_ubuntu.sh
|
||||
curl -fsSL https://raw.githubusercontent.com/zdzichu6969/pyTorrent/master/scripts/install_pytorrent.sh | sudo bash
|
||||
```
|
||||
|
||||
The installer installs system packages, creates the dedicated `pytorrent` system user, copies the app to `/opt/pytorrent`, creates a virtual environment, installs Python dependencies, downloads offline frontend libraries and GeoIP data when helper scripts are available, then creates and starts the `pytorrent` systemd service.
|
||||
|
||||
Optional environment variables:
|
||||
Local install after cloning:
|
||||
|
||||
```bash
|
||||
PYTORRENT_USER=pytorrent \
|
||||
PYTORRENT_APP_DIR=/opt/pytorrent \
|
||||
PYTORRENT_SERVICE_NAME=pytorrent \
|
||||
sudo -E bash scripts/install_debian_ubuntu.sh
|
||||
git clone https://github.com/zdzichu6969/pyTorrent.git
|
||||
cd pyTorrent
|
||||
sudo bash scripts/install_pytorrent_only.sh
|
||||
```
|
||||
|
||||
Check the service with:
|
||||
Non-interactive example for an existing rTorrent SCGI endpoint:
|
||||
|
||||
```bash
|
||||
sudo systemctl status pytorrent
|
||||
sudo journalctl -u pytorrent -f
|
||||
sudo bash scripts/install_pytorrent_only.sh \
|
||||
--yes \
|
||||
--port 8090 \
|
||||
--scgi-url scgi://127.0.0.1:5000 \
|
||||
--auth enable \
|
||||
--auth-provider local \
|
||||
--auth-user pytorrent \
|
||||
--auth-password 'change-this-password'
|
||||
```
|
||||
|
||||
## Run locally
|
||||
Optional full stack install is described below. Use it only when the server should install and configure rTorrent together with pyTorrent.
|
||||
|
||||
## Highlights
|
||||
|
||||
- Live torrent table with WebSocket updates and patch-based refreshes.
|
||||
- Multiple rTorrent profiles, including local and remote hosts.
|
||||
- Profile-level permissions, user management and API tokens.
|
||||
- Bulk torrent actions: start, pause, stop, resume, recheck, remove and move.
|
||||
- Background move/remove jobs with operation history.
|
||||
- Smart Queue with recent job status and expandable history.
|
||||
- Download Planner with quiet hours, speed limits, CPU/disk protection and dry-run mode.
|
||||
- Adaptive Poller with configurable intervals and diagnostics.
|
||||
- RSS tools, automation rules and cleanup helpers.
|
||||
- Torrent details: general data, files, peers, trackers and logs.
|
||||
- Peer GeoIP lookup with MaxMind GeoLite2 database support.
|
||||
- Dashboard, smart views, global search and notification center.
|
||||
- OpenAPI docs available from the app.
|
||||
- Offline frontend assets support for self-hosted deployments.
|
||||
|
||||
## Screenshots
|
||||

|
||||
|
||||
## Requirements
|
||||
|
||||
### Application
|
||||
|
||||
- Python 3.10+
|
||||
- rTorrent with SCGI/XML-RPC enabled
|
||||
- Linux server recommended for production
|
||||
|
||||
### Python packages
|
||||
|
||||
The project uses Flask, Flask-SocketIO, python-dotenv, psutil, geoip2, gunicorn and related runtime dependencies listed in `requirements.txt`.
|
||||
|
||||
## Manual development quick start
|
||||
|
||||
Clone the repository and run the local development installer:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/zdzichu6969/pyTorrent.git
|
||||
cd pyTorrent
|
||||
./install.sh
|
||||
. venv/bin/activate
|
||||
. .venv/bin/activate
|
||||
python app.py
|
||||
```
|
||||
|
||||
Default URL: `http://127.0.0.1:8090`.
|
||||
Default URL:
|
||||
|
||||
```text
|
||||
http://127.0.0.1:8090
|
||||
```
|
||||
|
||||
Copy the example environment file before customizing the app:
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
## rTorrent SCGI profile
|
||||
|
||||
Example pyTorrent profile URL:
|
||||
|
||||
```text
|
||||
scgi://127.0.0.1:5000/RPC2
|
||||
```
|
||||
|
||||
Example rTorrent configuration:
|
||||
|
||||
```text
|
||||
network.scgi.open_port = 127.0.0.1:5000
|
||||
```
|
||||
|
||||
For production, keep SCGI bound to localhost or a private trusted network only.
|
||||
|
||||
## Optional stack installer
|
||||
|
||||
The repository also includes a stack installer for a clean server. It can install and configure rTorrent + pyTorrent together.
|
||||
|
||||
Supported systems:
|
||||
|
||||
- Debian / Ubuntu
|
||||
- RHEL-compatible distributions: RHEL, Rocky Linux, AlmaLinux, CentOS Stream, Fedora-like systems with `dnf` or `yum`
|
||||
- Arch Linux
|
||||
|
||||
After cloning the repository:
|
||||
|
||||
```bash
|
||||
bash scripts/install_stack.sh
|
||||
```
|
||||
|
||||
The default stack install creates:
|
||||
|
||||
| Component | Default |
|
||||
| --- | --- |
|
||||
| rTorrent user | `rtorrent` |
|
||||
| rTorrent SCGI | `scgi://127.0.0.1:5000` |
|
||||
| BitTorrent port | `51300` |
|
||||
| pyTorrent app dir | `/opt/pytorrent` |
|
||||
| pyTorrent HTTP port | `8090` |
|
||||
| pyTorrent service | `pytorrent` |
|
||||
|
||||
### Optional one-line full stack install with rTorrent
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/zdzichu6969/pyTorrent/master/scripts/install_stack.sh \
|
||||
| PYTORRENT_PORT=8090 \
|
||||
RTORRENT_SCGI_PORT=5000 \
|
||||
PYTORRENT_PROFILE_NAME="Local rTorrent" \
|
||||
bash
|
||||
```
|
||||
|
||||
## Installer variables
|
||||
|
||||
### Bootstrap
|
||||
|
||||
| Variable | Default | Description |
|
||||
| --- | --- | --- |
|
||||
| `PYTORRENT_REPO_URL` | repository URL | Repository base URL. |
|
||||
| `PYTORRENT_REPO_BRANCH` | `master` | Branch used by the bootstrap installer. |
|
||||
| `PYTORRENT_ARCHIVE_URL` | derived | Custom repository archive URL. Required for GitHub one-line install unless the script default is updated. |
|
||||
| `PYTORRENT_BOOTSTRAP_DIR` | `/tmp/pytorrent-stack-installer` | Temporary bootstrap directory. |
|
||||
| `PYTORRENT_KEEP_BOOTSTRAP_DIR` | `0` | Set to `1` to keep bootstrap files after install. |
|
||||
|
||||
### rTorrent
|
||||
|
||||
| Variable | Default | Description |
|
||||
| --- | --- | --- |
|
||||
| `RTORRENT_USER` | `rtorrent` | System user used to run rTorrent. |
|
||||
| `RTORRENT_HOME` | `/home/${RTORRENT_USER}` | Home directory for the rTorrent user. |
|
||||
| `RTORRENT_BASE_DIR` | `/opt/rtorrent_build` | Build/install directory for source installs. |
|
||||
| `RTORRENT_SCGI_PORT` | `5000` | Local SCGI port. |
|
||||
| `RTORRENT_TORRENT_PORT` | `51300` | Incoming BitTorrent port. |
|
||||
| `RTORRENT_REF` | `v0.16.11` | rTorrent Git tag, branch or commit for source builds. |
|
||||
| `LIBTORRENT_REF` | `v0.16.11` | libtorrent Git tag, branch or commit for source builds. |
|
||||
| `RTORRENT_WITH_XMLRPC_C` | `0` | Set to `1` to build with classic xmlrpc-c. |
|
||||
| `RTORRENT_BUILD_FROM_SOURCE` | distro-specific | On Arch, set to `1` to compile instead of using `pacman`. |
|
||||
| `RTORRENT_FORCE_CONFIG` | `1` | Overwrite generated `.rtorrent.rc` when supported. |
|
||||
|
||||
### pyTorrent
|
||||
|
||||
| Variable | Default | Description |
|
||||
| --- | --- | --- |
|
||||
| `PYTORRENT_APP_DIR` | `/opt/pytorrent` | Installation directory. |
|
||||
| `PYTORRENT_PORT` | `8090` | HTTP port. |
|
||||
| `PYTORRENT_BASE_URL` | `http://127.0.0.1:${PYTORRENT_PORT}` | Base URL used by the API configurator. |
|
||||
| `PYTORRENT_PROFILE_NAME` | `Local rTorrent` | rTorrent profile created in pyTorrent. |
|
||||
| `PYTORRENT_API_TOKEN` | empty | Bearer token for authenticated API calls during setup. |
|
||||
| `PYTORRENT_SERVICE_NAME` | `pytorrent` | systemd service name. |
|
||||
| `PYTORRENT_RTORRENT_SCGI_URL` | `scgi://127.0.0.1:${RTORRENT_SCGI_PORT}` | SCGI URL saved in the generated profile. |
|
||||
|
||||
## Production run
|
||||
|
||||
Preferred mode without development Werkzeug:
|
||||
Recommended production command:
|
||||
|
||||
```bash
|
||||
. venv/bin/activate
|
||||
gunicorn --worker-class gthread --workers 1 --threads 32 --bind 0.0.0.0:8090 --access-logfile - --error-logfile - wsgi:app
|
||||
. .venv/bin/activate
|
||||
gunicorn --worker-class gthread \
|
||||
--workers 1 \
|
||||
--threads 32 \
|
||||
--bind 0.0.0.0:8090 \
|
||||
--access-logfile - \
|
||||
--error-logfile - \
|
||||
wsgi:app
|
||||
```
|
||||
|
||||
Note: the app keeps `async_mode="threading"`, so WebSocket, `start_background_task`, operation queues and the poller run in the same model as before.
|
||||
pyTorrent uses Flask-SocketIO with threading mode. Multiple Gunicorn workers are not a drop-in replacement unless a Socket.IO message queue such as Redis, RabbitMQ or Kafka is configured.
|
||||
|
||||
Alternatives reviewed but not enabled by default:
|
||||
## systemd
|
||||
|
||||
- Gunicorn with `eventlet`: works with Flask-SocketIO, but requires green threads and monkey patching, which increases regression risk for file and SCGI operations.
|
||||
- Gunicorn with `gevent`: a valid production option, but it needs extra dependencies and compatibility testing.
|
||||
- Multiple Gunicorn workers: requires Redis, RabbitMQ or Kafka as the Socket.IO message queue, so it is not a drop-in replacement.
|
||||
Useful service commands after stack installation:
|
||||
|
||||
```bash
|
||||
systemctl status pytorrent
|
||||
systemctl status rtorrent@rtorrent.service
|
||||
journalctl -u pytorrent -f
|
||||
journalctl -u rtorrent@rtorrent.service -f
|
||||
```
|
||||
|
||||
Application logs may also be available in:
|
||||
|
||||
```text
|
||||
data/logs/
|
||||
```
|
||||
|
||||
## Reverse proxy
|
||||
|
||||
When pyTorrent is served behind a reverse proxy, enable proxy header handling only when the proxy is trusted:
|
||||
Enable proxy header handling only when pyTorrent is behind a trusted proxy:
|
||||
|
||||
```env
|
||||
PYTORRENT_PROXY_FIX_ENABLE=true
|
||||
PYTORRENT_SESSION_COOKIE_SECURE=true
|
||||
```
|
||||
|
||||
The proxy should forward at least:
|
||||
Forward these headers from the proxy:
|
||||
|
||||
```txt
|
||||
```text
|
||||
X-Forwarded-For
|
||||
X-Forwarded-Proto
|
||||
X-Forwarded-Host
|
||||
X-Forwarded-Port
|
||||
```
|
||||
|
||||
This keeps login redirects, session cookies and same-origin API checks correct when HTTPS is terminated by the proxy. If pyTorrent is mounted under a sub-path, also forward `X-Forwarded-Prefix`.
|
||||
If pyTorrent is mounted under a sub-path, also forward:
|
||||
|
||||
## SCGI profile
|
||||
|
||||
Example:
|
||||
|
||||
```txt
|
||||
scgi://127.0.0.1:5000/RPC2
|
||||
```text
|
||||
X-Forwarded-Prefix
|
||||
```
|
||||
|
||||
On the rTorrent side:
|
||||
For HTTPS deployments, set allowed origins explicitly:
|
||||
|
||||
```txt
|
||||
network.scgi.open_port = 127.0.0.1:5000
|
||||
```env
|
||||
PYTORRENT_SOCKETIO_CORS_ALLOWED_ORIGINS=https://pytorrent.example.com
|
||||
PYTORRENT_API_ALLOWED_ORIGINS=https://pytorrent.example.com
|
||||
```
|
||||
|
||||
## Authentication
|
||||
|
||||
pyTorrent supports three authentication providers:
|
||||
|
||||
| Provider | Description |
|
||||
| --- | --- |
|
||||
| `local` | Built-in pyTorrent login screen with username and password. |
|
||||
| `tinyauth` | External authentication through Tinyauth and a trusted reverse proxy header. |
|
||||
| `proxy` | Generic external authentication through a trusted reverse proxy header. |
|
||||
|
||||
Enable authentication:
|
||||
|
||||
```env
|
||||
PYTORRENT_AUTH_ENABLE=true
|
||||
PYTORRENT_AUTH_PROVIDER=local
|
||||
```
|
||||
|
||||
Reset a local user's password:
|
||||
|
||||
```bash
|
||||
. .venv/bin/activate
|
||||
python -m pytorrent.cli reset-password admin new_password
|
||||
```
|
||||
|
||||
Without the password argument, the command asks interactively:
|
||||
|
||||
```bash
|
||||
python -m pytorrent.cli reset-password admin
|
||||
```
|
||||
|
||||
### API tokens
|
||||
|
||||
When authentication is enabled, API requests can use a browser session cookie or a per-user API token. Admin users can generate tokens in:
|
||||
|
||||
```text
|
||||
Tools -> Users -> Generate token
|
||||
```
|
||||
|
||||
Use the token as a bearer token or API key:
|
||||
|
||||
```bash
|
||||
curl -H "Authorization: Bearer pt_xxx" http://127.0.0.1:8090/api/system/status
|
||||
curl -H "X-API-Key: pt_xxx" http://127.0.0.1:8090/api/system/status
|
||||
```
|
||||
|
||||
Token permissions follow the owning user's role and profile permissions. Revoked tokens stop working immediately.
|
||||
|
||||
### External auth through Tinyauth or proxy
|
||||
|
||||
Example Tinyauth configuration:
|
||||
|
||||
```env
|
||||
PYTORRENT_AUTH_ENABLE=true
|
||||
PYTORRENT_AUTH_PROVIDER=tinyauth
|
||||
PYTORRENT_AUTH_PROXY_USER_HEADER=Remote-User
|
||||
PYTORRENT_AUTH_PROXY_AUTO_CREATE=true
|
||||
PYTORRENT_AUTH_PROXY_AUTO_CREATE_ROLE=admin
|
||||
PYTORRENT_AUTH_PROXY_AUTO_CREATE_PERMISSION=rw
|
||||
```
|
||||
|
||||
Example generic proxy configuration:
|
||||
|
||||
```env
|
||||
PYTORRENT_AUTH_ENABLE=true
|
||||
PYTORRENT_AUTH_PROVIDER=proxy
|
||||
PYTORRENT_AUTH_PROXY_USER_HEADER=X-Forwarded-User
|
||||
PYTORRENT_AUTH_PROXY_AUTO_CREATE=true
|
||||
PYTORRENT_AUTH_PROXY_AUTO_CREATE_ROLE=user
|
||||
PYTORRENT_AUTH_PROXY_AUTO_CREATE_PERMISSION=rw
|
||||
```
|
||||
|
||||
`rw` is accepted as an alias for `full`. Admin users can access all profiles.
|
||||
|
||||
Do not use auth bypass on public hostnames. Limit bypass hosts to trusted private addresses only:
|
||||
|
||||
```env
|
||||
PYTORRENT_AUTH_BYPASS_HOSTS=10.11.1.11:8090,10.11.1.11
|
||||
PYTORRENT_AUTH_BYPASS_USER=admin
|
||||
```
|
||||
|
||||
## GeoIP
|
||||
|
||||
The installer downloads GeoLite2-City once to:
|
||||
The installer can download the GeoLite2 City database to:
|
||||
|
||||
```txt
|
||||
```text
|
||||
data/GeoLite2-City.mmdb
|
||||
```
|
||||
|
||||
@@ -128,42 +350,155 @@ Manual download:
|
||||
./scripts/download_geoip.sh
|
||||
```
|
||||
|
||||
The script uses `https://git.io/GeoLite2-City.mmdb` as the primary source and `https://github.com/P3TERX/GeoLite.mmdb/raw/download/GeoLite2-City.mmdb` as fallback. The `data` directory is set to `755`, and the database file is set to `644`.
|
||||
Configure the database path:
|
||||
|
||||
## API docs
|
||||
|
||||
OpenAPI documentation is available at `/docs`. `/api/profiles` supports `max_parallel_jobs` with default value `5` and `is_remote`; `PUT /api/profiles/{profile_id}` edits an existing profile. `/api/preferences` supports fields including `theme`, `bootstrap_theme`, `font_family`, `table_columns_json`, `peers_refresh_seconds` and `port_check_enabled`. `/api/port-check` returns port status with `checked_at`; for remote profiles the public IP is read through rTorrent with fallbacks when supported. `/api/system/status` returns `usage_available=false` for remote profiles and does not read local CPU/RAM.
|
||||
|
||||
`/api/openapi.json` includes reusable schemas for main API responses, including `TorrentListResponse`, `TorrentSummary`, `TorrentFilterSummary`, `CleanupSummary` and `AppStatus`. `GET /api/torrents` documents the `summary` field used by sidebar filters.
|
||||
|
||||
## Admin CLI
|
||||
|
||||
Reset an existing user's password:
|
||||
|
||||
```bash
|
||||
. venv/bin/activate
|
||||
python -m pytorrent.cli reset-password admin new_password
|
||||
```env
|
||||
PYTORRENT_GEOIP_DB=data/GeoLite2-City.mmdb
|
||||
```
|
||||
|
||||
Without the password argument, the CLI asks for it interactively:
|
||||
## OpenAPI
|
||||
|
||||
```bash
|
||||
python -m pytorrent.cli reset-password admin
|
||||
OpenAPI documentation is available at:
|
||||
|
||||
```text
|
||||
/docs
|
||||
/api/openapi.json
|
||||
```
|
||||
|
||||
The command uses the same database as the app and respects `PYTORRENT_DB_PATH` from `.env`. The reset changes only the password hash and leaves role and permissions unchanged.
|
||||
The API includes profile management, torrent actions, preferences, port checks, system status, planner, poller, RSS, backups and diagnostics endpoints.
|
||||
|
||||
## API authentication tokens
|
||||
## Configuration reference
|
||||
|
||||
When `PYTORRENT_AUTH_ENABLE=0`, API endpoints work without authentication.
|
||||
Common environment variables:
|
||||
|
||||
When `PYTORRENT_AUTH_ENABLE=1`, API access can use either the browser session cookie or a per-user API token. Admin users can generate a token in **Tools -> Users** with **Generate token**. Copy the token immediately; only its prefix and metadata are stored afterwards.
|
||||
|
||||
Use a token in one of these forms:
|
||||
|
||||
```bash
|
||||
curl -H "Authorization: Bearer pt_xxx" http://127.0.0.1:8080/api/system/status
|
||||
curl -H "X-API-Key: pt_xxx" http://127.0.0.1:8080/api/system/status
|
||||
```env
|
||||
PYTORRENT_SECRET_KEY=change-me
|
||||
PYTORRENT_DB_PATH=data/pytorrent.sqlite3
|
||||
PYTORRENT_HOST=0.0.0.0
|
||||
PYTORRENT_PORT=8090
|
||||
PYTORRENT_DEBUG=0
|
||||
PYTORRENT_POLL_INTERVAL=1
|
||||
PYTORRENT_WORKERS=16
|
||||
PYTORRENT_USE_OFFLINE_LIBS=true
|
||||
PYTORRENT_LOG_ENABLE=false
|
||||
PYTORRENT_LOG_DIR=data/logs
|
||||
```
|
||||
|
||||
Token permissions follow the owning user's role and rTorrent profile permissions. Revoked tokens stop working immediately.
|
||||
Retention settings:
|
||||
|
||||
```env
|
||||
PYTORRENT_TRAFFIC_HISTORY_RETENTION_DAYS=90
|
||||
PYTORRENT_JOBS_RETENTION_DAYS=30
|
||||
PYTORRENT_SMART_QUEUE_HISTORY_RETENTION_DAYS=30
|
||||
PYTORRENT_LOG_RETENTION_DAYS=30
|
||||
```
|
||||
|
||||
Database maintenance:
|
||||
|
||||
```env
|
||||
PYTORRENT_DB_VACUUM_ENABLE=true
|
||||
PYTORRENT_DB_VACUUM_EVERY_SECONDS=86400
|
||||
PYTORRENT_DB_VACUUM_MIN_FREE_MB=512
|
||||
PYTORRENT_DB_VACUUM_MIN_FREE_RATIO=0.25
|
||||
```
|
||||
|
||||
See `.env.example` for the full list.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Service does not start
|
||||
|
||||
```bash
|
||||
journalctl -u pytorrent -n 100 --no-pager
|
||||
systemctl status pytorrent
|
||||
```
|
||||
|
||||
### rTorrent connection fails
|
||||
|
||||
Check that rTorrent exposes SCGI locally and that the pyTorrent profile uses the same port:
|
||||
|
||||
```text
|
||||
scgi://127.0.0.1:5000/RPC2
|
||||
```
|
||||
|
||||
### External auth creates users without profiles
|
||||
|
||||
Use admin auto-create mode:
|
||||
|
||||
```env
|
||||
PYTORRENT_AUTH_PROXY_AUTO_CREATE_ROLE=admin
|
||||
```
|
||||
|
||||
Or grant read-write profile permissions to non-admin users:
|
||||
|
||||
```env
|
||||
PYTORRENT_AUTH_PROXY_AUTO_CREATE_ROLE=user
|
||||
PYTORRENT_AUTH_PROXY_AUTO_CREATE_PERMISSION=rw
|
||||
```
|
||||
|
||||
### Socket.IO badge stays offline behind reverse proxy
|
||||
|
||||
Forward the same authenticated user header to all pyTorrent paths, including `/socket.io/`:
|
||||
|
||||
```nginx
|
||||
auth_request_set $auth_user $upstream_http_remote_user;
|
||||
proxy_set_header Remote-User $auth_user;
|
||||
```
|
||||
|
||||
### Build logs
|
||||
|
||||
Source-build installers write logs to:
|
||||
|
||||
```text
|
||||
/var/log/pytorrent-installer
|
||||
```
|
||||
|
||||
Enable verbose installer output:
|
||||
|
||||
```bash
|
||||
PYTORRENT_DEBUG_INSTALL=1 bash scripts/install_stack.sh
|
||||
```
|
||||
|
||||
## Security notes
|
||||
|
||||
- Do not expose rTorrent SCGI directly to the public internet.
|
||||
- Use HTTPS and authentication for remote access.
|
||||
- Set a strong `PYTORRENT_SECRET_KEY` before production use.
|
||||
- Review auth bypass settings before publishing or deploying.
|
||||
- Keep `.env` out of Git. Use `.env.example` for public defaults.
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
. .venv/bin/activate
|
||||
python app.py
|
||||
```
|
||||
|
||||
Run a quick Python compile check:
|
||||
|
||||
```bash
|
||||
python -m compileall pytorrent app.py wsgi.py
|
||||
```
|
||||
|
||||
Download offline frontend assets when needed:
|
||||
|
||||
```bash
|
||||
python scripts/download_frontend_libs.py
|
||||
```
|
||||
|
||||
## Project structure
|
||||
|
||||
```text
|
||||
pytorrent/ Application package
|
||||
pytorrent/routes/ Flask routes and API modules
|
||||
pytorrent/services/ rTorrent, planner, queue and helper services
|
||||
pytorrent/static/ Frontend JavaScript and CSS
|
||||
pytorrent/templates/ HTML templates
|
||||
scripts/ Installers and maintenance tools
|
||||
systemd/ systemd service files
|
||||
data/ Runtime data directory
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
AGPL-3
|
||||
@@ -1,265 +0,0 @@
|
||||
# TODO
|
||||
|
||||
## Done
|
||||
|
||||
- Fixed remote system statistics after the rTorrent service split so CPU/RAM are read from the rTorrent host again.
|
||||
- Fixed split system service dependencies for `default_download_path` and `list_torrents`, restoring disk/system status calls.
|
||||
- Fixed split rTorrent package exports so private compatibility helpers, remote caches and post-check state remain visible after refactor.
|
||||
- Restored remote directory browsing in the split rTorrent system service.
|
||||
- Split the largest backend files into smaller route modules while keeping existing API behavior.
|
||||
- Split rTorrent service logic into a package with focused modules and compatibility exports.
|
||||
- Updated OpenAPI coverage for backend routes, including Planner and Poller endpoints.
|
||||
- Split Planner and Adaptive Poller into separate Tools tabs.
|
||||
- Added Download Planner settings:
|
||||
- master enable switch,
|
||||
- night-only downloads as a separate option,
|
||||
- quiet hours,
|
||||
- weekday and weekend speed limits,
|
||||
- CPU protection,
|
||||
- disk protection,
|
||||
- auto-resume for planner-paused torrents.
|
||||
- Added speed presets and Mbit/s sliders while keeping exact B/s fields for rTorrent values.
|
||||
- Moved disk protection configuration out of Preferences and into Planner.
|
||||
- Kept worker-side disk-start protection wired to Planner disk settings.
|
||||
- Added footer Planner shortcut visible only when Planner is enabled.
|
||||
- Added Adaptive Poller settings:
|
||||
- active interval,
|
||||
- idle interval,
|
||||
- error interval,
|
||||
- system stats interval,
|
||||
- slow stats interval,
|
||||
- heartbeat interval.
|
||||
- Reduced unnecessary heartbeat emissions.
|
||||
- Added tick duration tracking for the poller.
|
||||
- Cleaned stale backup files from the package.
|
||||
- Normalized Planner and Poller UX to match Smart Queue style.
|
||||
- Cleaned Planner/Poller CSS and removed redundant UI wrappers.
|
||||
- Kept the WebSocket live refresh responsive by moving slow poller tasks (torrent stats, Smart Queue, automations and Planner enforce) into a guarded background task.
|
||||
- Added a regression smoke check for fixed-mode poller metrics while a slow background task is running.
|
||||
- Set Adaptive Poller startup defaults to the requested fast-live profile: active/torrent loop 0.5s, idle 3s, errors 2s, system stats 1s, queue 5s, heartbeat 5s, slow threshold 10000ms and slowdown multiplier 1.
|
||||
- Added poller runtime diagnostics for real tick gap, effective interval and configured minimum interval so 0.5s polling can be verified from the UI.
|
||||
|
||||
## Planned
|
||||
|
||||
### Phase 1 - Stabilization and maintainability
|
||||
|
||||
- Split `static/app.js` into smaller frontend modules:
|
||||
- `api.js`,
|
||||
- `state.js`,
|
||||
- `torrents.js`,
|
||||
- `modals.js`,
|
||||
- `smartQueue.js`,
|
||||
- `planner.js`,
|
||||
- `poller.js`,
|
||||
- `rss.js`,
|
||||
- `charts.js`.
|
||||
- Add request validation for API endpoints instead of repeated manual `request.json` parsing.
|
||||
- Add typed application exceptions:
|
||||
- `RtorrentError`,
|
||||
- `RtorrentTimeout`,
|
||||
- `ProfileAccessError`,
|
||||
- `ValidationError`,
|
||||
- `UnsafePathError`,
|
||||
- `BackgroundJobError`.
|
||||
- Reduce broad `except Exception` blocks and map expected errors to clear API responses.
|
||||
- Add structured logging with request ID, profile ID and operation timing.
|
||||
- Add endpoint timing metrics for API calls and rTorrent operations.
|
||||
- Add a deeper healthcheck endpoint covering:
|
||||
- database readability/writability,
|
||||
- active profile state,
|
||||
- rTorrent connection,
|
||||
- background queue state,
|
||||
- poller state.
|
||||
|
||||
### Phase 2 - Tests and safety
|
||||
|
||||
- Add unit tests for torrent parsing.
|
||||
- Add unit tests for Smart Queue decisions.
|
||||
- Add unit tests for Planner schedule windows.
|
||||
- Add unit tests for quiet hours.
|
||||
- Add unit tests for Planner speed limit selection.
|
||||
- Add unit tests for disk protection and CPU protection.
|
||||
- Add unit tests for RSS rule matching.
|
||||
- Add unit tests for ratio rules.
|
||||
- Add unit tests for profile validation.
|
||||
- Add auth and permission tests.
|
||||
- Add path safety tests for move, remove and ZIP download operations.
|
||||
- Add mocked rTorrent integration tests for Planner pause/resume and speed limit application.
|
||||
- Add mocked poller tests for adaptive cadence without requiring a live rTorrent instance.
|
||||
- Add visual regression checks for Tools tabs on narrow screens.
|
||||
- Add CI checks for Python compile, JS syntax, linting and tests.
|
||||
|
||||
### Phase 3 - Planner improvements
|
||||
|
||||
- [x] Add named Planner profiles:
|
||||
- night mode,
|
||||
- weekend mode,
|
||||
- low power mode,
|
||||
- unlimited mode.
|
||||
- [x] Keep Planner settings per active rTorrent profile.
|
||||
- [x] Add Planner preview with the currently matched rule and next scheduled change.
|
||||
- [x] Add Planner action history:
|
||||
- paused torrents,
|
||||
- resumed torrents,
|
||||
- speed limit changes,
|
||||
- CPU protection triggers,
|
||||
- disk protection triggers.
|
||||
- [x] Add dry-run mode for Planner actions.
|
||||
- [x] Add optional network/load protection rules.
|
||||
- [x] Add configurable grace period before auto-resume.
|
||||
- [x] Add manual override timeout, including `disable Planner for 1 hour`.
|
||||
- [x] Add clearer status badges in the footer and Tools panel.
|
||||
|
||||
Implementation notes:
|
||||
- Frontend settings live in the Planner tab and are stored through `/api/download-planner`.
|
||||
- Preview/history are exposed through `/api/download-planner/preview`.
|
||||
- Manual override is exposed through `/api/download-planner/override`.
|
||||
- Dry-run mode records intended actions without calling rTorrent mutations.
|
||||
|
||||
### Phase 4 - Adaptive poller improvements
|
||||
|
||||
- [x] Make polling intervals configurable per rTorrent profile.
|
||||
- [x] Add automatic slowdown when rTorrent responses are slow.
|
||||
- [x] Add automatic recovery after repeated rTorrent errors.
|
||||
- [x] Split polling loop configuration by data type:
|
||||
- torrent list,
|
||||
- system stats,
|
||||
- tracker stats,
|
||||
- disk stats,
|
||||
- queue/job stats.
|
||||
- [x] Emit WebSocket torrent updates only when data changed, with heartbeat fallback.
|
||||
- [x] Add per-tick metrics:
|
||||
- duration,
|
||||
- emitted payload size,
|
||||
- rTorrent call count,
|
||||
- skipped emissions,
|
||||
- current adaptive mode.
|
||||
- [x] Add a Poller diagnostics panel.
|
||||
- [x] Add safe fallback mode if adaptive polling is misconfigured.
|
||||
|
||||
Implementation notes:
|
||||
- Frontend settings live in the Poller tab and are stored through `/api/poller/settings`.
|
||||
- Runtime metrics are included in heartbeat/system stats payloads and shown in Poller diagnostics.
|
||||
- Fallback mode clamps unsafe intervals during normalization.
|
||||
|
||||
### Phase 5 - UX and dashboard
|
||||
|
||||
- [x] Add a torrent health dashboard with sections for:
|
||||
- torrents without seeders,
|
||||
- stopped torrents that should be active,
|
||||
- tracker errors,
|
||||
- duplicate torrents,
|
||||
- slowest torrents,
|
||||
- dead torrents,
|
||||
- largest torrents,
|
||||
- torrents below target ratio.
|
||||
- [x] Add Smart Views:
|
||||
- Needs attention,
|
||||
- Large and slow,
|
||||
- Seeding too long,
|
||||
- New from RSS,
|
||||
- No label,
|
||||
- Private trackers.
|
||||
- [x] Add global search across:
|
||||
- torrent name,
|
||||
- hash,
|
||||
- label,
|
||||
- tracker,
|
||||
- path,
|
||||
- ratio group,
|
||||
- error status.
|
||||
- [x] Add a persistent notification center for:
|
||||
- rTorrent errors,
|
||||
- RSS errors,
|
||||
- automation errors,
|
||||
- disk warnings,
|
||||
- Smart Queue decisions,
|
||||
- Planner actions,
|
||||
- port status.
|
||||
- [x] Add a Diagnostics page for profile, rTorrent, poller, database and worker state.
|
||||
|
||||
Implementation notes:
|
||||
- Health and Smart Views are calculated client-side from the live torrent snapshot.
|
||||
- `Seeding too long` uses rTorrent completion timestamp when available and falls back safely when older rTorrent builds do not expose it.
|
||||
- Global search uses torrent name, hash, label, tracker domain, path, ratio group and warning/error text.
|
||||
- App Status now stays application-focused: process, workers, database and cleanup counters.
|
||||
- Diagnostics owns profile, rTorrent connection, poller, planner, database and worker snapshots to avoid duplicated status cards.
|
||||
|
||||
### Phase 6 - RSS and automation
|
||||
|
||||
- Add RSS feed preview before saving a rule.
|
||||
- Add RSS rule testing against the latest feed entries.
|
||||
- Add RSS dry-run mode.
|
||||
- Add duplicate detection by info-hash where possible.
|
||||
- Add RSS rule priorities.
|
||||
- Add RSS history explaining why an item was accepted or rejected.
|
||||
- Add cleanup rules:
|
||||
- remove completed torrents after target ratio,
|
||||
- move completed data to a destination path,
|
||||
- remove dead torrents after a configurable time,
|
||||
- remove torrents with missing data,
|
||||
- stop seeding after a configured upload amount.
|
||||
- Add import/export for automation rules.
|
||||
|
||||
### Phase 7 - File and torrent operations
|
||||
|
||||
- Improve the Files view with extension filters.
|
||||
- Add bulk priority actions by file type, for example `.mkv`, `.srt`, `.nfo`.
|
||||
- Add folder-level priority actions by path pattern.
|
||||
- Add safer selected-file download handling for large selections.
|
||||
- Add missing-file detection.
|
||||
- Add duplicate torrent detection and merge/cleanup suggestions.
|
||||
|
||||
### Phase 8 - Profiles and rTorrent diagnostics
|
||||
|
||||
- Add SCGI connection test before saving a profile.
|
||||
- Add profile diagnostics:
|
||||
- rTorrent version,
|
||||
- base paths,
|
||||
- write permissions,
|
||||
- free disk space,
|
||||
- response time.
|
||||
- Add import/export for profiles.
|
||||
- Show profile status in the profile picker:
|
||||
- online,
|
||||
- offline,
|
||||
- slow,
|
||||
- error.
|
||||
- Add per-profile API and polling limits.
|
||||
|
||||
### Phase 9 - Security and API
|
||||
|
||||
- Require changing default admin credentials on first login when auth is enabled.
|
||||
- Add login rate limiting.
|
||||
- Add optional TOTP/2FA.
|
||||
- Add stronger CSRF protection for state-changing requests.
|
||||
- Set secure session flags when running behind HTTPS.
|
||||
- Add API tokens:
|
||||
- read-only token,
|
||||
- full-access token,
|
||||
- profile-limited token,
|
||||
- token revocation,
|
||||
- token usage log.
|
||||
- Generate or validate OpenAPI schemas from backend request/response models.
|
||||
- Add generated JS API client from OpenAPI.
|
||||
|
||||
### Phase 10 - Database, backup and release quality
|
||||
|
||||
- Add explicit database migrations with version numbers.
|
||||
- Add `PRAGMA busy_timeout` configuration.
|
||||
- Add periodic `VACUUM` or WAL checkpoint maintenance.
|
||||
- Add indexes for history and frequently filtered data.
|
||||
- Add retention limits for RSS history, automation history and Planner history.
|
||||
- Add backup checksums.
|
||||
- Add backup restore validation.
|
||||
- Keep release archives free from `.bak`, cache and local debug files.
|
||||
|
||||
|
||||
## Phase 5 implementation status
|
||||
|
||||
- [x] Torrent health dashboard in Tools.
|
||||
- [x] Smart Views filters, including completion-time based `Seeding too long`.
|
||||
- [x] Global search expanded to hash, label, tracker, path, ratio group and error/status.
|
||||
- [x] Persistent local notification center.
|
||||
- [x] Diagnostics tool panel for profile, rTorrent, poller, database and workers.
|
||||
|
||||
@@ -13,7 +13,7 @@ Group=pytorrent
|
||||
WorkingDirectory=/opt/pyTorrent
|
||||
Environment="PYTHONUNBUFFERED=1"
|
||||
EnvironmentFile=/opt/pyTorrent/.env
|
||||
ExecStart=/opt/pyTorrent/venv/bin/gunicorn -c /opt/pyTorrent/gunicorn.conf.py --worker-class gthread --workers 1 --threads 32 --bind ${PYTORRENT_HOST}:${PYTORRENT_PORT} wsgi:app
|
||||
ExecStart=/opt/pyTorrent/.venv/bin/gunicorn -c /opt/pyTorrent/gunicorn.conf.py --worker-class gthread --workers 1 --threads 32 --bind ${PYTORRENT_HOST}:${PYTORRENT_PORT} wsgi:app
|
||||
Restart=always
|
||||
RestartSec=3
|
||||
KillSignal=SIGINT
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 438 KiB |
+3
-3
@@ -1,7 +1,7 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
python3 -m venv venv
|
||||
. venv/bin/activate
|
||||
python3 -m venv .venv
|
||||
. .venv/bin/activate
|
||||
pip install --upgrade pip
|
||||
pip install -r requirements.txt
|
||||
cp -n .env.example .env || true
|
||||
@@ -11,4 +11,4 @@ mkdir -p data
|
||||
chmod 755 data
|
||||
./scripts/download_geoip.sh data/GeoLite2-City.mmdb
|
||||
python -c "from pytorrent.db import init_db; init_db(); print(\"SQLite initialized\")"
|
||||
echo "Run: . venv/bin/activate && python app.py"
|
||||
echo "Run: . .venv/bin/activate && python app.py"
|
||||
|
||||
+1
-1
@@ -45,7 +45,7 @@ def make_zip(repo_path: Path, output_zip: Path) -> None:
|
||||
|
||||
zf.write(abs_path, arcname=rel_path)
|
||||
|
||||
print(f"Utworzono archiwum: {output_zip}")
|
||||
print(f"Created: {output_zip}")
|
||||
print(f"Added files: {len(files)}")
|
||||
|
||||
|
||||
|
||||
+13
-13
@@ -16,11 +16,9 @@ from .config import (
|
||||
SOCKETIO_CORS_ALLOWED_ORIGINS,
|
||||
)
|
||||
from .db import init_db
|
||||
from .services.frontend_assets import asset_path, bootstrap_css_path, validate_offline_assets
|
||||
from .utils import file_md5
|
||||
from .services.frontend_assets import asset_path, bootstrap_css_path, initialize_static_hash, static_hash, validate_offline_assets
|
||||
|
||||
socketio = SocketIO(cors_allowed_origins=SOCKETIO_CORS_ALLOWED_ORIGINS, ping_timeout=30, async_mode="threading")
|
||||
_static_md5_cache: dict[tuple, str] = {}
|
||||
|
||||
|
||||
def _wants_json_response() -> bool:
|
||||
@@ -58,6 +56,7 @@ def register_error_pages(app: Flask) -> None:
|
||||
def create_app() -> Flask:
|
||||
validate_offline_assets()
|
||||
app = Flask(__name__)
|
||||
initialize_static_hash(Path(app.static_folder or ""))
|
||||
from .logging_config import configure_logging
|
||||
configure_logging(app)
|
||||
if PROXY_FIX_ENABLE:
|
||||
@@ -78,17 +77,15 @@ def create_app() -> Flask:
|
||||
|
||||
@app.context_processor
|
||||
def static_helpers():
|
||||
def current_static_hash() -> str:
|
||||
return static_hash(Path(app.static_folder or ""))
|
||||
|
||||
def static_url(filename: str) -> str:
|
||||
path = Path(app.static_folder or "") / filename
|
||||
try:
|
||||
stat = path.stat()
|
||||
key = (filename, stat.st_mtime_ns, stat.st_size)
|
||||
version = _static_md5_cache.get(key)
|
||||
if not version:
|
||||
_static_md5_cache.clear()
|
||||
version = file_md5(path)
|
||||
_static_md5_cache[key] = version
|
||||
return url_for("static", filename=filename, v=version)
|
||||
path.stat()
|
||||
# Note: A single JS/CSS hash keeps module imports, stylesheets and local libraries on the same cache version.
|
||||
return url_for("static", filename=filename, v=current_static_hash())
|
||||
except OSError:
|
||||
return url_for("static", filename=filename)
|
||||
|
||||
@@ -104,6 +101,7 @@ def create_app() -> Flask:
|
||||
"static_url": static_url,
|
||||
"frontend_asset_url": frontend_asset_url,
|
||||
"bootstrap_theme_url": bootstrap_theme_url,
|
||||
"static_hash": current_static_hash,
|
||||
}
|
||||
|
||||
@app.after_request
|
||||
@@ -126,10 +124,8 @@ def create_app() -> Flask:
|
||||
|
||||
from .routes.main import bp as main_bp
|
||||
from .routes.api import bp as api_bp
|
||||
from .routes.planner import bp as planner_api_bp
|
||||
app.register_blueprint(main_bp)
|
||||
app.register_blueprint(api_bp)
|
||||
app.register_blueprint(planner_api_bp)
|
||||
register_error_pages(app)
|
||||
init_db()
|
||||
from .services.speed_peaks import load_cache
|
||||
@@ -145,6 +141,8 @@ def create_app() -> Flask:
|
||||
register_socketio_handlers(socketio)
|
||||
from .services.startup_config import schedule_startup_config_apply
|
||||
schedule_startup_config_apply(socketio)
|
||||
from .services.background_automations import start_scheduler as start_background_automation_scheduler
|
||||
start_background_automation_scheduler(socketio)
|
||||
from .services.rss import start_scheduler as start_rss_scheduler
|
||||
from .services.ratio_rules import start_scheduler as start_ratio_scheduler
|
||||
from .services.download_planner import start_scheduler as start_download_planner_scheduler
|
||||
@@ -153,4 +151,6 @@ def create_app() -> Flask:
|
||||
start_ratio_scheduler(socketio)
|
||||
start_download_planner_scheduler(socketio)
|
||||
start_backup_scheduler()
|
||||
from .services.background_cache_warmup import start_scheduler as start_cache_warmup_scheduler
|
||||
start_cache_warmup_scheduler(socketio)
|
||||
return app
|
||||
|
||||
+1
-3
@@ -1,10 +1,8 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import getpass
|
||||
import sys
|
||||
import json
|
||||
|
||||
from .db import connect, init_db, utcnow
|
||||
from .services.auth import password_hash
|
||||
from .services import tracker_cache
|
||||
@@ -106,7 +104,7 @@ def build_parser() -> argparse.ArgumentParser:
|
||||
token.set_defaults(func=_cmd_revoke_api_token)
|
||||
|
||||
icon = sub.add_parser("tracker-favicon", help="Download or refresh a tracker favicon cache file")
|
||||
icon.add_argument("domain", help="Tracker domain, e.g. t.pte.nu")
|
||||
icon.add_argument("domain", help="Tracker domain e.g tracker.example.com")
|
||||
icon.add_argument("--no-refresh", action="store_true", help="Use fresh cache when available")
|
||||
icon.add_argument("--debug", action="store_true", help="Print cache diagnostics on success or failure")
|
||||
icon.set_defaults(func=_cmd_tracker_favicon)
|
||||
|
||||
+28
-2
@@ -1,5 +1,4 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import secrets
|
||||
from pathlib import Path
|
||||
@@ -29,6 +28,22 @@ DEBUG = _env_bool("PYTORRENT_DEBUG", False)
|
||||
USE_OFFLINE_LIBS = _env_bool("PYTORRENT_USE_OFFLINE_LIBS", False)
|
||||
# Note: Optional authentication remains disabled unless explicitly enabled in .env.
|
||||
AUTH_ENABLE = _env_bool("PYTORRENT_AUTH_ENABLE", False)
|
||||
AUTH_PROVIDER = os.getenv("PYTORRENT_AUTH_PROVIDER", "local").strip().lower() or "local"
|
||||
if AUTH_PROVIDER not in {"local", "proxy", "tinyauth"}:
|
||||
AUTH_PROVIDER = "local"
|
||||
|
||||
# Note: External auth reads only one identity value from the trusted reverse proxy.
|
||||
AUTH_PROXY_USER_HEADER = os.getenv("PYTORRENT_AUTH_PROXY_USER_HEADER", "Remote-User").strip() or "Remote-User"
|
||||
AUTH_PROXY_AUTO_CREATE = _env_bool("PYTORRENT_AUTH_PROXY_AUTO_CREATE", False)
|
||||
AUTH_PROXY_AUTO_CREATE_ROLE = os.getenv("PYTORRENT_AUTH_PROXY_AUTO_CREATE_ROLE", "user").strip().lower()
|
||||
AUTH_PROXY_AUTO_CREATE_PERMISSION = os.getenv("PYTORRENT_AUTH_PROXY_AUTO_CREATE_PERMISSION", "ro").strip().lower()
|
||||
if AUTH_PROXY_AUTO_CREATE_ROLE not in {"user", "admin"}:
|
||||
AUTH_PROXY_AUTO_CREATE_ROLE = "user"
|
||||
# Note: Keep rw as an operator-friendly alias while storing full internally.
|
||||
if AUTH_PROXY_AUTO_CREATE_PERMISSION == "rw":
|
||||
AUTH_PROXY_AUTO_CREATE_PERMISSION = "full"
|
||||
if AUTH_PROXY_AUTO_CREATE_PERMISSION not in {"none", "ro", "full"}:
|
||||
AUTH_PROXY_AUTO_CREATE_PERMISSION = "ro"
|
||||
if AUTH_ENABLE and (not _SECRET_KEY_ENV or SECRET_KEY == "dev-change-me"):
|
||||
# Note: Auth mode cannot use Flask's development secret; persist a local random session key instead.
|
||||
_secret_file = BASE_DIR / "data" / ".session_secret"
|
||||
@@ -70,16 +85,27 @@ PROXY_FIX_X_HOST = _env_int("PYTORRENT_PROXY_FIX_X_HOST", 1, 0)
|
||||
PROXY_FIX_X_PORT = _env_int("PYTORRENT_PROXY_FIX_X_PORT", 1, 0)
|
||||
PROXY_FIX_X_PREFIX = _env_int("PYTORRENT_PROXY_FIX_X_PREFIX", 1, 0)
|
||||
|
||||
def _env_csv(name: str) -> list[str]:
|
||||
return [item.strip().rstrip("/") for item in os.getenv(name, "").split(",") if item.strip()]
|
||||
|
||||
_SOCKETIO_CORS = os.getenv("PYTORRENT_SOCKETIO_CORS_ALLOWED_ORIGINS", "").strip()
|
||||
SOCKETIO_CORS_ALLOWED_ORIGINS = None if not _SOCKETIO_CORS else [item.strip() for item in _SOCKETIO_CORS.split(",") if item.strip()]
|
||||
# Note: API origin checks are separate from Socket.IO CORS. When unset, reuse the Socket.IO allowlist for operator-friendly reverse proxy setups.
|
||||
_API_ALLOWED_ORIGINS = _env_csv("PYTORRENT_API_ALLOWED_ORIGINS")
|
||||
API_ALLOWED_ORIGINS = _API_ALLOWED_ORIGINS or _env_csv("PYTORRENT_SOCKETIO_CORS_ALLOWED_ORIGINS")
|
||||
# Note: Optional auth bypass for trusted direct-IP/local access. Values can be hosts or host:port pairs.
|
||||
AUTH_BYPASS_HOSTS = {item.lower() for item in _env_csv("PYTORRENT_AUTH_BYPASS_HOSTS")}
|
||||
# Note: Trusted auth-bypass requests act as this existing active user.
|
||||
AUTH_BYPASS_USER = os.getenv("PYTORRENT_AUTH_BYPASS_USER", "admin").strip() or "admin"
|
||||
|
||||
TRAFFIC_HISTORY_RETENTION_DAYS = _env_int("PYTORRENT_TRAFFIC_HISTORY_RETENTION_DAYS", 90, 1)
|
||||
JOBS_RETENTION_DAYS = _env_int("PYTORRENT_JOBS_RETENTION_DAYS", 30, 1)
|
||||
SMART_QUEUE_HISTORY_RETENTION_DAYS = _env_int("PYTORRENT_SMART_QUEUE_HISTORY_RETENTION_DAYS", 30, 1)
|
||||
LOG_RETENTION_DAYS = _env_int("PYTORRENT_LOG_RETENTION_DAYS", 1, 1)
|
||||
LOG_RETENTION_HOURS = _env_int("PYTORRENT_LOG_RETENTION_HOURS", 24, 1)
|
||||
LOG_ENABLE = _env_bool("PYTORRENT_LOG_ENABLE", True)
|
||||
LOG_DIR = Path(os.getenv("PYTORRENT_LOG_DIR", "data/logs"))
|
||||
if not LOG_DIR.is_absolute():
|
||||
LOG_DIR = BASE_DIR / LOG_DIR
|
||||
SMART_QUEUE_LABEL = os.getenv("PYTORRENT_SMART_QUEUE_L.ABEL", "Smart Queue Stopped")
|
||||
SMART_QUEUE_LABEL = os.getenv("PYTORRENT_SMART_QUEUE_LABEL", os.getenv("PYTORRENT_SMART_QUEUE_L.ABEL", "Smart Queue Stopped"))
|
||||
SMART_QUEUE_STALLED_LABEL = os.getenv("PYTORRENT_SMART_QUEUE_STALLED_LABEL", "Stalled")
|
||||
|
||||
+113
-186
@@ -1,15 +1,19 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
from contextlib import contextmanager
|
||||
from datetime import datetime, timezone
|
||||
from .config import DB_PATH
|
||||
from .migrations import run_database_migrations
|
||||
|
||||
SCHEMA = """
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
username TEXT UNIQUE NOT NULL,
|
||||
password_hash TEXT,
|
||||
email TEXT,
|
||||
display_name TEXT,
|
||||
external_auth_provider TEXT,
|
||||
external_subject TEXT,
|
||||
role TEXT DEFAULT 'user',
|
||||
is_active INTEGER DEFAULT 1,
|
||||
created_at TEXT NOT NULL,
|
||||
@@ -51,32 +55,45 @@ CREATE TABLE IF NOT EXISTS user_preferences (
|
||||
bootstrap_theme TEXT DEFAULT 'default',
|
||||
font_family TEXT DEFAULT 'default',
|
||||
active_rtorrent_id INTEGER,
|
||||
table_columns_json TEXT,
|
||||
keyboard_json TEXT,
|
||||
mobile_mode INTEGER DEFAULT 0,
|
||||
peers_refresh_seconds INTEGER DEFAULT 0,
|
||||
port_check_enabled INTEGER DEFAULT 0,
|
||||
compact_torrent_list_enabled INTEGER DEFAULT 0,
|
||||
torrent_list_font_size INTEGER DEFAULT 13,
|
||||
footer_items_json TEXT,
|
||||
title_speed_enabled INTEGER DEFAULT 0,
|
||||
tracker_favicons_enabled INTEGER DEFAULT 0,
|
||||
reverse_dns_enabled INTEGER DEFAULT 0,
|
||||
automation_toasts_enabled INTEGER DEFAULT 1,
|
||||
smart_queue_toasts_enabled INTEGER DEFAULT 1,
|
||||
disk_monitor_paths_json TEXT,
|
||||
disk_monitor_mode TEXT DEFAULT 'default',
|
||||
disk_monitor_selected_path TEXT,
|
||||
disk_monitor_stop_enabled INTEGER DEFAULT 0,
|
||||
disk_monitor_stop_threshold INTEGER DEFAULT 98,
|
||||
easter_egg_enabled INTEGER DEFAULT 0,
|
||||
easter_egg_loading_image_url TEXT DEFAULT '',
|
||||
easter_egg_click_image_url TEXT DEFAULT '',
|
||||
interface_scale INTEGER DEFAULT 100,
|
||||
detail_panel_height INTEGER DEFAULT 255,
|
||||
torrent_sort_json TEXT,
|
||||
active_filter TEXT DEFAULT 'all',
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
FOREIGN KEY(user_id) REFERENCES users(id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_user_preferences_user ON user_preferences(user_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS profile_preferences (
|
||||
user_id INTEGER NOT NULL,
|
||||
profile_id INTEGER NOT NULL,
|
||||
table_columns_json TEXT,
|
||||
torrent_sort_json TEXT,
|
||||
active_filter TEXT DEFAULT 'all',
|
||||
peers_refresh_seconds INTEGER DEFAULT 0,
|
||||
port_check_enabled INTEGER DEFAULT 0,
|
||||
tracker_favicons_enabled INTEGER DEFAULT 0,
|
||||
reverse_dns_enabled INTEGER DEFAULT 0,
|
||||
sidebar_labels_expanded INTEGER DEFAULT 0,
|
||||
sidebar_shortcuts_expanded INTEGER DEFAULT 0,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
PRIMARY KEY(user_id, profile_id),
|
||||
FOREIGN KEY(user_id) REFERENCES users(id),
|
||||
FOREIGN KEY(profile_id) REFERENCES rtorrent_profiles(id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_profile_preferences_user_profile ON profile_preferences(user_id, profile_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS rtorrent_profiles (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
@@ -122,8 +139,8 @@ CREATE INDEX IF NOT EXISTS idx_jobs_created ON jobs(created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_jobs_profile_created ON jobs(profile_id, created_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS disk_monitor_preferences (
|
||||
profile_id INTEGER PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL,
|
||||
profile_id INTEGER NOT NULL,
|
||||
paths_json TEXT,
|
||||
mode TEXT DEFAULT 'default',
|
||||
selected_path TEXT,
|
||||
@@ -131,10 +148,10 @@ CREATE TABLE IF NOT EXISTS disk_monitor_preferences (
|
||||
stop_threshold INTEGER DEFAULT 98,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
PRIMARY KEY(user_id, profile_id),
|
||||
FOREIGN KEY(user_id) REFERENCES users(id),
|
||||
FOREIGN KEY(profile_id) REFERENCES rtorrent_profiles(id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_disk_monitor_preferences_owner ON disk_monitor_preferences(user_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS labels (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
@@ -170,8 +187,7 @@ CREATE TABLE IF NOT EXISTS ratio_groups (
|
||||
|
||||
CREATE TABLE IF NOT EXISTS rss_feeds (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
profile_id INTEGER,
|
||||
profile_id INTEGER NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
url TEXT NOT NULL,
|
||||
enabled INTEGER DEFAULT 1,
|
||||
@@ -185,8 +201,7 @@ CREATE TABLE IF NOT EXISTS rss_feeds (
|
||||
|
||||
CREATE TABLE IF NOT EXISTS rss_rules (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
profile_id INTEGER,
|
||||
profile_id INTEGER NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
pattern TEXT NOT NULL,
|
||||
exclude_pattern TEXT,
|
||||
@@ -203,13 +218,12 @@ CREATE TABLE IF NOT EXISTS rss_rules (
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_rss_feeds_user_profile_enabled_next ON rss_feeds(user_id, profile_id, enabled, next_check_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_rss_rules_user_profile_enabled ON rss_rules(user_id, profile_id, enabled);
|
||||
CREATE INDEX IF NOT EXISTS idx_rss_feeds_profile_enabled_next ON rss_feeds(profile_id, enabled, next_check_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_rss_rules_profile_enabled ON rss_rules(profile_id, enabled);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS rss_history (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
profile_id INTEGER,
|
||||
profile_id INTEGER NOT NULL,
|
||||
feed_id INTEGER,
|
||||
rule_id INTEGER,
|
||||
title TEXT,
|
||||
@@ -219,8 +233,7 @@ CREATE TABLE IF NOT EXISTS rss_history (
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_rss_history_profile_created ON rss_history(profile_id, created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_rss_history_user_profile_created ON rss_history(user_id, profile_id, created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_rss_history_user_profile_status ON rss_history(user_id, profile_id, status);
|
||||
CREATE INDEX IF NOT EXISTS idx_rss_history_profile_status ON rss_history(profile_id, status);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_rss_history_unique_success ON rss_history(profile_id, COALESCE(rule_id,0), link) WHERE status IN ('queued','added');
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ratio_assignments (
|
||||
@@ -252,17 +265,22 @@ CREATE INDEX IF NOT EXISTS idx_ratio_history_profile_created ON ratio_history(pr
|
||||
CREATE INDEX IF NOT EXISTS idx_ratio_history_user_profile_id ON ratio_history(user_id, profile_id, id);
|
||||
CREATE INDEX IF NOT EXISTS idx_ratio_assignments_profile_status ON ratio_assignments(profile_id, last_status);
|
||||
CREATE INDEX IF NOT EXISTS idx_ratio_groups_user_profile_enabled ON ratio_groups(user_id, profile_id, enabled);
|
||||
CREATE INDEX IF NOT EXISTS idx_ratio_groups_profile_enabled ON ratio_groups(profile_id, enabled, name);
|
||||
CREATE INDEX IF NOT EXISTS idx_labels_profile_name ON labels(profile_id, name);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS app_backups (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
backup_type TEXT DEFAULT 'app',
|
||||
profile_id INTEGER,
|
||||
payload_json TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_app_backups_profile_type_created ON app_backups(profile_id, backup_type, created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_app_backups_user_type_created ON app_backups(user_id, backup_type, created_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS smart_queue_settings (
|
||||
user_id INTEGER NOT NULL,
|
||||
profile_id INTEGER NOT NULL,
|
||||
enabled INTEGER DEFAULT 0,
|
||||
max_active_downloads INTEGER DEFAULT 5,
|
||||
@@ -278,12 +296,17 @@ CREATE TABLE IF NOT EXISTS smart_queue_settings (
|
||||
refill_enabled INTEGER DEFAULT 1,
|
||||
refill_interval_minutes INTEGER DEFAULT 0,
|
||||
last_refill_at TEXT,
|
||||
surge_refill_enabled INTEGER DEFAULT 0,
|
||||
surge_refill_interval_minutes INTEGER DEFAULT 1440,
|
||||
surge_refill_batch_size INTEGER DEFAULT 2000,
|
||||
last_surge_refill_at TEXT,
|
||||
stop_batch_size INTEGER DEFAULT 50,
|
||||
start_grace_seconds INTEGER DEFAULT 900,
|
||||
protect_active_below_cap INTEGER DEFAULT 1,
|
||||
prefer_partial_progress INTEGER DEFAULT 1,
|
||||
auto_stop_idle INTEGER DEFAULT 0,
|
||||
updated_at TEXT NOT NULL,
|
||||
PRIMARY KEY(user_id, profile_id)
|
||||
PRIMARY KEY(profile_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS smart_queue_stalled (
|
||||
@@ -304,19 +327,17 @@ CREATE TABLE IF NOT EXISTS smart_queue_start_grace (
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS smart_queue_exclusions (
|
||||
user_id INTEGER NOT NULL,
|
||||
profile_id INTEGER NOT NULL,
|
||||
torrent_hash TEXT NOT NULL,
|
||||
reason TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
PRIMARY KEY(user_id, profile_id, torrent_hash)
|
||||
PRIMARY KEY(profile_id, torrent_hash)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_smart_queue_exclusions_user_profile_created ON smart_queue_exclusions(user_id, profile_id, created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_smart_queue_exclusions_profile_created ON smart_queue_exclusions(profile_id, created_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS smart_queue_history (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
profile_id INTEGER NOT NULL,
|
||||
event TEXT NOT NULL,
|
||||
paused_count INTEGER DEFAULT 0,
|
||||
@@ -327,7 +348,7 @@ CREATE TABLE IF NOT EXISTS smart_queue_history (
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_smart_queue_history_profile_created ON smart_queue_history(profile_id, created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_smart_queue_history_user_profile_created ON smart_queue_history(user_id, profile_id, created_at);
|
||||
|
||||
|
||||
CREATE TABLE IF NOT EXISTS smart_queue_auto_labels (
|
||||
profile_id INTEGER NOT NULL,
|
||||
@@ -350,6 +371,15 @@ CREATE TABLE IF NOT EXISTS traffic_history (
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_traffic_history_profile_created ON traffic_history(profile_id, created_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS profile_speed_limits (
|
||||
profile_id INTEGER PRIMARY KEY,
|
||||
down_limit INTEGER DEFAULT 0,
|
||||
up_limit INTEGER DEFAULT 0,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
FOREIGN KEY(profile_id) REFERENCES rtorrent_profiles(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS transfer_speed_peaks (
|
||||
profile_id INTEGER PRIMARY KEY,
|
||||
session_started_at TEXT NOT NULL,
|
||||
@@ -405,14 +435,13 @@ CREATE INDEX IF NOT EXISTS idx_automation_history_profile_created ON automation_
|
||||
CREATE INDEX IF NOT EXISTS idx_automation_history_user_profile_created ON automation_history(user_id, profile_id, created_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS rtorrent_config_overrides (
|
||||
user_id INTEGER NOT NULL,
|
||||
profile_id INTEGER NOT NULL,
|
||||
key TEXT NOT NULL,
|
||||
value TEXT,
|
||||
baseline_value TEXT,
|
||||
apply_on_start INTEGER DEFAULT 0,
|
||||
updated_at TEXT NOT NULL,
|
||||
PRIMARY KEY(user_id, profile_id, key)
|
||||
PRIMARY KEY(profile_id, key)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_rtorrent_config_overrides_profile ON rtorrent_config_overrides(profile_id, apply_on_start);
|
||||
|
||||
@@ -421,6 +450,13 @@ CREATE TABLE IF NOT EXISTS app_settings (
|
||||
value TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS poller_settings (
|
||||
profile_id INTEGER PRIMARY KEY,
|
||||
settings_json TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
FOREIGN KEY(profile_id) REFERENCES rtorrent_profiles(id)
|
||||
);
|
||||
|
||||
|
||||
CREATE TABLE IF NOT EXISTS download_plan_settings (
|
||||
user_id INTEGER NOT NULL,
|
||||
@@ -429,6 +465,7 @@ CREATE TABLE IF NOT EXISTS download_plan_settings (
|
||||
updated_at TEXT NOT NULL,
|
||||
PRIMARY KEY(user_id, profile_id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_download_plan_settings_profile ON download_plan_settings(profile_id, updated_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS download_plan_paused (
|
||||
profile_id INTEGER NOT NULL,
|
||||
@@ -483,10 +520,24 @@ CREATE TABLE IF NOT EXISTS operation_log_settings (
|
||||
retention_mode TEXT DEFAULT 'days',
|
||||
retention_days INTEGER DEFAULT 30,
|
||||
retention_lines INTEGER DEFAULT 5000,
|
||||
retention_interval_hours INTEGER DEFAULT 24,
|
||||
job_retention_mode TEXT DEFAULT 'days',
|
||||
job_retention_days INTEGER DEFAULT 7,
|
||||
job_retention_lines INTEGER DEFAULT 2000,
|
||||
job_retention_interval_hours INTEGER DEFAULT 24,
|
||||
job_last_retention_run_at TEXT,
|
||||
job_last_retention_deleted INTEGER DEFAULT 0,
|
||||
operation_retention_mode TEXT DEFAULT 'days',
|
||||
operation_retention_days INTEGER DEFAULT 30,
|
||||
operation_retention_lines INTEGER DEFAULT 5000,
|
||||
operation_retention_interval_hours INTEGER DEFAULT 24,
|
||||
operation_last_retention_run_at TEXT,
|
||||
operation_last_retention_deleted INTEGER DEFAULT 0,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
PRIMARY KEY(user_id, profile_id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_operation_log_settings_profile ON operation_log_settings(profile_id, updated_at);
|
||||
CREATE TABLE IF NOT EXISTS tracker_favicon_cache (
|
||||
domain TEXT PRIMARY KEY,
|
||||
source_url TEXT,
|
||||
@@ -498,136 +549,30 @@ CREATE TABLE IF NOT EXISTS tracker_favicon_cache (
|
||||
);
|
||||
"""
|
||||
|
||||
MIGRATIONS = [
|
||||
"ALTER TABLE api_tokens ADD COLUMN last_used_at TEXT",
|
||||
"ALTER TABLE users ADD COLUMN role TEXT DEFAULT 'user'",
|
||||
"ALTER TABLE users ADD COLUMN is_active INTEGER DEFAULT 1",
|
||||
"ALTER TABLE users ADD COLUMN updated_at TEXT",
|
||||
"ALTER TABLE user_preferences ADD COLUMN mobile_mode INTEGER DEFAULT 0",
|
||||
"ALTER TABLE user_preferences ADD COLUMN peers_refresh_seconds INTEGER DEFAULT 0",
|
||||
"ALTER TABLE user_preferences ADD COLUMN port_check_enabled INTEGER DEFAULT 0",
|
||||
"ALTER TABLE user_preferences ADD COLUMN bootstrap_theme TEXT DEFAULT 'default'",
|
||||
"ALTER TABLE user_preferences ADD COLUMN font_family TEXT DEFAULT 'default'",
|
||||
"ALTER TABLE user_preferences ADD COLUMN footer_items_json TEXT",
|
||||
"ALTER TABLE user_preferences ADD COLUMN title_speed_enabled INTEGER DEFAULT 0",
|
||||
"ALTER TABLE user_preferences ADD COLUMN tracker_favicons_enabled INTEGER DEFAULT 0",
|
||||
"ALTER TABLE user_preferences ADD COLUMN reverse_dns_enabled INTEGER DEFAULT 0",
|
||||
"ALTER TABLE user_preferences ADD COLUMN interface_scale INTEGER DEFAULT 100",
|
||||
"ALTER TABLE user_preferences ADD COLUMN detail_panel_height INTEGER DEFAULT 255",
|
||||
"ALTER TABLE user_preferences ADD COLUMN torrent_sort_json TEXT",
|
||||
"ALTER TABLE user_preferences ADD COLUMN active_filter TEXT DEFAULT 'all'",
|
||||
"ALTER TABLE rtorrent_profiles ADD COLUMN max_parallel_jobs INTEGER DEFAULT 5",
|
||||
"ALTER TABLE rtorrent_profiles ADD COLUMN light_parallel_jobs INTEGER DEFAULT 4",
|
||||
"ALTER TABLE rtorrent_profiles ADD COLUMN light_job_timeout_seconds INTEGER DEFAULT 300",
|
||||
"ALTER TABLE rtorrent_profiles ADD COLUMN heavy_job_timeout_seconds INTEGER DEFAULT 7200",
|
||||
"ALTER TABLE rtorrent_profiles ADD COLUMN pending_job_timeout_seconds INTEGER DEFAULT 900",
|
||||
"ALTER TABLE rtorrent_profiles ADD COLUMN is_remote INTEGER DEFAULT 0",
|
||||
"ALTER TABLE jobs ADD COLUMN attempts INTEGER DEFAULT 0",
|
||||
"ALTER TABLE jobs ADD COLUMN max_attempts INTEGER DEFAULT 2",
|
||||
"ALTER TABLE jobs ADD COLUMN result_json TEXT",
|
||||
"ALTER TABLE jobs ADD COLUMN state_json TEXT",
|
||||
"ALTER TABLE jobs ADD COLUMN progress_current INTEGER DEFAULT 0",
|
||||
"ALTER TABLE jobs ADD COLUMN progress_total INTEGER DEFAULT 0",
|
||||
"ALTER TABLE jobs ADD COLUMN heartbeat_at TEXT",
|
||||
"ALTER TABLE jobs ADD COLUMN started_at TEXT",
|
||||
"ALTER TABLE jobs ADD COLUMN finished_at TEXT",
|
||||
"CREATE INDEX IF NOT EXISTS idx_jobs_status_updated ON jobs(status, updated_at)",
|
||||
"CREATE INDEX IF NOT EXISTS idx_jobs_status_started ON jobs(status, started_at)",
|
||||
"CREATE INDEX IF NOT EXISTS idx_jobs_status_heartbeat ON jobs(status, heartbeat_at)",
|
||||
"CREATE INDEX IF NOT EXISTS idx_jobs_user_profile_created ON jobs(user_id, profile_id, created_at)",
|
||||
"CREATE INDEX IF NOT EXISTS idx_jobs_profile_status_active ON jobs(profile_id, status)",
|
||||
"ALTER TABLE automation_rules ADD COLUMN cooldown_minutes INTEGER DEFAULT 60",
|
||||
"ALTER TABLE rtorrent_config_overrides ADD COLUMN apply_on_start INTEGER DEFAULT 0",
|
||||
"ALTER TABLE rtorrent_config_overrides ADD COLUMN baseline_value TEXT",
|
||||
"ALTER TABLE torrent_stats_cache ADD COLUMN updated_epoch REAL DEFAULT 0",
|
||||
"ALTER TABLE smart_queue_settings ADD COLUMN manage_stopped INTEGER DEFAULT 0",
|
||||
"ALTER TABLE smart_queue_settings ADD COLUMN min_peers INTEGER DEFAULT 0",
|
||||
"ALTER TABLE smart_queue_settings ADD COLUMN ignore_seed_peer INTEGER DEFAULT 0",
|
||||
"ALTER TABLE smart_queue_settings ADD COLUMN ignore_speed INTEGER DEFAULT 0",
|
||||
"ALTER TABLE smart_queue_stalled ADD COLUMN timer_key TEXT DEFAULT ''",
|
||||
"CREATE TABLE IF NOT EXISTS tracker_summary_cache (profile_id INTEGER NOT NULL, torrent_hash TEXT NOT NULL, trackers_json TEXT NOT NULL, updated_at TEXT NOT NULL, updated_epoch REAL DEFAULT 0, PRIMARY KEY(profile_id, torrent_hash))",
|
||||
"CREATE INDEX IF NOT EXISTS idx_tracker_summary_cache_profile ON tracker_summary_cache(profile_id, updated_epoch)",
|
||||
"CREATE TABLE IF NOT EXISTS tracker_favicon_cache (domain TEXT PRIMARY KEY, source_url TEXT, file_path TEXT, mime_type TEXT, updated_at TEXT NOT NULL, updated_epoch REAL DEFAULT 0, error TEXT)",
|
||||
"ALTER TABLE user_preferences ADD COLUMN automation_toasts_enabled INTEGER DEFAULT 1",
|
||||
"ALTER TABLE user_preferences ADD COLUMN smart_queue_toasts_enabled INTEGER DEFAULT 1",
|
||||
"ALTER TABLE user_preferences ADD COLUMN disk_monitor_paths_json TEXT",
|
||||
"ALTER TABLE user_preferences ADD COLUMN disk_monitor_mode TEXT DEFAULT 'default'",
|
||||
"ALTER TABLE user_preferences ADD COLUMN disk_monitor_selected_path TEXT",
|
||||
"ALTER TABLE user_preferences ADD COLUMN disk_monitor_stop_enabled INTEGER DEFAULT 0",
|
||||
"ALTER TABLE user_preferences ADD COLUMN disk_monitor_stop_threshold INTEGER DEFAULT 98",
|
||||
"ALTER TABLE smart_queue_settings ADD COLUMN cooldown_minutes INTEGER DEFAULT 10",
|
||||
"ALTER TABLE smart_queue_settings ADD COLUMN last_run_at TEXT",
|
||||
"ALTER TABLE smart_queue_settings ADD COLUMN refill_enabled INTEGER DEFAULT 1",
|
||||
"ALTER TABLE smart_queue_settings ADD COLUMN refill_interval_minutes INTEGER DEFAULT 0",
|
||||
"ALTER TABLE smart_queue_settings ADD COLUMN last_refill_at TEXT",
|
||||
"ALTER TABLE smart_queue_settings ADD COLUMN stop_batch_size INTEGER DEFAULT 50",
|
||||
"ALTER TABLE smart_queue_settings ADD COLUMN start_grace_seconds INTEGER DEFAULT 900",
|
||||
"ALTER TABLE smart_queue_settings ADD COLUMN protect_active_below_cap INTEGER DEFAULT 1",
|
||||
"ALTER TABLE smart_queue_settings ADD COLUMN auto_stop_idle INTEGER DEFAULT 0",
|
||||
"CREATE TABLE IF NOT EXISTS smart_queue_start_grace (profile_id INTEGER NOT NULL, torrent_hash TEXT NOT NULL, started_at TEXT NOT NULL, updated_at TEXT NOT NULL, PRIMARY KEY(profile_id, torrent_hash))",
|
||||
"ALTER TABLE rss_feeds ADD COLUMN interval_minutes INTEGER DEFAULT 30",
|
||||
"ALTER TABLE rss_feeds ADD COLUMN next_check_at TEXT",
|
||||
"ALTER TABLE rss_rules ADD COLUMN exclude_pattern TEXT",
|
||||
"ALTER TABLE rss_rules ADD COLUMN min_size_mb INTEGER DEFAULT 0",
|
||||
"ALTER TABLE rss_rules ADD COLUMN max_size_mb INTEGER DEFAULT 0",
|
||||
"ALTER TABLE rss_rules ADD COLUMN category TEXT",
|
||||
"ALTER TABLE rss_rules ADD COLUMN quality TEXT",
|
||||
"ALTER TABLE rss_rules ADD COLUMN season INTEGER",
|
||||
"ALTER TABLE rss_rules ADD COLUMN episode INTEGER",
|
||||
"ALTER TABLE ratio_groups ADD COLUMN min_seed_time_minutes INTEGER DEFAULT 0",
|
||||
"ALTER TABLE ratio_groups ADD COLUMN ignore_private INTEGER DEFAULT 1",
|
||||
"ALTER TABLE ratio_groups ADD COLUMN ignore_active_upload INTEGER DEFAULT 1",
|
||||
"ALTER TABLE ratio_groups ADD COLUMN active_upload_min_bytes INTEGER DEFAULT 1024",
|
||||
"ALTER TABLE ratio_groups ADD COLUMN move_path TEXT",
|
||||
"ALTER TABLE ratio_groups ADD COLUMN set_label TEXT",
|
||||
"ALTER TABLE automation_history ADD COLUMN torrent_name TEXT",
|
||||
"ALTER TABLE automation_history ADD COLUMN rule_name TEXT",
|
||||
"ALTER TABLE automation_history ADD COLUMN actions_json TEXT",
|
||||
"ALTER TABLE automation_history ADD COLUMN torrent_hash TEXT",
|
||||
"CREATE TABLE IF NOT EXISTS rss_history (id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL, profile_id INTEGER, feed_id INTEGER, rule_id INTEGER, title TEXT, link TEXT, status TEXT NOT NULL, message TEXT, created_at TEXT NOT NULL)",
|
||||
"CREATE INDEX IF NOT EXISTS idx_rss_history_profile_created ON rss_history(profile_id, created_at)",
|
||||
"CREATE UNIQUE INDEX IF NOT EXISTS idx_rss_history_unique_success ON rss_history(profile_id, COALESCE(rule_id,0), link) WHERE status IN ('queued','added')",
|
||||
"CREATE TABLE IF NOT EXISTS ratio_assignments (profile_id INTEGER NOT NULL, torrent_hash TEXT NOT NULL, group_id INTEGER, group_name TEXT, applied_at TEXT, last_status TEXT, updated_at TEXT NOT NULL, PRIMARY KEY(profile_id, torrent_hash))",
|
||||
"CREATE TABLE IF NOT EXISTS ratio_history (id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL, profile_id INTEGER NOT NULL, group_id INTEGER, group_name TEXT, torrent_hash TEXT NOT NULL, torrent_name TEXT, action TEXT NOT NULL, status TEXT NOT NULL, reason TEXT, details_json TEXT, created_at TEXT NOT NULL)",
|
||||
"CREATE INDEX IF NOT EXISTS idx_ratio_history_profile_created ON ratio_history(profile_id, created_at)",
|
||||
"CREATE TABLE IF NOT EXISTS app_backups (id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL, name TEXT NOT NULL, payload_json TEXT NOT NULL, created_at TEXT NOT NULL)",
|
||||
"CREATE TABLE IF NOT EXISTS disk_monitor_preferences (user_id INTEGER NOT NULL, profile_id INTEGER NOT NULL, paths_json TEXT, mode TEXT DEFAULT 'default', selected_path TEXT, stop_enabled INTEGER DEFAULT 0, stop_threshold INTEGER DEFAULT 98, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, PRIMARY KEY(user_id, profile_id), FOREIGN KEY(user_id) REFERENCES users(id), FOREIGN KEY(profile_id) REFERENCES rtorrent_profiles(id))",
|
||||
"CREATE TABLE IF NOT EXISTS download_plan_settings (user_id INTEGER NOT NULL, profile_id INTEGER NOT NULL, settings_json TEXT NOT NULL, updated_at TEXT NOT NULL, PRIMARY KEY(user_id, profile_id))",
|
||||
"CREATE TABLE IF NOT EXISTS download_plan_paused (profile_id INTEGER NOT NULL, torrent_hash TEXT NOT NULL, reason TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, PRIMARY KEY(profile_id, torrent_hash))",
|
||||
"CREATE INDEX IF NOT EXISTS idx_download_plan_paused_profile ON download_plan_paused(profile_id, updated_at)",
|
||||
"CREATE INDEX IF NOT EXISTS idx_jobs_created ON jobs(created_at)",
|
||||
"CREATE INDEX IF NOT EXISTS idx_jobs_profile_created ON jobs(profile_id, created_at)",
|
||||
"CREATE INDEX IF NOT EXISTS idx_rss_feeds_user_profile_enabled_next ON rss_feeds(user_id, profile_id, enabled, next_check_at)",
|
||||
"CREATE INDEX IF NOT EXISTS idx_rss_rules_user_profile_enabled ON rss_rules(user_id, profile_id, enabled)",
|
||||
"CREATE INDEX IF NOT EXISTS idx_rss_history_user_profile_created ON rss_history(user_id, profile_id, created_at)",
|
||||
"CREATE INDEX IF NOT EXISTS idx_rss_history_user_profile_status ON rss_history(user_id, profile_id, status)",
|
||||
"CREATE INDEX IF NOT EXISTS idx_ratio_groups_user_profile_enabled ON ratio_groups(user_id, profile_id, enabled)",
|
||||
"CREATE INDEX IF NOT EXISTS idx_ratio_assignments_profile_status ON ratio_assignments(profile_id, last_status)",
|
||||
"CREATE INDEX IF NOT EXISTS idx_ratio_history_user_profile_id ON ratio_history(user_id, profile_id, id)",
|
||||
"CREATE INDEX IF NOT EXISTS idx_smart_queue_exclusions_user_profile_created ON smart_queue_exclusions(user_id, profile_id, created_at)",
|
||||
"CREATE INDEX IF NOT EXISTS idx_smart_queue_history_user_profile_created ON smart_queue_history(user_id, profile_id, created_at)",
|
||||
"CREATE INDEX IF NOT EXISTS idx_automation_rules_user_profile_enabled ON automation_rules(user_id, profile_id, enabled)",
|
||||
"CREATE INDEX IF NOT EXISTS idx_automation_history_user_profile_created ON automation_history(user_id, profile_id, created_at)",
|
||||
"CREATE INDEX IF NOT EXISTS idx_user_preferences_user ON user_preferences(user_id)",
|
||||
"CREATE INDEX IF NOT EXISTS idx_rtorrent_profiles_user_default_name ON rtorrent_profiles(user_id, is_default, name COLLATE NOCASE)",
|
||||
"CREATE TABLE IF NOT EXISTS operation_logs (id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL, profile_id INTEGER, event_type TEXT NOT NULL, severity TEXT DEFAULT 'info', source TEXT DEFAULT 'system', torrent_hash TEXT, torrent_name TEXT, action TEXT, message TEXT NOT NULL, details_json TEXT, created_at TEXT NOT NULL)",
|
||||
"CREATE INDEX IF NOT EXISTS idx_operation_logs_profile_created ON operation_logs(profile_id, created_at)",
|
||||
"CREATE INDEX IF NOT EXISTS idx_operation_logs_user_profile_created ON operation_logs(user_id, profile_id, created_at)",
|
||||
"CREATE INDEX IF NOT EXISTS idx_operation_logs_event_type ON operation_logs(event_type, created_at)",
|
||||
"CREATE TABLE IF NOT EXISTS operation_log_settings (user_id INTEGER NOT NULL, profile_id INTEGER NOT NULL DEFAULT 0, retention_mode TEXT DEFAULT 'days', retention_days INTEGER DEFAULT 30, retention_lines INTEGER DEFAULT 5000, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, PRIMARY KEY(user_id, profile_id))",
|
||||
]
|
||||
|
||||
POST_MIGRATION_INDEXES = [
|
||||
"CREATE INDEX IF NOT EXISTS idx_api_tokens_active_user ON api_tokens(revoked_at, user_id)",
|
||||
"CREATE INDEX IF NOT EXISTS idx_user_profile_permissions_user ON user_profile_permissions(user_id, profile_id)",
|
||||
"CREATE INDEX IF NOT EXISTS idx_jobs_status_updated ON jobs(status, updated_at)",
|
||||
"CREATE INDEX IF NOT EXISTS idx_jobs_status_started ON jobs(status, started_at)",
|
||||
"CREATE INDEX IF NOT EXISTS idx_jobs_status_heartbeat ON jobs(status, heartbeat_at)",
|
||||
"CREATE INDEX IF NOT EXISTS idx_jobs_user_profile_created ON jobs(user_id, profile_id, created_at)",
|
||||
"CREATE INDEX IF NOT EXISTS idx_jobs_profile_status_active ON jobs(profile_id, status)",
|
||||
"CREATE INDEX IF NOT EXISTS idx_operation_logs_profile_created ON operation_logs(profile_id, created_at)",
|
||||
"CREATE INDEX IF NOT EXISTS idx_operation_logs_user_profile_created ON operation_logs(user_id, profile_id, created_at)",
|
||||
]
|
||||
def create_schema(conn: sqlite3.Connection) -> None:
|
||||
"""Create the current database schema definition."""
|
||||
conn.executescript(SCHEMA)
|
||||
|
||||
|
||||
def seed_default_user(conn: sqlite3.Connection) -> None:
|
||||
"""Ensure the built-in admin user and default preferences exist."""
|
||||
now = utcnow()
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO users(id, username, password_hash, role, is_active, created_at, updated_at) VALUES(1, 'default', NULL, 'admin', 1, ?, ?)",
|
||||
(now, now),
|
||||
)
|
||||
conn.execute(
|
||||
"UPDATE users SET role=COALESCE(role, 'admin'), is_active=COALESCE(is_active, 1), updated_at=COALESCE(updated_at, ?) WHERE id=1",
|
||||
(now,),
|
||||
)
|
||||
pref = conn.execute("SELECT id FROM user_preferences WHERE user_id=1").fetchone()
|
||||
if not pref:
|
||||
conn.execute(
|
||||
"INSERT INTO user_preferences(user_id, theme, created_at, updated_at) VALUES(1, 'dark', ?, ?)",
|
||||
(now, now),
|
||||
)
|
||||
|
||||
|
||||
def utcnow() -> str:
|
||||
return datetime.now(timezone.utc).isoformat(timespec="seconds")
|
||||
@@ -653,36 +598,18 @@ def connect():
|
||||
|
||||
|
||||
def init_db():
|
||||
"""Initialize SQLite, applying the current schema and idempotent migrations."""
|
||||
with connect() as conn:
|
||||
try:
|
||||
conn.execute("PRAGMA journal_mode = WAL")
|
||||
except sqlite3.OperationalError:
|
||||
pass
|
||||
conn.executescript(SCHEMA)
|
||||
for sql in MIGRATIONS:
|
||||
try:
|
||||
conn.execute(sql)
|
||||
except sqlite3.OperationalError:
|
||||
pass
|
||||
for sql in POST_MIGRATION_INDEXES:
|
||||
try:
|
||||
conn.execute(sql)
|
||||
except sqlite3.OperationalError:
|
||||
pass
|
||||
now = utcnow()
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO users(id, username, password_hash, role, is_active, created_at, updated_at) VALUES(1, 'default', NULL, 'admin', 1, ?, ?)",
|
||||
(now, now),
|
||||
)
|
||||
conn.execute("UPDATE users SET role=COALESCE(role, 'admin'), is_active=COALESCE(is_active, 1), updated_at=COALESCE(updated_at, ?) WHERE id=1", (now,))
|
||||
pref = conn.execute("SELECT id FROM user_preferences WHERE user_id=1").fetchone()
|
||||
if not pref:
|
||||
conn.execute(
|
||||
"INSERT INTO user_preferences(user_id, theme, created_at, updated_at) VALUES(1, 'dark', ?, ?)",
|
||||
(now, now),
|
||||
)
|
||||
create_schema(conn)
|
||||
run_database_migrations(conn)
|
||||
seed_default_user(conn)
|
||||
try:
|
||||
from .services.auth import ensure_admin_user
|
||||
|
||||
ensure_admin_user()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@@ -1,14 +1,11 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import time
|
||||
from logging.handlers import TimedRotatingFileHandler
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from flask import Flask, g, request
|
||||
|
||||
from .config import LOG_DIR, LOG_RETENTION_HOURS
|
||||
from .config import LOG_DIR, LOG_ENABLE, LOG_RETENTION_HOURS
|
||||
|
||||
_CONFIGURED = False
|
||||
|
||||
@@ -33,6 +30,9 @@ def _make_handler(path: Path, level: int) -> TimedRotatingFileHandler:
|
||||
def configure_logging(app: Flask | None = None) -> None:
|
||||
"""Route pyTorrent app, error and access logs to the configured data log directory."""
|
||||
global _CONFIGURED
|
||||
if not LOG_ENABLE:
|
||||
# Note: Installation can disable file logging while keeping normal service stdout/stderr available.
|
||||
return
|
||||
LOG_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
if not _CONFIGURED:
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
from __future__ import annotations
|
||||
import sqlite3
|
||||
from collections.abc import Callable
|
||||
from datetime import datetime, timezone
|
||||
|
||||
Migration = Callable[[sqlite3.Connection], bool]
|
||||
|
||||
|
||||
def _utcnow() -> str:
|
||||
return datetime.now(timezone.utc).isoformat(timespec="seconds")
|
||||
|
||||
|
||||
def _row_value(row: sqlite3.Row | dict[str, object] | tuple[object, ...], key: str, index: int) -> object:
|
||||
try:
|
||||
return row[key] # type: ignore[index]
|
||||
except (KeyError, IndexError, TypeError):
|
||||
return row[index] # type: ignore[index]
|
||||
|
||||
|
||||
def _column_names(conn: sqlite3.Connection, table: str) -> set[str]:
|
||||
return {str(_row_value(row, "name", 1)) for row in conn.execute(f"PRAGMA table_info({table})").fetchall()}
|
||||
|
||||
|
||||
def _primary_key_columns(conn: sqlite3.Connection, table: str) -> list[str]:
|
||||
columns = conn.execute(f"PRAGMA table_info({table})").fetchall()
|
||||
pk_columns = sorted(
|
||||
(
|
||||
(int(_row_value(row, "pk", 5) or 0), str(_row_value(row, "name", 1)))
|
||||
for row in columns
|
||||
if int(_row_value(row, "pk", 5) or 0)
|
||||
),
|
||||
key=lambda item: item[0],
|
||||
)
|
||||
return [name for _, name in pk_columns]
|
||||
|
||||
|
||||
def migrate_disk_monitor_preferences_to_profile_scope(conn: sqlite3.Connection) -> bool:
|
||||
if _primary_key_columns(conn, "disk_monitor_preferences") == ["profile_id"]:
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_disk_monitor_preferences_owner ON disk_monitor_preferences(user_id)")
|
||||
return False
|
||||
|
||||
now = _utcnow()
|
||||
conn.execute("DROP INDEX IF EXISTS idx_disk_monitor_preferences_owner")
|
||||
conn.execute("DROP TABLE IF EXISTS disk_monitor_preferences_new")
|
||||
conn.execute("DROP TABLE IF EXISTS disk_monitor_preferences_old_user_profile")
|
||||
conn.execute("""
|
||||
CREATE TABLE disk_monitor_preferences_new (
|
||||
profile_id INTEGER PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL,
|
||||
paths_json TEXT,
|
||||
mode TEXT DEFAULT 'default',
|
||||
selected_path TEXT,
|
||||
stop_enabled INTEGER DEFAULT 0,
|
||||
stop_threshold INTEGER DEFAULT 98,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
FOREIGN KEY(user_id) REFERENCES users(id),
|
||||
FOREIGN KEY(profile_id) REFERENCES rtorrent_profiles(id)
|
||||
)
|
||||
""")
|
||||
conn.execute("""
|
||||
INSERT INTO disk_monitor_preferences_new(
|
||||
profile_id, user_id, paths_json, mode, selected_path, stop_enabled, stop_threshold, created_at, updated_at
|
||||
)
|
||||
SELECT profile_id, user_id, paths_json, mode, selected_path, stop_enabled, stop_threshold,
|
||||
COALESCE(created_at, ?), COALESCE(updated_at, ?)
|
||||
FROM (
|
||||
SELECT d.*,
|
||||
ROW_NUMBER() OVER (
|
||||
PARTITION BY profile_id
|
||||
ORDER BY COALESCE(updated_at, created_at, '') DESC, user_id ASC
|
||||
) AS rn
|
||||
FROM disk_monitor_preferences d
|
||||
WHERE profile_id IS NOT NULL
|
||||
)
|
||||
WHERE rn = 1
|
||||
""", (now, now))
|
||||
conn.execute("ALTER TABLE disk_monitor_preferences RENAME TO disk_monitor_preferences_old_user_profile")
|
||||
conn.execute("ALTER TABLE disk_monitor_preferences_new RENAME TO disk_monitor_preferences")
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_disk_monitor_preferences_owner ON disk_monitor_preferences(user_id)")
|
||||
return True
|
||||
|
||||
|
||||
def migrate_profile_preferences_sidebar_columns(conn: sqlite3.Connection) -> bool:
|
||||
columns = _column_names(conn, "profile_preferences")
|
||||
changed = False
|
||||
if "sidebar_labels_expanded" not in columns:
|
||||
conn.execute("ALTER TABLE profile_preferences ADD COLUMN sidebar_labels_expanded INTEGER DEFAULT 0")
|
||||
changed = True
|
||||
if "sidebar_shortcuts_expanded" not in columns:
|
||||
conn.execute("ALTER TABLE profile_preferences ADD COLUMN sidebar_shortcuts_expanded INTEGER DEFAULT 0")
|
||||
changed = True
|
||||
return changed
|
||||
|
||||
|
||||
def migrate_operation_log_split_retention(conn: sqlite3.Connection) -> bool:
|
||||
columns = _column_names(conn, "operation_log_settings")
|
||||
changed = False
|
||||
additions = {
|
||||
"retention_interval_hours": "INTEGER DEFAULT 24",
|
||||
"job_retention_mode": "TEXT DEFAULT 'days'",
|
||||
"job_retention_days": "INTEGER DEFAULT 7",
|
||||
"job_retention_lines": "INTEGER DEFAULT 2000",
|
||||
"job_retention_interval_hours": "INTEGER DEFAULT 24",
|
||||
"job_last_retention_run_at": "TEXT",
|
||||
"job_last_retention_deleted": "INTEGER DEFAULT 0",
|
||||
"operation_retention_mode": "TEXT DEFAULT 'days'",
|
||||
"operation_retention_days": "INTEGER DEFAULT 30",
|
||||
"operation_retention_lines": "INTEGER DEFAULT 5000",
|
||||
"operation_retention_interval_hours": "INTEGER DEFAULT 24",
|
||||
"operation_last_retention_run_at": "TEXT",
|
||||
"operation_last_retention_deleted": "INTEGER DEFAULT 0",
|
||||
}
|
||||
for name, ddl in additions.items():
|
||||
if name not in columns:
|
||||
conn.execute(f"ALTER TABLE operation_log_settings ADD COLUMN {name} {ddl}")
|
||||
changed = True
|
||||
if changed:
|
||||
conn.execute("""
|
||||
UPDATE operation_log_settings
|
||||
SET operation_retention_mode=COALESCE(operation_retention_mode, retention_mode, 'days'),
|
||||
operation_retention_days=COALESCE(operation_retention_days, retention_days, 30),
|
||||
operation_retention_lines=COALESCE(operation_retention_lines, retention_lines, 5000),
|
||||
operation_retention_interval_hours=COALESCE(operation_retention_interval_hours, retention_interval_hours, 24),
|
||||
job_retention_mode=COALESCE(job_retention_mode, 'days'),
|
||||
job_retention_days=COALESCE(job_retention_days, 7),
|
||||
job_retention_lines=COALESCE(job_retention_lines, 2000),
|
||||
job_retention_interval_hours=COALESCE(job_retention_interval_hours, retention_interval_hours, 24),
|
||||
updated_at=COALESCE(updated_at, ?)
|
||||
""", (_utcnow(),))
|
||||
return changed
|
||||
|
||||
|
||||
def migrate_profile_speed_limits_table(conn: sqlite3.Connection) -> bool:
|
||||
existing = conn.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='profile_speed_limits'").fetchone()
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS profile_speed_limits (
|
||||
profile_id INTEGER PRIMARY KEY,
|
||||
down_limit INTEGER DEFAULT 0,
|
||||
up_limit INTEGER DEFAULT 0,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
FOREIGN KEY(profile_id) REFERENCES rtorrent_profiles(id) ON DELETE CASCADE
|
||||
)
|
||||
""")
|
||||
return existing is None
|
||||
|
||||
|
||||
MIGRATIONS: tuple[Migration, ...] = (
|
||||
migrate_disk_monitor_preferences_to_profile_scope,
|
||||
migrate_profile_preferences_sidebar_columns,
|
||||
migrate_operation_log_split_retention,
|
||||
migrate_profile_speed_limits_table,
|
||||
)
|
||||
|
||||
|
||||
def run_database_migrations(conn: sqlite3.Connection) -> int:
|
||||
"""Run idempotent database migrations and return how many changed the schema/data."""
|
||||
applied = 0
|
||||
for migration in MIGRATIONS:
|
||||
if migration(conn):
|
||||
applied += 1
|
||||
return applied
|
||||
+2698
-96
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,20 @@
|
||||
from __future__ import annotations
|
||||
from importlib import import_module
|
||||
|
||||
API_ROUTE_MODULES = (
|
||||
"torrents",
|
||||
"profiles",
|
||||
"rss",
|
||||
"automations",
|
||||
"smart_queue",
|
||||
"system",
|
||||
"backup",
|
||||
"operation_logs",
|
||||
"planner",
|
||||
)
|
||||
|
||||
|
||||
def load_api_route_modules() -> None:
|
||||
"""Import API route modules so their shared blueprint decorators are registered."""
|
||||
for module_name in API_ROUTE_MODULES:
|
||||
import_module(f"{__name__}.{module_name}")
|
||||
+127
-225
@@ -1,5 +1,4 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import os
|
||||
import platform
|
||||
@@ -18,11 +17,11 @@ import queue
|
||||
import threading
|
||||
from pathlib import Path
|
||||
from urllib.parse import quote
|
||||
from flask import Blueprint, jsonify, request, abort, send_file, redirect, Response, stream_with_context
|
||||
from flask import Blueprint, jsonify, request, abort, send_file, redirect, Response, stream_with_context, url_for
|
||||
from ..config import DB_PATH, JOBS_RETENTION_DAYS, SMART_QUEUE_HISTORY_RETENTION_DAYS, LOG_RETENTION_DAYS, WORKERS, PYTORRENT_TMP_DIR
|
||||
from ..db import connect, utcnow
|
||||
from ..services.auth import current_user_id as default_user_id, current_user, list_users, save_user, delete_user, login_user, logout_user, enabled as auth_enabled, require_profile_write
|
||||
from ..services import preferences, rtorrent, torrent_stats, speed_peaks, tracker_cache, rss as rss_service, ratio_rules, backup as backup_service, download_planner, operation_logs
|
||||
from ..services.auth import current_user_id as default_user_id, current_user, list_users, save_user, delete_user, login_user, logout_user, enabled as auth_enabled, require_profile_write, require_admin, is_admin
|
||||
from ..services import auth, preferences, rtorrent, torrent_stats, speed_peaks, tracker_cache, rss as rss_service, ratio_rules, backup as backup_service, download_planner, operation_logs, poller_control, database_maintenance
|
||||
from ..services.torrent_cache import torrent_cache
|
||||
from ..services.torrent_summary import cached_summary
|
||||
from ..services.workers import enqueue, list_jobs, cancel_job, retry_job, force_job, clear_jobs, emergency_clear_jobs
|
||||
@@ -33,11 +32,93 @@ bp = Blueprint("api", __name__, url_prefix="/api")
|
||||
|
||||
MOVE_BULK_MAX_HASHES = 100
|
||||
|
||||
|
||||
from .auth_api import register_auth_routes
|
||||
register_auth_routes(bp)
|
||||
|
||||
|
||||
def _request_profile_selector() -> tuple[int | None, str]:
|
||||
"""Return the optional rTorrent profile selector supplied by external API clients."""
|
||||
payload = {}
|
||||
if request.method in {"POST", "PUT", "PATCH", "DELETE"}:
|
||||
try:
|
||||
payload = request.get_json(silent=True) or {}
|
||||
except Exception:
|
||||
payload = {}
|
||||
|
||||
profile_id = (
|
||||
request.args.get("profile_id")
|
||||
or request.form.get("profile_id")
|
||||
or payload.get("rtorrent_profile_id")
|
||||
or request.headers.get("X-PyTorrent-Profile-Id")
|
||||
)
|
||||
profile_name = (
|
||||
request.args.get("profile_name")
|
||||
or request.form.get("profile_name")
|
||||
or payload.get("rtorrent_profile_name")
|
||||
or request.headers.get("X-PyTorrent-Profile-Name")
|
||||
or ""
|
||||
)
|
||||
|
||||
try:
|
||||
return (int(profile_id), "") if profile_id not in (None, "") else (None, str(profile_name or "").strip())
|
||||
except (TypeError, ValueError):
|
||||
raise ValueError("profile_id must be an integer")
|
||||
|
||||
|
||||
def _profile_by_name(profile_name: str, user_id: int | None = None):
|
||||
name = str(profile_name or "").strip()
|
||||
if not name:
|
||||
return None
|
||||
user_id = user_id or default_user_id()
|
||||
visible = auth.visible_profile_ids(user_id)
|
||||
with connect() as conn:
|
||||
if visible is None:
|
||||
return conn.execute(
|
||||
"SELECT * FROM rtorrent_profiles WHERE lower(name)=lower(?) ORDER BY is_default DESC, id LIMIT 1",
|
||||
(name,),
|
||||
).fetchone()
|
||||
if not visible:
|
||||
return None
|
||||
placeholders = ",".join("?" for _ in visible)
|
||||
return conn.execute(
|
||||
f"SELECT * FROM rtorrent_profiles WHERE id IN ({placeholders}) AND lower(name)=lower(?) ORDER BY is_default DESC, id LIMIT 1",
|
||||
(*tuple(visible), name),
|
||||
).fetchone()
|
||||
|
||||
|
||||
def request_profile(require_write: bool = False):
|
||||
"""Resolve API profile context from profile_id/profile_name, then active profile for compatibility."""
|
||||
try:
|
||||
profile_id, profile_name = _request_profile_selector()
|
||||
except ValueError:
|
||||
raise
|
||||
user_id = default_user_id()
|
||||
profile = None
|
||||
if profile_id:
|
||||
profile = preferences.get_profile(int(profile_id), user_id)
|
||||
elif profile_name:
|
||||
profile = _profile_by_name(profile_name, user_id)
|
||||
else:
|
||||
profile = preferences.active_profile(user_id)
|
||||
if not profile and auth.can_access_profile(1, user_id):
|
||||
profile = preferences.get_profile(1, user_id)
|
||||
if not profile and (profile_id or profile_name):
|
||||
abort(404)
|
||||
if not profile:
|
||||
return None
|
||||
pid = int(profile["id"])
|
||||
if require_write and not auth.can_write_profile(pid, user_id):
|
||||
abort(403)
|
||||
if not require_write and not auth.can_access_profile(pid, user_id):
|
||||
abort(403)
|
||||
return profile
|
||||
|
||||
|
||||
def request_profile_id(require_write: bool = False) -> int | None:
|
||||
profile = request_profile(require_write=require_write)
|
||||
return int(profile["id"]) if profile else None
|
||||
|
||||
|
||||
def _job_profile_id(job_id: str) -> int | None:
|
||||
with connect() as conn:
|
||||
row = conn.execute("SELECT profile_id FROM jobs WHERE id=?", (job_id,)).fetchone()
|
||||
@@ -50,196 +131,7 @@ def ok(payload=None):
|
||||
return jsonify(data)
|
||||
|
||||
|
||||
|
||||
PORT_CHECK_CACHE_SECONDS = 6 * 60 * 60
|
||||
|
||||
|
||||
def _app_setting_get(key: str):
|
||||
with connect() as conn:
|
||||
row = conn.execute("SELECT value FROM app_settings WHERE key=?", (key,)).fetchone()
|
||||
return row.get("value") if row else None
|
||||
|
||||
|
||||
def _app_setting_set(key: str, value: str):
|
||||
with connect() as conn:
|
||||
conn.execute("INSERT OR REPLACE INTO app_settings(key,value) VALUES(?,?)", (key, value))
|
||||
|
||||
|
||||
def _iso_from_epoch(value) -> str | None:
|
||||
try:
|
||||
return datetime.fromtimestamp(float(value), timezone.utc).isoformat(timespec="seconds")
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _public_ip(profile: dict | None = None, force: bool = False) -> str:
|
||||
if profile and bool(profile.get("is_remote")):
|
||||
return rtorrent.remote_public_ip(profile, force=force)
|
||||
req = urllib.request.Request("https://api.ipify.org", headers={"User-Agent": "pyTorrent/port-check"})
|
||||
with urllib.request.urlopen(req, timeout=8) as res:
|
||||
return res.read(64).decode("utf-8", "replace").strip()
|
||||
|
||||
|
||||
MAX_PORT_CHECK_CANDIDATES = 256
|
||||
|
||||
|
||||
def _parse_port_candidates(value: str, limit: int = MAX_PORT_CHECK_CANDIDATES) -> tuple[list[int], bool]:
|
||||
"""Return valid incoming port candidates from rTorrent network.port_range.
|
||||
|
||||
Note: rTorrent may keep a range/list and pick a random port on start.
|
||||
The old checker used only the first number, which produced false "closed"
|
||||
results when another configured port was actually active.
|
||||
"""
|
||||
ports: list[int] = []
|
||||
seen: set[int] = set()
|
||||
truncated = False
|
||||
|
||||
def add(port: int) -> None:
|
||||
nonlocal truncated
|
||||
if not 1 <= port <= 65535 or port in seen:
|
||||
return
|
||||
if len(ports) >= limit:
|
||||
truncated = True
|
||||
return
|
||||
seen.add(port)
|
||||
ports.append(port)
|
||||
|
||||
for start, end in re.findall(r"(\d{1,5})\s*-\s*(\d{1,5})", value or ""):
|
||||
a, b = int(start), int(end)
|
||||
if a > b:
|
||||
a, b = b, a
|
||||
for port in range(a, b + 1):
|
||||
add(port)
|
||||
if truncated:
|
||||
break
|
||||
|
||||
without_ranges = re.sub(r"\d{1,5}\s*-\s*\d{1,5}", " ", value or "")
|
||||
for item in re.findall(r"\d{1,5}", without_ranges):
|
||||
add(int(item))
|
||||
|
||||
return ports, truncated
|
||||
|
||||
|
||||
def _incoming_ports(profile: dict) -> dict:
|
||||
try:
|
||||
raw_value = str(rtorrent.client_for(profile).call("network.port_range") or "")
|
||||
except Exception:
|
||||
raw_value = ""
|
||||
ports, truncated = _parse_port_candidates(raw_value)
|
||||
return {"ports": ports, "raw": raw_value, "truncated": truncated}
|
||||
|
||||
|
||||
def _yougetsignal_check(public_ip: str, port: int) -> dict:
|
||||
body = urllib.parse.urlencode({"remoteAddress": public_ip, "portNumber": str(port)}).encode("utf-8")
|
||||
req = urllib.request.Request(
|
||||
"https://ports.yougetsignal.com/check-port.php",
|
||||
data=body,
|
||||
headers={
|
||||
"Content-Type": "application/x-www-form-urlencoded; charset=UTF-8",
|
||||
"User-Agent": "pyTorrent/port-check",
|
||||
"Accept": "text/html,application/json,*/*",
|
||||
},
|
||||
method="POST",
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=12) as res:
|
||||
text = res.read(8192).decode("utf-8", "replace")
|
||||
low = text.lower()
|
||||
if "is open" in low:
|
||||
return {"status": "open", "source": "yougetsignal", "raw": text[:500]}
|
||||
if "is closed" in low:
|
||||
return {"status": "closed", "source": "yougetsignal", "raw": text[:500]}
|
||||
return {"status": "unknown", "source": "yougetsignal", "raw": text[:500]}
|
||||
|
||||
|
||||
def _local_port_fallback(public_ip: str, port: int) -> dict:
|
||||
try:
|
||||
with socket.create_connection((public_ip, port), timeout=3):
|
||||
return {"status": "open", "source": "local-fallback"}
|
||||
except Exception as exc:
|
||||
return {"status": "unknown", "source": "local-fallback", "error": f"Local fallback inconclusive: {exc}"}
|
||||
|
||||
|
||||
def _check_ports(public_ip: str, ports: list[int], checker) -> dict:
|
||||
checked: list[int] = []
|
||||
first_closed: dict | None = None
|
||||
last_result: dict = {"status": "unknown"}
|
||||
|
||||
for port in ports:
|
||||
checked.append(port)
|
||||
current = checker(public_ip, port)
|
||||
last_result = current
|
||||
if current.get("status") == "open":
|
||||
current.update({"port": port, "open_port": port, "checked_ports": checked})
|
||||
return current
|
||||
if current.get("status") == "closed" and first_closed is None:
|
||||
first_closed = current
|
||||
|
||||
result = first_closed or last_result
|
||||
result.update({"port": ports[0] if ports else None, "open_port": None, "checked_ports": checked})
|
||||
return result
|
||||
|
||||
|
||||
def port_check_status(force: bool = False) -> dict:
|
||||
profile = preferences.active_profile()
|
||||
prefs = preferences.get_preferences()
|
||||
enabled = bool((prefs or {}).get("port_check_enabled"))
|
||||
if not profile:
|
||||
return {"status": "unknown", "enabled": enabled, "error": "No profile"}
|
||||
|
||||
port_info = _incoming_ports(profile)
|
||||
ports = port_info["ports"]
|
||||
if not ports:
|
||||
return {"status": "unknown", "enabled": enabled, "error": "Cannot read rTorrent network.port_range"}
|
||||
|
||||
ports_key = ",".join(str(port) for port in ports)
|
||||
cache_key = f"port_check:{profile['id']}:{ports_key}:{int(bool(port_info['truncated']))}"
|
||||
if not force:
|
||||
cached = _app_setting_get(cache_key)
|
||||
if cached:
|
||||
try:
|
||||
data = json.loads(cached)
|
||||
if time.time() - float(data.get("checked_at_epoch") or 0) < PORT_CHECK_CACHE_SECONDS:
|
||||
data["cached"] = True
|
||||
data["enabled"] = enabled
|
||||
if not data.get("checked_at"):
|
||||
data["checked_at"] = _iso_from_epoch(data.get("checked_at_epoch"))
|
||||
return data
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
checked_at_epoch = time.time()
|
||||
result = {
|
||||
"status": "unknown",
|
||||
"enabled": enabled,
|
||||
"port": ports[0],
|
||||
"ports": ports,
|
||||
"port_range": port_info["raw"],
|
||||
"ports_truncated": port_info["truncated"],
|
||||
"checked_at_epoch": checked_at_epoch,
|
||||
"checked_at": _iso_from_epoch(checked_at_epoch),
|
||||
"cached": False,
|
||||
}
|
||||
try:
|
||||
public_ip = _public_ip(profile, force=force)
|
||||
result["public_ip"] = public_ip
|
||||
result["remote"] = bool(profile.get("is_remote"))
|
||||
result.update(_check_ports(public_ip, ports, _yougetsignal_check))
|
||||
except Exception as exc:
|
||||
result["error"] = f"YouGetSignal failed: {exc}"
|
||||
try:
|
||||
public_ip = result.get("public_ip") or _public_ip(profile, force=force)
|
||||
result["public_ip"] = public_ip
|
||||
result["remote"] = bool(profile.get("is_remote"))
|
||||
result.update(_check_ports(public_ip, ports, _local_port_fallback))
|
||||
except Exception as fallback_exc:
|
||||
result["fallback_error"] = str(fallback_exc)
|
||||
result["source"] = "none"
|
||||
_app_setting_set(cache_key, json.dumps(result))
|
||||
return result
|
||||
|
||||
|
||||
|
||||
|
||||
from ..services.port_check import port_check_status
|
||||
|
||||
|
||||
def _safe_len(callable_obj) -> int | None:
|
||||
@@ -248,30 +140,37 @@ def _safe_len(callable_obj) -> int | None:
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def _table_count(table: str, where: str = "", params: tuple = ()) -> int:
|
||||
with connect() as conn:
|
||||
exists = conn.execute("SELECT name FROM sqlite_master WHERE type='table' AND name=?", (table,)).fetchone()
|
||||
if not exists:
|
||||
return 0
|
||||
row = conn.execute(f"SELECT COUNT(*) AS n FROM {table} {where}", params).fetchone()
|
||||
return int((row or {}).get("n") or 0)
|
||||
def _table_count(table: str, where: str = "", params: tuple = (), conn=None) -> int:
|
||||
"""Count rows with one SQL statement; schema-created tables do not need a sqlite_master pre-check."""
|
||||
try:
|
||||
if conn is None:
|
||||
with connect() as owned_conn:
|
||||
row = owned_conn.execute(f"SELECT COUNT(*) AS n FROM {table} {where}", params).fetchone()
|
||||
else:
|
||||
row = conn.execute(f"SELECT COUNT(*) AS n FROM {table} {where}", params).fetchone()
|
||||
return int((row or {}).get("n") or 0)
|
||||
except Exception:
|
||||
return 0
|
||||
|
||||
|
||||
def _db_size() -> dict:
|
||||
try:
|
||||
size = DB_PATH.stat().st_size if DB_PATH.exists() else 0
|
||||
return {"path": str(DB_PATH), "size": size, "size_h": rtorrent.human_size(size)}
|
||||
return database_maintenance.database_status()
|
||||
except Exception as exc:
|
||||
return {"path": str(DB_PATH), "size": 0, "size_h": "0 B", "error": str(exc)}
|
||||
try:
|
||||
size = DB_PATH.stat().st_size if DB_PATH.exists() else 0
|
||||
except Exception:
|
||||
size = 0
|
||||
return {"path": str(DB_PATH), "size": size, "size_h": rtorrent.human_size(size), "error": str(exc)}
|
||||
|
||||
|
||||
def _active_profile_cache_summary(profile_id: int | None = None) -> dict:
|
||||
def _active_profile_cache_summary(profile_id: int | None = None, conn=None) -> dict:
|
||||
profile = preferences.active_profile() if profile_id is None else {"id": profile_id}
|
||||
profile_id = int((profile or {}).get("id") or 0)
|
||||
if not profile_id:
|
||||
return {"profile_id": 0, "profile_rows": 0, "runtime_items": 0}
|
||||
tracker_rows = _table_count("tracker_summary_cache", "WHERE profile_id=?", (profile_id,))
|
||||
stats_rows = _table_count("torrent_stats_cache", "WHERE profile_id=?", (profile_id,))
|
||||
tracker_rows = _table_count("tracker_summary_cache", "WHERE profile_id=?", (profile_id,), conn=conn)
|
||||
stats_rows = _table_count("torrent_stats_cache", "WHERE profile_id=?", (profile_id,), conn=conn)
|
||||
runtime_items = 0
|
||||
try:
|
||||
runtime_items += len(torrent_cache.snapshot(profile_id))
|
||||
@@ -283,20 +182,29 @@ def _active_profile_cache_summary(profile_id: int | None = None) -> dict:
|
||||
def cleanup_summary() -> dict:
|
||||
active_profile = preferences.active_profile()
|
||||
profile_id = int((active_profile or {}).get("id") or 0)
|
||||
operation_logs_total = _table_count(
|
||||
"operation_logs",
|
||||
"WHERE profile_id=? OR profile_id IS NULL",
|
||||
(profile_id,),
|
||||
) if profile_id else _table_count("operation_logs")
|
||||
with connect() as conn:
|
||||
operation_logs_total = _table_count(
|
||||
"operation_logs",
|
||||
"WHERE profile_id=? OR profile_id IS NULL",
|
||||
(profile_id,),
|
||||
conn=conn,
|
||||
) if profile_id else _table_count("operation_logs", conn=conn)
|
||||
jobs_total = _table_count("jobs", conn=conn)
|
||||
jobs_clearable = _table_count("jobs", "WHERE status NOT IN ('pending', 'running')", conn=conn)
|
||||
smart_queue_history_total = _table_count("smart_queue_history", conn=conn)
|
||||
automation_history_total = _table_count("automation_history", conn=conn)
|
||||
cache_summary = _active_profile_cache_summary(profile_id if profile_id else None, conn=conn)
|
||||
operation_log_retention = operation_logs.get_settings(profile_id) if profile_id else operation_logs.get_settings(0)
|
||||
poller_runtime = poller_control.snapshot(profile_id) if profile_id else {}
|
||||
return {
|
||||
"jobs_total": _table_count("jobs"),
|
||||
"jobs_clearable": _table_count("jobs", "WHERE status NOT IN ('pending', 'running')"),
|
||||
"smart_queue_history_total": _table_count("smart_queue_history"),
|
||||
"jobs_total": jobs_total,
|
||||
"jobs_clearable": jobs_clearable,
|
||||
"smart_queue_history_total": smart_queue_history_total,
|
||||
"operation_logs_total": operation_logs_total,
|
||||
"automation_history_total": _table_count("automation_history"),
|
||||
"automation_history_total": automation_history_total,
|
||||
"planner_history_total": download_planner.history_count(profile_id) if profile_id else 0,
|
||||
"cache": _active_profile_cache_summary(profile_id if profile_id else None),
|
||||
"cache": cache_summary,
|
||||
"poller_runtime": poller_runtime,
|
||||
"retention_days": {
|
||||
"jobs": JOBS_RETENTION_DAYS,
|
||||
"smart_queue_history": SMART_QUEUE_HISTORY_RETENTION_DAYS,
|
||||
@@ -309,6 +217,7 @@ def cleanup_summary() -> dict:
|
||||
"operation_logs": operation_logs.retention_label(operation_log_retention),
|
||||
},
|
||||
"database": _db_size(),
|
||||
"admin": is_admin(current_user()),
|
||||
}
|
||||
|
||||
def active_default_download_path(profile: dict | None) -> str:
|
||||
@@ -356,13 +265,11 @@ def enrich_bulk_payload(profile: dict, action_name: str, data: dict) -> dict:
|
||||
|
||||
|
||||
def _chunk_hashes(hashes: list[str], size: int = MOVE_BULK_MAX_HASHES) -> list[list[str]]:
|
||||
# Note: Splits very large torrent selections into predictable chunks so each queued job stays small and recoverable.
|
||||
safe_size = max(1, int(size or MOVE_BULK_MAX_HASHES))
|
||||
return [hashes[index:index + safe_size] for index in range(0, len(hashes), safe_size)]
|
||||
|
||||
|
||||
def enqueue_bulk_parts(profile: dict, action_name: str, data: dict) -> list[dict]:
|
||||
# Note: One shared helper splits large move/remove operations into small ordered parts without changing other actions.
|
||||
base_payload = enrich_bulk_payload(profile, action_name, data)
|
||||
hashes = base_payload.get("hashes") or []
|
||||
chunks = _chunk_hashes(hashes)
|
||||
@@ -392,17 +299,14 @@ def enqueue_bulk_parts(profile: dict, action_name: str, data: dict) -> list[dict
|
||||
|
||||
|
||||
def enqueue_move_bulk_parts(profile: dict, data: dict) -> list[dict]:
|
||||
# Note: Keep the old public move helper while using the same partitioning logic.
|
||||
return enqueue_bulk_parts(profile, "move", data)
|
||||
|
||||
|
||||
def enqueue_remove_bulk_parts(profile: dict, data: dict) -> list[dict]:
|
||||
# Note: Remove/rm uses the same partitioning as move, which lowers rTorrent load.
|
||||
return enqueue_bulk_parts(profile, "remove", data)
|
||||
|
||||
|
||||
def _user_disk_status(profile: dict) -> dict:
|
||||
# Note: Disk usage is user-preference aware, so it is read separately from the shared Socket.IO poller.
|
||||
prefs = preferences.get_disk_monitor_preferences(profile.get("id") if profile else None)
|
||||
try:
|
||||
paths = json.loads((prefs or {}).get("disk_monitor_paths_json") or "[]") if prefs else []
|
||||
@@ -416,6 +320,4 @@ def _user_disk_status(profile: dict) -> dict:
|
||||
)
|
||||
|
||||
|
||||
|
||||
# Note: Route modules import shared helpers with wildcard imports; include private helper names intentionally.
|
||||
__all__ = [name for name in globals() if not name.startswith('__')]
|
||||
|
||||
@@ -1,15 +1,8 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from ._shared import bp
|
||||
from . import load_api_route_modules
|
||||
|
||||
# Note: Route modules are imported for their decorators; this keeps the public API unchanged.
|
||||
from . import torrents as _torrents_routes
|
||||
from . import profiles as _profiles_routes
|
||||
from . import rss as _rss_routes
|
||||
from . import automations as _automations_routes
|
||||
from . import smart_queue as _smart_queue_routes
|
||||
from . import system as _system_routes
|
||||
from . import backup as _backup_routes
|
||||
from . import operation_logs as _operation_logs_routes
|
||||
load_api_route_modules()
|
||||
|
||||
__all__ = ["bp"]
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from flask import abort, jsonify, request
|
||||
|
||||
from ..services.auth import current_user, list_users, save_user, delete_user, login_user, logout_user, enabled as auth_enabled, list_api_tokens, create_api_token, revoke_api_token
|
||||
from ..services.auth import current_user, list_users, save_user, delete_user, login_user, logout_user, enabled as auth_enabled, provider as auth_provider, uses_external_provider, external_auth_summary, list_api_tokens, create_api_token, revoke_api_token
|
||||
|
||||
|
||||
def _ok(payload=None):
|
||||
@@ -21,18 +19,20 @@ def register_auth_routes(bp):
|
||||
user = login_user(str(data.get("username") or ""), str(data.get("password") or ""))
|
||||
if not user:
|
||||
return jsonify({"ok": False, "error": "Invalid username or password"}), 401
|
||||
return _ok({"user": user, "auth_enabled": auth_enabled()})
|
||||
return _ok({"user": user, "auth_enabled": auth_enabled(), "auth_provider": auth_provider()})
|
||||
|
||||
@bp.get("/auth/me")
|
||||
def auth_me():
|
||||
if not auth_enabled():
|
||||
abort(404)
|
||||
return _ok({"user": current_user(), "auth_enabled": auth_enabled()})
|
||||
return _ok({"user": current_user(), "auth_enabled": auth_enabled(), "auth_provider": auth_provider()})
|
||||
|
||||
@bp.post("/auth/logout")
|
||||
def auth_logout():
|
||||
if not auth_enabled():
|
||||
abort(404)
|
||||
if uses_external_provider():
|
||||
return _ok({"logout_managed_by_provider": True, "auth_provider": auth_provider()})
|
||||
logout_user()
|
||||
return _ok()
|
||||
|
||||
@@ -40,7 +40,7 @@ def register_auth_routes(bp):
|
||||
def auth_users_list():
|
||||
if not auth_enabled():
|
||||
abort(404)
|
||||
return _ok({"users": list_users()})
|
||||
return _ok({"users": list_users(), "auth": external_auth_summary()})
|
||||
|
||||
@bp.post("/auth/users")
|
||||
def auth_users_create():
|
||||
|
||||
@@ -1,89 +1,97 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from ._shared import *
|
||||
|
||||
|
||||
def _automation_user_id() -> int:
|
||||
return int(default_user_id() or 0)
|
||||
|
||||
|
||||
@bp.get('/automations')
|
||||
def automations_get():
|
||||
from ..services import automation_rules
|
||||
profile = preferences.active_profile()
|
||||
profile = request_profile()
|
||||
if not profile:
|
||||
return ok({'rules': [], 'history': [], 'error': 'No profile'})
|
||||
try:
|
||||
return ok({'rules': automation_rules.list_rules(profile['id']), 'history': automation_rules.list_history(profile['id'])})
|
||||
user_id = _automation_user_id()
|
||||
return ok({
|
||||
'rules': automation_rules.list_rules(profile['id'], user_id=user_id),
|
||||
'history': automation_rules.list_history(profile['id'], user_id=user_id),
|
||||
})
|
||||
except Exception as exc:
|
||||
return jsonify({'ok': False, 'error': str(exc), 'rules': [], 'history': []}), 500
|
||||
|
||||
|
||||
|
||||
@bp.get('/automations/export')
|
||||
def automations_export():
|
||||
from ..services import automation_rules
|
||||
profile = preferences.active_profile()
|
||||
profile = request_profile()
|
||||
if not profile:
|
||||
return jsonify({'ok': False, 'error': 'No profile'}), 400
|
||||
try:
|
||||
# Note: JSON export is profile-scoped and excludes execution history/cooldown state.
|
||||
data = automation_rules.export_rules(profile['id'])
|
||||
data = automation_rules.export_rules(profile['id'], user_id=_automation_user_id())
|
||||
return ok({'export': data, 'count': len(data.get('rules') or [])})
|
||||
except Exception as exc:
|
||||
return jsonify({'ok': False, 'error': str(exc)}), 400
|
||||
|
||||
|
||||
|
||||
@bp.post('/automations/import')
|
||||
def automations_import():
|
||||
from ..services import automation_rules
|
||||
profile = preferences.active_profile()
|
||||
profile = request_profile()
|
||||
if not profile:
|
||||
return jsonify({'ok': False, 'error': 'No profile'}), 400
|
||||
try:
|
||||
payload = request.get_json(silent=True) or {}
|
||||
replace = str(request.args.get('replace') or '').lower() in {'1', 'true', 'yes'} or bool(payload.get('replace')) if isinstance(payload, dict) else False
|
||||
# Note: Import appends rules by default, so existing automations remain untouched.
|
||||
imported = automation_rules.import_rules(profile['id'], payload, replace=replace)
|
||||
return ok({'imported': len(imported), 'rules': automation_rules.list_rules(profile['id'])})
|
||||
user_id = _automation_user_id()
|
||||
imported = automation_rules.import_rules(profile['id'], payload, user_id=user_id, replace=replace)
|
||||
return ok({'imported': len(imported), 'rules': automation_rules.list_rules(profile['id'], user_id=user_id)})
|
||||
except Exception as exc:
|
||||
return jsonify({'ok': False, 'error': str(exc)}), 400
|
||||
|
||||
|
||||
|
||||
@bp.post('/automations')
|
||||
def automations_save():
|
||||
from ..services import automation_rules
|
||||
profile = preferences.active_profile()
|
||||
profile = request_profile()
|
||||
if not profile:
|
||||
return jsonify({'ok': False, 'error': 'No profile'}), 400
|
||||
try:
|
||||
rule = automation_rules.save_rule(profile['id'], request.get_json(silent=True) or {})
|
||||
return ok({'rule': rule, 'rules': automation_rules.list_rules(profile['id'])})
|
||||
user_id = _automation_user_id()
|
||||
rule = automation_rules.save_rule(profile['id'], request.get_json(silent=True) or {}, user_id=user_id)
|
||||
return ok({'rule': rule, 'rules': automation_rules.list_rules(profile['id'], user_id=user_id)})
|
||||
except Exception as exc:
|
||||
return jsonify({'ok': False, 'error': str(exc)}), 400
|
||||
|
||||
|
||||
|
||||
@bp.delete('/automations/<int:rule_id>')
|
||||
def automations_delete(rule_id: int):
|
||||
from ..services import automation_rules
|
||||
profile = preferences.active_profile()
|
||||
profile = request_profile()
|
||||
if not profile:
|
||||
return jsonify({'ok': False, 'error': 'No profile'}), 400
|
||||
try:
|
||||
automation_rules.delete_rule(rule_id, profile['id'])
|
||||
return ok({'rules': automation_rules.list_rules(profile['id'])})
|
||||
user_id = _automation_user_id()
|
||||
automation_rules.delete_rule(rule_id, profile['id'], user_id=user_id)
|
||||
return ok({'rules': automation_rules.list_rules(profile['id'], user_id=user_id)})
|
||||
except Exception as exc:
|
||||
return jsonify({'ok': False, 'error': str(exc)}), 400
|
||||
|
||||
|
||||
|
||||
@bp.post('/automations/<int:rule_id>/run')
|
||||
def automations_run_rule(rule_id: int):
|
||||
from ..services import automation_rules
|
||||
profile = preferences.active_profile()
|
||||
profile = request_profile()
|
||||
if not profile:
|
||||
return jsonify({'ok': False, 'error': 'No profile'}), 400
|
||||
try:
|
||||
# Note: Single-rule run ignores disabled state and cooldown for manual troubleshooting.
|
||||
return ok({'result': automation_rules.check(profile, force=True, rule_id=rule_id), 'rules': automation_rules.list_rules(profile['id']), 'history': automation_rules.list_history(profile['id'])})
|
||||
user_id = _automation_user_id()
|
||||
return ok({
|
||||
'result': automation_rules.check(profile, user_id=user_id, force=True, rule_id=rule_id),
|
||||
'rules': automation_rules.list_rules(profile['id'], user_id=user_id),
|
||||
'history': automation_rules.list_history(profile['id'], user_id=user_id),
|
||||
})
|
||||
except Exception as exc:
|
||||
return jsonify({'ok': False, 'error': str(exc)}), 500
|
||||
|
||||
@@ -91,26 +99,29 @@ def automations_run_rule(rule_id: int):
|
||||
@bp.post('/automations/check')
|
||||
def automations_check():
|
||||
from ..services import automation_rules
|
||||
profile = preferences.active_profile()
|
||||
profile = request_profile()
|
||||
if not profile:
|
||||
return jsonify({'ok': False, 'error': 'No profile'}), 400
|
||||
try:
|
||||
# Note: Force check ignores disabled state and cooldown, allowing a one-off manual automation pass.
|
||||
return ok({'result': automation_rules.check(profile, force=True), 'rules': automation_rules.list_rules(profile['id']), 'history': automation_rules.list_history(profile['id'])})
|
||||
user_id = _automation_user_id()
|
||||
return ok({
|
||||
'result': automation_rules.check(profile, user_id=user_id, force=True),
|
||||
'rules': automation_rules.list_rules(profile['id'], user_id=user_id),
|
||||
'history': automation_rules.list_history(profile['id'], user_id=user_id),
|
||||
})
|
||||
except Exception as exc:
|
||||
return jsonify({'ok': False, 'error': str(exc)}), 500
|
||||
|
||||
|
||||
|
||||
@bp.delete('/automations/history')
|
||||
def automations_history_clear():
|
||||
from ..services import automation_rules
|
||||
profile = preferences.active_profile()
|
||||
profile = request_profile()
|
||||
if not profile:
|
||||
return jsonify({'ok': False, 'error': 'No profile'}), 400
|
||||
try:
|
||||
# Note: Clear only automation execution logs; rules and cooldown state stay unchanged.
|
||||
deleted = automation_rules.clear_history(profile['id'])
|
||||
return ok({'deleted': deleted, 'history': automation_rules.list_history(profile['id']), 'cleanup': cleanup_summary()})
|
||||
user_id = _automation_user_id()
|
||||
deleted = automation_rules.clear_history(profile['id'], user_id=user_id)
|
||||
return ok({'deleted': deleted, 'history': automation_rules.list_history(profile['id'], user_id=user_id), 'cleanup': cleanup_summary()})
|
||||
except Exception as exc:
|
||||
return jsonify({'ok': False, 'error': str(exc)}), 500
|
||||
|
||||
+74
-15
@@ -1,31 +1,94 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from ._shared import *
|
||||
from ..services import auth
|
||||
|
||||
|
||||
def _active_profile_id(require_write: bool = False) -> int | None:
|
||||
profile = request_profile(require_write=require_write)
|
||||
return int(profile["id"]) if profile else None
|
||||
|
||||
|
||||
@bp.get("/backup")
|
||||
def backup_list():
|
||||
return ok({"backups": backup_service.list_backups(default_user_id()), "auto": backup_service.get_auto_backup_settings(default_user_id())})
|
||||
uid = default_user_id()
|
||||
pid = _active_profile_id()
|
||||
can_app = auth.is_admin()
|
||||
return ok({
|
||||
"profile_backups": backup_service.list_backups(uid, "profile", pid) if pid else [],
|
||||
"app_backups": backup_service.list_backups(uid, "app") if can_app else [],
|
||||
"profile_auto": backup_service.get_auto_backup_settings(uid, "profile", pid) if pid else None,
|
||||
"app_auto": backup_service.get_auto_backup_settings(uid, "app") if can_app else None,
|
||||
"auto": backup_service.get_auto_backup_settings(uid, "app") if can_app else None,
|
||||
"can_app_backup": can_app,
|
||||
})
|
||||
|
||||
|
||||
@bp.post("/backup/profile")
|
||||
def backup_create_profile():
|
||||
data = request.get_json(silent=True) or {}
|
||||
pid = _active_profile_id(require_write=True)
|
||||
if not pid:
|
||||
return jsonify({"ok": False, "error": "No profile"}), 400
|
||||
try:
|
||||
return ok({
|
||||
"backup": backup_service.create_profile_backup(str(data.get("name") or "Profile backup"), pid, default_user_id()),
|
||||
"profile_backups": backup_service.list_backups(default_user_id(), "profile", pid),
|
||||
})
|
||||
except Exception as exc:
|
||||
return jsonify({"ok": False, "error": str(exc)}), 400
|
||||
|
||||
|
||||
@bp.post("/backup/app")
|
||||
def backup_create_app():
|
||||
data = request.get_json(silent=True) or {}
|
||||
try:
|
||||
return ok({
|
||||
"backup": backup_service.create_app_backup(str(data.get("name") or "Application backup"), default_user_id()),
|
||||
"app_backups": backup_service.list_backups(default_user_id(), "app"),
|
||||
})
|
||||
except Exception as exc:
|
||||
return jsonify({"ok": False, "error": str(exc)}), 403 if isinstance(exc, PermissionError) else 400
|
||||
|
||||
|
||||
@bp.post("/backup")
|
||||
def backup_create():
|
||||
data = request.get_json(silent=True) or {}
|
||||
return ok({"backup": backup_service.create_backup(str(data.get("name") or "Manual backup"), default_user_id()), "backups": backup_service.list_backups(default_user_id())})
|
||||
return backup_create_profile()
|
||||
|
||||
|
||||
@bp.get("/backup/settings")
|
||||
def backup_settings_get():
|
||||
return ok({"settings": backup_service.get_auto_backup_settings(default_user_id())})
|
||||
if not auth.is_admin():
|
||||
return jsonify({"ok": False, "error": "Application backup settings are admin-only"}), 403
|
||||
return ok({"settings": backup_service.get_auto_backup_settings(default_user_id(), "app")})
|
||||
|
||||
|
||||
@bp.post("/backup/settings")
|
||||
def backup_settings_save():
|
||||
data = request.get_json(silent=True) or {}
|
||||
try:
|
||||
return ok({"settings": backup_service.save_auto_backup_settings(data, default_user_id())})
|
||||
return ok({"settings": backup_service.save_auto_backup_settings(data, default_user_id(), "app")})
|
||||
except Exception as exc:
|
||||
return jsonify({"ok": False, "error": str(exc)}), 400
|
||||
return jsonify({"ok": False, "error": str(exc)}), 403 if isinstance(exc, PermissionError) else 400
|
||||
|
||||
|
||||
@bp.get("/backup/profile/settings")
|
||||
def profile_backup_settings_get():
|
||||
pid = _active_profile_id()
|
||||
if not pid:
|
||||
return jsonify({"ok": False, "error": "No profile"}), 400
|
||||
return ok({"settings": backup_service.get_auto_backup_settings(default_user_id(), "profile", pid)})
|
||||
|
||||
|
||||
@bp.post("/backup/profile/settings")
|
||||
def profile_backup_settings_save():
|
||||
data = request.get_json(silent=True) or {}
|
||||
pid = _active_profile_id(require_write=True)
|
||||
if not pid:
|
||||
return jsonify({"ok": False, "error": "No profile"}), 400
|
||||
try:
|
||||
return ok({"settings": backup_service.save_auto_backup_settings(data, default_user_id(), "profile", pid)})
|
||||
except Exception as exc:
|
||||
return jsonify({"ok": False, "error": str(exc)}), 403 if isinstance(exc, PermissionError) else 400
|
||||
|
||||
|
||||
@bp.get("/backup/<int:backup_id>/preview")
|
||||
@@ -36,14 +99,13 @@ def backup_preview(backup_id: int):
|
||||
return jsonify({"ok": False, "error": str(exc)}), 400
|
||||
|
||||
|
||||
|
||||
@bp.post("/backup/<int:backup_id>/restore")
|
||||
def backup_restore(backup_id: int):
|
||||
try:
|
||||
return ok({"result": backup_service.restore_backup(backup_id, default_user_id())})
|
||||
pid = _active_profile_id(require_write=True)
|
||||
return ok({"result": backup_service.restore_backup(backup_id, default_user_id(), profile_id=pid)})
|
||||
except Exception as exc:
|
||||
return jsonify({"ok": False, "error": str(exc)}), 400
|
||||
|
||||
return jsonify({"ok": False, "error": str(exc)}), 403 if isinstance(exc, PermissionError) else 400
|
||||
|
||||
|
||||
@bp.delete("/backup/<int:backup_id>")
|
||||
@@ -54,7 +116,6 @@ def backup_delete(backup_id: int):
|
||||
return jsonify({"ok": False, "error": str(exc)}), 400
|
||||
|
||||
|
||||
|
||||
@bp.get("/backup/<int:backup_id>/download")
|
||||
def backup_download(backup_id: int):
|
||||
try:
|
||||
@@ -62,8 +123,6 @@ def backup_download(backup_id: int):
|
||||
tmp = tempfile.NamedTemporaryFile(prefix="pytorrent-backup-", suffix=".json", delete=False, mode="w", encoding="utf-8")
|
||||
json.dump(payload, tmp, ensure_ascii=False, indent=2)
|
||||
tmp.close()
|
||||
return send_file(tmp.name, as_attachment=True, download_name=f"pytorrent-backup-{backup_id}.json")
|
||||
return send_file(tmp.name, as_attachment=True, download_name=f"pytorrent-{payload.get('backup_type') or 'backup'}-{backup_id}.json")
|
||||
except Exception as exc:
|
||||
return jsonify({"ok": False, "error": str(exc)}), 400
|
||||
|
||||
|
||||
|
||||
+275
-7
@@ -1,13 +1,17 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from urllib.parse import quote
|
||||
import queue
|
||||
import tempfile
|
||||
import threading
|
||||
import zipfile
|
||||
|
||||
from flask import Blueprint, render_template, Response, request, redirect, url_for, abort, send_file
|
||||
from ..services.preferences import get_preferences, list_profiles, active_profile, BOOTSTRAP_THEMES, FONT_FAMILIES
|
||||
from ..services import auth
|
||||
from flask import Blueprint, render_template, Response, request, redirect, url_for, abort, send_file, stream_with_context
|
||||
from ..services.preferences import get_preferences, list_profiles, active_profile, get_profile, BOOTSTRAP_THEMES, FONT_FAMILIES
|
||||
from ..services import auth, pdf_preview_links, rtorrent
|
||||
from ..config import PYTORRENT_TMP_DIR, SMART_QUEUE_LABEL, SMART_QUEUE_STALLED_LABEL
|
||||
from ..services.frontend_assets import asset_path
|
||||
|
||||
# for favicon
|
||||
from flask import current_app, send_from_directory
|
||||
|
||||
bp = Blueprint("main", __name__)
|
||||
@@ -18,6 +22,139 @@ def _asset_url(key: str) -> str:
|
||||
return path if path.startswith("http") else url_for("static", filename=path)
|
||||
|
||||
|
||||
def _attachment_headers(download_name: str, content_type: str = "application/octet-stream", disposition: str = "attachment") -> dict:
|
||||
safe = Path(download_name or "download.bin").name or "download.bin"
|
||||
safe_disposition = "inline" if disposition == "inline" else "attachment"
|
||||
return {
|
||||
"Content-Type": content_type,
|
||||
"Content-Disposition": f"{safe_disposition}; filename*=UTF-8''{quote(safe)}",
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
}
|
||||
|
||||
|
||||
def _cleanup_staged_file(profile: dict, path: str, local: bool = False) -> None:
|
||||
if local:
|
||||
try:
|
||||
Path(path).unlink()
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
rtorrent._remote_remove_staged(profile, path)
|
||||
try:
|
||||
tmp_prefix = str(PYTORRENT_TMP_DIR).rstrip("/") + "/pytorrent-download-"
|
||||
if str(path).startswith(tmp_prefix) and Path(path).exists():
|
||||
Path(path).unlink()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _read_staged_file(profile: dict, path: str, local: bool = False) -> bytes:
|
||||
if local:
|
||||
return Path(path).read_bytes()
|
||||
return b"".join(bytes(chunk) for chunk in rtorrent.iter_remote_file_chunks(profile, path) if chunk)
|
||||
|
||||
|
||||
def _safe_zip_name(name: str, fallback: str) -> str:
|
||||
value = str(name or fallback).replace("\\", "/").lstrip("/")
|
||||
parts = [part for part in value.split("/") if part not in ("", ".", "..")]
|
||||
return "/".join(parts) or fallback
|
||||
|
||||
|
||||
class _ZipStream:
|
||||
def __init__(self):
|
||||
self.queue: queue.Queue[bytes | None] = queue.Queue(maxsize=16)
|
||||
self.closed = False
|
||||
|
||||
def write(self, data):
|
||||
if not data:
|
||||
return 0
|
||||
payload = bytes(data)
|
||||
self.queue.put(payload)
|
||||
return len(payload)
|
||||
|
||||
def flush(self):
|
||||
return None
|
||||
|
||||
def close(self):
|
||||
if not self.closed:
|
||||
self.closed = True
|
||||
self.queue.put(None)
|
||||
|
||||
def writable(self):
|
||||
return True
|
||||
|
||||
|
||||
def _stream_torrent_files_zip(profile: dict, items: list[dict]):
|
||||
writer = _ZipStream()
|
||||
errors: list[BaseException] = []
|
||||
|
||||
def produce():
|
||||
try:
|
||||
with zipfile.ZipFile(writer, "w", compression=zipfile.ZIP_STORED, allowZip64=True) as archive:
|
||||
used = set()
|
||||
for item in items:
|
||||
arcname = _safe_zip_name(str(item.get("path") or ""), f"file-{item.get('index', 0)}")
|
||||
base = arcname
|
||||
counter = 2
|
||||
while arcname in used:
|
||||
stem = Path(base).stem or "file"
|
||||
suffix = Path(base).suffix
|
||||
parent = str(Path(base).parent).replace(".", "", 1).strip("/")
|
||||
candidate = f"{stem}-{counter}{suffix}"
|
||||
arcname = f"{parent}/{candidate}" if parent else candidate
|
||||
counter += 1
|
||||
used.add(arcname)
|
||||
info = zipfile.ZipInfo(arcname)
|
||||
info.compress_type = zipfile.ZIP_STORED
|
||||
info.file_size = int(item.get("size") or 0)
|
||||
with archive.open(info, "w", force_zip64=True) as dest:
|
||||
for chunk in rtorrent.iter_remote_file_chunks(profile, item["remote_path"], size=int(item.get("size") or 0) or None):
|
||||
dest.write(chunk)
|
||||
except BaseException as exc:
|
||||
errors.append(exc)
|
||||
finally:
|
||||
writer.close()
|
||||
|
||||
threading.Thread(target=produce, name="pytorrent-token-zip-stream", daemon=True).start()
|
||||
while True:
|
||||
chunk = writer.queue.get()
|
||||
if chunk is None:
|
||||
break
|
||||
yield chunk
|
||||
if errors:
|
||||
raise errors[0]
|
||||
|
||||
|
||||
def _send_staged_torrent_file(profile: dict, path: str, download_name: str, local: bool = False):
|
||||
headers = _attachment_headers(download_name, "application/x-bittorrent")
|
||||
if local:
|
||||
data = Path(path).read_bytes()
|
||||
_cleanup_staged_file(profile, path, local=True)
|
||||
headers["Content-Length"] = str(len(data))
|
||||
return Response(data, headers=headers)
|
||||
|
||||
def generate():
|
||||
try:
|
||||
yield from rtorrent.iter_remote_file_chunks(profile, path)
|
||||
finally:
|
||||
_cleanup_staged_file(profile, path, local=False)
|
||||
|
||||
return Response(stream_with_context(generate()), headers=headers, direct_passthrough=True)
|
||||
|
||||
|
||||
def _profile_for_temporary_target(target: dict):
|
||||
profile_id = int(target.get("profile_id") or 0)
|
||||
owner_user_id = int(target.get("user_id") or 0)
|
||||
if auth.enabled() and owner_user_id != auth.current_user_id():
|
||||
abort(403)
|
||||
if not auth.can_access_profile(profile_id):
|
||||
abort(403)
|
||||
profile = active_profile() if not profile_id else get_profile(profile_id)
|
||||
if not profile:
|
||||
abort(404)
|
||||
return profile
|
||||
|
||||
|
||||
@bp.get("/favicon.ico")
|
||||
def favicon_ico():
|
||||
response = send_from_directory(
|
||||
@@ -33,17 +170,30 @@ def login():
|
||||
# Note: When optional authentication is disabled, /login is intentionally unavailable.
|
||||
if not auth.enabled():
|
||||
abort(404)
|
||||
next_url = request.args.get("next") or url_for("main.index")
|
||||
if auth.uses_external_provider():
|
||||
user = auth.authenticate_external_user()
|
||||
if user:
|
||||
return redirect(next_url)
|
||||
return render_template(
|
||||
"login.html",
|
||||
error="External authentication headers were not accepted by pyTorrent.",
|
||||
external_provider=auth.provider(),
|
||||
), 401
|
||||
error = ""
|
||||
if request.method == "POST":
|
||||
user = auth.login_user(request.form.get("username", ""), request.form.get("password", ""))
|
||||
if user:
|
||||
return redirect(request.args.get("next") or url_for("main.index"))
|
||||
return redirect(next_url)
|
||||
error = "Invalid username or password"
|
||||
return render_template("login.html", error=error)
|
||||
return render_template("login.html", error=error, external_provider=None)
|
||||
|
||||
|
||||
@bp.get("/logout")
|
||||
def logout():
|
||||
# Note: External providers such as Tinyauth own the login session, so pyTorrent must not pretend to log the user out locally.
|
||||
if auth.uses_external_provider():
|
||||
return redirect(url_for("main.index"))
|
||||
auth.logout_user()
|
||||
if not auth.enabled():
|
||||
return redirect(url_for("main.index"))
|
||||
@@ -61,10 +211,128 @@ def index():
|
||||
bootstrap_themes=BOOTSTRAP_THEMES,
|
||||
font_families=FONT_FAMILIES,
|
||||
auth_enabled=auth.enabled(),
|
||||
auth_provider=auth.provider(),
|
||||
external_auth=auth.uses_external_provider(),
|
||||
current_user=auth.current_user(),
|
||||
smart_queue_label=SMART_QUEUE_LABEL,
|
||||
smart_queue_stalled_label=SMART_QUEUE_STALLED_LABEL,
|
||||
)
|
||||
|
||||
|
||||
|
||||
|
||||
@bp.get("/preview/pdf/<token>")
|
||||
def pdf_preview(token: str):
|
||||
# Note: This route keeps browser-visible PDF links inside the app and delegates streaming to the existing rTorrent file reader.
|
||||
target = pdf_preview_links.get_pdf_preview_link(token)
|
||||
if not target:
|
||||
abort(404)
|
||||
profile_id = int(target.get("profile_id") or 0)
|
||||
owner_user_id = int(target.get("user_id") or 0)
|
||||
if auth.enabled() and owner_user_id != auth.current_user_id():
|
||||
abort(403)
|
||||
if not auth.can_access_profile(profile_id):
|
||||
abort(403)
|
||||
profile = active_profile() if not profile_id else get_profile(profile_id)
|
||||
if not profile:
|
||||
abort(404)
|
||||
item = rtorrent.torrent_download_file_info(profile, target["torrent_hash"], int(target["file_index"]))
|
||||
filename = Path(item.get("download_name") or "preview.pdf").name or "preview.pdf"
|
||||
if Path(filename).suffix.lower() != ".pdf":
|
||||
abort(404)
|
||||
size = int(item.get("size") or 0)
|
||||
headers = {
|
||||
"Content-Disposition": f"inline; filename*=UTF-8''{quote(filename)}",
|
||||
"Content-Type": "application/pdf",
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
}
|
||||
if size > 0:
|
||||
headers["Content-Length"] = str(size)
|
||||
|
||||
def generate():
|
||||
yield from rtorrent.iter_remote_file_chunks(profile, item["remote_path"], size=size or None)
|
||||
|
||||
return Response(stream_with_context(generate()), headers=headers, direct_passthrough=True)
|
||||
|
||||
|
||||
@bp.get("/download/<token>")
|
||||
def temporary_download(token: str):
|
||||
# Note: UI download actions resolve API-created temporary tokens here, keeping browser-visible URLs outside /api/.
|
||||
target = pdf_preview_links.get_temporary_link(token)
|
||||
if not target:
|
||||
abort(404)
|
||||
profile = _profile_for_temporary_target(target)
|
||||
kind = str(target.get("kind") or "")
|
||||
|
||||
if kind == "file_download":
|
||||
item = rtorrent.torrent_download_file_info(profile, target["torrent_hash"], int(target["file_index"]))
|
||||
size = int(item.get("size") or 0)
|
||||
headers = _attachment_headers(item.get("download_name") or "file.bin")
|
||||
if size > 0:
|
||||
headers["Content-Length"] = str(size)
|
||||
|
||||
def generate_file():
|
||||
yield from rtorrent.iter_remote_file_chunks(profile, item["remote_path"], size=size or None)
|
||||
|
||||
return Response(stream_with_context(generate_file()), headers=headers, direct_passthrough=True)
|
||||
|
||||
if kind == "file_zip_download":
|
||||
items = rtorrent.torrent_download_zip_items(profile, target["torrent_hash"], target.get("indexes"))
|
||||
headers = _attachment_headers(f"{str(target['torrent_hash'])[:12]}-files.zip", "application/zip")
|
||||
headers["X-PyTorrent-Download-Mode"] = "temporary-token"
|
||||
return Response(stream_with_context(_stream_torrent_files_zip(profile, items)), headers=headers, direct_passthrough=True)
|
||||
|
||||
if kind == "torrent_file_download":
|
||||
item = rtorrent.export_torrent_file(profile, target["torrent_hash"])
|
||||
return _send_staged_torrent_file(profile, item["path"], item["download_name"], bool(item.get("local")))
|
||||
|
||||
if kind == "torrent_files_zip_download":
|
||||
hashes = [str(item) for item in (target.get("hashes") or []) if str(item).strip()]
|
||||
if not hashes:
|
||||
abort(404)
|
||||
staged_paths = []
|
||||
PYTORRENT_TMP_DIR.mkdir(parents=True, exist_ok=True)
|
||||
tmp = tempfile.NamedTemporaryFile(prefix="pytorrent-torrents-", suffix=".zip", delete=False, dir=str(PYTORRENT_TMP_DIR))
|
||||
tmp.close()
|
||||
try:
|
||||
with zipfile.ZipFile(tmp.name, "w", compression=zipfile.ZIP_DEFLATED, allowZip64=True) as archive:
|
||||
used_names = set()
|
||||
for torrent_hash in hashes:
|
||||
item = rtorrent.export_torrent_file(profile, torrent_hash)
|
||||
staged_paths.append((item["path"], bool(item.get("local"))))
|
||||
name = Path(item["download_name"]).name or f"{torrent_hash}.torrent"
|
||||
base_name = name
|
||||
counter = 2
|
||||
while name in used_names:
|
||||
stem = Path(base_name).stem
|
||||
name = f"{stem}-{counter}.torrent"
|
||||
counter += 1
|
||||
used_names.add(name)
|
||||
archive.writestr(name, _read_staged_file(profile, item["path"], bool(item.get("local"))))
|
||||
response = send_file(tmp.name, as_attachment=True, download_name="pytorrent-torrents.zip")
|
||||
|
||||
def cleanup():
|
||||
for path, is_local in staged_paths:
|
||||
_cleanup_staged_file(profile, path, is_local)
|
||||
try:
|
||||
Path(tmp.name).unlink()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
response.call_on_close(cleanup)
|
||||
return response
|
||||
except Exception:
|
||||
for path, is_local in staged_paths:
|
||||
_cleanup_staged_file(profile, path, is_local)
|
||||
try:
|
||||
Path(tmp.name).unlink()
|
||||
except Exception:
|
||||
pass
|
||||
raise
|
||||
|
||||
abort(404)
|
||||
|
||||
|
||||
@bp.get("/docs")
|
||||
def docs():
|
||||
html = f"""<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>pyTorrent API Docs</title><link rel="stylesheet" href="{_asset_url('swagger_css')}"></head><body><div id="swagger-ui"></div><script src="{_asset_url('swagger_js')}"></script><script>window.onload=()=>SwaggerUIBundle({{url:'/api/openapi.json',dom_id:'#swagger-ui',deepLinking:true,persistAuthorization:true}});</script></body></html>"""
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from ._shared import *
|
||||
from ..services import operation_logs
|
||||
|
||||
|
||||
def _active_profile_or_400():
|
||||
profile = preferences.active_profile()
|
||||
profile = request_profile()
|
||||
if not profile:
|
||||
return None
|
||||
return profile
|
||||
@@ -16,7 +15,6 @@ def operation_logs_list():
|
||||
profile = _active_profile_or_400()
|
||||
if not profile:
|
||||
return ok({"logs": [], "total": 0, "stats": {}, "settings": operation_logs.get_settings(0), "error": "No profile"})
|
||||
operation_logs.apply_retention(int(profile["id"]))
|
||||
data = operation_logs.list_logs(
|
||||
int(profile["id"]),
|
||||
limit=int(request.args.get("limit") or 200),
|
||||
@@ -25,19 +23,32 @@ def operation_logs_list():
|
||||
q=str(request.args.get("q") or "").strip(),
|
||||
hide_jobs=str(request.args.get("hide_jobs") or "").lower() in {"1", "true", "yes", "on"},
|
||||
)
|
||||
data["stats"] = operation_logs.stats(int(profile["id"]))
|
||||
data["settings"] = data["stats"].get("settings")
|
||||
data["settings"] = operation_logs.get_settings(int(profile["id"]))
|
||||
if str(request.args.get("stats") or "").lower() in {"1", "true", "yes", "on"}:
|
||||
data["stats"] = operation_logs.stats(int(profile["id"]))
|
||||
data["settings"] = data["stats"].get("settings", data["settings"])
|
||||
return ok(data)
|
||||
|
||||
|
||||
@bp.get("/operation-logs/stats")
|
||||
def operation_logs_stats():
|
||||
profile = _active_profile_or_400()
|
||||
if not profile:
|
||||
return ok({"stats": {}, "settings": operation_logs.get_settings(0), "error": "No profile"})
|
||||
stats = operation_logs.stats(int(profile["id"]))
|
||||
return ok({"stats": stats, "settings": stats.get("settings")})
|
||||
|
||||
|
||||
@bp.post("/operation-logs/settings")
|
||||
def operation_logs_settings_save():
|
||||
profile = _active_profile_or_400()
|
||||
if not profile:
|
||||
return jsonify({"ok": False, "error": "No profile"}), 400
|
||||
settings = operation_logs.save_settings(int(profile["id"]), request.get_json(silent=True) or {})
|
||||
result = operation_logs.apply_retention(int(profile["id"]))
|
||||
return ok({"settings": settings, "retention": result})
|
||||
try:
|
||||
settings = operation_logs.save_settings(int(profile["id"]), request.get_json(silent=True) or {})
|
||||
return ok({"settings": settings})
|
||||
except Exception as exc:
|
||||
return jsonify({"ok": False, "error": str(exc)}), 403 if isinstance(exc, PermissionError) else 400
|
||||
|
||||
|
||||
@bp.post("/operation-logs/clear")
|
||||
@@ -54,4 +65,5 @@ def operation_logs_apply_retention():
|
||||
profile = _active_profile_or_400()
|
||||
if not profile:
|
||||
return jsonify({"ok": False, "error": "No profile"}), 400
|
||||
return ok(operation_logs.apply_retention(int(profile["id"])))
|
||||
category = str((request.get_json(silent=True) or {}).get("category") or "all").strip().lower()
|
||||
return ok(operation_logs.apply_retention(int(profile["id"]), category=category))
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from flask import Blueprint, jsonify, request
|
||||
from flask import jsonify, request
|
||||
|
||||
from ..services import preferences, download_planner, poller_control
|
||||
from ._shared import bp, request_profile
|
||||
from ..services import download_planner, poller_control
|
||||
from ..services.auth import current_user_id
|
||||
|
||||
bp = Blueprint("planner_api", __name__, url_prefix="/api")
|
||||
|
||||
|
||||
def ok(payload=None):
|
||||
data = {"ok": True}
|
||||
if payload:
|
||||
@@ -16,7 +14,7 @@ def ok(payload=None):
|
||||
|
||||
|
||||
def _profile_or_error():
|
||||
profile = preferences.active_profile()
|
||||
profile = request_profile()
|
||||
if not profile:
|
||||
return None, (jsonify({"ok": False, "error": "No profile"}), 400)
|
||||
return profile, None
|
||||
@@ -32,6 +30,7 @@ def download_planner_get():
|
||||
|
||||
@bp.post("/download-planner")
|
||||
def download_planner_save():
|
||||
# Note: Planner settings are saved through one canonical endpoint to keep the frontend/backend contract explicit.
|
||||
profile, error = _profile_or_error()
|
||||
if error:
|
||||
return error
|
||||
@@ -95,7 +94,8 @@ def poller_settings_get():
|
||||
if error:
|
||||
return error
|
||||
pid = int(profile["id"])
|
||||
return ok({"settings": poller_control.get_settings(pid), "runtime": poller_control.snapshot(pid)})
|
||||
settings = poller_control.get_settings(pid)
|
||||
return ok({"settings": settings, "runtime": poller_control.snapshot(pid, settings)})
|
||||
|
||||
|
||||
@bp.post("/poller/settings")
|
||||
|
||||
@@ -1,11 +1,19 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from ._shared import *
|
||||
from ..services.rtorrent.diagnostics import profile_diagnostics
|
||||
from ..services import auth
|
||||
|
||||
@bp.get("/profiles")
|
||||
def profiles_list():
|
||||
return ok({"profiles": preferences.list_profiles(), "active": preferences.active_profile()})
|
||||
profiles = []
|
||||
for row in preferences.list_profiles():
|
||||
item = dict(row)
|
||||
settings = backup_service.get_auto_backup_settings(default_user_id(), "profile", int(item.get("id") or 0))
|
||||
item["profile_backup_enabled"] = bool(settings.get("enabled"))
|
||||
item["profile_backup_interval_hours"] = settings.get("interval_hours")
|
||||
item["profile_backup_retention_days"] = settings.get("retention_days")
|
||||
profiles.append(item)
|
||||
return ok({"profiles": profiles, "active": preferences.active_profile()})
|
||||
|
||||
|
||||
|
||||
@@ -17,7 +25,6 @@ def profiles_create():
|
||||
return jsonify({"ok": False, "error": str(exc)}), 400
|
||||
|
||||
|
||||
|
||||
@bp.put("/profiles/<int:profile_id>")
|
||||
def profiles_update(profile_id: int):
|
||||
try:
|
||||
@@ -88,93 +95,146 @@ def profiles_import():
|
||||
|
||||
@bp.get("/preferences")
|
||||
def prefs_get():
|
||||
return ok({"preferences": preferences.get_preferences()})
|
||||
return ok({"preferences": preferences.get_preferences(profile_id=request_profile_id())})
|
||||
|
||||
|
||||
|
||||
@bp.post("/preferences")
|
||||
def prefs_save():
|
||||
return ok({"preferences": preferences.save_preferences(request.json or {})})
|
||||
return ok({"preferences": preferences.save_preferences(request.json or {}, profile_id=request_profile_id(require_write=True))})
|
||||
|
||||
|
||||
@bp.post("/preferences/table-columns/recommended")
|
||||
def prefs_table_columns_recommended():
|
||||
# Note: Applies the backend-owned recommended desktop and mobile column layout.
|
||||
return ok({"preferences": preferences.apply_recommended_table_columns()})
|
||||
return ok({"preferences": preferences.apply_recommended_table_columns(profile_id=request_profile_id(require_write=True))})
|
||||
|
||||
|
||||
|
||||
@bp.get("/labels")
|
||||
def labels_list():
|
||||
profile = preferences.active_profile()
|
||||
profile = request_profile()
|
||||
pid = profile["id"] if profile else None
|
||||
if not pid:
|
||||
return ok({"labels": []})
|
||||
with connect() as conn:
|
||||
rows = conn.execute("SELECT * FROM labels WHERE user_id=? AND (profile_id=? OR profile_id IS NULL) ORDER BY name COLLATE NOCASE", (default_user_id(), pid)).fetchall()
|
||||
rows = conn.execute(
|
||||
"""
|
||||
SELECT l.*, COALESCE(u.display_name,u.username,u.email,'user ' || l.user_id) AS owner_name
|
||||
FROM labels l
|
||||
LEFT JOIN users u ON u.id=l.user_id
|
||||
WHERE l.profile_id=?
|
||||
ORDER BY l.name COLLATE NOCASE, l.id
|
||||
""",
|
||||
(pid,),
|
||||
).fetchall()
|
||||
return ok({"labels": rows})
|
||||
|
||||
|
||||
|
||||
@bp.post("/labels")
|
||||
def labels_save():
|
||||
profile = preferences.active_profile()
|
||||
profile = request_profile()
|
||||
if not profile:
|
||||
return jsonify({"ok": False, "error": "No profile"}), 400
|
||||
data = request.get_json(silent=True) or {}
|
||||
name = str(data.get("name") or "").strip()
|
||||
if not name:
|
||||
return jsonify({"ok": False, "error": "Missing label name"}), 400
|
||||
if not auth.can_write_profile(int(profile["id"]), default_user_id()):
|
||||
return jsonify({"ok": False, "error": "No write access to profile"}), 403
|
||||
now = utcnow()
|
||||
with connect() as conn:
|
||||
conn.execute("INSERT OR IGNORE INTO labels(user_id,profile_id,name,color,created_at,updated_at) VALUES(?,?,?,?,?,?)", (default_user_id(), profile["id"], name, data.get("color") or "#64748b", now, now))
|
||||
existing = conn.execute("SELECT id FROM labels WHERE profile_id=? AND lower(name)=lower(?) ORDER BY id LIMIT 1", (profile["id"], name)).fetchone()
|
||||
if existing:
|
||||
conn.execute("UPDATE labels SET color=?, updated_at=? WHERE id=?", (data.get("color") or "#64748b", now, existing["id"]))
|
||||
else:
|
||||
conn.execute("INSERT INTO labels(user_id,profile_id,name,color,created_at,updated_at) VALUES(?,?,?,?,?,?)", (default_user_id(), profile["id"], name, data.get("color") or "#64748b", now, now))
|
||||
return labels_list()
|
||||
|
||||
|
||||
|
||||
@bp.delete("/labels/<int:label_id>")
|
||||
def labels_delete(label_id: int):
|
||||
profile = preferences.active_profile()
|
||||
profile = request_profile()
|
||||
pid = profile["id"] if profile else None
|
||||
if not pid or not auth.can_write_profile(int(pid), default_user_id()):
|
||||
return jsonify({"ok": False, "error": "No write access to profile"}), 403
|
||||
with connect() as conn:
|
||||
conn.execute("DELETE FROM labels WHERE id=? AND user_id=? AND (profile_id=? OR profile_id IS NULL)", (label_id, default_user_id(), pid))
|
||||
conn.execute("DELETE FROM labels WHERE id=? AND profile_id=?", (label_id, pid))
|
||||
return labels_list()
|
||||
|
||||
|
||||
|
||||
@bp.get("/ratio-groups")
|
||||
def ratio_groups_list():
|
||||
profile = preferences.active_profile()
|
||||
profile = request_profile()
|
||||
pid = profile["id"] if profile else None
|
||||
with connect() as conn:
|
||||
rows = conn.execute("SELECT * FROM ratio_groups WHERE user_id=? AND (profile_id=? OR profile_id IS NULL) ORDER BY name COLLATE NOCASE", (default_user_id(), pid)).fetchall()
|
||||
history = conn.execute("SELECT * FROM ratio_history WHERE user_id=? AND profile_id=? ORDER BY id DESC LIMIT 50", (default_user_id(), pid or 0)).fetchall() if pid else []
|
||||
rows = conn.execute(
|
||||
"""
|
||||
SELECT g.*, COALESCE(u.display_name,u.username,u.email,'user ' || g.user_id) AS owner_name
|
||||
FROM ratio_groups g
|
||||
LEFT JOIN users u ON u.id=g.user_id
|
||||
WHERE g.profile_id=?
|
||||
ORDER BY g.name COLLATE NOCASE, g.id
|
||||
""",
|
||||
(pid or 0,),
|
||||
).fetchall() if pid else []
|
||||
history = conn.execute("SELECT * FROM ratio_history WHERE profile_id=? ORDER BY id DESC LIMIT 50", (pid or 0,)).fetchall() if pid else []
|
||||
return ok({"groups": rows, "history": history})
|
||||
|
||||
|
||||
|
||||
@bp.post("/ratio-groups")
|
||||
def ratio_groups_save():
|
||||
profile = preferences.active_profile()
|
||||
profile = request_profile()
|
||||
if not profile:
|
||||
return jsonify({"ok": False, "error": "No profile"}), 400
|
||||
data = request.get_json(silent=True) or {}
|
||||
name = str(data.get("name") or "").strip()
|
||||
if not name:
|
||||
return jsonify({"ok": False, "error": "Missing group name"}), 400
|
||||
if not auth.can_write_profile(int(profile["id"]), default_user_id()):
|
||||
return jsonify({"ok": False, "error": "No write access to profile"}), 403
|
||||
now = utcnow()
|
||||
with connect() as conn:
|
||||
conn.execute(
|
||||
"""INSERT INTO ratio_groups(user_id,profile_id,name,min_ratio,max_ratio,seed_time_minutes,min_seed_time_minutes,ignore_private,ignore_active_upload,active_upload_min_bytes,move_path,set_label,action,enabled,created_at,updated_at)
|
||||
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
|
||||
ON CONFLICT(user_id,profile_id,name) DO UPDATE SET min_ratio=excluded.min_ratio,max_ratio=excluded.max_ratio,seed_time_minutes=excluded.seed_time_minutes,min_seed_time_minutes=excluded.min_seed_time_minutes,ignore_private=excluded.ignore_private,ignore_active_upload=excluded.ignore_active_upload,active_upload_min_bytes=excluded.active_upload_min_bytes,move_path=excluded.move_path,set_label=excluded.set_label,action=excluded.action,enabled=excluded.enabled,updated_at=excluded.updated_at""",
|
||||
(default_user_id(), profile["id"], name, float(data.get("min_ratio") or 1), float(data.get("max_ratio") or 2), int(data.get("seed_time_minutes") or 0), int(data.get("min_seed_time_minutes") or 0), 1 if data.get("ignore_private", True) else 0, 1 if data.get("ignore_active_upload", True) else 0, int(data.get("active_upload_min_bytes") or 1024), data.get("move_path") or "", data.get("set_label") or "", data.get("action") or "stop", 1 if data.get("enabled", True) else 0, now, now),
|
||||
)
|
||||
existing = conn.execute("SELECT id,user_id FROM ratio_groups WHERE profile_id=? AND lower(name)=lower(?) ORDER BY id LIMIT 1", (profile["id"], name)).fetchone()
|
||||
values = (float(data.get("min_ratio") or 1), float(data.get("max_ratio") or 2), int(data.get("seed_time_minutes") or 0), int(data.get("min_seed_time_minutes") or 0), 1 if data.get("ignore_private", True) else 0, 1 if data.get("ignore_active_upload", True) else 0, int(data.get("active_upload_min_bytes") or 1024), data.get("move_path") or "", data.get("set_label") or "", data.get("action") or "stop", 1 if data.get("enabled", True) else 0, now)
|
||||
if existing:
|
||||
conn.execute(
|
||||
"""UPDATE ratio_groups SET min_ratio=?,max_ratio=?,seed_time_minutes=?,min_seed_time_minutes=?,ignore_private=?,ignore_active_upload=?,active_upload_min_bytes=?,move_path=?,set_label=?,action=?,enabled=?,updated_at=? WHERE id=? AND profile_id=?""",
|
||||
(*values, existing["id"], profile["id"]),
|
||||
)
|
||||
else:
|
||||
conn.execute(
|
||||
"""INSERT INTO ratio_groups(user_id,profile_id,name,min_ratio,max_ratio,seed_time_minutes,min_seed_time_minutes,ignore_private,ignore_active_upload,active_upload_min_bytes,move_path,set_label,action,enabled,created_at,updated_at)
|
||||
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
|
||||
(default_user_id(), profile["id"], name, *values[:-1], now, now),
|
||||
)
|
||||
return ratio_groups_list()
|
||||
|
||||
|
||||
|
||||
@bp.delete("/ratio-groups/<int:group_id>")
|
||||
def ratio_groups_delete(group_id: int):
|
||||
profile = request_profile()
|
||||
if not profile:
|
||||
return jsonify({"ok": False, "error": "No profile"}), 400
|
||||
if not auth.can_write_profile(int(profile["id"]), default_user_id()):
|
||||
return jsonify({"ok": False, "error": "No write access to profile"}), 403
|
||||
with connect() as conn:
|
||||
# Note: Deleting a ratio group removes only the group definition and its assignment links; history stays as an audit trail.
|
||||
deleted = conn.execute("DELETE FROM ratio_groups WHERE id=? AND profile_id=?", (int(group_id), int(profile["id"]))).rowcount
|
||||
conn.execute("DELETE FROM ratio_assignments WHERE group_id=? AND profile_id=?", (int(group_id), int(profile["id"])))
|
||||
if not deleted:
|
||||
return jsonify({"ok": False, "error": "Ratio group not found"}), 404
|
||||
return ratio_groups_list()
|
||||
|
||||
|
||||
@bp.post("/ratio-groups/check")
|
||||
def ratio_groups_check():
|
||||
profile = preferences.active_profile()
|
||||
profile = request_profile()
|
||||
if not profile:
|
||||
return jsonify({"ok": False, "error": "No profile"}), 400
|
||||
return ok({"result": ratio_rules.check(profile, default_user_id())})
|
||||
|
||||
+64
-25
@@ -1,66 +1,108 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from ._shared import *
|
||||
|
||||
def _active_profile_or_400():
|
||||
profile = request_profile()
|
||||
if not profile:
|
||||
return None
|
||||
return profile
|
||||
|
||||
|
||||
@bp.get("/rss")
|
||||
def rss_list():
|
||||
profile = preferences.active_profile()
|
||||
pid = profile["id"] if profile else None
|
||||
profile = _active_profile_or_400()
|
||||
if not profile:
|
||||
return ok({"feeds": [], "rules": [], "history": []})
|
||||
pid = int(profile["id"])
|
||||
with connect() as conn:
|
||||
feeds = conn.execute("SELECT * FROM rss_feeds WHERE user_id=? AND (profile_id=? OR profile_id IS NULL) ORDER BY name", (default_user_id(), pid)).fetchall()
|
||||
rules = conn.execute("SELECT * FROM rss_rules WHERE user_id=? AND (profile_id=? OR profile_id IS NULL) ORDER BY name", (default_user_id(), pid)).fetchall()
|
||||
history = conn.execute("SELECT * FROM rss_history WHERE user_id=? AND (profile_id=? OR profile_id IS NULL) ORDER BY id DESC LIMIT 80", (default_user_id(), pid)).fetchall()
|
||||
feeds = conn.execute("SELECT * FROM rss_feeds WHERE profile_id=? ORDER BY name", (pid,)).fetchall()
|
||||
rules = conn.execute("SELECT * FROM rss_rules WHERE profile_id=? ORDER BY name", (pid,)).fetchall()
|
||||
history = conn.execute("SELECT * FROM rss_history WHERE profile_id=? ORDER BY id DESC LIMIT 80", (pid,)).fetchall()
|
||||
return ok({"feeds": feeds, "rules": rules, "history": history})
|
||||
|
||||
|
||||
|
||||
@bp.post("/rss/feeds")
|
||||
def rss_feed_save():
|
||||
profile = preferences.active_profile()
|
||||
profile = _active_profile_or_400()
|
||||
if not profile:
|
||||
return jsonify({"ok": False, "error": "No profile"}), 400
|
||||
data = request.get_json(silent=True) or {}
|
||||
now = utcnow()
|
||||
feed_id = data.get("id")
|
||||
pid = int(profile["id"])
|
||||
with connect() as conn:
|
||||
if feed_id:
|
||||
conn.execute("UPDATE rss_feeds SET name=?,url=?,enabled=?,interval_minutes=?,updated_at=? WHERE id=? AND user_id=?", (data.get("name") or "RSS", data.get("url") or "", 1 if data.get("enabled", True) else 0, int(data.get("interval_minutes") or 30), now, feed_id, default_user_id()))
|
||||
conn.execute(
|
||||
"UPDATE rss_feeds SET name=?,url=?,enabled=?,interval_minutes=?,updated_at=? WHERE id=? AND profile_id=?",
|
||||
(data.get("name") or "RSS", data.get("url") or "", 1 if data.get("enabled", True) else 0, int(data.get("interval_minutes") or 30), now, feed_id, pid),
|
||||
)
|
||||
else:
|
||||
conn.execute("INSERT INTO rss_feeds(user_id,profile_id,name,url,enabled,interval_minutes,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?)", (default_user_id(), profile["id"] if profile else None, data.get("name") or "RSS", data.get("url") or "", 1 if data.get("enabled", True) else 0, int(data.get("interval_minutes") or 30), now, now))
|
||||
conn.execute(
|
||||
"INSERT INTO rss_feeds(profile_id,name,url,enabled,interval_minutes,created_at,updated_at) VALUES(?,?,?,?,?,?,?)",
|
||||
(pid, data.get("name") or "RSS", data.get("url") or "", 1 if data.get("enabled", True) else 0, int(data.get("interval_minutes") or 30), now, now),
|
||||
)
|
||||
return rss_list()
|
||||
|
||||
|
||||
|
||||
@bp.delete("/rss/feeds/<int:feed_id>")
|
||||
def rss_feed_delete(feed_id: int):
|
||||
profile = _active_profile_or_400()
|
||||
if not profile:
|
||||
return jsonify({"ok": False, "error": "No profile"}), 400
|
||||
with connect() as conn:
|
||||
conn.execute("DELETE FROM rss_feeds WHERE id=? AND user_id=?", (feed_id, default_user_id()))
|
||||
conn.execute("DELETE FROM rss_feeds WHERE id=? AND profile_id=?", (feed_id, int(profile["id"])))
|
||||
return rss_list()
|
||||
|
||||
|
||||
|
||||
@bp.post("/rss/rules")
|
||||
def rss_rule_save():
|
||||
profile = preferences.active_profile()
|
||||
profile = _active_profile_or_400()
|
||||
if not profile:
|
||||
return jsonify({"ok": False, "error": "No profile"}), 400
|
||||
data = request.get_json(silent=True) or {}
|
||||
now = utcnow()
|
||||
rule_id = data.get("id")
|
||||
values = (data.get("name") or "Rule", data.get("pattern") or ".*", data.get("exclude_pattern") or "", int(data.get("min_size_mb") or 0), int(data.get("max_size_mb") or 0), data.get("category") or "", data.get("quality") or "", data.get("season") or None, data.get("episode") or None, data.get("save_path") or active_default_download_path(profile), data.get("label") or "", 1 if data.get("start", True) else 0, 1 if data.get("enabled", True) else 0, now)
|
||||
pid = int(profile["id"])
|
||||
values = (
|
||||
data.get("name") or "Rule",
|
||||
data.get("pattern") or ".*",
|
||||
data.get("exclude_pattern") or "",
|
||||
int(data.get("min_size_mb") or 0),
|
||||
int(data.get("max_size_mb") or 0),
|
||||
data.get("category") or "",
|
||||
data.get("quality") or "",
|
||||
data.get("season") or None,
|
||||
data.get("episode") or None,
|
||||
data.get("save_path") or active_default_download_path(profile),
|
||||
data.get("label") or "",
|
||||
1 if data.get("start", True) else 0,
|
||||
1 if data.get("enabled", True) else 0,
|
||||
now,
|
||||
)
|
||||
with connect() as conn:
|
||||
if rule_id:
|
||||
conn.execute("UPDATE rss_rules SET name=?,pattern=?,exclude_pattern=?,min_size_mb=?,max_size_mb=?,category=?,quality=?,season=?,episode=?,save_path=?,label=?,start=?,enabled=?,updated_at=? WHERE id=? AND user_id=?", (*values, rule_id, default_user_id()))
|
||||
conn.execute(
|
||||
"UPDATE rss_rules SET name=?,pattern=?,exclude_pattern=?,min_size_mb=?,max_size_mb=?,category=?,quality=?,season=?,episode=?,save_path=?,label=?,start=?,enabled=?,updated_at=? WHERE id=? AND profile_id=?",
|
||||
(*values, rule_id, pid),
|
||||
)
|
||||
else:
|
||||
conn.execute("INSERT INTO rss_rules(user_id,profile_id,name,pattern,exclude_pattern,min_size_mb,max_size_mb,category,quality,season,episode,save_path,label,start,enabled,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", (default_user_id(), profile["id"] if profile else None, *values, now))
|
||||
conn.execute(
|
||||
"INSERT INTO rss_rules(profile_id,name,pattern,exclude_pattern,min_size_mb,max_size_mb,category,quality,season,episode,save_path,label,start,enabled,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
|
||||
(pid, *values, now),
|
||||
)
|
||||
return rss_list()
|
||||
|
||||
|
||||
|
||||
@bp.delete("/rss/rules/<int:rule_id>")
|
||||
def rss_rule_delete(rule_id: int):
|
||||
profile = _active_profile_or_400()
|
||||
if not profile:
|
||||
return jsonify({"ok": False, "error": "No profile"}), 400
|
||||
with connect() as conn:
|
||||
conn.execute("DELETE FROM rss_rules WHERE id=? AND user_id=?", (rule_id, default_user_id()))
|
||||
conn.execute("DELETE FROM rss_rules WHERE id=? AND profile_id=?", (rule_id, int(profile["id"])))
|
||||
return rss_list()
|
||||
|
||||
|
||||
|
||||
@bp.post("/rss/rules/test")
|
||||
def rss_rule_test():
|
||||
data = request.get_json(silent=True) or {}
|
||||
@@ -71,12 +113,9 @@ def rss_rule_test():
|
||||
return jsonify({"ok": False, "error": str(exc)}), 400
|
||||
|
||||
|
||||
|
||||
@bp.post("/rss/check")
|
||||
def rss_check():
|
||||
profile = preferences.active_profile()
|
||||
profile = request_profile()
|
||||
if not profile:
|
||||
return jsonify({"ok": False, "error": "No profile"}), 400
|
||||
return ok(rss_service.check(profile, default_user_id(), only_due=False))
|
||||
|
||||
|
||||
return ok(rss_service.check(profile, only_due=False))
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from ._shared import *
|
||||
|
||||
|
||||
@bp.get('/smart-queue')
|
||||
def smart_queue_get():
|
||||
from ..services import smart_queue
|
||||
profile = preferences.active_profile()
|
||||
profile = request_profile()
|
||||
if not profile:
|
||||
return ok({'settings': {}, 'exclusions': [], 'error': 'No profile'})
|
||||
try:
|
||||
@@ -14,22 +14,21 @@ def smart_queue_get():
|
||||
exclusions = smart_queue.list_exclusions(profile['id'])
|
||||
history = smart_queue.list_history(profile['id'], limit=history_limit)
|
||||
history_total = smart_queue.count_history(profile['id'])
|
||||
return ok({'settings': settings, 'exclusions': exclusions, 'history': history, 'history_total': history_total, 'cooldown_remaining_seconds': smart_queue.cooldown_remaining(settings), 'refill_remaining_seconds': smart_queue.refill_remaining(settings)})
|
||||
return ok({'settings': settings, 'exclusions': exclusions, 'history': history, 'history_total': history_total, 'cooldown_remaining_seconds': smart_queue.cooldown_remaining(settings), 'refill_remaining_seconds': smart_queue.refill_remaining(settings), 'surge_refill_remaining_seconds': smart_queue.surge_refill_remaining(settings)})
|
||||
except Exception as exc:
|
||||
return jsonify({'ok': False, 'error': str(exc), 'settings': {}, 'exclusions': []})
|
||||
|
||||
|
||||
|
||||
@bp.post('/smart-queue')
|
||||
def smart_queue_save():
|
||||
from ..services import smart_queue
|
||||
profile = preferences.active_profile()
|
||||
profile = request_profile()
|
||||
if not profile:
|
||||
return ok({'settings': {}, 'error': 'No profile'})
|
||||
try:
|
||||
payload = request.get_json(silent=True) or {}
|
||||
settings = smart_queue.save_settings(profile['id'], payload)
|
||||
return ok({'settings': settings, 'cooldown_remaining_seconds': smart_queue.cooldown_remaining(settings), 'refill_remaining_seconds': smart_queue.refill_remaining(settings)})
|
||||
return ok({'settings': settings, 'cooldown_remaining_seconds': smart_queue.cooldown_remaining(settings), 'refill_remaining_seconds': smart_queue.refill_remaining(settings), 'surge_refill_remaining_seconds': smart_queue.surge_refill_remaining(settings)})
|
||||
except Exception as exc:
|
||||
return jsonify({'ok': False, 'error': str(exc)})
|
||||
|
||||
@@ -37,7 +36,7 @@ def smart_queue_save():
|
||||
|
||||
@bp.post('/smart-queue/check')
|
||||
def smart_queue_check():
|
||||
profile = preferences.active_profile()
|
||||
profile = request_profile()
|
||||
if not profile:
|
||||
return ok({'result': {'ok': False, 'error': 'No profile'}})
|
||||
if str(request.args.get('sync') or '').lower() in {'1', 'true', 'yes'}:
|
||||
@@ -66,7 +65,7 @@ def smart_queue_check():
|
||||
@bp.post('/smart-queue/exclusion')
|
||||
def smart_queue_exclusion():
|
||||
from ..services import smart_queue
|
||||
profile = preferences.active_profile()
|
||||
profile = request_profile()
|
||||
if not profile:
|
||||
return jsonify({'ok': False, 'error': 'No profile'}), 400
|
||||
data = request.get_json(silent=True) or {}
|
||||
@@ -79,7 +78,7 @@ def smart_queue_exclusion():
|
||||
@bp.delete('/smart-queue/history')
|
||||
def smart_queue_history_clear():
|
||||
from ..services import smart_queue
|
||||
profile = preferences.active_profile()
|
||||
profile = request_profile()
|
||||
if not profile:
|
||||
return jsonify({'ok': False, 'error': 'No profile'}), 400
|
||||
try:
|
||||
|
||||
+135
-24
@@ -1,11 +1,12 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from ._shared import *
|
||||
import posixpath
|
||||
from ..services import operation_logs
|
||||
from ..services.frontend_assets import static_hash
|
||||
|
||||
@bp.get("/system/disk")
|
||||
def system_disk():
|
||||
profile = preferences.active_profile()
|
||||
profile = request_profile()
|
||||
if not profile:
|
||||
return jsonify({"ok": False, "error": "No profile"})
|
||||
try:
|
||||
@@ -17,7 +18,7 @@ def system_disk():
|
||||
|
||||
@bp.get("/system/status")
|
||||
def system_status():
|
||||
profile = preferences.active_profile()
|
||||
profile = request_profile()
|
||||
if not profile:
|
||||
return jsonify({"ok": False, "error": "No profile"})
|
||||
try:
|
||||
@@ -25,7 +26,6 @@ def system_status():
|
||||
status["disk"] = _user_disk_status(profile)
|
||||
if bool(profile.get("is_remote")):
|
||||
try:
|
||||
# Note: Remote profiles must report CPU/RAM from the rTorrent host, not hide the footer stats.
|
||||
usage = rtorrent.remote_system_usage(profile)
|
||||
status.update(usage)
|
||||
status["usage_available"] = True
|
||||
@@ -38,7 +38,6 @@ def system_status():
|
||||
status["ram"] = psutil.virtual_memory().percent
|
||||
status["usage_source"] = "local"
|
||||
status["usage_available"] = True
|
||||
# Note: REST status returns the latest records without waiting for the next Socket.IO message.
|
||||
status["speed_peaks"] = speed_peaks.record(profile["id"], status.get("down_rate", 0), status.get("up_rate", 0))
|
||||
return ok({"status": status})
|
||||
except Exception as exc:
|
||||
@@ -46,6 +45,13 @@ def system_status():
|
||||
|
||||
|
||||
|
||||
@bp.get("/static_hash")
|
||||
def static_hash_get():
|
||||
# Note: This returns the startup-computed JS/CSS version without scanning files per request.
|
||||
value = static_hash()
|
||||
return ok({"static_hash": value, "version": value})
|
||||
|
||||
|
||||
@bp.get("/health")
|
||||
def health_check():
|
||||
# Note: Lightweight health endpoint avoids rTorrent calls, making it safe for frequent monitoring.
|
||||
@@ -71,13 +77,14 @@ def health_check_nagios():
|
||||
@bp.get("/app/status")
|
||||
def app_status():
|
||||
started = time.perf_counter()
|
||||
profile = preferences.active_profile()
|
||||
profile = request_profile()
|
||||
proc = psutil.Process(os.getpid())
|
||||
try:
|
||||
jobs = list_jobs(10, 0)
|
||||
jobs_total = jobs.get("total", 0)
|
||||
except Exception:
|
||||
jobs_total = 0
|
||||
include_cleanup = str(request.args.get("cleanup") or "").lower() in {"1", "true", "yes", "on"}
|
||||
status = {
|
||||
"pytorrent": {
|
||||
"ok": True,
|
||||
@@ -95,10 +102,11 @@ def app_status():
|
||||
"open_files": _safe_len(proc.open_files) if hasattr(proc, "open_files") else None,
|
||||
"connections": _safe_len(lambda: proc.net_connections(kind="inet")) if hasattr(proc, "net_connections") else None,
|
||||
},
|
||||
"cleanup": cleanup_summary(),
|
||||
"profile": profile,
|
||||
"scgi": None,
|
||||
}
|
||||
if include_cleanup:
|
||||
status["cleanup"] = cleanup_summary()
|
||||
if profile:
|
||||
try:
|
||||
status["scgi"] = rtorrent.scgi_diagnostics(profile)
|
||||
@@ -109,11 +117,22 @@ def app_status():
|
||||
status["speed_peaks"] = speed_peaks.current(profile["id"])
|
||||
except Exception as exc:
|
||||
status["speed_peaks"] = {"error": str(exc)}
|
||||
try:
|
||||
# Note: App status carries poller settings and runtime so the panel still renders when the separate poller endpoint is unavailable.
|
||||
poller_settings = poller_control.get_settings(int(profile["id"]))
|
||||
status["poller"] = {"settings": poller_settings, "runtime": poller_control.snapshot(int(profile["id"]), poller_settings)}
|
||||
except Exception as exc:
|
||||
status["poller"] = {"settings": {}, "runtime": {}, "error": str(exc)}
|
||||
try:
|
||||
prefs = preferences.get_preferences()
|
||||
status["port_check"] = {"status": "disabled", "enabled": False} if not bool((prefs or {}).get("port_check_enabled")) else port_check_status(force=False)
|
||||
except Exception as exc:
|
||||
status["port_check"] = {"status": "error", "error": str(exc)}
|
||||
try:
|
||||
from ..services import background_cache_warmup
|
||||
status["background_cache_warmup"] = background_cache_warmup.status()
|
||||
except Exception as exc:
|
||||
status["background_cache_warmup"] = {"started": False, "error": str(exc)}
|
||||
status["api_ms"] = round((time.perf_counter() - started) * 1000, 2)
|
||||
return ok({"status": status})
|
||||
|
||||
@@ -162,7 +181,7 @@ def cleanup_status():
|
||||
|
||||
@bp.post("/cleanup/cache")
|
||||
def cleanup_profile_cache():
|
||||
profile = preferences.active_profile()
|
||||
profile = request_profile()
|
||||
if not profile:
|
||||
return jsonify({"ok": False, "error": "No profile"}), 400
|
||||
profile_id = int(profile["id"])
|
||||
@@ -195,15 +214,31 @@ def cleanup_jobs():
|
||||
return ok({"deleted": deleted, "cleanup": cleanup_summary()})
|
||||
|
||||
|
||||
@bp.post("/cleanup/database/vacuum")
|
||||
def cleanup_database_vacuum():
|
||||
require_admin()
|
||||
data = request.get_json(silent=True) or {}
|
||||
try:
|
||||
result = database_maintenance.vacuum_database(force=bool(data.get("force")))
|
||||
return ok({"vacuum": result, "cleanup": cleanup_summary()})
|
||||
except Exception as exc:
|
||||
return jsonify({"ok": False, "error": str(exc), "cleanup": cleanup_summary()}), 400
|
||||
|
||||
|
||||
|
||||
@bp.post("/cleanup/smart-queue")
|
||||
def cleanup_smart_queue():
|
||||
profile = request_profile()
|
||||
if not profile:
|
||||
return jsonify({"ok": False, "error": "No profile"}), 400
|
||||
profile_id = int(profile["id"])
|
||||
with connect() as conn:
|
||||
exists = conn.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='smart_queue_history'").fetchone()
|
||||
if not exists:
|
||||
deleted = 0
|
||||
else:
|
||||
cur = conn.execute("DELETE FROM smart_queue_history")
|
||||
# Note: Cleanup is limited to the active profile so read/write permissions never affect other profiles.
|
||||
cur = conn.execute("DELETE FROM smart_queue_history WHERE profile_id=?", (profile_id,))
|
||||
deleted = int(cur.rowcount or 0)
|
||||
return ok({"deleted": deleted, "cleanup": cleanup_summary()})
|
||||
|
||||
@@ -211,7 +246,7 @@ def cleanup_smart_queue():
|
||||
|
||||
@bp.post("/cleanup/operation-logs")
|
||||
def cleanup_operation_logs():
|
||||
profile = preferences.active_profile()
|
||||
profile = request_profile()
|
||||
if not profile:
|
||||
return jsonify({"ok": False, "error": "No profile"}), 400
|
||||
# Note: Operation log cleanup removes only profile-scoped log entries; torrents, jobs and settings stay intact.
|
||||
@@ -222,7 +257,7 @@ def cleanup_operation_logs():
|
||||
|
||||
@bp.post("/cleanup/planner")
|
||||
def cleanup_planner():
|
||||
profile = preferences.active_profile()
|
||||
profile = request_profile()
|
||||
if not profile:
|
||||
return jsonify({"ok": False, "error": "No profile"}), 400
|
||||
# Note: Planner cleanup removes only the active profile action history, not saved Planner settings.
|
||||
@@ -232,22 +267,38 @@ def cleanup_planner():
|
||||
|
||||
@bp.post("/cleanup/automations")
|
||||
def cleanup_automations():
|
||||
profile = request_profile()
|
||||
if not profile:
|
||||
return jsonify({"ok": False, "error": "No profile"}), 400
|
||||
profile_id = int(profile["id"])
|
||||
with connect() as conn:
|
||||
exists = conn.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='automation_history'").fetchone()
|
||||
if not exists:
|
||||
deleted = 0
|
||||
else:
|
||||
# Note: Cleanup panel removes only automation logs, not saved automation rules.
|
||||
cur = conn.execute("DELETE FROM automation_history")
|
||||
# Note: Automation history is profile-scoped and can include rules owned by multiple users.
|
||||
cur = conn.execute("DELETE FROM automation_history WHERE profile_id=?", (profile_id,))
|
||||
deleted = int(cur.rowcount or 0)
|
||||
return ok({"deleted": deleted, "cleanup": cleanup_summary()})
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@bp.post("/cleanup/poller-diagnostics")
|
||||
def cleanup_poller_diagnostics():
|
||||
profile = request_profile()
|
||||
if not profile:
|
||||
return jsonify({"ok": False, "error": "No profile"}), 400
|
||||
profile_id = int(profile["id"])
|
||||
# Note: This cleanup clears only in-memory poller diagnostics; polling, settings and torrent state are preserved.
|
||||
runtime = poller_control.reset_runtime_stats(profile_id)
|
||||
return ok({"deleted": {"poller_runtime_counters": 1}, "runtime": runtime, "cleanup": cleanup_summary()})
|
||||
|
||||
@bp.post("/cleanup/all")
|
||||
def cleanup_all():
|
||||
deleted_jobs = clear_jobs()
|
||||
active_profile = preferences.active_profile()
|
||||
active_profile = request_profile()
|
||||
active_profile_id = int(active_profile["id"]) if active_profile else 0
|
||||
deleted_logs = operation_logs.clear(active_profile_id) if active_profile_id else 0
|
||||
deleted_planner = download_planner.clear_history(active_profile_id) if active_profile_id else 0
|
||||
@@ -256,13 +307,14 @@ def cleanup_all():
|
||||
if not exists:
|
||||
deleted_smart = 0
|
||||
else:
|
||||
cur = conn.execute("DELETE FROM smart_queue_history")
|
||||
cur = conn.execute("DELETE FROM smart_queue_history WHERE profile_id=?", (active_profile_id,))
|
||||
deleted_smart = int(cur.rowcount or 0)
|
||||
exists_auto = conn.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='automation_history'").fetchone()
|
||||
if not exists_auto:
|
||||
deleted_auto = 0
|
||||
else:
|
||||
cur = conn.execute("DELETE FROM automation_history")
|
||||
# Note: Full cleanup clears automation history for the active profile, regardless of rule owner.
|
||||
cur = conn.execute("DELETE FROM automation_history WHERE profile_id=?", (active_profile_id,))
|
||||
deleted_auto = int(cur.rowcount or 0)
|
||||
return ok({"deleted": {"jobs": deleted_jobs, "smart_queue_history": deleted_smart, "operation_logs": deleted_logs, "planner_history": deleted_planner, "automation_history": deleted_auto}, "cleanup": cleanup_summary()})
|
||||
|
||||
@@ -294,9 +346,35 @@ def jobs_retry(job_id: str):
|
||||
|
||||
|
||||
|
||||
def _remote_path_contains(base: str, candidate: str) -> bool:
|
||||
base = posixpath.normpath(str(base or "").rstrip("/") or "/")
|
||||
candidate = posixpath.normpath(str(candidate or "").rstrip("/") or "/")
|
||||
return candidate == base or candidate.startswith(base.rstrip("/") + "/")
|
||||
|
||||
|
||||
def _path_has_cached_torrents(profile_id: int, path: str) -> bool:
|
||||
# Note: The cache check prevents renaming folders that are currently known as torrent locations.
|
||||
if not str(path or "").strip():
|
||||
return False
|
||||
return any(_remote_path_contains(path, item.get("path") or "") for item in torrent_cache.snapshot(profile_id))
|
||||
|
||||
|
||||
def _annotate_path_directories(profile: dict, payload: dict) -> dict:
|
||||
dirs = payload.get("dirs") or []
|
||||
for item in dirs:
|
||||
item_path = item.get("path") or ""
|
||||
has_torrents = _path_has_cached_torrents(int(profile.get("id") or 0), item_path)
|
||||
is_empty = bool(item.get("empty"))
|
||||
item["has_torrents"] = has_torrents
|
||||
item["can_rename"] = is_empty and not has_torrents
|
||||
# Note: The picker exposes a short reason so disabled rename buttons explain the safety rule.
|
||||
item["rename_reason"] = "Rename folder" if item["can_rename"] else ("Folder contains a known torrent path" if has_torrents else "Only empty folders can be renamed")
|
||||
return payload
|
||||
|
||||
|
||||
@bp.get("/path/default")
|
||||
def path_default():
|
||||
profile = preferences.active_profile()
|
||||
profile = request_profile()
|
||||
if not profile:
|
||||
return jsonify({"ok": False, "error": "No profile"}), 400
|
||||
try:
|
||||
@@ -308,12 +386,45 @@ def path_default():
|
||||
|
||||
@bp.get("/path/browse")
|
||||
def path_browse():
|
||||
profile = preferences.active_profile()
|
||||
profile = request_profile()
|
||||
if not profile:
|
||||
return jsonify({"ok": False, "error": "No profile"}), 400
|
||||
base = request.args.get("path") or ""
|
||||
try:
|
||||
return ok(rtorrent.browse_path(profile, base))
|
||||
return ok(_annotate_path_directories(profile, rtorrent.browse_path(profile, base)))
|
||||
except Exception as exc:
|
||||
return jsonify({"ok": False, "error": str(exc)}), 400
|
||||
|
||||
|
||||
@bp.post("/path/directories")
|
||||
def path_directory_create():
|
||||
profile = request_profile()
|
||||
if not profile:
|
||||
return jsonify({"ok": False, "error": "No profile"}), 400
|
||||
require_profile_write(profile.get("id"))
|
||||
data = request.get_json(silent=True) or {}
|
||||
try:
|
||||
# Note: This endpoint only creates an empty directory and does not alter any torrent state.
|
||||
result = rtorrent.create_directory(profile, data.get("parent") or "", data.get("name") or "")
|
||||
return ok({"directory": result})
|
||||
except Exception as exc:
|
||||
return jsonify({"ok": False, "error": str(exc)}), 400
|
||||
|
||||
|
||||
@bp.post("/path/directories/rename")
|
||||
def path_directory_rename():
|
||||
profile = request_profile()
|
||||
if not profile:
|
||||
return jsonify({"ok": False, "error": "No profile"}), 400
|
||||
require_profile_write(profile.get("id"))
|
||||
data = request.get_json(silent=True) or {}
|
||||
path = str(data.get("path") or "").strip()
|
||||
if _path_has_cached_torrents(int(profile.get("id") or 0), path):
|
||||
return jsonify({"ok": False, "error": "Directory contains a known torrent path"}), 400
|
||||
try:
|
||||
# Note: The service also verifies that the remote directory is empty before renaming.
|
||||
result = rtorrent.rename_empty_directory(profile, path, data.get("new_name") or "")
|
||||
return ok({"directory": result})
|
||||
except Exception as exc:
|
||||
return jsonify({"ok": False, "error": str(exc)}), 400
|
||||
|
||||
@@ -321,7 +432,7 @@ def path_browse():
|
||||
|
||||
@bp.get('/rtorrent-config')
|
||||
def rtorrent_config_get():
|
||||
profile = preferences.active_profile()
|
||||
profile = request_profile()
|
||||
if not profile:
|
||||
return jsonify({'ok': False, 'error': 'No profile'}), 400
|
||||
try:
|
||||
@@ -332,7 +443,7 @@ def rtorrent_config_get():
|
||||
|
||||
@bp.post('/rtorrent-config')
|
||||
def rtorrent_config_save():
|
||||
profile = preferences.active_profile()
|
||||
profile = request_profile()
|
||||
if not profile:
|
||||
return jsonify({'ok': False, 'error': 'No profile'}), 400
|
||||
try:
|
||||
@@ -349,7 +460,7 @@ def rtorrent_config_save():
|
||||
|
||||
@bp.post('/rtorrent-config/reset')
|
||||
def rtorrent_config_reset():
|
||||
profile = preferences.active_profile()
|
||||
profile = request_profile()
|
||||
if not profile:
|
||||
return jsonify({'ok': False, 'error': 'No profile'}), 400
|
||||
try:
|
||||
@@ -360,7 +471,7 @@ def rtorrent_config_reset():
|
||||
|
||||
@bp.post('/rtorrent-config/generate')
|
||||
def rtorrent_config_generate():
|
||||
profile = preferences.active_profile()
|
||||
profile = request_profile()
|
||||
if not profile:
|
||||
return jsonify({'ok': False, 'error': 'No profile'}), 400
|
||||
try:
|
||||
@@ -373,7 +484,7 @@ def rtorrent_config_generate():
|
||||
@bp.get('/traffic/history')
|
||||
def traffic_history_get():
|
||||
from ..services import traffic_history
|
||||
profile = preferences.active_profile()
|
||||
profile = request_profile()
|
||||
if not profile:
|
||||
return ok({'history': {'range': request.args.get('range') or '7d', 'bucket': 'day', 'rows': []}})
|
||||
range_name = request.args.get('range') or '7d'
|
||||
|
||||
+128
-29
@@ -1,12 +1,12 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from ._shared import *
|
||||
from ..services import torrent_creator
|
||||
from ..services import profile_speed_limits
|
||||
from ..services import pdf_preview_links, torrent_creator
|
||||
from ..services.reverse_dns import attach_reverse_dns
|
||||
|
||||
@bp.get("/torrents")
|
||||
def torrents():
|
||||
profile = preferences.active_profile()
|
||||
profile = request_profile()
|
||||
if not profile:
|
||||
return ok({"torrents": [], "summary": cached_summary(0, []), "error": "No rTorrent profile"})
|
||||
rows = torrent_cache.snapshot(profile["id"])
|
||||
@@ -19,10 +19,9 @@ def torrents():
|
||||
|
||||
|
||||
|
||||
|
||||
@bp.get("/trackers/summary")
|
||||
def trackers_summary():
|
||||
profile = preferences.active_profile()
|
||||
profile = request_profile()
|
||||
if not profile:
|
||||
return ok({"summary": {"hashes": {}, "trackers": [], "errors": [], "scanned": 0, "pending": 0}, "error": "No profile"})
|
||||
try:
|
||||
@@ -77,7 +76,7 @@ def tracker_favicon_query():
|
||||
|
||||
@bp.get("/torrent-stats")
|
||||
def torrent_stats_get():
|
||||
profile = preferences.active_profile()
|
||||
profile = request_profile()
|
||||
if not profile:
|
||||
return ok({"stats": {}, "error": "No profile"})
|
||||
force = str(request.args.get("force") or "").lower() in {"1", "true", "yes"}
|
||||
@@ -91,16 +90,39 @@ def torrent_stats_get():
|
||||
|
||||
@bp.get("/torrents/<torrent_hash>/files")
|
||||
def torrent_files(torrent_hash: str):
|
||||
profile = preferences.active_profile()
|
||||
profile = request_profile()
|
||||
if not profile:
|
||||
return jsonify({"ok": False, "error": "No profile"}), 400
|
||||
return ok({"files": rtorrent.torrent_files(profile, torrent_hash)})
|
||||
|
||||
|
||||
|
||||
@bp.get("/torrents/<torrent_hash>/files/<int:file_index>/mediainfo")
|
||||
def torrent_file_media_info(torrent_hash: str, file_index: int):
|
||||
profile = request_profile()
|
||||
if not profile:
|
||||
return jsonify({"ok": False, "error": "No profile"}), 400
|
||||
try:
|
||||
# Note: The route is additive and keeps all existing file endpoints unchanged.
|
||||
media_info = rtorrent.torrent_file_media_info(profile, torrent_hash, file_index)
|
||||
if media_info.get("kind") == "pdf":
|
||||
link = pdf_preview_links.create_pdf_preview_link(
|
||||
torrent_hash,
|
||||
file_index,
|
||||
int(profile.get("id") or 0),
|
||||
int(default_user_id() or 0),
|
||||
)
|
||||
# Note: The frontend receives an in-app temporary URL instead of exposing the API download endpoint in the new-tab action.
|
||||
media_info["preview_url"] = url_for("main.pdf_preview", token=link["token"])
|
||||
media_info["preview_expires_in"] = link["expires_in"]
|
||||
return ok({"media_info": media_info})
|
||||
except Exception as exc:
|
||||
return jsonify({"ok": False, "error": str(exc)}), 400
|
||||
|
||||
|
||||
@bp.post("/torrents/<torrent_hash>/files/priority")
|
||||
def torrent_file_priority(torrent_hash: str):
|
||||
profile = preferences.active_profile()
|
||||
profile = request_profile()
|
||||
if not profile:
|
||||
return jsonify({"ok": False, "error": "No profile"}), 400
|
||||
data = request.get_json(silent=True) or {}
|
||||
@@ -115,7 +137,7 @@ def torrent_file_priority(torrent_hash: str):
|
||||
|
||||
@bp.get("/torrents/<torrent_hash>/files/tree")
|
||||
def torrent_file_tree(torrent_hash: str):
|
||||
profile = preferences.active_profile()
|
||||
profile = request_profile()
|
||||
if not profile:
|
||||
return jsonify({"ok": False, "error": "No profile"}), 400
|
||||
return ok({"tree": rtorrent.torrent_file_tree(profile, torrent_hash)})
|
||||
@@ -124,7 +146,7 @@ def torrent_file_tree(torrent_hash: str):
|
||||
|
||||
@bp.post("/torrents/<torrent_hash>/files/folder-priority")
|
||||
def torrent_folder_priority(torrent_hash: str):
|
||||
profile = preferences.active_profile()
|
||||
profile = request_profile()
|
||||
if not profile:
|
||||
return jsonify({"ok": False, "error": "No profile"}), 400
|
||||
data = request.get_json(silent=True) or {}
|
||||
@@ -133,11 +155,12 @@ def torrent_folder_priority(torrent_hash: str):
|
||||
return ok(result), status
|
||||
|
||||
|
||||
def _attachment_headers(download_name: str, content_type: str = "application/octet-stream") -> dict:
|
||||
def _attachment_headers(download_name: str, content_type: str = "application/octet-stream", disposition: str = "attachment") -> dict:
|
||||
safe = Path(download_name or "download.bin").name or "download.bin"
|
||||
safe_disposition = "inline" if disposition == "inline" else "attachment"
|
||||
return {
|
||||
"Content-Type": content_type,
|
||||
"Content-Disposition": f"attachment; filename*=UTF-8''{quote(safe)}",
|
||||
"Content-Disposition": f"{safe_disposition}; filename*=UTF-8''{quote(safe)}",
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
}
|
||||
|
||||
@@ -186,15 +209,89 @@ def _send_staged_file(profile: dict, path: str, download_name: str, local: bool
|
||||
|
||||
|
||||
|
||||
|
||||
@bp.post("/torrents/<torrent_hash>/files/<int:file_index>/download-link")
|
||||
def torrent_file_download_link(torrent_hash: str, file_index: int):
|
||||
profile = request_profile()
|
||||
if not profile:
|
||||
return jsonify({"ok": False, "error": "No profile"}), 400
|
||||
try:
|
||||
# Note: The API validates the file selection before returning a short-lived in-app /download URL to the UI.
|
||||
rtorrent.torrent_download_file_info(profile, torrent_hash, file_index)
|
||||
link = pdf_preview_links.create_file_download_link(torrent_hash, file_index, int(profile.get("id") or 0), int(default_user_id() or 0))
|
||||
return ok({"url": url_for("main.temporary_download", token=link["token"]), "expires_in": link["expires_in"]})
|
||||
except Exception as exc:
|
||||
return jsonify({"ok": False, "error": str(exc)}), 400
|
||||
|
||||
|
||||
@bp.post("/torrents/<torrent_hash>/files/download-link")
|
||||
def torrent_file_download_link_from_body(torrent_hash: str):
|
||||
data = request.get_json(silent=True) or {}
|
||||
try:
|
||||
file_index = int(data.get("file_index"))
|
||||
except Exception:
|
||||
return jsonify({"ok": False, "error": "file_index is required"}), 400
|
||||
return torrent_file_download_link(torrent_hash, file_index)
|
||||
|
||||
|
||||
@bp.post("/torrents/<torrent_hash>/files/download.zip/link")
|
||||
def torrent_files_download_zip_link(torrent_hash: str):
|
||||
profile = request_profile()
|
||||
if not profile:
|
||||
return jsonify({"ok": False, "error": "No profile"}), 400
|
||||
data = request.get_json(silent=True) or {}
|
||||
try:
|
||||
indexes = data.get("indexes") or None
|
||||
# Note: ZIP link creation validates the requested files through the same service used by the direct download endpoint.
|
||||
rtorrent.torrent_download_zip_items(profile, torrent_hash, indexes)
|
||||
link = pdf_preview_links.create_file_zip_download_link(torrent_hash, indexes, int(profile.get("id") or 0), int(default_user_id() or 0))
|
||||
return ok({"url": url_for("main.temporary_download", token=link["token"]), "expires_in": link["expires_in"]})
|
||||
except Exception as exc:
|
||||
return jsonify({"ok": False, "error": str(exc)}), 400
|
||||
|
||||
|
||||
@bp.get("/torrents/<torrent_hash>/torrent-file/link")
|
||||
def torrent_file_export_link(torrent_hash: str):
|
||||
profile = request_profile()
|
||||
if not profile:
|
||||
return jsonify({"ok": False, "error": "No profile"}), 400
|
||||
try:
|
||||
# Note: Create only a short-lived link here; the actual .torrent export runs once when the browser opens /download/<token>.
|
||||
link = pdf_preview_links.create_torrent_file_download_link(torrent_hash, int(profile.get("id") or 0), int(default_user_id() or 0))
|
||||
return ok({"url": url_for("main.temporary_download", token=link["token"]), "expires_in": link["expires_in"]})
|
||||
except Exception as exc:
|
||||
return jsonify({"ok": False, "error": str(exc)}), 400
|
||||
|
||||
|
||||
@bp.post("/torrents/torrent-files.zip/link")
|
||||
def torrent_files_export_zip_link():
|
||||
profile = request_profile()
|
||||
if not profile:
|
||||
return jsonify({"ok": False, "error": "No profile"}), 400
|
||||
data = request.get_json(silent=True) or {}
|
||||
hashes = [str(h) for h in (data.get("hashes") or []) if str(h).strip()]
|
||||
if not hashes:
|
||||
return jsonify({"ok": False, "error": "No torrents selected"}), 400
|
||||
try:
|
||||
# Note: Store only the selected hashes in the temporary token; exporting each .torrent now happens once during the real ZIP download.
|
||||
link = pdf_preview_links.create_torrent_files_zip_download_link(hashes, int(profile.get("id") or 0), int(default_user_id() or 0))
|
||||
return ok({"url": url_for("main.temporary_download", token=link["token"]), "expires_in": link["expires_in"]})
|
||||
except Exception as exc:
|
||||
return jsonify({"ok": False, "error": str(exc)}), 400
|
||||
|
||||
|
||||
@bp.get("/torrents/<torrent_hash>/files/<int:file_index>/download")
|
||||
def torrent_file_download(torrent_hash: str, file_index: int):
|
||||
profile = preferences.active_profile()
|
||||
profile = request_profile()
|
||||
if not profile:
|
||||
return jsonify({"ok": False, "error": "No profile"}), 400
|
||||
try:
|
||||
item = rtorrent.torrent_download_file_info(profile, torrent_hash, file_index)
|
||||
size = int(item.get("size") or 0)
|
||||
headers = _attachment_headers(item.get("download_name") or "file.bin")
|
||||
download_name = item.get("download_name") or "file.bin"
|
||||
inline_pdf = str(request.args.get("disposition") or "").lower() == "inline" and Path(download_name).suffix.lower() == ".pdf"
|
||||
# Note: Inline mode is limited to PDFs so the existing download behavior remains unchanged for every other file type.
|
||||
headers = _attachment_headers(download_name, "application/pdf" if inline_pdf else "application/octet-stream", "inline" if inline_pdf else "attachment")
|
||||
if size > 0:
|
||||
headers["Content-Length"] = str(size)
|
||||
def generate():
|
||||
@@ -278,7 +375,7 @@ def _stream_torrent_files_zip(profile: dict, items: list[dict]):
|
||||
|
||||
@bp.post("/torrents/<torrent_hash>/files/download.zip")
|
||||
def torrent_files_download_zip(torrent_hash: str):
|
||||
profile = preferences.active_profile()
|
||||
profile = request_profile()
|
||||
if not profile:
|
||||
return jsonify({"ok": False, "error": "No profile"}), 400
|
||||
data = request.get_json(silent=True) or {}
|
||||
@@ -294,7 +391,7 @@ def torrent_files_download_zip(torrent_hash: str):
|
||||
|
||||
@bp.get("/torrents/<torrent_hash>/torrent-file")
|
||||
def torrent_file_export(torrent_hash: str):
|
||||
profile = preferences.active_profile()
|
||||
profile = request_profile()
|
||||
if not profile:
|
||||
return jsonify({"ok": False, "error": "No profile"}), 400
|
||||
try:
|
||||
@@ -307,7 +404,7 @@ def torrent_file_export(torrent_hash: str):
|
||||
|
||||
@bp.post("/torrents/torrent-files.zip")
|
||||
def torrent_files_export_zip():
|
||||
profile = preferences.active_profile()
|
||||
profile = request_profile()
|
||||
if not profile:
|
||||
return jsonify({"ok": False, "error": "No profile"}), 400
|
||||
data = request.get_json(silent=True) or {}
|
||||
@@ -356,7 +453,7 @@ def torrent_files_export_zip():
|
||||
|
||||
@bp.get("/torrents/<torrent_hash>/chunks")
|
||||
def torrent_chunks(torrent_hash: str):
|
||||
profile = preferences.active_profile()
|
||||
profile = request_profile()
|
||||
if not profile:
|
||||
return jsonify({"ok": False, "error": "No profile"}), 400
|
||||
try:
|
||||
@@ -368,7 +465,7 @@ def torrent_chunks(torrent_hash: str):
|
||||
|
||||
@bp.post("/torrents/<torrent_hash>/chunks/<action_name>")
|
||||
def torrent_chunk_action(torrent_hash: str, action_name: str):
|
||||
profile = preferences.active_profile()
|
||||
profile = request_profile()
|
||||
if not profile:
|
||||
return jsonify({"ok": False, "error": "No profile"}), 400
|
||||
try:
|
||||
@@ -381,7 +478,7 @@ def torrent_chunk_action(torrent_hash: str, action_name: str):
|
||||
|
||||
@bp.get("/torrents/<torrent_hash>/peers")
|
||||
def torrent_peers(torrent_hash: str):
|
||||
profile = preferences.active_profile()
|
||||
profile = request_profile()
|
||||
if not profile:
|
||||
return jsonify({"ok": False, "error": "No profile"}), 400
|
||||
peers = rtorrent.torrent_peers(profile, torrent_hash)
|
||||
@@ -397,7 +494,7 @@ def torrent_peers(torrent_hash: str):
|
||||
|
||||
@bp.get("/torrents/<torrent_hash>/trackers")
|
||||
def torrent_trackers(torrent_hash: str):
|
||||
profile = preferences.active_profile()
|
||||
profile = request_profile()
|
||||
if not profile:
|
||||
return jsonify({"ok": False, "error": "No profile"}), 400
|
||||
return ok({"trackers": rtorrent.torrent_trackers(profile, torrent_hash)})
|
||||
@@ -406,7 +503,7 @@ def torrent_trackers(torrent_hash: str):
|
||||
|
||||
@bp.post("/torrents/<torrent_hash>/trackers/<action_name>")
|
||||
def torrent_tracker_action(torrent_hash: str, action_name: str):
|
||||
profile = preferences.active_profile()
|
||||
profile = request_profile()
|
||||
if not profile:
|
||||
return jsonify({"ok": False, "error": "No profile"}), 400
|
||||
try:
|
||||
@@ -419,7 +516,7 @@ def torrent_tracker_action(torrent_hash: str, action_name: str):
|
||||
|
||||
@bp.post("/torrents/<action_name>")
|
||||
def torrent_action(action_name: str):
|
||||
profile = preferences.active_profile()
|
||||
profile = request_profile()
|
||||
if not profile:
|
||||
return jsonify({"ok": False, "error": "No profile"}), 400
|
||||
data = request.get_json(silent=True) or {}
|
||||
@@ -448,7 +545,7 @@ def torrent_action(action_name: str):
|
||||
|
||||
@bp.post("/torrents/create")
|
||||
def torrent_create():
|
||||
profile = preferences.active_profile()
|
||||
profile = request_profile()
|
||||
if not profile:
|
||||
return jsonify({"ok": False, "error": "No profile"}), 400
|
||||
form = request.form if request.content_type and request.content_type.startswith("multipart/form-data") else (request.get_json(silent=True) or {})
|
||||
@@ -478,7 +575,7 @@ def torrent_create():
|
||||
|
||||
@bp.post("/torrents/add")
|
||||
def torrent_add():
|
||||
profile = preferences.active_profile()
|
||||
profile = request_profile()
|
||||
if not profile:
|
||||
return jsonify({"ok": False, "error": "No profile"}), 400
|
||||
job_ids = []
|
||||
@@ -535,7 +632,7 @@ def torrent_add():
|
||||
|
||||
@bp.post("/torrents/preview")
|
||||
def torrent_preview():
|
||||
profile = preferences.active_profile()
|
||||
profile = request_profile()
|
||||
existing_hashes = set()
|
||||
if profile:
|
||||
try:
|
||||
@@ -565,12 +662,14 @@ def torrent_preview():
|
||||
|
||||
@bp.post("/speed/limits")
|
||||
def speed_limits():
|
||||
profile = preferences.active_profile()
|
||||
profile = request_profile()
|
||||
if not profile:
|
||||
return jsonify({"ok": False, "error": "No profile"}), 400
|
||||
data = request.get_json(silent=True) or {}
|
||||
job_id = enqueue("set_limits", profile["id"], {"down": data.get("down"), "up": data.get("up")})
|
||||
return ok({"job_id": job_id})
|
||||
limits = profile_speed_limits.save_limits(profile["id"], data.get("down"), data.get("up"))
|
||||
# Note: Manual speed limits are stored once per rTorrent profile, so every user opening this profile sees and applies the same values.
|
||||
job_id = enqueue("set_limits", profile["id"], {"down": limits["down"], "up": limits["up"]})
|
||||
return ok({"job_id": job_id, "limits": limits})
|
||||
|
||||
|
||||
def _user_disk_status(profile: dict) -> dict:
|
||||
|
||||
+299
-24
@@ -1,15 +1,22 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from functools import wraps
|
||||
from typing import Any
|
||||
import secrets
|
||||
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from flask import abort, g, jsonify, redirect, request, session, url_for
|
||||
from flask import abort, g, has_request_context, jsonify, redirect, request, session, url_for
|
||||
from werkzeug.security import check_password_hash, generate_password_hash
|
||||
|
||||
from ..config import AUTH_ENABLE
|
||||
from ..config import (
|
||||
AUTH_ENABLE,
|
||||
AUTH_PROVIDER,
|
||||
AUTH_PROXY_AUTO_CREATE,
|
||||
AUTH_PROXY_AUTO_CREATE_PERMISSION,
|
||||
AUTH_PROXY_AUTO_CREATE_ROLE,
|
||||
AUTH_PROXY_USER_HEADER,
|
||||
API_ALLOWED_ORIGINS,
|
||||
AUTH_BYPASS_HOSTS,
|
||||
AUTH_BYPASS_USER,
|
||||
)
|
||||
from ..db import connect, default_user_id, utcnow
|
||||
|
||||
PUBLIC_ENDPOINTS = {"main.login", "main.logout", "api.auth_login", "api.auth_me", "static"}
|
||||
@@ -21,11 +28,14 @@ RTORRENT_WRITE_PREFIXES = (
|
||||
"/api/rss",
|
||||
"/api/smart-queue",
|
||||
"/api/automations",
|
||||
"/api/download-planner",
|
||||
"/api/poller/settings",
|
||||
"/api/operation-logs",
|
||||
"/api/jobs",
|
||||
"/api/cleanup",
|
||||
)
|
||||
RTORRENT_CONFIG_PREFIXES = ("/api/rtorrent-config",)
|
||||
ADMIN_PREFIXES = ("/api/auth/users", "/api/profiles")
|
||||
# Note: API reads that expose rTorrent/profile data must also respect profile permissions.
|
||||
PROFILE_READ_PREFIXES = (
|
||||
"/api/torrents",
|
||||
"/api/torrent-stats",
|
||||
@@ -40,6 +50,9 @@ PROFILE_READ_PREFIXES = (
|
||||
"/api/smart-queue",
|
||||
"/api/traffic/history",
|
||||
"/api/automations",
|
||||
"/api/download-planner",
|
||||
"/api/poller/settings",
|
||||
"/api/operation-logs",
|
||||
)
|
||||
|
||||
|
||||
@@ -47,16 +60,74 @@ def enabled() -> bool:
|
||||
return bool(AUTH_ENABLE)
|
||||
|
||||
|
||||
def provider() -> str:
|
||||
return AUTH_PROVIDER if AUTH_PROVIDER in {"local", "proxy", "tinyauth"} else "local"
|
||||
|
||||
|
||||
def uses_external_provider() -> bool:
|
||||
return enabled() and provider() in {"proxy", "tinyauth"}
|
||||
|
||||
|
||||
def external_auth_summary() -> dict[str, Any]:
|
||||
# Note: Exposes safe auth-mode facts for the Users panel without leaking secrets.
|
||||
return {
|
||||
"enabled": enabled(),
|
||||
"provider": provider(),
|
||||
"external": uses_external_provider(),
|
||||
"auto_create": bool(AUTH_PROXY_AUTO_CREATE) if uses_external_provider() else False,
|
||||
"auto_create_role": AUTH_PROXY_AUTO_CREATE_ROLE,
|
||||
"auto_create_permission": AUTH_PROXY_AUTO_CREATE_PERMISSION,
|
||||
"bypass_enabled": bool(AUTH_BYPASS_HOSTS),
|
||||
"bypass_hosts": sorted(AUTH_BYPASS_HOSTS),
|
||||
"bypass_user": AUTH_BYPASS_USER,
|
||||
"password_editable": not uses_external_provider(),
|
||||
}
|
||||
|
||||
|
||||
def password_hash(password: str) -> str:
|
||||
return generate_password_hash(password or "")
|
||||
|
||||
|
||||
def _host_matches_bypass(host: str) -> bool:
|
||||
clean = str(host or "").strip().lower()
|
||||
if not clean:
|
||||
return False
|
||||
return clean in AUTH_BYPASS_HOSTS or clean.split(":", 1)[0] in AUTH_BYPASS_HOSTS
|
||||
|
||||
|
||||
def auth_bypassed_request() -> bool:
|
||||
if not enabled() or not AUTH_BYPASS_HOSTS or not has_request_context():
|
||||
return False
|
||||
return _host_matches_bypass(request.host)
|
||||
|
||||
|
||||
|
||||
def bypass_user_id() -> int:
|
||||
"""Return the configured active user id used for trusted auth-bypass requests."""
|
||||
username = str(AUTH_BYPASS_USER or "admin").strip() or "admin"
|
||||
with connect() as conn:
|
||||
row = conn.execute("SELECT id FROM users WHERE username=? AND is_active=1", (username,)).fetchone()
|
||||
if row:
|
||||
return int(row["id"])
|
||||
row = conn.execute("SELECT id FROM users WHERE username='admin' AND is_active=1").fetchone()
|
||||
if row:
|
||||
return int(row["id"])
|
||||
row = conn.execute("SELECT id FROM users WHERE id=? AND is_active=1", (default_user_id(),)).fetchone()
|
||||
return int(row["id"]) if row else 0
|
||||
|
||||
def current_user_id() -> int:
|
||||
if not enabled():
|
||||
return default_user_id()
|
||||
if not has_request_context():
|
||||
return 0
|
||||
if auth_bypassed_request():
|
||||
return bypass_user_id()
|
||||
api_user_id = getattr(g, "api_user_id", None)
|
||||
if api_user_id:
|
||||
return int(api_user_id)
|
||||
external_user_id = getattr(g, "external_user_id", None)
|
||||
if external_user_id:
|
||||
return int(external_user_id)
|
||||
try:
|
||||
return int(session.get("user_id") or 0)
|
||||
except Exception:
|
||||
@@ -69,7 +140,7 @@ def current_user() -> dict[str, Any] | None:
|
||||
return None
|
||||
with connect() as conn:
|
||||
return conn.execute(
|
||||
"SELECT id, username, role, is_active, created_at, updated_at FROM users WHERE id=?",
|
||||
"SELECT id, username, email, display_name, external_auth_provider, external_subject, role, is_active, created_at, updated_at FROM users WHERE id=?",
|
||||
(uid,),
|
||||
).fetchone()
|
||||
|
||||
@@ -153,14 +224,29 @@ def visible_profile_ids(user_id: int | None = None) -> set[int] | None:
|
||||
|
||||
|
||||
|
||||
def _origin_key(value: str) -> str:
|
||||
parsed = urlparse(str(value or "").strip())
|
||||
if not parsed.scheme or not parsed.netloc:
|
||||
return ""
|
||||
return f"{parsed.scheme.lower()}://{parsed.netloc.lower()}"
|
||||
|
||||
|
||||
def _request_origin() -> str:
|
||||
return _origin_key(f"{request.scheme}://{request.host}")
|
||||
|
||||
|
||||
def same_origin_request() -> bool:
|
||||
"""Return False only when an unsafe request clearly comes from another origin."""
|
||||
"""Return False only when an unsafe API request clearly comes from an untrusted origin."""
|
||||
origin = request.headers.get("Origin") or request.headers.get("Referer")
|
||||
if not origin:
|
||||
return True
|
||||
try:
|
||||
parsed = urlparse(origin)
|
||||
return parsed.scheme == request.scheme and parsed.netloc == request.host
|
||||
source_origin = _origin_key(origin)
|
||||
if not source_origin:
|
||||
return False
|
||||
if source_origin == _request_origin():
|
||||
return True
|
||||
return source_origin in set(API_ALLOWED_ORIGINS)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
@@ -200,6 +286,8 @@ def require_profile_write(profile_id: int | None) -> None:
|
||||
def login_user(username: str, password: str) -> dict[str, Any] | None:
|
||||
if not enabled():
|
||||
return {"id": default_user_id(), "username": "default", "role": "admin", "is_active": 1}
|
||||
if uses_external_provider():
|
||||
return None
|
||||
with connect() as conn:
|
||||
user = conn.execute("SELECT * FROM users WHERE username=?", (username.strip(),)).fetchone()
|
||||
if not user or not int(user.get("is_active") or 0):
|
||||
@@ -213,6 +301,139 @@ def login_user(username: str, password: str) -> dict[str, Any] | None:
|
||||
return current_user()
|
||||
|
||||
|
||||
|
||||
|
||||
def _clean_header_value(name: str) -> str:
|
||||
if not name:
|
||||
return ""
|
||||
value = request.headers.get(name) or request.headers.get(name.lower()) or request.headers.get(name.upper()) or ""
|
||||
return str(value).strip()
|
||||
|
||||
|
||||
def _safe_username(value: str, fallback: str = "external-user") -> str:
|
||||
raw = str(value or "").strip()
|
||||
if "@" in raw:
|
||||
raw = raw.split("@", 1)[0]
|
||||
clean = "".join(ch for ch in raw if ch.isalnum() or ch in {".", "_", "-"}).strip("._-")
|
||||
return (clean or fallback)[:80]
|
||||
|
||||
|
||||
def _external_identity_from_headers() -> dict[str, str] | None:
|
||||
# Note: Tinyauth and generic proxy auth use a single trusted username header.
|
||||
username = _clean_header_value(AUTH_PROXY_USER_HEADER)
|
||||
if not username:
|
||||
return None
|
||||
safe_username = _safe_username(username)
|
||||
return {
|
||||
"provider": provider(),
|
||||
"username": safe_username,
|
||||
"subject": safe_username,
|
||||
}
|
||||
|
||||
|
||||
def _grant_default_external_permissions(conn, user_id: int, now: str) -> None:
|
||||
# Note: Admins can see and write all profiles through role-based access.
|
||||
if AUTH_PROXY_AUTO_CREATE_PERMISSION == "none" or AUTH_PROXY_AUTO_CREATE_ROLE == "admin":
|
||||
return
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO user_profile_permissions(user_id,profile_id,access_level,created_at,updated_at) VALUES(?,?,?,?,?)",
|
||||
(user_id, 0, AUTH_PROXY_AUTO_CREATE_PERMISSION, now, now),
|
||||
)
|
||||
|
||||
|
||||
def _sync_external_auto_created_user(conn, user: dict[str, Any], now: str) -> None:
|
||||
# Note: Passwordless external users follow the external auto-create defaults on login.
|
||||
if not AUTH_PROXY_AUTO_CREATE or user.get("password_hash"):
|
||||
return
|
||||
if user.get("external_auth_provider") and user.get("external_auth_provider") != provider():
|
||||
return
|
||||
user_id = int(user["id"])
|
||||
conn.execute("UPDATE users SET role=?, updated_at=? WHERE id=?", (AUTH_PROXY_AUTO_CREATE_ROLE, now, user_id))
|
||||
if AUTH_PROXY_AUTO_CREATE_ROLE == "admin" or AUTH_PROXY_AUTO_CREATE_PERMISSION == "none":
|
||||
conn.execute("DELETE FROM user_profile_permissions WHERE user_id=?", (user_id,))
|
||||
return
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO user_profile_permissions(user_id,profile_id,access_level,created_at,updated_at) VALUES(?,?,?,?,?)",
|
||||
(user_id, 0, AUTH_PROXY_AUTO_CREATE_PERMISSION, now, now),
|
||||
)
|
||||
|
||||
|
||||
def authenticate_external_user() -> dict[str, Any] | None:
|
||||
if not uses_external_provider():
|
||||
return None
|
||||
identity = _external_identity_from_headers()
|
||||
if not identity:
|
||||
return None
|
||||
now = utcnow()
|
||||
with connect() as conn:
|
||||
user = None
|
||||
if identity["subject"]:
|
||||
user = conn.execute(
|
||||
"SELECT * FROM users WHERE external_auth_provider=? AND external_subject=?",
|
||||
(identity["provider"], identity["subject"]),
|
||||
).fetchone()
|
||||
if not user:
|
||||
user = conn.execute("SELECT * FROM users WHERE username=?", (identity["username"],)).fetchone()
|
||||
if not user:
|
||||
if not AUTH_PROXY_AUTO_CREATE:
|
||||
return None
|
||||
cur = conn.execute(
|
||||
"""INSERT INTO users(username,password_hash,email,display_name,external_auth_provider,external_subject,role,is_active,created_at,updated_at)
|
||||
VALUES(?,?,?,?,?,?,?,?,?,?)""",
|
||||
(
|
||||
identity["username"],
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
identity["provider"],
|
||||
identity["subject"] or identity["username"],
|
||||
AUTH_PROXY_AUTO_CREATE_ROLE,
|
||||
1,
|
||||
now,
|
||||
now,
|
||||
),
|
||||
)
|
||||
user_id = int(cur.lastrowid)
|
||||
_grant_default_external_permissions(conn, user_id, now)
|
||||
user = conn.execute("SELECT * FROM users WHERE id=?", (user_id,)).fetchone()
|
||||
else:
|
||||
user_id = int(user["id"])
|
||||
conn.execute(
|
||||
"""UPDATE users
|
||||
SET external_auth_provider=?,
|
||||
external_subject=COALESCE(NULLIF(?, ''), external_subject),
|
||||
updated_at=?
|
||||
WHERE id=?""",
|
||||
(identity["provider"], identity["subject"], now, user_id),
|
||||
)
|
||||
user = conn.execute("SELECT * FROM users WHERE id=?", (user_id,)).fetchone()
|
||||
if user:
|
||||
_sync_external_auto_created_user(conn, user, now)
|
||||
user = conn.execute("SELECT * FROM users WHERE id=?", (user_id,)).fetchone()
|
||||
if not user or not int(user.get("is_active") or 0):
|
||||
return None
|
||||
g.external_user_id = int(user["id"])
|
||||
session["user_id"] = int(user["id"])
|
||||
session["username"] = user.get("username")
|
||||
session["role"] = user.get("role") or "user"
|
||||
return _public_user(user)
|
||||
|
||||
|
||||
def ensure_request_user() -> int:
|
||||
# Note: Socket.IO events do not go through Flask before_request like normal REST calls,
|
||||
# so external proxy auth must be resolved explicitly during the Socket.IO handshake/events.
|
||||
if not enabled():
|
||||
return default_user_id()
|
||||
if auth_bypassed_request():
|
||||
return bypass_user_id()
|
||||
uid = current_user_id()
|
||||
if uid:
|
||||
return uid
|
||||
if uses_external_provider():
|
||||
authenticate_external_user()
|
||||
return current_user_id()
|
||||
|
||||
|
||||
def logout_user() -> None:
|
||||
session.clear()
|
||||
|
||||
@@ -236,7 +457,7 @@ def list_users() -> list[dict[str, Any]]:
|
||||
require_admin()
|
||||
with connect() as conn:
|
||||
users = conn.execute(
|
||||
"SELECT id, username, role, is_active, created_at, updated_at FROM users ORDER BY username COLLATE NOCASE"
|
||||
"SELECT id, username, email, display_name, external_auth_provider, external_subject, role, is_active, created_at, updated_at FROM users ORDER BY username COLLATE NOCASE"
|
||||
).fetchall()
|
||||
perms = conn.execute(
|
||||
"SELECT user_id, profile_id, access_level FROM user_profile_permissions ORDER BY user_id, profile_id"
|
||||
@@ -263,6 +484,7 @@ def save_user(data: dict[str, Any], user_id: int | None = None) -> dict[str, Any
|
||||
username = str(data.get("username") or "").strip()
|
||||
role = "admin" if data.get("role") == "admin" else "user"
|
||||
is_active = 1 if data.get("is_active", True) else 0
|
||||
password_editable = not uses_external_provider()
|
||||
if not username:
|
||||
raise ValueError("Username is required")
|
||||
with connect() as conn:
|
||||
@@ -271,16 +493,19 @@ def save_user(data: dict[str, Any], user_id: int | None = None) -> dict[str, Any
|
||||
if not row:
|
||||
raise ValueError("User does not exist")
|
||||
conn.execute(
|
||||
"UPDATE users SET username=?, role=?, is_active=?, updated_at=? WHERE id=?",
|
||||
(username, role, is_active, now, user_id),
|
||||
"UPDATE users SET username=?, email=?, display_name=?, role=?, is_active=?, updated_at=? WHERE id=?",
|
||||
(username, str(data.get("email") or "").strip() or None, str(data.get("display_name") or "").strip() or None, role, is_active, now, user_id),
|
||||
)
|
||||
else:
|
||||
initial_password_hash = password_hash(str(data.get("password") or username)) if password_editable else None
|
||||
# Note: TinyAuth/proxy users are passwordless in pyTorrent; credentials stay with the auth provider.
|
||||
cur = conn.execute(
|
||||
"INSERT INTO users(username,password_hash,role,is_active,created_at,updated_at) VALUES(?,?,?,?,?,?)",
|
||||
(username, password_hash(str(data.get("password") or username)), role, is_active, now, now),
|
||||
"INSERT INTO users(username,password_hash,email,display_name,role,is_active,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?)",
|
||||
(username, initial_password_hash, str(data.get("email") or "").strip() or None, str(data.get("display_name") or "").strip() or None, role, is_active, now, now),
|
||||
)
|
||||
user_id = int(cur.lastrowid)
|
||||
if data.get("password"):
|
||||
if data.get("password") and password_editable:
|
||||
# Note: Password changes are intentionally disabled for external auth providers.
|
||||
conn.execute("UPDATE users SET password_hash=?, updated_at=? WHERE id=?", (password_hash(str(data.get("password"))), now, user_id))
|
||||
if role != "admin":
|
||||
conn.execute("DELETE FROM user_profile_permissions WHERE user_id=?", (user_id,))
|
||||
@@ -293,7 +518,7 @@ def save_user(data: dict[str, Any], user_id: int | None = None) -> dict[str, Any
|
||||
)
|
||||
else:
|
||||
conn.execute("DELETE FROM user_profile_permissions WHERE user_id=?", (user_id,))
|
||||
return conn.execute("SELECT id, username, role, is_active, created_at, updated_at FROM users WHERE id=?", (user_id,)).fetchone()
|
||||
return conn.execute("SELECT id, username, email, display_name, external_auth_provider, external_subject, role, is_active, created_at, updated_at FROM users WHERE id=?", (user_id,)).fetchone()
|
||||
|
||||
|
||||
def delete_user(user_id: int) -> None:
|
||||
@@ -323,6 +548,10 @@ def _public_user(row: dict[str, Any] | None) -> dict[str, Any] | None:
|
||||
return {
|
||||
"id": int(row["id"]),
|
||||
"username": row.get("username"),
|
||||
"email": row.get("email"),
|
||||
"display_name": row.get("display_name"),
|
||||
"external_auth_provider": row.get("external_auth_provider"),
|
||||
"external_subject": row.get("external_subject"),
|
||||
"role": row.get("role") or "user",
|
||||
"is_active": int(row.get("is_active") or 0),
|
||||
"created_at": row.get("created_at"),
|
||||
@@ -352,7 +581,7 @@ def list_api_tokens(user_id: int) -> list[dict[str, Any]]:
|
||||
abort(403)
|
||||
with connect() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT id,user_id,name,token_prefix,last_used_at,created_at,updated_at,revoked_at FROM api_tokens WHERE user_id=? ORDER BY created_at DESC",
|
||||
"SELECT id,user_id,name,token_prefix,last_used_at,created_at,updated_at,revoked_at FROM api_tokens WHERE user_id=? AND revoked_at IS NULL ORDER BY created_at DESC",
|
||||
(uid,),
|
||||
).fetchall()
|
||||
return [_token_response(row) for row in rows]
|
||||
@@ -396,10 +625,13 @@ def revoke_api_token(user_id: int, token_id: int) -> None:
|
||||
abort(403)
|
||||
now = utcnow()
|
||||
with connect() as conn:
|
||||
conn.execute(
|
||||
"UPDATE api_tokens SET revoked_at=COALESCE(revoked_at, ?), updated_at=? WHERE id=? AND user_id=?",
|
||||
# Note: Report missing/already revoked tokens instead of showing a false success in the UI.
|
||||
cur = conn.execute(
|
||||
"UPDATE api_tokens SET revoked_at=COALESCE(revoked_at, ?), updated_at=? WHERE id=? AND user_id=? AND revoked_at IS NULL",
|
||||
(now, now, tid, uid),
|
||||
)
|
||||
if cur.rowcount <= 0:
|
||||
raise ValueError("Active API token not found")
|
||||
|
||||
|
||||
def authenticate_api_token(token: str) -> dict[str, Any] | None:
|
||||
@@ -439,12 +671,22 @@ def install_guards(app) -> None:
|
||||
def _auth_guard():
|
||||
if not enabled():
|
||||
return None
|
||||
|
||||
# Allow unauthenticated health checks for monitoring.
|
||||
if request.path == "/api/health" or request.path.startswith("/api/health/"):
|
||||
return None
|
||||
|
||||
g.api_token_authenticated = False
|
||||
if auth_bypassed_request():
|
||||
return None
|
||||
|
||||
if request.path.startswith("/api/"):
|
||||
token_user = authenticate_api_token(_request_api_token())
|
||||
if token_user:
|
||||
g.api_user_id = int(token_user["id"])
|
||||
g.api_token_authenticated = True
|
||||
if not getattr(g, "api_user_id", None):
|
||||
authenticate_external_user()
|
||||
endpoint = request.endpoint or ""
|
||||
if endpoint in PUBLIC_ENDPOINTS or endpoint.startswith("static"):
|
||||
return None
|
||||
@@ -478,12 +720,45 @@ def install_guards(app) -> None:
|
||||
def _request_profile_id() -> int | None:
|
||||
if request.view_args and request.view_args.get("profile_id"):
|
||||
return int(request.view_args["profile_id"])
|
||||
payload = {}
|
||||
try:
|
||||
payload = request.get_json(silent=True) or {}
|
||||
if payload.get("profile_id"):
|
||||
return int(payload.get("profile_id"))
|
||||
except Exception:
|
||||
pass
|
||||
payload = {}
|
||||
raw_id = (
|
||||
request.args.get("profile_id")
|
||||
or request.form.get("profile_id")
|
||||
or payload.get("profile_id")
|
||||
or request.headers.get("X-PyTorrent-Profile-Id")
|
||||
)
|
||||
if raw_id not in (None, ""):
|
||||
try:
|
||||
return int(raw_id)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
raw_name = (
|
||||
request.args.get("profile_name")
|
||||
or request.form.get("profile_name")
|
||||
or payload.get("profile_name")
|
||||
or request.headers.get("X-PyTorrent-Profile-Name")
|
||||
)
|
||||
if raw_name:
|
||||
from . import preferences
|
||||
visible = visible_profile_ids(current_user_id())
|
||||
with connect() as conn:
|
||||
if visible is None:
|
||||
row = conn.execute("SELECT id FROM rtorrent_profiles WHERE lower(name)=lower(?) ORDER BY is_default DESC, id LIMIT 1", (str(raw_name).strip(),)).fetchone()
|
||||
elif visible:
|
||||
placeholders = ",".join("?" for _ in visible)
|
||||
row = conn.execute(
|
||||
f"SELECT id FROM rtorrent_profiles WHERE id IN ({placeholders}) AND lower(name)=lower(?) ORDER BY is_default DESC, id LIMIT 1",
|
||||
(*tuple(visible), str(raw_name).strip()),
|
||||
).fetchone()
|
||||
else:
|
||||
row = None
|
||||
return int(row["id"]) if row else None
|
||||
from . import preferences
|
||||
profile = preferences.active_profile()
|
||||
return int(profile["id"]) if profile else None
|
||||
if profile:
|
||||
return int(profile["id"])
|
||||
return 1 if can_access_profile(1) else None
|
||||
|
||||
@@ -2,25 +2,53 @@ from __future__ import annotations
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
import json
|
||||
import threading
|
||||
from ..db import connect, default_user_id, utcnow
|
||||
from . import rtorrent
|
||||
from . import rtorrent, auth
|
||||
from .preferences import active_profile
|
||||
from .workers import enqueue
|
||||
|
||||
AUTOMATION_JOB_CHUNK_SIZE = 100
|
||||
AUTOMATION_LIGHT_ACTIONS = {'start', 'stop', 'pause', 'resume', 'set_label'}
|
||||
_CHECK_LOCKS: dict[tuple[int, int | None], threading.Lock] = {}
|
||||
_CHECK_LOCKS_GUARD = threading.Lock()
|
||||
|
||||
|
||||
def _check_lock(profile_id: int, rule_id: int | None = None) -> threading.Lock:
|
||||
"""Prevent overlapping automation runs for the same profile or rule."""
|
||||
key = (int(profile_id), int(rule_id) if rule_id is not None else None)
|
||||
with _CHECK_LOCKS_GUARD:
|
||||
if key not in _CHECK_LOCKS:
|
||||
_CHECK_LOCKS[key] = threading.Lock()
|
||||
return _CHECK_LOCKS[key]
|
||||
|
||||
|
||||
def _resolve_user_id(profile: dict[str, Any] | None = None, user_id: int | None = None) -> int:
|
||||
"""Return a safe user id for rule ownership or background execution."""
|
||||
if user_id:
|
||||
return int(user_id)
|
||||
request_user_id = auth.current_user_id()
|
||||
if request_user_id:
|
||||
return int(request_user_id)
|
||||
if profile and profile.get('user_id'):
|
||||
return int(profile.get('user_id') or 0)
|
||||
return int(default_user_id())
|
||||
|
||||
|
||||
def _loads(value: str | None, default: Any) -> Any:
|
||||
try: return json.loads(value or '')
|
||||
except Exception: return default
|
||||
try:
|
||||
return json.loads(value or '')
|
||||
except Exception:
|
||||
return default
|
||||
|
||||
|
||||
def _ts(value: str | None) -> float:
|
||||
if not value: return 0.0
|
||||
try: return datetime.fromisoformat(str(value).replace('Z', '+00:00')).timestamp()
|
||||
except Exception: return 0.0
|
||||
if not value:
|
||||
return 0.0
|
||||
try:
|
||||
return datetime.fromisoformat(str(value).replace('Z', '+00:00')).timestamp()
|
||||
except Exception:
|
||||
return 0.0
|
||||
|
||||
|
||||
def _now_ts() -> float:
|
||||
@@ -31,7 +59,8 @@ def _label_names(value: str | None) -> list[str]:
|
||||
seen = []
|
||||
for part in str(value or '').replace(';', ',').replace('|', ',').split(','):
|
||||
item = part.strip()
|
||||
if item and item not in seen: seen.append(item)
|
||||
if item and item not in seen:
|
||||
seen.append(item)
|
||||
return seen
|
||||
|
||||
|
||||
@@ -39,7 +68,8 @@ def _label_value(labels: list[str]) -> str:
|
||||
out = []
|
||||
for label in labels:
|
||||
label = str(label or '').strip()
|
||||
if label and label not in out: out.append(label)
|
||||
if label and label not in out:
|
||||
out.append(label)
|
||||
return ', '.join(out)
|
||||
|
||||
|
||||
@@ -47,35 +77,98 @@ def _rule_row(row: dict[str, Any]) -> dict[str, Any]:
|
||||
item = dict(row)
|
||||
item['conditions'] = _loads(item.pop('conditions_json', '[]'), [])
|
||||
item['effects'] = _loads(item.pop('effects_json', '[]'), [])
|
||||
item['owner_user_id'] = int(item.get('user_id') or 0)
|
||||
item['owner_username'] = str(item.get('owner_username') or '').strip()
|
||||
item['owner_display_name'] = str(item.get('owner_display_name') or '').strip()
|
||||
item['owner_label'] = item['owner_display_name'] or item['owner_username'] or f"user #{item['owner_user_id']}"
|
||||
return item
|
||||
|
||||
|
||||
def list_rules(profile_id: int | None = None, user_id: int | None = None) -> list[dict[str, Any]]:
|
||||
user_id = user_id or default_user_id()
|
||||
def _require_profile_read(profile_id: int, user_id: int | None = None) -> int:
|
||||
viewer_id = _resolve_user_id(user_id=user_id)
|
||||
if not auth.can_access_profile(profile_id, viewer_id):
|
||||
raise ValueError('No access to profile')
|
||||
return viewer_id
|
||||
|
||||
|
||||
def _require_profile_write(profile_id: int, user_id: int | None = None) -> int:
|
||||
viewer_id = _resolve_user_id(user_id=user_id)
|
||||
if not auth.can_write_profile(profile_id, viewer_id):
|
||||
raise ValueError('No write access to profile')
|
||||
return viewer_id
|
||||
|
||||
|
||||
def _can_manage_rule(profile_id: int, rule: dict[str, Any], user_id: int) -> bool:
|
||||
return int(rule.get('user_id') or 0) == int(user_id) or auth.can_write_profile(profile_id, user_id)
|
||||
|
||||
|
||||
def _select_rules_sql(where_sql: str) -> str:
|
||||
return f'''
|
||||
SELECT
|
||||
r.*,
|
||||
u.username AS owner_username,
|
||||
COALESCE(u.display_name, '') AS owner_display_name
|
||||
FROM automation_rules r
|
||||
LEFT JOIN users u ON u.id = r.user_id
|
||||
WHERE {where_sql}
|
||||
ORDER BY r.enabled DESC, r.name COLLATE NOCASE
|
||||
'''
|
||||
|
||||
|
||||
def _decorate_rule_state(rules: list[dict[str, Any]], profile_id: int | None) -> None:
|
||||
if profile_id is None:
|
||||
profile = active_profile(); profile_id = int(profile['id']) if profile else None
|
||||
return
|
||||
with connect() as conn:
|
||||
rows = conn.execute('SELECT * FROM automation_rules WHERE user_id=? AND (profile_id=? OR profile_id IS NULL) ORDER BY enabled DESC, name COLLATE NOCASE', (user_id, profile_id)).fetchall()
|
||||
for rule in rules:
|
||||
row = conn.execute(
|
||||
'SELECT last_applied_at FROM automation_rule_state WHERE rule_id=? AND profile_id=? AND torrent_hash=?',
|
||||
(rule['id'], profile_id, '__rule__'),
|
||||
).fetchone()
|
||||
last = row.get('last_applied_at') if row else None
|
||||
cooldown = int(rule.get('cooldown_minutes') or 0)
|
||||
remaining = max(0, int((_ts(last) + cooldown * 60) - _now_ts())) if last and cooldown > 0 else 0
|
||||
rule['last_applied_at'] = last
|
||||
rule['cooldown_remaining_seconds'] = remaining
|
||||
|
||||
|
||||
def list_rules(profile_id: int | None = None, user_id: int | None = None) -> list[dict[str, Any]]:
|
||||
if profile_id is None:
|
||||
profile = active_profile(user_id=user_id)
|
||||
profile_id = int(profile['id']) if profile else None
|
||||
if profile_id is None:
|
||||
return []
|
||||
_require_profile_read(profile_id, user_id)
|
||||
with connect() as conn:
|
||||
rows = conn.execute(_select_rules_sql('r.profile_id=?'), (profile_id,)).fetchall()
|
||||
rules = [_rule_row(r) for r in rows]
|
||||
if profile_id is not None:
|
||||
with connect() as conn:
|
||||
for rule in rules:
|
||||
row = conn.execute('SELECT last_applied_at FROM automation_rule_state WHERE rule_id=? AND profile_id=? AND torrent_hash=?', (rule['id'], profile_id, '__rule__')).fetchone()
|
||||
last = row.get('last_applied_at') if row else None
|
||||
cooldown = int(rule.get('cooldown_minutes') or 0)
|
||||
remaining = max(0, int((_ts(last) + cooldown * 60) - _now_ts())) if last and cooldown > 0 else 0
|
||||
# Note: Exposes live cooldown timers for the Automations tab without changing rule behavior.
|
||||
rule['last_applied_at'] = last
|
||||
rule['cooldown_remaining_seconds'] = remaining
|
||||
_decorate_rule_state(rules, profile_id)
|
||||
return rules
|
||||
|
||||
|
||||
def _list_enabled_rules_for_profile(profile_id: int, rule_id: int | None = None, force: bool = False) -> list[dict[str, Any]]:
|
||||
params: list[Any] = [profile_id]
|
||||
clauses = ['r.profile_id=?']
|
||||
if rule_id is not None:
|
||||
clauses.append('r.id=?')
|
||||
params.append(int(rule_id))
|
||||
if not force:
|
||||
clauses.append('r.enabled=1')
|
||||
with connect() as conn:
|
||||
rows = conn.execute(_select_rules_sql(' AND '.join(clauses)), tuple(params)).fetchall()
|
||||
rules = [_rule_row(r) for r in rows]
|
||||
_decorate_rule_state(rules, profile_id)
|
||||
return rules
|
||||
|
||||
|
||||
def get_rule(rule_id: int, profile_id: int, user_id: int | None = None) -> dict[str, Any]:
|
||||
user_id = user_id or default_user_id()
|
||||
_require_profile_read(profile_id, user_id)
|
||||
with connect() as conn:
|
||||
row = conn.execute('SELECT * FROM automation_rules WHERE id=? AND user_id=? AND profile_id=?', (rule_id, user_id, profile_id)).fetchone()
|
||||
if not row: raise ValueError('Rule not found')
|
||||
return _rule_row(row)
|
||||
row = conn.execute(_select_rules_sql('r.id=? AND r.profile_id=?'), (rule_id, profile_id)).fetchone()
|
||||
if not row:
|
||||
raise ValueError('Rule not found')
|
||||
rule = _rule_row(row)
|
||||
_decorate_rule_state([rule], profile_id)
|
||||
return rule
|
||||
|
||||
|
||||
def _portable_rule(rule: dict[str, Any]) -> dict[str, Any]:
|
||||
@@ -89,70 +182,96 @@ def _portable_rule(rule: dict[str, Any]) -> dict[str, Any]:
|
||||
|
||||
|
||||
def export_rules(profile_id: int, user_id: int | None = None) -> dict[str, Any]:
|
||||
# Note: Export contains only portable rule definitions, never DB ids or execution history.
|
||||
rules = [_portable_rule(rule) for rule in list_rules(profile_id, user_id)]
|
||||
return {'version': 1, 'app': 'pyTorrent', 'exported_at': utcnow(), 'rules': rules}
|
||||
return {'version': 1, 'app': 'pyTorrent', 'exported_at': utcnow(), 'scope': 'profile', 'rules': rules}
|
||||
|
||||
|
||||
def import_rules(profile_id: int, payload: dict[str, Any] | list[Any], user_id: int | None = None, replace: bool = False) -> list[dict[str, Any]]:
|
||||
user_id = user_id or default_user_id()
|
||||
owner_id = _require_profile_write(profile_id, user_id)
|
||||
raw_rules = payload if isinstance(payload, list) else payload.get('rules', []) if isinstance(payload, dict) else []
|
||||
if not isinstance(raw_rules, list) or not raw_rules:
|
||||
raise ValueError('Import file does not contain automation rules')
|
||||
if replace:
|
||||
with connect() as conn:
|
||||
# Note: Optional replace is profile-scoped; it does not touch other profiles or history tables.
|
||||
conn.execute('DELETE FROM automation_rules WHERE user_id=? AND profile_id=?', (user_id, profile_id))
|
||||
conn.execute('DELETE FROM automation_rules WHERE profile_id=?', (profile_id,))
|
||||
conn.execute('DELETE FROM automation_rule_state WHERE profile_id=?', (profile_id,))
|
||||
imported = []
|
||||
for raw in raw_rules:
|
||||
if not isinstance(raw, dict):
|
||||
continue
|
||||
rule = _portable_rule(raw)
|
||||
rule.pop('id', None)
|
||||
imported.append(save_rule(profile_id, rule, user_id))
|
||||
imported.append(save_rule(profile_id, rule, owner_id))
|
||||
if not imported:
|
||||
raise ValueError('No valid automation rules found')
|
||||
return imported
|
||||
|
||||
|
||||
def save_rule(profile_id: int, data: dict[str, Any], user_id: int | None = None) -> dict[str, Any]:
|
||||
user_id = user_id or default_user_id()
|
||||
actor_id = _resolve_user_id(user_id=user_id)
|
||||
name = str(data.get('name') or 'Automation rule').strip() or 'Automation rule'
|
||||
conditions = data.get('conditions') or []
|
||||
effects = data.get('effects') or []
|
||||
if not isinstance(conditions, list) or not conditions: raise ValueError('Rule needs at least one condition')
|
||||
if not isinstance(effects, list) or not effects: raise ValueError('Rule needs at least one effect')
|
||||
if not isinstance(conditions, list) or not conditions:
|
||||
raise ValueError('Rule needs at least one condition')
|
||||
if not isinstance(effects, list) or not effects:
|
||||
raise ValueError('Rule needs at least one effect')
|
||||
cooldown = max(0, int(data.get('cooldown_minutes') or 0))
|
||||
enabled = 1 if data.get('enabled', True) else 0
|
||||
now = utcnow(); rule_id = int(data.get('id') or 0)
|
||||
with connect() as conn:
|
||||
if rule_id:
|
||||
conn.execute('UPDATE automation_rules SET name=?, enabled=?, conditions_json=?, effects_json=?, cooldown_minutes=?, updated_at=? WHERE id=? AND user_id=? AND profile_id=?', (name, enabled, json.dumps(conditions), json.dumps(effects), cooldown, now, rule_id, user_id, profile_id))
|
||||
else:
|
||||
cur = conn.execute('INSERT INTO automation_rules(user_id,profile_id,name,enabled,conditions_json,effects_json,cooldown_minutes,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?)', (user_id, profile_id, name, enabled, json.dumps(conditions), json.dumps(effects), cooldown, now, now))
|
||||
now = utcnow()
|
||||
rule_id = int(data.get('id') or 0)
|
||||
if rule_id:
|
||||
existing = get_rule(rule_id, profile_id, actor_id)
|
||||
if not _can_manage_rule(profile_id, existing, actor_id):
|
||||
raise ValueError('No permission to edit this automation rule')
|
||||
owner_id = int(existing.get('user_id') or existing.get('owner_user_id') or actor_id)
|
||||
with connect() as conn:
|
||||
cur = conn.execute(
|
||||
'UPDATE automation_rules SET name=?, enabled=?, conditions_json=?, effects_json=?, cooldown_minutes=?, updated_at=? WHERE id=? AND profile_id=?',
|
||||
(name, enabled, json.dumps(conditions), json.dumps(effects), cooldown, now, rule_id, profile_id),
|
||||
)
|
||||
if not cur.rowcount:
|
||||
raise ValueError('Rule not found')
|
||||
else:
|
||||
owner_id = _require_profile_write(profile_id, actor_id)
|
||||
with connect() as conn:
|
||||
cur = conn.execute(
|
||||
'INSERT INTO automation_rules(user_id,profile_id,name,enabled,conditions_json,effects_json,cooldown_minutes,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?)',
|
||||
(owner_id, profile_id, name, enabled, json.dumps(conditions), json.dumps(effects), cooldown, now, now),
|
||||
)
|
||||
rule_id = int(cur.lastrowid)
|
||||
return get_rule(rule_id, profile_id, user_id)
|
||||
return get_rule(rule_id, profile_id, actor_id)
|
||||
|
||||
|
||||
def delete_rule(rule_id: int, profile_id: int, user_id: int | None = None) -> None:
|
||||
user_id = user_id or default_user_id()
|
||||
actor_id = _resolve_user_id(user_id=user_id)
|
||||
rule = get_rule(rule_id, profile_id, actor_id)
|
||||
if not _can_manage_rule(profile_id, rule, actor_id):
|
||||
raise ValueError('No permission to delete this automation rule')
|
||||
with connect() as conn:
|
||||
conn.execute('DELETE FROM automation_rules WHERE id=? AND user_id=? AND profile_id=?', (rule_id, user_id, profile_id))
|
||||
conn.execute('DELETE FROM automation_rules WHERE id=? AND profile_id=?', (rule_id, profile_id))
|
||||
conn.execute('DELETE FROM automation_rule_state WHERE rule_id=? AND profile_id=?', (rule_id, profile_id))
|
||||
|
||||
|
||||
def list_history(profile_id: int, user_id: int | None = None, limit: int = 30) -> list[dict[str, Any]]:
|
||||
user_id = user_id or default_user_id()
|
||||
_require_profile_read(profile_id, user_id)
|
||||
with connect() as conn:
|
||||
return conn.execute('SELECT * FROM automation_history WHERE user_id=? AND profile_id=? ORDER BY created_at DESC LIMIT ?', (user_id, profile_id, max(1, min(int(limit or 30), 100)))).fetchall()
|
||||
return conn.execute('''
|
||||
SELECT
|
||||
h.*,
|
||||
u.username AS owner_username,
|
||||
COALESCE(u.display_name, '') AS owner_display_name
|
||||
FROM automation_history h
|
||||
LEFT JOIN users u ON u.id = h.user_id
|
||||
WHERE h.profile_id=?
|
||||
ORDER BY h.created_at DESC
|
||||
LIMIT ?
|
||||
''', (profile_id, max(1, min(int(limit or 30), 100)))).fetchall()
|
||||
|
||||
|
||||
def clear_history(profile_id: int, user_id: int | None = None) -> int:
|
||||
user_id = user_id or default_user_id()
|
||||
_require_profile_write(profile_id, user_id)
|
||||
with connect() as conn:
|
||||
# Note: Manual automation log cleanup is scoped to the active profile and current user.
|
||||
cur = conn.execute('DELETE FROM automation_history WHERE user_id=? AND profile_id=?', (user_id, profile_id))
|
||||
cur = conn.execute('DELETE FROM automation_history WHERE profile_id=?', (profile_id,))
|
||||
return int(cur.rowcount or 0)
|
||||
|
||||
|
||||
@@ -177,46 +296,47 @@ def _conditions_match(conn, rule: dict[str, Any], profile_id: int, t: dict[str,
|
||||
for cond in rule.get('conditions') or []:
|
||||
raw_ok = _condition_true(t, cond)
|
||||
negated = bool(cond.get('negate'))
|
||||
# Note: Negation is applied in the backend, so UI and API only store the condition flag.
|
||||
ok = (not raw_ok) if negated else raw_ok
|
||||
if cond.get('type') == 'no_seeds' and int(cond.get('minutes') or 0) > 0 and not negated:
|
||||
row = conn.execute('SELECT condition_since_at FROM automation_rule_state WHERE rule_id=? AND profile_id=? AND torrent_hash=?', (rule['id'], profile_id, h)).fetchone()
|
||||
if ok:
|
||||
since = row['condition_since_at'] if row and row.get('condition_since_at') else now
|
||||
conn.execute('INSERT INTO automation_rule_state(rule_id,profile_id,torrent_hash,condition_since_at,last_matched_at,updated_at) VALUES(?,?,?,?,?,?) ON CONFLICT(rule_id,profile_id,torrent_hash) DO UPDATE SET condition_since_at=COALESCE(automation_rule_state.condition_since_at, excluded.condition_since_at), last_matched_at=excluded.last_matched_at, updated_at=excluded.updated_at', (rule['id'], profile_id, h, since, now, now))
|
||||
delayed_ok = delayed_ok and (now_ts - _ts(since) >= int(cond.get('minutes') or 0) * 60)
|
||||
since = row.get('condition_since_at') if row else None
|
||||
if raw_ok:
|
||||
if not since:
|
||||
conn.execute('INSERT INTO automation_rule_state(rule_id,profile_id,torrent_hash,condition_since_at,updated_at) VALUES(?,?,?,?,?) ON CONFLICT(rule_id,profile_id,torrent_hash) DO UPDATE SET condition_since_at=excluded.condition_since_at, updated_at=excluded.updated_at', (rule['id'], profile_id, h, now, now))
|
||||
since = now
|
||||
delayed_ok = delayed_ok and (_ts(since) + int(cond.get('minutes') or 0) * 60 <= now_ts)
|
||||
else:
|
||||
conn.execute('UPDATE automation_rule_state SET condition_since_at=NULL, updated_at=? WHERE rule_id=? AND profile_id=? AND torrent_hash=?', (now, rule['id'], profile_id, h)); delayed_ok = False
|
||||
conn.execute('INSERT INTO automation_rule_state(rule_id,profile_id,torrent_hash,condition_since_at,updated_at) VALUES(?,?,?,?,?) ON CONFLICT(rule_id,profile_id,torrent_hash) DO UPDATE SET condition_since_at=NULL, updated_at=excluded.updated_at', (rule['id'], profile_id, h, None, now))
|
||||
delayed_ok = False
|
||||
else:
|
||||
immediate_ok = immediate_ok and ok
|
||||
return immediate_ok and delayed_ok
|
||||
|
||||
|
||||
def _cooldown_ok(conn, rule: dict[str, Any], profile_id: int, torrent_hash: str = '__rule__') -> bool:
|
||||
def _cooldown_ok(conn, rule: dict[str, Any], profile_id: int) -> bool:
|
||||
cooldown = int(rule.get('cooldown_minutes') or 0)
|
||||
if cooldown <= 0: return True
|
||||
row = conn.execute('SELECT last_applied_at FROM automation_rule_state WHERE rule_id=? AND profile_id=? AND torrent_hash=?', (rule['id'], profile_id, torrent_hash)).fetchone()
|
||||
if not row or not row.get('last_applied_at'): return True
|
||||
return _now_ts() - _ts(row['last_applied_at']) >= cooldown * 60
|
||||
row = conn.execute('SELECT last_applied_at FROM automation_rule_state WHERE rule_id=? AND profile_id=? AND torrent_hash=?', (rule['id'], profile_id, '__rule__')).fetchone()
|
||||
last = row.get('last_applied_at') if row else None
|
||||
return not last or (_ts(last) + cooldown * 60 <= _now_ts())
|
||||
|
||||
|
||||
def _mark_rule_cooldown(conn, rule: dict[str, Any], profile_id: int, now: str) -> None:
|
||||
# Note: Cooldown is rule-level, so one batch execution blocks the whole automation until the cooldown expires.
|
||||
conn.execute('INSERT INTO automation_rule_state(rule_id,profile_id,torrent_hash,last_applied_at,updated_at) VALUES(?,?,?,?,?) ON CONFLICT(rule_id,profile_id,torrent_hash) DO UPDATE SET last_applied_at=excluded.last_applied_at, updated_at=excluded.updated_at', (rule['id'], profile_id, '__rule__', now, now))
|
||||
|
||||
|
||||
def _chunk_hashes(hashes: list[str], size: int = AUTOMATION_JOB_CHUNK_SIZE) -> list[list[str]]:
|
||||
# Note: Automation jobs use the same small-batch idea as manual bulk jobs, so long move/remove/actions remain visible and recoverable.
|
||||
safe_size = max(1, int(size or AUTOMATION_JOB_CHUNK_SIZE))
|
||||
return [hashes[index:index + safe_size] for index in range(0, len(hashes), safe_size)]
|
||||
|
||||
|
||||
def _job_context(rule: dict[str, Any], eff_type: str, hashes: list[str], torrents_by_hash: dict[str, dict[str, Any]], extra: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||
# Note: Job context marks jobs created by automations, making the Jobs log explain what rule queued the work.
|
||||
ctx = {
|
||||
'source': 'automation',
|
||||
'rule_id': rule.get('id'),
|
||||
'rule_name': str(rule.get('name') or ''),
|
||||
'rule_owner_user_id': int(rule.get('user_id') or rule.get('owner_user_id') or 0),
|
||||
'rule_owner': str(rule.get('owner_label') or ''),
|
||||
'effect': eff_type,
|
||||
'bulk': len(hashes) > 1,
|
||||
'hash_count': len(hashes),
|
||||
@@ -236,7 +356,6 @@ def _job_context(rule: dict[str, Any], eff_type: str, hashes: list[str], torrent
|
||||
|
||||
|
||||
def _enqueue_automation_job(profile: dict[str, Any], rule: dict[str, Any], action_name: str, hashes: list[str], payload: dict[str, Any], torrents_by_hash: dict[str, dict[str, Any]], user_id: int | None = None, context_extra: dict[str, Any] | None = None) -> list[str]:
|
||||
# Note: Light automation actions stay in one job; heavy actions are chunked for recoverability.
|
||||
job_ids: list[str] = []
|
||||
chunks = [hashes] if action_name in AUTOMATION_LIGHT_ACTIONS else _chunk_hashes(hashes)
|
||||
for index, chunk in enumerate(chunks, start=1):
|
||||
@@ -252,7 +371,8 @@ def _enqueue_automation_job(profile: dict[str, Any], rule: dict[str, Any], actio
|
||||
extra.update({'target_path': str(part_payload.get('path') or ''), 'move_data': bool(part_payload.get('move_data'))})
|
||||
if action_name == 'remove':
|
||||
extra.update({'remove_data': bool(part_payload.get('remove_data'))})
|
||||
part_payload['job_context'] = _job_context(rule, str(context_extra.get('effect_type') if context_extra else action_name), chunk, torrents_by_hash, extra)
|
||||
effect_type = str(context_extra.get('effect_type') if context_extra else action_name)
|
||||
part_payload['job_context'] = _job_context(rule, effect_type, chunk, torrents_by_hash, extra)
|
||||
job_ids.append(enqueue(action_name, int(profile['id']), part_payload, user_id=user_id))
|
||||
return job_ids
|
||||
|
||||
@@ -278,7 +398,6 @@ def _apply_effects_bulk(c: Any, profile: dict[str, Any], torrents: list[dict[str
|
||||
elif typ == 'add_label':
|
||||
label = str(eff.get('label') or '').strip()
|
||||
if label:
|
||||
# Note: Add-label automations are idempotent and queue only torrents that need a changed label value.
|
||||
grouped: dict[str, list[str]] = {}
|
||||
for h in hashes:
|
||||
labels = labels_by_hash.get(h, [])
|
||||
@@ -297,7 +416,6 @@ def _apply_effects_bulk(c: Any, profile: dict[str, Any], torrents: list[dict[str
|
||||
elif typ == 'remove_label':
|
||||
label = str(eff.get('label') or '').strip()
|
||||
if label:
|
||||
# Note: Remove-label automations are queued only for torrents where the requested label exists.
|
||||
grouped: dict[str, list[str]] = {}
|
||||
for h in hashes:
|
||||
labels = labels_by_hash.get(h, [])
|
||||
@@ -315,7 +433,6 @@ def _apply_effects_bulk(c: Any, profile: dict[str, Any], torrents: list[dict[str
|
||||
elif typ == 'set_labels':
|
||||
value = _label_value(_label_names(eff.get('labels')))
|
||||
target_labels = _label_names(value)
|
||||
# Note: Set-labels queues a job only if the current labels differ from the requested exact list.
|
||||
target_hashes = [h for h in hashes if labels_by_hash.get(h, []) != target_labels]
|
||||
for h in target_hashes:
|
||||
labels_by_hash[h] = list(target_labels)
|
||||
@@ -323,60 +440,86 @@ def _apply_effects_bulk(c: Any, profile: dict[str, Any], torrents: list[dict[str
|
||||
job_ids = _enqueue_automation_job(profile, rule, 'set_label', target_hashes, {'label': value}, torrents_by_hash, user_id, {'effect_type': 'set_labels', 'labels': value})
|
||||
applied.append({'type': 'set_labels', 'labels': value, 'count': len(target_hashes), 'target_hashes': target_hashes, 'job_ids': job_ids})
|
||||
elif typ in {'pause', 'stop', 'start', 'resume', 'recheck', 'reannounce'}:
|
||||
# Note: Runtime actions are queued as jobs too, so automation activity is visible in the Jobs panel.
|
||||
job_ids = _enqueue_automation_job(profile, rule, typ, hashes, {}, torrents_by_hash, user_id, {'effect_type': typ})
|
||||
applied.append({'type': typ, 'count': len(hashes), 'target_hashes': hashes, 'job_ids': job_ids})
|
||||
elif typ == 'remove':
|
||||
# Note: Remove is supported for automation payloads and still goes through ordered worker jobs.
|
||||
payload = {'remove_data': bool(eff.get('remove_data'))}
|
||||
job_ids = _enqueue_automation_job(profile, rule, 'remove', hashes, payload, torrents_by_hash, user_id, {'effect_type': 'remove'})
|
||||
applied.append({'type': 'remove', 'count': len(hashes), 'target_hashes': hashes, 'remove_data': payload['remove_data'], 'job_ids': job_ids})
|
||||
return applied
|
||||
|
||||
|
||||
def check(profile: dict | None = None, user_id: int | None = None, force: bool = False, rule_id: int | None = None) -> dict[str, Any]:
|
||||
profile = profile or active_profile()
|
||||
if not profile: return {'ok': False, 'error': 'No active rTorrent profile'}
|
||||
user_id = user_id or default_user_id(); profile_id = int(profile['id'])
|
||||
rules = [r for r in list_rules(profile_id, user_id) if (rule_id is None or int(r.get('id') or 0) == int(rule_id)) and (force or int(r.get('enabled') or 0))]
|
||||
if not rules: return {'ok': True, 'checked': 0, 'applied': [], 'batches': [], 'rules': 0}
|
||||
torrents = rtorrent.list_torrents(profile); applied = []; batches = []; now = utcnow()
|
||||
planned: list[dict[str, Any]] = []
|
||||
def _record_skipped_rule(profile_id: int, rule: dict[str, Any], hashes: list[str], reason: str, now: str) -> dict[str, Any]:
|
||||
action = {'type': 'skipped', 'error': reason, 'count': len(hashes)}
|
||||
owner_id = int(rule.get('user_id') or rule.get('owner_user_id') or default_user_id())
|
||||
torrent_hash = hashes[0] if len(hashes) == 1 else f'batch:{rule["id"]}:{now}:skipped'
|
||||
torrent_name = '1 torrent' if len(hashes) == 1 else f'{len(hashes)} torrents'
|
||||
with connect() as conn:
|
||||
for rule in rules:
|
||||
# Note: This pass only matches rules and updates condition timers; job creation is intentionally delayed until after this DB transaction commits.
|
||||
if not force and not _cooldown_ok(conn, rule, profile_id):
|
||||
continue
|
||||
matched = [t for t in torrents if _conditions_match(conn, rule, profile_id, t)]
|
||||
if not matched:
|
||||
continue
|
||||
hashes = [str(t.get('hash') or '') for t in matched if str(t.get('hash') or '')]
|
||||
if hashes:
|
||||
planned.append({'rule': rule, 'matched': matched, 'hashes': hashes})
|
||||
for item in planned:
|
||||
rule = item['rule']
|
||||
matched = item['matched']
|
||||
hashes = item['hashes']
|
||||
# Note: Automation jobs are enqueued outside the rule-state transaction, preventing SQLite self-locks when enqueue() writes to jobs.
|
||||
try:
|
||||
actions = _apply_effects_bulk(None, profile, matched, rule.get('effects') or [], rule, user_id)
|
||||
except Exception as exc:
|
||||
actions = [{'error': str(exc), 'count': len(hashes), 'target_hashes': hashes}]
|
||||
changed_hashes = sorted({h for a in actions for h in (a.get('target_hashes') or [])})
|
||||
if not actions or not changed_hashes:
|
||||
# Note: Matching torrents with no real action are not logged and do not restart the cooldown.
|
||||
continue
|
||||
history_actions = [{k: v for k, v in a.items() if k != 'target_hashes'} for a in actions]
|
||||
matched_by_hash = {str(t.get('hash') or ''): t for t in matched}
|
||||
conn.execute(
|
||||
'INSERT INTO automation_history(user_id,profile_id,rule_id,torrent_hash,torrent_name,rule_name,actions_json,created_at) VALUES(?,?,?,?,?,?,?,?)',
|
||||
(owner_id, profile_id, rule['id'], torrent_hash, torrent_name, str(rule.get('name') or ''), json.dumps([action]), now),
|
||||
)
|
||||
return {'rule_id': rule['id'], 'rule_name': rule.get('name'), 'count': len(hashes), 'actions': [action], 'skipped': True}
|
||||
|
||||
|
||||
def check(profile: dict | None = None, user_id: int | None = None, force: bool = False, rule_id: int | None = None) -> dict[str, Any]:
|
||||
profile = profile or active_profile(user_id=user_id)
|
||||
if not profile:
|
||||
return {'ok': False, 'error': 'No active rTorrent profile'}
|
||||
profile_id = int(profile['id'])
|
||||
if rule_id is not None:
|
||||
_require_profile_read(profile_id, user_id)
|
||||
lock = _check_lock(profile_id, rule_id)
|
||||
if not lock.acquire(blocking=False):
|
||||
# Note: Browser, manual and background checks can now coexist without duplicate rule application.
|
||||
return {'ok': True, 'checked': 0, 'applied': [], 'batches': [], 'rules': 0, 'skipped': True, 'reason': 'Automation check already running'}
|
||||
try:
|
||||
rules = _list_enabled_rules_for_profile(profile_id, rule_id=rule_id, force=force)
|
||||
if not rules:
|
||||
return {'ok': True, 'checked': 0, 'applied': [], 'batches': [], 'rules': 0}
|
||||
torrents = rtorrent.list_torrents(profile)
|
||||
applied = []
|
||||
batches = []
|
||||
now = utcnow()
|
||||
planned: list[dict[str, Any]] = []
|
||||
with connect() as conn:
|
||||
# Note: State/history writes happen after enqueue succeeds, so failed job creation does not create misleading automation history.
|
||||
for h in changed_hashes:
|
||||
t = matched_by_hash.get(h, {})
|
||||
conn.execute('INSERT INTO automation_rule_state(rule_id,profile_id,torrent_hash,last_matched_at,last_applied_at,updated_at) VALUES(?,?,?,?,?,?) ON CONFLICT(rule_id,profile_id,torrent_hash) DO UPDATE SET last_matched_at=excluded.last_matched_at, last_applied_at=excluded.last_applied_at, updated_at=excluded.updated_at', (rule['id'], profile_id, h, now, now, now))
|
||||
applied.append({'rule_id': rule['id'], 'rule_name': rule.get('name'), 'hash': h, 'name': t.get('name'), 'actions': [{'type': a.get('type', 'error'), 'count': a.get('count', len(changed_hashes))} for a in actions]})
|
||||
_mark_rule_cooldown(conn, rule, profile_id, now)
|
||||
torrent_name = str(matched_by_hash.get(changed_hashes[0], {}).get('name') or '') if len(changed_hashes) == 1 else f'{len(changed_hashes)} torrents'
|
||||
torrent_hash = changed_hashes[0] if len(changed_hashes) == 1 else f'batch:{rule["id"]}:{now}'
|
||||
conn.execute('INSERT INTO automation_history(user_id,profile_id,rule_id,torrent_hash,torrent_name,rule_name,actions_json,created_at) VALUES(?,?,?,?,?,?,?,?)', (user_id, profile_id, rule['id'], torrent_hash, torrent_name, str(rule.get('name') or ''), json.dumps(history_actions), now))
|
||||
batches.append({'rule_id': rule['id'], 'rule_name': rule.get('name'), 'count': len(changed_hashes), 'actions': history_actions})
|
||||
return {'ok': True, 'checked': len(torrents), 'rules': len(rules), 'applied': applied, 'batches': batches}
|
||||
for rule in rules:
|
||||
if not force and not _cooldown_ok(conn, rule, profile_id):
|
||||
continue
|
||||
matched = [t for t in torrents if _conditions_match(conn, rule, profile_id, t)]
|
||||
if not matched:
|
||||
continue
|
||||
hashes = [str(t.get('hash') or '') for t in matched if str(t.get('hash') or '')]
|
||||
if hashes:
|
||||
planned.append({'rule': rule, 'matched': matched, 'hashes': hashes})
|
||||
for item in planned:
|
||||
rule = item['rule']
|
||||
matched = item['matched']
|
||||
hashes = item['hashes']
|
||||
owner_id = int(rule.get('user_id') or rule.get('owner_user_id') or default_user_id())
|
||||
if not auth.can_write_profile(profile_id, owner_id):
|
||||
batch = _record_skipped_rule(profile_id, rule, hashes, 'Rule owner no longer has write access to profile', now)
|
||||
batches.append(batch)
|
||||
continue
|
||||
try:
|
||||
actions = _apply_effects_bulk(None, profile, matched, rule.get('effects') or [], rule, owner_id)
|
||||
except Exception as exc:
|
||||
actions = [{'error': str(exc), 'count': len(hashes), 'target_hashes': hashes}]
|
||||
changed_hashes = sorted({h for a in actions for h in (a.get('target_hashes') or [])})
|
||||
if not actions or not changed_hashes:
|
||||
continue
|
||||
history_actions = [{k: v for k, v in a.items() if k != 'target_hashes'} for a in actions]
|
||||
matched_by_hash = {str(t.get('hash') or ''): t for t in matched}
|
||||
with connect() as conn:
|
||||
for h in changed_hashes:
|
||||
t = matched_by_hash.get(h, {})
|
||||
conn.execute('INSERT INTO automation_rule_state(rule_id,profile_id,torrent_hash,last_matched_at,last_applied_at,updated_at) VALUES(?,?,?,?,?,?) ON CONFLICT(rule_id,profile_id,torrent_hash) DO UPDATE SET last_matched_at=excluded.last_matched_at, last_applied_at=excluded.last_applied_at, updated_at=excluded.updated_at', (rule['id'], profile_id, h, now, now, now))
|
||||
applied.append({'rule_id': rule['id'], 'rule_name': rule.get('name'), 'owner_user_id': owner_id, 'owner_label': rule.get('owner_label'), 'hash': h, 'name': t.get('name'), 'actions': [{'type': a.get('type', 'error'), 'count': a.get('count', len(changed_hashes))} for a in actions]})
|
||||
_mark_rule_cooldown(conn, rule, profile_id, now)
|
||||
torrent_name = str(matched_by_hash.get(changed_hashes[0], {}).get('name') or '') if len(changed_hashes) == 1 else f'{len(changed_hashes)} torrents'
|
||||
torrent_hash = changed_hashes[0] if len(changed_hashes) == 1 else f'batch:{rule["id"]}:{now}'
|
||||
conn.execute('INSERT INTO automation_history(user_id,profile_id,rule_id,torrent_hash,torrent_name,rule_name,actions_json,created_at) VALUES(?,?,?,?,?,?,?,?)', (owner_id, profile_id, rule['id'], torrent_hash, torrent_name, str(rule.get('name') or ''), json.dumps(history_actions), now))
|
||||
batches.append({'rule_id': rule['id'], 'rule_name': rule.get('name'), 'owner_user_id': owner_id, 'owner_label': rule.get('owner_label'), 'count': len(changed_hashes), 'actions': history_actions})
|
||||
return {'ok': True, 'checked': len(torrents), 'rules': len(rules), 'applied': applied, 'batches': batches}
|
||||
finally:
|
||||
lock.release()
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
from typing import Any
|
||||
from ..db import connect, default_user_id
|
||||
from . import automation_rules, operation_logs, poller_control, rtorrent
|
||||
from .websocket import emit_profile_event
|
||||
|
||||
_started = False
|
||||
_start_lock = threading.Lock()
|
||||
_profile_locks: dict[int, threading.Lock] = {}
|
||||
_profile_locks_lock = threading.Lock()
|
||||
_last_logged_status: dict[int, str] = {}
|
||||
|
||||
|
||||
def _configured_interval() -> float:
|
||||
"""Return the minimum background automation interval from environment settings."""
|
||||
try:
|
||||
return max(5.0, min(3600.0, float(os.environ.get("PYTORRENT_AUTOMATION_BACKGROUND_INTERVAL_SECONDS", "15"))))
|
||||
except Exception:
|
||||
return 15.0
|
||||
|
||||
|
||||
def _profiles() -> list[dict[str, Any]]:
|
||||
"""Read configured profiles without relying on a browser session."""
|
||||
with connect() as conn:
|
||||
return [dict(row) for row in conn.execute("SELECT * FROM rtorrent_profiles ORDER BY id").fetchall()]
|
||||
|
||||
|
||||
def _profile_lock(profile_id: int) -> threading.Lock:
|
||||
"""Keep one automation pass per profile active at a time."""
|
||||
with _profile_locks_lock:
|
||||
if profile_id not in _profile_locks:
|
||||
_profile_locks[profile_id] = threading.Lock()
|
||||
return _profile_locks[profile_id]
|
||||
|
||||
|
||||
def _owner_user_id(profile: dict[str, Any]) -> int:
|
||||
"""Use the profile owner for background checks so rule permissions stay stable."""
|
||||
return int(profile.get("user_id") or default_user_id())
|
||||
|
||||
|
||||
def _profile_interval(profile_id: int) -> float:
|
||||
"""Reuse the existing queue poller cadence instead of adding another UI setting."""
|
||||
settings = poller_control.get_settings(profile_id)
|
||||
return max(_configured_interval(), float(settings.get("queue_stats_interval_seconds") or 15.0))
|
||||
|
||||
|
||||
def _connected(profile: dict[str, Any]) -> tuple[bool, str]:
|
||||
"""Verify rTorrent connectivity before running automation logic."""
|
||||
try:
|
||||
rtorrent.client_for(profile).call("system.client_version")
|
||||
return True, ""
|
||||
except Exception as exc:
|
||||
return False, str(exc)
|
||||
|
||||
|
||||
def _log_status(profile_id: int, status: str, message: str, *, error: str = "") -> None:
|
||||
"""Log only connectivity state changes to avoid noisy system logs."""
|
||||
if _last_logged_status.get(profile_id) == status:
|
||||
return
|
||||
_last_logged_status[profile_id] = status
|
||||
severity = "warning" if error else "info"
|
||||
operation_logs.record(
|
||||
profile_id,
|
||||
"background_automation_status",
|
||||
message,
|
||||
severity=severity,
|
||||
source="system",
|
||||
action="background_automation",
|
||||
details={"status": status, "error": error},
|
||||
)
|
||||
|
||||
|
||||
def _run_profile(socketio, profile: dict[str, Any]) -> None:
|
||||
"""Run one safe background automation pass for a connected profile."""
|
||||
profile_id = int(profile.get("id") or 0)
|
||||
if not profile_id:
|
||||
return
|
||||
lock = _profile_lock(profile_id)
|
||||
if not lock.acquire(blocking=False):
|
||||
return
|
||||
try:
|
||||
ok, error = _connected(profile)
|
||||
if not ok:
|
||||
_log_status(profile_id, "disconnected", f"Background automations waiting for rTorrent: {error}", error=error)
|
||||
return
|
||||
_log_status(profile_id, "connected", "Background automations detected a working rTorrent connection")
|
||||
result = automation_rules.check(profile, user_id=_owner_user_id(profile), force=False)
|
||||
if result.get("applied") or result.get("batches"):
|
||||
operation_logs.record(
|
||||
profile_id,
|
||||
"background_automation_run",
|
||||
"Background automations applied matching rules",
|
||||
source="system",
|
||||
action="background_automation",
|
||||
details={"applied": len(result.get("applied") or []), "batches": len(result.get("batches") or []), "result": result},
|
||||
user_id=_owner_user_id(profile),
|
||||
)
|
||||
emit_profile_event(socketio, "automation_update", result, profile_id)
|
||||
except Exception as exc:
|
||||
operation_logs.record(
|
||||
profile_id,
|
||||
"background_automation_error",
|
||||
f"Background automation check failed: {exc}",
|
||||
severity="warning",
|
||||
source="system",
|
||||
action="background_automation",
|
||||
details={"error": str(exc)},
|
||||
user_id=_owner_user_id(profile),
|
||||
)
|
||||
finally:
|
||||
lock.release()
|
||||
|
||||
|
||||
def start_scheduler(socketio) -> None:
|
||||
"""Start browser-independent automation checks once per application process."""
|
||||
global _started
|
||||
with _start_lock:
|
||||
if _started:
|
||||
return
|
||||
_started = True
|
||||
|
||||
def runner() -> None:
|
||||
last_run: dict[int, float] = {}
|
||||
while True:
|
||||
started = time.monotonic()
|
||||
next_sleep = _configured_interval()
|
||||
for profile in _profiles():
|
||||
profile_id = int(profile.get("id") or 0)
|
||||
if not profile_id:
|
||||
continue
|
||||
interval = _profile_interval(profile_id)
|
||||
elapsed = started - float(last_run.get(profile_id) or 0.0)
|
||||
if elapsed < interval:
|
||||
next_sleep = min(next_sleep, max(1.0, interval - elapsed))
|
||||
continue
|
||||
last_run[profile_id] = started
|
||||
_run_profile(socketio, profile)
|
||||
next_sleep = min(next_sleep, interval)
|
||||
socketio.sleep(max(1.0, next_sleep))
|
||||
|
||||
socketio.start_background_task(runner)
|
||||
@@ -0,0 +1,209 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
from typing import Any
|
||||
from ..db import connect, default_user_id
|
||||
from . import port_check, preferences, rtorrent, tracker_cache
|
||||
from .torrent_cache import torrent_cache
|
||||
|
||||
STARTUP_DELAY_SECONDS = 60
|
||||
DEFAULT_TRACKER_INTERVAL_SECONDS = 15 * 60
|
||||
DEFAULT_PORT_INTERVAL_SECONDS = port_check.PORT_CHECK_CACHE_SECONDS
|
||||
FAVICON_BATCH_SIZE = 20
|
||||
|
||||
_started = False
|
||||
_start_lock = threading.Lock()
|
||||
_status_lock = threading.Lock()
|
||||
_status: dict[str, Any] = {
|
||||
"started": False,
|
||||
"tracker_warmup": {},
|
||||
"port_check": {},
|
||||
}
|
||||
|
||||
|
||||
def _setting_float(name: str, default: float, minimum: float, maximum: float) -> float:
|
||||
"""Read a bounded worker interval from the environment."""
|
||||
# Note: Defaults keep the worker light while still making UI-independent caches fresh after startup.
|
||||
try:
|
||||
value = float(os.environ.get(name, str(default)))
|
||||
except Exception:
|
||||
value = default
|
||||
return max(minimum, min(maximum, value))
|
||||
|
||||
|
||||
def _profiles() -> list[dict[str, Any]]:
|
||||
"""Read every rTorrent profile directly from the database."""
|
||||
# Note: The worker cannot rely on active browser session state, so it iterates real configured profiles.
|
||||
with connect() as conn:
|
||||
return [dict(row) for row in conn.execute("SELECT * FROM rtorrent_profiles ORDER BY id").fetchall()]
|
||||
|
||||
|
||||
def _owner_user_id(profile: dict[str, Any]) -> int:
|
||||
"""Return the profile owner used for profile-scoped preferences."""
|
||||
return int(profile.get("user_id") or default_user_id())
|
||||
|
||||
|
||||
def _connected(profile: dict[str, Any]) -> tuple[bool, str]:
|
||||
"""Check rTorrent connectivity without changing user state."""
|
||||
try:
|
||||
rtorrent.client_for(profile).call("system.client_version")
|
||||
return True, ""
|
||||
except Exception as exc:
|
||||
return False, str(exc)
|
||||
|
||||
|
||||
def _remember(section: str, profile_id: int, payload: dict[str, Any]) -> None:
|
||||
"""Store lightweight in-memory diagnostics for app/status."""
|
||||
# Note: Cache warmups are not user operations, so they stay out of operation logs by default.
|
||||
with _status_lock:
|
||||
data = dict(_status.get(section) or {})
|
||||
data[str(profile_id)] = {**payload, "updated_at_epoch": time.time()}
|
||||
_status[section] = data
|
||||
|
||||
|
||||
def status() -> dict[str, Any]:
|
||||
"""Return current worker diagnostics for system status endpoints."""
|
||||
with _status_lock:
|
||||
return {
|
||||
"started": bool(_status.get("started")),
|
||||
"startup_delay_seconds": STARTUP_DELAY_SECONDS,
|
||||
"tracker_warmup": dict(_status.get("tracker_warmup") or {}),
|
||||
"port_check": dict(_status.get("port_check") or {}),
|
||||
}
|
||||
|
||||
|
||||
def _tracker_domains_from_rows(rows: list[dict[str, Any]], summary: dict[str, Any], profile_id: int) -> list[str]:
|
||||
"""Build a bounded tracker domain list from fresh summary data and cached rows."""
|
||||
domains = [str(item.get("domain") or "") for item in summary.get("trackers") or []]
|
||||
if not domains:
|
||||
domains = tracker_cache.cached_domains_for_profile(profile_id, limit=200)
|
||||
return domains
|
||||
|
||||
|
||||
def _warm_tracker_profile(profile: dict[str, Any]) -> None:
|
||||
"""Warm tracker summary cache and optional favicon cache for one profile."""
|
||||
# Note: This mirrors the sidebar warmup, but runs from the backend scheduler instead of waiting for the filter panel.
|
||||
profile_id = int(profile.get("id") or 0)
|
||||
if not profile_id:
|
||||
return
|
||||
ok, error = _connected(profile)
|
||||
if not ok:
|
||||
_remember("tracker_warmup", profile_id, {"ok": False, "skipped": True, "reason": "rtorrent_disconnected", "error": error})
|
||||
return
|
||||
|
||||
owner_id = _owner_user_id(profile)
|
||||
prefs = preferences.get_preferences(owner_id, profile_id)
|
||||
rows = torrent_cache.snapshot(profile_id)
|
||||
if not rows:
|
||||
torrent_cache.refresh(profile)
|
||||
rows = torrent_cache.snapshot(profile_id)
|
||||
hashes = [str(row.get("hash") or "") for row in rows if row.get("hash")]
|
||||
if not hashes:
|
||||
_remember("tracker_warmup", profile_id, {"ok": True, "skipped": True, "reason": "no_torrents"})
|
||||
return
|
||||
|
||||
loader = lambda h: rtorrent.torrent_trackers(profile, h)
|
||||
summary = tracker_cache.summary(profile, hashes, loader, scan_limit=tracker_cache.TRACKER_SCAN_LIMIT, include_favicons=False)
|
||||
warming = False
|
||||
if int(summary.get("pending") or 0) > 0:
|
||||
warming = tracker_cache.warm_summary_cache(profile, hashes, loader, batch_size=tracker_cache.TRACKER_SCAN_LIMIT)
|
||||
|
||||
favicon_result = {"checked": 0, "cached": 0, "errors": []}
|
||||
if bool((prefs or {}).get("tracker_favicons_enabled")):
|
||||
domains = _tracker_domains_from_rows(rows, summary, profile_id)
|
||||
favicon_result = tracker_cache.warm_favicon_cache(domains, enabled=True, limit=FAVICON_BATCH_SIZE, force=False)
|
||||
|
||||
_remember(
|
||||
"tracker_warmup",
|
||||
profile_id,
|
||||
{
|
||||
"ok": True,
|
||||
"hashes": len(hashes),
|
||||
"pending": int(summary.get("pending") or 0),
|
||||
"scanned_now": int(summary.get("scanned_now") or 0),
|
||||
"warming": bool(warming),
|
||||
"favicons_enabled": bool((prefs or {}).get("tracker_favicons_enabled")),
|
||||
"favicons": favicon_result,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _check_port_profile(profile: dict[str, Any]) -> None:
|
||||
"""Refresh incoming-port status when the profile preference enables it."""
|
||||
# Note: force=False respects the existing six-hour cache and avoids unnecessary external checks.
|
||||
profile_id = int(profile.get("id") or 0)
|
||||
if not profile_id:
|
||||
return
|
||||
owner_id = _owner_user_id(profile)
|
||||
prefs = preferences.get_preferences(owner_id, profile_id)
|
||||
if not bool((prefs or {}).get("port_check_enabled")):
|
||||
_remember("port_check", profile_id, {"ok": True, "enabled": False, "skipped": True, "reason": "disabled"})
|
||||
return
|
||||
result = port_check.port_check_status(profile=profile, force=False, user_id=owner_id)
|
||||
_remember(
|
||||
"port_check",
|
||||
profile_id,
|
||||
{
|
||||
"ok": not bool(result.get("error") and result.get("source") == "none"),
|
||||
"enabled": True,
|
||||
"status": result.get("status"),
|
||||
"cached": bool(result.get("cached")),
|
||||
"checked_at": result.get("checked_at"),
|
||||
"error": result.get("error") or result.get("fallback_error") or "",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def start_scheduler(socketio=None) -> None:
|
||||
"""Start browser-independent cache warmup and port-check scheduler."""
|
||||
global _started
|
||||
with _start_lock:
|
||||
if _started:
|
||||
return
|
||||
_started = True
|
||||
with _status_lock:
|
||||
_status["started"] = True
|
||||
|
||||
tracker_interval = _setting_float("PYTORRENT_CACHE_WARMUP_INTERVAL_SECONDS", DEFAULT_TRACKER_INTERVAL_SECONDS, 60.0, 24 * 60 * 60.0)
|
||||
port_interval = _setting_float("PYTORRENT_PORT_CHECK_INTERVAL_SECONDS", DEFAULT_PORT_INTERVAL_SECONDS, 60.0, 24 * 60 * 60.0)
|
||||
|
||||
def runner() -> None:
|
||||
time.sleep(STARTUP_DELAY_SECONDS)
|
||||
last_tracker: dict[int, float] = {}
|
||||
last_port: dict[int, float] = {}
|
||||
while True:
|
||||
now = time.monotonic()
|
||||
next_sleep = 60.0
|
||||
for profile in _profiles():
|
||||
profile_id = int(profile.get("id") or 0)
|
||||
if not profile_id:
|
||||
continue
|
||||
if now - float(last_tracker.get(profile_id) or 0.0) >= tracker_interval:
|
||||
last_tracker[profile_id] = now
|
||||
try:
|
||||
_warm_tracker_profile(profile)
|
||||
except Exception as exc:
|
||||
_remember("tracker_warmup", profile_id, {"ok": False, "error": str(exc)})
|
||||
if now - float(last_port.get(profile_id) or 0.0) >= port_interval:
|
||||
last_port[profile_id] = now
|
||||
try:
|
||||
_check_port_profile(profile)
|
||||
except Exception as exc:
|
||||
_remember("port_check", profile_id, {"ok": False, "error": str(exc)})
|
||||
next_sleep = min(
|
||||
next_sleep,
|
||||
max(1.0, tracker_interval - (time.monotonic() - float(last_tracker.get(profile_id) or 0.0))),
|
||||
max(1.0, port_interval - (time.monotonic() - float(last_port.get(profile_id) or 0.0))),
|
||||
)
|
||||
sleep_for = max(5.0, min(60.0, next_sleep))
|
||||
if socketio:
|
||||
socketio.sleep(sleep_for)
|
||||
else:
|
||||
time.sleep(sleep_for)
|
||||
|
||||
if socketio:
|
||||
socketio.start_background_task(runner)
|
||||
else:
|
||||
threading.Thread(target=runner, daemon=True, name="pytorrent-cache-warmup-scheduler").start()
|
||||
+445
-153
@@ -1,19 +1,61 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import threading
|
||||
import time
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from ..db import connect, utcnow, default_user_id
|
||||
from . import auth
|
||||
|
||||
# Note: Settings backups include persistent configuration tables only; volatile queues, caches, histories and tokens are intentionally skipped.
|
||||
BACKUP_TABLES = [
|
||||
"users", "user_profile_permissions", "user_preferences", "rtorrent_profiles",
|
||||
# Note: Application backups are admin-only because they include users, permissions and all profiles.
|
||||
APP_BACKUP_TABLES = [
|
||||
"users", "user_profile_permissions", "user_preferences", "profile_preferences", "rtorrent_profiles",
|
||||
"disk_monitor_preferences", "labels", "ratio_groups", "rss_feeds", "rss_rules",
|
||||
"smart_queue_settings", "smart_queue_exclusions", "automation_rules",
|
||||
"rtorrent_config_overrides", "app_settings", "download_plan_settings",
|
||||
"rtorrent_config_overrides", "poller_settings", "app_settings", "download_plan_settings",
|
||||
]
|
||||
|
||||
# Note: Profile backups contain profile behavior plus user-specific view preferences for the user creating the backup.
|
||||
PROFILE_BACKUP_TABLES = [
|
||||
"rtorrent_profiles", "profile_preferences", "disk_monitor_preferences", "labels", "ratio_groups",
|
||||
"rss_feeds", "rss_rules", "smart_queue_settings", "smart_queue_exclusions",
|
||||
"automation_rules", "rtorrent_config_overrides", "poller_settings", "download_plan_settings",
|
||||
]
|
||||
|
||||
# Scope values:
|
||||
# - profile: shared profile behavior, visible/restored by profile access.
|
||||
# - user_profile: personal preferences for the backup creator/restorer.
|
||||
PROFILE_TABLE_SCOPES = {
|
||||
"rtorrent_profiles": "profile_id",
|
||||
"profile_preferences": "user_profile",
|
||||
"disk_monitor_preferences": "profile",
|
||||
"labels": "profile",
|
||||
"ratio_groups": "profile",
|
||||
"rss_feeds": "profile",
|
||||
"rss_rules": "profile",
|
||||
"smart_queue_settings": "profile",
|
||||
"smart_queue_exclusions": "profile",
|
||||
"automation_rules": "profile",
|
||||
"rtorrent_config_overrides": "profile",
|
||||
"poller_settings": "profile",
|
||||
"download_plan_settings": "profile_singleton",
|
||||
}
|
||||
|
||||
PROFILE_TABLE_FILTERS = {
|
||||
"rtorrent_profiles": "id=?",
|
||||
"profile_preferences": "user_id=? AND profile_id=?",
|
||||
"disk_monitor_preferences": "profile_id=?",
|
||||
"labels": "profile_id=?",
|
||||
"ratio_groups": "profile_id=?",
|
||||
"rss_feeds": "profile_id=?",
|
||||
"rss_rules": "profile_id=?",
|
||||
"smart_queue_settings": "profile_id=?",
|
||||
"smart_queue_exclusions": "profile_id=?",
|
||||
"automation_rules": "profile_id=?",
|
||||
"rtorrent_config_overrides": "profile_id=?",
|
||||
"poller_settings": "profile_id=?",
|
||||
"download_plan_settings": "profile_id=?",
|
||||
}
|
||||
|
||||
DEFAULT_AUTO_BACKUP_SETTINGS = {
|
||||
"enabled": False,
|
||||
"interval_hours": 24,
|
||||
@@ -22,101 +64,26 @@ DEFAULT_AUTO_BACKUP_SETTINGS = {
|
||||
}
|
||||
BACKUP_PREVIEW_VALUE_LIMIT = 80
|
||||
BACKUP_PREVIEW_ROW_LIMIT = 3
|
||||
BACKUP_PREVIEW_SENSITIVE_KEYS = {
|
||||
"password",
|
||||
"password_hash",
|
||||
"token",
|
||||
"token_hash",
|
||||
"api_key",
|
||||
"secret",
|
||||
}
|
||||
BACKUP_PREVIEW_SENSITIVE_KEYS = {"password", "password_hash", "token", "token_hash", "api_key", "secret"}
|
||||
AUTO_BACKUP_SETTINGS_KEY = "backup:auto"
|
||||
_scheduler_started = False
|
||||
_scheduler_lock = threading.Lock()
|
||||
|
||||
|
||||
def create_backup(name: str, user_id: int | None = None, automatic: bool = False) -> dict:
|
||||
"""Create a settings backup and return a table-count summary.
|
||||
|
||||
Note: The automatic flag is metadata only; restore/download behavior remains unchanged.
|
||||
"""
|
||||
user_id = user_id or default_user_id()
|
||||
payload = {"version": 1, "created_at": utcnow(), "automatic": bool(automatic), "tables": {}}
|
||||
def _is_admin_user(user_id: int | None = None) -> bool:
|
||||
if not auth.enabled():
|
||||
return True
|
||||
uid = user_id or auth.current_user_id()
|
||||
if not uid:
|
||||
return False
|
||||
with connect() as conn:
|
||||
for table in BACKUP_TABLES:
|
||||
try:
|
||||
payload["tables"][table] = conn.execute(f"SELECT * FROM {table}").fetchall()
|
||||
except Exception:
|
||||
payload["tables"][table] = []
|
||||
cur = conn.execute(
|
||||
"INSERT INTO app_backups(user_id,name,payload_json,created_at) VALUES(?,?,?,?)",
|
||||
(user_id, name or f"Backup {payload['created_at']}", json.dumps(payload), payload["created_at"]),
|
||||
)
|
||||
backup_id = cur.lastrowid
|
||||
return {"id": backup_id, "name": name, "created_at": payload["created_at"], "automatic": bool(automatic), "tables": {k: len(v) for k, v in payload["tables"].items()}}
|
||||
|
||||
|
||||
def list_backups(user_id: int | None = None) -> list[dict]:
|
||||
user_id = user_id or default_user_id()
|
||||
with connect() as conn:
|
||||
rows = conn.execute("SELECT id,name,created_at,payload_json FROM app_backups WHERE user_id=? ORDER BY id DESC", (user_id,)).fetchall()
|
||||
result = []
|
||||
for row in rows:
|
||||
payload = _loads(row.get("payload_json") or "{}")
|
||||
tables = payload.get("tables") or {}
|
||||
result.append({
|
||||
"id": row.get("id"),
|
||||
"name": row.get("name"),
|
||||
"created_at": row.get("created_at"),
|
||||
"automatic": bool(payload.get("automatic")),
|
||||
"tables": {key: len(value or []) for key, value in tables.items()},
|
||||
})
|
||||
return result
|
||||
|
||||
|
||||
def payload_for_backup(backup_id: int, user_id: int | None = None) -> dict:
|
||||
user_id = user_id or default_user_id()
|
||||
with connect() as conn:
|
||||
row = conn.execute("SELECT payload_json FROM app_backups WHERE id=? AND user_id=?", (backup_id, user_id)).fetchone()
|
||||
if not row:
|
||||
raise ValueError("Backup not found")
|
||||
return json.loads(row["payload_json"] or "{}")
|
||||
|
||||
|
||||
def restore_backup(backup_id: int, user_id: int | None = None) -> dict:
|
||||
user_id = user_id or default_user_id()
|
||||
payload = payload_for_backup(backup_id, user_id)
|
||||
tables = payload.get("tables") or {}
|
||||
restored = {}
|
||||
with connect() as conn:
|
||||
conn.execute("PRAGMA foreign_keys = OFF")
|
||||
try:
|
||||
for table in BACKUP_TABLES:
|
||||
rows = tables.get(table) or []
|
||||
if not rows:
|
||||
continue
|
||||
columns = list(rows[0].keys())
|
||||
placeholders = ",".join("?" for _ in columns)
|
||||
conn.execute(f"DELETE FROM {table}")
|
||||
for row in rows:
|
||||
conn.execute(f"INSERT INTO {table}({','.join(columns)}) VALUES({placeholders})", [row.get(col) for col in columns])
|
||||
restored[table] = len(rows)
|
||||
finally:
|
||||
conn.execute("PRAGMA foreign_keys = ON")
|
||||
return {"restored": restored}
|
||||
|
||||
def delete_backup(backup_id: int, user_id: int | None = None) -> dict:
|
||||
user_id = user_id or default_user_id()
|
||||
with connect() as conn:
|
||||
cur = conn.execute(
|
||||
"DELETE FROM app_backups WHERE id=? AND user_id=?",
|
||||
(backup_id, user_id),
|
||||
)
|
||||
if not cur.rowcount:
|
||||
raise ValueError("Backup not found")
|
||||
return {"deleted": backup_id}
|
||||
row = conn.execute("SELECT role,is_active FROM users WHERE id=?", (uid,)).fetchone()
|
||||
return bool(row and row.get("role") == "admin" and int(row.get("is_active") or 0))
|
||||
|
||||
|
||||
def _require_admin(user_id: int | None = None) -> None:
|
||||
if not _is_admin_user(user_id):
|
||||
raise PermissionError("Application backups are available only to admins")
|
||||
|
||||
|
||||
def _loads(value: str) -> dict:
|
||||
@@ -127,26 +94,290 @@ def _loads(value: str) -> dict:
|
||||
return {}
|
||||
|
||||
|
||||
def _settings_row_key(user_id: int | None = None) -> str:
|
||||
return f"{AUTO_BACKUP_SETTINGS_KEY}:{user_id or default_user_id()}"
|
||||
def _table_columns(conn, table: str) -> set[str]:
|
||||
try:
|
||||
return {str(row["name"]) for row in conn.execute(f"PRAGMA table_info({table})").fetchall()}
|
||||
except Exception:
|
||||
return set()
|
||||
|
||||
|
||||
def _latest_backup_created_at(user_id: int) -> str | None:
|
||||
"""Return the newest persisted backup timestamp for scheduler recovery after restarts.
|
||||
def _table_rows(conn, table: str, where: str | None = None, params: tuple = ()) -> list[dict]:
|
||||
try:
|
||||
sql = f"SELECT * FROM {table}" + (f" WHERE {where}" if where else "")
|
||||
return [dict(row) for row in conn.execute(sql, params).fetchall()]
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
Note: Automatic scheduling is based on the latest database backup record, so process
|
||||
restarts cannot create repeated backups before the configured interval elapses.
|
||||
"""
|
||||
|
||||
def _profile_filter_params(table: str, user_id: int, profile_id: int) -> tuple[object, ...]:
|
||||
scope = PROFILE_TABLE_SCOPES.get(table)
|
||||
if scope in {"profile", "profile_id", "profile_singleton"}:
|
||||
return (int(profile_id),)
|
||||
return (int(user_id), int(profile_id))
|
||||
|
||||
|
||||
def _user_label(conn, user_id: int | None) -> str:
|
||||
if not user_id:
|
||||
return "system"
|
||||
try:
|
||||
row = conn.execute("SELECT display_name, username, email FROM users WHERE id=?", (int(user_id),)).fetchone()
|
||||
if row:
|
||||
return str(row.get("display_name") or row.get("username") or row.get("email") or f"user {user_id}")
|
||||
except Exception:
|
||||
pass
|
||||
return f"user {user_id}"
|
||||
|
||||
|
||||
def _backup_row_visible(row: dict, user_id: int) -> bool:
|
||||
backup_type = str(row.get("backup_type") or "app")
|
||||
if backup_type == "app":
|
||||
return _is_admin_user(user_id)
|
||||
profile_id = int(row.get("profile_id") or 0)
|
||||
return bool(profile_id and auth.can_access_profile(profile_id, user_id))
|
||||
|
||||
|
||||
def _backup_row_writable(row: dict, user_id: int) -> bool:
|
||||
backup_type = str(row.get("backup_type") or "app")
|
||||
if backup_type == "app":
|
||||
return _is_admin_user(user_id)
|
||||
profile_id = int(row.get("profile_id") or 0)
|
||||
return bool(profile_id and auth.can_write_profile(profile_id, user_id))
|
||||
|
||||
|
||||
def _store_backup(user_id: int, name: str, backup_type: str, profile_id: int | None, payload: dict) -> dict:
|
||||
with connect() as conn:
|
||||
cur = conn.execute(
|
||||
"INSERT INTO app_backups(user_id,name,backup_type,profile_id,payload_json,created_at) VALUES(?,?,?,?,?,?)",
|
||||
(user_id, name or f"Backup {payload['created_at']}", backup_type, profile_id, json.dumps(payload), payload["created_at"]),
|
||||
)
|
||||
backup_id = cur.lastrowid
|
||||
return {
|
||||
"id": backup_id,
|
||||
"name": name,
|
||||
"backup_type": backup_type,
|
||||
"profile_id": profile_id,
|
||||
"created_at": payload["created_at"],
|
||||
"automatic": bool(payload.get("automatic")),
|
||||
"tables": {k: len(v) for k, v in (payload.get("tables") or {}).items()},
|
||||
}
|
||||
|
||||
|
||||
def create_app_backup(name: str, user_id: int | None = None, automatic: bool = False) -> dict:
|
||||
user_id = user_id or auth.current_user_id() or default_user_id()
|
||||
_require_admin(user_id)
|
||||
payload = {"version": 2, "backup_type": "app", "created_at": utcnow(), "automatic": bool(automatic), "tables": {}}
|
||||
with connect() as conn:
|
||||
for table in APP_BACKUP_TABLES:
|
||||
payload["tables"][table] = _table_rows(conn, table)
|
||||
return _store_backup(user_id, name, "app", None, payload)
|
||||
|
||||
|
||||
def create_profile_backup(name: str, profile_id: int, user_id: int | None = None, automatic: bool = False) -> dict:
|
||||
user_id = user_id or auth.current_user_id() or default_user_id()
|
||||
if not auth.can_write_profile(profile_id, user_id):
|
||||
raise PermissionError("No write access to profile")
|
||||
payload = {"version": 2, "backup_type": "profile", "source_profile_id": int(profile_id), "created_at": utcnow(), "automatic": bool(automatic), "tables": {}}
|
||||
with connect() as conn:
|
||||
for table in PROFILE_BACKUP_TABLES:
|
||||
where = PROFILE_TABLE_FILTERS.get(table)
|
||||
payload["tables"][table] = _table_rows(conn, table, where, _profile_filter_params(table, user_id, int(profile_id)))
|
||||
return _store_backup(user_id, name, "profile", int(profile_id), payload)
|
||||
|
||||
|
||||
def create_backup(name: str, user_id: int | None = None, automatic: bool = False) -> dict:
|
||||
return create_app_backup(name, user_id, automatic)
|
||||
|
||||
|
||||
def list_backups(user_id: int | None = None, backup_type: str | None = None, profile_id: int | None = None) -> list[dict]:
|
||||
user_id = user_id or auth.current_user_id() or default_user_id()
|
||||
clauses: list[str] = []
|
||||
params: list[object] = []
|
||||
if backup_type:
|
||||
clauses.append("COALESCE(backup_type,'app')=?")
|
||||
params.append(backup_type)
|
||||
if profile_id is not None:
|
||||
clauses.append("profile_id=?")
|
||||
params.append(int(profile_id))
|
||||
where = f"WHERE {' AND '.join(clauses)}" if clauses else ""
|
||||
with connect() as conn:
|
||||
rows = conn.execute(
|
||||
f"""
|
||||
SELECT b.id,b.name,b.user_id,b.created_at,b.payload_json,COALESCE(b.backup_type,'app') AS backup_type,b.profile_id,
|
||||
u.display_name AS owner_display_name,u.username AS owner_username,u.email AS owner_email
|
||||
FROM app_backups b
|
||||
LEFT JOIN users u ON u.id=b.user_id
|
||||
{where}
|
||||
ORDER BY b.id DESC
|
||||
""",
|
||||
tuple(params),
|
||||
).fetchall()
|
||||
result = []
|
||||
for row in rows:
|
||||
if not _backup_row_visible(row, user_id):
|
||||
continue
|
||||
payload = _loads(row.get("payload_json") or "{}")
|
||||
tables = payload.get("tables") or {}
|
||||
owner_name = str(row.get("owner_display_name") or row.get("owner_username") or row.get("owner_email") or f"user {row.get('user_id')}")
|
||||
result.append({
|
||||
"id": row.get("id"),
|
||||
"name": row.get("name"),
|
||||
"owner_user_id": row.get("user_id"),
|
||||
"owner_name": owner_name,
|
||||
"created_at": row.get("created_at"),
|
||||
"backup_type": row.get("backup_type") or payload.get("backup_type") or "app",
|
||||
"profile_id": row.get("profile_id") or payload.get("source_profile_id"),
|
||||
"automatic": bool(payload.get("automatic")),
|
||||
"tables": {key: len(value or []) for key, value in tables.items()},
|
||||
})
|
||||
return result
|
||||
|
||||
def payload_for_backup(backup_id: int, user_id: int | None = None, require_write: bool = False) -> dict:
|
||||
user_id = user_id or auth.current_user_id() or default_user_id()
|
||||
with connect() as conn:
|
||||
row = conn.execute("SELECT id,user_id,COALESCE(backup_type,'app') AS backup_type,profile_id,payload_json FROM app_backups WHERE id=?", (backup_id,)).fetchone()
|
||||
if not row or not (_backup_row_writable(row, user_id) if require_write else _backup_row_visible(row, user_id)):
|
||||
raise ValueError("Backup not found")
|
||||
return json.loads(row["payload_json"] or "{}")
|
||||
|
||||
def _backup_type(payload: dict) -> str:
|
||||
return str(payload.get("backup_type") or ("profile" if payload.get("source_profile_id") else "app"))
|
||||
|
||||
|
||||
def restore_app_backup(backup_id: int, user_id: int | None = None) -> dict:
|
||||
user_id = user_id or auth.current_user_id() or default_user_id()
|
||||
_require_admin(user_id)
|
||||
payload = payload_for_backup(backup_id, user_id, require_write=True)
|
||||
if _backup_type(payload) != "app":
|
||||
raise ValueError("This is not an application backup")
|
||||
tables = payload.get("tables") or {}
|
||||
restored = {}
|
||||
with connect() as conn:
|
||||
conn.execute("PRAGMA foreign_keys = OFF")
|
||||
try:
|
||||
for table in APP_BACKUP_TABLES:
|
||||
rows = tables.get(table) or []
|
||||
if not rows:
|
||||
continue
|
||||
available = _table_columns(conn, table)
|
||||
columns = [col for col in rows[0].keys() if col in available]
|
||||
if not columns:
|
||||
continue
|
||||
placeholders = ",".join("?" for _ in columns)
|
||||
conn.execute(f"DELETE FROM {table}")
|
||||
for row in rows:
|
||||
conn.execute(f"INSERT INTO {table}({','.join(columns)}) VALUES({placeholders})", [row.get(col) for col in columns])
|
||||
restored[table] = len(rows)
|
||||
finally:
|
||||
conn.execute("PRAGMA foreign_keys = ON")
|
||||
return {"restored": restored, "backup_type": "app"}
|
||||
|
||||
|
||||
def _single_profile_row(rows: list[dict]) -> list[dict]:
|
||||
if not rows:
|
||||
return []
|
||||
return [sorted(rows, key=lambda row: str(row.get("updated_at") or row.get("created_at") or ""), reverse=True)[0]]
|
||||
|
||||
|
||||
def _rewrite_profile_row(table: str, row: dict, user_id: int, target_profile_id: int) -> dict:
|
||||
clean = dict(row)
|
||||
if table == "rtorrent_profiles":
|
||||
clean["id"] = target_profile_id
|
||||
clean["user_id"] = user_id
|
||||
clean["is_default"] = int(clean.get("is_default") or 0)
|
||||
return clean
|
||||
if "profile_id" in clean:
|
||||
clean["profile_id"] = target_profile_id
|
||||
if "user_id" in clean:
|
||||
clean["user_id"] = user_id
|
||||
if table == "poller_settings":
|
||||
clean["profile_id"] = target_profile_id
|
||||
if "id" in clean and table != "rtorrent_profiles":
|
||||
clean.pop("id", None)
|
||||
return clean
|
||||
|
||||
|
||||
def restore_profile_backup(backup_id: int, target_profile_id: int, user_id: int | None = None) -> dict:
|
||||
user_id = user_id or auth.current_user_id() or default_user_id()
|
||||
if not auth.can_write_profile(target_profile_id, user_id):
|
||||
raise PermissionError("No write access to profile")
|
||||
payload = payload_for_backup(backup_id, user_id, require_write=True)
|
||||
if _backup_type(payload) != "profile":
|
||||
raise ValueError("This is not a profile backup")
|
||||
tables = payload.get("tables") or {}
|
||||
restored = {}
|
||||
with connect() as conn:
|
||||
conn.execute("PRAGMA foreign_keys = OFF")
|
||||
try:
|
||||
for table in PROFILE_BACKUP_TABLES:
|
||||
rows = tables.get(table) or []
|
||||
if table == "disk_monitor_preferences":
|
||||
rows = _single_profile_row([dict(row) for row in rows])
|
||||
where = PROFILE_TABLE_FILTERS.get(table)
|
||||
params = _profile_filter_params(table, user_id, int(target_profile_id))
|
||||
conn.execute(f"DELETE FROM {table} WHERE {where}", params)
|
||||
if not rows:
|
||||
continue
|
||||
count = 0
|
||||
for row in rows:
|
||||
clean = _rewrite_profile_row(table, dict(row), user_id, int(target_profile_id))
|
||||
available = _table_columns(conn, table)
|
||||
columns = [col for col in clean.keys() if col in available]
|
||||
if not columns:
|
||||
continue
|
||||
placeholders = ",".join("?" for _ in columns)
|
||||
conn.execute(f"INSERT INTO {table}({','.join(columns)}) VALUES({placeholders})", [clean.get(col) for col in columns])
|
||||
count += 1
|
||||
restored[table] = count
|
||||
finally:
|
||||
conn.execute("PRAGMA foreign_keys = ON")
|
||||
return {"restored": restored, "backup_type": "profile", "profile_id": int(target_profile_id)}
|
||||
|
||||
|
||||
def restore_backup(backup_id: int, user_id: int | None = None, profile_id: int | None = None) -> dict:
|
||||
payload = payload_for_backup(backup_id, user_id, require_write=True)
|
||||
if _backup_type(payload) == "profile":
|
||||
target = profile_id or payload.get("source_profile_id")
|
||||
if not target:
|
||||
raise ValueError("Missing target profile")
|
||||
return restore_profile_backup(backup_id, int(target), user_id)
|
||||
return restore_app_backup(backup_id, user_id)
|
||||
|
||||
|
||||
def delete_backup(backup_id: int, user_id: int | None = None) -> dict:
|
||||
user_id = user_id or auth.current_user_id() or default_user_id()
|
||||
with connect() as conn:
|
||||
row = conn.execute("SELECT id,user_id,COALESCE(backup_type,'app') AS backup_type,profile_id FROM app_backups WHERE id=?", (backup_id,)).fetchone()
|
||||
if not row or not _backup_row_writable(row, user_id):
|
||||
raise ValueError("Backup not found")
|
||||
cur = conn.execute("DELETE FROM app_backups WHERE id=?", (backup_id,))
|
||||
if not cur.rowcount:
|
||||
raise ValueError("Backup not found")
|
||||
return {"deleted": backup_id}
|
||||
|
||||
def _settings_row_key(user_id: int | None = None, backup_type: str = "app", profile_id: int | None = None) -> str:
|
||||
uid = user_id or auth.current_user_id() or default_user_id()
|
||||
scope = "profile" if backup_type == "profile" else "app"
|
||||
if scope == "profile":
|
||||
return f"{AUTO_BACKUP_SETTINGS_KEY}:profile:{int(profile_id or 0)}"
|
||||
return f"{AUTO_BACKUP_SETTINGS_KEY}:app:{uid}"
|
||||
|
||||
def _latest_backup_created_at(user_id: int, backup_type: str = "app", profile_id: int | None = None) -> str | None:
|
||||
clauses = ["COALESCE(backup_type,'app')=?"]
|
||||
params: list[object] = [backup_type]
|
||||
if backup_type == "profile":
|
||||
clauses.append("profile_id=?")
|
||||
params.append(int(profile_id or 0))
|
||||
else:
|
||||
clauses.append("user_id=?")
|
||||
params.append(user_id)
|
||||
with connect() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT created_at FROM app_backups WHERE user_id=? ORDER BY created_at DESC, id DESC LIMIT 1",
|
||||
(user_id,),
|
||||
f"SELECT created_at FROM app_backups WHERE {' AND '.join(clauses)} ORDER BY created_at DESC, id DESC LIMIT 1",
|
||||
tuple(params),
|
||||
).fetchone()
|
||||
return str(row["created_at"] or "") if row and row.get("created_at") else None
|
||||
|
||||
|
||||
def _preview_value(value: object) -> object:
|
||||
"""Return a safe, compact value for backup previews without exposing secrets."""
|
||||
if value is None or isinstance(value, (int, float, bool)):
|
||||
return value
|
||||
text = str(value)
|
||||
@@ -157,34 +388,41 @@ def _preview_row(row: dict) -> dict:
|
||||
output = {}
|
||||
for key, value in row.items():
|
||||
lowered = str(key).lower()
|
||||
if any(secret in lowered for secret in BACKUP_PREVIEW_SENSITIVE_KEYS):
|
||||
output[key] = "[hidden]"
|
||||
else:
|
||||
output[key] = _preview_value(value)
|
||||
output[key] = "[hidden]" if any(secret in lowered for secret in BACKUP_PREVIEW_SENSITIVE_KEYS) else _preview_value(value)
|
||||
return output
|
||||
|
||||
|
||||
def get_auto_backup_settings(user_id: int | None = None) -> dict:
|
||||
"""Return automatic backup schedule settings for the current user.
|
||||
|
||||
Note: The UI uses this as the single source for interval and retention controls.
|
||||
"""
|
||||
key = _settings_row_key(user_id)
|
||||
def get_auto_backup_settings(user_id: int | None = None, backup_type: str = "app", profile_id: int | None = None) -> dict:
|
||||
user_id = user_id or auth.current_user_id() or default_user_id()
|
||||
key = _settings_row_key(user_id, backup_type, profile_id)
|
||||
with connect() as conn:
|
||||
row = conn.execute("SELECT value FROM app_settings WHERE key=?", (key,)).fetchone()
|
||||
if not row and backup_type == "profile":
|
||||
legacy_key = f"{AUTO_BACKUP_SETTINGS_KEY}:profile:{int(user_id)}:{int(profile_id or 0)}"
|
||||
row = conn.execute("SELECT value FROM app_settings WHERE key=?", (legacy_key,)).fetchone()
|
||||
settings = {**DEFAULT_AUTO_BACKUP_SETTINGS, **_loads(row.get("value") if row else "{}")}
|
||||
settings["enabled"] = bool(settings.get("enabled"))
|
||||
settings["interval_hours"] = max(1, int(settings.get("interval_hours") or 24))
|
||||
settings["retention_days"] = max(1, int(settings.get("retention_days") or 30))
|
||||
settings["backup_type"] = "profile" if backup_type == "profile" else "app"
|
||||
if backup_type == "profile":
|
||||
settings["profile_id"] = int(profile_id or 0)
|
||||
settings["owner_user_id"] = user_id or auth.current_user_id() or default_user_id()
|
||||
with connect() as conn:
|
||||
settings["owner_name"] = _user_label(conn, settings["owner_user_id"])
|
||||
return settings
|
||||
|
||||
|
||||
def save_auto_backup_settings(data: dict, user_id: int | None = None) -> dict:
|
||||
"""Persist automatic backup schedule settings after validating UI input.
|
||||
|
||||
Note: Minimum interval is one hour to avoid creating excessive database rows.
|
||||
"""
|
||||
current = get_auto_backup_settings(user_id)
|
||||
def save_auto_backup_settings(data: dict, user_id: int | None = None, backup_type: str = "app", profile_id: int | None = None) -> dict:
|
||||
user_id = user_id or auth.current_user_id() or default_user_id()
|
||||
backup_type = "profile" if backup_type == "profile" else "app"
|
||||
if backup_type == "app":
|
||||
_require_admin(user_id)
|
||||
else:
|
||||
# Note: Profile backup schedules affect profile operations, so read-only users may view/export backups but cannot change automation.
|
||||
if not profile_id or not auth.can_write_profile(int(profile_id), user_id):
|
||||
raise PermissionError("No write access to profile")
|
||||
current = get_auto_backup_settings(user_id, backup_type, profile_id)
|
||||
settings = {
|
||||
**current,
|
||||
"enabled": bool(data.get("enabled")),
|
||||
@@ -192,22 +430,37 @@ def save_auto_backup_settings(data: dict, user_id: int | None = None) -> dict:
|
||||
"retention_days": max(1, int(data.get("retention_days") or current["retention_days"])),
|
||||
"last_run_at": data.get("last_run_at", current.get("last_run_at")),
|
||||
}
|
||||
key = _settings_row_key(user_id)
|
||||
key = _settings_row_key(user_id, backup_type, profile_id)
|
||||
with connect() as conn:
|
||||
conn.execute("INSERT OR REPLACE INTO app_settings(key,value) VALUES(?,?)", (key, json.dumps(settings)))
|
||||
return settings
|
||||
|
||||
|
||||
def preview_backup(backup_id: int, user_id: int | None = None) -> dict:
|
||||
"""Return a compact backup preview without exposing the full JSON payload in the list view.
|
||||
|
||||
Note: The preview shows included tables and example keys so users can verify settings coverage.
|
||||
"""
|
||||
def _backup_owner_info(backup_id: int) -> dict:
|
||||
with connect() as conn:
|
||||
row = conn.execute(
|
||||
"""
|
||||
SELECT b.user_id,COALESCE(u.display_name,u.username,u.email,'user ' || b.user_id) AS owner_name
|
||||
FROM app_backups b
|
||||
LEFT JOIN users u ON u.id=b.user_id
|
||||
WHERE b.id=?
|
||||
""",
|
||||
(int(backup_id),),
|
||||
).fetchone()
|
||||
return {"owner_user_id": row.get("user_id") if row else None, "owner_name": row.get("owner_name") if row else ""}
|
||||
|
||||
def preview_backup(backup_id: int, user_id: int | None = None) -> dict:
|
||||
payload = payload_for_backup(backup_id, user_id)
|
||||
tables = payload.get("tables") or {}
|
||||
owner = _backup_owner_info(backup_id)
|
||||
return {
|
||||
"version": payload.get("version"),
|
||||
"owner_user_id": owner.get("owner_user_id"),
|
||||
"owner_name": owner.get("owner_name"),
|
||||
"created_at": payload.get("created_at"),
|
||||
"backup_type": _backup_type(payload),
|
||||
"source_profile_id": payload.get("source_profile_id"),
|
||||
"automatic": bool(payload.get("automatic")),
|
||||
"tables": [
|
||||
{
|
||||
@@ -221,50 +474,87 @@ def preview_backup(backup_id: int, user_id: int | None = None) -> dict:
|
||||
}
|
||||
|
||||
|
||||
def prune_old_backups(user_id: int | None = None, retention_days: int = 30) -> int:
|
||||
"""Delete backups older than the configured retention window for the selected user.
|
||||
|
||||
Note: Retention is applied only to backup records, not to restored application settings.
|
||||
"""
|
||||
user_id = user_id or default_user_id()
|
||||
def prune_old_backups(user_id: int | None = None, retention_days: int = 30, backup_type: str = "app", profile_id: int | None = None) -> int:
|
||||
user_id = user_id or auth.current_user_id() or default_user_id()
|
||||
cutoff = (datetime.now(timezone.utc) - timedelta(days=max(1, int(retention_days)))).isoformat(timespec="seconds")
|
||||
clauses = ["COALESCE(backup_type,'app')=?", "created_at<?"]
|
||||
params: list[object] = [backup_type, cutoff]
|
||||
if backup_type == "profile":
|
||||
clauses.append("profile_id=?")
|
||||
params.append(int(profile_id or 0))
|
||||
else:
|
||||
clauses.append("user_id=?")
|
||||
params.append(user_id)
|
||||
with connect() as conn:
|
||||
cur = conn.execute("DELETE FROM app_backups WHERE user_id=? AND created_at<?", (user_id, cutoff))
|
||||
cur = conn.execute(f"DELETE FROM app_backups WHERE {' AND '.join(clauses)}", tuple(params))
|
||||
return int(cur.rowcount or 0)
|
||||
|
||||
|
||||
def maybe_create_automatic_backup(user_id: int | None = None) -> dict | None:
|
||||
"""Create an automatic backup when the saved interval has elapsed.
|
||||
|
||||
Note: The scheduler calls this periodically, while the UI controls the interval and retention values.
|
||||
"""
|
||||
user_id = user_id or default_user_id()
|
||||
settings = get_auto_backup_settings(user_id)
|
||||
if not settings.get("enabled"):
|
||||
return None
|
||||
def _should_run(settings: dict, last_value: str | None) -> bool:
|
||||
now = datetime.now(timezone.utc)
|
||||
last_value = settings.get("last_run_at") or _latest_backup_created_at(user_id)
|
||||
try:
|
||||
last = datetime.fromisoformat(str(last_value).replace("Z", "+00:00")) if last_value else None
|
||||
except Exception:
|
||||
last = None
|
||||
if last and now - last < timedelta(hours=settings["interval_hours"]):
|
||||
return not last or now - last >= timedelta(hours=settings["interval_hours"])
|
||||
|
||||
|
||||
def maybe_create_automatic_backup(user_id: int | None = None, backup_type: str = "app", profile_id: int | None = None) -> dict | None:
|
||||
user_id = user_id or default_user_id()
|
||||
backup_type = "profile" if backup_type == "profile" else "app"
|
||||
if backup_type == "app" and not _is_admin_user(user_id):
|
||||
return None
|
||||
if backup_type == "profile" and (not profile_id or not auth.can_access_profile(int(profile_id), user_id)):
|
||||
return None
|
||||
settings = get_auto_backup_settings(user_id, backup_type, profile_id)
|
||||
if not settings.get("enabled"):
|
||||
return None
|
||||
last_value = settings.get("last_run_at") or _latest_backup_created_at(user_id, backup_type, profile_id)
|
||||
if not _should_run(settings, last_value):
|
||||
if settings.get("last_run_at") != last_value:
|
||||
settings["last_run_at"] = last_value
|
||||
save_auto_backup_settings(settings, user_id)
|
||||
save_auto_backup_settings(settings, user_id, backup_type, profile_id)
|
||||
return None
|
||||
backup = create_backup(f"Automatic backup {now.isoformat(timespec='seconds')}", user_id, automatic=True)
|
||||
now = datetime.now(timezone.utc)
|
||||
if backup_type == "profile":
|
||||
backup = create_profile_backup(f"Automatic profile backup {now.isoformat(timespec='seconds')}", int(profile_id or 0), user_id, automatic=True)
|
||||
else:
|
||||
backup = create_app_backup(f"Automatic application backup {now.isoformat(timespec='seconds')}", user_id, automatic=True)
|
||||
settings["last_run_at"] = backup.get("created_at") or now.isoformat(timespec="seconds")
|
||||
save_auto_backup_settings(settings, user_id)
|
||||
prune_old_backups(user_id, settings["retention_days"])
|
||||
save_auto_backup_settings(settings, user_id, backup_type, profile_id)
|
||||
prune_old_backups(user_id, settings["retention_days"], backup_type, profile_id)
|
||||
return backup
|
||||
|
||||
|
||||
def start_scheduler() -> None:
|
||||
"""Start a lightweight automatic-backup scheduler.
|
||||
def _profile_schedule_keys() -> list[tuple[int, int]]:
|
||||
prefix = f"{AUTO_BACKUP_SETTINGS_KEY}:profile:"
|
||||
keys: set[tuple[int, int]] = set()
|
||||
with connect() as conn:
|
||||
rows = conn.execute("SELECT key FROM app_settings WHERE key LIKE ?", (prefix + "%",)).fetchall()
|
||||
for row in rows:
|
||||
parts = str(row.get("key") or "").split(":")
|
||||
try:
|
||||
if len(parts) >= 5:
|
||||
# Legacy key: backup:auto:profile:{uid}:{profile_id}
|
||||
keys.add((int(parts[-2]), int(parts[-1])))
|
||||
elif len(parts) >= 4:
|
||||
profile_id = int(parts[-1])
|
||||
keys.add((_profile_owner_for_backup(profile_id), profile_id))
|
||||
except Exception:
|
||||
continue
|
||||
return sorted(keys)
|
||||
|
||||
Note: It scans configured users and never blocks normal request handling.
|
||||
"""
|
||||
|
||||
def _profile_owner_for_backup(profile_id: int) -> int:
|
||||
with connect() as conn:
|
||||
row = conn.execute("SELECT user_id FROM rtorrent_profiles WHERE id=?", (int(profile_id),)).fetchone()
|
||||
if row and row.get("user_id"):
|
||||
return int(row["user_id"])
|
||||
row = conn.execute("SELECT user_id FROM user_profile_permissions WHERE profile_id=? AND access_level='full' ORDER BY user_id LIMIT 1", (int(profile_id),)).fetchone()
|
||||
if row and row.get("user_id"):
|
||||
return int(row["user_id"])
|
||||
return default_user_id()
|
||||
|
||||
def start_scheduler() -> None:
|
||||
global _scheduler_started
|
||||
with _scheduler_lock:
|
||||
if _scheduler_started:
|
||||
@@ -275,10 +565,12 @@ def start_scheduler() -> None:
|
||||
while True:
|
||||
try:
|
||||
with connect() as conn:
|
||||
rows = conn.execute("SELECT id FROM users WHERE is_active=1").fetchall()
|
||||
rows = conn.execute("SELECT id FROM users WHERE is_active=1 AND role='admin'").fetchall()
|
||||
user_ids = [int(row["id"]) for row in rows] or [default_user_id()]
|
||||
for uid in user_ids:
|
||||
maybe_create_automatic_backup(uid)
|
||||
maybe_create_automatic_backup(uid, "app")
|
||||
for uid, pid in _profile_schedule_keys():
|
||||
maybe_create_automatic_backup(uid, "profile", pid)
|
||||
except Exception:
|
||||
pass
|
||||
time.sleep(300)
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
from __future__ import annotations
|
||||
import shutil
|
||||
import sqlite3
|
||||
import threading
|
||||
import time
|
||||
from typing import Any
|
||||
from ..config import DB_PATH
|
||||
|
||||
_VACUUM_LOCK = threading.Lock()
|
||||
MIN_DISK_HEADROOM_BYTES = 128 * 1024 * 1024
|
||||
|
||||
|
||||
def _human_size(value: int | float | None) -> str:
|
||||
size = float(value or 0)
|
||||
units = ["B", "KiB", "MiB", "GiB", "TiB"]
|
||||
idx = 0
|
||||
while size >= 1024 and idx < len(units) - 1:
|
||||
size /= 1024.0
|
||||
idx += 1
|
||||
if idx == 0:
|
||||
return f"{int(size)} {units[idx]}"
|
||||
return f"{size:.2f} {units[idx]}"
|
||||
|
||||
|
||||
def _connect() -> sqlite3.Connection:
|
||||
DB_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||
conn = sqlite3.connect(DB_PATH, timeout=60, isolation_level=None)
|
||||
conn.row_factory = sqlite3.Row
|
||||
conn.execute("PRAGMA busy_timeout = 60000")
|
||||
return conn
|
||||
|
||||
|
||||
def _pragma_int(conn: sqlite3.Connection, pragma_name: str) -> int:
|
||||
row = conn.execute(f"PRAGMA {pragma_name}").fetchone()
|
||||
if row is None:
|
||||
return 0
|
||||
return int(row[0] or 0)
|
||||
|
||||
|
||||
def database_status() -> dict[str, Any]:
|
||||
size_bytes = DB_PATH.stat().st_size if DB_PATH.exists() else 0
|
||||
wal_path = DB_PATH.with_name(DB_PATH.name + "-wal")
|
||||
wal_bytes = wal_path.stat().st_size if wal_path.exists() else 0
|
||||
page_size = 0
|
||||
page_count = 0
|
||||
freelist_count = 0
|
||||
error = None
|
||||
if DB_PATH.exists():
|
||||
try:
|
||||
with _connect() as conn:
|
||||
page_size = _pragma_int(conn, "page_size")
|
||||
page_count = _pragma_int(conn, "page_count")
|
||||
freelist_count = _pragma_int(conn, "freelist_count")
|
||||
except Exception as exc:
|
||||
error = str(exc)
|
||||
free_bytes = int(page_size * freelist_count)
|
||||
logical_bytes = int(page_size * page_count)
|
||||
free_ratio = (free_bytes / logical_bytes) if logical_bytes else 0.0
|
||||
try:
|
||||
disk = shutil.disk_usage(str(DB_PATH.parent))
|
||||
disk_free = int(disk.free)
|
||||
except Exception:
|
||||
disk_free = 0
|
||||
return {
|
||||
"path": str(DB_PATH),
|
||||
"size": int(size_bytes),
|
||||
"size_h": _human_size(size_bytes),
|
||||
"wal_size": int(wal_bytes),
|
||||
"wal_size_h": _human_size(wal_bytes),
|
||||
"page_size": page_size,
|
||||
"page_count": page_count,
|
||||
"freelist_count": freelist_count,
|
||||
"free_inside": free_bytes,
|
||||
"free_inside_h": _human_size(free_bytes),
|
||||
"free_ratio": round(free_ratio, 4),
|
||||
"free_ratio_percent": round(free_ratio * 100, 2),
|
||||
"disk_free": disk_free,
|
||||
"disk_free_h": _human_size(disk_free),
|
||||
"vacuum_running": _VACUUM_LOCK.locked(),
|
||||
"error": error,
|
||||
}
|
||||
|
||||
|
||||
def _checkpoint_truncate(conn: sqlite3.Connection) -> dict[str, int] | None:
|
||||
try:
|
||||
row = conn.execute("PRAGMA wal_checkpoint(TRUNCATE)").fetchone()
|
||||
if row is None:
|
||||
return None
|
||||
return {"busy": int(row[0] or 0), "log": int(row[1] or 0), "checkpointed": int(row[2] or 0)}
|
||||
except sqlite3.DatabaseError:
|
||||
return None
|
||||
|
||||
|
||||
def vacuum_database(force: bool = False) -> dict[str, Any]:
|
||||
if not DB_PATH.exists():
|
||||
raise FileNotFoundError(f"Database not found: {DB_PATH}")
|
||||
if not _VACUUM_LOCK.acquire(blocking=False):
|
||||
raise RuntimeError("Database vacuum is already running")
|
||||
try:
|
||||
before = database_status()
|
||||
required_free = int(before.get("size") or 0) + MIN_DISK_HEADROOM_BYTES
|
||||
available_free = int(before.get("disk_free") or 0)
|
||||
if available_free and available_free < required_free:
|
||||
raise RuntimeError(
|
||||
"Not enough free disk space for VACUUM: "
|
||||
f"need about {_human_size(required_free)}, have {_human_size(available_free)}"
|
||||
)
|
||||
if not force and int(before.get("free_inside") or 0) <= 0:
|
||||
return {"ok": True, "skipped": True, "reason": "No free pages inside SQLite database", "before": before, "after": before}
|
||||
started = time.perf_counter()
|
||||
with _connect() as conn:
|
||||
checkpoint_before = _checkpoint_truncate(conn)
|
||||
conn.execute("VACUUM")
|
||||
checkpoint_after = _checkpoint_truncate(conn)
|
||||
after = database_status()
|
||||
return {
|
||||
"ok": True,
|
||||
"skipped": False,
|
||||
"duration_seconds": round(time.perf_counter() - started, 3),
|
||||
"checkpoint_before": checkpoint_before,
|
||||
"checkpoint_after": checkpoint_after,
|
||||
"before": before,
|
||||
"after": after,
|
||||
"reclaimed": max(0, int(before.get("size") or 0) - int(after.get("size") or 0)),
|
||||
"reclaimed_h": _human_size(max(0, int(before.get("size") or 0) - int(after.get("size") or 0))),
|
||||
}
|
||||
finally:
|
||||
_VACUUM_LOCK.release()
|
||||
@@ -1,7 +1,5 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from . import download_planner
|
||||
|
||||
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import time
|
||||
import psutil
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
import psutil
|
||||
|
||||
from ..db import connect, default_user_id, utcnow
|
||||
from . import rtorrent
|
||||
from . import auth, operation_logs, rtorrent
|
||||
|
||||
PLANNER_STARTUP_DELAY_SECONDS = 60
|
||||
_APP_STARTED_AT = time.monotonic()
|
||||
|
||||
DEFAULTS = {
|
||||
"enabled": False,
|
||||
@@ -45,6 +45,34 @@ DEFAULTS = {
|
||||
_LAST_RUN: dict[int, float] = {}
|
||||
_LAST_LIMITS: dict[int, tuple[int, int]] = {}
|
||||
_HIGH_CPU_SINCE: dict[int, float] = {}
|
||||
_PLANNER_CONNECTION_STATUS: dict[int, str] = {}
|
||||
|
||||
|
||||
def _rtorrent_ready(profile: dict) -> tuple[bool, str]:
|
||||
"""Check rTorrent connectivity before the planner evaluates or applies changes."""
|
||||
try:
|
||||
rtorrent.client_for(profile).call("system.client_version")
|
||||
return True, ""
|
||||
except Exception as exc:
|
||||
return False, str(exc)
|
||||
|
||||
|
||||
def _log_connection_status(profile: dict, status: str, message: str, *, error: str = "", user_id: int | None = None) -> None:
|
||||
"""Record planner connectivity state changes as system operations without noisy repeats."""
|
||||
profile_id = int(profile.get("id") or 0)
|
||||
if _PLANNER_CONNECTION_STATUS.get(profile_id) == status:
|
||||
return
|
||||
_PLANNER_CONNECTION_STATUS[profile_id] = status
|
||||
operation_logs.record(
|
||||
profile_id,
|
||||
"download_planner_status",
|
||||
message,
|
||||
severity="warning" if error else "info",
|
||||
source="system",
|
||||
action="download_planner",
|
||||
details={"status": status, "error": error},
|
||||
user_id=user_id or int(profile.get("user_id") or 0) or None,
|
||||
)
|
||||
|
||||
|
||||
def _bool(value: Any) -> bool:
|
||||
@@ -140,14 +168,31 @@ def normalize(data: dict | None) -> dict:
|
||||
}
|
||||
|
||||
|
||||
def _row(user_id: int, profile_id: int) -> dict | None:
|
||||
def _row(user_id: int | None, profile_id: int) -> dict | None:
|
||||
with connect() as conn:
|
||||
return conn.execute(
|
||||
"SELECT * FROM download_plan_settings WHERE user_id=? AND profile_id=?",
|
||||
(user_id, profile_id),
|
||||
row = conn.execute(
|
||||
"SELECT * FROM download_plan_settings WHERE profile_id=? ORDER BY updated_at DESC, user_id ASC LIMIT 1",
|
||||
(profile_id,),
|
||||
).fetchone()
|
||||
if row:
|
||||
return row
|
||||
if user_id:
|
||||
return conn.execute(
|
||||
"SELECT * FROM download_plan_settings WHERE user_id=? AND profile_id=?",
|
||||
(user_id, profile_id),
|
||||
).fetchone()
|
||||
return None
|
||||
|
||||
|
||||
def _user_label(user_id: int | None) -> str:
|
||||
if not user_id:
|
||||
return "system"
|
||||
with connect() as conn:
|
||||
row = conn.execute("SELECT display_name, username, email FROM users WHERE id=?", (int(user_id),)).fetchone()
|
||||
if row:
|
||||
return str(row.get("display_name") or row.get("username") or row.get("email") or f"user {user_id}")
|
||||
return f"user {user_id}"
|
||||
|
||||
|
||||
|
||||
def _preference_row_for_disk_source(profile_id: int, user_id: int | None = None) -> dict | None:
|
||||
@@ -269,12 +314,13 @@ def get_settings(profile_id: int, user_id: int | None = None) -> dict:
|
||||
row = _row(user_id, profile_id)
|
||||
if not row:
|
||||
migrated = normalize({**DEFAULTS, **_legacy_disk_guard_defaults(int(profile_id), user_id)})
|
||||
return {**migrated, "profile_id": int(profile_id), "user_id": int(user_id)}
|
||||
return {**migrated, "profile_id": int(profile_id), "owner_user_id": int(user_id), "owner_name": _user_label(user_id)}
|
||||
try:
|
||||
data = json.loads(row.get("settings_json") or "{}")
|
||||
except Exception:
|
||||
data = {}
|
||||
settings = {**normalize(data), "profile_id": int(profile_id), "user_id": int(user_id), "updated_at": row.get("updated_at")}
|
||||
owner_user_id = int(row.get("user_id") or user_id)
|
||||
settings = {**normalize(data), "profile_id": int(profile_id), "owner_user_id": owner_user_id, "owner_name": _user_label(owner_user_id), "updated_at": row.get("updated_at")}
|
||||
runtime_override = _override_until(int(profile_id))
|
||||
if runtime_override:
|
||||
settings["manual_override_until"] = runtime_override
|
||||
@@ -283,18 +329,20 @@ def get_settings(profile_id: int, user_id: int | None = None) -> dict:
|
||||
|
||||
def save_settings(profile_id: int, data: dict, user_id: int | None = None) -> dict:
|
||||
user_id = user_id or default_user_id()
|
||||
if not auth.can_write_profile(int(profile_id), user_id):
|
||||
raise PermissionError("No write access to profile")
|
||||
settings = normalize(data)
|
||||
now = utcnow()
|
||||
with connect() as conn:
|
||||
conn.execute("DELETE FROM download_plan_settings WHERE profile_id=?", (int(profile_id),))
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO download_plan_settings(user_id, profile_id, settings_json, updated_at)
|
||||
VALUES(?,?,?,?)
|
||||
ON CONFLICT(user_id, profile_id) DO UPDATE SET settings_json=excluded.settings_json, updated_at=excluded.updated_at
|
||||
""",
|
||||
(user_id, profile_id, json.dumps(settings), now),
|
||||
)
|
||||
return {**settings, "profile_id": int(profile_id), "user_id": int(user_id), "updated_at": now}
|
||||
return {**settings, "profile_id": int(profile_id), "owner_user_id": int(user_id), "owner_name": _user_label(user_id), "updated_at": now}
|
||||
|
||||
|
||||
def _active_downloading_hashes(profile: dict) -> list[str]:
|
||||
@@ -303,7 +351,7 @@ def _active_downloading_hashes(profile: dict) -> list[str]:
|
||||
for row in rows:
|
||||
if int(row.get("complete") or 0):
|
||||
continue
|
||||
if int(row.get("state") or 0) and not row.get("paused"):
|
||||
if int(row.get("state") or 0) and not row.get("paused") and str(row.get("status") or "") != "Queued":
|
||||
h = str(row.get("hash") or "")
|
||||
if h:
|
||||
hashes.append(h)
|
||||
@@ -443,16 +491,28 @@ def evaluate(profile: dict, settings: dict | None = None, now: datetime | None =
|
||||
}
|
||||
|
||||
|
||||
def enforce(profile: dict, force: bool = False) -> dict:
|
||||
def enforce(profile: dict, force: bool = False, user_id: int | None = None) -> dict:
|
||||
profile_id = int(profile.get("id") or 0)
|
||||
settings = get_settings(profile_id)
|
||||
settings = get_settings(profile_id, user_id or int(profile.get("user_id") or default_user_id()))
|
||||
user_id = int(settings.get("owner_user_id") or user_id or profile.get("user_id") or default_user_id())
|
||||
if not auth.can_write_profile(profile_id, user_id):
|
||||
return {"ok": True, "enabled": False, "profile_id": profile_id, "skipped": True, "reason": "planner owner has no write access", "history": history(profile_id, 20), "history_total": history_count(profile_id)}
|
||||
if not settings.get("enabled"):
|
||||
return {"ok": True, "enabled": False, "profile_id": profile_id, "history": history(profile_id, 20), "history_total": history_count(profile_id), "preview": preview(profile)}
|
||||
return {"ok": True, "enabled": False, "profile_id": profile_id, "history": history(profile_id, 20), "history_total": history_count(profile_id), "preview": preview(profile, user_id=user_id)}
|
||||
startup_remaining = int(PLANNER_STARTUP_DELAY_SECONDS - (time.monotonic() - _APP_STARTED_AT))
|
||||
if not force and startup_remaining > 0:
|
||||
# Note: The background planner keeps the same startup grace as rTorrent config apply, while manual checks still run immediately.
|
||||
return {"ok": True, "enabled": True, "profile_id": profile_id, "skipped": True, "reason": "startup_delay", "retry_after_seconds": startup_remaining}
|
||||
now = time.monotonic()
|
||||
interval = int(settings.get("check_interval_seconds") or 30)
|
||||
if not force and now - _LAST_RUN.get(profile_id, 0) < interval:
|
||||
return {"ok": True, "enabled": True, "profile_id": profile_id, "skipped": True}
|
||||
_LAST_RUN[profile_id] = now
|
||||
ready, connection_error = _rtorrent_ready(profile)
|
||||
if not ready:
|
||||
_log_connection_status(profile, "waiting", f"Download Planner is waiting for rTorrent: {connection_error}", error=connection_error, user_id=user_id)
|
||||
return {"ok": True, "enabled": True, "profile_id": profile_id, "skipped": True, "reason": "rtorrent_unavailable", "error": connection_error, "retry_after_seconds": interval}
|
||||
_log_connection_status(profile, "connected", "Download Planner detected a working rTorrent connection", user_id=user_id)
|
||||
decision = evaluate(profile, settings)
|
||||
result: dict[str, Any] = {"ok": True, "enabled": True, **decision, "limits_changed": False, "paused": 0, "resumed": 0}
|
||||
wanted_limits = (int(decision["down"]), int(decision["up"]))
|
||||
@@ -497,13 +557,13 @@ def enforce(profile: dict, force: bool = False) -> dict:
|
||||
_append_history(profile_id, "resumed_torrents", {"count": len(hashes), "dry_run": dry_run})
|
||||
result["history"] = history(profile_id, 20)
|
||||
result["history_total"] = history_count(profile_id)
|
||||
result["preview"] = preview(profile)
|
||||
result["preview"] = preview(profile, user_id=user_id)
|
||||
return result
|
||||
|
||||
|
||||
def preview(profile: dict) -> dict:
|
||||
def preview(profile: dict, user_id: int | None = None) -> dict:
|
||||
profile_id = int(profile.get("id") or 0)
|
||||
settings = get_settings(profile_id)
|
||||
settings = get_settings(profile_id, user_id or int(profile.get("user_id") or default_user_id()))
|
||||
decision = evaluate(profile, settings)
|
||||
return {
|
||||
"profile_id": profile_id,
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from ..config import BASE_DIR, USE_OFFLINE_LIBS
|
||||
|
||||
LIBS_STATIC_DIR = "libs"
|
||||
@@ -40,18 +38,76 @@ def google_fonts_css_url() -> str:
|
||||
return f"https://fonts.googleapis.com/css2?{families}&display=swap"
|
||||
|
||||
|
||||
BOOTSTRAP_THEMES = (
|
||||
"default",
|
||||
"flatly",
|
||||
"litera",
|
||||
"lumen",
|
||||
"minty",
|
||||
"sketchy",
|
||||
"solar",
|
||||
"spacelab",
|
||||
"united",
|
||||
"zephyr",
|
||||
)
|
||||
DEVEXPRESS_BOOTSTRAP_THEMES = {
|
||||
"blazing-berry": "Blazing Berry",
|
||||
"office-white": "Office White",
|
||||
"purple": "Purple",
|
||||
}
|
||||
|
||||
PYTORRENT_APP_THEMES = {
|
||||
"adaptive": "pyTorrent Adaptive",
|
||||
"ocean": "pyTorrent Ocean",
|
||||
"graphite": "pyTorrent Graphite",
|
||||
"forest": "pyTorrent Forest",
|
||||
"amber": "pyTorrent Amber",
|
||||
"nord": "pyTorrent Nord",
|
||||
"crimson": "pyTorrent Crimson",
|
||||
"sky": "pyTorrent Sky",
|
||||
"bootstrap22": "Bootstrap 2 Classic",
|
||||
"bootstrap22-inverse": "Bootstrap 2 Inverse",
|
||||
"bootstrap3": "Bootstrap 3 Glyph",
|
||||
"bootstrap3-inverse": "Bootstrap 3 Inverse",
|
||||
}
|
||||
|
||||
|
||||
BOOTSTRAP_THEME_DEFINITIONS = {
|
||||
"default": {
|
||||
"label": "Default Bootstrap",
|
||||
"local": f"{LIBS_STATIC_DIR}/bootstrap/{BOOTSTRAP_VERSION}/css/bootstrap.min.css",
|
||||
"cdn": f"https://cdn.jsdelivr.net/npm/bootstrap@{BOOTSTRAP_VERSION}/dist/css/bootstrap.min.css",
|
||||
},
|
||||
# Bootswatch themes.
|
||||
"flatly": {"label": "Bootswatch: Flatly", "provider": "bootswatch"},
|
||||
"litera": {"label": "Bootswatch: Litera", "provider": "bootswatch"},
|
||||
"lumen": {"label": "Bootswatch: Lumen", "provider": "bootswatch"},
|
||||
"minty": {"label": "Bootswatch: Minty", "provider": "bootswatch"},
|
||||
"sketchy": {"label": "Bootswatch: Sketchy", "provider": "bootswatch"},
|
||||
"spacelab": {"label": "Bootswatch: Spacelab", "provider": "bootswatch"},
|
||||
"united": {"label": "Bootswatch: United", "provider": "bootswatch"},
|
||||
"zephyr": {"label": "Bootswatch: Zephyr", "provider": "bootswatch"},
|
||||
# Complete DevExpress Bootstrap v5 dist.v5 set.
|
||||
**{
|
||||
f"dx-{theme}": {
|
||||
"label": f"DevExpress: {label}",
|
||||
"provider": "devexpress",
|
||||
"local": f"{LIBS_STATIC_DIR}/devexpress-bootstrap-themes/dist.v5/{theme}/bootstrap.min.css",
|
||||
"cdn": f"https://cdn.jsdelivr.net/gh/DevExpress/bootstrap-themes@master/dist.v5/{theme}/bootstrap.min.css",
|
||||
}
|
||||
for theme, label in DEVEXPRESS_BOOTSTRAP_THEMES.items()
|
||||
},
|
||||
# App-specific Bootstrap variable overrides. These sit on top of default Bootstrap.
|
||||
**{
|
||||
f"pytorrent-{theme}": {
|
||||
"label": f"Custom: {label}",
|
||||
"provider": "pytorrent",
|
||||
"local": f"{LIBS_STATIC_DIR}/pytorrent-themes/{theme}/bootstrap.min.css",
|
||||
"cdn": f"/static/{LIBS_STATIC_DIR}/pytorrent-themes/{theme}/bootstrap.min.css",
|
||||
}
|
||||
for theme, label in PYTORRENT_APP_THEMES.items()
|
||||
},
|
||||
}
|
||||
|
||||
def _theme_definition(theme: str | None) -> dict[str, str]:
|
||||
theme = theme if theme in BOOTSTRAP_THEME_DEFINITIONS else "default"
|
||||
item = dict(BOOTSTRAP_THEME_DEFINITIONS[theme])
|
||||
if item.get("provider") == "bootswatch":
|
||||
item["local"] = f"{LIBS_STATIC_DIR}/bootswatch/{BOOTSWATCH_VERSION}/{theme}/bootstrap.min.css"
|
||||
item["cdn"] = f"https://cdn.jsdelivr.net/npm/bootswatch@{BOOTSWATCH_VERSION}/dist/{theme}/bootstrap.min.css"
|
||||
return item
|
||||
|
||||
|
||||
BOOTSTRAP_THEMES = tuple(BOOTSTRAP_THEME_DEFINITIONS.keys())
|
||||
BOOTSTRAP_THEME_LABELS = {key: value["label"] for key, value in BOOTSTRAP_THEME_DEFINITIONS.items()}
|
||||
|
||||
STATIC_ASSETS = {
|
||||
"bootstrap_js": {
|
||||
@@ -86,16 +142,8 @@ STATIC_ASSETS = {
|
||||
|
||||
|
||||
def bootstrap_css_asset(theme: str | None = None) -> dict[str, str]:
|
||||
theme = theme if theme in BOOTSTRAP_THEMES else "default"
|
||||
if theme == "default":
|
||||
return {
|
||||
"local": f"{LIBS_STATIC_DIR}/bootstrap/{BOOTSTRAP_VERSION}/css/bootstrap.min.css",
|
||||
"cdn": f"https://cdn.jsdelivr.net/npm/bootstrap@{BOOTSTRAP_VERSION}/dist/css/bootstrap.min.css",
|
||||
}
|
||||
return {
|
||||
"local": f"{LIBS_STATIC_DIR}/bootswatch/{BOOTSWATCH_VERSION}/{theme}/bootstrap.min.css",
|
||||
"cdn": f"https://cdn.jsdelivr.net/npm/bootswatch@{BOOTSWATCH_VERSION}/dist/{theme}/bootstrap.min.css",
|
||||
}
|
||||
item = _theme_definition(theme)
|
||||
return {"local": item["local"], "cdn": item["cdn"]}
|
||||
|
||||
|
||||
def asset_path(key: str) -> str:
|
||||
@@ -138,3 +186,70 @@ def validate_offline_assets() -> None:
|
||||
"Run: ./scripts/download_frontend_libs.py or ./install.sh\n"
|
||||
f"Missing files:\n{preview}{extra}"
|
||||
)
|
||||
|
||||
|
||||
_STATIC_HASH_VALUE = "dev"
|
||||
_STATIC_HASH_READY = False
|
||||
|
||||
|
||||
def _versioned_static_files(root: Path) -> list[Path]:
|
||||
"""Return static files that should invalidate frontend JS/CSS caches.
|
||||
|
||||
Note: Only JavaScript and CSS affect the executable frontend version. Images,
|
||||
favicons and user-provided tracker icons stay outside this lightweight hash.
|
||||
"""
|
||||
return [
|
||||
path
|
||||
for path in root.rglob("*")
|
||||
if path.is_file()
|
||||
and path.suffix.lower() in {".js", ".css"}
|
||||
and "tracker_favicons" not in path.parts
|
||||
]
|
||||
|
||||
|
||||
def compute_static_hash(static_root: Path | None = None) -> str:
|
||||
"""Compute one short startup hash for frontend JavaScript and CSS files.
|
||||
|
||||
Note: This function reads JS/CSS files and should be called during app
|
||||
startup, not from frequent request handlers.
|
||||
"""
|
||||
import hashlib
|
||||
|
||||
root = static_root or (BASE_DIR / "pytorrent" / "static")
|
||||
digest = hashlib.sha256()
|
||||
files = sorted(_versioned_static_files(root), key=lambda item: item.as_posix())
|
||||
for path in files:
|
||||
rel = path.relative_to(root).as_posix()
|
||||
try:
|
||||
stat = path.stat()
|
||||
content = path.read_bytes()
|
||||
except OSError:
|
||||
continue
|
||||
digest.update(rel.encode("utf-8"))
|
||||
digest.update(str(stat.st_size).encode("ascii"))
|
||||
digest.update(content)
|
||||
value = digest.hexdigest()[:16]
|
||||
return value or "dev"
|
||||
|
||||
|
||||
def initialize_static_hash(static_root: Path | None = None) -> str:
|
||||
"""Compute and store the frontend static hash once for this process.
|
||||
|
||||
Note: The API endpoint and template helpers only return this in-memory value,
|
||||
which keeps mobile version checks ultra-light.
|
||||
"""
|
||||
global _STATIC_HASH_VALUE, _STATIC_HASH_READY
|
||||
_STATIC_HASH_VALUE = compute_static_hash(static_root)
|
||||
_STATIC_HASH_READY = True
|
||||
return _STATIC_HASH_VALUE
|
||||
|
||||
|
||||
def static_hash(static_root: Path | None = None) -> str:
|
||||
"""Return the startup frontend static hash without rescanning files.
|
||||
|
||||
Note: The optional argument is kept for compatibility with existing callers;
|
||||
it is only used for a lazy fallback before app startup initialization.
|
||||
"""
|
||||
if not _STATIC_HASH_READY:
|
||||
return initialize_static_hash(static_root)
|
||||
return _STATIC_HASH_VALUE
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
from ..config import GEOIP_DB
|
||||
|
||||
try:
|
||||
import geoip2.database
|
||||
except Exception: # pragma: no cover
|
||||
except Exception:
|
||||
geoip2 = None
|
||||
|
||||
_reader = None
|
||||
|
||||
@@ -1,24 +1,102 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any
|
||||
from ..db import connect, utcnow, default_user_id
|
||||
from . import auth, rtorrent
|
||||
from . import auth
|
||||
|
||||
DEFAULT_SETTINGS = {"retention_mode": "days", "retention_days": 30, "retention_lines": 5000}
|
||||
VALID_RETENTION_MODES = {"days", "lines", "both", "manual"}
|
||||
|
||||
DEFAULT_SETTINGS = {
|
||||
"retention_mode": "days",
|
||||
"retention_days": 30,
|
||||
"retention_lines": 5000,
|
||||
"retention_interval_hours": 24,
|
||||
}
|
||||
DEFAULT_CATEGORY_SETTINGS = {
|
||||
"job": {"retention_mode": "days", "retention_days": 7, "retention_lines": 2000, "retention_interval_hours": 24},
|
||||
"operation": {"retention_mode": "days", "retention_days": 30, "retention_lines": 5000, "retention_interval_hours": 24},
|
||||
}
|
||||
VALID_LOG_CATEGORIES = {"job", "operation"}
|
||||
MAX_DETAIL_TEXT = 4000
|
||||
MAX_DETAIL_ITEMS = 200
|
||||
|
||||
|
||||
def _user_id(user_id: int | None = None) -> int:
|
||||
return int(user_id or auth.current_user_id() or default_user_id())
|
||||
|
||||
|
||||
def _json_safe(value: Any, depth: int = 0) -> Any:
|
||||
"""Convert operation details to JSON-safe data without dropping the whole payload on one bad value."""
|
||||
if depth > 8:
|
||||
return str(value)[:MAX_DETAIL_TEXT]
|
||||
if value is None or isinstance(value, (bool, int, float, str)):
|
||||
if isinstance(value, str) and len(value) > MAX_DETAIL_TEXT:
|
||||
return value[:MAX_DETAIL_TEXT] + "..."
|
||||
return value
|
||||
if isinstance(value, bytes):
|
||||
return f"<bytes:{len(value)}>"
|
||||
if isinstance(value, (list, tuple, set)):
|
||||
data = list(value)
|
||||
safe = [_json_safe(item, depth + 1) for item in data[:MAX_DETAIL_ITEMS]]
|
||||
if len(data) > MAX_DETAIL_ITEMS:
|
||||
safe.append({"truncated_items": len(data) - MAX_DETAIL_ITEMS})
|
||||
return safe
|
||||
if isinstance(value, dict):
|
||||
items = list(value.items())
|
||||
safe = {str(k): _json_safe(v, depth + 1) for k, v in items[:MAX_DETAIL_ITEMS]}
|
||||
if len(items) > MAX_DETAIL_ITEMS:
|
||||
safe["truncated_keys"] = len(items) - MAX_DETAIL_ITEMS
|
||||
return safe
|
||||
return str(value)[:MAX_DETAIL_TEXT]
|
||||
|
||||
|
||||
def _details(value: dict | None = None) -> str:
|
||||
"""Serialize details defensively so partial non-serializable values do not erase the log details."""
|
||||
try:
|
||||
return json.dumps(value or {}, ensure_ascii=False, sort_keys=True)
|
||||
except Exception:
|
||||
return "{}"
|
||||
return json.dumps(_json_safe(value or {}), ensure_ascii=False, sort_keys=True)
|
||||
except Exception as exc:
|
||||
return json.dumps({"serialization_error": str(exc), "raw_type": type(value).__name__}, ensure_ascii=False)
|
||||
|
||||
|
||||
def _compact_detail_value(value: Any) -> str:
|
||||
"""Build a readable one-line value for the Details column while keeping full JSON separately."""
|
||||
if value in (None, ""):
|
||||
return ""
|
||||
if isinstance(value, (list, tuple)):
|
||||
if not value:
|
||||
return ""
|
||||
return f"{len(value)} item(s)"
|
||||
if isinstance(value, dict):
|
||||
if not value:
|
||||
return ""
|
||||
return f"{len(value)} field(s)"
|
||||
text = str(value)
|
||||
return text if len(text) <= 160 else text[:157] + "..."
|
||||
|
||||
|
||||
def _details_summary(details: dict) -> str:
|
||||
"""Summarize important detail fields without hiding the full details_json payload."""
|
||||
priority = [
|
||||
"status", "job_id", "attempt", "attempts", "count", "hash_count", "action",
|
||||
"source", "source_label", "directory", "label", "target_path", "remove_data",
|
||||
"move_data", "keep_seeding", "error", "error_count", "result_count",
|
||||
]
|
||||
parts: list[str] = []
|
||||
for key in priority:
|
||||
if key in details:
|
||||
value = _compact_detail_value(details.get(key))
|
||||
if value:
|
||||
parts.append(f"{key}: {value}")
|
||||
for key, raw in details.items():
|
||||
if key in priority:
|
||||
continue
|
||||
value = _compact_detail_value(raw)
|
||||
if value:
|
||||
parts.append(f"{key}: {value}")
|
||||
if len(parts) >= 10:
|
||||
break
|
||||
return ", ".join(parts)
|
||||
|
||||
|
||||
def _row_to_public(row: dict) -> dict:
|
||||
@@ -27,105 +105,302 @@ def _row_to_public(row: dict) -> dict:
|
||||
item["details"] = json.loads(item.get("details_json") or "{}")
|
||||
except Exception:
|
||||
item["details"] = {}
|
||||
item["details_h"] = ", ".join(f"{k}: {v}" for k, v in item["details"].items() if v not in (None, ""))
|
||||
item["details_h"] = _details_summary(item["details"])
|
||||
return item
|
||||
|
||||
|
||||
def _sanitize_mode(value: Any, default: str = "days") -> str:
|
||||
mode = str(value or default).lower()
|
||||
return mode if mode in VALID_RETENTION_MODES else default
|
||||
|
||||
|
||||
def _sanitize_days(value: Any, default: int) -> int:
|
||||
return max(1, min(3650, int(value or default)))
|
||||
|
||||
|
||||
def _sanitize_lines(value: Any, default: int) -> int:
|
||||
return max(100, min(1_000_000, int(value or default)))
|
||||
|
||||
|
||||
def _sanitize_interval(value: Any, default: int = 24) -> int:
|
||||
return max(1, min(8760, int(value or default)))
|
||||
|
||||
|
||||
def _log_category(event_type: str = "", source: str = "") -> str:
|
||||
return "job" if str(source or "") in {"job", "worker"} or str(event_type or "").startswith("job_") else "operation"
|
||||
|
||||
|
||||
def _category_where(category: str) -> str:
|
||||
if category == "job":
|
||||
return "(COALESCE(source, '') IN ('job', 'worker') OR event_type LIKE 'job_%')"
|
||||
return "NOT (COALESCE(source, '') IN ('job', 'worker') OR event_type LIKE 'job_%')"
|
||||
|
||||
|
||||
def _parse_dt(value: Any) -> datetime | None:
|
||||
if not value:
|
||||
return None
|
||||
try:
|
||||
text = str(value).replace("Z", "+00:00")
|
||||
dt = datetime.fromisoformat(text)
|
||||
return dt if dt.tzinfo else dt.replace(tzinfo=timezone.utc)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _next_retention_run(settings: dict, category: str) -> str | None:
|
||||
last = _parse_dt(settings.get(f"{category}_last_retention_run_at"))
|
||||
if not last:
|
||||
return None
|
||||
return (last + timedelta(hours=int(settings.get(f"{category}_retention_interval_hours") or 24))).isoformat(timespec="seconds")
|
||||
|
||||
|
||||
def _profile_settings_owner_id() -> int:
|
||||
"""Use one canonical owner for profile-level retention settings."""
|
||||
return 0
|
||||
|
||||
|
||||
def get_settings(profile_id: int = 0, user_id: int | None = None) -> dict:
|
||||
user_id = _user_id(user_id)
|
||||
"""Return profile-level retention settings, with legacy per-user rows as fallback only."""
|
||||
profile_id = int(profile_id or 0)
|
||||
owner_id = _profile_settings_owner_id()
|
||||
with connect() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT * FROM operation_log_settings WHERE user_id=? AND profile_id=?",
|
||||
(user_id, profile_id),
|
||||
"""
|
||||
SELECT *
|
||||
FROM operation_log_settings
|
||||
WHERE profile_id=?
|
||||
ORDER BY CASE WHEN user_id=? THEN 0 ELSE 1 END, updated_at DESC, user_id ASC
|
||||
LIMIT 1
|
||||
""",
|
||||
(profile_id, owner_id),
|
||||
).fetchone()
|
||||
if not row:
|
||||
return {"user_id": user_id, "profile_id": profile_id, **DEFAULT_SETTINGS}
|
||||
data = {**DEFAULT_SETTINGS, **dict(row)}
|
||||
data["retention_mode"] = data.get("retention_mode") if data.get("retention_mode") in VALID_RETENTION_MODES else "days"
|
||||
data["retention_days"] = max(1, int(data.get("retention_days") or DEFAULT_SETTINGS["retention_days"]))
|
||||
data["retention_lines"] = max(100, int(data.get("retention_lines") or DEFAULT_SETTINGS["retention_lines"]))
|
||||
data = {"owner_user_id": owner_id, "profile_id": profile_id, **DEFAULT_SETTINGS}
|
||||
else:
|
||||
data = {**DEFAULT_SETTINGS, **dict(row)}
|
||||
data["owner_user_id"] = int(data.pop("user_id", owner_id) or owner_id)
|
||||
data["profile_id"] = profile_id
|
||||
data["retention_mode"] = _sanitize_mode(data.get("retention_mode"), DEFAULT_SETTINGS["retention_mode"])
|
||||
data["retention_days"] = _sanitize_days(data.get("retention_days"), DEFAULT_SETTINGS["retention_days"])
|
||||
data["retention_lines"] = _sanitize_lines(data.get("retention_lines"), DEFAULT_SETTINGS["retention_lines"])
|
||||
data["retention_interval_hours"] = _sanitize_interval(data.get("retention_interval_hours"), DEFAULT_SETTINGS["retention_interval_hours"])
|
||||
for category, defaults in DEFAULT_CATEGORY_SETTINGS.items():
|
||||
data[f"{category}_retention_mode"] = _sanitize_mode(data.get(f"{category}_retention_mode") or data.get("retention_mode"), defaults["retention_mode"])
|
||||
data[f"{category}_retention_days"] = _sanitize_days(data.get(f"{category}_retention_days") or data.get("retention_days"), defaults["retention_days"])
|
||||
data[f"{category}_retention_lines"] = _sanitize_lines(data.get(f"{category}_retention_lines") or data.get("retention_lines"), defaults["retention_lines"])
|
||||
data[f"{category}_retention_interval_hours"] = _sanitize_interval(data.get(f"{category}_retention_interval_hours") or data.get("retention_interval_hours"), defaults["retention_interval_hours"])
|
||||
data[f"{category}_last_retention_deleted"] = max(0, int(data.get(f"{category}_last_retention_deleted") or 0))
|
||||
data[f"{category}_next_retention_run_at"] = _next_retention_run(data, category)
|
||||
return data
|
||||
|
||||
|
||||
def save_settings(profile_id: int, data: dict, user_id: int | None = None) -> dict:
|
||||
user_id = _user_id(user_id)
|
||||
profile_id = int(profile_id or 0)
|
||||
mode = str(data.get("retention_mode") or "days").lower()
|
||||
if mode not in VALID_RETENTION_MODES:
|
||||
mode = "days"
|
||||
days = max(1, min(3650, int(data.get("retention_days") or DEFAULT_SETTINGS["retention_days"])))
|
||||
lines = max(100, min(1_000_000, int(data.get("retention_lines") or DEFAULT_SETTINGS["retention_lines"])))
|
||||
owner_id = _profile_settings_owner_id()
|
||||
now = utcnow()
|
||||
if not auth.can_write_profile(profile_id, user_id):
|
||||
raise PermissionError("No write access to profile")
|
||||
# Note: retention is intentionally shared by every user that works on the same profile.
|
||||
current = get_settings(profile_id, user_id)
|
||||
legacy_mode = _sanitize_mode(data.get("retention_mode") or current.get("retention_mode"), DEFAULT_SETTINGS["retention_mode"])
|
||||
legacy_days = _sanitize_days(data.get("retention_days") or current.get("retention_days"), DEFAULT_SETTINGS["retention_days"])
|
||||
legacy_lines = _sanitize_lines(data.get("retention_lines") or current.get("retention_lines"), DEFAULT_SETTINGS["retention_lines"])
|
||||
legacy_interval = _sanitize_interval(data.get("retention_interval_hours") or current.get("retention_interval_hours"), DEFAULT_SETTINGS["retention_interval_hours"])
|
||||
values: dict[str, Any] = {
|
||||
"retention_mode": legacy_mode,
|
||||
"retention_days": legacy_days,
|
||||
"retention_lines": legacy_lines,
|
||||
"retention_interval_hours": legacy_interval,
|
||||
}
|
||||
for category, defaults in DEFAULT_CATEGORY_SETTINGS.items():
|
||||
values[f"{category}_retention_mode"] = _sanitize_mode(data.get(f"{category}_retention_mode") or current.get(f"{category}_retention_mode"), defaults["retention_mode"])
|
||||
values[f"{category}_retention_days"] = _sanitize_days(data.get(f"{category}_retention_days") or current.get(f"{category}_retention_days"), defaults["retention_days"])
|
||||
values[f"{category}_retention_lines"] = _sanitize_lines(data.get(f"{category}_retention_lines") or current.get(f"{category}_retention_lines"), defaults["retention_lines"])
|
||||
values[f"{category}_retention_interval_hours"] = _sanitize_interval(data.get(f"{category}_retention_interval_hours") or current.get(f"{category}_retention_interval_hours"), defaults["retention_interval_hours"])
|
||||
values[f"{category}_last_retention_run_at"] = current.get(f"{category}_last_retention_run_at")
|
||||
values[f"{category}_last_retention_deleted"] = int(current.get(f"{category}_last_retention_deleted") or 0)
|
||||
with connect() as conn:
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO operation_log_settings(user_id, profile_id, retention_mode, retention_days, retention_lines, created_at, updated_at)
|
||||
VALUES(?,?,?,?,?,?,?)
|
||||
INSERT INTO operation_log_settings(
|
||||
user_id, profile_id, retention_mode, retention_days, retention_lines,
|
||||
retention_interval_hours,
|
||||
job_retention_mode, job_retention_days, job_retention_lines, job_retention_interval_hours, job_last_retention_run_at, job_last_retention_deleted,
|
||||
operation_retention_mode, operation_retention_days, operation_retention_lines, operation_retention_interval_hours, operation_last_retention_run_at, operation_last_retention_deleted,
|
||||
created_at, updated_at
|
||||
) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
|
||||
ON CONFLICT(user_id, profile_id) DO UPDATE SET
|
||||
retention_mode=excluded.retention_mode,
|
||||
retention_days=excluded.retention_days,
|
||||
retention_lines=excluded.retention_lines,
|
||||
retention_interval_hours=excluded.retention_interval_hours,
|
||||
job_retention_mode=excluded.job_retention_mode,
|
||||
job_retention_days=excluded.job_retention_days,
|
||||
job_retention_lines=excluded.job_retention_lines,
|
||||
job_retention_interval_hours=excluded.job_retention_interval_hours,
|
||||
job_last_retention_run_at=excluded.job_last_retention_run_at,
|
||||
job_last_retention_deleted=excluded.job_last_retention_deleted,
|
||||
operation_retention_mode=excluded.operation_retention_mode,
|
||||
operation_retention_days=excluded.operation_retention_days,
|
||||
operation_retention_lines=excluded.operation_retention_lines,
|
||||
operation_retention_interval_hours=excluded.operation_retention_interval_hours,
|
||||
operation_last_retention_run_at=excluded.operation_last_retention_run_at,
|
||||
operation_last_retention_deleted=excluded.operation_last_retention_deleted,
|
||||
updated_at=excluded.updated_at
|
||||
""",
|
||||
(user_id, profile_id, mode, days, lines, now, now),
|
||||
(
|
||||
owner_id, profile_id, values["retention_mode"], values["retention_days"], values["retention_lines"], values["retention_interval_hours"],
|
||||
values["job_retention_mode"], values["job_retention_days"], values["job_retention_lines"], values["job_retention_interval_hours"], values["job_last_retention_run_at"], values["job_last_retention_deleted"],
|
||||
values["operation_retention_mode"], values["operation_retention_days"], values["operation_retention_lines"], values["operation_retention_interval_hours"], values["operation_last_retention_run_at"], values["operation_last_retention_deleted"],
|
||||
now, now,
|
||||
),
|
||||
)
|
||||
return get_settings(profile_id, user_id)
|
||||
|
||||
|
||||
def record(profile_id: int | None, event_type: str, message: str, *, severity: str = "info", source: str = "system", torrent_hash: str | None = None, torrent_name: str | None = None, action: str | None = None, details: dict | None = None, user_id: int | None = None) -> int:
|
||||
"""Insert one operation log row and lazily run retention for its category when due."""
|
||||
now = utcnow()
|
||||
user_id = _user_id(user_id)
|
||||
event_type_s = str(event_type)
|
||||
source_s = str(source or "system")
|
||||
with connect() as conn:
|
||||
cur = conn.execute(
|
||||
"""
|
||||
INSERT INTO operation_logs(user_id, profile_id, event_type, severity, source, torrent_hash, torrent_name, action, message, details_json, created_at)
|
||||
VALUES(?,?,?,?,?,?,?,?,?,?,?)
|
||||
""",
|
||||
(user_id, int(profile_id or 0) or None, str(event_type), str(severity or "info"), str(source or "system"), torrent_hash, torrent_name, action, str(message), _details(details), now),
|
||||
(user_id, int(profile_id or 0) or None, event_type_s, str(severity or "info"), source_s, torrent_hash, torrent_name, action, str(message), _details(details), now),
|
||||
)
|
||||
return int(cur.lastrowid)
|
||||
row_id = int(cur.lastrowid)
|
||||
try:
|
||||
maybe_apply_retention(int(profile_id or 0), _log_category(event_type_s, source_s), user_id=user_id)
|
||||
except Exception:
|
||||
# Logging must never fail because cleanup metadata could not be updated.
|
||||
pass
|
||||
return row_id
|
||||
|
||||
|
||||
def _job_event_type(status: str) -> str:
|
||||
"""Map worker states to explicit operation log event types without changing old done/failed names."""
|
||||
return {
|
||||
"queued": "job_queued",
|
||||
"started": "job_started",
|
||||
"done": "job_done",
|
||||
"failed": "job_failed",
|
||||
"retry": "job_retry",
|
||||
"cancelled": "job_cancelled",
|
||||
"timeout": "job_timeout",
|
||||
"resubmitted": "job_resubmitted",
|
||||
"forced": "job_forced",
|
||||
}.get(str(status), "job_event")
|
||||
|
||||
|
||||
def _job_severity(status: str) -> str:
|
||||
"""Use severity consistently for filtering and badge rendering."""
|
||||
if status in {"failed", "timeout"}:
|
||||
return "danger"
|
||||
if status in {"retry", "resubmitted", "cancelled", "forced"}:
|
||||
return "warning"
|
||||
return "info"
|
||||
|
||||
|
||||
def _job_action_label(action: str) -> str:
|
||||
"""Return a stable human-readable action label for log messages."""
|
||||
labels = {
|
||||
"add_magnet": "Magnet link",
|
||||
"add_torrent_raw": "Torrent file",
|
||||
"set_label": "Set label",
|
||||
"set_ratio_group": "Set ratio group",
|
||||
"set_limits": "Set speed limits",
|
||||
"smart_queue_check": "Smart Queue check",
|
||||
}
|
||||
return labels.get(str(action or ""), str(action or "job"))
|
||||
|
||||
|
||||
def _result_summary(result: dict) -> dict:
|
||||
"""Extract compact result counters while preserving full result in details."""
|
||||
result = result or {}
|
||||
results = result.get("results") if isinstance(result.get("results"), list) else []
|
||||
errors = result.get("errors") if isinstance(result.get("errors"), list) else []
|
||||
ignored_errors = result.get("ignored_errors") if isinstance(result.get("ignored_errors"), list) else []
|
||||
return {
|
||||
"result_count": len(results) if results is not None else result.get("count"),
|
||||
"error_count": len(errors or []) + len(ignored_errors or []),
|
||||
}
|
||||
|
||||
|
||||
def record_job_event(profile_id: int, action: str, status: str, payload: dict | None, result: dict | None = None, error: str = "", job_id: str | None = None, user_id: int | None = None) -> None:
|
||||
"""Record queued, running and terminal job states with per-torrent context when available."""
|
||||
payload = payload or {}
|
||||
result = result or {}
|
||||
hashes = payload.get("hashes") or []
|
||||
ctx = payload.get("job_context") or {}
|
||||
items = ctx.get("items") or []
|
||||
by_hash = {str(item.get("hash")): item for item in items if item}
|
||||
event_type = "job_done" if status == "done" else "job_failed" if status == "failed" else "job_started"
|
||||
severity = "danger" if status == "failed" else "info"
|
||||
event_type = _job_event_type(str(status))
|
||||
severity = _job_severity(str(status))
|
||||
context_source = str(ctx.get("source") or payload.get("source") or "user")
|
||||
source_label = str(ctx.get("rule_name") or ctx.get("source") or context_source)
|
||||
source = "job"
|
||||
base_details = {
|
||||
"job_id": job_id,
|
||||
"status": status,
|
||||
"source": context_source,
|
||||
"source_label": source_label,
|
||||
"directory": payload.get("directory"),
|
||||
"label": payload.get("label"),
|
||||
"target_path": ctx.get("target_path") or payload.get("path"),
|
||||
"remove_data": ctx.get("remove_data") or payload.get("remove_data"),
|
||||
"move_data": ctx.get("move_data") or payload.get("move_data"),
|
||||
"keep_seeding": payload.get("keep_seeding"),
|
||||
"hash_count": len(hashes),
|
||||
"error": error,
|
||||
"result": result,
|
||||
**_result_summary(result),
|
||||
}
|
||||
if action in {"add_magnet", "add_torrent_raw"}:
|
||||
name = str(payload.get("name") or payload.get("filename") or payload.get("uri") or "torrent")[:300]
|
||||
msg = f"{action} {status}: {name}"
|
||||
record(profile_id, "torrent_added" if status == "done" else event_type, msg, severity=severity, source="job", action=action, details={"job_id": job_id, "status": status, "directory": payload.get("directory"), "label": payload.get("label"), "error": error, "result": result}, user_id=user_id)
|
||||
status_label = {"queued": "queued", "started": "started", "done": "added", "failed": "failed", "retry": "retry scheduled", "cancelled": "cancelled"}.get(str(status), str(status))
|
||||
msg = f"{_job_action_label(action)} {status_label}: {name}"
|
||||
record(profile_id, "torrent_added" if status == "done" else event_type, msg, severity=severity, source=source, action=action, details=base_details, user_id=user_id)
|
||||
return
|
||||
if not hashes:
|
||||
record(profile_id, event_type, f"{action} {status}", severity=severity, source="job", action=action, details={"job_id": job_id, "status": status, "error": error, "result": result}, user_id=user_id)
|
||||
record(profile_id, event_type, f"{_job_action_label(action)} {status}", severity=severity, source=source, action=action, details=base_details, user_id=user_id)
|
||||
return
|
||||
for h in hashes:
|
||||
item = by_hash.get(str(h)) or {}
|
||||
name = str(item.get("name") or h)
|
||||
record(profile_id, "torrent_removed" if action == "remove" and status == "done" else event_type, f"{action} {status}: {name}", severity=severity, source="job", torrent_hash=str(h), torrent_name=name, action=action, details={"job_id": job_id, "status": status, "error": error, "result": result, "target_path": ctx.get("target_path"), "remove_data": ctx.get("remove_data")}, user_id=user_id)
|
||||
row_details = {**base_details, "item": item}
|
||||
record(profile_id, "torrent_removed" if action == "remove" and status == "done" else event_type, f"{_job_action_label(action)} {status}: {name}", severity=severity, source=source, torrent_hash=str(h), torrent_name=name, action=action, details=row_details, user_id=user_id)
|
||||
|
||||
|
||||
def record_worker_event(profile_id: int, action: str, status: str, message: str, *, payload: dict | None = None, job_id: str | None = None, user_id: int | None = None, error: str = "", details: dict | None = None) -> None:
|
||||
"""Log worker-only lifecycle events that do not execute the normal job action path."""
|
||||
payload = payload or {}
|
||||
merged = {"job_id": job_id, "status": status, "error": error, "payload": payload, **(details or {})}
|
||||
record(profile_id, _job_event_type(status), message, severity=_job_severity(status), source="worker", action=action, details=merged, user_id=user_id)
|
||||
|
||||
|
||||
def record_cache_diff(profile_id: int, added: list[dict], removed: list[str], updated: list[dict], old_rows: dict[str, dict]) -> None:
|
||||
"""Record torrent cache changes detected by the poller without depending on manual jobs."""
|
||||
for row in added or []:
|
||||
record(profile_id, "torrent_added", f"Torrent added: {row.get('name') or row.get('hash')}", source="poller", torrent_hash=row.get("hash"), torrent_name=row.get("name"), details={"size": row.get("size"), "path": row.get("path"), "label": row.get("label")})
|
||||
record(profile_id, "torrent_added", f"Torrent added: {row.get('name') or row.get('hash')}", source="poller", torrent_hash=row.get("hash"), torrent_name=row.get("name"), details={"size": row.get("size"), "path": row.get("path"), "label": row.get("label"), "tracker": row.get("tracker")})
|
||||
for h in removed or []:
|
||||
old = old_rows.get(str(h)) or {}
|
||||
record(profile_id, "torrent_removed", f"Torrent removed: {old.get('name') or h}", source="poller", torrent_hash=str(h), torrent_name=old.get("name"), details={"path": old.get("path"), "label": old.get("label")})
|
||||
record(profile_id, "torrent_removed", f"Torrent removed: {old.get('name') or h}", source="poller", torrent_hash=str(h), torrent_name=old.get("name"), details={"path": old.get("path"), "label": old.get("label"), "tracker": old.get("tracker")})
|
||||
for patch in updated or []:
|
||||
h = str(patch.get("hash") or "")
|
||||
old = old_rows.get(h) or {}
|
||||
was_complete = bool(old.get("complete")) or float(old.get("progress") or 0) >= 100
|
||||
is_complete = bool(patch.get("complete", old.get("complete"))) or float(patch.get("progress", old.get("progress") or 0) or 0) >= 100
|
||||
if h and not was_complete and is_complete:
|
||||
record(profile_id, "torrent_completed", f"Torrent completed: {old.get('name') or h}", source="poller", torrent_hash=h, torrent_name=old.get("name"), details={"ratio": patch.get("ratio", old.get("ratio")), "size": old.get("size"), "path": old.get("path")})
|
||||
record(profile_id, "torrent_completed", f"Torrent completed: {old.get('name') or h}", source="poller", torrent_hash=h, torrent_name=old.get("name"), details={"ratio": patch.get("ratio", old.get("ratio")), "size": old.get("size"), "path": old.get("path"), "label": old.get("label"), "tracker": old.get("tracker")})
|
||||
|
||||
|
||||
def list_logs(profile_id: int, *, limit: int = 200, offset: int = 0, event_type: str = "", q: str = "", hide_jobs: bool = False) -> dict:
|
||||
"""Return operation logs with searchable messages, torrents, actions and detail JSON."""
|
||||
limit = max(1, min(int(limit or 200), 1000))
|
||||
offset = max(0, int(offset or 0))
|
||||
where = ["(profile_id=? OR profile_id IS NULL)"]
|
||||
@@ -134,12 +409,11 @@ def list_logs(profile_id: int, *, limit: int = 200, offset: int = 0, event_type:
|
||||
where.append("event_type=?")
|
||||
params.append(event_type)
|
||||
if hide_jobs:
|
||||
# Note: Job-originated rows include torrent_added/torrent_removed events, so source is the reliable filter.
|
||||
where.append("COALESCE(source, '') <> 'job'")
|
||||
where.append("COALESCE(source, '') NOT IN ('job', 'worker') AND event_type NOT LIKE 'job_%'")
|
||||
if q:
|
||||
where.append("(message LIKE ? OR torrent_name LIKE ? OR torrent_hash LIKE ? OR action LIKE ?)")
|
||||
where.append("(message LIKE ? OR torrent_name LIKE ? OR torrent_hash LIKE ? OR action LIKE ? OR details_json LIKE ?)")
|
||||
like = f"%{q}%"
|
||||
params.extend([like, like, like, like])
|
||||
params.extend([like, like, like, like, like])
|
||||
sql_where = " WHERE " + " AND ".join(where)
|
||||
with connect() as conn:
|
||||
rows = conn.execute(f"SELECT * FROM operation_logs{sql_where} ORDER BY id DESC LIMIT ? OFFSET ?", (*params, limit, offset)).fetchall()
|
||||
@@ -158,20 +432,29 @@ def stats(profile_id: int) -> dict:
|
||||
return {"total": int(total or 0), "by_type": by_type, "by_day": by_day, "by_month": by_month, "top_actions": top_actions, "settings": get_settings(profile_id)}
|
||||
|
||||
|
||||
def _retention_label_for(settings: dict, category: str) -> str:
|
||||
mode = settings.get(f"{category}_retention_mode") or "days"
|
||||
days = settings.get(f"{category}_retention_days") or DEFAULT_CATEGORY_SETTINGS[category]["retention_days"]
|
||||
lines = settings.get(f"{category}_retention_lines") or DEFAULT_CATEGORY_SETTINGS[category]["retention_lines"]
|
||||
interval = settings.get(f"{category}_retention_interval_hours") or DEFAULT_CATEGORY_SETTINGS[category]["retention_interval_hours"]
|
||||
if mode == "manual":
|
||||
return f"manual cleanup only, checked every {interval}h"
|
||||
if mode == "lines":
|
||||
return f"retention {lines} lines, checked every {interval}h"
|
||||
if mode == "both":
|
||||
return f"retention {days} days and {lines} lines, checked every {interval}h"
|
||||
return f"retention {days} days, checked every {interval}h"
|
||||
|
||||
|
||||
def retention_label(settings: dict) -> str:
|
||||
mode = settings.get("retention_mode") or "days"
|
||||
if mode == "manual":
|
||||
return "manual cleanup only"
|
||||
if mode == "lines":
|
||||
return f"retention {settings.get('retention_lines') or DEFAULT_SETTINGS['retention_lines']} lines"
|
||||
if mode == "both":
|
||||
return f"retention {settings.get('retention_days') or DEFAULT_SETTINGS['retention_days']} days and {settings.get('retention_lines') or DEFAULT_SETTINGS['retention_lines']} lines"
|
||||
return f"retention {settings.get('retention_days') or DEFAULT_SETTINGS['retention_days']} days"
|
||||
return f"Jobs: {_retention_label_for(settings, 'job')} / Operations: {_retention_label_for(settings, 'operation')}"
|
||||
|
||||
def clear(profile_id: int, *, event_type: str = "") -> int:
|
||||
|
||||
def clear(profile_id: int, *, event_type: str = "", category: str = "") -> int:
|
||||
where = ["(profile_id=? OR profile_id IS NULL)"]
|
||||
params: list[Any] = [int(profile_id or 0)]
|
||||
if category in VALID_LOG_CATEGORIES:
|
||||
where.append(_category_where(category))
|
||||
if event_type:
|
||||
where.append("event_type=?")
|
||||
params.append(event_type)
|
||||
@@ -180,21 +463,116 @@ def clear(profile_id: int, *, event_type: str = "") -> int:
|
||||
return int(cur.rowcount or 0)
|
||||
|
||||
|
||||
def apply_retention(profile_id: int, user_id: int | None = None) -> dict:
|
||||
settings = get_settings(profile_id, user_id)
|
||||
mode = settings.get("retention_mode") or "manual"
|
||||
def _apply_retention_category(conn, profile_id: int, settings: dict, category: str) -> dict:
|
||||
mode = settings.get(f"{category}_retention_mode") or "manual"
|
||||
deleted_days = 0
|
||||
deleted_lines = 0
|
||||
base_where = f"(profile_id=? OR profile_id IS NULL) AND {_category_where(category)}"
|
||||
if mode in {"days", "both"}:
|
||||
cutoff = (datetime.now(timezone.utc) - timedelta(days=int(settings[f"{category}_retention_days"]))).isoformat(timespec="seconds")
|
||||
cur = conn.execute(f"DELETE FROM operation_logs WHERE {base_where} AND created_at<?", (int(profile_id or 0), cutoff))
|
||||
deleted_days = int(cur.rowcount or 0)
|
||||
if mode in {"lines", "both"}:
|
||||
keep = int(settings[f"{category}_retention_lines"])
|
||||
cur = conn.execute(
|
||||
f"""
|
||||
DELETE FROM operation_logs
|
||||
WHERE id IN (
|
||||
SELECT id FROM operation_logs
|
||||
WHERE {base_where}
|
||||
ORDER BY id DESC
|
||||
LIMIT -1 OFFSET ?
|
||||
)
|
||||
""",
|
||||
(int(profile_id or 0), keep),
|
||||
)
|
||||
deleted_lines = int(cur.rowcount or 0)
|
||||
return {"deleted_days": deleted_days, "deleted_lines": deleted_lines, "deleted": deleted_days + deleted_lines}
|
||||
|
||||
|
||||
def _update_retention_metadata(conn, profile_id: int, category: str, deleted: int, settings: dict, user_id: int | None = None) -> None:
|
||||
"""Update last retention state on the shared profile settings row."""
|
||||
now = utcnow()
|
||||
owner_id = _profile_settings_owner_id()
|
||||
profile_id = int(profile_id or 0)
|
||||
cur = conn.execute(
|
||||
f"""
|
||||
UPDATE operation_log_settings
|
||||
SET {category}_last_retention_run_at=?, {category}_last_retention_deleted=?, updated_at=?
|
||||
WHERE user_id=? AND profile_id=?
|
||||
""",
|
||||
(now, int(deleted or 0), now, owner_id, profile_id),
|
||||
)
|
||||
if int(cur.rowcount or 0) == 0:
|
||||
# Note: preserve legacy settings when creating the shared profile row lazily.
|
||||
values = {
|
||||
"retention_mode": _sanitize_mode(settings.get("retention_mode"), DEFAULT_SETTINGS["retention_mode"]),
|
||||
"retention_days": _sanitize_days(settings.get("retention_days"), DEFAULT_SETTINGS["retention_days"]),
|
||||
"retention_lines": _sanitize_lines(settings.get("retention_lines"), DEFAULT_SETTINGS["retention_lines"]),
|
||||
"retention_interval_hours": _sanitize_interval(settings.get("retention_interval_hours"), DEFAULT_SETTINGS["retention_interval_hours"]),
|
||||
}
|
||||
for cat, defaults in DEFAULT_CATEGORY_SETTINGS.items():
|
||||
values[f"{cat}_retention_mode"] = _sanitize_mode(settings.get(f"{cat}_retention_mode"), defaults["retention_mode"])
|
||||
values[f"{cat}_retention_days"] = _sanitize_days(settings.get(f"{cat}_retention_days"), defaults["retention_days"])
|
||||
values[f"{cat}_retention_lines"] = _sanitize_lines(settings.get(f"{cat}_retention_lines"), defaults["retention_lines"])
|
||||
values[f"{cat}_retention_interval_hours"] = _sanitize_interval(settings.get(f"{cat}_retention_interval_hours"), defaults["retention_interval_hours"])
|
||||
values[f"{cat}_last_retention_run_at"] = settings.get(f"{cat}_last_retention_run_at")
|
||||
values[f"{cat}_last_retention_deleted"] = int(settings.get(f"{cat}_last_retention_deleted") or 0)
|
||||
values[f"{category}_last_retention_run_at"] = now
|
||||
values[f"{category}_last_retention_deleted"] = int(deleted or 0)
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO operation_log_settings(
|
||||
user_id, profile_id, retention_mode, retention_days, retention_lines,
|
||||
retention_interval_hours,
|
||||
job_retention_mode, job_retention_days, job_retention_lines, job_retention_interval_hours, job_last_retention_run_at, job_last_retention_deleted,
|
||||
operation_retention_mode, operation_retention_days, operation_retention_lines, operation_retention_interval_hours, operation_last_retention_run_at, operation_last_retention_deleted,
|
||||
created_at, updated_at
|
||||
) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
|
||||
ON CONFLICT(user_id, profile_id) DO UPDATE SET
|
||||
job_last_retention_run_at=excluded.job_last_retention_run_at,
|
||||
job_last_retention_deleted=excluded.job_last_retention_deleted,
|
||||
operation_last_retention_run_at=excluded.operation_last_retention_run_at,
|
||||
operation_last_retention_deleted=excluded.operation_last_retention_deleted,
|
||||
updated_at=excluded.updated_at
|
||||
""",
|
||||
(
|
||||
owner_id, profile_id, values["retention_mode"], values["retention_days"], values["retention_lines"], values["retention_interval_hours"],
|
||||
values["job_retention_mode"], values["job_retention_days"], values["job_retention_lines"], values["job_retention_interval_hours"], values["job_last_retention_run_at"], values["job_last_retention_deleted"],
|
||||
values["operation_retention_mode"], values["operation_retention_days"], values["operation_retention_lines"], values["operation_retention_interval_hours"], values["operation_last_retention_run_at"], values["operation_last_retention_deleted"],
|
||||
now, now,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def apply_retention(profile_id: int, user_id: int | None = None, category: str = "all") -> dict:
|
||||
"""Apply due operation-log retention without touching torrent data or other history tables."""
|
||||
profile_id = int(profile_id or 0)
|
||||
settings = get_settings(profile_id, user_id)
|
||||
categories = [category] if category in VALID_LOG_CATEGORIES else ["job", "operation"]
|
||||
results: dict[str, Any] = {}
|
||||
total = 0
|
||||
with connect() as conn:
|
||||
if mode in {"days", "both"}:
|
||||
cutoff = (datetime.now(timezone.utc) - timedelta(days=int(settings["retention_days"]))).isoformat(timespec="seconds")
|
||||
cur = conn.execute("DELETE FROM operation_logs WHERE (profile_id=? OR profile_id IS NULL) AND created_at<?", (int(profile_id or 0), cutoff))
|
||||
deleted_days = int(cur.rowcount or 0)
|
||||
if mode in {"lines", "both"}:
|
||||
keep = int(settings["retention_lines"])
|
||||
ids = conn.execute("SELECT id FROM operation_logs WHERE profile_id=? OR profile_id IS NULL ORDER BY id DESC LIMIT -1 OFFSET ?", (int(profile_id or 0), keep)).fetchall()
|
||||
if ids:
|
||||
placeholders = ",".join("?" for _ in ids)
|
||||
cur = conn.execute(f"DELETE FROM operation_logs WHERE id IN ({placeholders})", tuple(r["id"] for r in ids))
|
||||
deleted_lines = int(cur.rowcount or 0)
|
||||
return {"deleted_days": deleted_days, "deleted_lines": deleted_lines, "deleted": deleted_days + deleted_lines, "settings": settings}
|
||||
for cat in categories:
|
||||
item = _apply_retention_category(conn, profile_id, settings, cat)
|
||||
_update_retention_metadata(conn, profile_id, cat, int(item["deleted"]), settings, user_id=user_id)
|
||||
results[cat] = item
|
||||
total += int(item["deleted"])
|
||||
fresh = get_settings(profile_id, user_id)
|
||||
return {"deleted": total, "categories": results, "settings": fresh}
|
||||
|
||||
|
||||
def maybe_apply_retention(profile_id: int, category: str, user_id: int | None = None) -> dict:
|
||||
"""Run retention for a category only when interval since last cleanup elapsed."""
|
||||
if category not in VALID_LOG_CATEGORIES:
|
||||
category = "operation"
|
||||
settings = get_settings(profile_id, user_id)
|
||||
interval = int(settings.get(f"{category}_retention_interval_hours") or 24)
|
||||
last = _parse_dt(settings.get(f"{category}_last_retention_run_at"))
|
||||
now = datetime.now(timezone.utc)
|
||||
if last and now < last + timedelta(hours=interval):
|
||||
return {"skipped": True, "category": category, "next_run_at": (last + timedelta(hours=interval)).isoformat(timespec="seconds"), "settings": settings}
|
||||
result = apply_retention(profile_id, user_id=user_id, category=category)
|
||||
result["skipped"] = False
|
||||
result["category"] = category
|
||||
return result
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
from __future__ import annotations
|
||||
import secrets
|
||||
import threading
|
||||
import time
|
||||
|
||||
_LINK_TTL_SECONDS = 10 * 60
|
||||
_TEMPORARY_LINKS: dict[str, dict] = {}
|
||||
_TEMPORARY_LINK_LOCK = threading.Lock()
|
||||
|
||||
|
||||
def _cleanup_expired(now: float | None = None) -> None:
|
||||
now = time.time() if now is None else float(now)
|
||||
expired = [token for token, item in _TEMPORARY_LINKS.items() if float(item.get("expires_at") or 0) <= now]
|
||||
for token in expired:
|
||||
_TEMPORARY_LINKS.pop(token, None)
|
||||
|
||||
|
||||
def _create_temporary_link(kind: str, profile_id: int, user_id: int, payload: dict) -> dict:
|
||||
"""Create a short-lived in-app link target used by preview and download routes."""
|
||||
now = time.time()
|
||||
token = secrets.token_urlsafe(24)
|
||||
with _TEMPORARY_LINK_LOCK:
|
||||
_cleanup_expired(now)
|
||||
_TEMPORARY_LINKS[token] = {
|
||||
"kind": str(kind),
|
||||
"profile_id": int(profile_id),
|
||||
"user_id": int(user_id),
|
||||
"expires_at": now + _LINK_TTL_SECONDS,
|
||||
**payload,
|
||||
}
|
||||
return {"token": token, "expires_in": _LINK_TTL_SECONDS}
|
||||
|
||||
|
||||
def create_pdf_preview_link(torrent_hash: str, file_index: int, profile_id: int, user_id: int) -> dict:
|
||||
"""Create a short-lived in-app PDF preview link without exposing the API download URL."""
|
||||
return _create_temporary_link(
|
||||
"pdf_preview",
|
||||
profile_id,
|
||||
user_id,
|
||||
{"torrent_hash": str(torrent_hash), "file_index": int(file_index)},
|
||||
)
|
||||
|
||||
|
||||
def create_file_download_link(torrent_hash: str, file_index: int, profile_id: int, user_id: int) -> dict:
|
||||
"""Create a temporary in-app download link for one torrent file."""
|
||||
return _create_temporary_link(
|
||||
"file_download",
|
||||
profile_id,
|
||||
user_id,
|
||||
{"torrent_hash": str(torrent_hash), "file_index": int(file_index)},
|
||||
)
|
||||
|
||||
|
||||
def create_file_zip_download_link(torrent_hash: str, indexes: list[int] | None, profile_id: int, user_id: int) -> dict:
|
||||
"""Create a temporary in-app download link for a ZIP of torrent files."""
|
||||
clean_indexes = None if indexes is None else [int(index) for index in indexes]
|
||||
return _create_temporary_link(
|
||||
"file_zip_download",
|
||||
profile_id,
|
||||
user_id,
|
||||
{"torrent_hash": str(torrent_hash), "indexes": clean_indexes},
|
||||
)
|
||||
|
||||
|
||||
def create_torrent_file_download_link(torrent_hash: str, profile_id: int, user_id: int) -> dict:
|
||||
"""Create a temporary in-app download link for an exported .torrent file."""
|
||||
return _create_temporary_link(
|
||||
"torrent_file_download",
|
||||
profile_id,
|
||||
user_id,
|
||||
{"torrent_hash": str(torrent_hash)},
|
||||
)
|
||||
|
||||
|
||||
def create_torrent_files_zip_download_link(hashes: list[str], profile_id: int, user_id: int) -> dict:
|
||||
"""Create a temporary in-app download link for a ZIP of exported .torrent files."""
|
||||
return _create_temporary_link(
|
||||
"torrent_files_zip_download",
|
||||
profile_id,
|
||||
user_id,
|
||||
{"hashes": [str(item) for item in hashes]},
|
||||
)
|
||||
|
||||
|
||||
def get_temporary_link(token: str) -> dict | None:
|
||||
"""Return a temporary target if the link is still valid."""
|
||||
clean = str(token or "").strip()
|
||||
if not clean:
|
||||
return None
|
||||
with _TEMPORARY_LINK_LOCK:
|
||||
_cleanup_expired()
|
||||
item = _TEMPORARY_LINKS.get(clean)
|
||||
return dict(item) if item else None
|
||||
|
||||
|
||||
def get_pdf_preview_link(token: str) -> dict | None:
|
||||
"""Return a temporary PDF preview target if the link is still valid."""
|
||||
item = get_temporary_link(token)
|
||||
if not item or item.get("kind") != "pdf_preview":
|
||||
return None
|
||||
return item
|
||||
@@ -1,20 +1,19 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
from ..db import connect, utcnow
|
||||
from ..config import POLL_INTERVAL, MIN_POLL_INTERVAL_SECONDS
|
||||
|
||||
DEFAULTS = {
|
||||
"adaptive_enabled": True,
|
||||
"safe_fallback_enabled": True,
|
||||
"active_interval_seconds": 5.0,
|
||||
"active_interval_seconds": 3.0,
|
||||
"idle_interval_seconds": 15.0,
|
||||
"error_interval_seconds": 30.0,
|
||||
"torrent_list_interval_seconds": 5.0,
|
||||
"live_stats_interval_seconds": 3.0,
|
||||
"torrent_list_interval_seconds": 30.0,
|
||||
"system_stats_interval_seconds": 5.0,
|
||||
"tracker_stats_interval_seconds": 300.0,
|
||||
"disk_stats_interval_seconds": 60.0,
|
||||
@@ -27,6 +26,20 @@ DEFAULTS = {
|
||||
"recovery_after_errors": 3,
|
||||
}
|
||||
|
||||
SAFE_FALLBACK_MINIMUMS = {
|
||||
"active_interval_seconds": 3.0,
|
||||
"idle_interval_seconds": 15.0,
|
||||
"error_interval_seconds": 30.0,
|
||||
"live_stats_interval_seconds": 3.0,
|
||||
"torrent_list_interval_seconds": 30.0,
|
||||
"system_stats_interval_seconds": 5.0,
|
||||
"tracker_stats_interval_seconds": 300.0,
|
||||
"disk_stats_interval_seconds": 60.0,
|
||||
"queue_stats_interval_seconds": 15.0,
|
||||
"slow_stats_interval_seconds": 60.0,
|
||||
"heartbeat_interval_seconds": 15.0,
|
||||
}
|
||||
|
||||
|
||||
def _key(profile_id: int) -> str:
|
||||
return f"poller.settings.{int(profile_id)}"
|
||||
@@ -52,6 +65,7 @@ def normalize_settings(data: dict | None) -> dict:
|
||||
"active_interval_seconds": _coerce_float(raw.get("active_interval_seconds"), DEFAULTS["active_interval_seconds"], MIN_POLL_INTERVAL_SECONDS, 30.0),
|
||||
"idle_interval_seconds": _coerce_float(raw.get("idle_interval_seconds"), DEFAULTS["idle_interval_seconds"], MIN_POLL_INTERVAL_SECONDS, 120.0),
|
||||
"error_interval_seconds": _coerce_float(raw.get("error_interval_seconds"), DEFAULTS["error_interval_seconds"], MIN_POLL_INTERVAL_SECONDS, 300.0),
|
||||
"live_stats_interval_seconds": _coerce_float(raw.get("live_stats_interval_seconds"), DEFAULTS["live_stats_interval_seconds"], MIN_POLL_INTERVAL_SECONDS, 60.0),
|
||||
"torrent_list_interval_seconds": _coerce_float(raw.get("torrent_list_interval_seconds"), DEFAULTS["torrent_list_interval_seconds"], MIN_POLL_INTERVAL_SECONDS, 120.0),
|
||||
"system_stats_interval_seconds": _coerce_float(raw.get("system_stats_interval_seconds"), DEFAULTS["system_stats_interval_seconds"], MIN_POLL_INTERVAL_SECONDS, 120.0),
|
||||
"tracker_stats_interval_seconds": _coerce_float(raw.get("tracker_stats_interval_seconds"), DEFAULTS["tracker_stats_interval_seconds"], MIN_POLL_INTERVAL_SECONDS, 1800.0),
|
||||
@@ -65,17 +79,25 @@ def normalize_settings(data: dict | None) -> dict:
|
||||
"recovery_after_errors": int(_coerce_float(raw.get("recovery_after_errors"), 3, 1, 20)),
|
||||
}
|
||||
if settings["safe_fallback_enabled"]:
|
||||
for key in ("active_interval_seconds", "idle_interval_seconds", "error_interval_seconds", "torrent_list_interval_seconds", "system_stats_interval_seconds", "queue_stats_interval_seconds"):
|
||||
if settings[key] <= 0:
|
||||
settings[key] = DEFAULTS[key]
|
||||
for key, minimum in SAFE_FALLBACK_MINIMUMS.items():
|
||||
settings[key] = max(float(settings.get(key) or DEFAULTS[key]), float(minimum))
|
||||
return settings
|
||||
|
||||
|
||||
def get_settings(profile_id: int) -> dict:
|
||||
with connect() as conn:
|
||||
row = conn.execute("SELECT value FROM app_settings WHERE key=?", (_key(profile_id),)).fetchone()
|
||||
row = conn.execute("SELECT settings_json FROM poller_settings WHERE profile_id=?", (int(profile_id),)).fetchone()
|
||||
if not row:
|
||||
legacy = conn.execute("SELECT value FROM app_settings WHERE key=?", (_key(profile_id),)).fetchone()
|
||||
if legacy:
|
||||
try:
|
||||
settings = normalize_settings(json.loads(legacy.get("value") or "{}"))
|
||||
except Exception:
|
||||
settings = normalize_settings({})
|
||||
conn.execute("INSERT OR REPLACE INTO poller_settings(profile_id,settings_json,updated_at) VALUES(?,?,?)", (int(profile_id), json.dumps(settings), utcnow()))
|
||||
return settings
|
||||
try:
|
||||
data = json.loads(row.get("value") or "{}") if row else {}
|
||||
data = json.loads(row.get("settings_json") or "{}") if row else {}
|
||||
except Exception:
|
||||
data = {}
|
||||
return normalize_settings(data)
|
||||
@@ -84,7 +106,7 @@ def get_settings(profile_id: int) -> dict:
|
||||
def save_settings(profile_id: int, data: dict) -> dict:
|
||||
settings = normalize_settings(data)
|
||||
with connect() as conn:
|
||||
conn.execute("INSERT OR REPLACE INTO app_settings(key,value) VALUES(?,?)", (_key(profile_id), json.dumps(settings)))
|
||||
conn.execute("INSERT OR REPLACE INTO poller_settings(profile_id,settings_json,updated_at) VALUES(?,?,?)", (int(profile_id), json.dumps(settings), utcnow()))
|
||||
return settings
|
||||
|
||||
|
||||
@@ -92,6 +114,8 @@ def save_settings(profile_id: int, data: dict) -> dict:
|
||||
class ProfilePollState:
|
||||
profile_id: int
|
||||
last_fast_at: float = 0.0
|
||||
last_live_at: float = 0.0
|
||||
last_list_at: float = 0.0
|
||||
last_system_at: float = 0.0
|
||||
last_slow_at: float = 0.0
|
||||
last_tracker_at: float = 0.0
|
||||
@@ -112,6 +136,24 @@ class ProfilePollState:
|
||||
skipped_emissions: int = 0
|
||||
emitted_payload_size: int = 0
|
||||
rtorrent_call_count: int = 0
|
||||
live_poll_count: int = 0
|
||||
list_poll_count: int = 0
|
||||
live_updated_total: int = 0
|
||||
live_full_refresh_requested_total: int = 0
|
||||
list_added_total: int = 0
|
||||
list_updated_total: int = 0
|
||||
list_removed_total: int = 0
|
||||
last_live_duration_ms: float = 0.0
|
||||
last_list_duration_ms: float = 0.0
|
||||
last_live_updated_count: int = 0
|
||||
last_list_added_count: int = 0
|
||||
last_list_updated_count: int = 0
|
||||
last_list_removed_count: int = 0
|
||||
last_live_ok: bool = True
|
||||
last_list_ok: bool = True
|
||||
last_live_error: str = ""
|
||||
last_list_error: str = ""
|
||||
last_live_requires_full_refresh: bool = False
|
||||
adaptive_mode: str = "normal"
|
||||
slow_task_running: bool = False
|
||||
system_task_running: bool = False
|
||||
@@ -141,12 +183,29 @@ def interval_for(settings: dict, state: ProfilePollState) -> float:
|
||||
return base
|
||||
|
||||
|
||||
def effective_live_interval(settings: dict, state: ProfilePollState) -> float:
|
||||
return max(MIN_POLL_INTERVAL_SECONDS, interval_for(settings, state), float(settings.get("live_stats_interval_seconds") or DEFAULTS["live_stats_interval_seconds"]))
|
||||
|
||||
|
||||
def effective_list_interval(settings: dict, state: ProfilePollState) -> float:
|
||||
return max(MIN_POLL_INTERVAL_SECONDS, float(settings.get("torrent_list_interval_seconds") or DEFAULTS["torrent_list_interval_seconds"]))
|
||||
|
||||
|
||||
def effective_fast_interval(settings: dict, state: ProfilePollState) -> float:
|
||||
return max(MIN_POLL_INTERVAL_SECONDS, interval_for(settings, state), float(settings.get("torrent_list_interval_seconds") or DEFAULTS["torrent_list_interval_seconds"]))
|
||||
# Note: Kept for compatibility with older diagnostics; the fast interval now means lightweight live stats.
|
||||
return effective_live_interval(settings, state)
|
||||
|
||||
|
||||
def should_live_poll(now: float, settings: dict, state: ProfilePollState) -> bool:
|
||||
return (now - state.last_live_at) >= effective_live_interval(settings, state)
|
||||
|
||||
|
||||
def should_list_poll(now: float, settings: dict, state: ProfilePollState) -> bool:
|
||||
return (now - state.last_list_at) >= effective_list_interval(settings, state)
|
||||
|
||||
|
||||
def should_fast_poll(now: float, settings: dict, state: ProfilePollState) -> bool:
|
||||
return (now - state.last_fast_at) >= effective_fast_interval(settings, state)
|
||||
return should_live_poll(now, settings, state)
|
||||
|
||||
|
||||
def should_system_poll(now: float, settings: dict, state: ProfilePollState) -> bool:
|
||||
@@ -175,6 +234,66 @@ def should_heartbeat(now: float, settings: dict, state: ProfilePollState, change
|
||||
return (now - state.last_heartbeat_at) >= float(settings["heartbeat_interval_seconds"])
|
||||
|
||||
|
||||
def mark_live_poll(state: ProfilePollState, started_at: float, ok: bool, error: str = "", updated_count: int = 0, requires_full_refresh: bool = False) -> None:
|
||||
now = time.monotonic()
|
||||
state.live_poll_count += 1
|
||||
state.last_live_duration_ms = round((now - started_at) * 1000.0, 2)
|
||||
state.last_live_updated_count = int(updated_count or 0)
|
||||
state.live_updated_total += int(updated_count or 0)
|
||||
state.last_live_requires_full_refresh = bool(requires_full_refresh)
|
||||
if requires_full_refresh:
|
||||
state.live_full_refresh_requested_total += 1
|
||||
state.last_live_ok = bool(ok)
|
||||
state.last_live_error = str(error or "")
|
||||
|
||||
|
||||
def mark_list_poll(state: ProfilePollState, started_at: float, ok: bool, error: str = "", added_count: int = 0, updated_count: int = 0, removed_count: int = 0) -> None:
|
||||
now = time.monotonic()
|
||||
state.list_poll_count += 1
|
||||
state.last_list_duration_ms = round((now - started_at) * 1000.0, 2)
|
||||
state.last_list_added_count = int(added_count or 0)
|
||||
state.last_list_updated_count = int(updated_count or 0)
|
||||
state.last_list_removed_count = int(removed_count or 0)
|
||||
state.list_added_total += int(added_count or 0)
|
||||
state.list_updated_total += int(updated_count or 0)
|
||||
state.list_removed_total += int(removed_count or 0)
|
||||
state.last_list_ok = bool(ok)
|
||||
state.last_list_error = str(error or "")
|
||||
|
||||
|
||||
def reset_runtime_stats(profile_id: int) -> dict:
|
||||
state = state_for(profile_id)
|
||||
state.tick_count = 0
|
||||
state.last_tick_ms = 0.0
|
||||
state.last_tick_gap_ms = 0.0
|
||||
state.last_tick_started_at = 0.0
|
||||
state.error_count = 0
|
||||
state.slow_count = 0
|
||||
state.skipped_emissions = 0
|
||||
state.emitted_payload_size = 0
|
||||
state.rtorrent_call_count = 0
|
||||
state.live_poll_count = 0
|
||||
state.list_poll_count = 0
|
||||
state.live_updated_total = 0
|
||||
state.live_full_refresh_requested_total = 0
|
||||
state.list_added_total = 0
|
||||
state.list_updated_total = 0
|
||||
state.list_removed_total = 0
|
||||
state.last_live_duration_ms = 0.0
|
||||
state.last_list_duration_ms = 0.0
|
||||
state.last_live_updated_count = 0
|
||||
state.last_list_added_count = 0
|
||||
state.last_list_updated_count = 0
|
||||
state.last_list_removed_count = 0
|
||||
state.last_live_ok = True
|
||||
state.last_list_ok = True
|
||||
state.last_live_error = ""
|
||||
state.last_list_error = ""
|
||||
state.last_live_requires_full_refresh = False
|
||||
state.stats = {}
|
||||
return snapshot(profile_id)
|
||||
|
||||
|
||||
def mark_tick(state: ProfilePollState, started_at: float, active: bool, ok: bool, error: str = "", emitted_payload_size: int = 0, rtorrent_call_count: int = 0, skipped_emissions: int = 0, settings: dict | None = None) -> dict:
|
||||
now = time.monotonic()
|
||||
effective_settings = normalize_settings(settings) if settings is not None else DEFAULTS
|
||||
@@ -184,7 +303,7 @@ def mark_tick(state: ProfilePollState, started_at: float, active: bool, ok: bool
|
||||
state.last_tick_gap_ms = round((started_at - previous_started_at) * 1000.0, 2) if previous_started_at else 0.0
|
||||
state.last_tick_started_at = started_at
|
||||
state.last_active = bool(active)
|
||||
state.effective_interval_seconds = effective_fast_interval(effective_settings, state)
|
||||
state.effective_interval_seconds = effective_live_interval(effective_settings, state)
|
||||
state.last_ok = bool(ok)
|
||||
state.last_error = str(error or "")
|
||||
state.emitted_payload_size = int(emitted_payload_size or 0)
|
||||
@@ -224,6 +343,8 @@ def mark_tick(state: ProfilePollState, started_at: float, active: bool, ok: bool
|
||||
"last_ok": state.last_ok,
|
||||
"last_tick_gap_ms": state.last_tick_gap_ms,
|
||||
"effective_interval_seconds": state.effective_interval_seconds,
|
||||
"live_stats_interval_seconds": effective_live_interval(effective_settings, state),
|
||||
"torrent_list_interval_seconds": effective_list_interval(effective_settings, state),
|
||||
"configured_min_interval_seconds": MIN_POLL_INTERVAL_SECONDS,
|
||||
"last_error": state.last_error,
|
||||
"duration_ms": state.last_tick_ms,
|
||||
@@ -234,11 +355,60 @@ def mark_tick(state: ProfilePollState, started_at: float, active: bool, ok: bool
|
||||
"adaptive_mode": state.adaptive_mode,
|
||||
"error_count": state.error_count,
|
||||
"slow_count": state.slow_count,
|
||||
"live_poll_count": state.live_poll_count,
|
||||
"list_poll_count": state.list_poll_count,
|
||||
"last_live_duration_ms": state.last_live_duration_ms,
|
||||
"last_list_duration_ms": state.last_list_duration_ms,
|
||||
"last_live_updated_count": state.last_live_updated_count,
|
||||
"last_list_added_count": state.last_list_added_count,
|
||||
"last_list_updated_count": state.last_list_updated_count,
|
||||
"last_list_removed_count": state.last_list_removed_count,
|
||||
"live_updated_total": state.live_updated_total,
|
||||
"list_added_total": state.list_added_total,
|
||||
"list_updated_total": state.list_updated_total,
|
||||
"list_removed_total": state.list_removed_total,
|
||||
"live_full_refresh_requested_total": state.live_full_refresh_requested_total,
|
||||
"last_live_requires_full_refresh": state.last_live_requires_full_refresh,
|
||||
"last_live_ok": state.last_live_ok,
|
||||
"last_list_ok": state.last_list_ok,
|
||||
"last_live_error": state.last_live_error,
|
||||
"last_list_error": state.last_list_error,
|
||||
"updated_at": utcnow(),
|
||||
}
|
||||
return dict(state.stats)
|
||||
|
||||
|
||||
def snapshot(profile_id: int) -> dict:
|
||||
def snapshot(profile_id: int, settings: dict | None = None) -> dict:
|
||||
state = state_for(profile_id)
|
||||
return dict(state.stats or {"profile_id": int(profile_id), "tick_count": state.tick_count})
|
||||
effective_settings = normalize_settings(settings) if settings is not None else get_settings(profile_id)
|
||||
data = dict(state.stats or {"profile_id": int(profile_id), "tick_count": state.tick_count})
|
||||
runtime_ready = bool(state.stats) or state.tick_count > 0
|
||||
data.setdefault("runtime_ready", runtime_ready)
|
||||
data.setdefault("adaptive_enabled", bool(effective_settings.get("adaptive_enabled", DEFAULTS["adaptive_enabled"])))
|
||||
data.setdefault("adaptive_mode", state.adaptive_mode if runtime_ready else ("fixed" if not data.get("adaptive_enabled") else "waiting"))
|
||||
data.setdefault("live_stats_interval_seconds", effective_live_interval(effective_settings, state))
|
||||
data.setdefault("torrent_list_interval_seconds", effective_list_interval(effective_settings, state))
|
||||
data.setdefault("configured_min_interval_seconds", MIN_POLL_INTERVAL_SECONDS)
|
||||
if not runtime_ready:
|
||||
data["last_ok"] = None
|
||||
data.update({
|
||||
"live_poll_count": state.live_poll_count,
|
||||
"list_poll_count": state.list_poll_count,
|
||||
"last_live_duration_ms": state.last_live_duration_ms,
|
||||
"last_list_duration_ms": state.last_list_duration_ms,
|
||||
"last_live_updated_count": state.last_live_updated_count,
|
||||
"last_list_added_count": state.last_list_added_count,
|
||||
"last_list_updated_count": state.last_list_updated_count,
|
||||
"last_list_removed_count": state.last_list_removed_count,
|
||||
"live_updated_total": state.live_updated_total,
|
||||
"list_added_total": state.list_added_total,
|
||||
"list_updated_total": state.list_updated_total,
|
||||
"list_removed_total": state.list_removed_total,
|
||||
"live_full_refresh_requested_total": state.live_full_refresh_requested_total,
|
||||
"last_live_requires_full_refresh": state.last_live_requires_full_refresh,
|
||||
"last_live_ok": state.last_live_ok,
|
||||
"last_list_ok": state.last_list_ok,
|
||||
"last_live_error": state.last_live_error,
|
||||
"last_list_error": state.last_list_error,
|
||||
})
|
||||
return data
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
from __future__ import annotations
|
||||
import json
|
||||
import re
|
||||
import socket
|
||||
import time
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
from ..db import connect
|
||||
from . import preferences, rtorrent
|
||||
|
||||
PORT_CHECK_CACHE_SECONDS = 6 * 60 * 60
|
||||
MAX_PORT_CHECK_CANDIDATES = 256
|
||||
|
||||
|
||||
def _app_setting_get(key: str) -> str | None:
|
||||
with connect() as conn:
|
||||
row = conn.execute("SELECT value FROM app_settings WHERE key=?", (key,)).fetchone()
|
||||
return row.get("value") if row else None
|
||||
|
||||
|
||||
def _app_setting_set(key: str, value: str) -> None:
|
||||
with connect() as conn:
|
||||
conn.execute("INSERT OR REPLACE INTO app_settings(key,value) VALUES(?,?)", (key, value))
|
||||
|
||||
|
||||
def _iso_from_epoch(value: Any) -> str | None:
|
||||
try:
|
||||
return datetime.fromtimestamp(float(value), timezone.utc).isoformat(timespec="seconds")
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _public_ip(profile: dict | None = None, force: bool = False) -> str:
|
||||
if profile and bool(profile.get("is_remote")):
|
||||
return rtorrent.remote_public_ip(profile, force=force)
|
||||
req = urllib.request.Request("https://api.ipify.org", headers={"User-Agent": "pyTorrent/port-check"})
|
||||
with urllib.request.urlopen(req, timeout=8) as res:
|
||||
return res.read(64).decode("utf-8", "replace").strip()
|
||||
|
||||
|
||||
def _parse_port_candidates(value: str, limit: int = MAX_PORT_CHECK_CANDIDATES) -> tuple[list[int], bool]:
|
||||
"""Return valid incoming port candidates from rTorrent network.port_range."""
|
||||
ports: list[int] = []
|
||||
seen: set[int] = set()
|
||||
truncated = False
|
||||
|
||||
def add(port: int) -> None:
|
||||
nonlocal truncated
|
||||
if not 1 <= port <= 65535 or port in seen:
|
||||
return
|
||||
if len(ports) >= limit:
|
||||
truncated = True
|
||||
return
|
||||
seen.add(port)
|
||||
ports.append(port)
|
||||
|
||||
for start, end in re.findall(r"(\d{1,5})\s*-\s*(\d{1,5})", value or ""):
|
||||
a, b = int(start), int(end)
|
||||
if a > b:
|
||||
a, b = b, a
|
||||
for port in range(a, b + 1):
|
||||
add(port)
|
||||
if truncated:
|
||||
break
|
||||
|
||||
without_ranges = re.sub(r"\d{1,5}\s*-\s*\d{1,5}", " ", value or "")
|
||||
for item in re.findall(r"\d{1,5}", without_ranges):
|
||||
add(int(item))
|
||||
|
||||
return ports, truncated
|
||||
|
||||
|
||||
def _incoming_ports(profile: dict) -> dict:
|
||||
try:
|
||||
raw_value = str(rtorrent.client_for(profile).call("network.port_range") or "")
|
||||
except Exception:
|
||||
raw_value = ""
|
||||
ports, truncated = _parse_port_candidates(raw_value)
|
||||
return {"ports": ports, "raw": raw_value, "truncated": truncated}
|
||||
|
||||
|
||||
def _yougetsignal_check(public_ip: str, port: int) -> dict:
|
||||
body = urllib.parse.urlencode({"remoteAddress": public_ip, "portNumber": str(port)}).encode("utf-8")
|
||||
req = urllib.request.Request(
|
||||
"https://ports.yougetsignal.com/check-port.php",
|
||||
data=body,
|
||||
headers={
|
||||
"Content-Type": "application/x-www-form-urlencoded; charset=UTF-8",
|
||||
"User-Agent": "pyTorrent/port-check",
|
||||
"Accept": "text/html,application/json,*/*",
|
||||
},
|
||||
method="POST",
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=12) as res:
|
||||
text = res.read(8192).decode("utf-8", "replace")
|
||||
low = text.lower()
|
||||
if "is open" in low:
|
||||
return {"status": "open", "source": "yougetsignal", "raw": text[:500]}
|
||||
if "is closed" in low:
|
||||
return {"status": "closed", "source": "yougetsignal", "raw": text[:500]}
|
||||
return {"status": "unknown", "source": "yougetsignal", "raw": text[:500]}
|
||||
|
||||
|
||||
def _local_port_fallback(public_ip: str, port: int) -> dict:
|
||||
try:
|
||||
with socket.create_connection((public_ip, port), timeout=3):
|
||||
return {"status": "open", "source": "local-fallback"}
|
||||
except Exception as exc:
|
||||
return {"status": "unknown", "source": "local-fallback", "error": f"Local fallback inconclusive: {exc}"}
|
||||
|
||||
|
||||
def _check_ports(public_ip: str, ports: list[int], checker) -> dict:
|
||||
checked: list[int] = []
|
||||
first_closed: dict | None = None
|
||||
last_result: dict = {"status": "unknown"}
|
||||
|
||||
for port in ports:
|
||||
checked.append(port)
|
||||
current = checker(public_ip, port)
|
||||
last_result = current
|
||||
if current.get("status") == "open":
|
||||
current.update({"port": port, "open_port": port, "checked_ports": checked})
|
||||
return current
|
||||
if current.get("status") == "closed" and first_closed is None:
|
||||
first_closed = current
|
||||
|
||||
result = first_closed or last_result
|
||||
result.update({"port": ports[0] if ports else None, "open_port": None, "checked_ports": checked})
|
||||
return result
|
||||
|
||||
|
||||
def port_check_status(profile: dict | None = None, force: bool = False, user_id: int | None = None) -> dict:
|
||||
"""Return cached or freshly checked incoming-port status for one rTorrent profile."""
|
||||
profile = profile or preferences.active_profile(user_id)
|
||||
prefs = preferences.get_preferences(user_id, int(profile.get("id"))) if profile else preferences.get_preferences(user_id)
|
||||
enabled = bool((prefs or {}).get("port_check_enabled"))
|
||||
if not profile:
|
||||
return {"status": "unknown", "enabled": enabled, "error": "No profile"}
|
||||
|
||||
port_info = _incoming_ports(profile)
|
||||
ports = port_info["ports"]
|
||||
if not ports:
|
||||
return {"status": "unknown", "enabled": enabled, "error": "Cannot read rTorrent network.port_range"}
|
||||
|
||||
ports_key = ",".join(str(port) for port in ports)
|
||||
cache_key = f"port_check:{profile['id']}:{ports_key}:{int(bool(port_info['truncated']))}"
|
||||
if not force:
|
||||
cached = _app_setting_get(cache_key)
|
||||
if cached:
|
||||
try:
|
||||
data = json.loads(cached)
|
||||
if time.time() - float(data.get("checked_at_epoch") or 0) < PORT_CHECK_CACHE_SECONDS:
|
||||
data["cached"] = True
|
||||
data["enabled"] = enabled
|
||||
if not data.get("checked_at"):
|
||||
data["checked_at"] = _iso_from_epoch(data.get("checked_at_epoch"))
|
||||
return data
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
checked_at_epoch = time.time()
|
||||
result = {
|
||||
"status": "unknown",
|
||||
"enabled": enabled,
|
||||
"port": ports[0],
|
||||
"ports": ports,
|
||||
"port_range": port_info["raw"],
|
||||
"ports_truncated": port_info["truncated"],
|
||||
"checked_at_epoch": checked_at_epoch,
|
||||
"checked_at": _iso_from_epoch(checked_at_epoch),
|
||||
"cached": False,
|
||||
}
|
||||
try:
|
||||
public_ip = _public_ip(profile, force=force)
|
||||
result["public_ip"] = public_ip
|
||||
result["remote"] = bool(profile.get("is_remote"))
|
||||
result.update(_check_ports(public_ip, ports, _yougetsignal_check))
|
||||
except Exception as exc:
|
||||
result["error"] = f"YouGetSignal failed: {exc}"
|
||||
try:
|
||||
public_ip = result.get("public_ip") or _public_ip(profile, force=force)
|
||||
result["public_ip"] = public_ip
|
||||
result["remote"] = bool(profile.get("is_remote"))
|
||||
result.update(_check_ports(public_ip, ports, _local_port_fallback))
|
||||
except Exception as fallback_exc:
|
||||
result["fallback_error"] = str(fallback_exc)
|
||||
result["source"] = "none"
|
||||
_app_setting_set(cache_key, json.dumps(result))
|
||||
return result
|
||||
@@ -1,22 +1,10 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
from ..db import connect, utcnow, default_user_id
|
||||
from . import auth
|
||||
from .frontend_assets import BOOTSTRAP_THEME_LABELS
|
||||
|
||||
BOOTSTRAP_THEMES = {
|
||||
"default": "Default Bootstrap",
|
||||
"flatly": "Flatly",
|
||||
"litera": "Litera",
|
||||
"lumen": "Lumen",
|
||||
"minty": "Minty",
|
||||
"sketchy": "Sketchy",
|
||||
"solar": "Solar",
|
||||
"spacelab": "Spacelab",
|
||||
"united": "United",
|
||||
"zephyr": "Zephyr",
|
||||
}
|
||||
BOOTSTRAP_THEMES = BOOTSTRAP_THEME_LABELS
|
||||
|
||||
FONT_FAMILIES = {
|
||||
"default": "Theme default",
|
||||
@@ -38,7 +26,6 @@ FONT_FAMILIES = {
|
||||
"adwaita-mono": "Adwaita Mono",
|
||||
}
|
||||
|
||||
# Note: Backend owns the recommended torrent table layout so frontend builds do not duplicate presets.
|
||||
RECOMMENDED_TABLE_COLUMNS = {
|
||||
"hidden": ["hash", "priority", "hashing", "active", "message", "complete", "state", "ratio_group"],
|
||||
"shown": ["down_total", "to_download", "up_total", "created"],
|
||||
@@ -46,7 +33,7 @@ RECOMMENDED_TABLE_COLUMNS = {
|
||||
"status": True, "size": True, "progress": True, "down_rate": True, "up_rate": True,
|
||||
"eta": True, "seeds": True, "peers": True, "ratio": True, "path": True, "label": True,
|
||||
"ratio_group": False, "down_total": True, "to_download": True, "up_total": True,
|
||||
"created": False, "priority": False, "state": False, "active": False, "complete": False,
|
||||
"created": False, "last_activity": False, "priority": False, "state": False, "active": False, "complete": False,
|
||||
"hashing": False, "message": False, "hash": False,
|
||||
},
|
||||
"mobileSortFilters": {
|
||||
@@ -58,7 +45,7 @@ RECOMMENDED_TABLE_COLUMNS = {
|
||||
"down_rate": 60, "up_rate": 55, "eta": 53, "seeds": 44, "peers": 49,
|
||||
"ratio": 47, "path": 135, "label": 67, "ratio_group": 87,
|
||||
"down_total": 82, "to_download": 89, "up_total": 44, "created": 150,
|
||||
"priority": 80, "state": 70, "active": 70, "complete": 82, "hashing": 82,
|
||||
"last_activity": 150, "priority": 80, "state": 70, "active": 70, "complete": 82, "hashing": 82,
|
||||
"message": 220, "hash": 280,
|
||||
},
|
||||
}
|
||||
@@ -68,17 +55,21 @@ def recommended_table_columns_json() -> str:
|
||||
return json.dumps(RECOMMENDED_TABLE_COLUMNS, separators=(",", ":"))
|
||||
|
||||
|
||||
def apply_recommended_table_columns(user_id: int | None = None):
|
||||
def apply_recommended_table_columns(user_id: int | None = None, profile_id: int | None = None):
|
||||
user_id = user_id or auth.current_user_id() or default_user_id()
|
||||
get_preferences(user_id)
|
||||
profile_id = profile_id or _active_profile_id_for_user(user_id)
|
||||
if not profile_id:
|
||||
return get_preferences(user_id)
|
||||
get_preferences(user_id, profile_id)
|
||||
now = utcnow()
|
||||
value = recommended_table_columns_json()
|
||||
with connect() as conn:
|
||||
conn.execute(
|
||||
"UPDATE user_preferences SET table_columns_json=?, updated_at=? WHERE user_id=?",
|
||||
(value, now, user_id),
|
||||
"INSERT INTO profile_preferences(user_id,profile_id,table_columns_json,created_at,updated_at) VALUES(?,?,?,?,?) "
|
||||
"ON CONFLICT(user_id,profile_id) DO UPDATE SET table_columns_json=excluded.table_columns_json, updated_at=excluded.updated_at",
|
||||
(user_id, profile_id, value, now, now),
|
||||
)
|
||||
return get_preferences(user_id)
|
||||
return get_preferences(user_id, profile_id)
|
||||
|
||||
def bootstrap_css_url(theme: str | None) -> str:
|
||||
from .frontend_assets import bootstrap_css_path
|
||||
@@ -94,6 +85,15 @@ def _int_setting(data: dict, key: str, default: int, minimum: int, maximum: int)
|
||||
return max(minimum, min(maximum, value))
|
||||
|
||||
|
||||
def _url_setting(data: dict, key: str, default: str = "") -> str:
|
||||
value = str(data.get(key) if data.get(key) is not None else default).strip()
|
||||
if len(value) > 2048:
|
||||
value = value[:2048]
|
||||
if value and not (value.startswith("https://") or value.startswith("http://")):
|
||||
return ""
|
||||
return value
|
||||
|
||||
|
||||
def list_profiles(user_id: int | None = None):
|
||||
user_id = user_id or auth.current_user_id() or default_user_id()
|
||||
visible = auth.visible_profile_ids(user_id)
|
||||
@@ -128,6 +128,10 @@ def active_profile(user_id: int | None = None):
|
||||
if row:
|
||||
return row
|
||||
profiles = list_profiles(user_id)
|
||||
# Note: Trusted auth-bypass access must choose a profile explicitly on first entry,
|
||||
# instead of silently reusing the first configured profile.
|
||||
if auth.auth_bypassed_request() and profiles:
|
||||
return None
|
||||
return profiles[0] if profiles else None
|
||||
|
||||
|
||||
@@ -289,17 +293,39 @@ def legacy_disk_monitor_preferences(user_id: int | None = None) -> dict:
|
||||
return _normalize_disk_monitor(row)
|
||||
|
||||
|
||||
def _disk_monitor_owner_label(row: dict | None) -> str:
|
||||
if not row:
|
||||
return ""
|
||||
return str(row.get("owner_display_name") or row.get("owner_username") or row.get("owner_email") or (f"user #{row.get('user_id')}" if row.get("user_id") else "")).strip()
|
||||
|
||||
|
||||
def get_disk_monitor_preferences(profile_id: int | None = None, user_id: int | None = None) -> dict:
|
||||
user_id = user_id or auth.current_user_id() or default_user_id()
|
||||
profile_id = int(profile_id or _active_profile_id_for_user(user_id) or 0)
|
||||
if not profile_id:
|
||||
return legacy_disk_monitor_preferences(user_id)
|
||||
if not auth.can_access_profile(profile_id, user_id):
|
||||
return legacy_disk_monitor_preferences(user_id)
|
||||
with connect() as conn:
|
||||
row = conn.execute("SELECT * FROM disk_monitor_preferences WHERE user_id=? AND profile_id=?", (user_id, profile_id)).fetchone()
|
||||
row = conn.execute(
|
||||
"""
|
||||
SELECT d.*, u.username AS owner_username, u.display_name AS owner_display_name, u.email AS owner_email
|
||||
FROM disk_monitor_preferences d
|
||||
LEFT JOIN users u ON u.id=d.user_id
|
||||
WHERE d.profile_id=?
|
||||
""",
|
||||
(profile_id,),
|
||||
).fetchone()
|
||||
if row:
|
||||
return _normalize_disk_monitor(row)
|
||||
clean = _normalize_disk_monitor(row)
|
||||
clean["disk_monitor_owner_user_id"] = int(row.get("user_id") or 0)
|
||||
clean["disk_monitor_owner_label"] = _disk_monitor_owner_label(row)
|
||||
return clean
|
||||
# Backward-compatible seed: existing global disk monitor values become defaults for first use of a profile.
|
||||
return legacy_disk_monitor_preferences(user_id)
|
||||
clean = legacy_disk_monitor_preferences(user_id)
|
||||
clean["disk_monitor_owner_user_id"] = 0
|
||||
clean["disk_monitor_owner_label"] = ""
|
||||
return clean
|
||||
|
||||
|
||||
def save_disk_monitor_preferences(profile_id: int | None, data: dict, user_id: int | None = None) -> dict:
|
||||
@@ -307,6 +333,8 @@ def save_disk_monitor_preferences(profile_id: int | None, data: dict, user_id: i
|
||||
profile_id = int(profile_id or _active_profile_id_for_user(user_id) or 0)
|
||||
if not profile_id:
|
||||
return legacy_disk_monitor_preferences(user_id)
|
||||
if not auth.can_write_profile(profile_id, user_id):
|
||||
raise PermissionError("No write access to profile")
|
||||
current = get_disk_monitor_preferences(profile_id, user_id)
|
||||
merged = dict(current)
|
||||
for key in ("disk_monitor_paths_json", "disk_monitor_mode", "disk_monitor_selected_path", "disk_monitor_stop_enabled", "disk_monitor_stop_threshold"):
|
||||
@@ -316,49 +344,172 @@ def save_disk_monitor_preferences(profile_id: int | None, data: dict, user_id: i
|
||||
now = utcnow()
|
||||
with connect() as conn:
|
||||
conn.execute(
|
||||
"INSERT INTO disk_monitor_preferences(user_id,profile_id,paths_json,mode,selected_path,stop_enabled,stop_threshold,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?) "
|
||||
"ON CONFLICT(user_id,profile_id) DO UPDATE SET paths_json=excluded.paths_json, mode=excluded.mode, selected_path=excluded.selected_path, stop_enabled=excluded.stop_enabled, stop_threshold=excluded.stop_threshold, updated_at=excluded.updated_at",
|
||||
(user_id, profile_id, clean["disk_monitor_paths_json"], clean["disk_monitor_mode"], clean["disk_monitor_selected_path"], clean["disk_monitor_stop_enabled"], clean["disk_monitor_stop_threshold"], now, now),
|
||||
"INSERT INTO disk_monitor_preferences(profile_id,user_id,paths_json,mode,selected_path,stop_enabled,stop_threshold,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?) "
|
||||
"ON CONFLICT(profile_id) DO UPDATE SET user_id=excluded.user_id, paths_json=excluded.paths_json, mode=excluded.mode, selected_path=excluded.selected_path, stop_enabled=excluded.stop_enabled, stop_threshold=excluded.stop_threshold, updated_at=excluded.updated_at",
|
||||
(profile_id, user_id, clean["disk_monitor_paths_json"], clean["disk_monitor_mode"], clean["disk_monitor_selected_path"], clean["disk_monitor_stop_enabled"], clean["disk_monitor_stop_threshold"], now, now),
|
||||
)
|
||||
clean["disk_monitor_owner_user_id"] = int(user_id)
|
||||
with connect() as conn:
|
||||
row = conn.execute("SELECT display_name AS owner_display_name, username AS owner_username, email AS owner_email, id AS user_id FROM users WHERE id=?", (user_id,)).fetchone()
|
||||
clean["disk_monitor_owner_label"] = _disk_monitor_owner_label(row)
|
||||
return clean
|
||||
|
||||
|
||||
PROFILE_PREFERENCE_COLUMNS = {
|
||||
"table_columns_json",
|
||||
"torrent_sort_json",
|
||||
"active_filter",
|
||||
"peers_refresh_seconds",
|
||||
"port_check_enabled",
|
||||
"tracker_favicons_enabled",
|
||||
"reverse_dns_enabled",
|
||||
}
|
||||
|
||||
|
||||
def _seed_profile_preferences(conn, user_id: int, profile_id: int) -> dict:
|
||||
now = utcnow()
|
||||
legacy = conn.execute("SELECT * FROM user_preferences WHERE user_id=?", (user_id,)).fetchone() or {}
|
||||
row = conn.execute("SELECT * FROM profile_preferences WHERE user_id=? AND profile_id=?", (user_id, profile_id)).fetchone()
|
||||
if row:
|
||||
return dict(row)
|
||||
# Note: First profile preference row is seeded from legacy user-level values so upgrades keep the current layout/filter behavior.
|
||||
conn.execute(
|
||||
"INSERT INTO profile_preferences(user_id,profile_id,table_columns_json,torrent_sort_json,active_filter,peers_refresh_seconds,port_check_enabled,tracker_favicons_enabled,reverse_dns_enabled,sidebar_labels_expanded,sidebar_shortcuts_expanded,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?)",
|
||||
(
|
||||
user_id,
|
||||
profile_id,
|
||||
legacy.get("table_columns_json"),
|
||||
legacy.get("torrent_sort_json"),
|
||||
legacy.get("active_filter") or "all",
|
||||
int(legacy.get("peers_refresh_seconds") or 0),
|
||||
int(legacy.get("port_check_enabled") or 0),
|
||||
int(legacy.get("tracker_favicons_enabled") or 0),
|
||||
int(legacy.get("reverse_dns_enabled") or 0),
|
||||
int(legacy.get("sidebar_labels_expanded") or 0),
|
||||
int(legacy.get("sidebar_shortcuts_expanded") or 0),
|
||||
now,
|
||||
now,
|
||||
),
|
||||
)
|
||||
return dict(conn.execute("SELECT * FROM profile_preferences WHERE user_id=? AND profile_id=?", (user_id, profile_id)).fetchone() or {})
|
||||
|
||||
|
||||
def get_profile_preferences(user_id: int, profile_id: int | None) -> dict:
|
||||
if not profile_id:
|
||||
return {}
|
||||
with connect() as conn:
|
||||
return _seed_profile_preferences(conn, user_id, int(profile_id))
|
||||
|
||||
|
||||
def save_profile_preferences(user_id: int, profile_id: int | None, data: dict) -> None:
|
||||
if not profile_id:
|
||||
return
|
||||
profile_id = int(profile_id)
|
||||
now = utcnow()
|
||||
with connect() as conn:
|
||||
current = _seed_profile_preferences(conn, user_id, profile_id)
|
||||
updates: dict[str, object] = {}
|
||||
if data.get("table_columns_json") is not None:
|
||||
updates["table_columns_json"] = str(data.get("table_columns_json"))
|
||||
if data.get("peers_refresh_seconds") is not None:
|
||||
sec = int(data.get("peers_refresh_seconds") or 0)
|
||||
updates["peers_refresh_seconds"] = sec if sec in {0, 10, 15, 30, 60} else 0
|
||||
if data.get("port_check_enabled") is not None:
|
||||
updates["port_check_enabled"] = 1 if data.get("port_check_enabled") else 0
|
||||
if data.get("tracker_favicons_enabled") is not None:
|
||||
updates["tracker_favicons_enabled"] = 1 if data.get("tracker_favicons_enabled") else 0
|
||||
if data.get("reverse_dns_enabled") is not None:
|
||||
# Note: Reverse DNS is stored per profile because PTR lookups depend on swarm size and profile network latency.
|
||||
updates["reverse_dns_enabled"] = 1 if data.get("reverse_dns_enabled") else 0
|
||||
if data.get("sidebar_labels_expanded") is not None:
|
||||
# Note: Label collapse state is per profile because each rTorrent can have a very different label set.
|
||||
updates["sidebar_labels_expanded"] = 1 if data.get("sidebar_labels_expanded") else 0
|
||||
if data.get("sidebar_shortcuts_expanded") is not None:
|
||||
# Note: Shortcut help visibility is stored with profile preferences to survive refreshes.
|
||||
updates["sidebar_shortcuts_expanded"] = 1 if data.get("sidebar_shortcuts_expanded") else 0
|
||||
if data.get("torrent_sort_json") is not None:
|
||||
value = data.get("torrent_sort_json") if isinstance(data.get("torrent_sort_json"), str) else json.dumps(data.get("torrent_sort_json"))
|
||||
parsed = json.loads(value or "{}")
|
||||
if not isinstance(parsed, dict):
|
||||
parsed = {}
|
||||
try:
|
||||
direction = int(parsed.get("dir") or 1)
|
||||
except (TypeError, ValueError):
|
||||
direction = 1
|
||||
allowed_sort_keys = {"name", "status", "size", "progress", "down_rate", "up_rate", "eta", "seeds", "peers", "ratio", "path", "label", "ratio_group", "down_total", "to_download", "up_total", "created", "last_activity", "priority", "state", "active", "complete", "hashing", "message", "hash"}
|
||||
sort_key = str(parsed.get("key") or "name")
|
||||
if sort_key not in allowed_sort_keys:
|
||||
sort_key = "name"
|
||||
updates["torrent_sort_json"] = json.dumps({"key": sort_key, "dir": 1 if direction >= 0 else -1})
|
||||
if data.get("active_filter") is not None:
|
||||
value = str(data.get("active_filter") or "all").strip()
|
||||
if not value or len(value) > 180:
|
||||
value = "all"
|
||||
allowed_static_filters = {"all", "downloading", "queued", "seeding", "paused", "checking", "error", "post_check", "stopped", "moving"}
|
||||
if value not in allowed_static_filters and not value.startswith("label:") and not value.startswith("tracker:"):
|
||||
value = "all"
|
||||
updates["active_filter"] = value
|
||||
if not updates:
|
||||
return
|
||||
merged = {**current, **updates}
|
||||
conn.execute(
|
||||
"INSERT INTO profile_preferences(user_id,profile_id,table_columns_json,torrent_sort_json,active_filter,peers_refresh_seconds,port_check_enabled,tracker_favicons_enabled,reverse_dns_enabled,sidebar_labels_expanded,sidebar_shortcuts_expanded,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?) "
|
||||
"ON CONFLICT(user_id,profile_id) DO UPDATE SET table_columns_json=excluded.table_columns_json, torrent_sort_json=excluded.torrent_sort_json, active_filter=excluded.active_filter, peers_refresh_seconds=excluded.peers_refresh_seconds, port_check_enabled=excluded.port_check_enabled, tracker_favicons_enabled=excluded.tracker_favicons_enabled, reverse_dns_enabled=excluded.reverse_dns_enabled, sidebar_labels_expanded=excluded.sidebar_labels_expanded, sidebar_shortcuts_expanded=excluded.sidebar_shortcuts_expanded, updated_at=excluded.updated_at",
|
||||
(
|
||||
user_id,
|
||||
profile_id,
|
||||
merged.get("table_columns_json"),
|
||||
merged.get("torrent_sort_json"),
|
||||
merged.get("active_filter") or "all",
|
||||
int(merged.get("peers_refresh_seconds") or 0),
|
||||
int(merged.get("port_check_enabled") or 0),
|
||||
int(merged.get("tracker_favicons_enabled") or 0),
|
||||
int(merged.get("reverse_dns_enabled") or 0),
|
||||
int(merged.get("sidebar_labels_expanded") or 0),
|
||||
int(merged.get("sidebar_shortcuts_expanded") or 0),
|
||||
merged.get("created_at") or now,
|
||||
now,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def get_preferences(user_id: int | None = None, profile_id: int | None = None):
|
||||
user_id = user_id or auth.current_user_id() or default_user_id()
|
||||
profile_id = profile_id or _active_profile_id_for_user(user_id)
|
||||
with connect() as conn:
|
||||
pref = conn.execute("SELECT * FROM user_preferences WHERE user_id=?", (user_id,)).fetchone()
|
||||
if not pref:
|
||||
now = utcnow()
|
||||
conn.execute("INSERT INTO user_preferences(user_id, theme, created_at, updated_at) VALUES(?, 'dark', ?, ?)", (user_id, now, now))
|
||||
pref = conn.execute("SELECT * FROM user_preferences WHERE user_id=?", (user_id,)).fetchone()
|
||||
merged = dict(pref or {})
|
||||
merged = dict(pref or {})
|
||||
if profile_id:
|
||||
merged.update(_seed_profile_preferences(conn, user_id, int(profile_id)))
|
||||
merged.update(get_disk_monitor_preferences(profile_id, user_id))
|
||||
return merged
|
||||
|
||||
|
||||
def save_preferences(data: dict, user_id: int | None = None):
|
||||
def save_preferences(data: dict, user_id: int | None = None, profile_id: int | None = None):
|
||||
user_id = user_id or auth.current_user_id() or default_user_id()
|
||||
profile_id = profile_id or _active_profile_id_for_user(user_id)
|
||||
allowed_theme = data.get("theme") if data.get("theme") in {"light", "dark"} else None
|
||||
bootstrap_theme = data.get("bootstrap_theme") if data.get("bootstrap_theme") in BOOTSTRAP_THEMES else None
|
||||
font_family = data.get("font_family") if data.get("font_family") in FONT_FAMILIES else None
|
||||
table_columns_json = data.get("table_columns_json")
|
||||
peers_refresh_seconds = data.get("peers_refresh_seconds")
|
||||
port_check_enabled = data.get("port_check_enabled")
|
||||
footer_items_json = data.get("footer_items_json")
|
||||
title_speed_enabled = data.get("title_speed_enabled")
|
||||
tracker_favicons_enabled = data.get("tracker_favicons_enabled")
|
||||
reverse_dns_enabled = data.get("reverse_dns_enabled")
|
||||
automation_toasts_enabled = data.get("automation_toasts_enabled")
|
||||
smart_queue_toasts_enabled = data.get("smart_queue_toasts_enabled")
|
||||
easter_egg_enabled = data.get("easter_egg_enabled")
|
||||
easter_egg_loading_image_url = data.get("easter_egg_loading_image_url")
|
||||
easter_egg_click_image_url = data.get("easter_egg_click_image_url")
|
||||
disk_monitor_paths_json = data.get("disk_monitor_paths_json")
|
||||
disk_monitor_mode = data.get("disk_monitor_mode")
|
||||
disk_monitor_selected_path = data.get("disk_monitor_selected_path")
|
||||
disk_monitor_stop_enabled = data.get("disk_monitor_stop_enabled")
|
||||
disk_monitor_stop_threshold = data.get("disk_monitor_stop_threshold")
|
||||
interface_scale = data.get("interface_scale")
|
||||
torrent_list_font_size = data.get("torrent_list_font_size")
|
||||
compact_torrent_list_enabled = data.get("compact_torrent_list_enabled")
|
||||
detail_panel_height = data.get("detail_panel_height")
|
||||
torrent_sort_json = data.get("torrent_sort_json")
|
||||
active_filter = data.get("active_filter")
|
||||
disk_payload = None
|
||||
if any(value is not None for value in (disk_monitor_paths_json, disk_monitor_mode, disk_monitor_selected_path, disk_monitor_stop_enabled, disk_monitor_stop_threshold)):
|
||||
disk_payload = {
|
||||
@@ -376,32 +527,37 @@ def save_preferences(data: dict, user_id: int | None = None):
|
||||
conn.execute("UPDATE user_preferences SET bootstrap_theme=?, updated_at=? WHERE user_id=?", (bootstrap_theme, now, user_id))
|
||||
if font_family:
|
||||
conn.execute("UPDATE user_preferences SET font_family=?, updated_at=? WHERE user_id=?", (font_family, now, user_id))
|
||||
if table_columns_json is not None:
|
||||
conn.execute("UPDATE user_preferences SET table_columns_json=?, updated_at=? WHERE user_id=?", (str(table_columns_json), now, user_id))
|
||||
if peers_refresh_seconds is not None:
|
||||
sec = int(peers_refresh_seconds or 0)
|
||||
if sec not in {0, 10, 15, 30, 60}: sec = 0
|
||||
conn.execute("UPDATE user_preferences SET peers_refresh_seconds=?, updated_at=? WHERE user_id=?", (sec, now, user_id))
|
||||
if port_check_enabled is not None:
|
||||
conn.execute("UPDATE user_preferences SET port_check_enabled=?, updated_at=? WHERE user_id=?", (1 if port_check_enabled else 0, now, user_id))
|
||||
if title_speed_enabled is not None:
|
||||
conn.execute("UPDATE user_preferences SET title_speed_enabled=?, updated_at=? WHERE user_id=?", (1 if title_speed_enabled else 0, now, user_id))
|
||||
if tracker_favicons_enabled is not None:
|
||||
conn.execute("UPDATE user_preferences SET tracker_favicons_enabled=?, updated_at=? WHERE user_id=?", (1 if tracker_favicons_enabled else 0, now, user_id))
|
||||
if reverse_dns_enabled is not None:
|
||||
# Note: Reverse DNS is optional because peer PTR lookups can add latency on busy swarms.
|
||||
conn.execute("UPDATE user_preferences SET reverse_dns_enabled=?, updated_at=? WHERE user_id=?", (1 if reverse_dns_enabled else 0, now, user_id))
|
||||
if automation_toasts_enabled is not None:
|
||||
# Note: Lets users silence automation-created toast noise without hiding job/history data.
|
||||
conn.execute("UPDATE user_preferences SET automation_toasts_enabled=?, updated_at=? WHERE user_id=?", (1 if automation_toasts_enabled else 0, now, user_id))
|
||||
if smart_queue_toasts_enabled is not None:
|
||||
# Note: Smart Queue toast noise can be disabled independently from automation notifications.
|
||||
conn.execute("UPDATE user_preferences SET smart_queue_toasts_enabled=?, updated_at=? WHERE user_id=?", (1 if smart_queue_toasts_enabled else 0, now, user_id))
|
||||
if easter_egg_enabled is not None:
|
||||
conn.execute("UPDATE user_preferences SET easter_egg_enabled=?, updated_at=? WHERE user_id=?", (1 if easter_egg_enabled else 0, now, user_id))
|
||||
if easter_egg_loading_image_url is not None:
|
||||
conn.execute("UPDATE user_preferences SET easter_egg_loading_image_url=?, updated_at=? WHERE user_id=?", (_url_setting(data, "easter_egg_loading_image_url"), now, user_id))
|
||||
if easter_egg_click_image_url is not None:
|
||||
conn.execute("UPDATE user_preferences SET easter_egg_click_image_url=?, updated_at=? WHERE user_id=?", (_url_setting(data, "easter_egg_click_image_url"), now, user_id))
|
||||
if interface_scale is not None:
|
||||
scale = int(interface_scale or 100)
|
||||
if scale < 80: scale = 80
|
||||
if scale > 140: scale = 140
|
||||
conn.execute("UPDATE user_preferences SET interface_scale=?, updated_at=? WHERE user_id=?", (scale, now, user_id))
|
||||
if torrent_list_font_size is not None:
|
||||
# Note: Torrent list font size is clamped so dense rows cannot break the virtualized list layout.
|
||||
try:
|
||||
list_font_size = int(torrent_list_font_size or 13)
|
||||
except (TypeError, ValueError):
|
||||
list_font_size = 13
|
||||
if list_font_size < 11: list_font_size = 11
|
||||
if list_font_size > 16: list_font_size = 16
|
||||
conn.execute("UPDATE user_preferences SET torrent_list_font_size=?, updated_at=? WHERE user_id=?", (list_font_size, now, user_id))
|
||||
if compact_torrent_list_enabled is not None:
|
||||
# Note: Compact torrent list is a visual-only preference for desktop and mobile list density.
|
||||
conn.execute("UPDATE user_preferences SET compact_torrent_list_enabled=?, updated_at=? WHERE user_id=?", (1 if compact_torrent_list_enabled else 0, now, user_id))
|
||||
if footer_items_json is not None:
|
||||
# Note: Store only JSON objects so footer visibility can be extended without schema churn.
|
||||
value = footer_items_json if isinstance(footer_items_json, str) else json.dumps(footer_items_json)
|
||||
@@ -417,30 +573,7 @@ def save_preferences(data: dict, user_id: int | None = None):
|
||||
if height < 160: height = 160
|
||||
if height > 720: height = 720
|
||||
conn.execute("UPDATE user_preferences SET detail_panel_height=?, updated_at=? WHERE user_id=?", (height, now, user_id))
|
||||
if torrent_sort_json is not None:
|
||||
# Note: Persist only a compact sort object; unknown keys are ignored on the client.
|
||||
value = torrent_sort_json if isinstance(torrent_sort_json, str) else json.dumps(torrent_sort_json)
|
||||
parsed = json.loads(value or "{}")
|
||||
if not isinstance(parsed, dict):
|
||||
parsed = {}
|
||||
try:
|
||||
direction = int(parsed.get("dir") or 1)
|
||||
except (TypeError, ValueError):
|
||||
direction = 1
|
||||
allowed_sort_keys = {"name", "status", "size", "progress", "down_rate", "up_rate", "eta", "seeds", "peers", "ratio", "path", "label", "ratio_group", "down_total", "to_download", "up_total", "created", "priority", "state", "active", "complete", "hashing", "message", "hash"}
|
||||
sort_key = str(parsed.get("key") or "name")
|
||||
if sort_key not in allowed_sort_keys:
|
||||
sort_key = "name"
|
||||
clean = {"key": sort_key, "dir": 1 if direction >= 0 else -1}
|
||||
conn.execute("UPDATE user_preferences SET torrent_sort_json=?, updated_at=? WHERE user_id=?", (json.dumps(clean), now, user_id))
|
||||
if active_filter is not None:
|
||||
value = str(active_filter or "all").strip()
|
||||
if not value or len(value) > 180:
|
||||
value = "all"
|
||||
allowed_static_filters = {"all", "downloading", "seeding", "paused", "checking", "error", "stopped", "moving"}
|
||||
if value not in allowed_static_filters and not value.startswith("label:") and not value.startswith("tracker:"):
|
||||
value = "all"
|
||||
conn.execute("UPDATE user_preferences SET active_filter=?, updated_at=? WHERE user_id=?", (value, now, user_id))
|
||||
save_profile_preferences(user_id, profile_id, data)
|
||||
if disk_payload is not None:
|
||||
save_disk_monitor_preferences(_active_profile_id_for_user(user_id), disk_payload, user_id)
|
||||
return get_preferences(user_id)
|
||||
save_disk_monitor_preferences(profile_id, disk_payload, user_id)
|
||||
return get_preferences(user_id, profile_id)
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
from __future__ import annotations
|
||||
from ..db import connect, utcnow
|
||||
|
||||
|
||||
def normalize_limit(value: object) -> int:
|
||||
try:
|
||||
limit = int(float(value or 0))
|
||||
except (TypeError, ValueError):
|
||||
return 0
|
||||
return max(0, limit)
|
||||
|
||||
|
||||
def get_limits(profile_id: int | None) -> dict:
|
||||
profile_id = int(profile_id or 0)
|
||||
if not profile_id:
|
||||
return {"down": 0, "up": 0, "configured": False}
|
||||
with connect() as conn:
|
||||
row = conn.execute("SELECT down_limit, up_limit FROM profile_speed_limits WHERE profile_id=?", (profile_id,)).fetchone()
|
||||
if not row:
|
||||
return {"down": 0, "up": 0, "configured": False}
|
||||
return {"down": int(row.get("down_limit") or 0), "up": int(row.get("up_limit") or 0), "configured": True}
|
||||
|
||||
|
||||
def save_limits(profile_id: int, down: object, up: object) -> dict:
|
||||
profile_id = int(profile_id or 0)
|
||||
if not profile_id:
|
||||
raise ValueError("Missing profile id")
|
||||
clean = {"down": normalize_limit(down), "up": normalize_limit(up), "configured": True}
|
||||
now = utcnow()
|
||||
with connect() as conn:
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO profile_speed_limits(profile_id, down_limit, up_limit, created_at, updated_at)
|
||||
VALUES(?,?,?,?,?)
|
||||
ON CONFLICT(profile_id) DO UPDATE SET
|
||||
down_limit=excluded.down_limit,
|
||||
up_limit=excluded.up_limit,
|
||||
updated_at=excluded.updated_at
|
||||
""",
|
||||
(profile_id, clean["down"], clean["up"], now, now),
|
||||
)
|
||||
return clean
|
||||
|
||||
|
||||
def delete_limits(profile_id: int) -> None:
|
||||
with connect() as conn:
|
||||
conn.execute("DELETE FROM profile_speed_limits WHERE profile_id=?", (int(profile_id or 0),))
|
||||
@@ -1,11 +1,9 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from ..db import connect, utcnow, default_user_id
|
||||
from . import rtorrent
|
||||
from . import auth, rtorrent
|
||||
from .workers import enqueue
|
||||
|
||||
|
||||
@@ -67,12 +65,14 @@ def _should_apply(profile: dict, group: dict, torrent: dict) -> tuple[bool, str]
|
||||
|
||||
|
||||
def check(profile: dict, user_id: int | None = None) -> dict:
|
||||
user_id = user_id or default_user_id()
|
||||
viewer_user_id = user_id or default_user_id()
|
||||
profile_id = int(profile["id"])
|
||||
with connect() as conn:
|
||||
groups = conn.execute("SELECT * FROM ratio_groups WHERE user_id=? AND profile_id=? AND enabled=1", (user_id, profile_id)).fetchall()
|
||||
groups = conn.execute("SELECT * FROM ratio_groups WHERE profile_id=? AND enabled=1 ORDER BY lower(name), id", (profile_id,)).fetchall()
|
||||
already = {row["torrent_hash"] for row in conn.execute("SELECT torrent_hash FROM ratio_assignments WHERE profile_id=? AND last_status='applied'", (profile_id,)).fetchall()}
|
||||
groups_by_name = {str(g.get("name") or ""): g for g in groups}
|
||||
groups_by_name: dict[str, dict] = {}
|
||||
for group in groups:
|
||||
groups_by_name.setdefault(str(group.get("name") or ""), group)
|
||||
applied = 0
|
||||
skipped = 0
|
||||
queued_jobs = []
|
||||
@@ -93,6 +93,11 @@ def check(profile: dict, user_id: int | None = None) -> dict:
|
||||
)
|
||||
continue
|
||||
action = str(group.get("action") or "stop")
|
||||
owner_user_id = int(group.get("user_id") or viewer_user_id)
|
||||
if not auth.can_write_profile(profile_id, owner_user_id):
|
||||
skipped += 1
|
||||
_record(owner_user_id, profile_id, group, torrent, action, "skipped", "owner has no write access to profile")
|
||||
continue
|
||||
payload = {"hashes": [torrent["hash"]], "source": "ratio", "job_context": {"source": "ratio", "rule_name": group.get("name"), "hash_count": 1}}
|
||||
if action == "remove_data":
|
||||
api_action = "remove"
|
||||
@@ -105,10 +110,10 @@ def check(profile: dict, user_id: int | None = None) -> dict:
|
||||
payload["label"] = group.get("set_label") or group.get("name") or ""
|
||||
else:
|
||||
api_action = action if action in {"stop", "remove", "pause"} else "stop"
|
||||
job_id = enqueue(api_action, profile_id, payload, user_id=user_id)
|
||||
job_id = enqueue(api_action, profile_id, payload, user_id=owner_user_id)
|
||||
queued_jobs.append(job_id)
|
||||
applied += 1
|
||||
_record(user_id, profile_id, group, torrent, action, "applied", reason, {"job_id": job_id, "api_action": api_action})
|
||||
_record(owner_user_id, profile_id, group, torrent, action, "applied", reason, {"job_id": job_id, "api_action": api_action})
|
||||
return {"applied": applied, "skipped": skipped, "job_ids": queued_jobs}
|
||||
|
||||
|
||||
@@ -127,12 +132,15 @@ def start_scheduler(socketio=None) -> None:
|
||||
try:
|
||||
from .preferences import get_profile
|
||||
with connect() as conn:
|
||||
profiles = conn.execute("SELECT DISTINCT user_id, profile_id FROM ratio_groups WHERE enabled=1 AND profile_id IS NOT NULL").fetchall()
|
||||
profiles = conn.execute("SELECT DISTINCT profile_id FROM ratio_groups WHERE enabled=1 AND profile_id IS NOT NULL").fetchall()
|
||||
for row in profiles:
|
||||
profile = get_profile(int(row["profile_id"]), int(row["user_id"]))
|
||||
profile_id = int(row["profile_id"])
|
||||
with connect() as conn:
|
||||
owner = conn.execute("SELECT user_id FROM rtorrent_profiles WHERE id=?", (profile_id,)).fetchone()
|
||||
profile = get_profile(profile_id, int(owner["user_id"] if owner and owner.get("user_id") else default_user_id()))
|
||||
if not profile:
|
||||
continue
|
||||
result = check(profile, int(row["user_id"]))
|
||||
result = check(profile)
|
||||
if socketio and result.get("applied"):
|
||||
socketio.emit("ratio_rules_checked", {"profile_id": profile["id"], **result}, to=f"profile:{profile['id']}")
|
||||
except Exception:
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from ..config import JOBS_RETENTION_DAYS, LOG_RETENTION_DAYS, SMART_QUEUE_HISTORY_RETENTION_DAYS, TRAFFIC_HISTORY_RETENTION_DAYS
|
||||
from ..db import connect
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import ipaddress
|
||||
import socket
|
||||
import time
|
||||
|
||||
+12
-14
@@ -1,5 +1,4 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import time
|
||||
import urllib.request
|
||||
@@ -8,7 +7,7 @@ from datetime import datetime, timezone, timedelta
|
||||
from email.utils import parsedate_to_datetime
|
||||
from typing import Iterable
|
||||
|
||||
from ..db import connect, utcnow, default_user_id
|
||||
from ..db import connect, utcnow
|
||||
from . import rtorrent
|
||||
from .workers import enqueue
|
||||
|
||||
@@ -122,12 +121,12 @@ def matches_rule(rule: dict, item: dict) -> tuple[bool, str]:
|
||||
return True, "matched"
|
||||
|
||||
|
||||
def _log(user_id: int, profile_id: int, feed_id: int | None, rule_id: int | None, item: dict, status: str, message: str) -> None:
|
||||
def _log(profile_id: int, feed_id: int | None, rule_id: int | None, item: dict, status: str, message: str) -> None:
|
||||
with connect() as conn:
|
||||
try:
|
||||
conn.execute(
|
||||
"INSERT INTO rss_history(user_id,profile_id,feed_id,rule_id,title,link,status,message,created_at) VALUES(?,?,?,?,?,?,?,?,?)",
|
||||
(user_id, profile_id, feed_id, rule_id, item.get("title"), item.get("link"), status, message, utcnow()),
|
||||
"INSERT INTO rss_history(profile_id,feed_id,rule_id,title,link,status,message,created_at) VALUES(?,?,?,?,?,?,?,?)",
|
||||
(profile_id, feed_id, rule_id, item.get("title"), item.get("link"), status, message, utcnow()),
|
||||
)
|
||||
except Exception:
|
||||
# Note: Duplicate successful RSS matches are ignored to prevent recurring duplicate downloads.
|
||||
@@ -135,15 +134,14 @@ def _log(user_id: int, profile_id: int, feed_id: int | None, rule_id: int | None
|
||||
|
||||
|
||||
def check(profile: dict, user_id: int | None = None, only_due: bool = False) -> dict:
|
||||
user_id = user_id or default_user_id()
|
||||
profile_id = int(profile["id"])
|
||||
now = utcnow()
|
||||
with connect() as conn:
|
||||
if only_due:
|
||||
feeds = conn.execute("SELECT * FROM rss_feeds WHERE user_id=? AND profile_id=? AND enabled=1 AND (next_check_at IS NULL OR next_check_at<=?)", (user_id, profile_id, now)).fetchall()
|
||||
feeds = conn.execute("SELECT * FROM rss_feeds WHERE profile_id=? AND enabled=1 AND (next_check_at IS NULL OR next_check_at<=?)", (profile_id, now)).fetchall()
|
||||
else:
|
||||
feeds = conn.execute("SELECT * FROM rss_feeds WHERE user_id=? AND profile_id=? AND enabled=1", (user_id, profile_id)).fetchall()
|
||||
rules = conn.execute("SELECT * FROM rss_rules WHERE user_id=? AND profile_id=? AND enabled=1", (user_id, profile_id)).fetchall()
|
||||
feeds = conn.execute("SELECT * FROM rss_feeds WHERE profile_id=? AND enabled=1", (profile_id,)).fetchall()
|
||||
rules = conn.execute("SELECT * FROM rss_rules WHERE profile_id=? AND enabled=1", (profile_id,)).fetchall()
|
||||
queued = 0
|
||||
tested = 0
|
||||
errors: list[dict] = []
|
||||
@@ -160,11 +158,11 @@ def check(profile: dict, user_id: int | None = None, only_due: bool = False) ->
|
||||
continue
|
||||
link = item.get("link") or ""
|
||||
if not link:
|
||||
_log(user_id, profile_id, feed["id"], rule["id"], item, "skipped", "missing link")
|
||||
_log(profile_id, feed["id"], rule["id"], item, "skipped", "missing link")
|
||||
continue
|
||||
enqueue("add_magnet", profile_id, {"uri": link, "start": bool(rule["start"]), "directory": rule.get("save_path") or rtorrent.default_download_path(profile), "label": rule.get("label") or "", "source": "rss"}, user_id=user_id)
|
||||
queued += 1
|
||||
_log(user_id, profile_id, feed["id"], rule["id"], item, "queued", reason)
|
||||
_log(profile_id, feed["id"], rule["id"], item, "queued", reason)
|
||||
with connect() as conn:
|
||||
conn.execute("UPDATE rss_feeds SET last_error=NULL,last_checked_at=?,next_check_at=?,updated_at=? WHERE id=?", (now, next_check, now, feed["id"]))
|
||||
except Exception as exc:
|
||||
@@ -200,11 +198,11 @@ def start_scheduler(socketio=None) -> None:
|
||||
try:
|
||||
from .preferences import get_profile
|
||||
with connect() as conn:
|
||||
profiles = conn.execute("SELECT DISTINCT user_id, profile_id FROM rss_feeds WHERE enabled=1 AND profile_id IS NOT NULL").fetchall()
|
||||
profiles = conn.execute("SELECT DISTINCT profile_id FROM rss_feeds WHERE enabled=1 AND profile_id IS NOT NULL").fetchall()
|
||||
for row in profiles:
|
||||
profile = get_profile(int(row["profile_id"]), int(row["user_id"]))
|
||||
profile = get_profile(int(row["profile_id"]))
|
||||
if profile:
|
||||
result = check(profile, int(row["user_id"]), only_due=True)
|
||||
result = check(profile, only_due=True)
|
||||
if socketio and result.get("queued"):
|
||||
socketio.emit("rss_checked", {"profile_id": profile["id"], **result}, to=f"profile:{profile['id']}")
|
||||
except Exception:
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
# rTorrent service modules
|
||||
|
||||
The old `pytorrent/services/rtorrent.py` monolith is end-of-life.
|
||||
Do not recreate it and do not add new rTorrent logic outside this directory.
|
||||
|
||||
Use focused modules in `pytorrent/services/rtorrent/` instead:
|
||||
- `client.py` for SCGI/XMLRPC transport and shared caches.
|
||||
- `system.py` for status, footer metrics, disk and remote host usage.
|
||||
- `torrents.py` for torrent list and torrent operations.
|
||||
- `files.py`, `config.py`, `diagnostics.py` for their dedicated areas.
|
||||
@@ -1,10 +1,5 @@
|
||||
from __future__ import annotations
|
||||
|
||||
# EOL note: do not recreate or edit the old pytorrent/services/rtorrent.py monolith.
|
||||
# All rTorrent code belongs in this package directory.
|
||||
|
||||
# Note: Public functions are re-exported here so existing imports from services.rtorrent remain transparent.
|
||||
# Compatibility note: module __all__ definitions include selected private helpers used by existing routes.
|
||||
from .client import *
|
||||
from .system import *
|
||||
from .diagnostics import *
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import re
|
||||
from .client import *
|
||||
@@ -11,13 +10,11 @@ _HEX_RE = re.compile(r"[0-9a-fA-F]")
|
||||
|
||||
def _clean_hex_bitfield(value) -> str:
|
||||
"""Return only hexadecimal bitfield characters from rTorrent output."""
|
||||
# Note: rTorrent may return spacing or non-hex separators; keep only the actual bitfield payload.
|
||||
return "".join(_HEX_RE.findall(str(value or ""))).lower()
|
||||
|
||||
|
||||
def _hex_to_bits(value: str, limit: int | None = None) -> list[int]:
|
||||
"""Decode an rTorrent hex bitfield into one bit per torrent piece."""
|
||||
# Note: d.bitfield is a packed bitset, not a per-nibble completion percentage; decoding fixes false partial cells near 100% torrents.
|
||||
bits: list[int] = []
|
||||
for char in _clean_hex_bitfield(value):
|
||||
nibble = int(char, 16)
|
||||
@@ -47,7 +44,6 @@ def _chunk_status(completed: int, total: int, seen: bool = False) -> str:
|
||||
|
||||
def _group_cells(cells: list[dict], max_cells: int) -> list[dict]:
|
||||
"""Reduce very large torrents to a browser-friendly number of visual cells."""
|
||||
# Note: Grouping now happens on real piece states, so the aggregated percentage matches the actual torrent progress.
|
||||
if max_cells <= 0 or len(cells) <= max_cells:
|
||||
return cells
|
||||
grouped: list[dict] = []
|
||||
@@ -79,7 +75,6 @@ def _group_cells(cells: list[dict], max_cells: int) -> list[dict]:
|
||||
|
||||
def _build_piece_cells(total_chunks: int, have_bits: list[int], seen_bits: list[int]) -> list[dict]:
|
||||
"""Create one raw cell per real torrent piece."""
|
||||
# Note: The UI still groups these cells later when needed, but the source data remains exact per piece.
|
||||
cells: list[dict] = []
|
||||
for idx in range(max(0, int(total_chunks or 0))):
|
||||
completed = 1 if idx < len(have_bits) and have_bits[idx] else 0
|
||||
@@ -101,7 +96,6 @@ def _build_piece_cells(total_chunks: int, have_bits: list[int], seen_bits: list[
|
||||
|
||||
def torrent_chunks(profile: dict, torrent_hash: str, max_cells: int = 2048) -> dict:
|
||||
"""Return ruTorrent-like visual chunk data for one torrent."""
|
||||
# Note: Uses documented rTorrent XML-RPC fields: d.bitfield, d.chunks_seen, d.chunk_size and d.size_chunks.
|
||||
c = client_for(profile)
|
||||
values = {
|
||||
"bitfield": _clean_hex_bitfield(c.call("d.bitfield", torrent_hash)),
|
||||
@@ -177,7 +171,6 @@ def _files_touching_chunks(c: ScgiRtorrentClient, torrent_hash: str, first_chunk
|
||||
|
||||
def torrent_chunk_action(profile: dict, torrent_hash: str, action: str, payload: dict | None = None) -> dict:
|
||||
"""Run safe actions related to visual chunk selection."""
|
||||
# Note: rTorrent does not expose a supported XML-RPC method to redownload one arbitrary chunk; recheck is torrent-wide.
|
||||
payload = payload or {}
|
||||
action = str(action or "").strip().lower()
|
||||
c = client_for(profile)
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import errno
|
||||
import os
|
||||
import posixpath
|
||||
@@ -95,6 +94,7 @@ _REMOTE_USAGE_CACHE: dict[int, tuple[float, dict]] = {}
|
||||
_REMOTE_USAGE_TTL_SECONDS = 60.0
|
||||
_REMOTE_PUBLIC_IP_CACHE: dict[int, tuple[float, str]] = {}
|
||||
_REMOTE_PUBLIC_IP_TTL_SECONDS = 6 * 60 * 60.0
|
||||
PY_MANUAL_PAUSE_FIELD = "py_manual_pause"
|
||||
POST_CHECK_DOWNLOAD_LABEL = "To download after check"
|
||||
_POST_CHECK_WATCH_TTL_SECONDS = 48 * 60 * 60
|
||||
_POST_CHECK_WATCH_MIN_SECONDS = 2.0
|
||||
|
||||
@@ -1,47 +1,388 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from .client import *
|
||||
|
||||
RTORRENT_CONFIG_FIELDS = [
|
||||
{"group": "Directories", "key": "directory.default", "label": "Default download directory", "type": "text"},
|
||||
{"group": "Directories", "key": "session.path", "label": "Session path", "type": "text"},
|
||||
{"group": "Directories", "key": "system.cwd", "label": "Working directory", "type": "text", "readonly": True},
|
||||
{"group": "Network", "key": "network.port_range", "label": "Incoming port range", "type": "text", "placeholder": "49164-49164"},
|
||||
{"group": "Network", "key": "network.port_random", "label": "Random incoming port", "type": "bool"},
|
||||
{"group": "Network", "key": "network.bind_address", "label": "Bind address", "type": "text", "placeholder": "0.0.0.0"},
|
||||
{"group": "Network", "key": "network.local_address", "label": "Local address", "type": "text"},
|
||||
{"group": "Network", "key": "network.max_open_files", "label": "Max open files", "type": "number"},
|
||||
{"group": "Network", "key": "network.max_open_sockets", "label": "Max open sockets", "type": "number"},
|
||||
{"group": "Network", "key": "network.http.max_open", "label": "Max HTTP connections", "type": "number"},
|
||||
{"group": "Network", "key": "network.http.ssl_verify_peer", "label": "Verify SSL peers", "type": "bool"},
|
||||
{"group": "Network", "key": "network.xmlrpc.size_limit", "label": "XML-RPC upload size limit", "type": "text", "placeholder": "16M"},
|
||||
{"group": "Peers", "key": "throttle.min_peers.normal", "label": "Min peers downloading", "type": "number"},
|
||||
{"group": "Peers", "key": "throttle.max_peers.normal", "label": "Max peers downloading", "type": "number"},
|
||||
{"group": "Peers", "key": "throttle.min_peers.seed", "label": "Min peers seeding", "type": "number"},
|
||||
{"group": "Peers", "key": "throttle.max_peers.seed", "label": "Max peers seeding", "type": "number"},
|
||||
{"group": "Peers", "key": "trackers.numwant", "label": "Tracker numwant", "type": "number"},
|
||||
{"group": "Throttle", "key": "throttle.global_down.max_rate", "label": "Global download limit B/s", "type": "number"},
|
||||
{"group": "Throttle", "key": "throttle.global_up.max_rate", "label": "Global upload limit B/s", "type": "number"},
|
||||
{"group": "Throttle", "key": "throttle.max_downloads.global", "label": "Max active downloads", "type": "number"},
|
||||
{"group": "Throttle", "key": "throttle.max_uploads.global", "label": "Max active uploads", "type": "number"},
|
||||
{"group": "Throttle", "key": "throttle.max_downloads.div", "label": "Max downloads per throttle", "type": "number"},
|
||||
{"group": "Throttle", "key": "throttle.max_uploads.div", "label": "Max uploads per throttle", "type": "number"},
|
||||
{"group": "DHT / PEX", "key": "dht.mode", "label": "DHT mode", "type": "text", "placeholder": "disable/off/auto/on"},
|
||||
{"group": "DHT / PEX", "key": "dht.port", "label": "DHT port", "type": "number"},
|
||||
{"group": "DHT / PEX", "key": "protocol.pex", "label": "Peer exchange", "type": "bool"},
|
||||
{"group": "Protocol", "key": "protocol.encryption.set", "label": "Encryption flags", "type": "text", "placeholder": "allow_incoming,try_outgoing,enable_retry"},
|
||||
{"group": "Protocol", "key": "protocol.connection.leech", "label": "Leech connection type", "type": "text", "placeholder": "leech"},
|
||||
{"group": "Protocol", "key": "protocol.connection.seed", "label": "Seed connection type", "type": "text", "placeholder": "seed"},
|
||||
{"group": "Files", "key": "pieces.hash.on_completion", "label": "Hash check on completion", "type": "bool"},
|
||||
{"group": "Files", "key": "pieces.preload.type", "label": "Pieces preload type", "type": "number"},
|
||||
{"group": "Files", "key": "pieces.preload.min_size", "label": "Pieces preload min size", "type": "number"},
|
||||
{"group": "Files", "key": "pieces.preload.min_rate", "label": "Pieces preload min rate", "type": "number"},
|
||||
{"group": "Files", "key": "system.file.allocate", "label": "File allocation", "type": "number"},
|
||||
{"group": "Files", "key": "system.file.max_size", "label": "Max file size", "type": "number"},
|
||||
{"group": "System", "key": "system.umask", "label": "File umask", "type": "text", "placeholder": "0002"},
|
||||
{"group": "System", "key": "system.hostname", "label": "Hostname", "type": "text", "readonly": True},
|
||||
{"group": "System", "key": "system.client_version", "label": "Client version", "type": "text", "readonly": True},
|
||||
{"group": "System", "key": "system.library_version", "label": "Library version", "type": "text", "readonly": True},
|
||||
{
|
||||
"group": "Directories",
|
||||
"key": "directory.default",
|
||||
"label": "Default download directory",
|
||||
"type": "text",
|
||||
"description": "Main destination for new downloads added without an explicit directory.",
|
||||
"recommendation": "Use a stable absolute path on storage with enough free space; avoid changing it while active torrents use relative paths.",
|
||||
},
|
||||
{
|
||||
"group": "Directories",
|
||||
"key": "session.path",
|
||||
"label": "Session path",
|
||||
"type": "text",
|
||||
"description": "Directory where rTorrent stores session state, resume data and internal torrent metadata.",
|
||||
"recommendation": "Keep it on reliable local storage and include it in backups before maintenance.",
|
||||
},
|
||||
{
|
||||
"group": "Directories",
|
||||
"key": "system.cwd",
|
||||
"label": "Working directory",
|
||||
"type": "text",
|
||||
"readonly": True,
|
||||
"description": "Current rTorrent process working directory reported by rTorrent.",
|
||||
"recommendation": "Read-only diagnostic value; change it in the service or startup configuration if needed.",
|
||||
},
|
||||
{
|
||||
"group": "Network",
|
||||
"key": "network.port_range",
|
||||
"label": "Incoming port range",
|
||||
"type": "text",
|
||||
"placeholder": "49164-49164",
|
||||
"description": "TCP port or range used for incoming peer connections.",
|
||||
"recommendation": "Use a fixed forwarded port, for example 49164-49164, for stable connectivity.",
|
||||
},
|
||||
{
|
||||
"group": "Network",
|
||||
"key": "network.port_random",
|
||||
"label": "Random incoming port",
|
||||
"type": "bool",
|
||||
"description": "Lets rTorrent select a random incoming port on startup.",
|
||||
"recommendation": "Disable it when using router/NAT forwarding; fixed ports are easier to monitor.",
|
||||
},
|
||||
{
|
||||
"group": "Network",
|
||||
"key": "network.bind_address",
|
||||
"label": "Bind address",
|
||||
"type": "text",
|
||||
"placeholder": "0.0.0.0",
|
||||
"description": "Local interface address used for peer traffic binding.",
|
||||
"recommendation": "Leave empty unless the host has multiple interfaces or policy routing.",
|
||||
},
|
||||
{
|
||||
"group": "Network",
|
||||
"key": "network.local_address",
|
||||
"label": "Announced local address",
|
||||
"type": "text",
|
||||
"description": "Address rTorrent may announce as its local network address.",
|
||||
"recommendation": "Usually leave empty; set only when a specific advertised address is required.",
|
||||
},
|
||||
{
|
||||
"group": "Network",
|
||||
"key": "network.max_open_files",
|
||||
"label": "Max open files",
|
||||
"type": "number",
|
||||
"description": "Maximum number of files rTorrent can keep open at once.",
|
||||
"recommendation": "Raise together with the OS file descriptor limit on large seeds.",
|
||||
},
|
||||
{
|
||||
"group": "Network",
|
||||
"key": "network.max_open_sockets",
|
||||
"label": "Max open sockets",
|
||||
"type": "number",
|
||||
"description": "Upper bound for peer and tracker sockets opened by rTorrent.",
|
||||
"recommendation": "Keep below OS limits; increase gradually when many torrents are active.",
|
||||
},
|
||||
{
|
||||
"group": "Network",
|
||||
"key": "network.http.max_open",
|
||||
"label": "Max HTTP connections",
|
||||
"type": "number",
|
||||
"description": "Maximum simultaneous HTTP connections for tracker and metadata requests.",
|
||||
"recommendation": "Moderate values reduce tracker pressure; increase only if tracker requests queue up.",
|
||||
},
|
||||
{
|
||||
"group": "Network",
|
||||
"key": "network.http.dns_cache_timeout",
|
||||
"label": "HTTP DNS cache timeout",
|
||||
"type": "number",
|
||||
"description": "Seconds rTorrent keeps DNS results for tracker and HTTP requests.",
|
||||
"recommendation": "Use a small positive value, for example 25, when many tracker hostnames are queried repeatedly.",
|
||||
"runtime_note": "Applied through SCGI immediately; new HTTP lookups use the updated timeout.",
|
||||
},
|
||||
{
|
||||
"group": "Network",
|
||||
"key": "network.http.ssl_verify_peer",
|
||||
"label": "Verify SSL peers",
|
||||
"type": "bool",
|
||||
"description": "Controls certificate verification for HTTPS tracker connections.",
|
||||
"recommendation": "Keep enabled unless a private tracker has a known certificate problem.",
|
||||
},
|
||||
{
|
||||
"group": "Network",
|
||||
"key": "network.xmlrpc.size_limit",
|
||||
"label": "XML-RPC upload size limit",
|
||||
"type": "text",
|
||||
"placeholder": "16M",
|
||||
"description": "Maximum XML-RPC payload size accepted by rTorrent.",
|
||||
"recommendation": "Keep enough headroom for large UI responses; avoid very high values on public endpoints.",
|
||||
},
|
||||
{
|
||||
"group": "Peers",
|
||||
"key": "throttle.min_peers.normal",
|
||||
"label": "Min peers while downloading",
|
||||
"type": "number",
|
||||
"description": "Minimum peer target for incomplete torrents.",
|
||||
"recommendation": "Use a conservative floor; too high values can waste sockets on weak swarms.",
|
||||
},
|
||||
{
|
||||
"group": "Peers",
|
||||
"key": "throttle.max_peers.normal",
|
||||
"label": "Max peers while downloading",
|
||||
"type": "number",
|
||||
"description": "Maximum peer target for incomplete torrents.",
|
||||
"recommendation": "Increase for fast lines, but keep total sockets and CPU usage under control.",
|
||||
},
|
||||
{
|
||||
"group": "Peers",
|
||||
"key": "throttle.min_peers.seed",
|
||||
"label": "Min peers while seeding",
|
||||
"type": "number",
|
||||
"description": "Minimum peer target for complete torrents.",
|
||||
"recommendation": "Lower than download min peers is usually enough for long-term seeding.",
|
||||
},
|
||||
{
|
||||
"group": "Peers",
|
||||
"key": "throttle.max_peers.seed",
|
||||
"label": "Max peers while seeding",
|
||||
"type": "number",
|
||||
"description": "Maximum peer target for complete torrents.",
|
||||
"recommendation": "Avoid excessive values on many seeding torrents because sockets multiply quickly.",
|
||||
},
|
||||
{
|
||||
"group": "Peers",
|
||||
"key": "trackers.numwant",
|
||||
"label": "Tracker numwant",
|
||||
"type": "number",
|
||||
"description": "Number of peers requested from trackers per announce where supported.",
|
||||
"recommendation": "Use moderate values; many trackers cap this server-side anyway.",
|
||||
},
|
||||
{
|
||||
"group": "Throttle",
|
||||
"key": "throttle.global_down.max_rate",
|
||||
"label": "Global download limit B/s",
|
||||
"type": "number",
|
||||
"description": "Global download speed cap in bytes per second. Zero usually means unlimited.",
|
||||
"recommendation": "Leave unlimited or cap below line speed if other services share the connection.",
|
||||
},
|
||||
{
|
||||
"group": "Throttle",
|
||||
"key": "throttle.global_up.max_rate",
|
||||
"label": "Global upload limit B/s",
|
||||
"type": "number",
|
||||
"description": "Global upload speed cap in bytes per second. Zero usually means unlimited.",
|
||||
"recommendation": "Keep below real upstream capacity to avoid bufferbloat and slow downloads.",
|
||||
},
|
||||
{
|
||||
"group": "Throttle",
|
||||
"key": "throttle.max_downloads.global",
|
||||
"label": "Global download slots",
|
||||
"type": "number",
|
||||
"description": "Global number of peer download slots across all torrents; this is not the active torrent count.",
|
||||
"recommendation": "Raise this on large instances so a few busy torrents do not starve the rest.",
|
||||
"runtime_note": "Applied through SCGI immediately; existing peer scheduling catches up gradually.",
|
||||
},
|
||||
{
|
||||
"group": "Throttle",
|
||||
"key": "throttle.max_uploads.global",
|
||||
"label": "Global upload slots",
|
||||
"type": "number",
|
||||
"description": "Global number of peer upload slots across all torrents; this is not the active torrent count.",
|
||||
"recommendation": "Keep enough slots for many seeds, but stay below socket and file descriptor limits.",
|
||||
"runtime_note": "Applied through SCGI immediately; current peer connections may rebalance over time.",
|
||||
},
|
||||
{
|
||||
"group": "Throttle",
|
||||
"key": "throttle.max_downloads",
|
||||
"label": "Per-torrent download slots",
|
||||
"type": "number",
|
||||
"description": "Maximum peer download slots allowed for a single torrent in the default throttle group.",
|
||||
"recommendation": "Use values like 5-20 to prevent one torrent from consuming all global download slots.",
|
||||
"runtime_note": "Applied through SCGI immediately; it affects new and rebalanced peer slot allocation.",
|
||||
},
|
||||
{
|
||||
"group": "Throttle",
|
||||
"key": "throttle.max_uploads",
|
||||
"label": "Per-torrent upload slots",
|
||||
"type": "number",
|
||||
"description": "Maximum peer upload slots allowed for a single torrent in the default throttle group.",
|
||||
"recommendation": "Use conservative values on very large seedboxes so many seeds can stay reachable.",
|
||||
"runtime_note": "Applied through SCGI immediately; it affects new and rebalanced peer slot allocation.",
|
||||
},
|
||||
{
|
||||
"group": "Throttle",
|
||||
"key": "throttle.max_downloads.div",
|
||||
"label": "Download slot divisor",
|
||||
"type": "number",
|
||||
"description": "Per-throttle download slot divisor used by rTorrent throttling logic.",
|
||||
"recommendation": "Keep at 1 unless you intentionally use advanced throttle groups.",
|
||||
"runtime_note": "Applied through SCGI immediately for the default throttle scheduler.",
|
||||
},
|
||||
{
|
||||
"group": "Throttle",
|
||||
"key": "throttle.max_uploads.div",
|
||||
"label": "Upload slot divisor",
|
||||
"type": "number",
|
||||
"description": "Per-throttle upload slot divisor used by rTorrent throttling logic.",
|
||||
"recommendation": "Keep at 1 unless you intentionally use advanced throttle groups.",
|
||||
"runtime_note": "Applied through SCGI immediately for the default throttle scheduler.",
|
||||
},
|
||||
{
|
||||
"group": "Ratio",
|
||||
"key": "ratio.max",
|
||||
"label": "Global ratio max",
|
||||
"type": "number",
|
||||
"description": "Global maximum ratio value used by rTorrent ratio logic where enabled.",
|
||||
"recommendation": "Use -1 for no global cap, or manage per-profile ratio policies from pyTorrent when possible.",
|
||||
"runtime_note": "Applied through SCGI immediately when the rTorrent ratio method is available.",
|
||||
},
|
||||
{
|
||||
"group": "DHT / PEX",
|
||||
"key": "dht.mode",
|
||||
"label": "DHT mode",
|
||||
"type": "text",
|
||||
"placeholder": "disable/off/auto/on",
|
||||
"description": "Controls Distributed Hash Table usage for peer discovery.",
|
||||
"recommendation": "Private-tracker setups often disable DHT; public torrents usually benefit from auto/on.",
|
||||
},
|
||||
{
|
||||
"group": "DHT / PEX",
|
||||
"key": "dht.port",
|
||||
"label": "DHT port",
|
||||
"type": "number",
|
||||
"description": "UDP port used by DHT traffic.",
|
||||
"recommendation": "Use the same forwarded port strategy as incoming TCP when DHT is enabled.",
|
||||
},
|
||||
{
|
||||
"group": "DHT / PEX",
|
||||
"key": "protocol.pex",
|
||||
"label": "Peer exchange",
|
||||
"type": "bool",
|
||||
"description": "Enables Peer Exchange peer discovery between connected peers.",
|
||||
"recommendation": "Disable for strict private-tracker policies; enable for public swarms if allowed.",
|
||||
},
|
||||
{
|
||||
"group": "DHT / PEX",
|
||||
"key": "trackers.use_udp",
|
||||
"label": "UDP trackers",
|
||||
"type": "bool",
|
||||
"description": "Allows rTorrent to use UDP trackers where supported.",
|
||||
"recommendation": "Keep enabled for public torrents unless the network blocks UDP tracker traffic.",
|
||||
},
|
||||
{
|
||||
"group": "Protocol",
|
||||
"key": "protocol.encryption.set",
|
||||
"label": "Encryption flags",
|
||||
"type": "text",
|
||||
"placeholder": "allow_incoming,try_outgoing,enable_retry",
|
||||
"description": "Encryption policy flags for peer connections.",
|
||||
"recommendation": "Prefer permissive settings unless a tracker or network requires strict encryption.",
|
||||
},
|
||||
{
|
||||
"group": "Protocol",
|
||||
"key": "protocol.connection.leech",
|
||||
"label": "Leech connection type",
|
||||
"type": "text",
|
||||
"placeholder": "leech",
|
||||
"description": "Connection behavior profile used by incomplete torrents.",
|
||||
"recommendation": "Leave default unless tuning advanced libTorrent behavior.",
|
||||
},
|
||||
{
|
||||
"group": "Protocol",
|
||||
"key": "protocol.connection.seed",
|
||||
"label": "Seed connection type",
|
||||
"type": "text",
|
||||
"placeholder": "seed",
|
||||
"description": "Connection behavior profile used by complete torrents.",
|
||||
"recommendation": "Leave default unless tuning advanced libTorrent behavior.",
|
||||
},
|
||||
{
|
||||
"group": "Files",
|
||||
"key": "pieces.hash.on_completion",
|
||||
"label": "Hash check on completion",
|
||||
"type": "bool",
|
||||
"description": "Runs a hash verification after a torrent completes.",
|
||||
"recommendation": "Enable for data integrity when storage is unreliable; disable if completion checks are too expensive.",
|
||||
},
|
||||
{
|
||||
"group": "Files",
|
||||
"key": "pieces.preload.type",
|
||||
"label": "Pieces preload type",
|
||||
"type": "number",
|
||||
"description": "Controls how rTorrent preloads torrent pieces from disk.",
|
||||
"recommendation": "Keep default unless you are tuning disk cache behavior for a known workload.",
|
||||
},
|
||||
{
|
||||
"group": "Files",
|
||||
"key": "pieces.preload.min_size",
|
||||
"label": "Pieces preload min size",
|
||||
"type": "number",
|
||||
"description": "Minimum piece size threshold for preload behavior.",
|
||||
"recommendation": "Keep default unless large-piece torrents show disk latency issues.",
|
||||
},
|
||||
{
|
||||
"group": "Files",
|
||||
"key": "pieces.preload.min_rate",
|
||||
"label": "Pieces preload min rate",
|
||||
"type": "number",
|
||||
"description": "Minimum transfer rate threshold for preloading pieces.",
|
||||
"recommendation": "Tune only after measuring disk read pressure.",
|
||||
},
|
||||
{
|
||||
"group": "Files",
|
||||
"key": "pieces.memory.max",
|
||||
"label": "Pieces memory max",
|
||||
"type": "text",
|
||||
"placeholder": "512M",
|
||||
"description": "Maximum memory rTorrent may use for piece handling where supported.",
|
||||
"recommendation": "Avoid values that compete with OS page cache; increase only on hosts with spare RAM.",
|
||||
},
|
||||
{
|
||||
"group": "Files",
|
||||
"key": "system.file.allocate",
|
||||
"label": "File allocation",
|
||||
"type": "number",
|
||||
"description": "Controls preallocation behavior for downloaded files.",
|
||||
"recommendation": "Preallocation can reduce fragmentation but may slow adding very large torrents.",
|
||||
},
|
||||
{
|
||||
"group": "Files",
|
||||
"key": "system.file.max_size",
|
||||
"label": "Max file size",
|
||||
"type": "number",
|
||||
"description": "Maximum single file size rTorrent accepts where supported.",
|
||||
"recommendation": "Leave default unless you intentionally need to block oversized files.",
|
||||
},
|
||||
{
|
||||
"group": "System",
|
||||
"key": "system.umask",
|
||||
"label": "File umask",
|
||||
"type": "text",
|
||||
"placeholder": "0002",
|
||||
"description": "Permission mask applied to files created by rTorrent.",
|
||||
"recommendation": "Use 0002 for shared media groups, 0022 for private single-user setups.",
|
||||
},
|
||||
{
|
||||
"group": "System",
|
||||
"key": "system.hostname",
|
||||
"label": "Hostname",
|
||||
"type": "text",
|
||||
"readonly": True,
|
||||
"description": "Hostname reported by the rTorrent runtime.",
|
||||
"recommendation": "Read-only diagnostic value.",
|
||||
},
|
||||
{
|
||||
"group": "System",
|
||||
"key": "system.client_version",
|
||||
"label": "Client version",
|
||||
"type": "text",
|
||||
"readonly": True,
|
||||
"description": "rTorrent client version reported through XML-RPC.",
|
||||
"recommendation": "Read-only diagnostic value useful when checking compatibility.",
|
||||
},
|
||||
{
|
||||
"group": "System",
|
||||
"key": "system.library_version",
|
||||
"label": "Library version",
|
||||
"type": "text",
|
||||
"readonly": True,
|
||||
"description": "libTorrent library version used by rTorrent.",
|
||||
"recommendation": "Read-only diagnostic value useful when checking compatibility.",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@@ -54,11 +395,10 @@ def _normalize_config_value(meta: dict, value):
|
||||
|
||||
|
||||
def saved_config_overrides(profile_id: int, user_id: int | None = None) -> dict[str, dict]:
|
||||
user_id = user_id or default_user_id()
|
||||
with connect() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT key,value,baseline_value,apply_on_start,updated_at FROM rtorrent_config_overrides WHERE user_id=? AND profile_id=?",
|
||||
(user_id, int(profile_id)),
|
||||
"SELECT key,value,baseline_value,apply_on_start,updated_at FROM rtorrent_config_overrides WHERE profile_id=?",
|
||||
(int(profile_id),),
|
||||
).fetchall()
|
||||
return {r["key"]: r for r in rows}
|
||||
|
||||
@@ -109,6 +449,19 @@ def default_download_path(profile: dict) -> str:
|
||||
errors.append(f"{method}: {exc}")
|
||||
raise RuntimeError("Cannot read rTorrent default download directory: " + "; ".join(errors))
|
||||
|
||||
def _rtorrent_set_method(key: str, meta: dict) -> str:
|
||||
# Note: Most runtime values use the conventional <method>.set setter.
|
||||
# Some rTorrent commands, such as protocol.encryption.set, are already
|
||||
# setter commands and must not receive another .set suffix.
|
||||
return str(meta.get("set_method") or (key if key.endswith(".set") else f"{key}.set"))
|
||||
|
||||
|
||||
def _rtorrent_config_line_key(key: str, meta: dict) -> str:
|
||||
# Note: Generated snippets must match rTorrent config syntax and avoid
|
||||
# producing invalid protocol.encryption.set.set lines.
|
||||
return str(meta.get("config_key") or _rtorrent_set_method(key, meta))
|
||||
|
||||
|
||||
def generate_config_text(values: dict) -> str:
|
||||
known = {f["key"]: f for f in RTORRENT_CONFIG_FIELDS}
|
||||
lines = []
|
||||
@@ -119,7 +472,7 @@ def generate_config_text(values: dict) -> str:
|
||||
normalized = _normalize_config_value(meta, value)
|
||||
if meta.get("type") == "text" and any(ch.isspace() for ch in normalized):
|
||||
normalized = '"' + normalized.replace('\\', '\\\\').replace('"', '\\"') + '"'
|
||||
lines.append(f"{key}.set = {normalized}")
|
||||
lines.append(f"{_rtorrent_config_line_key(key, meta)} = {normalized}")
|
||||
return "\n".join(lines) + ("\n" if lines else "")
|
||||
|
||||
|
||||
@@ -129,7 +482,6 @@ def _read_rtorrent_config_value(client, key: str, meta: dict) -> str:
|
||||
|
||||
def store_config_overrides(profile: dict, values: dict, apply_on_start: bool, baseline_values: dict | None = None, clear_keys: list[str] | None = None) -> list[str]:
|
||||
known = {f["key"]: f for f in RTORRENT_CONFIG_FIELDS}
|
||||
user_id = default_user_id()
|
||||
now = utcnow()
|
||||
profile_id = int(profile["id"])
|
||||
baseline_values = baseline_values or {}
|
||||
@@ -139,8 +491,8 @@ def store_config_overrides(profile: dict, values: dict, apply_on_start: bool, ba
|
||||
for key in clear_set:
|
||||
if key in known:
|
||||
conn.execute(
|
||||
"DELETE FROM rtorrent_config_overrides WHERE user_id=? AND profile_id=? AND key=?",
|
||||
(user_id, profile_id, key),
|
||||
"DELETE FROM rtorrent_config_overrides WHERE profile_id=? AND key=?",
|
||||
(profile_id, key),
|
||||
)
|
||||
for key, value in (values or {}).items():
|
||||
if key in clear_set:
|
||||
@@ -150,8 +502,8 @@ def store_config_overrides(profile: dict, values: dict, apply_on_start: bool, ba
|
||||
continue
|
||||
normalized = _normalize_config_value(meta, value)
|
||||
existing = conn.execute(
|
||||
"SELECT baseline_value FROM rtorrent_config_overrides WHERE user_id=? AND profile_id=? AND key=?",
|
||||
(user_id, profile_id, key),
|
||||
"SELECT baseline_value FROM rtorrent_config_overrides WHERE profile_id=? AND key=?",
|
||||
(profile_id, key),
|
||||
).fetchone()
|
||||
existing_baseline = existing.get("baseline_value") if existing else None
|
||||
|
||||
@@ -165,18 +517,18 @@ def store_config_overrides(profile: dict, values: dict, apply_on_start: bool, ba
|
||||
|
||||
if baseline not in (None, "") and normalized == baseline:
|
||||
conn.execute(
|
||||
"DELETE FROM rtorrent_config_overrides WHERE user_id=? AND profile_id=? AND key=?",
|
||||
(user_id, profile_id, key),
|
||||
"DELETE FROM rtorrent_config_overrides WHERE profile_id=? AND key=?",
|
||||
(profile_id, key),
|
||||
)
|
||||
continue
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO rtorrent_config_overrides(user_id,profile_id,key,value,baseline_value,apply_on_start,updated_at) VALUES(?,?,?,?,?,?,?)",
|
||||
(user_id, profile_id, key, normalized, baseline, 1 if apply_on_start else 0, now),
|
||||
"INSERT OR REPLACE INTO rtorrent_config_overrides(profile_id,key,value,baseline_value,apply_on_start,updated_at) VALUES(?,?,?,?,?,?)",
|
||||
(profile_id, key, normalized, baseline, 1 if apply_on_start else 0, now),
|
||||
)
|
||||
stored.append(key)
|
||||
conn.execute(
|
||||
"UPDATE rtorrent_config_overrides SET apply_on_start=?, updated_at=? WHERE user_id=? AND profile_id=?",
|
||||
(1 if apply_on_start else 0, now, user_id, profile_id),
|
||||
"UPDATE rtorrent_config_overrides SET apply_on_start=?, updated_at=? WHERE profile_id=?",
|
||||
(1 if apply_on_start else 0, now, profile_id),
|
||||
)
|
||||
return stored
|
||||
|
||||
@@ -206,10 +558,11 @@ def set_config(profile: dict, values: dict, apply_now: bool = True, apply_on_sta
|
||||
value = _normalize_config_value(meta, raw_value)
|
||||
rpc_value = int(value) if meta.get("type") in {"bool", "number"} else value
|
||||
try:
|
||||
method = _rtorrent_set_method(key, meta)
|
||||
try:
|
||||
c.call(key + ".set", "", rpc_value)
|
||||
c.call(method, "", rpc_value)
|
||||
except Exception:
|
||||
c.call(key + ".set", rpc_value)
|
||||
c.call(method, rpc_value)
|
||||
updated.append(key)
|
||||
except Exception as exc:
|
||||
errors.append({"key": key, "error": str(exc)})
|
||||
@@ -220,17 +573,16 @@ def set_config(profile: dict, values: dict, apply_now: bool = True, apply_on_sta
|
||||
def reset_config_overrides(profile: dict, user_id: int | None = None) -> dict:
|
||||
"""Remove saved UI overrides and return the freshly read rTorrent config."""
|
||||
# Note: Reset means "forget pyTorrent UI overrides"; it does not write defaults back to rTorrent.
|
||||
user_id = user_id or default_user_id()
|
||||
profile_id = int(profile["id"])
|
||||
with connect() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT COUNT(*) AS count FROM rtorrent_config_overrides WHERE user_id=? AND profile_id=?",
|
||||
(user_id, profile_id),
|
||||
"SELECT COUNT(*) AS count FROM rtorrent_config_overrides WHERE profile_id=?",
|
||||
(profile_id,),
|
||||
).fetchone()
|
||||
removed = int((row or {}).get("count") or 0)
|
||||
conn.execute(
|
||||
"DELETE FROM rtorrent_config_overrides WHERE user_id=? AND profile_id=?",
|
||||
(user_id, profile_id),
|
||||
"DELETE FROM rtorrent_config_overrides WHERE profile_id=?",
|
||||
(profile_id,),
|
||||
)
|
||||
config = get_config(profile)
|
||||
config["reset_removed"] = removed
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from .client import *
|
||||
import shlex
|
||||
from .. import poller_control
|
||||
|
||||
def scgi_diagnostics(profile: dict) -> dict:
|
||||
c = client_for(profile)
|
||||
@@ -64,7 +63,12 @@ def scgi_diagnostics(profile: dict) -> dict:
|
||||
def profile_diagnostics(profile: dict) -> dict:
|
||||
"""Lightweight per-profile diagnostics for save/test UI."""
|
||||
started = time.perf_counter()
|
||||
result = {"profile_id": profile.get("id"), "ok": False, "checks": {}}
|
||||
profile_id = profile.get("id")
|
||||
try:
|
||||
slow_threshold_ms = float(poller_control.get_settings(int(profile_id)).get("slow_response_threshold_ms") or poller_control.DEFAULTS["slow_response_threshold_ms"])
|
||||
except Exception:
|
||||
slow_threshold_ms = float(poller_control.DEFAULTS["slow_response_threshold_ms"])
|
||||
result = {"profile_id": profile_id, "ok": False, "checks": {}, "slow_threshold_ms": slow_threshold_ms}
|
||||
try:
|
||||
c = client_for(profile)
|
||||
version = str(c.call("system.client_version") or "")
|
||||
@@ -84,19 +88,19 @@ def profile_diagnostics(profile: dict) -> dict:
|
||||
base = paths.get("default_directory") if isinstance(paths.get("default_directory"), str) else ""
|
||||
if base:
|
||||
try:
|
||||
out = _rt_execute(c, "execute.capture", "sh", "-lc", f"test -w {shlex.quote(base)} && printf writable || printf readonly")
|
||||
out = _rt_execute(c, "execute.capture", "sh", "-c", 'if test -w "$1"; then printf writable; else printf readonly; fi', "pytorrent-diagnostics-write", base)
|
||||
write_permissions[base] = str(out or "").strip() or "unknown"
|
||||
except Exception as exc:
|
||||
write_permissions[base] = f"error: {exc}"
|
||||
try:
|
||||
out = _rt_execute(c, "execute.capture", "sh", "-lc", f"df -Pk {shlex.quote(base)} | tail -1 | awk '{{print $4}}'")
|
||||
out = _rt_execute(c, "execute.capture", "sh", "-c", "df -Pk \"$1\" 2>/dev/null | awk 'END {print $4}'", "pytorrent-diagnostics-df", base)
|
||||
kb = int(str(out or "0").strip() or 0)
|
||||
free_disk[base] = {"free_bytes": kb * 1024, "free_h": human_size(kb * 1024)}
|
||||
except Exception as exc:
|
||||
free_disk[base] = {"error": str(exc)}
|
||||
result.update({
|
||||
"ok": True,
|
||||
"status": "online",
|
||||
"status": "normal",
|
||||
"version": version,
|
||||
"library_version": library,
|
||||
"base_paths": paths,
|
||||
@@ -106,7 +110,8 @@ def profile_diagnostics(profile: dict) -> dict:
|
||||
})
|
||||
except Exception as exc:
|
||||
result.update({"ok": False, "status": "error", "error": str(exc), "response_time_ms": round((time.perf_counter() - started) * 1000, 2)})
|
||||
if result.get("ok") and result.get("response_time_ms", 0) > 1500:
|
||||
# Note: Profile diagnostics uses the same slow-response threshold as Tools -> Poller for this profile.
|
||||
if result.get("ok") and result.get("response_time_ms", 0) > slow_threshold_ms:
|
||||
result["status"] = "slow"
|
||||
return result
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from .client import *
|
||||
from ...config import BASE_DIR
|
||||
|
||||
def torrent_files(profile: dict, torrent_hash: str) -> list[dict]:
|
||||
rows = client_for(profile).f.multicall(torrent_hash, "", "f.path=", "f.size_bytes=", "f.completed_chunks=", "f.size_chunks=", "f.priority=")
|
||||
@@ -24,7 +24,6 @@ def torrent_files(profile: dict, torrent_hash: str) -> list[dict]:
|
||||
|
||||
|
||||
def torrent_file_tree(profile: dict, torrent_hash: str) -> dict:
|
||||
# Note: The tree is built from rTorrent file paths without changing the existing flat file API.
|
||||
root = {"name": "", "path": "", "type": "directory", "size": 0, "children": {}}
|
||||
for item in torrent_files(profile, torrent_hash):
|
||||
parts = [part for part in str(item.get("path") or "").split("/") if part]
|
||||
@@ -58,10 +57,17 @@ def _torrent_file_remote_path(profile: dict, torrent_hash: str, index: int) -> t
|
||||
if selected is None:
|
||||
available = ", ".join(str(f.get("index")) for f in files[:20]) or "none"
|
||||
raise ValueError(f"File index {index} not found. Available indexes: {available}")
|
||||
|
||||
base = _remote_clean_path(_torrent_data_path(c, torrent_hash))
|
||||
rel = str(selected.get("path") or "").lstrip("/")
|
||||
if len(files) == 1 and base and not base.endswith("/"):
|
||||
path = base
|
||||
|
||||
# Note: rTorrent can report d.base_path as either the payload file or the
|
||||
# containing data directory for a one-file torrent. Keep both existing
|
||||
# layouts working and avoid treating a directory as the media file.
|
||||
if len(files) == 1 and base and rel:
|
||||
base_name = posixpath.basename(base.rstrip("/"))
|
||||
rel_name = posixpath.basename(rel.rstrip("/"))
|
||||
path = base if base_name == rel_name else _remote_join(base, rel)
|
||||
else:
|
||||
path = _remote_join(base, rel)
|
||||
return selected, path
|
||||
@@ -123,6 +129,392 @@ def iter_remote_file_chunks(profile: dict, source_path: str, size: int | None =
|
||||
break
|
||||
|
||||
|
||||
|
||||
_MEDIA_INFO_EXTENSIONS = {
|
||||
".3g2", ".3gp", ".aac", ".aiff", ".ape", ".asf", ".avi", ".flac",
|
||||
".flv", ".m4a", ".m4v", ".mka", ".mkv", ".mov", ".mp3", ".mp4",
|
||||
".mpeg", ".mpg", ".ogg", ".opus", ".ts", ".wav", ".webm", ".wma", ".wmv",
|
||||
}
|
||||
_TEXT_PREVIEW_EXTENSIONS = {
|
||||
".ass", ".cue", ".csv", ".ini", ".json", ".log", ".m3u", ".m3u8",
|
||||
".md", ".nfo", ".srt", ".ssa", ".sub", ".sfv", ".txt", ".url",
|
||||
".xml", ".yaml", ".yml",
|
||||
}
|
||||
_IMAGE_PREVIEW_EXTENSIONS = {".avif", ".bmp", ".gif", ".jpeg", ".jpg", ".png", ".webp"}
|
||||
_PDF_PREVIEW_EXTENSIONS = {".pdf"}
|
||||
_MEDIA_INFO_SAMPLE_BYTES = 32 * 1024 * 1024
|
||||
_MEDIA_INFO_CHUNK_BYTES = 1024 * 1024
|
||||
_TEXT_PREVIEW_BYTES = 512 * 1024
|
||||
_IMAGE_PREVIEW_BYTES = 8 * 1024 * 1024
|
||||
_MEDIA_INFO_TMP_DIR = BASE_DIR / "data" / "media-info-samples"
|
||||
|
||||
|
||||
def _file_extension(path: str) -> str:
|
||||
return LocalPath(str(path or "")).suffix.lower()
|
||||
|
||||
|
||||
def _media_info_supported(path: str) -> bool:
|
||||
# Note: Extension filtering avoids trying binary metadata parsers on every torrent payload file.
|
||||
return _file_extension(path) in _MEDIA_INFO_EXTENSIONS
|
||||
|
||||
|
||||
def _text_preview_supported(path: str) -> bool:
|
||||
# Note: Text previews intentionally include NFO and subtitle files so the existing info button becomes useful for release notes too.
|
||||
return _file_extension(path) in _TEXT_PREVIEW_EXTENSIONS
|
||||
|
||||
|
||||
def _image_preview_supported(path: str) -> bool:
|
||||
# Note: Image previews are limited to browser-safe raster formats and avoid SVG to prevent inline script-like payloads.
|
||||
return _file_extension(path) in _IMAGE_PREVIEW_EXTENSIONS
|
||||
|
||||
|
||||
def _pdf_preview_supported(path: str) -> bool:
|
||||
# Note: PDF previews are rendered inline by the browser so image-heavy books keep their page layout.
|
||||
return _file_extension(path) in _PDF_PREVIEW_EXTENSIONS
|
||||
|
||||
|
||||
def _media_info_sample_suffix(source_path: str) -> str:
|
||||
suffix = LocalPath(str(source_path or "")).suffix.lower()
|
||||
if suffix and len(suffix) <= 16 and all(ch.isalnum() or ch in ".-_" for ch in suffix):
|
||||
return suffix
|
||||
return ".bin"
|
||||
|
||||
|
||||
def _read_file_prefix(profile: dict, source_path: str, max_bytes: int) -> bytes:
|
||||
# Note: File info must read through rTorrent, not the pyTorrent process, because torrents may live on a remote host or under rTorrent-only permissions.
|
||||
limit = max(0, int(max_bytes or 0))
|
||||
chunks: list[bytes] = []
|
||||
collected = 0
|
||||
for chunk in iter_remote_file_chunks(profile, source_path, size=limit, chunk_size=_MEDIA_INFO_CHUNK_BYTES):
|
||||
if collected >= limit:
|
||||
break
|
||||
data = bytes(chunk[: max(0, limit - collected)])
|
||||
chunks.append(data)
|
||||
collected += len(data)
|
||||
return b"".join(chunks)
|
||||
|
||||
|
||||
def _decode_text_preview(data: bytes) -> tuple[str, str]:
|
||||
# Note: NFO files are often CP437, while normal text is usually UTF-8; the fallback keeps ASCII art readable.
|
||||
if not data:
|
||||
return "utf-8", ""
|
||||
for encoding in ("utf-8-sig", "utf-8"):
|
||||
try:
|
||||
return encoding, data.decode(encoding)
|
||||
except UnicodeDecodeError:
|
||||
pass
|
||||
for encoding in ("cp437", "cp1250", "latin-1"):
|
||||
try:
|
||||
return encoding, data.decode(encoding, errors="replace")
|
||||
except Exception:
|
||||
pass
|
||||
return "utf-8", data.decode("utf-8", errors="replace")
|
||||
|
||||
|
||||
def _image_preview_mime(path: str) -> str:
|
||||
# Note: The MIME type is extension-based because preview input is already restricted to known image suffixes.
|
||||
ext = _file_extension(path)
|
||||
return {
|
||||
".avif": "image/avif",
|
||||
".bmp": "image/bmp",
|
||||
".gif": "image/gif",
|
||||
".jpeg": "image/jpeg",
|
||||
".jpg": "image/jpeg",
|
||||
".png": "image/png",
|
||||
".webp": "image/webp",
|
||||
}.get(ext, "application/octet-stream")
|
||||
|
||||
|
||||
def _text_file_preview(profile: dict, selected: dict, remote_path: str, max_bytes: int = _TEXT_PREVIEW_BYTES) -> dict:
|
||||
# Note: Text preview returns escaped-by-frontend content and a clear truncation flag for large NFO/log/subtitle files.
|
||||
size = int(selected.get("size") or 0)
|
||||
data = _read_file_prefix(profile, remote_path, max_bytes)
|
||||
encoding, text = _decode_text_preview(data)
|
||||
return {
|
||||
**selected,
|
||||
"kind": "text",
|
||||
"parser": "text-preview",
|
||||
"supported": True,
|
||||
"sample_bytes": len(data),
|
||||
"sample_limit": int(max_bytes),
|
||||
"partial": bool(size and len(data) < size),
|
||||
"encoding": encoding,
|
||||
"text": text,
|
||||
"line_count": text.count("\n") + (1 if text else 0),
|
||||
"summary": {},
|
||||
"fields": [
|
||||
{"key": "Type", "value": "Text preview"},
|
||||
{"key": "Encoding", "value": encoding},
|
||||
{"key": "Preview bytes", "value": human_size(len(data))},
|
||||
],
|
||||
"raw": [],
|
||||
}
|
||||
|
||||
|
||||
def _image_file_preview(profile: dict, selected: dict, remote_path: str, max_bytes: int = _IMAGE_PREVIEW_BYTES) -> dict:
|
||||
# Note: Image preview is size capped and CSS-constrained in the modal instead of decoding/resizing images server-side.
|
||||
size = int(selected.get("size") or 0)
|
||||
result = {
|
||||
**selected,
|
||||
"kind": "image",
|
||||
"parser": "image-preview",
|
||||
"supported": True,
|
||||
"sample_bytes": 0,
|
||||
"sample_limit": int(max_bytes),
|
||||
"partial": False,
|
||||
"mime_type": _image_preview_mime(str(selected.get("path") or remote_path)),
|
||||
"summary": {},
|
||||
"fields": [
|
||||
{"key": "Type", "value": "Image preview"},
|
||||
{"key": "Preview limit", "value": human_size(max_bytes)},
|
||||
],
|
||||
"raw": [],
|
||||
}
|
||||
if size > max_bytes:
|
||||
result.update({
|
||||
"too_large": True,
|
||||
"error": f"Image preview is limited to {human_size(max_bytes)}. Download the file to view the full image.",
|
||||
})
|
||||
return result
|
||||
data = _read_file_prefix(profile, remote_path, max_bytes)
|
||||
import base64
|
||||
|
||||
result.update({
|
||||
"sample_bytes": len(data),
|
||||
"data_url": f"data:{result['mime_type']};base64,{base64.b64encode(data).decode('ascii')}",
|
||||
"fields": result["fields"] + [
|
||||
{"key": "Image bytes", "value": human_size(len(data))},
|
||||
{"key": "MIME type", "value": result["mime_type"]},
|
||||
],
|
||||
})
|
||||
return result
|
||||
|
||||
|
||||
def _pdf_file_preview(
|
||||
profile: dict,
|
||||
selected: dict,
|
||||
remote_path: str,
|
||||
) -> dict:
|
||||
# Note: pypdf is no longer required because PDFs are not parsed; the browser renders the original file stream.
|
||||
size = int(selected.get("size") or 0)
|
||||
return {
|
||||
**selected,
|
||||
"kind": "pdf",
|
||||
"parser": "browser-pdf-viewer",
|
||||
"supported": True,
|
||||
"sample_bytes": 0,
|
||||
"sample_limit": 0,
|
||||
"page_limit": 0,
|
||||
"partial": False,
|
||||
"summary": {
|
||||
"duration": None,
|
||||
"bit_rate": human_size(size) if size else None,
|
||||
"compression": "PDF",
|
||||
"producer": "Browser inline preview",
|
||||
"creation_date": None,
|
||||
},
|
||||
"fields": [
|
||||
{"key": "Type", "value": "PDF inline preview"},
|
||||
{"key": "PDF size", "value": human_size(size)},
|
||||
{"key": "Preview mode", "value": "Browser PDF renderer"},
|
||||
],
|
||||
"raw": [],
|
||||
"text": "",
|
||||
}
|
||||
|
||||
|
||||
def _media_info_temp_sample(profile: dict, source_path: str, max_bytes: int) -> tuple[str, int]:
|
||||
# Note: hachoir needs a seekable file, so this writes a bounded sample into the app data directory instead of loading whole media into RAM.
|
||||
import tempfile
|
||||
|
||||
_MEDIA_INFO_TMP_DIR.mkdir(parents=True, exist_ok=True)
|
||||
fd, tmp_path = tempfile.mkstemp(
|
||||
prefix="pytorrent-mediainfo-",
|
||||
suffix=_media_info_sample_suffix(source_path),
|
||||
dir=str(_MEDIA_INFO_TMP_DIR),
|
||||
)
|
||||
written = 0
|
||||
try:
|
||||
with os.fdopen(fd, "wb") as tmp:
|
||||
for chunk in iter_remote_file_chunks(profile, source_path, size=max_bytes, chunk_size=_MEDIA_INFO_CHUNK_BYTES):
|
||||
if written >= max_bytes:
|
||||
break
|
||||
data = bytes(chunk[: max(0, max_bytes - written)])
|
||||
tmp.write(data)
|
||||
written += len(data)
|
||||
return tmp_path, written
|
||||
except Exception:
|
||||
try:
|
||||
os.unlink(tmp_path)
|
||||
except Exception:
|
||||
pass
|
||||
raise
|
||||
|
||||
|
||||
def _media_info_plaintext(metadata) -> list[str]:
|
||||
# Note: exportPlaintext is the most stable hachoir API across supported package versions.
|
||||
try:
|
||||
lines = metadata.exportPlaintext() or []
|
||||
except Exception:
|
||||
return []
|
||||
return [str(line).strip(" -") for line in lines if str(line).strip(" -")]
|
||||
|
||||
|
||||
def _media_info_parse_lines(lines: list[str]) -> list[dict]:
|
||||
# Note: The frontend receives both grouped fields and raw text so unknown hachoir fields stay visible.
|
||||
fields = []
|
||||
for line in lines:
|
||||
if not line or ":" not in line:
|
||||
continue
|
||||
key, value = line.split(":", 1)
|
||||
key = key.strip()
|
||||
value = value.strip()
|
||||
if key and value:
|
||||
fields.append({"key": key, "value": value})
|
||||
return fields
|
||||
|
||||
|
||||
def _media_info_field_lookup(fields: list[dict]) -> dict:
|
||||
lookup = {}
|
||||
for field in fields:
|
||||
key = str(field.get("key") or "").lower()
|
||||
if key and key not in lookup:
|
||||
lookup[key] = field.get("value")
|
||||
return lookup
|
||||
|
||||
|
||||
def _media_info_summary(fields: list[dict]) -> dict:
|
||||
# Note: Summary keeps the modal readable while raw fields remain available below it.
|
||||
lookup = _media_info_field_lookup(fields)
|
||||
def first(*names):
|
||||
for name in names:
|
||||
value = lookup.get(name.lower())
|
||||
if value:
|
||||
return value
|
||||
return None
|
||||
return {
|
||||
"duration": first("Duration", "Play duration"),
|
||||
"bit_rate": first("Bit rate", "Overall bit rate"),
|
||||
"width": first("Image width", "Width"),
|
||||
"height": first("Image height", "Height"),
|
||||
"frame_rate": first("Frame rate"),
|
||||
"sample_rate": first("Sample rate"),
|
||||
"channels": first("Channel", "Channel(s)", "Channels"),
|
||||
"compression": first("Compression", "Compressor", "Codec", "Video codec", "Audio codec"),
|
||||
"producer": first("Producer", "Encoder", "Writing application"),
|
||||
"creation_date": first("Creation date", "Creation time"),
|
||||
}
|
||||
|
||||
|
||||
def _media_info_hachoir_imports():
|
||||
# Note: Import is checked before reading the media sample so dependency problems fail fast and clearly.
|
||||
import sys
|
||||
|
||||
try:
|
||||
from hachoir.metadata import extractMetadata
|
||||
from hachoir.parser import createParser
|
||||
return createParser, extractMetadata
|
||||
except ModuleNotFoundError as exc:
|
||||
missing = str(getattr(exc, "name", "") or "hachoir")
|
||||
if missing.split(".", 1)[0] == "hachoir":
|
||||
raise RuntimeError(
|
||||
"Python package 'hachoir' is not importable in the application runtime. "
|
||||
"Install it inside the pyTorrent virtualenv and restart the service: "
|
||||
"/opt/pyTorrent/venv/bin/pip install -r /opt/pyTorrent/requirements.txt && systemctl restart pytorrent. "
|
||||
f"Runtime: {sys.executable}."
|
||||
) from exc
|
||||
raise RuntimeError(
|
||||
f"hachoir is installed, but one of its Python dependencies is missing: {missing}. "
|
||||
f"Runtime: {sys.executable}."
|
||||
) from exc
|
||||
except Exception as exc:
|
||||
raise RuntimeError(
|
||||
"hachoir was found, but failed during import. "
|
||||
f"Runtime: {sys.executable}. Details: {exc}"
|
||||
) from exc
|
||||
|
||||
|
||||
def _torrent_file_is_complete(selected: dict) -> bool:
|
||||
# Note: File info reads real file bytes, so incomplete payload files are blocked before any parser touches them.
|
||||
size = int(selected.get("size") or 0)
|
||||
completed_chunks = int(selected.get("completed_chunks") or 0)
|
||||
size_chunks = int(selected.get("size_chunks") or 0)
|
||||
progress = float(selected.get("progress") or 0)
|
||||
return size <= 0 or progress >= 100.0 or (size_chunks > 0 and completed_chunks >= size_chunks)
|
||||
|
||||
|
||||
def torrent_file_media_info(profile: dict, torrent_hash: str, index: int, max_bytes: int = _MEDIA_INFO_SAMPLE_BYTES) -> dict:
|
||||
# Note: This additive endpoint now acts as a smart file preview: media metadata, text/NFO reader, or image preview depending on file type.
|
||||
selected, remote_path = _torrent_file_remote_path(profile, torrent_hash, index)
|
||||
name = str(selected.get("path") or remote_path)
|
||||
size = int(selected.get("size") or 0)
|
||||
|
||||
if not _torrent_file_is_complete(selected):
|
||||
raise RuntimeError("File info is available only after this file is fully downloaded.")
|
||||
|
||||
err = remote_file_readability_error(profile, remote_path)
|
||||
if err:
|
||||
raise RuntimeError(err)
|
||||
|
||||
if _text_preview_supported(name):
|
||||
return _text_file_preview(profile, selected, remote_path)
|
||||
if _image_preview_supported(name):
|
||||
return _image_file_preview(profile, selected, remote_path)
|
||||
if _pdf_preview_supported(name):
|
||||
return _pdf_file_preview(profile, selected, remote_path)
|
||||
|
||||
supported = _media_info_supported(name)
|
||||
result = {
|
||||
**selected,
|
||||
"kind": "media",
|
||||
"supported": supported,
|
||||
"sample_bytes": 0,
|
||||
"sample_limit": int(max_bytes),
|
||||
"partial": True,
|
||||
"summary": {},
|
||||
"fields": [],
|
||||
"raw": [],
|
||||
"parser": "hachoir",
|
||||
}
|
||||
if not supported:
|
||||
result.update({
|
||||
"kind": "unsupported",
|
||||
"error": "This file extension is not supported by the built-in preview or media info parser.",
|
||||
})
|
||||
return result
|
||||
|
||||
createParser, extractMetadata = _media_info_hachoir_imports()
|
||||
|
||||
tmp_path = None
|
||||
try:
|
||||
tmp_path, written = _media_info_temp_sample(profile, remote_path, max(1024 * 1024, int(max_bytes)))
|
||||
# Note: Do not pass real_filename here; some hachoir versions treat it as an input path and fail for nested torrent file names.
|
||||
parser = createParser(tmp_path)
|
||||
if parser is None:
|
||||
result.update({"sample_bytes": written, "error": "hachoir could not detect this media container."})
|
||||
return result
|
||||
with parser:
|
||||
metadata = extractMetadata(parser)
|
||||
if metadata is None:
|
||||
result.update({"sample_bytes": written, "error": "No media metadata found in the sampled part of the file."})
|
||||
return result
|
||||
raw = _media_info_plaintext(metadata)
|
||||
fields = _media_info_parse_lines(raw)
|
||||
result.update({
|
||||
"sample_bytes": written,
|
||||
"partial": bool(size and written < size),
|
||||
"summary": _media_info_summary(fields),
|
||||
"fields": fields,
|
||||
"raw": raw,
|
||||
})
|
||||
return result
|
||||
finally:
|
||||
if tmp_path:
|
||||
try:
|
||||
os.unlink(tmp_path)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def torrent_download_file_info(profile: dict, torrent_hash: str, index: int) -> dict:
|
||||
selected, remote_path = _torrent_file_remote_path(profile, torrent_hash, index)
|
||||
err = remote_file_readability_error(profile, remote_path)
|
||||
@@ -148,17 +540,31 @@ def torrent_download_zip_items(profile: dict, torrent_hash: str, indexes: list[i
|
||||
return items
|
||||
|
||||
|
||||
def _remote_file_exists(c: ScgiRtorrentClient, source_path: str) -> bool:
|
||||
# Note: Export fallback checks candidate .torrent files on the rTorrent host before staging, avoiding stale tied-file paths.
|
||||
clean = _remote_clean_path(source_path)
|
||||
if not clean:
|
||||
return False
|
||||
script = 'p=$1; [ -f "$p" ] && [ -r "$p" ] && printf OK || true'
|
||||
try:
|
||||
return str(_rt_execute(c, "execute.capture", "sh", "-c", script, "pytorrent-file-exists", clean) or "").strip() == "OK"
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _remote_stage_path(c: ScgiRtorrentClient, source_path: str, suffix: str = "") -> str:
|
||||
token = uuid.uuid4().hex
|
||||
safe_suffix = ''.join(ch if ch.isalnum() or ch in '.-_' else '_' for ch in str(suffix or ''))[:80]
|
||||
target = f"{download_tmp_dir().rstrip('/')}/pytorrent-download-{token}{safe_suffix}"
|
||||
script = (
|
||||
'src=$1; dst=$2; '
|
||||
'if [ ! -f "$src" ]; then echo "ERR\tmissing source"; exit 0; fi; '
|
||||
'if [ ! -f "$src" ]; then printf "ERR\tmissing source: %s\n" "$src"; exit 0; fi; '
|
||||
'if [ ! -r "$src" ]; then printf "ERR\tsource is not readable: %s\n" "$src"; exit 0; fi; '
|
||||
'cp -- "$src" "$dst" 2>/tmp/pytorrent-cp-err-$$ || { rc=$?; err=$(cat /tmp/pytorrent-cp-err-$$ 2>/dev/null); rm -f /tmp/pytorrent-cp-err-$$; printf "ERR\t%s\t%s\n" "$rc" "$err"; exit 0; }; '
|
||||
'rm -f /tmp/pytorrent-cp-err-$$; chmod 0644 "$dst" 2>/dev/null || true; printf "OK\t%s\n" "$dst"'
|
||||
)
|
||||
output = str(_rt_execute(c, "execute.capture", "sh", "-c", script, "pytorrent-stage-file", source_path, target) or "").strip()
|
||||
clean_source = _remote_clean_path(source_path)
|
||||
output = str(_rt_execute(c, "execute.capture", "sh", "-c", script, "pytorrent-stage-file", clean_source, target) or "").strip()
|
||||
parts = (output.splitlines()[0] if output else "").split("\t", 2)
|
||||
if len(parts) >= 2 and parts[0] == "OK":
|
||||
return parts[1]
|
||||
@@ -250,14 +656,48 @@ def _torrent_raw_from_method(c: ScgiRtorrentClient, torrent_hash: str) -> bytes
|
||||
return None
|
||||
|
||||
|
||||
def _torrent_source_file(c: ScgiRtorrentClient, torrent_hash: str) -> str:
|
||||
def _rtorrent_session_path(c: ScgiRtorrentClient) -> str:
|
||||
for method in ("session.path", "get_session"):
|
||||
try:
|
||||
value = str(c.call(method) or "").strip()
|
||||
except Exception:
|
||||
continue
|
||||
if value:
|
||||
return _remote_clean_path(value)
|
||||
return ""
|
||||
|
||||
|
||||
def _torrent_source_file_candidates(c: ScgiRtorrentClient, torrent_hash: str) -> list[str]:
|
||||
# Note: rTorrent may keep stale watch/tied paths; session candidates preserve .torrent export when the original source was moved.
|
||||
candidates: list[str] = []
|
||||
for method in ("d.tied_to_file", "d.get_tied_to_file", "d.loaded_file", "d.get_loaded_file", "d.session_file", "d.get_session_file"):
|
||||
try:
|
||||
value = str(c.call(method, torrent_hash) or "").strip()
|
||||
except Exception:
|
||||
continue
|
||||
if value:
|
||||
return value
|
||||
candidates.append(value)
|
||||
session_path = _rtorrent_session_path(c)
|
||||
hash_values = []
|
||||
clean_hash = str(torrent_hash or "").strip()
|
||||
if clean_hash:
|
||||
hash_values.extend([clean_hash, clean_hash.upper(), clean_hash.lower()])
|
||||
for h in dict.fromkeys(hash_values):
|
||||
if session_path:
|
||||
candidates.append(_remote_join(session_path, f"{h}.torrent"))
|
||||
candidates.append(f"/tmp/{h}.torrent")
|
||||
result = []
|
||||
for item in candidates:
|
||||
clean = _remote_clean_path(item)
|
||||
if clean and clean not in result:
|
||||
result.append(clean)
|
||||
return result
|
||||
|
||||
|
||||
def _torrent_source_file(c: ScgiRtorrentClient, torrent_hash: str) -> str:
|
||||
for source in _torrent_source_file_candidates(c, torrent_hash):
|
||||
if _remote_file_exists(c, source):
|
||||
return source
|
||||
return ""
|
||||
|
||||
|
||||
@@ -265,16 +705,16 @@ def export_torrent_file(profile: dict, torrent_hash: str) -> dict:
|
||||
c = client_for(profile)
|
||||
name = str(c.call("d.name", torrent_hash) or torrent_hash).strip() or torrent_hash
|
||||
filename = f"{name}.torrent" if not name.lower().endswith(".torrent") else name
|
||||
source = _torrent_source_file(c, torrent_hash)
|
||||
if source:
|
||||
# Note: Stream the existing .torrent source directly instead of copying it to a temporary staged file first.
|
||||
return {"path": source, "download_name": filename, "local": False}
|
||||
raw = _torrent_raw_from_method(c, torrent_hash)
|
||||
if raw:
|
||||
target = LocalPath(download_tmp_dir()) / f"pytorrent-download-{uuid.uuid4().hex}.torrent"
|
||||
target.write_bytes(raw)
|
||||
return {"path": str(target), "download_name": filename, "local": True}
|
||||
source = _torrent_source_file(c, torrent_hash)
|
||||
if not source:
|
||||
raise RuntimeError("Cannot find torrent source file in rTorrent")
|
||||
staged = _remote_stage_path(c, source, ".torrent")
|
||||
return {"path": staged, "download_name": filename, "local": False}
|
||||
raise RuntimeError("Cannot find torrent source file in rTorrent")
|
||||
|
||||
|
||||
def set_file_priorities(profile: dict, torrent_hash: str, files: list[dict]) -> dict:
|
||||
|
||||
@@ -1,4 +1,2 @@
|
||||
from __future__ import annotations
|
||||
|
||||
# Note: Backward-compatible internal alias for modules created during refactor.
|
||||
from .client import *
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
from threading import RLock
|
||||
|
||||
from .client import *
|
||||
from .config import default_download_path
|
||||
from ...utils import human_size
|
||||
@@ -10,7 +8,6 @@ from ...utils import human_size
|
||||
|
||||
def browse_path(profile: dict, path: str | None = None) -> dict:
|
||||
"""List directories through rTorrent execute.capture to avoid pyTorrent FS permissions."""
|
||||
# Note: Directory browsing stays remote-side, matching the original monolithic service behavior.
|
||||
c = client_for(profile)
|
||||
base = _remote_clean_path(path or default_download_path(profile))
|
||||
script = (
|
||||
@@ -20,7 +17,10 @@ def browse_path(profile: dict, path: str | None = None) -> dict:
|
||||
'dir_count=0; file_count=0; '
|
||||
'for p in "$base"/* "$base"/.[!.]* "$base"/..?*; do '
|
||||
'[ -e "$p" ] || continue; '
|
||||
'if [ -d "$p" ]; then dir_count=$((dir_count+1)); name=${p##*/}; printf "D\\t%s\\t%s\\n" "$name" "$p"; '
|
||||
'if [ -d "$p" ]; then '
|
||||
'dir_count=$((dir_count+1)); name=${p##*/}; empty=1; '
|
||||
'if find "$p" -mindepth 1 -maxdepth 1 -print -quit 2>/dev/null | grep -q .; then empty=0; fi; '
|
||||
'printf "D\\t%s\\t%s\\t%s\\n" "$name" "$p" "$empty"; '
|
||||
'elif [ -f "$p" ]; then file_count=$((file_count+1)); fi; '
|
||||
'done; '
|
||||
'printf "M\\t%s\\t%s\\n" "$dir_count" "$file_count"; '
|
||||
@@ -37,9 +37,11 @@ def browse_path(profile: dict, path: str | None = None) -> dict:
|
||||
continue
|
||||
marker, rest = line.split("\t", 1)
|
||||
if marker == "D" and "\t" in rest:
|
||||
name, full_path = rest.split("\t", 1)
|
||||
parts = rest.split("\t", 2)
|
||||
name, full_path = parts[0], parts[1]
|
||||
is_empty = len(parts) > 2 and parts[2] == "1"
|
||||
if name not in {".", ".."}:
|
||||
dirs.append({"name": name, "path": full_path})
|
||||
dirs.append({"name": name, "path": full_path, "empty": is_empty})
|
||||
elif marker == "M" and "\t" in rest:
|
||||
first, second = rest.split("\t", 1)
|
||||
try:
|
||||
@@ -61,7 +63,6 @@ def browse_path(profile: dict, path: str | None = None) -> dict:
|
||||
parent = posixpath.dirname(base.rstrip("/")) or "/"
|
||||
if parent == base:
|
||||
parent = base
|
||||
# Note: Path picker metadata is best-effort and remote-side, so it works for move targets on remote rTorrent hosts.
|
||||
return {
|
||||
"path": base,
|
||||
"parent": parent,
|
||||
@@ -78,6 +79,60 @@ def browse_path(profile: dict, path: str | None = None) -> dict:
|
||||
"used_percent": disk_percent,
|
||||
}
|
||||
|
||||
|
||||
|
||||
def _safe_directory_name(name: str) -> str:
|
||||
value = str(name or "").strip()
|
||||
if not value or value in {".", ".."} or "/" in value or "\x00" in value:
|
||||
raise ValueError("Invalid directory name")
|
||||
return value
|
||||
|
||||
|
||||
def create_directory(profile: dict, parent: str, name: str) -> dict:
|
||||
"""Create a remote directory without changing existing path-picker behavior."""
|
||||
# Note: Directory creation is remote-side, so Add/Move sees the same filesystem as rTorrent.
|
||||
c = client_for(profile)
|
||||
clean_parent = _remote_clean_path(parent or default_download_path(profile))
|
||||
clean_name = _safe_directory_name(name)
|
||||
target = _remote_join(clean_parent, clean_name)
|
||||
script = (
|
||||
'parent=$1; target=$2; '
|
||||
'if [ ! -d "$parent" ]; then printf "ERR\tParent directory does not exist"; exit 0; fi; '
|
||||
'if [ -e "$target" ] || [ -L "$target" ]; then printf "ERR\tDirectory already exists"; exit 0; fi; '
|
||||
'mkdir -- "$target" 2>/dev/null || { printf "ERR\tCannot create directory"; exit 0; }; '
|
||||
'printf "OK\t%s" "$target"'
|
||||
)
|
||||
output = str(_rt_execute(c, "execute.capture", "sh", "-c", script, "pytorrent-mkdir", clean_parent, target) or "").strip()
|
||||
if not output.startswith("OK\t"):
|
||||
raise RuntimeError(output.split("\t", 1)[1] if "\t" in output else "Cannot create directory")
|
||||
return {"path": output.split("\t", 1)[1], "name": clean_name}
|
||||
|
||||
|
||||
def rename_empty_directory(profile: dict, path: str, new_name: str) -> dict:
|
||||
"""Rename an empty remote directory in place."""
|
||||
# Note: Rename is intentionally limited to empty folders to avoid invalidating active torrent paths.
|
||||
c = client_for(profile)
|
||||
source = _remote_clean_path(path or "")
|
||||
clean_name = _safe_directory_name(new_name)
|
||||
if not source or source == "/":
|
||||
raise ValueError("Cannot rename this directory")
|
||||
parent = posixpath.dirname(source.rstrip("/")) or "/"
|
||||
target = _remote_join(parent, clean_name)
|
||||
if source == target:
|
||||
return {"path": target, "name": clean_name, "parent": parent}
|
||||
script = (
|
||||
'src=$1; dst=$2; '
|
||||
'if [ ! -d "$src" ]; then printf "ERR\tDirectory does not exist"; exit 0; fi; '
|
||||
'if [ -e "$dst" ] || [ -L "$dst" ]; then printf "ERR\tTarget directory already exists"; exit 0; fi; '
|
||||
'if [ -n "$(find "$src" -mindepth 1 -maxdepth 1 -print -quit 2>/dev/null)" ]; then printf "ERR\tOnly empty directories can be renamed"; exit 0; fi; '
|
||||
'mv -- "$src" "$dst" 2>/dev/null || { printf "ERR\tCannot rename directory"; exit 0; }; '
|
||||
'printf "OK\t%s" "$dst"'
|
||||
)
|
||||
output = str(_rt_execute(c, "execute.capture", "sh", "-c", script, "pytorrent-rename-dir", source, target) or "").strip()
|
||||
if not output.startswith("OK\t"):
|
||||
raise RuntimeError(output.split("\t", 1)[1] if "\t" in output else "Cannot rename directory")
|
||||
return {"path": output.split("\t", 1)[1], "name": clean_name, "parent": parent}
|
||||
|
||||
def remote_public_ip(profile: dict, force: bool = False) -> str:
|
||||
profile_id = int(profile.get("id") or 0)
|
||||
now = time.monotonic()
|
||||
|
||||
@@ -1,16 +1,14 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from .client import *
|
||||
from .files import set_file_priorities
|
||||
from .system import disk_usage_for_default_path
|
||||
|
||||
|
||||
XMLRPC_DEFAULT_SIZE_LIMIT_BYTES = 512 * 1024
|
||||
|
||||
|
||||
def _parse_xmlrpc_size_limit(value) -> int:
|
||||
"""Parse rTorrent XML-RPC size values such as 524288, 16M or 8K."""
|
||||
# Note: rTorrent accepts human suffixes in config files; UI validation normalizes them to bytes.
|
||||
text = str(value or '').strip().lower()
|
||||
if not text:
|
||||
return XMLRPC_DEFAULT_SIZE_LIMIT_BYTES
|
||||
@@ -27,7 +25,6 @@ def _parse_xmlrpc_size_limit(value) -> int:
|
||||
|
||||
def xmlrpc_size_limit(profile: dict) -> dict:
|
||||
"""Return the current rTorrent XML-RPC request size limit."""
|
||||
# Note: This value controls .torrent uploads because load.raw sends the torrent through XML-RPC.
|
||||
try:
|
||||
raw = client_for(profile).call('network.xmlrpc.size_limit')
|
||||
limit = _parse_xmlrpc_size_limit(raw)
|
||||
@@ -38,7 +35,6 @@ def xmlrpc_size_limit(profile: dict) -> dict:
|
||||
|
||||
def estimate_torrent_upload_request_size(data: bytes, start: bool = True, directory: str = '', label: str = '', file_priorities: list[dict] | None = None) -> int:
|
||||
"""Estimate the XML-RPC body size produced by rTorrent load.raw* for a .torrent file."""
|
||||
# Note: XML-RPC uses base64 for Binary payloads, so the request is larger than the raw .torrent file.
|
||||
commands = []
|
||||
if directory:
|
||||
commands.append(f'd.directory.set={directory}')
|
||||
@@ -91,7 +87,6 @@ def _is_post_check_watched(profile_id: int, torrent_hash: str) -> bool:
|
||||
if age > _POST_CHECK_WATCH_TTL_SECONDS:
|
||||
_clear_post_check_watch(profile_id, torrent_hash)
|
||||
return False
|
||||
# Note: A short grace period prevents labeling a recheck that was queued but has not visibly entered hashing yet.
|
||||
return age >= _POST_CHECK_WATCH_MIN_SECONDS
|
||||
|
||||
|
||||
@@ -122,7 +117,6 @@ def clear_post_check_download_label(c: ScgiRtorrentClient, torrent_hash: str, cu
|
||||
labels = _label_names(str(label_source or ""))
|
||||
if POST_CHECK_DOWNLOAD_LABEL not in labels:
|
||||
return False
|
||||
# Note: The temporary post-check label is removed only after the torrent leaves the stopped waiting queue.
|
||||
c.call("d.custom1.set", str(torrent_hash or ""), _label_value([label for label in labels if label != POST_CHECK_DOWNLOAD_LABEL]))
|
||||
return True
|
||||
|
||||
@@ -149,10 +143,9 @@ def _cleanup_post_check_label_if_ready(c: ScgiRtorrentClient, row: dict) -> bool
|
||||
if POST_CHECK_DOWNLOAD_LABEL not in labels:
|
||||
return False
|
||||
status = str(row.get("status") or "").lower()
|
||||
started_after_wait = bool(int(row.get("state") or 0)) and status != "checking"
|
||||
started_after_wait = bool(int(row.get("state") or 0)) and bool(int(row.get("active") or 0)) and status != "checking"
|
||||
if not (_row_progress_complete(row) or status == "seeding" or started_after_wait):
|
||||
return False
|
||||
# Note: Keep the post-check label while the torrent is stopped; remove it once it is started for download/seeding.
|
||||
clear_post_check_download_label(c, str(row.get("hash") or ""), str(row.get("label") or ""))
|
||||
row["label"] = _without_post_check_download_label(str(row.get("label") or ""))
|
||||
return True
|
||||
@@ -180,7 +173,6 @@ def apply_post_check_policy(profile: dict, rows: list[dict], previous_rows: dict
|
||||
complete = _row_progress_complete(row)
|
||||
try:
|
||||
if complete:
|
||||
# Note: A fully checked torrent is started with the same helper as the manual Start action so it seeds immediately.
|
||||
start_result = start_or_resume_hash(c, h)
|
||||
clear_post_check_download_label(c, h, str(row.get("label") or ""))
|
||||
row.update({"state": 1, "active": 1, "paused": False, "status": "Seeding", "label": _without_post_check_download_label(str(row.get("label") or ""))})
|
||||
@@ -190,15 +182,14 @@ def apply_post_check_policy(profile: dict, rows: list[dict], previous_rows: dict
|
||||
if POST_CHECK_DOWNLOAD_LABEL not in labels:
|
||||
labels.append(POST_CHECK_DOWNLOAD_LABEL)
|
||||
label_value = _label_value(labels)
|
||||
# Note: Incomplete torrents are left stopped after check so Smart Queue can start them later within the global limit.
|
||||
c.call("d.stop", h)
|
||||
try:
|
||||
c.call("d.close", h)
|
||||
except Exception:
|
||||
pass
|
||||
c.call("d.custom1.set", h, label_value)
|
||||
row.update({"state": 0, "active": 0, "paused": False, "status": "Stopped", "label": label_value})
|
||||
changes.append({"hash": h, "action": "stop_and_label_after_check", "complete": False, "label": POST_CHECK_DOWNLOAD_LABEL})
|
||||
row.update({"state": 0, "active": 0, "paused": False, "post_check": True, "status": "Post-check", "label": label_value})
|
||||
changes.append({"hash": h, "action": "mark_post_check_waiting", "complete": False, "label": POST_CHECK_DOWNLOAD_LABEL})
|
||||
_clear_post_check_watch(profile_id, h)
|
||||
except Exception as exc:
|
||||
changes.append({"hash": h, "action": "post_check_policy_failed", "error": str(exc)})
|
||||
@@ -209,16 +200,23 @@ TORRENT_FIELDS = [
|
||||
"d.hash=", "d.name=", "d.state=", "d.complete=", "d.size_bytes=", "d.completed_bytes=",
|
||||
"d.ratio=", "d.up.rate=", "d.down.rate=", "d.up.total=", "d.down.total=", "d.peers_connected=",
|
||||
"d.peers_complete=", "d.priority=", "d.directory=", "d.base_path=", "d.creation_date=", "d.custom1=",
|
||||
"d.custom=py_ratio_group", "d.message=", "d.hashing=", "d.is_active=", "d.is_multi_file=",
|
||||
"d.custom=py_ratio_group", f"d.custom={PY_MANUAL_PAUSE_FIELD}", "d.message=", "d.hashing=", "d.is_active=", "d.is_open=", "d.is_multi_file=",
|
||||
]
|
||||
|
||||
TORRENT_OPTIONAL_FIELDS = [
|
||||
"d.timestamp.last_active=",
|
||||
"d.timestamp.finished=",
|
||||
]
|
||||
|
||||
LIVE_TORRENT_FIELDS = [
|
||||
"d.hash=", "d.state=", "d.complete=", "d.size_bytes=", "d.completed_bytes=",
|
||||
"d.ratio=", "d.up.rate=", "d.down.rate=", "d.up.total=", "d.down.total=",
|
||||
"d.peers_connected=", "d.peers_complete=", "d.message=", "d.hashing=", "d.is_active=",
|
||||
"d.is_open=", "d.custom1=", f"d.custom={PY_MANUAL_PAUSE_FIELD}",
|
||||
]
|
||||
|
||||
|
||||
def human_duration(seconds: int) -> str:
|
||||
# Note: Download ETA is derived locally from remaining bytes and current download speed.
|
||||
seconds = max(0, int(seconds or 0))
|
||||
if seconds <= 0:
|
||||
return '-'
|
||||
@@ -243,12 +241,10 @@ def normalize_row(row: list) -> dict:
|
||||
eta_seconds = int(remaining_bytes / down_rate) if down_rate > 0 and not int(row[3] or 0) else 0
|
||||
directory = str(row[14] or "")
|
||||
base_path = str(row[15] or "")
|
||||
is_multi_file = int(row[22] or 0) if len(row) > 22 else 0
|
||||
completed_at = int(row[23] or 0) if len(row) > 23 else 0
|
||||
state = int(row[2] or 0)
|
||||
complete = int(row[3] or 0)
|
||||
is_multi_file = int(row[24] or 0) if len(row) > 24 else 0
|
||||
|
||||
# Show the selected download location only. Hide the torrent root
|
||||
# directory for multi-file torrents and the filename for single-file
|
||||
# torrents. Data deletion still uses the full d.base_path elsewhere.
|
||||
if base_path and base_path != "/":
|
||||
display_parent = posixpath.dirname(base_path.rstrip("/")) or "/"
|
||||
display_path = display_parent.rstrip("/") + "/" if display_parent != "/" else display_parent
|
||||
@@ -259,24 +255,31 @@ def normalize_row(row: list) -> dict:
|
||||
display_path = directory.rstrip("/") + "/" if directory != "/" else directory
|
||||
else:
|
||||
display_path = ""
|
||||
msg = str(row[19] or "")
|
||||
manual_pause = str(row[19] or "").strip() == "1"
|
||||
msg = str(row[20] or "")
|
||||
msg_l = msg.lower()
|
||||
hashing = int(row[20] or 0) if len(row) > 20 else 0
|
||||
is_active = int(row[21] or 0) if len(row) > 21 else int(row[2] or 0)
|
||||
state = int(row[2] or 0)
|
||||
complete = int(row[3] or 0)
|
||||
# Note: d.hashing is authoritative; stale "hash check complete" messages must not keep the UI in Checking forever.
|
||||
hashing = int(row[21] or 0) if len(row) > 21 else 0
|
||||
is_active = int(row[22] or 0) if len(row) > 22 else int(state)
|
||||
is_open = int(row[23] or 0) if len(row) > 23 else int(is_active or state)
|
||||
last_activity = int(row[25] or 0) if len(row) > 25 else 0
|
||||
if not last_activity and (down_rate > 0 or up_rate > 0):
|
||||
last_activity = int(time.time())
|
||||
completed_at = int(row[26] or 0) if len(row) > 26 else 0
|
||||
is_checking = bool(hashing) or _message_indicates_active_check(msg_l)
|
||||
is_paused = bool(state) and not bool(is_active) and not is_checking
|
||||
status = "Checking" if is_checking else "Paused" if is_paused else "Seeding" if complete and state else "Downloading" if state else "Stopped"
|
||||
post_check = POST_CHECK_DOWNLOAD_LABEL in _label_names(str(row[17] or "")) and not is_checking and not bool(is_active)
|
||||
is_paused = manual_pause and not is_checking and not post_check
|
||||
is_queued = bool(state) and bool(is_open) and not bool(is_active) and not bool(complete) and not is_paused and not is_checking and not post_check
|
||||
status = "Checking" if is_checking else "Post-check" if post_check else "Paused" if is_paused else "Queued" if is_queued else "Seeding" if complete and state else "Downloading" if state else "Stopped"
|
||||
to_download_bytes = remaining_bytes if not complete else 0
|
||||
# Note: The To download column is only meaningful for incomplete torrents; complete rows expose an empty display value.
|
||||
|
||||
return {
|
||||
"hash": str(row[0] or ""),
|
||||
"name": str(row[1] or ""),
|
||||
"state": state,
|
||||
"active": is_active,
|
||||
"open": is_open,
|
||||
"paused": is_paused,
|
||||
"queued": is_queued,
|
||||
"complete": complete,
|
||||
"size": size,
|
||||
"size_h": human_size(size),
|
||||
@@ -300,27 +303,90 @@ def normalize_row(row: list) -> dict:
|
||||
"priority": int(row[13] or 0),
|
||||
"path": display_path,
|
||||
"created": int(row[16] or 0),
|
||||
"last_activity": last_activity,
|
||||
"completed_at": completed_at,
|
||||
"label": str(row[17] or ""),
|
||||
"ratio_group": str(row[18] or ""),
|
||||
"message": msg,
|
||||
"status": status,
|
||||
"post_check": post_check,
|
||||
"hashing": hashing,
|
||||
}
|
||||
|
||||
|
||||
def normalize_live_row(row: list) -> dict:
|
||||
"""Normalize the small row used by the fast live stats poller."""
|
||||
size = int(row[3] or 0)
|
||||
completed = int(row[4] or 0)
|
||||
complete = int(row[2] or 0)
|
||||
state = int(row[1] or 0)
|
||||
down_rate = int(row[7] or 0)
|
||||
up_rate = int(row[6] or 0)
|
||||
ratio_raw = int(row[5] or 0)
|
||||
remaining_bytes = max(0, size - completed)
|
||||
eta_seconds = int(remaining_bytes / down_rate) if down_rate > 0 and not complete else 0
|
||||
msg = str(row[12] or "")
|
||||
hashing = int(row[13] or 0)
|
||||
is_active = int(row[14] or 0)
|
||||
is_open = int(row[15] or 0) if len(row) > 15 else int(is_active or state)
|
||||
labels = str(row[16] or "") if len(row) > 16 else ""
|
||||
manual_pause = str(row[17] or "").strip() == "1" if len(row) > 17 else False
|
||||
is_checking = bool(hashing) or _message_indicates_active_check(msg.lower())
|
||||
post_check = POST_CHECK_DOWNLOAD_LABEL in _label_names(labels) and not is_checking and not bool(is_active)
|
||||
# Note: Live patches keep Queued separate from explicit user Paused using the same app marker as full snapshots.
|
||||
is_paused = manual_pause and not is_checking and not post_check
|
||||
is_queued = bool(state) and bool(is_open) and not bool(is_active) and not bool(complete) and not is_paused and not is_checking and not post_check
|
||||
status = "Checking" if is_checking else "Post-check" if post_check else "Paused" if is_paused else "Queued" if is_queued else "Seeding" if complete and state else "Downloading" if state else "Stopped"
|
||||
progress = 100.0 if size <= 0 and complete else round((completed / size) * 100, 2) if size else 0.0
|
||||
to_download_bytes = remaining_bytes if not complete else 0
|
||||
return {
|
||||
"hash": str(row[0] or ""),
|
||||
"state": state,
|
||||
"active": is_active,
|
||||
"open": is_open,
|
||||
"paused": is_paused,
|
||||
"queued": is_queued,
|
||||
"complete": complete,
|
||||
"completed_bytes": completed,
|
||||
"progress": progress,
|
||||
"ratio": round(ratio_raw / 1000, 3),
|
||||
"up_rate": up_rate,
|
||||
"up_rate_h": human_rate(up_rate),
|
||||
"down_rate": down_rate,
|
||||
"down_rate_h": human_rate(down_rate),
|
||||
"eta_seconds": eta_seconds,
|
||||
"eta_h": human_duration(eta_seconds) if eta_seconds else "-",
|
||||
"up_total": int(row[8] or 0),
|
||||
"up_total_h": human_size(row[8] or 0),
|
||||
"down_total": int(row[9] or 0),
|
||||
"down_total_h": human_size(row[9] or 0),
|
||||
"to_download": to_download_bytes,
|
||||
"to_download_h": human_size(to_download_bytes) if to_download_bytes else "",
|
||||
"peers": int(row[10] or 0),
|
||||
"seeds": int(row[11] or 0),
|
||||
"message": msg,
|
||||
"status": status,
|
||||
"post_check": post_check,
|
||||
"hashing": hashing,
|
||||
}
|
||||
|
||||
|
||||
def list_torrent_live_stats(profile: dict) -> list[dict]:
|
||||
"""Return lightweight live torrent stats for the fast poller."""
|
||||
# Note: This avoids the full torrent row multicall on every speed/status tick.
|
||||
rows = client_for(profile).d.multicall2("", "main", *LIVE_TORRENT_FIELDS)
|
||||
return [normalize_live_row(list(row)) for row in rows]
|
||||
|
||||
|
||||
def list_torrents(profile: dict) -> list[dict]:
|
||||
c = client_for(profile)
|
||||
try:
|
||||
rows = c.d.multicall2("", "main", *(TORRENT_FIELDS + TORRENT_OPTIONAL_FIELDS))
|
||||
except Exception:
|
||||
# Keep compatibility with older rTorrent builds that do not expose optional timestamp fields.
|
||||
rows = c.d.multicall2("", "main", *TORRENT_FIELDS)
|
||||
return [normalize_row(list(row)) for row in rows]
|
||||
|
||||
|
||||
|
||||
|
||||
def torrent_peers(profile: dict, torrent_hash: str) -> list[dict]:
|
||||
fields = [
|
||||
"p.address=", "p.client_version=", "p.completed_percent=", "p.down_rate=",
|
||||
@@ -352,8 +418,6 @@ def torrent_peers(profile: dict, torrent_hash: str) -> list[dict]:
|
||||
return peers
|
||||
|
||||
|
||||
|
||||
|
||||
def _call_first(c: ScgiRtorrentClient, candidates: list[tuple[str, tuple]]) -> dict:
|
||||
errors = []
|
||||
for method, args in candidates:
|
||||
@@ -365,7 +429,6 @@ def _call_first(c: ScgiRtorrentClient, candidates: list[tuple[str, tuple]]) -> d
|
||||
raise RuntimeError("; ".join(errors))
|
||||
|
||||
|
||||
|
||||
def _tracker_domain(url: str) -> str:
|
||||
raw = str(url or '').strip()
|
||||
if not raw:
|
||||
@@ -379,7 +442,6 @@ def _tracker_domain(url: str) -> str:
|
||||
|
||||
def tracker_summary(profile: dict, torrent_hashes: list[str] | None = None, limit: int = 1000) -> dict:
|
||||
"""Return tracker domains grouped by torrent for the sidebar filter."""
|
||||
# Note: Tracker summary is read-only and isolated from the normal torrent snapshot, so slow tracker RPC calls cannot break the main list.
|
||||
hashes = [str(h or '').strip() for h in (torrent_hashes or []) if str(h or '').strip()]
|
||||
if not hashes:
|
||||
hashes = [t.get('hash') for t in list_torrents(profile) if t.get('hash')]
|
||||
@@ -539,43 +601,77 @@ def _str_rpc(c: ScgiRtorrentClient, method: str, h: str, default: str = '') -> s
|
||||
return default
|
||||
|
||||
|
||||
|
||||
def _set_manual_pause(c: ScgiRtorrentClient, torrent_hash: str, enabled: bool) -> None:
|
||||
"""Persist the user Pause intent without touching the visible label field."""
|
||||
# Note: rTorrent has no reliable queued-vs-user-paused flag, so pyTorrent stores that intent in d.custom.
|
||||
c.call('d.custom.set', str(torrent_hash or ''), PY_MANUAL_PAUSE_FIELD, '1' if enabled else '')
|
||||
|
||||
|
||||
def _manual_pause_enabled(c: ScgiRtorrentClient, torrent_hash: str) -> bool:
|
||||
h = str(torrent_hash or '')
|
||||
for method, args in (
|
||||
(f'd.custom={PY_MANUAL_PAUSE_FIELD}', (h,)),
|
||||
('d.custom', (h, PY_MANUAL_PAUSE_FIELD)),
|
||||
):
|
||||
try:
|
||||
if str(c.call(method, *args) or '').strip() == '1':
|
||||
return True
|
||||
except Exception:
|
||||
continue
|
||||
return False
|
||||
|
||||
def _download_runtime_state(c: ScgiRtorrentClient, h: str) -> dict:
|
||||
"""Read rTorrent state using the native pause model: stopped, paused or active."""
|
||||
state = _int_rpc(c, 'd.state', h)
|
||||
active = _int_rpc(c, 'd.is_active', h)
|
||||
opened = _int_rpc(c, 'd.is_open', h)
|
||||
# Note: In rTorrent, pause does not change d.state. Paused means state=1, open=1, active=0.
|
||||
label = _str_rpc(c, 'd.custom1', h)
|
||||
manual_pause = _manual_pause_enabled(c, h)
|
||||
post_check = POST_CHECK_DOWNLOAD_LABEL in _label_names(label) and not bool(active)
|
||||
paused = bool(manual_pause and not post_check)
|
||||
queued = bool(state and opened and not active and not paused and not post_check)
|
||||
return {
|
||||
'state': state,
|
||||
'open': opened,
|
||||
'active': active,
|
||||
'paused': bool(state and opened and not active),
|
||||
'paused': paused,
|
||||
'queued': queued,
|
||||
'stopped': not bool(state),
|
||||
'post_check': post_check,
|
||||
'label': label,
|
||||
'manual_pause': manual_pause,
|
||||
'message': _str_rpc(c, 'd.message', h),
|
||||
}
|
||||
|
||||
|
||||
def pause_hash(c: ScgiRtorrentClient, torrent_hash: str) -> dict:
|
||||
"""Pause an active rTorrent item without stopping or closing it."""
|
||||
"""Mark a torrent as user-paused and ask rTorrent to pause it."""
|
||||
h = str(torrent_hash or '')
|
||||
if not h:
|
||||
return {'hash': h, 'ok': False, 'error': 'missing hash'}
|
||||
before = _download_runtime_state(c, h)
|
||||
result = {'hash': h, 'before': before, 'commands': []}
|
||||
try:
|
||||
_set_manual_pause(c, h, True)
|
||||
result['commands'].append('set_py_manual_pause')
|
||||
if before.get('stopped'):
|
||||
# Note: rTorrent does not turn a stopped item into a paused one with d.pause alone.
|
||||
# First move it out of STOP, then pause it, which matches the expected START -> PAUSE flow.
|
||||
# Note: A stopped torrent has no native paused flag; opening it first lets the UI and later Resume follow the same path.
|
||||
try:
|
||||
c.call('d.open', h)
|
||||
result['commands'].append('d.open')
|
||||
except Exception as exc:
|
||||
result.setdefault('ignored_errors', []).append(f'd.open: {exc}')
|
||||
c.call('d.start', h)
|
||||
result['commands'].append('d.start')
|
||||
# Note: Smart Queue frees a slot with d.pause, not d.stop, so later d.resume behaves like ruTorrent.
|
||||
c.call('d.pause', h)
|
||||
result['commands'].append('d.pause')
|
||||
try:
|
||||
c.call('d.start', h)
|
||||
result['commands'].append('d.start')
|
||||
except Exception as exc:
|
||||
result.setdefault('ignored_errors', []).append(f'd.start: {exc}')
|
||||
try:
|
||||
c.call('d.pause', h)
|
||||
result['commands'].append('d.pause')
|
||||
except Exception as exc:
|
||||
result.setdefault('ignored_errors', []).append(f'd.pause: {exc}')
|
||||
result['after'] = _download_runtime_state(c, h)
|
||||
result['ok'] = True
|
||||
except Exception as exc:
|
||||
@@ -590,10 +686,21 @@ def stop_hash(c: ScgiRtorrentClient, torrent_hash: str) -> dict:
|
||||
return {'hash': h, 'ok': False, 'error': 'missing hash'}
|
||||
before = _download_runtime_state(c, h)
|
||||
result = {'hash': h, 'before': before, 'commands': []}
|
||||
if before.get('stopped'):
|
||||
if before.get('stopped') and not before.get('post_check'):
|
||||
if before.get('manual_pause'):
|
||||
_set_manual_pause(c, h, False)
|
||||
result['commands'].append('clear_py_manual_pause')
|
||||
before = _download_runtime_state(c, h)
|
||||
result.update({'ok': True, 'skipped': 'already_stopped', 'after': before})
|
||||
return result
|
||||
try:
|
||||
if before.get('manual_pause'):
|
||||
_set_manual_pause(c, h, False)
|
||||
result['commands'].append('clear_py_manual_pause')
|
||||
# Note: User Stop converts the app-level Post-check state into a regular stopped torrent.
|
||||
if before.get('post_check'):
|
||||
clear_post_check_download_label(c, h, before.get('label'))
|
||||
result['commands'].append('clear_post_check_label')
|
||||
# Note: Smart Queue now enforces the queue with d.stop only; user-paused torrents stay untouched.
|
||||
c.call('d.stop', h)
|
||||
result['commands'].append('d.stop')
|
||||
@@ -605,23 +712,34 @@ def stop_hash(c: ScgiRtorrentClient, torrent_hash: str) -> dict:
|
||||
|
||||
|
||||
def resume_paused_hash(c: ScgiRtorrentClient, torrent_hash: str) -> dict:
|
||||
"""Resume only a paused rTorrent item; never convert it through stop/start."""
|
||||
"""Resume a user-paused torrent and clear pyTorrent's pause marker."""
|
||||
h = str(torrent_hash or '')
|
||||
if not h:
|
||||
return {'hash': h, 'ok': False, 'error': 'missing hash'}
|
||||
before = _download_runtime_state(c, h)
|
||||
result: dict = {'hash': h, 'before': before, 'commands': []}
|
||||
if before.get('stopped'):
|
||||
result.update({'ok': False, 'skipped': 'stopped_not_paused', 'after': before})
|
||||
return result
|
||||
if before.get('active'):
|
||||
if before.get('active') and not before.get('manual_pause'):
|
||||
result.update({'ok': True, 'skipped': 'already_active', 'after': before})
|
||||
return result
|
||||
try:
|
||||
# Note: ruTorrent unpauses with the equivalent of d.resume. Do not add d.start/d.open,
|
||||
# because those commands belong to Stopped/Open state, not a clean Paused state.
|
||||
c.call('d.resume', h)
|
||||
result['commands'].append('d.resume')
|
||||
if before.get('manual_pause'):
|
||||
_set_manual_pause(c, h, False)
|
||||
result['commands'].append('clear_py_manual_pause')
|
||||
try:
|
||||
c.call('d.resume', h)
|
||||
result['commands'].append('d.resume')
|
||||
except Exception as exc:
|
||||
result.setdefault('ignored_errors', []).append(f'd.resume: {exc}')
|
||||
try:
|
||||
c.call('d.open', h)
|
||||
result['commands'].append('d.open')
|
||||
except Exception as exc:
|
||||
result.setdefault('ignored_errors', []).append(f'd.open: {exc}')
|
||||
try:
|
||||
c.call('d.start', h)
|
||||
result['commands'].append('d.start')
|
||||
except Exception as exc:
|
||||
result.setdefault('ignored_errors', []).append(f'd.start: {exc}')
|
||||
result['after'] = _download_runtime_state(c, h)
|
||||
result['ok'] = True
|
||||
except Exception as exc:
|
||||
@@ -630,30 +748,36 @@ def resume_paused_hash(c: ScgiRtorrentClient, torrent_hash: str) -> dict:
|
||||
|
||||
|
||||
def start_or_resume_hash(c: ScgiRtorrentClient, torrent_hash: str, prefer_start: bool = False) -> dict:
|
||||
"""Start stopped torrents or resume real paused torrents.
|
||||
"""Start stopped torrents and recover open/inactive paused torrents.
|
||||
|
||||
Smart Queue passes prefer_start=True for candidates that were selected as stopped.
|
||||
This avoids treating rTorrent's intermediate open/inactive state after a check as
|
||||
a user pause and sending only d.resume, which can leave items pending forever.
|
||||
rTorrent can expose a torrent as state=1, open=1 and active=0 while d.resume/d.start
|
||||
alone does not wake it up. Manual Start uses the same recovery path users already
|
||||
perform by hand: d.stop followed by d.open and d.start.
|
||||
"""
|
||||
h = str(torrent_hash or '')
|
||||
if not h:
|
||||
return {'hash': h, 'ok': False, 'error': 'missing hash'}
|
||||
before = _download_runtime_state(c, h)
|
||||
result: dict = {'hash': h, 'before': before, 'commands': []}
|
||||
if before.get('manual_pause'):
|
||||
_set_manual_pause(c, h, False)
|
||||
result['commands'].append('clear_py_manual_pause')
|
||||
before = _download_runtime_state(c, h)
|
||||
|
||||
if before.get('active'):
|
||||
if before.get('post_check'):
|
||||
clear_post_check_download_label(c, h, before.get('label'))
|
||||
before = _download_runtime_state(c, h)
|
||||
result.update({'ok': True, 'skipped': 'already_active', 'after': before})
|
||||
return result
|
||||
|
||||
if before.get('paused') and not prefer_start:
|
||||
# Note: Manual Start keeps the clean pause-to-resume path. Do not classify every
|
||||
# state=1/active=0 item as paused; after auto-check this can be only a transient
|
||||
# open/inactive rTorrent state and needs d.open + d.start.
|
||||
resumed = resume_paused_hash(c, h)
|
||||
resumed['mode'] = 'resume_paused'
|
||||
return resumed
|
||||
|
||||
if (before.get('paused') and not prefer_start) or before.get('queued') or before.get('post_check'):
|
||||
try:
|
||||
# Note: Start intentionally normalizes open/inactive torrents through Stop -> Start because d.resume can leave them stuck.
|
||||
c.call('d.stop', h)
|
||||
result['commands'].append('d.stop')
|
||||
except Exception as exc:
|
||||
result.setdefault('ignored_errors', []).append(f'd.stop: {exc}')
|
||||
try:
|
||||
c.call('d.open', h)
|
||||
result['commands'].append('d.open')
|
||||
@@ -670,7 +794,13 @@ def start_or_resume_hash(c: ScgiRtorrentClient, torrent_hash: str, prefer_start:
|
||||
except Exception as exc2:
|
||||
result.setdefault('ignored_errors', []).append(f'd.try_start: {exc2}')
|
||||
result['ok'] = False
|
||||
result['after'] = _download_runtime_state(c, h)
|
||||
after = _download_runtime_state(c, h)
|
||||
if before.get('post_check') and after.get('active'):
|
||||
# Note: The marker stays in place when start fails so the row remains visible in the Post-check filter.
|
||||
clear_post_check_download_label(c, h, before.get('label'))
|
||||
result['commands'].append('clear_post_check_label')
|
||||
after = _download_runtime_state(c, h)
|
||||
result['after'] = after
|
||||
result['ok'] = result.get('ok', True)
|
||||
return result
|
||||
|
||||
@@ -787,7 +917,7 @@ def action(profile: dict, torrent_hashes: list[str], name: str, payload: dict |
|
||||
mark_done(h, item, results)
|
||||
return {"ok": True, "count": len(torrent_hashes), "remove_data": False, "results": results}
|
||||
if name in {"resume", "unpause"}:
|
||||
# Note: Resume/Unpause uses only d.resume for Paused state.
|
||||
# Note: Resume/Unpause keeps native rTorrent resume semantics; Start is the recovery action for stuck open/inactive torrents.
|
||||
results = previous_results
|
||||
for h in pending_hashes():
|
||||
item = resume_paused_hash(c, h)
|
||||
@@ -795,7 +925,7 @@ def action(profile: dict, torrent_hashes: list[str], name: str, payload: dict |
|
||||
mark_done(h, item, results)
|
||||
return {"ok": True, "count": len(torrent_hashes), "remove_data": False, "results": results}
|
||||
if name == "start":
|
||||
# Note: Start separates Stopped from Paused; paused items go through d.resume, stopped items through d.start.
|
||||
# Note: Start recovers stuck Paused/open-inactive rows with Stop -> Start while keeping normal stopped rows on d.start.
|
||||
results = previous_results
|
||||
for h in pending_hashes():
|
||||
item = start_or_resume_hash(c, h)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,12 +1,10 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import Counter
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
|
||||
from ..config import BASE_DIR, SMART_QUEUE_LABEL, SMART_QUEUE_STALLED_LABEL
|
||||
from ..db import connect, default_user_id, utcnow
|
||||
from . import rtorrent
|
||||
@@ -66,6 +64,8 @@ def _diagnostics_torrent(t: dict[str, Any] | None) -> dict[str, Any]:
|
||||
'hashing': int(t.get('hashing') or 0),
|
||||
'priority': int(t.get('priority') or 0),
|
||||
'down_rate': int(t.get('down_rate') or 0),
|
||||
'up_rate': int(t.get('up_rate') or 0),
|
||||
'last_activity': int(t.get('last_activity') or 0),
|
||||
'peers': int(t.get('peers') or 0),
|
||||
'seeds': int(t.get('seeds') or 0),
|
||||
'label': str(t.get('label') or ''),
|
||||
@@ -135,9 +135,8 @@ def _int_setting(data: dict[str, Any], current: dict[str, Any], key: str, defaul
|
||||
return max(minimum, int(default))
|
||||
|
||||
|
||||
def _default_settings(user_id: int, profile_id: int) -> dict[str, Any]:
|
||||
def _default_settings(profile_id: int) -> dict[str, Any]:
|
||||
return {
|
||||
'user_id': user_id,
|
||||
'profile_id': profile_id,
|
||||
'enabled': 0,
|
||||
'max_active_downloads': 5,
|
||||
@@ -153,27 +152,30 @@ def _default_settings(user_id: int, profile_id: int) -> dict[str, Any]:
|
||||
'refill_enabled': 1,
|
||||
'refill_interval_minutes': 0,
|
||||
'last_refill_at': None,
|
||||
'surge_refill_enabled': 0,
|
||||
'surge_refill_interval_minutes': 1440,
|
||||
'surge_refill_batch_size': 2000,
|
||||
'last_surge_refill_at': None,
|
||||
'stop_batch_size': 50,
|
||||
'start_grace_seconds': 900,
|
||||
'protect_active_below_cap': 1,
|
||||
'prefer_partial_progress': 1,
|
||||
'auto_stop_idle': 0,
|
||||
'updated_at': utcnow(),
|
||||
}
|
||||
|
||||
|
||||
def get_settings(profile_id: int, user_id: int | None = None) -> dict[str, Any]:
|
||||
user_id = user_id or default_user_id()
|
||||
with connect() as conn:
|
||||
row = conn.execute(
|
||||
'SELECT * FROM smart_queue_settings WHERE user_id=? AND profile_id=?',
|
||||
(user_id, profile_id),
|
||||
'SELECT * FROM smart_queue_settings WHERE profile_id=?',
|
||||
(profile_id,),
|
||||
).fetchone()
|
||||
settings = dict(row or _default_settings(user_id, profile_id))
|
||||
settings = dict(row or _default_settings(profile_id))
|
||||
return settings
|
||||
|
||||
|
||||
def save_settings(profile_id: int, data: dict[str, Any], user_id: int | None = None) -> dict[str, Any]:
|
||||
user_id = user_id or default_user_id()
|
||||
current = get_settings(profile_id, user_id)
|
||||
settings = {
|
||||
'enabled': 1 if data.get('enabled', current.get('enabled')) else 0,
|
||||
@@ -197,6 +199,8 @@ def save_settings(profile_id: int, data: dict[str, Any], user_id: int | None = N
|
||||
'start_grace_seconds': _int_setting(data, current, 'start_grace_seconds', 900, 0),
|
||||
# Note: When below the target cap, prefer refilling first instead of reducing active slots by stopping stalled downloads.
|
||||
'protect_active_below_cap': 1 if data.get('protect_active_below_cap', current.get('protect_active_below_cap', 1)) else 0,
|
||||
# Note: Prefer partially downloaded stopped torrents so Smart Queue finishes existing work before opening fresh downloads.
|
||||
'prefer_partial_progress': 1 if data.get('prefer_partial_progress', current.get('prefer_partial_progress', 1)) else 0,
|
||||
# Note: Optional safety valve that disables Smart Queue when there are no active or waiting downloads to manage.
|
||||
'auto_stop_idle': 1 if data.get('auto_stop_idle', current.get('auto_stop_idle', 0)) else 0,
|
||||
}
|
||||
@@ -211,12 +215,16 @@ def save_settings(profile_id: int, data: dict[str, Any], user_id: int | None = N
|
||||
# Note: Refill can be disabled, use the existing poller cadence, or run on a user-defined minute interval.
|
||||
settings['refill_enabled'] = 0 if refill_mode == 'off' else 1
|
||||
settings['refill_interval_minutes'] = _int_setting(data, current, 'refill_interval_minutes', 5, 1) if refill_mode == 'custom' else 0
|
||||
# Note: Surge refill is a separate periodic over-cap starter; it never changes the normal target limit.
|
||||
settings['surge_refill_enabled'] = 1 if data.get('surge_refill_enabled', current.get('surge_refill_enabled', 0)) else 0
|
||||
settings['surge_refill_interval_minutes'] = _int_setting(data, current, 'surge_refill_interval_minutes', 1440, 1)
|
||||
settings['surge_refill_batch_size'] = _int_setting(data, current, 'surge_refill_batch_size', 2000, 1)
|
||||
now = utcnow()
|
||||
with connect() as conn:
|
||||
conn.execute(
|
||||
'''INSERT INTO smart_queue_settings(user_id,profile_id,enabled,max_active_downloads,stalled_seconds,min_speed_bytes,min_seeds,min_peers,ignore_seed_peer,ignore_speed,manage_stopped,cooldown_minutes,stop_batch_size,start_grace_seconds,protect_active_below_cap,auto_stop_idle,refill_enabled,refill_interval_minutes,updated_at)
|
||||
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
|
||||
ON CONFLICT(user_id, profile_id) DO UPDATE SET
|
||||
'''INSERT INTO smart_queue_settings(profile_id,enabled,max_active_downloads,stalled_seconds,min_speed_bytes,min_seeds,min_peers,ignore_seed_peer,ignore_speed,manage_stopped,cooldown_minutes,stop_batch_size,start_grace_seconds,protect_active_below_cap,prefer_partial_progress,auto_stop_idle,refill_enabled,refill_interval_minutes,surge_refill_enabled,surge_refill_interval_minutes,surge_refill_batch_size,updated_at)
|
||||
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
|
||||
ON CONFLICT(profile_id) DO UPDATE SET
|
||||
enabled=excluded.enabled,
|
||||
max_active_downloads=excluded.max_active_downloads,
|
||||
stalled_seconds=excluded.stalled_seconds,
|
||||
@@ -230,84 +238,82 @@ def save_settings(profile_id: int, data: dict[str, Any], user_id: int | None = N
|
||||
stop_batch_size=excluded.stop_batch_size,
|
||||
start_grace_seconds=excluded.start_grace_seconds,
|
||||
protect_active_below_cap=excluded.protect_active_below_cap,
|
||||
prefer_partial_progress=excluded.prefer_partial_progress,
|
||||
auto_stop_idle=excluded.auto_stop_idle,
|
||||
refill_enabled=excluded.refill_enabled,
|
||||
refill_interval_minutes=excluded.refill_interval_minutes,
|
||||
surge_refill_enabled=excluded.surge_refill_enabled,
|
||||
surge_refill_interval_minutes=excluded.surge_refill_interval_minutes,
|
||||
surge_refill_batch_size=excluded.surge_refill_batch_size,
|
||||
updated_at=excluded.updated_at''',
|
||||
(user_id, profile_id, settings['enabled'], settings['max_active_downloads'], settings['stalled_seconds'], settings['min_speed_bytes'], settings['min_seeds'], settings['min_peers'], settings['ignore_seed_peer'], settings['ignore_speed'], settings['manage_stopped'], settings['cooldown_minutes'], settings['stop_batch_size'], settings['start_grace_seconds'], settings['protect_active_below_cap'], settings['auto_stop_idle'], settings['refill_enabled'], settings['refill_interval_minutes'], now),
|
||||
(profile_id, settings['enabled'], settings['max_active_downloads'], settings['stalled_seconds'], settings['min_speed_bytes'], settings['min_seeds'], settings['min_peers'], settings['ignore_seed_peer'], settings['ignore_speed'], settings['manage_stopped'], settings['cooldown_minutes'], settings['stop_batch_size'], settings['start_grace_seconds'], settings['protect_active_below_cap'], settings['prefer_partial_progress'], settings['auto_stop_idle'], settings['refill_enabled'], settings['refill_interval_minutes'], settings['surge_refill_enabled'], settings['surge_refill_interval_minutes'], settings['surge_refill_batch_size'], now),
|
||||
)
|
||||
return get_settings(profile_id, user_id)
|
||||
|
||||
|
||||
def list_exclusions(profile_id: int, user_id: int | None = None) -> list[dict[str, Any]]:
|
||||
user_id = user_id or default_user_id()
|
||||
with connect() as conn:
|
||||
return conn.execute(
|
||||
'SELECT * FROM smart_queue_exclusions WHERE user_id=? AND profile_id=? ORDER BY created_at DESC',
|
||||
(user_id, profile_id),
|
||||
'SELECT * FROM smart_queue_exclusions WHERE profile_id=? ORDER BY created_at DESC',
|
||||
(profile_id,),
|
||||
).fetchall()
|
||||
|
||||
|
||||
def set_exclusion(profile_id: int, torrent_hash: str, excluded: bool, reason: str = '', user_id: int | None = None) -> None:
|
||||
user_id = user_id or default_user_id()
|
||||
now = utcnow()
|
||||
with connect() as conn:
|
||||
if excluded:
|
||||
conn.execute(
|
||||
'INSERT OR REPLACE INTO smart_queue_exclusions(user_id,profile_id,torrent_hash,reason,created_at) VALUES(?,?,?,?,?)',
|
||||
(user_id, profile_id, torrent_hash, reason, now),
|
||||
'INSERT OR REPLACE INTO smart_queue_exclusions(profile_id,torrent_hash,reason,created_at) VALUES(?,?,?,?)',
|
||||
(profile_id, torrent_hash, reason, now),
|
||||
)
|
||||
else:
|
||||
conn.execute(
|
||||
'DELETE FROM smart_queue_exclusions WHERE user_id=? AND profile_id=? AND torrent_hash=?',
|
||||
(user_id, profile_id, torrent_hash),
|
||||
'DELETE FROM smart_queue_exclusions WHERE profile_id=? AND torrent_hash=?',
|
||||
(profile_id, torrent_hash),
|
||||
)
|
||||
|
||||
|
||||
|
||||
def add_history(profile_id: int, event: str, paused: list[str] | None = None, resumed: list[str] | None = None, checked: int = 0, details: dict[str, Any] | None = None, user_id: int | None = None) -> None:
|
||||
user_id = user_id or default_user_id()
|
||||
paused = paused or []
|
||||
resumed = resumed or []
|
||||
details = details or {}
|
||||
with connect() as conn:
|
||||
conn.execute(
|
||||
'INSERT INTO smart_queue_history(user_id,profile_id,event,paused_count,resumed_count,checked_count,details_json,created_at) VALUES(?,?,?,?,?,?,?,?)',
|
||||
(user_id, profile_id, event, len(paused), len(resumed), int(checked or 0), json.dumps({**details, 'paused': paused, 'resumed': resumed}), utcnow()),
|
||||
'INSERT INTO smart_queue_history(profile_id,event,paused_count,resumed_count,checked_count,details_json,created_at) VALUES(?,?,?,?,?,?,?)',
|
||||
(profile_id, event, len(paused), len(resumed), int(checked or 0), json.dumps({**details, 'paused': paused, 'resumed': resumed}), utcnow()),
|
||||
)
|
||||
|
||||
def list_history(profile_id: int, user_id: int | None = None, limit: int = 30) -> list[dict[str, Any]]:
|
||||
user_id = user_id or default_user_id()
|
||||
with connect() as conn:
|
||||
return conn.execute(
|
||||
'SELECT * FROM smart_queue_history WHERE user_id=? AND profile_id=? ORDER BY created_at DESC LIMIT ?',
|
||||
(user_id, profile_id, max(1, min(int(limit or 30), 100))),
|
||||
'SELECT * FROM smart_queue_history WHERE profile_id=? ORDER BY created_at DESC LIMIT ?',
|
||||
(profile_id, max(1, min(int(limit or 30), 100))),
|
||||
).fetchall()
|
||||
|
||||
|
||||
def clear_history(profile_id: int, user_id: int | None = None) -> int:
|
||||
"""Delete Smart Queue history rows for the current profile and return the removed count."""
|
||||
# Note: Manual cleanup only removes audit history; settings, exclusions and pending queue state stay untouched.
|
||||
user_id = user_id or default_user_id()
|
||||
with connect() as conn:
|
||||
row = conn.execute(
|
||||
'SELECT COUNT(*) AS count FROM smart_queue_history WHERE user_id=? AND profile_id=?',
|
||||
(user_id, profile_id),
|
||||
'SELECT COUNT(*) AS count FROM smart_queue_history WHERE profile_id=?',
|
||||
(profile_id,),
|
||||
).fetchone()
|
||||
count = int((row or {}).get('count') or 0)
|
||||
conn.execute(
|
||||
'DELETE FROM smart_queue_history WHERE user_id=? AND profile_id=?',
|
||||
(user_id, profile_id),
|
||||
'DELETE FROM smart_queue_history WHERE profile_id=?',
|
||||
(profile_id,),
|
||||
)
|
||||
return count
|
||||
|
||||
|
||||
def count_history(profile_id: int, user_id: int | None = None) -> int:
|
||||
user_id = user_id or default_user_id()
|
||||
with connect() as conn:
|
||||
row = conn.execute(
|
||||
'SELECT COUNT(*) AS count FROM smart_queue_history WHERE user_id=? AND profile_id=?',
|
||||
(user_id, profile_id),
|
||||
'SELECT COUNT(*) AS count FROM smart_queue_history WHERE profile_id=?',
|
||||
(profile_id,),
|
||||
).fetchone()
|
||||
return int((row or {}).get('count') or 0)
|
||||
|
||||
@@ -315,11 +321,10 @@ def count_history(profile_id: int, user_id: int | None = None) -> int:
|
||||
def _latest_history_event(profile_id: int, user_id: int | None = None) -> str:
|
||||
"""Return the newest Smart Queue history event for duplicate suppression."""
|
||||
# Note: Disabled Smart Queue should leave one waiting marker, not a poller-generated log stream.
|
||||
user_id = user_id or default_user_id()
|
||||
with connect() as conn:
|
||||
row = conn.execute(
|
||||
'SELECT event FROM smart_queue_history WHERE user_id=? AND profile_id=? ORDER BY created_at DESC LIMIT 1',
|
||||
(user_id, profile_id),
|
||||
'SELECT event FROM smart_queue_history WHERE profile_id=? ORDER BY created_at DESC LIMIT 1',
|
||||
(profile_id,),
|
||||
).fetchone()
|
||||
return str((row or {}).get('event') or '')
|
||||
|
||||
@@ -338,8 +343,8 @@ def _record_disabled_waiting_once(profile_id: int, user_id: int, details: dict[s
|
||||
return True
|
||||
|
||||
|
||||
def _excluded_hashes(profile_id: int, user_id: int) -> set[str]:
|
||||
return {r['torrent_hash'] for r in list_exclusions(profile_id, user_id)}
|
||||
def _excluded_hashes(profile_id: int, user_id: int | None = None) -> set[str]:
|
||||
return {r['torrent_hash'] for r in list_exclusions(profile_id)}
|
||||
|
||||
|
||||
|
||||
@@ -384,9 +389,8 @@ def _smart_queue_label_cleanup_value(live_label: str | None, previous_label: str
|
||||
|
||||
|
||||
def _has_stalled_label(value: str | None) -> bool:
|
||||
# Note: Stalled is treated case-insensitively so manually edited labels still block Smart Queue.
|
||||
target = SMART_QUEUE_STALLED_LABEL.casefold()
|
||||
return any(label.casefold() == target for label in _label_names(value))
|
||||
# Note: Stalled is an exact technical label; lower-case variants are normal user labels.
|
||||
return SMART_QUEUE_STALLED_LABEL in _label_names(value)
|
||||
|
||||
|
||||
def _without_queue_technical_labels(value: str | None) -> str:
|
||||
@@ -396,7 +400,7 @@ def _without_queue_technical_labels(value: str | None) -> str:
|
||||
def _ensure_stalled_label(client: Any, torrent_hash: str, current_label: str = '') -> bool:
|
||||
labels = [label for label in _label_names(current_label) if label != SMART_QUEUE_LABEL]
|
||||
changed = False
|
||||
if not any(label.casefold() == SMART_QUEUE_STALLED_LABEL.casefold() for label in labels):
|
||||
if SMART_QUEUE_STALLED_LABEL not in labels:
|
||||
labels.append(SMART_QUEUE_STALLED_LABEL)
|
||||
changed = True
|
||||
if SMART_QUEUE_LABEL in _label_names(current_label):
|
||||
@@ -410,6 +414,27 @@ def _ensure_stalled_label(client: Any, torrent_hash: str, current_label: str = '
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _without_stalled_label(value: str | None) -> str:
|
||||
"""Return labels without Smart Queue's Stalled marker."""
|
||||
# Note: This keeps user labels intact while clearing only the automatic stalled state.
|
||||
return _label_value([label for label in _label_names(value) if label != SMART_QUEUE_STALLED_LABEL])
|
||||
|
||||
|
||||
def _clear_stalled_label(client: Any, torrent_hash: str, current_label: str = '') -> bool:
|
||||
"""Remove the Stalled marker from a torrent that is active again."""
|
||||
labels = _label_names(current_label)
|
||||
if SMART_QUEUE_STALLED_LABEL not in labels:
|
||||
return False
|
||||
try:
|
||||
# Note: Active downloads must not keep the Stalled marker after they resume transferring.
|
||||
client.call('d.custom1.set', torrent_hash, _without_stalled_label(current_label))
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
|
||||
def _remember_auto_label(profile_id: int, torrent_hash: str, previous_label: str) -> None:
|
||||
now = utcnow()
|
||||
with connect() as conn:
|
||||
@@ -807,12 +832,25 @@ def _is_running_download_slot(t: dict[str, Any]) -> bool:
|
||||
"""Return True for incomplete torrents that already occupy a Smart Queue slot."""
|
||||
# Note: Do not exclude Smart Queue/Stalled labels here. Manual Start can leave old labels,
|
||||
# and those torrents still must count toward the global Smart Queue limit.
|
||||
return _is_started_download_slot(t)
|
||||
return _is_started_download_slot(t) and not _is_user_paused(t)
|
||||
|
||||
|
||||
def _is_stalled_download(t: dict[str, Any], min_speed: int, min_seeds: int, min_peers: int, ignore_seed_peer: bool, ignore_speed: bool) -> bool:
|
||||
def _has_recent_transfer_activity(t: dict[str, Any], stalled_seconds: int) -> bool:
|
||||
"""Return True when a torrent is currently transferring or was active within the stalled window."""
|
||||
# Note: Live transfer rates always protect a torrent from being marked as stalled.
|
||||
if int(t.get('down_rate') or 0) > 0 or int(t.get('up_rate') or 0) > 0:
|
||||
return True
|
||||
last_activity = int(t.get('last_activity') or 0)
|
||||
if last_activity <= 0:
|
||||
return False
|
||||
return time.time() - last_activity < max(1, int(stalled_seconds or 0))
|
||||
|
||||
|
||||
def _is_stalled_download(t: dict[str, Any], min_speed: int, min_seeds: int, min_peers: int, stalled_seconds: int, ignore_seed_peer: bool, ignore_speed: bool) -> bool:
|
||||
"""Return True when a started torrent should begin or continue the stalled timer."""
|
||||
# Note: Each ignore switch removes only its own criterion; the stalled timer still applies after criteria match.
|
||||
# Note: Recent transfer activity wins over ignored source/speed criteria, preventing active torrents from being stopped as stalled.
|
||||
if _has_recent_transfer_activity(t, stalled_seconds):
|
||||
return False
|
||||
speed_ok = True if ignore_speed else int(t.get('down_rate') or 0) <= max(0, int(min_speed or 0))
|
||||
source_ok = True if ignore_seed_peer else int(t.get('seeds') or 0) <= max(0, int(min_seeds or 0)) and (min_peers <= 0 or int(t.get('peers') or 0) <= min_peers)
|
||||
return speed_ok and source_ok
|
||||
@@ -820,13 +858,15 @@ def _is_stalled_download(t: dict[str, Any], min_speed: int, min_seeds: int, min_
|
||||
|
||||
def _stalled_timer_key(min_speed: int, min_seeds: int, min_peers: int, stalled_seconds: int, ignore_seed_peer: bool, ignore_speed: bool) -> str:
|
||||
"""Return a stable key for the stalled rules that started the current timer."""
|
||||
# Note: Changing ignore switches or thresholds restarts existing stalled timers instead of reusing old rows.
|
||||
return f"v4|speed={int(min_speed or 0)}|seeds={int(min_seeds or 0)}|peers={int(min_peers or 0)}|seconds={int(stalled_seconds or 0)}|ignore_sources={int(bool(ignore_seed_peer))}|ignore_speed={int(bool(ignore_speed))}"
|
||||
# Note: Version bump clears old timers created by the previous ignore-speed/source behavior.
|
||||
return f"v5|speed={int(min_speed or 0)}|seeds={int(min_seeds or 0)}|peers={int(min_peers or 0)}|seconds={int(stalled_seconds or 0)}|ignore_sources={int(bool(ignore_seed_peer))}|ignore_speed={int(bool(ignore_speed))}"
|
||||
|
||||
|
||||
def _is_low_activity_download(t: dict[str, Any], min_speed: int, min_seeds: int, min_peers: int, ignore_seed_peer: bool = False, ignore_speed: bool = False) -> bool:
|
||||
def _is_low_activity_download(t: dict[str, Any], min_speed: int, min_seeds: int, min_peers: int, stalled_seconds: int, ignore_seed_peer: bool = False, ignore_speed: bool = False) -> bool:
|
||||
"""Return True when a started torrent is weak and should be stopped first."""
|
||||
# Note: Stop priority uses only criteria that are not ignored, so disabled criteria cannot stop torrents earlier.
|
||||
# Note: Active transfers are never preferred for cleanup while non-transferring rows are available.
|
||||
if _has_recent_transfer_activity(t, stalled_seconds):
|
||||
return False
|
||||
low_speed = False if ignore_speed else int(t.get('down_rate') or 0) <= max(0, int(min_speed or 0))
|
||||
low_seeds = False if ignore_seed_peer else int(t.get('seeds') or 0) <= max(0, int(min_seeds or 0))
|
||||
low_peers = False if ignore_seed_peer or min_peers <= 0 else int(t.get('peers') or 0) <= max(0, int(min_peers or 0))
|
||||
@@ -852,6 +892,28 @@ def _is_waiting_download_candidate(t: dict[str, Any], manage_stopped: bool) -> b
|
||||
|
||||
|
||||
|
||||
def _progress_value(torrent: dict[str, Any]) -> float:
|
||||
"""Return a safe 0-100 progress value for queue ranking."""
|
||||
try:
|
||||
value = float(torrent.get('progress') or 0)
|
||||
except (TypeError, ValueError):
|
||||
return 0.0
|
||||
return max(0.0, min(100.0, value))
|
||||
|
||||
|
||||
def _start_candidate_sort_key(torrent: dict[str, Any], prefer_partial_progress: bool) -> tuple[float, float, int, int, int]:
|
||||
"""Rank stopped downloads for starting; partial progress can win so work is finished first."""
|
||||
progress = _progress_value(torrent)
|
||||
# Note: Existing partial downloads are preferred by default, then higher progress, then better source counts.
|
||||
partial_rank = 1.0 if prefer_partial_progress and 0.0 < progress < 100.0 else 0.0
|
||||
return (
|
||||
partial_rank,
|
||||
progress if prefer_partial_progress else 0.0,
|
||||
int(torrent.get('seeds') or 0),
|
||||
int(torrent.get('peers') or 0),
|
||||
int(torrent.get('down_rate') or 0),
|
||||
)
|
||||
|
||||
def _split_start_candidates(torrents: list[dict[str, Any]]) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
|
||||
"""Return all stopped torrents as start candidates without relying on stale source counts."""
|
||||
# Note: rTorrent/tracker source counts can be missing before announce, so start decisions are not filtered by seeds or peers.
|
||||
@@ -891,7 +953,7 @@ def _refill_mode(settings: dict[str, Any]) -> str:
|
||||
def _mark_refill_run(profile_id: int, user_id: int) -> None:
|
||||
# Note: Custom refill interval is measured from the last lightweight refill attempt.
|
||||
with connect() as conn:
|
||||
conn.execute('UPDATE smart_queue_settings SET last_refill_at=?, updated_at=? WHERE user_id=? AND profile_id=?', (utcnow(), utcnow(), user_id, profile_id))
|
||||
conn.execute('UPDATE smart_queue_settings SET last_refill_at=?, updated_at=? WHERE profile_id=?', (utcnow(), utcnow(), profile_id))
|
||||
|
||||
|
||||
def _refill_underfilled_queue(profile: dict, settings: dict[str, Any], profile_id: int, user_id: int) -> dict[str, Any]:
|
||||
@@ -942,9 +1004,10 @@ def _refill_underfilled_queue(profile: dict, settings: dict[str, Any], profile_i
|
||||
return _disable_when_idle(profile_id, user_id, torrents, idle_details)
|
||||
available_slots = max(0, max_active - len(downloading))
|
||||
startable_stopped, source_skipped = _split_start_candidates(stopped)
|
||||
prefer_partial_progress = bool(int(settings.get('prefer_partial_progress', 1) or 0))
|
||||
candidates = sorted(
|
||||
startable_stopped,
|
||||
key=lambda t: (int(t.get('seeds') or 0), int(t.get('peers') or 0), int(t.get('down_rate') or 0)),
|
||||
key=lambda t: _start_candidate_sort_key(t, prefer_partial_progress),
|
||||
reverse=True,
|
||||
)
|
||||
c = rtorrent.client_for(profile)
|
||||
@@ -987,13 +1050,37 @@ def _refill_underfilled_queue(profile: dict, settings: dict[str, Any], profile_i
|
||||
| {str(t.get('hash') or '') for t in stopped if _has_smart_queue_label(str(t.get('label') or '')) and str(t.get('hash') or '') not in set(started_by_queue)}
|
||||
)
|
||||
restored = _cleanup_auto_labels(c, profile_id, torrents, keep_labels, True)
|
||||
# Note: Cooldown refill uses started incomplete torrents as queue slots. This diagnostic
|
||||
# explains why a refill may legitimately start nothing even when only a few torrents transfer data.
|
||||
active_transferring = sum(1 for t in downloading if int(t.get('down_rate') or 0) > 0 or int(t.get('up_rate') or 0) > 0)
|
||||
active_rtorrent = sum(1 for t in downloading if int(t.get('active') or 0))
|
||||
active_state = sum(1 for t in downloading if int(t.get('state') or 0))
|
||||
active_after_expected = len(downloading) + len(start_requested)
|
||||
if available_slots <= 0:
|
||||
refill_decision = f'Cooldown refill skipped: active slots at limit ({len(downloading)}/{max_active})'
|
||||
refill_blocked_reason = 'active_slots_at_limit'
|
||||
elif not candidates:
|
||||
refill_decision = 'Cooldown refill skipped: no stopped candidates available'
|
||||
refill_blocked_reason = 'no_candidates'
|
||||
elif start_requested:
|
||||
refill_decision = f'Cooldown refill requested {len(start_requested)} start(s)'
|
||||
refill_blocked_reason = ''
|
||||
else:
|
||||
refill_decision = 'Cooldown refill ran but rTorrent did not confirm new starts yet'
|
||||
refill_blocked_reason = 'start_not_confirmed'
|
||||
details = {
|
||||
'decision': refill_decision,
|
||||
'blocked_reason': refill_blocked_reason,
|
||||
'enabled': bool(settings.get('enabled')),
|
||||
'cooldown_refill': True,
|
||||
'cooldown_respected': True,
|
||||
'refill_mode': _refill_mode(settings),
|
||||
'refill_interval_minutes': int(settings.get('refill_interval_minutes') or 0),
|
||||
'active_before': len(downloading),
|
||||
'active_after_expected': active_after_expected,
|
||||
'active_transferring_count': active_transferring,
|
||||
'active_rtorrent_count': active_rtorrent,
|
||||
'active_state_count': active_state,
|
||||
'available_slots': available_slots,
|
||||
'candidates': len(candidates),
|
||||
'start_source_skipped': len(source_skipped),
|
||||
@@ -1012,6 +1099,7 @@ def _refill_underfilled_queue(profile: dict, settings: dict[str, Any], profile_i
|
||||
'labels_failed': label_failed,
|
||||
'labels_restored': restored,
|
||||
'max_active_downloads': max_active,
|
||||
'prefer_partial_progress': prefer_partial_progress,
|
||||
'excluded': len(user_excluded),
|
||||
'excluded_stalled': len(stalled_label_hashes),
|
||||
}
|
||||
@@ -1024,6 +1112,10 @@ def _refill_underfilled_queue(profile: dict, settings: dict[str, Any], profile_i
|
||||
'max_active_downloads': max_active,
|
||||
'available_slots': available_slots,
|
||||
'candidates': len(candidates),
|
||||
'active_transferring': active_transferring,
|
||||
'active_rtorrent': active_rtorrent,
|
||||
'active_state': active_state,
|
||||
'blocked_reason': refill_blocked_reason,
|
||||
'start_source_skipped': len(source_skipped),
|
||||
'requested': len(start_requested),
|
||||
'verified': len(active_verified),
|
||||
@@ -1040,6 +1132,7 @@ def _refill_underfilled_queue(profile: dict, settings: dict[str, Any], profile_i
|
||||
'refill_interval_minutes': int(settings.get('refill_interval_minutes') or 0),
|
||||
'min_seeds': min_seeds,
|
||||
'min_peers': min_peers,
|
||||
'prefer_partial_progress': prefer_partial_progress,
|
||||
},
|
||||
'to_start': _diagnostics_torrents(to_start),
|
||||
'to_label_waiting': _diagnostics_torrents(to_label_waiting),
|
||||
@@ -1079,24 +1172,256 @@ def _refill_underfilled_queue(profile: dict, settings: dict[str, Any], profile_i
|
||||
'start_pending_confirmation': start_pending_confirmation,
|
||||
'active_verified': active_verified,
|
||||
'active_before': len(downloading),
|
||||
'active_after_expected': len(downloading) + len(started_by_queue),
|
||||
'active_after_expected': active_after_expected,
|
||||
'active_transferring_count': active_transferring,
|
||||
'active_rtorrent_count': active_rtorrent,
|
||||
'active_state_count': active_state,
|
||||
'blocked_reason': refill_blocked_reason,
|
||||
'available_slots': available_slots,
|
||||
'start_source_skipped': len(source_skipped),
|
||||
'checked': len(torrents),
|
||||
'excluded': len(user_excluded),
|
||||
'rtorrent_cap': rtorrent_cap,
|
||||
'settings': settings,
|
||||
}
|
||||
|
||||
|
||||
def surge_refill_remaining(settings: dict[str, Any]) -> int:
|
||||
"""Return seconds until the next over-cap Surge refill may run."""
|
||||
# Note: Surge refill has its own timer because it intentionally starts more torrents than the normal cap.
|
||||
if not int(settings.get('surge_refill_enabled') or 0):
|
||||
return 0
|
||||
minutes = int(settings.get('surge_refill_interval_minutes') or 0)
|
||||
if minutes <= 0:
|
||||
return 0
|
||||
last = _ts(settings.get('last_surge_refill_at'))
|
||||
if not last:
|
||||
return 0
|
||||
return max(0, int((last + minutes * 60) - time.time()))
|
||||
|
||||
|
||||
def _mark_surge_refill_run(profile_id: int, user_id: int) -> None:
|
||||
# Note: The over-cap refill timer is updated even when no candidates are found, preventing tight retry loops.
|
||||
with connect() as conn:
|
||||
conn.execute('UPDATE smart_queue_settings SET last_surge_refill_at=?, updated_at=? WHERE profile_id=?', (utcnow(), utcnow(), profile_id))
|
||||
|
||||
|
||||
def _surge_refill_over_limit(profile: dict, settings: dict[str, Any], profile_id: int, user_id: int) -> dict[str, Any]:
|
||||
"""Start a large user-defined batch above the Smart Queue cap, then let normal checks drain it."""
|
||||
# Note: Surge refill never raises max_active_downloads; it only overfills once per configured interval.
|
||||
torrents = rtorrent.list_torrents(profile)
|
||||
user_excluded = _excluded_hashes(profile_id, user_id)
|
||||
max_active = max(1, int(settings.get('max_active_downloads') or 5))
|
||||
batch_size = max(1, int(settings.get('surge_refill_batch_size') or 2000))
|
||||
stalled_label_hashes = {str(t.get('hash') or '') for t in torrents if _has_stalled_label(str(t.get('label') or '')) and t.get('hash')}
|
||||
downloading = [
|
||||
t for t in torrents
|
||||
if _is_running_download_slot(t)
|
||||
and str(t.get('hash') or '') not in user_excluded
|
||||
]
|
||||
stopped = [
|
||||
t for t in torrents
|
||||
if str(t.get('hash') or '') not in user_excluded
|
||||
and str(t.get('hash') or '') not in stalled_label_hashes
|
||||
and _is_waiting_download_candidate(t, True)
|
||||
and not _is_running_download_slot(t)
|
||||
]
|
||||
if int(settings.get('auto_stop_idle') or 0) and not downloading and not stopped:
|
||||
idle_details = {
|
||||
'decision': 'Smart Queue auto-stopped during Surge refill: no active or waiting downloads',
|
||||
'enabled': False,
|
||||
'auto_stop_idle': True,
|
||||
'surge_refill': True,
|
||||
'checked': len(torrents),
|
||||
'active_before': 0,
|
||||
'active_after_stop': 0,
|
||||
'active_after_expected': 0,
|
||||
'max_active_downloads': max_active,
|
||||
'surge_refill_batch_size': batch_size,
|
||||
'over_limit': 0,
|
||||
'stopped': [],
|
||||
'started': [],
|
||||
'start_requested': [],
|
||||
'stalled_detected': 0,
|
||||
'stalled_stopped': 0,
|
||||
'protected_stalled': 0,
|
||||
'excluded': len(user_excluded),
|
||||
'excluded_stalled': len(stalled_label_hashes),
|
||||
}
|
||||
_mark_surge_refill_run(profile_id, user_id)
|
||||
_diagnostics_write('smart_queue.surge_refill_idle', {'profile_id': profile_id, 'checked': len(torrents)}, idle_details)
|
||||
return _disable_when_idle(profile_id, user_id, torrents, idle_details)
|
||||
|
||||
startable_stopped, source_skipped = _split_start_candidates(stopped)
|
||||
prefer_partial_progress = bool(int(settings.get('prefer_partial_progress', 1) or 0))
|
||||
candidates = sorted(
|
||||
startable_stopped,
|
||||
key=lambda t: _start_candidate_sort_key(t, prefer_partial_progress),
|
||||
reverse=True,
|
||||
)
|
||||
c = rtorrent.client_for(profile)
|
||||
rtorrent_cap = _ensure_rtorrent_download_cap(c, max(max_active, len(downloading) + batch_size))
|
||||
label_failed: list[str] = []
|
||||
to_start = candidates[:batch_size]
|
||||
to_label_waiting = candidates[batch_size:]
|
||||
|
||||
for t in to_label_waiting:
|
||||
h = str(t.get('hash') or '')
|
||||
if not h:
|
||||
continue
|
||||
try:
|
||||
if not _mark_auto_stopped(c, profile_id, t):
|
||||
label_failed.append(h)
|
||||
except Exception:
|
||||
label_failed.append(h)
|
||||
|
||||
start_summary = _start_and_verify_downloads(c, profile_id, to_start)
|
||||
active_verified = start_summary['active_verified']
|
||||
start_pending_confirmation = start_summary.get('start_pending_confirmation', [])
|
||||
start_failed = start_summary['start_failed']
|
||||
start_requested = start_summary['start_requested']
|
||||
start_results = start_summary['start_results']
|
||||
_record_start_grace(profile_id, start_requested)
|
||||
for h in start_requested:
|
||||
_restore_auto_label(c, profile_id, h, None)
|
||||
try:
|
||||
rtorrent.clear_post_check_download_label(c, h, None)
|
||||
except Exception:
|
||||
label_failed.append(h)
|
||||
|
||||
keep_labels = (
|
||||
{str(t.get('hash') or '') for t in to_label_waiting}
|
||||
| {str(t.get('hash') or '') for t in stopped if _has_smart_queue_label(str(t.get('label') or '')) and str(t.get('hash') or '') not in set(start_requested)}
|
||||
)
|
||||
restored = _cleanup_auto_labels(c, profile_id, torrents, keep_labels, True)
|
||||
active_transferring = sum(1 for t in downloading if int(t.get('down_rate') or 0) > 0 or int(t.get('up_rate') or 0) > 0)
|
||||
active_rtorrent = sum(1 for t in downloading if int(t.get('active') or 0))
|
||||
active_state = sum(1 for t in downloading if int(t.get('state') or 0))
|
||||
active_after_expected = len(downloading) + len(start_requested)
|
||||
over_limit_expected = max(0, active_after_expected - max_active)
|
||||
if start_requested:
|
||||
decision = f'Surge refill requested {len(start_requested)} over-cap start(s); normal checks will drain overflow'
|
||||
blocked_reason = ''
|
||||
elif not candidates:
|
||||
decision = 'Surge refill skipped: no stopped candidates available'
|
||||
blocked_reason = 'no_candidates'
|
||||
else:
|
||||
decision = 'Surge refill ran but rTorrent did not confirm new starts yet'
|
||||
blocked_reason = 'start_not_confirmed'
|
||||
details = {
|
||||
'decision': decision,
|
||||
'blocked_reason': blocked_reason,
|
||||
'enabled': bool(settings.get('enabled')),
|
||||
'surge_refill': True,
|
||||
'surge_refill_interval_minutes': int(settings.get('surge_refill_interval_minutes') or 0),
|
||||
'surge_refill_batch_size': batch_size,
|
||||
'active_before': len(downloading),
|
||||
'active_after_expected': active_after_expected,
|
||||
'active_transferring_count': active_transferring,
|
||||
'active_rtorrent_count': active_rtorrent,
|
||||
'active_state_count': active_state,
|
||||
'max_active_downloads': max_active,
|
||||
'over_limit': over_limit_expected,
|
||||
'candidates': len(candidates),
|
||||
'started_planned': len(to_start),
|
||||
'waiting_labeled': len(to_label_waiting),
|
||||
'start_requested': start_requested,
|
||||
'start_results': start_results,
|
||||
'active_verified_count': len(active_verified),
|
||||
'pending_confirmation_count': len(start_pending_confirmation),
|
||||
'start_pending_confirmation': start_pending_confirmation,
|
||||
'start_failed': start_failed,
|
||||
'labels_failed': label_failed,
|
||||
'labels_restored': restored,
|
||||
'start_source_skipped': len(source_skipped),
|
||||
'rtorrent_cap_updated': bool(rtorrent_cap.get('updated')),
|
||||
'rtorrent_cap': rtorrent_cap,
|
||||
'excluded': len(user_excluded),
|
||||
'excluded_stalled': len(stalled_label_hashes),
|
||||
}
|
||||
_diagnostics_write(
|
||||
'smart_queue.surge_refill',
|
||||
{
|
||||
'profile_id': profile_id,
|
||||
'checked': len(torrents),
|
||||
'active_before': len(downloading),
|
||||
'active_after_expected': active_after_expected,
|
||||
'max_active_downloads': max_active,
|
||||
'over_limit': over_limit_expected,
|
||||
'batch_size': batch_size,
|
||||
'candidates': len(candidates),
|
||||
'requested': len(start_requested),
|
||||
'verified': len(active_verified),
|
||||
'pending': len(start_pending_confirmation),
|
||||
'start_failed': len(start_failed),
|
||||
'waiting_labeled': len(to_label_waiting),
|
||||
'blocked_reason': blocked_reason,
|
||||
'rtorrent_cap_updated': bool(rtorrent_cap.get('updated')),
|
||||
},
|
||||
{
|
||||
'rtorrent_cap': rtorrent_cap,
|
||||
'settings': {
|
||||
'surge_refill_interval_minutes': int(settings.get('surge_refill_interval_minutes') or 0),
|
||||
'surge_refill_batch_size': batch_size,
|
||||
'prefer_partial_progress': prefer_partial_progress,
|
||||
},
|
||||
'to_start': _diagnostics_torrents(to_start),
|
||||
'to_label_waiting': _diagnostics_torrents(to_label_waiting),
|
||||
'source_skipped': _diagnostics_torrents(source_skipped),
|
||||
'pending_confirmation': _diagnostics_sample(start_pending_confirmation),
|
||||
'start_failed': _diagnostics_sample(start_failed),
|
||||
'labels_failed': _diagnostics_sample(label_failed),
|
||||
},
|
||||
)
|
||||
_mark_surge_refill_run(profile_id, user_id)
|
||||
add_history(profile_id, 'surge_refill', [], start_requested, len(torrents), details, user_id)
|
||||
settings = get_settings(profile_id, user_id)
|
||||
return {
|
||||
'ok': True,
|
||||
'enabled': bool(settings.get('enabled')),
|
||||
'surge_refill': True,
|
||||
'cooldown_skipped': True,
|
||||
'refill_mode': _refill_mode(settings),
|
||||
'refill_remaining_seconds': refill_remaining(settings),
|
||||
'surge_refill_remaining_seconds': surge_refill_remaining(settings),
|
||||
'paused': [],
|
||||
'resumed': start_requested,
|
||||
'stopped': [],
|
||||
'started': start_requested,
|
||||
'start_requested': start_requested,
|
||||
'start_batch_size': start_summary['start_batch_size'],
|
||||
'start_verify_attempts': start_summary['start_verify_attempts'],
|
||||
'start_verify_delay_seconds': start_summary['start_verify_delay_seconds'],
|
||||
'waiting_labeled': len(to_label_waiting),
|
||||
'labels_restored': restored,
|
||||
'labels_failed': label_failed,
|
||||
'start_failed': start_failed,
|
||||
'start_no_effect': start_summary['start_no_effect'],
|
||||
'start_pending_confirmation': start_pending_confirmation,
|
||||
'active_verified': active_verified,
|
||||
'active_before': len(downloading),
|
||||
'active_after_expected': active_after_expected,
|
||||
'over_limit': over_limit_expected,
|
||||
'active_transferring_count': active_transferring,
|
||||
'active_rtorrent_count': active_rtorrent,
|
||||
'active_state_count': active_state,
|
||||
'blocked_reason': blocked_reason,
|
||||
'start_source_skipped': len(source_skipped),
|
||||
'checked': len(torrents),
|
||||
'excluded': len(user_excluded),
|
||||
'settings': settings,
|
||||
}
|
||||
|
||||
def mark_run(profile_id: int, user_id: int | None = None) -> None:
|
||||
user_id = user_id or default_user_id()
|
||||
with connect() as conn:
|
||||
conn.execute('UPDATE smart_queue_settings SET last_run_at=?, updated_at=? WHERE user_id=? AND profile_id=?', (utcnow(), utcnow(), user_id, profile_id))
|
||||
conn.execute('UPDATE smart_queue_settings SET last_run_at=?, updated_at=? WHERE profile_id=?', (utcnow(), utcnow(), profile_id))
|
||||
|
||||
def _disable_when_idle(profile_id: int, user_id: int, torrents: list[dict[str, Any]], details: dict[str, Any]) -> dict[str, Any]:
|
||||
# Note: Auto-stop is intentionally profile-scoped and only flips the Smart Queue enabled flag; saved thresholds remain intact.
|
||||
now = utcnow()
|
||||
with connect() as conn:
|
||||
conn.execute('UPDATE smart_queue_settings SET enabled=0, last_run_at=?, updated_at=? WHERE user_id=? AND profile_id=?', (now, now, user_id, profile_id))
|
||||
conn.execute('UPDATE smart_queue_settings SET enabled=0, last_run_at=?, updated_at=? WHERE profile_id=?', (now, now, profile_id))
|
||||
add_history(profile_id, 'auto_stopped_idle', [], [], len(torrents), details, user_id)
|
||||
settings = get_settings(profile_id, user_id)
|
||||
return {'ok': True, 'enabled': False, 'auto_stopped_idle': True, 'paused': [], 'resumed': [], 'stopped': [], 'started': [], 'checked': len(torrents), 'settings': settings, 'message': 'Smart Queue stopped because there is no active or waiting work.'}
|
||||
@@ -1109,13 +1434,18 @@ def check(profile: dict | None = None, user_id: int | None = None, force: bool =
|
||||
profile_id = int(profile['id'])
|
||||
settings = get_settings(profile_id, user_id)
|
||||
remaining = cooldown_remaining(settings)
|
||||
if not force and int(settings.get('enabled') or 0) and int(settings.get('surge_refill_enabled') or 0) and not surge_refill_remaining(settings):
|
||||
try:
|
||||
return _surge_refill_over_limit(profile, settings, profile_id, user_id)
|
||||
except Exception as exc:
|
||||
return {'ok': True, 'enabled': True, 'surge_refill': False, 'settings': settings, 'error': str(exc)}
|
||||
if remaining and not force:
|
||||
if int(settings.get('enabled') or 0):
|
||||
refill_wait = refill_remaining(settings)
|
||||
if not int(settings.get('refill_enabled') or 0):
|
||||
return {'ok': True, 'enabled': True, 'cooldown_skipped': True, 'cooldown_refill': False, 'refill_disabled': True, 'cooldown_remaining_seconds': remaining, 'settings': settings}
|
||||
return {'ok': True, 'enabled': True, 'cooldown_skipped': True, 'cooldown_refill': False, 'refill_disabled': True, 'cooldown_remaining_seconds': remaining, 'surge_refill_remaining_seconds': surge_refill_remaining(settings), 'settings': settings}
|
||||
if refill_wait:
|
||||
return {'ok': True, 'enabled': True, 'cooldown_skipped': True, 'cooldown_refill': False, 'refill_wait_seconds': refill_wait, 'cooldown_remaining_seconds': remaining, 'settings': settings}
|
||||
return {'ok': True, 'enabled': True, 'cooldown_skipped': True, 'cooldown_refill': False, 'refill_wait_seconds': refill_wait, 'cooldown_remaining_seconds': remaining, 'surge_refill_remaining_seconds': surge_refill_remaining(settings), 'settings': settings}
|
||||
try:
|
||||
# Note: Cooldown still blocks the full Smart Queue pass, but configured refill may fill free slots safely.
|
||||
refill = _refill_underfilled_queue(profile, settings, profile_id, user_id)
|
||||
@@ -1123,7 +1453,7 @@ def check(profile: dict | None = None, user_id: int | None = None, force: bool =
|
||||
return refill
|
||||
except Exception as exc:
|
||||
return {'ok': True, 'enabled': True, 'cooldown_skipped': True, 'cooldown_refill': False, 'cooldown_remaining_seconds': remaining, 'settings': settings, 'error': str(exc)}
|
||||
return {'ok': True, 'enabled': bool(settings.get('enabled')), 'cooldown_skipped': True, 'cooldown_remaining_seconds': remaining, 'settings': settings}
|
||||
return {'ok': True, 'enabled': bool(settings.get('enabled')), 'cooldown_skipped': True, 'cooldown_remaining_seconds': remaining, 'surge_refill_remaining_seconds': surge_refill_remaining(settings), 'settings': settings}
|
||||
if not force and not int(settings.get('enabled') or 0):
|
||||
restored: list[str] = []
|
||||
try:
|
||||
@@ -1203,6 +1533,9 @@ def check(profile: dict | None = None, user_id: int | None = None, force: bool =
|
||||
ignored_seed_peer_count = 0
|
||||
ignored_speed_count = 0
|
||||
|
||||
snapshot_activity_protected: list[str] = []
|
||||
snapshot_activity_protected_hashes: set[str] = set()
|
||||
|
||||
with connect() as conn:
|
||||
for t in downloading:
|
||||
# Note: Ignore switches keep matching criteria from advancing stalled cleanup while preserving diagnostics.
|
||||
@@ -1210,9 +1543,9 @@ def check(profile: dict | None = None, user_id: int | None = None, force: bool =
|
||||
ignored_seed_peer_count += 1
|
||||
if ignore_speed and int(t.get('down_rate') or 0) <= max(0, int(min_speed or 0)):
|
||||
ignored_speed_count += 1
|
||||
is_stalled = _is_stalled_download(t, min_speed, min_seeds, min_peers, ignore_seed_peer, ignore_speed)
|
||||
is_stalled = _is_stalled_download(t, min_speed, min_seeds, min_peers, stalled_seconds, ignore_seed_peer, ignore_speed)
|
||||
# Note: Hard-limit enforcement uses only non-ignored weak criteria before choosing weak items.
|
||||
if _is_low_activity_download(t, min_speed, min_seeds, min_peers, ignore_seed_peer, ignore_speed):
|
||||
if _is_low_activity_download(t, min_speed, min_seeds, min_peers, stalled_seconds, ignore_seed_peer, ignore_speed):
|
||||
stop_eligible.append(t)
|
||||
h = str(t.get('hash') or '')
|
||||
if not h:
|
||||
@@ -1237,31 +1570,32 @@ def check(profile: dict | None = None, user_id: int | None = None, force: bool =
|
||||
|
||||
# Note: Start candidates are not filtered by seeds/peers because those counts may be stale before announce.
|
||||
startable_stopped, source_skipped = _split_start_candidates(stopped)
|
||||
prefer_partial_progress = bool(int(settings.get('prefer_partial_progress', 1) or 0))
|
||||
candidates = sorted(
|
||||
startable_stopped,
|
||||
key=lambda t: (int(t.get('seeds') or 0), int(t.get('peers') or 0), int(t.get('down_rate') or 0)),
|
||||
key=lambda t: _start_candidate_sort_key(t, prefer_partial_progress),
|
||||
reverse=True,
|
||||
)
|
||||
max_active = max(1, int(settings.get('max_active_downloads') or 5))
|
||||
stalled_hashes = {str(t.get('hash') or '') for t in stalled}
|
||||
|
||||
# Enforce the hard active-download cap across the whole started queue, including manual starts.
|
||||
# Note: Weak/no-source torrents are stopped first, but the cap is still enforced when the overflow is larger.
|
||||
# Enforce the active-download cap using only torrents that the current snapshot already proves idle/weak.
|
||||
# Note: A transferring or recently active torrent is never stopped just because the cap is exceeded.
|
||||
over_limit = max(0, len(downloading) - max_active)
|
||||
stop_eligible_hashes = {str(t.get('hash') or '') for t in stop_eligible}
|
||||
stop_rank = sorted(
|
||||
downloading,
|
||||
stop_eligible,
|
||||
key=lambda t: (
|
||||
0 if str(t.get('hash') or '') in stalled_hashes else 1,
|
||||
0 if str(t.get('hash') or '') in stop_eligible_hashes else 1,
|
||||
int(t.get('down_rate') or 0),
|
||||
int(t.get('seeds') or 0),
|
||||
int(t.get('peers') or 0),
|
||||
),
|
||||
)
|
||||
capped_over_limit = min(over_limit, len(stop_rank))
|
||||
# Note: The user-defined batch limit caps all automatic stops in one pass.
|
||||
# Hard cap overflow is handled first, then stalled replacement uses only proven spare candidate capacity.
|
||||
to_stop: list[dict[str, Any]] = stop_rank[:min(over_limit, stop_batch_size)]
|
||||
to_stop: list[dict[str, Any]] = stop_rank[:min(capped_over_limit, stop_batch_size)]
|
||||
stop_hashes = {str(t.get('hash') or '') for t in to_stop}
|
||||
remaining_stop_budget = max(0, stop_batch_size - len(to_stop))
|
||||
free_slots_before_stop = max(0, max_active - len(downloading))
|
||||
@@ -1284,6 +1618,17 @@ def check(profile: dict | None = None, user_id: int | None = None, force: bool =
|
||||
|
||||
c = rtorrent.client_for(profile)
|
||||
rtorrent_cap = _ensure_rtorrent_download_cap(c, max_active)
|
||||
for t in downloading:
|
||||
h = str(t.get('hash') or '')
|
||||
if not h or not _has_stalled_label(str(t.get('label') or '')):
|
||||
continue
|
||||
if _has_recent_transfer_activity(t, stalled_seconds):
|
||||
# Note: Snapshot activity is enough to remove Stalled; no per-torrent live RPC guard is needed.
|
||||
snapshot_activity_protected.append(h)
|
||||
snapshot_activity_protected_hashes.add(h)
|
||||
_clear_stalled_label(c, h, str(t.get('label') or ''))
|
||||
with connect() as conn:
|
||||
conn.execute('DELETE FROM smart_queue_stalled WHERE profile_id=? AND torrent_hash=?', (profile_id, h))
|
||||
stopped_by_queue: list[str] = []
|
||||
started_by_queue: list[str] = []
|
||||
label_failed: list[str] = []
|
||||
@@ -1297,8 +1642,18 @@ def check(profile: dict | None = None, user_id: int | None = None, force: bool =
|
||||
for t in to_stop:
|
||||
h = str(t.get('hash') or '')
|
||||
try:
|
||||
if not h or h in snapshot_activity_protected_hashes:
|
||||
continue
|
||||
if _has_recent_transfer_activity(t, stalled_seconds):
|
||||
# Note: Snapshot activity wins; active torrents are protected without slow per-item live checks.
|
||||
snapshot_activity_protected.append(h)
|
||||
snapshot_activity_protected_hashes.add(h)
|
||||
_clear_stalled_label(c, h, str(t.get('label') or ''))
|
||||
with connect() as conn:
|
||||
conn.execute('DELETE FROM smart_queue_stalled WHERE profile_id=? AND torrent_hash=?', (profile_id, h))
|
||||
continue
|
||||
# Note: Smart Queue stops with the same low-level d.stop command used by the manual Stop action.
|
||||
# This avoids extra pre-check RPCs and keeps large queues from failing after only a few items.
|
||||
# This avoids extra pre-check RPCs and keeps large queues fast even with many candidates.
|
||||
c.call('d.stop', h)
|
||||
if h in stalled_hashes:
|
||||
if _ensure_stalled_label(c, h, _read_label(c, h, str(t.get('label') or ''))):
|
||||
@@ -1361,10 +1716,12 @@ def check(profile: dict | None = None, user_id: int | None = None, force: bool =
|
||||
'enabled': bool(settings.get('enabled')),
|
||||
'checked': len(torrents),
|
||||
'max_active_downloads': max_active,
|
||||
'prefer_partial_progress': prefer_partial_progress,
|
||||
'active_before': len(downloading),
|
||||
'active_after_stop': active_after_stop,
|
||||
'active_after_expected': active_after_stop + len(started_by_queue),
|
||||
'over_limit': over_limit,
|
||||
'stoppable_over_limit': capped_over_limit,
|
||||
'stopped': stopped_by_queue,
|
||||
'started': started_by_queue,
|
||||
'start_requested': start_requested,
|
||||
@@ -1380,6 +1737,8 @@ def check(profile: dict | None = None, user_id: int | None = None, force: bool =
|
||||
'stalled_stopped_hashes': _hash_sample(stalled_stopped_hashes),
|
||||
'stalled_labeled': stalled_labeled,
|
||||
'protected_stalled': protected_stalled,
|
||||
'snapshot_activity_protected': len(snapshot_activity_protected),
|
||||
'snapshot_activity_protected_hashes': _hash_sample(snapshot_activity_protected),
|
||||
'stalled_replacement_allowed': stalled_replacement_allowed,
|
||||
'excluded': len(user_excluded),
|
||||
'excluded_stalled': len(stalled_label_hashes),
|
||||
@@ -1412,9 +1771,11 @@ def check(profile: dict | None = None, user_id: int | None = None, force: bool =
|
||||
'active_after_expected': active_after_stop + len(started_by_queue),
|
||||
'max_active_downloads': max_active,
|
||||
'over_limit': over_limit,
|
||||
'stoppable_over_limit': capped_over_limit,
|
||||
'stopped': len(stopped_by_queue),
|
||||
'stalled': len(stalled),
|
||||
'protected_stalled': protected_stalled,
|
||||
'snapshot_activity_protected': len(snapshot_activity_protected),
|
||||
'stalled_stopped': len(stalled_stopped_hashes),
|
||||
'stalled_stopped_hashes': _hash_sample(stalled_stopped_hashes, 20),
|
||||
'stop_eligible': len(stop_eligible),
|
||||
@@ -1443,9 +1804,11 @@ def check(profile: dict | None = None, user_id: int | None = None, force: bool =
|
||||
'start_grace_seconds': start_grace_seconds,
|
||||
'protect_active_below_cap': protect_active_below_cap,
|
||||
'auto_stop_idle': bool(int(settings.get('auto_stop_idle') or 0)),
|
||||
'prefer_partial_progress': prefer_partial_progress,
|
||||
},
|
||||
'rtorrent_cap': rtorrent_cap,
|
||||
'to_stop': _diagnostics_torrents(to_stop),
|
||||
'snapshot_activity_protected': _diagnostics_sample(snapshot_activity_protected),
|
||||
'stalled': _diagnostics_torrents(stalled),
|
||||
'stop_eligible': _diagnostics_torrents(stop_eligible),
|
||||
'to_start': _diagnostics_torrents(to_start),
|
||||
@@ -1463,4 +1826,4 @@ def check(profile: dict | None = None, user_id: int | None = None, force: bool =
|
||||
mark_run(profile_id, user_id)
|
||||
settings = get_settings(profile_id, user_id)
|
||||
remaining = cooldown_remaining(settings)
|
||||
return {'ok': True, 'enabled': bool(settings.get('enabled')), 'paused': stopped_by_queue, 'resumed': started_by_queue, 'stopped': stopped_by_queue, 'started': started_by_queue, 'start_requested': start_requested, 'start_batch_size': start_summary['start_batch_size'], 'start_verify_attempts': start_summary['start_verify_attempts'], 'start_verify_delay_seconds': start_summary['start_verify_delay_seconds'], 'waiting_labeled': len(to_label_waiting), 'stalled_labeled': stalled_labeled, 'excluded_stalled': len(stalled_label_hashes), 'manual_labeled_running': len(manual_labeled_running), 'labels_restored': restored, 'labels_failed': label_failed, 'stop_failed': stop_failed, 'start_failed': start_failed, 'start_no_effect': start_no_effect, 'start_pending_confirmation': start_pending_confirmation, 'active_verified': active_verified, 'active_before': len(downloading), 'active_after_stop': active_after_stop, 'over_limit': over_limit, 'stop_eligible': len(stop_eligible), 'start_source_skipped': len(source_skipped), 'ignore_seed_peer': ignore_seed_peer, 'ignore_speed': ignore_speed, 'ignored_seed_peer_count': ignored_seed_peer_count if ignore_seed_peer else 0, 'ignored_speed_count': ignored_speed_count if ignore_speed else 0, 'stalled_seconds': stalled_seconds, 'stalled_timer_key': timer_key, 'stop_batch_size': stop_batch_size, 'start_grace_seconds': start_grace_seconds, 'protect_active_below_cap': protect_active_below_cap, 'auto_stop_idle': bool(int(settings.get('auto_stop_idle') or 0)), 'stalled_replacement_allowed': stalled_replacement_allowed, 'start_grace_protected': len(start_grace_hashes), 'replacement_capacity': replacement_capacity, 'protected_stalled': protected_stalled, 'healthy_active_protected': 0, 'rtorrent_cap': rtorrent_cap, 'checked': len(torrents), 'excluded': len(user_excluded), 'settings': settings, 'cooldown_remaining_seconds': remaining}
|
||||
return {'ok': True, 'enabled': bool(settings.get('enabled')), 'paused': stopped_by_queue, 'resumed': started_by_queue, 'stopped': stopped_by_queue, 'started': started_by_queue, 'start_requested': start_requested, 'start_batch_size': start_summary['start_batch_size'], 'start_verify_attempts': start_summary['start_verify_attempts'], 'start_verify_delay_seconds': start_summary['start_verify_delay_seconds'], 'waiting_labeled': len(to_label_waiting), 'stalled_labeled': stalled_labeled, 'excluded_stalled': len(stalled_label_hashes), 'manual_labeled_running': len(manual_labeled_running), 'labels_restored': restored, 'labels_failed': label_failed, 'stop_failed': stop_failed, 'start_failed': start_failed, 'start_no_effect': start_no_effect, 'start_pending_confirmation': start_pending_confirmation, 'active_verified': active_verified, 'active_before': len(downloading), 'active_after_stop': active_after_stop, 'over_limit': over_limit, 'stoppable_over_limit': capped_over_limit, 'stop_eligible': len(stop_eligible), 'start_source_skipped': len(source_skipped), 'ignore_seed_peer': ignore_seed_peer, 'ignore_speed': ignore_speed, 'ignored_seed_peer_count': ignored_seed_peer_count if ignore_seed_peer else 0, 'ignored_speed_count': ignored_speed_count if ignore_speed else 0, 'stalled_seconds': stalled_seconds, 'stalled_timer_key': timer_key, 'stop_batch_size': stop_batch_size, 'start_grace_seconds': start_grace_seconds, 'protect_active_below_cap': protect_active_below_cap, 'prefer_partial_progress': prefer_partial_progress, 'auto_stop_idle': bool(int(settings.get('auto_stop_idle') or 0)), 'stalled_replacement_allowed': stalled_replacement_allowed, 'start_grace_protected': len(start_grace_hashes), 'replacement_capacity': replacement_capacity, 'protected_stalled': protected_stalled, 'healthy_active_protected': len(snapshot_activity_protected), 'snapshot_activity_protected': snapshot_activity_protected, 'rtorrent_cap': rtorrent_cap, 'checked': len(torrents), 'excluded': len(user_excluded), 'settings': settings, 'cooldown_remaining_seconds': remaining, 'surge_refill_remaining_seconds': surge_refill_remaining(settings)}
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
from typing import Any
|
||||
|
||||
from ..db import connect, utcnow
|
||||
from .rtorrent import human_rate
|
||||
|
||||
|
||||
@@ -1,26 +1,101 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from time import sleep
|
||||
from . import preferences, rtorrent
|
||||
import threading
|
||||
from time import monotonic
|
||||
from ..db import connect
|
||||
from . import operation_logs, rtorrent
|
||||
|
||||
_started = False
|
||||
_start_lock = threading.Lock()
|
||||
_applied_profiles: set[int] = set()
|
||||
_last_status: dict[int, str] = {}
|
||||
|
||||
|
||||
def schedule_startup_config_apply(socketio, delay_seconds: int = 60) -> None:
|
||||
"""Apply saved rTorrent UI overrides after pyTorrent has been running for a moment."""
|
||||
global _started
|
||||
if _started:
|
||||
def _profiles() -> list[dict]:
|
||||
"""Read all configured profiles because startup work has no browser user session."""
|
||||
with connect() as conn:
|
||||
return [dict(row) for row in conn.execute("SELECT * FROM rtorrent_profiles ORDER BY id").fetchall()]
|
||||
|
||||
|
||||
def _log_status(profile: dict, status: str, message: str, *, error: str = "", result: dict | None = None) -> None:
|
||||
"""Write meaningful startup config state changes as system operations."""
|
||||
profile_id = int(profile.get("id") or 0)
|
||||
if status in {"waiting", "skipped"} and _last_status.get(profile_id) == status:
|
||||
return
|
||||
_started = True
|
||||
_last_status[profile_id] = status
|
||||
operation_logs.record(
|
||||
profile_id,
|
||||
"rtorrent_config_startup",
|
||||
message,
|
||||
severity="warning" if error else "info",
|
||||
source="system",
|
||||
action="rtorrent_config",
|
||||
details={"status": status, "error": error, "result": result or {}},
|
||||
user_id=int(profile.get("user_id") or 0) or None,
|
||||
)
|
||||
|
||||
def runner():
|
||||
sleep(max(0, int(delay_seconds)))
|
||||
try:
|
||||
for profile in preferences.list_profiles():
|
||||
result = rtorrent.apply_startup_overrides(profile)
|
||||
if not result.get("skipped"):
|
||||
socketio.emit("rtorrent_config_applied", {"profile_id": profile["id"], "result": result})
|
||||
except Exception as exc:
|
||||
socketio.emit("rtorrent_config_applied", {"ok": False, "error": str(exc)})
|
||||
|
||||
def _rtorrent_ready(profile: dict) -> tuple[bool, str]:
|
||||
"""Check rTorrent before applying saved runtime overrides."""
|
||||
try:
|
||||
rtorrent.client_for(profile).call("system.client_version")
|
||||
return True, ""
|
||||
except Exception as exc:
|
||||
return False, str(exc)
|
||||
|
||||
|
||||
def _apply_profile(socketio, profile: dict) -> None:
|
||||
"""Apply saved config only after the target rTorrent is reachable."""
|
||||
profile_id = int(profile.get("id") or 0)
|
||||
if not profile_id or profile_id in _applied_profiles:
|
||||
return
|
||||
ok, error = _rtorrent_ready(profile)
|
||||
if not ok:
|
||||
_log_status(profile, "waiting", f"rTorrent config apply is waiting for connection: {error}", error=error)
|
||||
return
|
||||
result = rtorrent.apply_startup_overrides(profile)
|
||||
if result.get("skipped"):
|
||||
_applied_profiles.add(profile_id)
|
||||
_log_status(profile, "skipped", "No saved rTorrent startup config overrides to apply", result=result)
|
||||
return
|
||||
_applied_profiles.add(profile_id)
|
||||
_log_status(profile, "applied", "Saved rTorrent startup config overrides applied", result=result)
|
||||
socketio.emit("rtorrent_config_applied", {"profile_id": profile_id, "result": result}, to=f"profile:{int(profile_id)}")
|
||||
|
||||
|
||||
def schedule_startup_config_apply(socketio, delay_seconds: int = 60, retry_seconds: int = 30, max_wait_seconds: int = 3600) -> None:
|
||||
"""Apply saved rTorrent UI overrides after the configured startup delay without requiring a browser."""
|
||||
global _started
|
||||
with _start_lock:
|
||||
if _started:
|
||||
return
|
||||
_started = True
|
||||
|
||||
def runner() -> None:
|
||||
socketio.sleep(max(0, int(delay_seconds)))
|
||||
started_at = monotonic()
|
||||
while True:
|
||||
try:
|
||||
profiles = _profiles()
|
||||
for profile in profiles:
|
||||
_apply_profile(socketio, profile)
|
||||
pending = [int(profile.get("id") or 0) for profile in profiles if int(profile.get("id") or 0) not in _applied_profiles]
|
||||
if not pending or monotonic() - started_at >= max(0, int(max_wait_seconds)):
|
||||
for profile in profiles:
|
||||
profile_id = int(profile.get("id") or 0)
|
||||
if profile_id in pending:
|
||||
_log_status(profile, "timeout", "rTorrent config startup apply stopped waiting for connection", error="startup wait timeout")
|
||||
return
|
||||
except Exception as exc:
|
||||
operation_logs.record(
|
||||
None,
|
||||
"rtorrent_config_startup",
|
||||
f"rTorrent startup config scheduler failed: {exc}",
|
||||
severity="warning",
|
||||
source="system",
|
||||
action="rtorrent_config",
|
||||
details={"error": str(exc)},
|
||||
)
|
||||
socketio.emit("rtorrent_config_applied", {"ok": False, "profile_id": int(profile_id or 0), "error": str(exc)}, to=f"profile:{int(profile_id or 0)}" if profile_id else None)
|
||||
socketio.sleep(max(5, int(retry_seconds)))
|
||||
|
||||
socketio.start_background_task(runner)
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from threading import RLock
|
||||
from time import time
|
||||
from . import rtorrent, operation_logs
|
||||
|
||||
_LIVE_KEYS = {"state", "active", "paused", "complete", "completed_bytes", "progress", "ratio", "up_rate", "up_rate_h", "down_rate", "down_rate_h", "eta_seconds", "eta_h", "up_total", "up_total_h", "down_total", "down_total_h", "to_download", "to_download_h", "peers", "seeds", "message", "status", "post_check", "hashing"}
|
||||
_VOLATILE = {"down_rate", "down_rate_h", "up_rate", "up_rate_h", "progress", "completed_bytes", "peers", "seeds", "ratio", "state", "status", "message", "down_total", "down_total_h", "to_download", "to_download_h", "up_total", "up_total_h"}
|
||||
|
||||
|
||||
@@ -33,6 +33,42 @@ class TorrentCache:
|
||||
self._updated_at.pop(profile_id, None)
|
||||
return removed
|
||||
|
||||
|
||||
def refresh_live(self, profile: dict) -> dict:
|
||||
"""Refresh only volatile live fields without replacing the full cached torrent rows."""
|
||||
# Note: The fast poller uses this lightweight path so speeds/statuses can update often while the full list poller stays slower.
|
||||
profile_id = int(profile["id"])
|
||||
try:
|
||||
rows = rtorrent.list_torrent_live_stats(profile)
|
||||
live = {t["hash"]: t for t in rows if t.get("hash")}
|
||||
with self._lock:
|
||||
old = dict(self._data.get(profile_id, {}))
|
||||
if not old:
|
||||
self._errors[profile_id] = ""
|
||||
return {"ok": True, "profile_id": profile_id, "updated": [], "missing": [], "unknown": list(live.keys()), "requires_full_refresh": bool(live)}
|
||||
updated = []
|
||||
for h, live_row in live.items():
|
||||
current = old.get(h)
|
||||
if not current:
|
||||
continue
|
||||
patch = {"hash": h}
|
||||
for key in _LIVE_KEYS:
|
||||
if key in live_row and current.get(key) != live_row.get(key):
|
||||
patch[key] = live_row.get(key)
|
||||
if len(patch) > 1:
|
||||
current.update({k: v for k, v in patch.items() if k != "hash"})
|
||||
updated.append(patch)
|
||||
missing = [h for h in old.keys() if h not in live]
|
||||
unknown = [h for h in live.keys() if h not in old]
|
||||
self._data[profile_id] = old
|
||||
self._errors[profile_id] = ""
|
||||
self._updated_at[profile_id] = time()
|
||||
return {"ok": True, "profile_id": profile_id, "updated": updated, "missing": missing, "unknown": unknown, "requires_full_refresh": bool(missing or unknown)}
|
||||
except Exception as exc:
|
||||
with self._lock:
|
||||
self._errors[profile_id] = str(exc)
|
||||
return {"ok": False, "profile_id": profile_id, "error": str(exc), "updated": [], "missing": [], "unknown": [], "requires_full_refresh": False}
|
||||
|
||||
def refresh(self, profile: dict) -> dict:
|
||||
profile_id = int(profile["id"])
|
||||
try:
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import os
|
||||
import time
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
from pathlib import PurePosixPath
|
||||
from typing import Any
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import threading
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
from ..db import connect, utcnow
|
||||
from . import rtorrent
|
||||
from .torrent_cache import torrent_cache
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from copy import deepcopy
|
||||
from threading import RLock
|
||||
from time import time
|
||||
@@ -19,7 +18,7 @@ _ERROR_PATTERNS = (
|
||||
"unreachable",
|
||||
"denied",
|
||||
)
|
||||
_SUMMARY_TYPES = ("all", "downloading", "seeding", "paused", "checking", "error", "stopped")
|
||||
_SUMMARY_TYPES = ("all", "downloading", "queued", "seeding", "paused", "checking", "error", "post_check", "stopped")
|
||||
_summary_cache: dict[int, dict] = {}
|
||||
_summary_lock = RLock()
|
||||
|
||||
@@ -46,7 +45,9 @@ def _matches(row: dict, summary_type: str) -> bool:
|
||||
if summary_type == "all":
|
||||
return True
|
||||
if summary_type == "downloading":
|
||||
return not checking and not bool(row.get("complete")) and bool(row.get("state")) and not bool(row.get("paused"))
|
||||
return not checking and not bool(row.get("complete")) and bool(row.get("state")) and not bool(row.get("paused")) and str(row.get("status") or "") != "Queued"
|
||||
if summary_type == "queued":
|
||||
return not checking and (bool(row.get("queued")) or str(row.get("status") or "") == "Queued")
|
||||
if summary_type == "seeding":
|
||||
return not checking and bool(row.get("complete")) and bool(row.get("state")) and not bool(row.get("paused"))
|
||||
if summary_type == "paused":
|
||||
@@ -55,9 +56,12 @@ def _matches(row: dict, summary_type: str) -> bool:
|
||||
return checking
|
||||
if summary_type == "error":
|
||||
return _has_error(row)
|
||||
if summary_type == "post_check":
|
||||
# Note: Post-check is counted separately from Stopped so automation can target it safely.
|
||||
return str(row.get("status") or "") == "Post-check" or bool(row.get("post_check"))
|
||||
if summary_type == "stopped":
|
||||
# Note: Stopped count follows the UI filter exactly, so torrents being hash-checked do not inflate an empty Stopped list.
|
||||
return not checking and not bool(row.get("state"))
|
||||
# Note: Stopped count follows the UI filter exactly and excludes app-level post-check waiting rows.
|
||||
return not checking and not bool(row.get("state")) and str(row.get("status") or "") != "Post-check" and not bool(row.get("post_check"))
|
||||
return False
|
||||
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import mimetypes
|
||||
import re
|
||||
@@ -11,7 +10,6 @@ import urllib.parse
|
||||
import urllib.request
|
||||
from html.parser import HTMLParser
|
||||
from pathlib import Path
|
||||
|
||||
from ..config import BASE_DIR
|
||||
from ..db import connect, utcnow
|
||||
|
||||
@@ -438,3 +436,50 @@ def favicon_path(domain: str, enabled: bool = True, force: bool = False) -> tupl
|
||||
(clean, utcnow(), now, "; ".join(errors[-8:]) or "favicon not found"),
|
||||
)
|
||||
return None, None
|
||||
|
||||
|
||||
def cached_domains_for_profile(profile_id: int, limit: int = 200) -> list[str]:
|
||||
"""Return tracker domains already known for a profile from the summary cache."""
|
||||
# Note: The background favicon worker reads cached summary rows first, so it does not need the browser sidebar to discover domains.
|
||||
domains: list[str] = []
|
||||
seen: set[str] = set()
|
||||
with connect() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT trackers_json FROM tracker_summary_cache WHERE profile_id=? ORDER BY updated_epoch DESC LIMIT ?",
|
||||
(int(profile_id), max(1, int(limit or 200))),
|
||||
).fetchall()
|
||||
for row in rows:
|
||||
try:
|
||||
items = json.loads(row.get("trackers_json") or "[]")
|
||||
except Exception:
|
||||
items = []
|
||||
for item in items if isinstance(items, list) else []:
|
||||
domain = tracker_domain(str((item or {}).get("url") or (item or {}).get("domain") or "")) or str((item or {}).get("domain") or "")
|
||||
if domain and domain not in seen:
|
||||
seen.add(domain)
|
||||
domains.append(domain)
|
||||
return domains[:max(1, int(limit or 200))]
|
||||
|
||||
|
||||
def warm_favicon_cache(domains: list[str], enabled: bool = True, limit: int = 20, force: bool = False) -> dict:
|
||||
"""Warm missing or stale tracker favicons for a bounded list of domains."""
|
||||
# Note: Favicon lookup can perform network requests, so the caller must keep the batch size small.
|
||||
clean_domains = []
|
||||
seen: set[str] = set()
|
||||
for domain in domains or []:
|
||||
clean = tracker_domain(domain)
|
||||
if clean and clean not in seen:
|
||||
seen.add(clean)
|
||||
clean_domains.append(clean)
|
||||
checked = 0
|
||||
cached = 0
|
||||
errors: list[dict] = []
|
||||
for domain in clean_domains[:max(0, int(limit or 0))]:
|
||||
checked += 1
|
||||
try:
|
||||
path, _mime = favicon_path(domain, enabled=enabled, force=force)
|
||||
if path:
|
||||
cached += 1
|
||||
except Exception as exc:
|
||||
errors.append({"domain": domain, "error": str(exc)})
|
||||
return {"checked": checked, "cached": cached, "errors": errors[:10]}
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any
|
||||
|
||||
from ..config import TRAFFIC_HISTORY_RETENTION_DAYS
|
||||
from ..db import connect, utcnow
|
||||
from . import retention
|
||||
|
||||
+104
-44
@@ -1,14 +1,14 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
import time
|
||||
import json
|
||||
import psutil
|
||||
from flask_socketio import emit, join_room, leave_room, disconnect
|
||||
from .preferences import active_profile, get_profile
|
||||
from ..db import default_user_id
|
||||
from .torrent_cache import torrent_cache
|
||||
from .torrent_summary import cached_summary
|
||||
from . import rtorrent, smart_queue, traffic_history, automation_rules, torrent_stats, auth, speed_peaks, poller_control, download_planner
|
||||
from . import rtorrent, smart_queue, traffic_history, automation_rules, torrent_stats, auth, speed_peaks, poller_control, download_planner, profile_speed_limits
|
||||
|
||||
|
||||
def _profile_room(profile_id: int) -> str:
|
||||
@@ -16,7 +16,6 @@ def _profile_room(profile_id: int) -> str:
|
||||
|
||||
|
||||
def _poller_profiles() -> list[dict]:
|
||||
# Background polling has no browser session, so auth-enabled mode refreshes all profiles and emits only to per-profile rooms.
|
||||
if not auth.enabled():
|
||||
profile = active_profile()
|
||||
return [profile] if profile else []
|
||||
@@ -26,25 +25,31 @@ def _poller_profiles() -> list[dict]:
|
||||
|
||||
|
||||
def emit_profile_event(socketio, event: str, payload: dict, profile_id: int) -> None:
|
||||
target = _profile_room(profile_id) if auth.enabled() else None
|
||||
socketio.emit(event, payload, to=target) if target else socketio.emit(event, payload)
|
||||
scoped_payload = {**(payload or {}), "profile_id": int(profile_id)}
|
||||
socketio.emit(event, scoped_payload, to=_profile_room(profile_id))
|
||||
|
||||
|
||||
def _emit_profile(socketio, event: str, payload: dict, profile_id: int) -> None:
|
||||
emit_profile_event(socketio, event, payload, profile_id)
|
||||
|
||||
|
||||
def _apply_configured_speed_limits(profile: dict) -> None:
|
||||
limits = profile_speed_limits.get_limits(int(profile.get("id") or 0))
|
||||
if not limits.get("configured"):
|
||||
return
|
||||
rtorrent.set_limits(profile, limits.get("down"), limits.get("up"))
|
||||
|
||||
|
||||
def _run_slow_profile_tasks(socketio, profile: dict, profile_id: int) -> None:
|
||||
state = poller_control.state_for(profile_id)
|
||||
profile_user_id = int(profile.get("user_id") or default_user_id())
|
||||
try:
|
||||
try:
|
||||
torrent_stats.queue_refresh(socketio, profile, force=False, room=_profile_room(profile_id) if auth.enabled() else None)
|
||||
torrent_stats.queue_refresh(socketio, profile, force=False, room=_profile_room(profile_id))
|
||||
except Exception as exc:
|
||||
_emit_profile(socketio, "torrent_stats_update", {"ok": False, "profile_id": profile_id, "error": str(exc)}, profile_id)
|
||||
try:
|
||||
result = smart_queue.check(profile, force=False)
|
||||
result = smart_queue.check(profile, user_id=profile_user_id, force=False)
|
||||
if result.get("enabled"):
|
||||
_emit_profile(socketio, "smart_queue_update", result, profile_id)
|
||||
if result.get("stopped") or result.get("started") or result.get("start_requested") or result.get("paused") or result.get("resumed"):
|
||||
@@ -56,12 +61,12 @@ def _run_slow_profile_tasks(socketio, profile: dict, profile_id: int) -> None:
|
||||
_emit_profile(socketio, "smart_queue_update", {"ok": False, "profile_id": profile_id, "error": str(exc)}, profile_id)
|
||||
try:
|
||||
auto_result = automation_rules.check(profile, force=False)
|
||||
if auto_result.get("applied"):
|
||||
if auto_result.get("applied") or auto_result.get("batches"):
|
||||
_emit_profile(socketio, "automation_update", auto_result, profile_id)
|
||||
except Exception as exc:
|
||||
_emit_profile(socketio, "automation_update", {"ok": False, "profile_id": profile_id, "error": str(exc)}, profile_id)
|
||||
try:
|
||||
plan_result = download_planner.enforce(profile, force=False)
|
||||
plan_result = download_planner.enforce(profile, force=False, user_id=profile_user_id)
|
||||
if plan_result.get("enabled") and not plan_result.get("skipped"):
|
||||
_emit_profile(socketio, "download_plan_update", plan_result, profile_id)
|
||||
except Exception as exc:
|
||||
@@ -81,7 +86,6 @@ def _is_active_rows(rows: list[dict]) -> bool:
|
||||
|
||||
|
||||
def _speed_status_from_rows(profile_id: int, rows: list[dict]) -> dict:
|
||||
# Note: Fast-poller speed status keeps browser-title speed and peaks independent from slower system_stats.
|
||||
down_rate = sum(int(row.get("down_rate") or 0) for row in rows or [])
|
||||
up_rate = sum(int(row.get("up_rate") or 0) for row in rows or [])
|
||||
return {
|
||||
@@ -103,7 +107,7 @@ def register_socketio_handlers(socketio):
|
||||
def poller():
|
||||
while True:
|
||||
loop_started = time.monotonic()
|
||||
next_sleep = poller_control.MIN_POLL_INTERVAL_SECONDS
|
||||
next_sleep = 10.0
|
||||
for profile in _poller_profiles():
|
||||
if not profile:
|
||||
continue
|
||||
@@ -111,52 +115,99 @@ def register_socketio_handlers(socketio):
|
||||
settings = poller_control.get_settings(pid)
|
||||
state = poller_control.state_for(pid)
|
||||
now = time.monotonic()
|
||||
next_sleep = min(next_sleep, poller_control.effective_fast_interval(settings, state))
|
||||
if not poller_control.should_fast_poll(now, settings, state):
|
||||
live_interval = poller_control.effective_live_interval(settings, state)
|
||||
list_interval = poller_control.effective_list_interval(settings, state)
|
||||
next_sleep = min(
|
||||
next_sleep,
|
||||
max(poller_control.MIN_POLL_INTERVAL_SECONDS, live_interval - (now - state.last_live_at)),
|
||||
max(poller_control.MIN_POLL_INTERVAL_SECONDS, list_interval - (now - state.last_list_at)),
|
||||
max(poller_control.MIN_POLL_INTERVAL_SECONDS, float(settings["system_stats_interval_seconds"]) - (now - state.last_system_at)),
|
||||
max(poller_control.MIN_POLL_INTERVAL_SECONDS, float(settings["slow_stats_interval_seconds"]) - (now - state.last_slow_at)),
|
||||
max(poller_control.MIN_POLL_INTERVAL_SECONDS, float(settings["queue_stats_interval_seconds"]) - (now - state.last_queue_at)),
|
||||
)
|
||||
|
||||
run_live = poller_control.should_live_poll(now, settings, state)
|
||||
run_list = poller_control.should_list_poll(now, settings, state)
|
||||
run_system = poller_control.should_system_poll(now, settings, state)
|
||||
run_slow = poller_control.should_slow_poll(now, settings, state)
|
||||
run_queue = poller_control.should_queue_poll(now, settings, state)
|
||||
if not (run_live or run_list or run_system or run_slow or run_queue):
|
||||
continue
|
||||
|
||||
tick_started = time.monotonic()
|
||||
changed = False
|
||||
ok = True
|
||||
error = ""
|
||||
active = False
|
||||
active = state.last_active
|
||||
emitted_payload_size = 0
|
||||
rtorrent_call_count = 0
|
||||
skipped_emissions = 0
|
||||
heartbeat = {"ok": True, "profile_id": pid, "tick": state.tick_count + 1, "error": ""}
|
||||
|
||||
try:
|
||||
diff = torrent_cache.refresh(profile)
|
||||
rtorrent_call_count += 1
|
||||
state.last_fast_at = now
|
||||
ok = bool(diff.get("ok"))
|
||||
error = str(diff.get("error") or "")
|
||||
rows = torrent_cache.snapshot(pid)
|
||||
active = _is_active_rows(rows)
|
||||
speed_status = _speed_status_from_rows(pid, rows) if diff.get("ok") else None
|
||||
if diff.get("ok") and (diff["added"] or diff["updated"] or diff["removed"]):
|
||||
changed = True
|
||||
payload = {**diff, "summary": cached_summary(pid, rows, force=True), "speed_status": speed_status}
|
||||
emitted_payload_size += len(json.dumps(payload, default=str))
|
||||
_emit_profile(socketio, "torrent_patch", payload, pid)
|
||||
elif not diff.get("ok"):
|
||||
_emit_profile(socketio, "rtorrent_error", diff, pid)
|
||||
else:
|
||||
# Note: Speeds and peak records may change even when no torrent rows need repainting.
|
||||
if speed_status:
|
||||
payload = {"ok": True, "profile_id": pid, "added": [], "updated": [], "removed": [], "speed_status": speed_status}
|
||||
speed_status = _speed_status_from_rows(pid, rows)
|
||||
|
||||
if run_live:
|
||||
live_started = time.monotonic()
|
||||
live = torrent_cache.refresh_live(profile)
|
||||
rtorrent_call_count += 1
|
||||
state.last_live_at = now
|
||||
state.last_fast_at = now
|
||||
ok = bool(live.get("ok"))
|
||||
error = str(live.get("error") or "")
|
||||
poller_control.mark_live_poll(state, live_started, ok, error, len(live.get("updated") or []), bool(live.get("requires_full_refresh")))
|
||||
rows = torrent_cache.snapshot(pid)
|
||||
active = _is_active_rows(rows)
|
||||
speed_status = _speed_status_from_rows(pid, rows) if live.get("ok") else speed_status
|
||||
if live.get("ok"):
|
||||
if live.get("updated") or speed_status:
|
||||
changed = changed or bool(live.get("updated"))
|
||||
payload = {
|
||||
"ok": True,
|
||||
"profile_id": pid,
|
||||
"updated": live.get("updated") or [],
|
||||
"speed_status": speed_status,
|
||||
"requires_full_refresh": bool(live.get("requires_full_refresh")),
|
||||
}
|
||||
emitted_payload_size += len(json.dumps(payload, default=str))
|
||||
_emit_profile(socketio, "torrent_live_patch", payload, pid)
|
||||
else:
|
||||
skipped_emissions += 1
|
||||
if live.get("requires_full_refresh"):
|
||||
state.last_list_at = 0.0
|
||||
run_list = True
|
||||
else:
|
||||
_emit_profile(socketio, "rtorrent_error", live, pid)
|
||||
|
||||
if run_list:
|
||||
list_started = time.monotonic()
|
||||
diff = torrent_cache.refresh(profile)
|
||||
rtorrent_call_count += 1
|
||||
state.last_list_at = now
|
||||
ok = bool(diff.get("ok"))
|
||||
error = str(diff.get("error") or "")
|
||||
poller_control.mark_list_poll(state, list_started, ok, error, len(diff.get("added") or []), len(diff.get("updated") or []), len(diff.get("removed") or []))
|
||||
rows = torrent_cache.snapshot(pid)
|
||||
active = _is_active_rows(rows)
|
||||
speed_status = _speed_status_from_rows(pid, rows) if diff.get("ok") else speed_status
|
||||
if diff.get("ok") and (diff["added"] or diff["updated"] or diff["removed"]):
|
||||
changed = True
|
||||
payload = {**diff, "summary": cached_summary(pid, rows, force=True), "speed_status": speed_status}
|
||||
emitted_payload_size += len(json.dumps(payload, default=str))
|
||||
_emit_profile(socketio, "torrent_patch", payload, pid)
|
||||
elif not diff.get("ok"):
|
||||
_emit_profile(socketio, "rtorrent_error", diff, pid)
|
||||
else:
|
||||
skipped_emissions += 1
|
||||
|
||||
if poller_control.should_system_poll(now, settings, state):
|
||||
if run_system:
|
||||
state.last_system_at = now
|
||||
rows = torrent_cache.snapshot(pid)
|
||||
status = rtorrent.system_status(profile, rows)
|
||||
rtorrent_call_count += 1
|
||||
if bool(profile.get("is_remote")):
|
||||
try:
|
||||
# Note: Remote profiles must report CPU/RAM from the rTorrent host, not hide the footer stats.
|
||||
usage = rtorrent.remote_system_usage(profile)
|
||||
status.update(usage)
|
||||
status["usage_available"] = True
|
||||
@@ -182,9 +233,11 @@ def register_socketio_handlers(socketio):
|
||||
if poller_control.should_tracker_poll(now, settings, state):
|
||||
state.last_tracker_at = now
|
||||
|
||||
if poller_control.should_slow_poll(now, settings, state) or poller_control.should_queue_poll(now, settings, state):
|
||||
state.last_slow_at = now
|
||||
state.last_queue_at = now
|
||||
if run_slow or run_queue:
|
||||
if run_slow:
|
||||
state.last_slow_at = now
|
||||
if run_queue:
|
||||
state.last_queue_at = now
|
||||
if state.slow_task_running:
|
||||
skipped_emissions += 1
|
||||
else:
|
||||
@@ -208,7 +261,6 @@ def register_socketio_handlers(socketio):
|
||||
global _started
|
||||
with _start_lock:
|
||||
if not _started:
|
||||
# The poller starts with the app, so Smart Queue, planner and automations work without an open UI.
|
||||
socketio.start_background_task(poller)
|
||||
_started = True
|
||||
|
||||
@@ -217,7 +269,7 @@ def register_socketio_handlers(socketio):
|
||||
@socketio.on("connect")
|
||||
def handle_connect():
|
||||
ensure_poller_started()
|
||||
if auth.enabled() and not auth.current_user_id():
|
||||
if auth.enabled() and not auth.ensure_request_user():
|
||||
disconnect()
|
||||
return False
|
||||
profile = active_profile()
|
||||
@@ -227,14 +279,18 @@ def register_socketio_handlers(socketio):
|
||||
if not profile:
|
||||
emit("profile_required", {"ok": True, "profiles": []})
|
||||
return
|
||||
try:
|
||||
_apply_configured_speed_limits(profile)
|
||||
except Exception as exc:
|
||||
emit("rtorrent_error", {"profile_id": profile["id"], "error": str(exc)})
|
||||
rows = torrent_cache.snapshot(profile["id"])
|
||||
emit("torrent_snapshot", {"profile_id": profile["id"], "torrents": rows, "summary": cached_summary(profile["id"], rows), "speed_status": _speed_status_from_rows(profile["id"], rows)})
|
||||
emit("poller_settings", {"settings": poller_control.get_settings(int(profile["id"])), "runtime": poller_control.snapshot(int(profile["id"]))})
|
||||
emit("download_plan_update", {"settings": download_planner.get_settings(int(profile["id"]))})
|
||||
emit("poller_settings", {"profile_id": int(profile["id"]), "settings": poller_control.get_settings(int(profile["id"])), "runtime": poller_control.snapshot(int(profile["id"]))})
|
||||
emit("download_plan_update", {"profile_id": int(profile["id"]), "settings": download_planner.get_settings(int(profile["id"]))})
|
||||
|
||||
@socketio.on("select_profile")
|
||||
def handle_select_profile(data):
|
||||
if auth.enabled() and not auth.current_user_id():
|
||||
if auth.enabled() and not auth.ensure_request_user():
|
||||
disconnect()
|
||||
return
|
||||
old_profile = active_profile()
|
||||
@@ -249,8 +305,12 @@ def register_socketio_handlers(socketio):
|
||||
emit("rtorrent_error", {"error": "Profile access denied or profile does not exist"})
|
||||
return
|
||||
join_room(_profile_room(profile_id))
|
||||
try:
|
||||
_apply_configured_speed_limits(profile)
|
||||
except Exception as exc:
|
||||
emit("rtorrent_error", {"profile_id": profile_id, "error": str(exc)})
|
||||
diff = torrent_cache.refresh(profile)
|
||||
rows = torrent_cache.snapshot(profile_id)
|
||||
emit("torrent_snapshot", {"profile_id": profile_id, "torrents": rows, "summary": cached_summary(profile_id, rows, force=True), "speed_status": _speed_status_from_rows(profile_id, rows), "error": diff.get("error", "")})
|
||||
emit("poller_settings", {"settings": poller_control.get_settings(profile_id), "runtime": poller_control.snapshot(profile_id)})
|
||||
emit("download_plan_update", {"settings": download_planner.get_settings(profile_id)})
|
||||
emit("poller_settings", {"profile_id": profile_id, "settings": poller_control.get_settings(profile_id), "runtime": poller_control.snapshot(profile_id)})
|
||||
emit("download_plan_update", {"profile_id": profile_id, "settings": download_planner.get_settings(profile_id)})
|
||||
|
||||
+128
-15
@@ -1,5 +1,4 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import threading
|
||||
import time
|
||||
@@ -9,6 +8,8 @@ from . import rtorrent, auth, disk_guard, operation_logs
|
||||
from .preferences import get_profile
|
||||
from ..config import WORKERS
|
||||
from ..db import connect, utcnow, default_user_id
|
||||
from .torrent_cache import torrent_cache
|
||||
from .torrent_summary import cached_summary
|
||||
|
||||
LIGHT_ACTIONS = {"start", "stop", "pause", "resume", "unpause", "set_label", "set_ratio_group", "reannounce", "set_limits"}
|
||||
WATCHDOG_INTERVAL_SECONDS = 30
|
||||
@@ -24,6 +25,11 @@ _sem_lock = threading.Lock()
|
||||
_runner_lock = threading.Lock()
|
||||
_watchdog_started = False
|
||||
_watchdog_lock = threading.Lock()
|
||||
_disk_refresh_delays = (30, 90)
|
||||
_disk_refresh_min_immediate_seconds = 5
|
||||
_disk_refresh_lock = threading.Lock()
|
||||
_disk_refresh_timers: dict[tuple[int, int], threading.Timer] = {}
|
||||
_disk_refresh_last_immediate: dict[int, float] = {}
|
||||
|
||||
|
||||
def set_socketio(socketio):
|
||||
@@ -35,8 +41,7 @@ def _emit(name: str, payload: dict):
|
||||
if not _socketio:
|
||||
return
|
||||
profile_id = payload.get("profile_id")
|
||||
if auth.enabled() and profile_id:
|
||||
# Note: Job/socket events are sent only to clients joined to the affected profile room.
|
||||
if profile_id:
|
||||
_socketio.emit(name, payload, to=f"profile:{int(profile_id)}")
|
||||
else:
|
||||
_socketio.emit(name, payload)
|
||||
@@ -95,7 +100,6 @@ def _job_payload(row) -> dict:
|
||||
def _is_ordered_job(row) -> bool:
|
||||
payload = _job_payload(row)
|
||||
action = str((row or {}).get("action") or "")
|
||||
# Note: Only long/destructive tasks are ordered; lightweight start/stop/label jobs may run beside other work.
|
||||
return action in {"move", "remove", "add_magnet", "add_torrent_raw"} or bool(payload.get("requires_order"))
|
||||
|
||||
|
||||
@@ -188,7 +192,6 @@ def enqueue(action_name: str, profile_id: int, payload: dict, user_id: int | Non
|
||||
job_id = uuid.uuid4().hex
|
||||
if force:
|
||||
payload = dict(payload or {})
|
||||
# Note: Forced pending jobs bypass ordered waits and run in a separate worker slot after explicit user confirmation.
|
||||
payload['force_job'] = True
|
||||
payload['priority_job'] = True
|
||||
now = utcnow()
|
||||
@@ -198,6 +201,7 @@ def enqueue(action_name: str, profile_id: int, payload: dict, user_id: int | Non
|
||||
"INSERT INTO jobs(id,user_id,profile_id,action,payload_json,status,attempts,max_attempts,progress_total,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?,?)",
|
||||
(job_id, user_id, profile_id, action_name, json.dumps(payload), "pending", 0, max_attempts, progress_total, now, now),
|
||||
)
|
||||
operation_logs.record_job_event(profile_id, action_name, "queued", payload, job_id=job_id, user_id=user_id)
|
||||
_emit("job_update", {"id": job_id, "action": action_name, "profile_id": profile_id, "status": "pending"})
|
||||
_submit_job(job_id, action_name)
|
||||
return job_id
|
||||
@@ -208,7 +212,6 @@ def _job_event_meta(payload: dict) -> dict:
|
||||
source = str(ctx.get("source") or payload.get("source") or "user")
|
||||
meta = {"source": source}
|
||||
if source == "automation":
|
||||
# Note: Socket operation toasts use this flag so automation notifications respect user preferences.
|
||||
meta["automation"] = True
|
||||
meta["source_label"] = str(ctx.get("rule_name") or "automation")
|
||||
if ctx.get("rule_id") is not None:
|
||||
@@ -216,10 +219,79 @@ def _job_event_meta(payload: dict) -> dict:
|
||||
return meta
|
||||
|
||||
|
||||
def _execute(profile: dict, action_name: str, payload: dict):
|
||||
|
||||
def _remove_job_deletes_data(action_name: str, payload: dict, result: dict | None = None) -> bool:
|
||||
# Note: Disk usage refreshes only when a remove job actually requested data deletion.
|
||||
if str(action_name or "") != "remove":
|
||||
return False
|
||||
if bool((payload or {}).get("remove_data")):
|
||||
return True
|
||||
ctx = (payload or {}).get("job_context") or {}
|
||||
return bool(ctx.get("remove_data") or (result or {}).get("remove_data"))
|
||||
|
||||
|
||||
def _clear_disk_refresh_cache(profile_id: int) -> None:
|
||||
try:
|
||||
rtorrent.clear_profile_runtime_caches(int(profile_id))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _emit_profile_disk_refresh(profile_id: int, reason: str, hash_count: int = 0, delay_seconds: int = 0) -> None:
|
||||
_clear_disk_refresh_cache(profile_id)
|
||||
_emit("disk_refresh_requested", {
|
||||
"profile_id": int(profile_id),
|
||||
"hash_count": int(hash_count or 0),
|
||||
"reason": reason,
|
||||
"delay_seconds": int(delay_seconds or 0),
|
||||
})
|
||||
|
||||
|
||||
def _run_delayed_disk_refresh(profile_id: int, delay_seconds: int) -> None:
|
||||
key = (int(profile_id), int(delay_seconds))
|
||||
try:
|
||||
_emit_profile_disk_refresh(profile_id, "remove_data_settled", delay_seconds=delay_seconds)
|
||||
finally:
|
||||
with _disk_refresh_lock:
|
||||
current = _disk_refresh_timers.get(key)
|
||||
if current is threading.current_thread():
|
||||
_disk_refresh_timers.pop(key, None)
|
||||
|
||||
|
||||
def _schedule_profile_disk_refresh(profile_id: int, hash_count: int = 0) -> None:
|
||||
profile_id = int(profile_id)
|
||||
now = time.monotonic()
|
||||
emit_immediately = False
|
||||
timers_to_start: list[threading.Timer] = []
|
||||
with _disk_refresh_lock:
|
||||
last_immediate = float(_disk_refresh_last_immediate.get(profile_id) or 0)
|
||||
if now - last_immediate >= _disk_refresh_min_immediate_seconds:
|
||||
_disk_refresh_last_immediate[profile_id] = now
|
||||
emit_immediately = True
|
||||
for delay_seconds in _disk_refresh_delays:
|
||||
key = (profile_id, int(delay_seconds))
|
||||
old_timer = _disk_refresh_timers.get(key)
|
||||
if old_timer:
|
||||
old_timer.cancel()
|
||||
timer = threading.Timer(float(delay_seconds), _run_delayed_disk_refresh, args=(profile_id, int(delay_seconds)))
|
||||
timer.daemon = True
|
||||
_disk_refresh_timers[key] = timer
|
||||
timers_to_start.append(timer)
|
||||
if emit_immediately:
|
||||
_emit_profile_disk_refresh(profile_id, "remove_data_done", hash_count=hash_count, delay_seconds=0)
|
||||
for timer in timers_to_start:
|
||||
timer.start()
|
||||
|
||||
|
||||
def _emit_disk_refresh_requested(profile_id: int, action_name: str, payload: dict, result: dict | None = None) -> None:
|
||||
if not _remove_job_deletes_data(action_name, payload, result):
|
||||
return
|
||||
_schedule_profile_disk_refresh(int(profile_id), len((payload or {}).get("hashes") or []))
|
||||
|
||||
def _execute(profile: dict, action_name: str, payload: dict, user_id: int | None = None):
|
||||
if action_name == "smart_queue_check":
|
||||
from . import smart_queue
|
||||
return smart_queue.check(profile, user_id=auth.current_user_id() or default_user_id(), force=True)
|
||||
return smart_queue.check(profile, user_id=user_id or default_user_id(), force=True)
|
||||
if action_name == "add_magnet":
|
||||
if bool(payload.get("start", True)):
|
||||
disk_guard.assert_can_start_download(profile)
|
||||
@@ -268,11 +340,41 @@ def _mark_running(job_id: str, attempts: int) -> bool:
|
||||
return int(cur.rowcount or 0) == 1
|
||||
|
||||
|
||||
def _emit_torrent_refresh(profile: dict, action_name: str) -> None:
|
||||
if action_name not in {"add_magnet", "add_torrent_raw", "remove", "move", "start", "stop", "pause", "resume", "unpause", "set_label", "set_ratio_group", "recheck"}:
|
||||
return
|
||||
try:
|
||||
diff = torrent_cache.refresh(profile)
|
||||
profile_id = int(profile["id"])
|
||||
if diff.get("ok"):
|
||||
rows = torrent_cache.snapshot(profile_id)
|
||||
_emit("torrent_patch", {**diff, "profile_id": profile_id, "summary": cached_summary(profile_id, rows, force=True)})
|
||||
else:
|
||||
_emit("rtorrent_error", {**diff, "profile_id": profile_id})
|
||||
except Exception as exc:
|
||||
_emit("rtorrent_error", {"profile_id": int(profile.get("id") or 0), "error": str(exc)})
|
||||
|
||||
|
||||
def _schedule_delayed_torrent_refresh(profile: dict, action_name: str) -> None:
|
||||
if action_name not in {"start", "stop", "pause", "resume", "unpause"} or not _socketio:
|
||||
return
|
||||
|
||||
def delayed_refresh():
|
||||
sleep_fn = getattr(_socketio, "sleep", time.sleep)
|
||||
for delay in (0.75, 1.75):
|
||||
sleep_fn(delay)
|
||||
_emit_torrent_refresh(profile, action_name)
|
||||
|
||||
_socketio.start_background_task(delayed_refresh)
|
||||
|
||||
|
||||
def _run(job_id: str):
|
||||
if not _claim_runner(job_id):
|
||||
return
|
||||
sem = None
|
||||
ordered_lock = None
|
||||
job = {}
|
||||
payload = {}
|
||||
try:
|
||||
job = _job_row(job_id)
|
||||
if not job or job["status"] == "cancelled":
|
||||
@@ -280,6 +382,7 @@ def _run(job_id: str):
|
||||
profile = get_profile(int(job["profile_id"]), int(job["user_id"]))
|
||||
if not profile:
|
||||
_set_job(job_id, "failed", "rTorrent profile does not exist", finished=True)
|
||||
operation_logs.record_worker_event(int(job.get("profile_id") or 0), str(job.get("action") or ""), "failed", "Job failed: rTorrent profile does not exist", job_id=job_id, user_id=int(job.get("user_id") or 0), error="profile not found")
|
||||
_emit("job_update", {"id": job_id, "profile_id": job.get("profile_id"), "status": "failed", "error": "profile not found"})
|
||||
return
|
||||
profile_id = int(profile["id"])
|
||||
@@ -303,14 +406,17 @@ def _run(job_id: str):
|
||||
operation_logs.record_job_event(profile["id"], job["action"], "started", payload, job_id=job_id, user_id=int(job.get("user_id") or 0))
|
||||
_emit("operation_started", {"job_id": job_id, "action": job["action"], "profile_id": profile["id"], "hashes": payload.get("hashes") or [], "hash_count": len(payload.get("hashes") or []), "bulk": len(payload.get("hashes") or []) > 1, **event_meta})
|
||||
_emit("job_update", {"id": job_id, "profile_id": profile["id"], "status": "running", "attempts": attempts})
|
||||
result = _execute(profile, job["action"], payload)
|
||||
result = _execute(profile, job["action"], payload, user_id=int(job.get("user_id") or 0))
|
||||
fresh = _job_row(job_id)
|
||||
# Note: Emergency cancel and watchdog timeout keep late work from overwriting a terminal state.
|
||||
if fresh and fresh["status"] != "running":
|
||||
return
|
||||
_set_job(job_id, "done", result=result, finished=True)
|
||||
operation_logs.record_job_event(profile["id"], job["action"], "done", payload, result=result or {}, job_id=job_id, user_id=int(job.get("user_id") or 0))
|
||||
_emit("operation_finished", {"job_id": job_id, "action": job["action"], "profile_id": profile["id"], "hashes": payload.get("hashes") or [], "hash_count": len(payload.get("hashes") or []), "bulk": len(payload.get("hashes") or []) > 1, "result": result, **event_meta})
|
||||
action_name = str(job["action"] or "")
|
||||
_emit_disk_refresh_requested(int(profile["id"]), action_name, payload, result or {})
|
||||
_emit_torrent_refresh(profile, action_name)
|
||||
_schedule_delayed_torrent_refresh(profile, action_name)
|
||||
_emit("job_update", {"id": job_id, "profile_id": profile["id"], "status": "done", "result": result})
|
||||
except Exception as exc:
|
||||
fresh = _job_row(job_id) or {}
|
||||
@@ -323,6 +429,9 @@ def _run(job_id: str):
|
||||
_set_job(job_id, status, str(exc), finished=(status == "failed"))
|
||||
if status == "failed":
|
||||
operation_logs.record_job_event(int(job.get("profile_id") or 0), job.get("action"), "failed", payload, error=str(exc), job_id=job_id, user_id=int(job.get("user_id") or 0))
|
||||
else:
|
||||
# Note: Retried attempts are logged explicitly so transient failures are not lost between final states.
|
||||
operation_logs.record_job_event(int(job.get("profile_id") or 0), job.get("action"), "retry", payload, error=str(exc), job_id=job_id, user_id=int(job.get("user_id") or 0))
|
||||
_emit("operation_failed", {"job_id": job_id, "action": job.get("action"), "profile_id": job.get("profile_id"), "hashes": payload.get("hashes") or [], "error": str(exc), **_job_event_meta(payload)})
|
||||
_emit("job_update", {"id": job_id, "profile_id": job.get("profile_id"), "status": status, "error": str(exc), "attempts": attempts})
|
||||
if status == "pending":
|
||||
@@ -369,6 +478,7 @@ def _timeout_running_jobs() -> None:
|
||||
continue
|
||||
message = f"Watchdog timeout after {_job_timeout_seconds(profile, row)} seconds"
|
||||
_set_job(row["id"], "failed", message, finished=True)
|
||||
operation_logs.record_worker_event(int(row.get("profile_id") or 0), str(row.get("action") or ""), "timeout", message, job_id=row["id"], user_id=int(row.get("user_id") or 0), error=message)
|
||||
_emit("operation_failed", {"job_id": row["id"], "action": row.get("action"), "profile_id": row.get("profile_id"), "hashes": [], "error": message, "source": "watchdog"})
|
||||
_emit("job_update", {"id": row["id"], "profile_id": row.get("profile_id"), "status": "failed", "error": message})
|
||||
|
||||
@@ -386,8 +496,7 @@ def _resubmit_interrupted_running_jobs() -> None:
|
||||
if not profile:
|
||||
continue
|
||||
last_seen_ts = _parse_ts(row.get("heartbeat_at") or row.get("updated_at"))
|
||||
# Note: After process restart there is no in-memory runner for this job.
|
||||
# A short grace avoids stealing work from another still-alive Gunicorn worker.
|
||||
|
||||
if last_seen_ts is not None and now_ts - last_seen_ts < 90:
|
||||
continue
|
||||
with connect() as conn:
|
||||
@@ -396,6 +505,7 @@ def _resubmit_interrupted_running_jobs() -> None:
|
||||
("Resuming interrupted job from last checkpoint", utcnow(), row["id"]),
|
||||
)
|
||||
if int(cur.rowcount or 0):
|
||||
operation_logs.record_worker_event(int(row.get("profile_id") or 0), str(row.get("action") or ""), "resubmitted", "Interrupted job resubmitted from checkpoint", job_id=row["id"], user_id=int(row.get("user_id") or 0))
|
||||
_emit("job_update", {"id": row["id"], "profile_id": row.get("profile_id"), "status": "pending", "resumed": True})
|
||||
_submit_job(row["id"], row.get("action"))
|
||||
|
||||
@@ -417,6 +527,7 @@ def _resubmit_stale_pending_jobs() -> None:
|
||||
continue
|
||||
with connect() as conn:
|
||||
conn.execute("UPDATE jobs SET error=?, updated_at=? WHERE id=? AND status='pending'", ("Watchdog resubmitted stale pending job", utcnow(), row["id"]))
|
||||
operation_logs.record_worker_event(int(row.get("profile_id") or 0), str(row.get("action") or ""), "resubmitted", "Stale pending job resubmitted by watchdog", job_id=row["id"], user_id=int(row.get("user_id") or 0))
|
||||
_emit("job_update", {"id": row["id"], "profile_id": row.get("profile_id"), "status": "pending", "watchdog": True})
|
||||
_submit_job(row["id"], row.get("action"))
|
||||
|
||||
@@ -454,7 +565,6 @@ def _job_summary(row: dict, payload: dict, result: dict) -> str:
|
||||
count = int(ctx.get("hash_count") or len(payload.get("hashes") or []) or result.get("count") or 0)
|
||||
parts = []
|
||||
if ctx.get("bulk_label"):
|
||||
# Note: Shows which generated bulk part is being displayed in the job queue.
|
||||
parts.append(f"{ctx.get('bulk_label')} of {ctx.get('bulk_parts')}")
|
||||
if count:
|
||||
parts.append(("bulk " if count > 1 else "single ") + f"{count} torrent(s)")
|
||||
@@ -520,8 +630,9 @@ def cancel_job(job_id: str) -> bool:
|
||||
row = _job_row(job_id)
|
||||
if not row or row["status"] not in {"pending", "running"}:
|
||||
return False
|
||||
# Note: Emergency cancel is useful only for unfinished jobs; failed/done entries stay available for retry or log cleanup.
|
||||
_set_job(job_id, "cancelled", finished=True)
|
||||
payload = _job_payload(row)
|
||||
operation_logs.record_job_event(int(row.get("profile_id") or 0), row.get("action"), "cancelled", payload, error="Cancelled by user", job_id=job_id, user_id=int(row.get("user_id") or 0))
|
||||
_emit("job_update", {"id": job_id, "profile_id": row.get("profile_id"), "status": "cancelled"})
|
||||
return True
|
||||
|
||||
@@ -536,7 +647,6 @@ def clear_jobs() -> int:
|
||||
|
||||
|
||||
def emergency_clear_jobs() -> int:
|
||||
# Note: Emergency cleanup first marks active jobs as cancelled, then clears the whole job log list.
|
||||
now = utcnow()
|
||||
where, params = _job_scope_sql(writable=True)
|
||||
status_clause = "status IN ('pending', 'running')"
|
||||
@@ -558,6 +668,7 @@ def force_job(job_id: str) -> bool:
|
||||
payload['priority_job'] = True
|
||||
with connect() as conn:
|
||||
conn.execute("UPDATE jobs SET payload_json=?, updated_at=? WHERE id=?", (json.dumps(payload), utcnow(), job_id))
|
||||
operation_logs.record_job_event(int(row.get('profile_id') or 0), row.get('action'), 'forced', payload, job_id=job_id, user_id=int(row.get('user_id') or 0))
|
||||
_emit('job_update', {'id': job_id, 'profile_id': row.get('profile_id'), 'status': 'pending', 'forced': True})
|
||||
_submit_job(job_id, row.get('action'))
|
||||
return True
|
||||
@@ -568,6 +679,8 @@ def retry_job(job_id: str) -> bool:
|
||||
return False
|
||||
with connect() as conn:
|
||||
conn.execute("UPDATE jobs SET status='pending', error='', finished_at=NULL, state_json=NULL, progress_current=0, heartbeat_at=NULL, updated_at=? WHERE id=?", (utcnow(), job_id))
|
||||
payload = _job_payload(row)
|
||||
operation_logs.record_job_event(int(row.get("profile_id") or 0), row.get("action"), "retry", payload, job_id=job_id, user_id=int(row.get("user_id") or 0))
|
||||
_emit("job_update", {"id": job_id, "profile_id": row.get("profile_id"), "status": "pending"})
|
||||
_submit_job(job_id, row.get("action"))
|
||||
return True
|
||||
|
||||
File diff suppressed because one or more lines are too long
+116
-43
@@ -1,54 +1,127 @@
|
||||
import { stateSource } from './state.js';
|
||||
import { torrentsSource } from './torrents.js';
|
||||
import { mobileSource } from './mobile.js';
|
||||
import { messagesSource } from './messages.js';
|
||||
import { torrentAddSource } from './torrentAdd.js';
|
||||
import { apiSource } from './api.js';
|
||||
import { createTorrentSource } from './createTorrent.js';
|
||||
import { torrentDetailsSource } from './torrentDetails.js';
|
||||
import { modalsSource } from './modals.js';
|
||||
import { rssSource } from './rss.js';
|
||||
import { smartQueueSource } from './smartQueue.js';
|
||||
import { plannerSource } from './planner.js';
|
||||
import { pollerSource } from './poller.js';
|
||||
import { profilesSource } from './profiles.js';
|
||||
import { dashboardSource } from './dashboard.js';
|
||||
import { chartsSource } from './charts.js';
|
||||
import { operationLogsSource } from './operationLogs.js';
|
||||
import { bootstrapSource } from './bootstrap.js';
|
||||
|
||||
export const moduleSources = [
|
||||
stateSource,
|
||||
torrentsSource,
|
||||
mobileSource,
|
||||
messagesSource,
|
||||
torrentAddSource,
|
||||
apiSource,
|
||||
createTorrentSource,
|
||||
torrentDetailsSource,
|
||||
modalsSource,
|
||||
rssSource,
|
||||
smartQueueSource,
|
||||
plannerSource,
|
||||
dashboardSource,
|
||||
operationLogsSource,
|
||||
pollerSource,
|
||||
profilesSource,
|
||||
chartsSource,
|
||||
bootstrapSource,
|
||||
const staticImportVersion = encodeURIComponent(String(window.PYTORRENT?.staticHash || 'dev'));
|
||||
const versionedImport = (path) => import(`${path}?v=${staticImportVersion}`);
|
||||
const moduleImportSpecs = [
|
||||
['./stateCore.js', 'stateCoreSource'],
|
||||
['./columnState.js', 'columnStateSource'],
|
||||
['./runtimeState.js', 'runtimeStateSource'],
|
||||
['./sharedUi.js', 'sharedUiSource'],
|
||||
['./torrentFilterHelpers.js', 'torrentFilterHelpersSource'],
|
||||
['./torrentFilterUi.js', 'torrentFilterUiSource'],
|
||||
['./torrentTrackerFilters.js', 'torrentTrackerFiltersSource'],
|
||||
['./torrentTableState.js', 'torrentTableStateSource'],
|
||||
['./torrentActionState.js', 'torrentActionStateSource'],
|
||||
['./torrentRowRenderer.js', 'torrentRowRendererSource'],
|
||||
['./torrentTableRenderer.js', 'torrentTableRendererSource'],
|
||||
['./mobile.js', 'mobileSource'],
|
||||
['./messages.js', 'messagesSource'],
|
||||
['./torrentAdd.js', 'torrentAddSource'],
|
||||
['./api.js', 'apiSource'],
|
||||
['./createTorrent.js', 'createTorrentSource'],
|
||||
['./torrentGeneralDetails.js', 'torrentGeneralDetailsSource'],
|
||||
['./torrentFileDetails.js', 'torrentFileDetailsSource'],
|
||||
['./torrentChunkDetails.js', 'torrentChunkDetailsSource'],
|
||||
['./torrentPeerDetails.js', 'torrentPeerDetailsSource'],
|
||||
['./torrentTrackerDetails.js', 'torrentTrackerDetailsSource'],
|
||||
['./mobileTorrentDetails.js', 'mobileTorrentDetailsSource'],
|
||||
['./torrentDetailsLoader.js', 'torrentDetailsLoaderSource'],
|
||||
['./pathPickerTools.js', 'pathPickerToolsSource'],
|
||||
['./columnManager.js', 'columnManagerSource'],
|
||||
['./jobTools.js', 'jobToolsSource'],
|
||||
['./labelTools.js', 'labelToolsSource'],
|
||||
['./ratioTools.js', 'ratioToolsSource'],
|
||||
['./rssTools.js', 'rssToolsSource'],
|
||||
['./backupTools.js', 'backupToolsSource'],
|
||||
['./smartQueue.js', 'smartQueueSource'],
|
||||
['./rtorrentConfig.js', 'rtorrentConfigSource'],
|
||||
['./appearancePreferences.js', 'appearancePreferencesSource'],
|
||||
['./peerRefresh.js', 'peerRefreshSource'],
|
||||
['./automationRules.js', 'automationRulesSource'],
|
||||
['./cleanupTools.js', 'cleanupToolsSource'],
|
||||
['./appDiagnostics.js', 'appDiagnosticsSource'],
|
||||
['./footerPreferences.js', 'footerPreferencesSource'],
|
||||
['./liveSpeedStats.js', 'liveSpeedStatsSource'],
|
||||
['./statusBar.js', 'statusBarSource'],
|
||||
['./preferencesTools.js', 'preferencesToolsSource'],
|
||||
['./diskMonitor.js', 'diskMonitorSource'],
|
||||
['./portCheckActions.js', 'portCheckActionsSource'],
|
||||
['./appStatus.js', 'appStatusSource'],
|
||||
['./torrentStats.js', 'torrentStatsSource'],
|
||||
['./toolUiHelpers.js', 'toolUiHelpersSource'],
|
||||
['./authUsers.js', 'authUsersSource'],
|
||||
['./plannerToolsUi.js', 'plannerToolsUiSource'],
|
||||
['./plannerSpeedControls.js', 'plannerSpeedControlsSource'],
|
||||
['./plannerSettings.js', 'plannerSettingsSource'],
|
||||
['./plannerPreviewHistory.js', 'plannerPreviewHistorySource'],
|
||||
['./plannerActions.js', 'plannerActionsSource'],
|
||||
['./smartViews.js', 'smartViewsSource'],
|
||||
['./notificationCenter.js', 'notificationCenterSource'],
|
||||
['./diagnosticsDashboard.js', 'diagnosticsDashboardSource'],
|
||||
['./dashboardTools.js', 'dashboardToolsSource'],
|
||||
['./operationLogs.js', 'operationLogsSource'],
|
||||
['./pollerSettings.js', 'pollerSettingsSource'],
|
||||
['./toolsModal.js', 'toolsModalSource'],
|
||||
['./toolPaneEvents.js', 'toolPaneEventsSource'],
|
||||
['./rssEvents.js', 'rssEventsSource'],
|
||||
['./smartQueueEvents.js', 'smartQueueEventsSource'],
|
||||
['./backupCleanupRtconfigEvents.js', 'backupCleanupRtconfigEventsSource'],
|
||||
['./automationEvents.js', 'automationEventsSource'],
|
||||
['./labelSmartEvents.js', 'labelSmartEventsSource'],
|
||||
['./torrentSelectionEvents.js', 'torrentSelectionEventsSource'],
|
||||
['./torrentTableEvents.js', 'torrentTableEventsSource'],
|
||||
['./preferenceEvents.js', 'preferenceEventsSource'],
|
||||
['./keyboardEvents.js', 'keyboardEventsSource'],
|
||||
['./speedLimitControls.js', 'speedLimitControlsSource'],
|
||||
['./themeMobileControls.js', 'themeMobileControlsSource'],
|
||||
['./jobSettings.js', 'jobSettingsSource'],
|
||||
['./profileList.js', 'profileListSource'],
|
||||
['./profileForm.js', 'profileFormSource'],
|
||||
['./profileActions.js', 'profileActionsSource'],
|
||||
['./profileSelection.js', 'profileSelectionSource'],
|
||||
['./realtimeCharts.js', 'realtimeChartsSource'],
|
||||
['./trafficHistoryData.js', 'trafficHistoryDataSource'],
|
||||
['./trafficChartRenderer.js', 'trafficChartRendererSource'],
|
||||
['./initialSnapshot.js', 'initialSnapshotSource'],
|
||||
['./footerStatusRefresh.js', 'footerStatusRefreshSource'],
|
||||
['./systemStatsSocket.js', 'systemStatsSocketSource'],
|
||||
['./mobileSelectEvents.js', 'mobileSelectEventsSource'],
|
||||
['./bootstrapRuntime.js', 'bootstrapRuntimeSource'],
|
||||
];
|
||||
|
||||
export function buildRuntimeSource(){
|
||||
return `(() => {\n${moduleSources.join('\n')}\n})();\n`;
|
||||
export let moduleSources = [];
|
||||
let moduleSourcesPromise = null;
|
||||
|
||||
async function loadModuleSources(){
|
||||
if(moduleSourcesPromise) return moduleSourcesPromise;
|
||||
moduleSourcesPromise = Promise.all(moduleImportSpecs.map(([path]) => versionedImport(path))).then((modules) => {
|
||||
moduleSources = modules.map((mod, index) => mod[moduleImportSpecs[index][1]]);
|
||||
return moduleSources;
|
||||
});
|
||||
return moduleSourcesPromise;
|
||||
}
|
||||
|
||||
export function startApp(){
|
||||
const runtimeSource = buildRuntimeSource();
|
||||
function normalizeRuntimeSource(source){
|
||||
const text = String(source || '');
|
||||
// Note: Some generated source chunks may end with a literal \\n marker;
|
||||
// normalize only this trailing marker to avoid invalid Function() source.
|
||||
return text.endsWith('\\n') ? `${text.slice(0, -2)}\n` : text;
|
||||
}
|
||||
|
||||
export async function buildRuntimeSource(){
|
||||
const sources = await loadModuleSources();
|
||||
return `(() => {\n${sources.map(normalizeRuntimeSource).join('\n')}\n})();\n`;
|
||||
}
|
||||
|
||||
export async function startApp(){
|
||||
const runtimeSource = await buildRuntimeSource();
|
||||
// Keep the original shared lexical scope while loading the source from smaller ES modules.
|
||||
// `io` is passed explicitly so Socket.IO remains available inside the generated runtime.
|
||||
return Function('io', runtimeSource)(window.io);
|
||||
}
|
||||
|
||||
if(typeof window !== 'undefined' && !window.PYTORRENT_DISABLE_AUTOSTART){
|
||||
startApp();
|
||||
startApp().catch((error) => {
|
||||
console.error('pyTorrent frontend failed to start', error);
|
||||
const loaderText = document.getElementById('initialLoaderText');
|
||||
if(loaderText) loaderText.textContent = 'Frontend failed to start. Reload the page or clear browser cache.';
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export const appDiagnosticsSource = " function diagCard(label,value,extra=''){ return `<div class=\"diag-card ${extra}\"><b>${esc(label)}</b><span>${esc(value ?? '-')}</span></div>`; }\n\n // Note: Centralizes footer visibility so Preferences can hide items without removing existing status logic.";
|
||||
@@ -0,0 +1 @@
|
||||
export const appStatusSource = " async function loadAppStatus(){\n const box=$('appStatusManager'); if(!box) return;\n box.innerHTML='<span class=\"spinner-border spinner-border-sm\"></span> Loading diagnostics...';\n try{\n const [status,pollerResponse]=await Promise.all([\n fetch('/api/app/status',{cache:'no-store'}).then(r=>r.json()),\n fetch('/api/poller/settings',{cache:'no-store'}).then(r=>r.json()).catch(()=>({ok:false}))\n ]);\n if(!status.ok) throw new Error(status.error||'Failed to load diagnostics');\n const st=status.status||{}, py=st.pytorrent||{}, scgi=st.scgi||{}, profile=st.profile||{};\n const pollerBundle=(pollerResponse && pollerResponse.ok!==false) ? pollerResponse : (st.poller||{});\n const rt=pollerBundle.runtime||{}, ps=pollerBundle.settings||{};\n // Note: App status uses embedded poller data as a fallback, so one failing endpoint cannot leave Runtime poller empty.\n const intervalValue=(runtimeKey,settingsKey)=>rt[runtimeKey] ?? ps[settingsKey] ?? '-';\n const runtimeReady=rt.runtime_ready!==false && (Number(rt.tick_count||0)>0 || Number(rt.live_poll_count||0)>0 || Number(rt.list_poll_count||0)>0 || Number(rt.last_tick_ms||0)>0);\n const waiting=!runtimeReady && rt.runtime_ready===false;\n const mode=waiting?'waiting':(rt.adaptive_mode || ((rt.adaptive_enabled ?? ps.adaptive_enabled)===false?'fixed':'normal'));\n const processCards=[\n diagCard('PID', py.pid),\n diagCard('Uptime', `${py.uptime_seconds||0}s`),\n diagCard('Memory RSS', py.memory_rss_h||py.memory_rss),\n diagCard('Threads', py.threads),\n diagCard('CPU', `${py.cpu_percent ?? '-'}%`),\n diagCard('Python', py.python||'-'),\n diagCard('Worker threads', py.worker_threads ?? '-'),\n diagCard('Jobs total', py.jobs_total ?? '-')\n ];\n const pollerCards=[\n diagCard('Adaptive', (rt.adaptive_enabled ?? ps.adaptive_enabled)===false?'off':'on'),\n diagCard('Mode', mode),\n diagCard('Live interval', `${intervalValue('live_stats_interval_seconds','live_stats_interval_seconds')}s`),\n diagCard('List interval', `${intervalValue('torrent_list_interval_seconds','torrent_list_interval_seconds')}s`),\n diagCard('Last tick', waiting?'waiting':`${rt.duration_ms||rt.last_tick_ms||0} ms`),\n diagCard('Tick gap', waiting?'waiting':`${rt.last_tick_gap_ms||0} ms`),\n diagCard('Payload', waiting?'waiting':fmtBytes(rt.emitted_payload_size||0)),\n diagCard('rTorrent calls', waiting?'waiting':(rt.rtorrent_call_count||0))\n ];\n const connectionCards=[\n diagCard('Active profile', profile.name||profile.id||'-'),\n diagCard('API response time', `${st.api_ms ?? '-'} ms`),\n diagCard('SCGI status', scgi.ok?'OK':'ERROR', scgi.ok?'':'diag-error'),\n diagCard('SCGI URL', scgi.url||'-'),\n diagCard('SCGI connect', scgi.connect_ms!=null?`${scgi.connect_ms} ms`:'-'),\n diagCard('SCGI first byte', scgi.first_byte_ms!=null?`${scgi.first_byte_ms} ms`:'-'),\n diagCard('SCGI total', scgi.total_ms!=null?`${scgi.total_ms} ms`:'-'),\n diagCard('Request bytes', scgi.request_bytes),\n diagCard('Response bytes', scgi.response_bytes),\n diagCard('XML bytes', scgi.xml_bytes),\n diagCard('rTorrent version', scgi.client_version||'-')\n ];\n const panes=[\n ['process','Process', `${diagnosticsSection('pyTorrent process', processCards)}${diagnosticsSection('Runtime poller', pollerCards)}`],\n ['connection','Connection', diagnosticsSection('Profile and rTorrent', connectionCards)]\n ];\n const tabs=`<div class=\"column-manager-tabs appstatus-tabs\"><ul class=\"nav nav-pills\">${panes.map((p,i)=>`<li class=\"nav-item\"><button class=\"nav-link ${i?'':'active'}\" type=\"button\" data-appstatus-pane=\"${p[0]}\">${p[1]}</button></li>`).join('')}</ul></div>`;\n if($('appStatusTabs')) $('appStatusTabs').innerHTML=tabs;\n box.innerHTML=`${panes.map((p,i)=>`<div class=\"appstatus-pane ${i?'d-none':''}\" data-appstatus-panel=\"${p[0]}\">${p[2]}</div>`).join('')}${scgi.error?`<div class=\"alert alert-danger mt-3 mb-0\">${esc(scgi.error)}</div>`:''}`;\n }catch(e){ box.innerHTML=`<div class=\"alert alert-danger mb-0\">${esc(e.message)}</div>`; }\n }\n\n\n\n const TORRENT_STATS_PANE_STORAGE_KEY = 'pytorrent.torrentStatsPane.v1';";
|
||||
@@ -0,0 +1 @@
|
||||
export const appearancePreferencesSource = " function bootstrapThemeUrl(theme){ /* Note: Themes use the URL map generated by the backend, so they also work offline. */ const key=theme||\"default\"; return window.PYTORRENT?.bootstrapThemeUrls?.[key] || window.PYTORRENT?.bootstrapThemeUrls?.default || \"\"; }\n function applyBootstrapTheme(theme){\n // Note: Custom Bootstrap 2-inspired themes are normal selectable themes and keep light/dark compatibility through data-bs-theme.\n bootstrapTheme = theme || \"default\";\n document.documentElement.dataset.bootstrapSkin = bootstrapTheme;\n const link=$(\"bootstrapThemeStylesheet\");\n if(link) link.href = bootstrapThemeUrl(bootstrapTheme);\n if($(\"bootstrapThemeSelect\")) $(\"bootstrapThemeSelect\").value = bootstrapTheme;\n }\n function applyFontFamily(font){ fontFamily = font || \"default\"; document.documentElement.dataset.appFont = fontFamily; if($(\"fontFamilySelect\")) $(\"fontFamilySelect\").value = fontFamily; }\n function clampInterfaceScale(value){ value = Number(value || 100); if(!Number.isFinite(value)) value = 100; return Math.max(80, Math.min(140, Math.round(value / 5) * 5)); }\n function applyInterfaceScale(value){ interfaceScale = clampInterfaceScale(value); document.documentElement.style.setProperty(\"--ui-scale\", String(interfaceScale / 100)); if($(\"interfaceScaleRange\")) $(\"interfaceScaleRange\").value = interfaceScale; if($(\"interfaceScaleValue\")) $(\"interfaceScaleValue\").textContent = `${interfaceScale}%`; scheduleRender(false); }\n function applyTorrentListFontSize(value){\n // Note: This controls torrent list text only; compact mode stays responsible for row density.\n torrentListFontSize = clampTorrentListFontSize(value);\n document.documentElement.style.setProperty(\"--torrent-list-font-size\", `${torrentListFontSize}px`);\n if($(\"torrentListFontSizeRange\")) $(\"torrentListFontSizeRange\").value = torrentListFontSize;\n if($(\"torrentListFontSizeValue\")) $(\"torrentListFontSizeValue\").textContent = `${torrentListFontSize}px`;\n scheduleRender(false);\n }\n function torrentRowHeight(){ return compactTorrentListEnabled ? COMPACT_ROW_HEIGHT : ROW_HEIGHT; }\n function applyCompactTorrentList(value){\n // Note: The compact switch changes density only; filtering, sorting and existing row actions stay unchanged.\n compactTorrentListEnabled = !!value;\n document.body.classList.toggle(\"compact-torrent-list\", compactTorrentListEnabled);\n if($(\"compactTorrentListEnabled\")) $(\"compactTorrentListEnabled\").checked = compactTorrentListEnabled;\n scheduleRender(true);\n }\n async function saveAppearancePreferences(){ applyBootstrapTheme($(\"bootstrapThemeSelect\")?.value || \"default\"); applyFontFamily($(\"fontFamilySelect\")?.value || \"default\"); applyInterfaceScale($(\"interfaceScaleRange\")?.value || interfaceScale); applyTorrentListFontSize($(\"torrentListFontSizeRange\")?.value || torrentListFontSize); applyCompactTorrentList($(\"compactTorrentListEnabled\")?.checked); try{ await post(\"/api/preferences\",{bootstrap_theme:bootstrapTheme,font_family:fontFamily,interface_scale:interfaceScale,torrent_list_font_size:torrentListFontSize,compact_torrent_list_enabled:compactTorrentListEnabled}); toast(\"Appearance preferences saved\",\"success\"); }catch(e){ toast(e.message,\"danger\"); } }\n if($(\"titleSpeedEnabled\")) $(\"titleSpeedEnabled\").checked=titleSpeedEnabled;\n applyBootstrapTheme(bootstrapTheme);\n applyTorrentListFontSize(torrentListFontSize);\n applyCompactTorrentList(compactTorrentListEnabled);\n";
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
||||
export const automationEventsSource = "$('statusPlannerOpen')?.addEventListener('click',()=>{ ensurePlannerToolsUI(); activateToolTab('planner'); new bootstrap.Modal($('toolsModal')).show(); }); $('peersRefreshSelect')?.addEventListener('change',async e=>{peersRefreshSeconds=Number(e.target.value||0); await post('/api/preferences',{peers_refresh_seconds:peersRefreshSeconds}).catch(()=>{}); setupPeersRefresh(activeTab()); toast('Peers refresh preference saved','success');});\n $('autoConditionType')?.addEventListener('change',updateAutomationForm); $('autoEffectType')?.addEventListener('change',updateAutomationForm); $('automationAddConditionBtn')?.addEventListener('click',()=>{automationConditions.push(automationCondition()); renderAutomationBuilder();}); $('automationAddEffectBtn')?.addEventListener('click',()=>{automationEffects.push(automationEffect()); renderAutomationBuilder();}); $('automationConditionList')?.addEventListener('click',e=>{const b=e.target.closest('.automation-remove-condition'); if(!b)return; automationConditions.splice(Number(b.dataset.index||0),1); renderAutomationBuilder();}); $('automationEffectList')?.addEventListener('click',e=>{const b=e.target.closest('.automation-remove-effect'); if(!b)return; automationEffects.splice(Number(b.dataset.index||0),1); renderAutomationBuilder();}); $('automationCancelEditBtn')?.addEventListener('click',resetAutomationForm); $('automationSaveBtn')?.addEventListener('click',saveAutomation); $('automationExportBtn')?.addEventListener('click',exportAutomations); $('automationImportBtn')?.addEventListener('click',()=>$('automationImportFile')?.click()); $('automationImportFile')?.addEventListener('change',e=>importAutomations(e.target.files?.[0])); $('automationCheckBtn')?.addEventListener('click',async()=>{setBusy(true);try{const j=await post('/api/automations/check',{}); const torrents=j.result?.applied?.length||0; const batches=j.result?.batches?.length||0; toastMessage('toast.automationsApplied','success',{count:torrents,batches}); await loadAutomations();}catch(e){toast(e.message,'danger');}finally{setBusy(false);}}); $('automationManager')?.addEventListener('click',async e=>{const run=e.target.closest('.automation-run'); if(run){ setBusy(true); try{ const j=await post(`/api/automations/${run.dataset.id}/run`,{}); toastMessage('toast.automationForceRunDone','success',{count:j.result?.applied?.length}); await loadAutomations(); }catch(err){ toast(err.message,'danger'); } finally{ setBusy(false); } return; } const toggle=e.target.closest('.automation-toggle'); if(toggle){ await toggleAutomationRule(automationRulesCache.find(r=>String(r.id)===String(toggle.dataset.id))); return; } const edit=e.target.closest('.automation-edit'); if(edit){ editAutomationRule(automationRulesCache.find(r=>String(r.id)===String(edit.dataset.id))); return; } const id=e.target.closest('.automation-delete')?.dataset.id;if(!id)return;if(!confirm('Delete this automation rule?'))return;const r=await fetch('/api/automations/'+id,{method:'DELETE'});const j=await r.json();if(!j.ok)toast(j.error||'Delete failed','danger');await loadAutomations();}); $('automationHistory')?.addEventListener('click',e=>{ if(e.target.closest('#automationClearHistoryBtn')) clearAutomationHistory(); });\n ";
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
||||
export const backupCleanupRtconfigEventsSource = "$('profileBackupCreateBtn')?.addEventListener('click',async()=>{await post('/api/backup/profile',{name:$('profileBackupName')?.value||'Profile backup'}); toast('Profile backup created','success'); loadBackup();}); $('appBackupCreateBtn')?.addEventListener('click',async()=>{await post('/api/backup/app',{name:$('appBackupName')?.value||'Application backup'}); toast('Application backup created','success'); loadBackup();}); $('profileBackupSettingsSaveBtn')?.addEventListener('click',async()=>{await post('/api/backup/profile/settings',{enabled:$('profileBackupAutoEnabled')?.checked,interval_hours:Number($('profileBackupAutoInterval')?.value||24),retention_days:Number($('profileBackupRetentionDays')?.value||30)}); toast('Profile backup schedule saved','success'); loadBackup();}); $('backupSettingsSaveBtn')?.addEventListener('click',async()=>{await post('/api/backup/settings',{enabled:$('backupAutoEnabled')?.checked,interval_hours:Number($('backupAutoInterval')?.value||24),retention_days:Number($('backupRetentionDays')?.value||30)}); toast('Application backup schedule saved','success'); loadBackup();}); document.querySelectorAll('[data-backup-pane]').forEach(tab=>tab.addEventListener('click',()=>{ if(tab.classList.contains('disabled')) return; switchBackupPane(tab.dataset.backupPane||'profile'); })); const backupClickHandler=async e=>{const preview=e.target.closest('.backup-preview-btn'); const restore=e.target.closest('.backup-restore'); const del=e.target.closest('.backup-delete'); if(preview){ const j=await (await fetch(`/api/backup/${preview.dataset.id}/preview`)).json(); if(!j.ok) throw new Error(j.error||'Backup preview failed'); const box=$('backupPreview'); if(box){ box.classList.remove('d-none'); box.innerHTML=backupPreviewTable(j.preview||{}); box.scrollIntoView({block:'nearest'}); } return; } if(restore){ const type=restore.dataset.type==='app'?'application':'profile'; const msg=type==='application'?'Restore this application backup and replace users, profiles and global settings?':'Restore this profile backup into the current active profile?'; if(!confirm(msg)) return; await post(`/api/backup/${restore.dataset.id}/restore`,{}); toast('Backup restored','success'); loadBackup(); return; } if(del){ if(!confirm('Delete this backup permanently?')) return; await post(`/api/backup/${del.dataset.id}`,{},'DELETE'); toast('Backup deleted','success'); loadBackup(); }}; $('profileBackupManager')?.addEventListener('click',backupClickHandler); $('appBackupManager')?.addEventListener('click',backupClickHandler); $('cleanupManager')?.addEventListener('click',async e=>{ if(e.target.closest('#cleanupRefreshBtn')) return loadCleanup(); if(e.target.closest('#cleanupProfileCacheBtn')) return runCleanupAction('/api/cleanup/cache','Clear active profile cache'); if(e.target.closest('#cleanupPollerDiagnosticsBtn')) return runCleanupAction('/api/cleanup/poller-diagnostics','Reset poller diagnostics'); if(e.target.closest('#cleanupDatabaseVacuumBtn')) return runCleanupAction('/api/cleanup/database/vacuum','Compact SQLite database'); if(e.target.closest('#cleanupJobsBtn')) return runCleanupAction('/api/cleanup/jobs','Clear finished job logs'); if(e.target.closest('#cleanupSmartQueueBtn')) return runCleanupAction('/api/cleanup/smart-queue','Clear Smart Queue logs'); if(e.target.closest('#cleanupOperationLogsBtn')) return runCleanupAction('/api/cleanup/operation-logs','Clear operation logs'); if(e.target.closest('#cleanupPlannerBtn')) return runCleanupAction('/api/cleanup/planner','Clear Planner logs'); if(e.target.closest('#cleanupAutomationsBtn')) return runCleanupAction('/api/cleanup/automations','Clear automation logs'); if(e.target.closest('#cleanupAllBtn')) return runCleanupAction('/api/cleanup/all','Clear job, Smart Queue, operation, Planner and automation logs'); }); $('rtConfigReloadBtn')?.addEventListener('click',loadRtConfig); $('rtConfigResetBtn')?.addEventListener('click',resetRtConfig); $('rtConfigSaveBtn')?.addEventListener('click',saveRtConfig); $('rtConfigGenerateBtn')?.addEventListener('click',generateRtConfig); $('rtConfigManager')?.addEventListener('input',e=>{ if(e.target.classList.contains('rt-config-input')) updateRtConfigDirty(); }); $('rtConfigManager')?.addEventListener('change',e=>{ if(e.target.classList.contains('rt-config-input')){ const label=e.target.closest('.rt-config-switch')?.querySelector('.form-check-label'); if(label) label.textContent=e.target.checked?'On':'Off'; updateRtConfigDirty(); } }); $('rtConfigApplyOnStart')?.addEventListener('change',updateRtConfigDirty); ";
|
||||
@@ -0,0 +1 @@
|
||||
export const backupToolsSource = " function fillBackupSettings(settings={}, prefix='app'){\n const cap=prefix==='profile'?'Profile':'App';\n const enabled=$(prefix==='profile'?'profileBackupAutoEnabled':'backupAutoEnabled');\n const interval=$(prefix==='profile'?'profileBackupAutoInterval':'backupAutoInterval');\n const retention=$(prefix==='profile'?'profileBackupRetentionDays':'backupRetentionDays');\n if(enabled) enabled.checked=!!settings.enabled;\n if(interval) interval.value=settings.interval_hours||24;\n if(retention) retention.value=settings.retention_days||30;\n }\n function backupPreviewDetails(table={}){\n const sample=table.sample||[];\n if(!sample.length) return '<div class=\"backup-preview-empty\">No saved rows in this table.</div>';\n const keys=[...new Set(sample.flatMap(row=>Object.keys(row||{})))].slice(0,8);\n return responsiveTable(keys.map(esc), sample.map(row=>keys.map(key=>esc(row?.[key] ?? ''))), 'backup-preview-sample-table');\n }\n function backupPreviewTable(preview={}){\n const tables=preview.tables||[];\n const rows=tables.map(t=>`<details class=\"backup-preview-table-details\"><summary><span><b>${esc(t.name)}</b><small>${esc(t.rows)} row(s) \u00b7 ${(t.columns||[]).length} column(s)</small></span></summary>${backupPreviewDetails(t)}</details>`).join('');\n const type=preview.backup_type==='app'?'application':'profile';\n return `<div class=\"surface-section backup-preview-card\"><div class=\"section-title\"><i class=\"fa-solid fa-eye\"></i> Backup preview</div><div class=\"small text-muted mb-2\">${esc(type)} backup \u00b7 Created: ${esc(preview.created_at||'-')} \u00b7 ${preview.automatic?'automatic':'manual'} \u00b7 Owner: ${esc(preview.owner_name||'-')} \u00b7 sensitive values hidden</div>${rows || '<div class=\"empty-mini\">Backup has no previewable settings.</div>'}</div>`;\n }\n function backupRows(rows=[]){\n return responsiveTable(['Name','Created','Owner','Type','Actions'],rows.map(b=>[esc(b.name),humanDateCell(b.created_at),esc(b.owner_name||'-'),b.automatic?'Auto':'Manual',`<div class=\"table-action-group backup-actions\"><button class=\"btn btn-xs btn-outline-info backup-preview-btn\" data-id=\"${esc(b.id)}\"><i class=\"fa-solid fa-eye\"></i> Preview</button><a class=\"btn btn-xs btn-outline-secondary\" href=\"/api/backup/${esc(b.id)}/download\"><i class=\"fa-solid fa-download\"></i> Download</a><button class=\"btn btn-xs btn-outline-warning backup-restore\" data-id=\"${esc(b.id)}\" data-type=\"${esc(b.backup_type||'profile')}\"><i class=\"fa-solid fa-rotate-left\"></i> Restore</button><button class=\"btn btn-xs btn-outline-danger backup-delete\" data-id=\"${esc(b.id)}\"><i class=\"fa-solid fa-trash-can\"></i> Delete</button></div>`]),'backup-table');\n }\n function switchBackupPane(pane){\n document.querySelectorAll('[data-backup-pane]').forEach(x=>x.classList.toggle('active',x.dataset.backupPane===pane));\n document.querySelectorAll('[data-backup-panel]').forEach(x=>x.classList.toggle('d-none',x.dataset.backupPanel!==pane));\n }\n async function loadBackup(){\n const j=await (await fetch('/api/backup')).json();\n fillBackupSettings(j.profile_auto||{}, 'profile');\n fillBackupSettings(j.app_auto||j.auto||{}, 'app');\n if($('profileBackupManager')) $('profileBackupManager').innerHTML=backupRows(j.profile_backups||[]);\n if($('appBackupManager')) $('appBackupManager').innerHTML=j.can_app_backup ? backupRows(j.app_backups||[]) : '<div class=\"empty-mini\">Application backups are admin-only.</div>';\n if(!j.can_app_backup) document.querySelector('[data-backup-pane=\"app\"]')?.classList.add('disabled');\n }\n";
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
+1
@@ -0,0 +1 @@
|
||||
export const bootstrapRuntimeSource = " let lastStaticAssetVersionCheck=0;\n async function checkStaticAssetVersion(force=false){ const now=Date.now(); if(!force && now-lastStaticAssetVersionCheck<60000) return; lastStaticAssetVersionCheck=now; try{ const r=await fetch('/api/static_hash',{cache:'no-store'}); const j=await r.json(); const current=String(window.PYTORRENT?.staticHash||''); const next=String(j.static_hash||j.version||''); if(current && next && current!==next){ window.PYTORRENT.staticHash=next; toast('A new frontend version is available. Reloading...','info'); setTimeout(()=>window.location.reload(), 600); } }catch(e){} }\n setInterval(()=>checkStaticAssetVersion(true), 900000);\n window.addEventListener('focus',()=>checkStaticAssetVersion(false));\n initSidebarShortcuts(); updateSortHeaders(); setupColumnResizers(); applyColumnVisibility(); renderColumnManager(); restoreFooterStatusCache(); refreshFooterStatusNow(); renderFooterPreferences(); applyFooterPreferences(); updateFooterClock(); updateBrowserSpeedTitle(); setupTorrentDropZone(); setInterval(updateFooterClock,1000); scheduleRender(true); if(!hasActiveProfile) renderNoProfileState(); loadLabels().catch(()=>{}); loadRatios().catch(()=>{}); loadSmartQueue().catch(()=>{}); loadAutomations().catch(()=>{}); ensureDashboardToolsUI(); if(portCheckEnabled) loadPortCheck(false); else renderPortCheck({status:'disabled',enabled:false}); if(hasActiveProfile) applyDefaultDownloadPath(false).catch(()=>{}); if(hasActiveProfile) refreshUserDiskUsage(true).catch(()=>{}); scheduleTrackerSummary(true);\n";
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
||||
export const columnStateSource = " const COLUMN_DEFS = [[\"status\",\"Status\",false],[\"size\",\"Size\",false],[\"progress\",\"Progressbar\",false],[\"down_rate\",\"DL\",false],[\"up_rate\",\"UL\",false],[\"eta\",\"ETA\",false],[\"seeds\",\"Seeds\",false],[\"peers\",\"Peers\",false],[\"ratio\",\"Ratio\",false],[\"path\",\"Path\",false],[\"label\",\"Label\",false],[\"ratio_group\",\"Ratio group\",false],[\"down_total\",\"Downloaded\",true],[\"to_download\",\"To download\",true],[\"up_total\",\"Uploaded\",true],[\"created\",\"Created\",true],[\"last_activity\",\"Last activity\",true],[\"priority\",\"Priority\",true],[\"state\",\"State\",true],[\"active\",\"Active\",true],[\"complete\",\"Complete\",true],[\"hashing\",\"Hashing\",true],[\"message\",\"Message\",true],[\"hash\",\"Hash\",true]];\n const DEFAULT_HIDDEN_COLUMNS = new Set(COLUMN_DEFS.filter(([, , hiddenByDefault]) => hiddenByDefault).map(([key]) => key));\n const savedColumns = window.PYTORRENT?.tableColumns || {};\n const DEFAULT_COLUMN_WIDTHS = {\n select: 34, name: 360, status: 110, size: 90, progress: 120,\n down_rate: 86, up_rate: 86, eta: 92, seeds: 70, peers: 70,\n ratio: 72, path: 300, label: 140, ratio_group: 130,\n down_total: 120, to_download: 120, up_total: 120, created: 150,\n last_activity: 150, priority: 80, state: 70, active: 70, complete: 82, hashing: 82,\n message: 220, hash: 280\n };\n const COLUMN_WIDTH_MIN = 44;\n const COLUMN_WIDTH_MAX = 720;\n const explicitlyShownColumns = new Set(savedColumns.shown || []);\n let hiddenColumns = new Set([...(savedColumns.hidden || []), ...[...DEFAULT_HIDDEN_COLUMNS].filter(key => !explicitlyShownColumns.has(key))]);\n // Note: Column widths are persisted with the existing column preferences payload, so no database migration is needed.\n function normalizeColumnWidths(value={}){\n const allowed = new Set(['select', ...COLUMN_DEFS.map(([key]) => key)]);\n const normalized = {...DEFAULT_COLUMN_WIDTHS};\n Object.entries(value || {}).forEach(([key, width])=>{\n if(allowed.has(key)) normalized[key] = clampNumber(width, COLUMN_WIDTH_MIN, COLUMN_WIDTH_MAX, DEFAULT_COLUMN_WIDTHS[key] || 120);\n });\n return normalized;\n }\n let columnWidths = normalizeColumnWidths(savedColumns.widths || {});\n if(browserViewPrefs.columnWidths) columnWidths = normalizeColumnWidths({...columnWidths, ...browserViewPrefs.columnWidths});\n function mobileSortStepId(step){ return `${step.key}:${step.dir}`; }\n function normalizeMobileSortFilters(value={}){\n const normalized = Object.fromEntries(MOBILE_SORT_STEPS.map(step => {\n const id = mobileSortStepId(step);\n return [id, DEFAULT_MOBILE_SORT_FILTER_IDS.has(id)];\n }));\n Object.entries(value || {}).forEach(([id, enabled]) => { if(id in normalized) normalized[id] = !!enabled; });\n return normalized;\n }\n let mobileSortFilters = normalizeMobileSortFilters(savedColumns.mobileSortFilters || {});\n if(browserViewPrefs.mobileSortFilters) mobileSortFilters = normalizeMobileSortFilters({...mobileSortFilters, ...browserViewPrefs.mobileSortFilters});\n const DEFAULT_MOBILE_COLUMNS = new Set([\"status\",\"progress\",\"down_rate\",\"up_rate\",\"eta\",\"seeds\",\"peers\",\"ratio\",\"path\"]);\n const MOBILE_COLUMN_DEFS = COLUMN_DEFS.map(([key,label]) => [key, label, DEFAULT_MOBILE_COLUMNS.has(key)]);\n function normalizeMobileColumns(value={}){\n const normalized = {...Object.fromEntries(MOBILE_COLUMN_DEFS.map(([key,,shown])=>[key, shown]))};\n Object.entries(value || {}).forEach(([key, shown])=>{\n if(key === \"speed\"){ normalized.down_rate = !!shown; normalized.up_rate = !!shown; }\n else if(key === \"seed_peer\"){ normalized.seeds = !!shown; normalized.peers = !!shown; }\n else if(key in normalized) normalized[key] = !!shown;\n });\n return normalized;\n }\n let mobileColumns = normalizeMobileColumns(savedColumns.mobile || {});\n if(browserViewPrefs.mobileColumns) mobileColumns = normalizeMobileColumns({...mobileColumns, ...browserViewPrefs.mobileColumns});\n let mobileSmartFiltersEnabled = browserViewPrefs.mobileSmartFiltersEnabled ?? savedColumns.mobileSmartFiltersEnabled ?? true;\n";
|
||||
@@ -1 +1 @@
|
||||
export const createTorrentSource = " function isCreateTorrentTabActive(){\n return $('createTorrentPane')?.classList.contains('active');\n }\n function syncAddAndCreateActions(){\n const createActive = isCreateTorrentTabActive();\n $('addBtn')?.classList.toggle('d-none', !!createActive);\n $('createTorrentBtn')?.classList.toggle('d-none', !createActive);\n }\n function createTorrentPayload(){\n const fd = new FormData();\n fd.append('source_path', $('createSourcePath')?.value || '');\n fd.append('trackers', $('createTrackers')?.value || '');\n fd.append('comment', $('createComment')?.value || '');\n fd.append('source', $('createSourceName')?.value || '');\n fd.append('piece_size_kib', $('createPieceSize')?.value || '256');\n fd.append('private', $('createPrivate')?.checked ? '1' : '0');\n fd.append('share', $('createShare')?.checked ? '1' : '0');\n fd.append('label', $('createLabel')?.value || '');\n return fd;\n }\n function downloadCreatedTorrent(blob,name){\n const obj = URL.createObjectURL(blob);\n const a = document.createElement('a');\n a.href = obj;\n a.download = name;\n document.body.appendChild(a);\n a.click();\n a.remove();\n setTimeout(()=>URL.revokeObjectURL(obj), 1000);\n }\n async function createTorrentFromModal(){\n const btn = $('createTorrentBtn');\n const info = $('createTorrentInfo');\n buttonBusy(btn, true);\n setBusy(true, 'Creating torrent...');\n if(info) info.textContent = 'Creating .torrent file...';\n try{\n const res = await fetch('/api/torrents/create', {method: 'POST', body: createTorrentPayload()});\n if(!res.ok){\n const j = await res.json().catch(()=>({}));\n throw new Error(j.error || `Create failed (${res.status})`);\n }\n const name = filenameFromResponse(res, 'created.torrent');\n const message = res.headers.get('X-PyTorrent-Create-Message') || 'Torrent created';\n const blob = await res.blob();\n downloadCreatedTorrent(blob, name);\n if(info) info.textContent = message;\n toast(message, 'success');\n }catch(e){\n if(info) info.textContent = e.message;\n toast(e.message, 'danger');\n }finally{\n setBusy(false);\n buttonBusy(btn, false);\n }\n }\n $('addModal')?.addEventListener('shown.bs.modal', syncAddAndCreateActions);\n document.querySelectorAll('#addModal [data-bs-toggle=\"pill\"]').forEach(tab => tab.addEventListener('shown.bs.tab', syncAddAndCreateActions));\n $('createTorrentBtn')?.addEventListener('click', createTorrentFromModal);\n";
|
||||
export const createTorrentSource = " function isCreateTorrentTabActive(){\n return $('createTorrentPane')?.classList.contains('active');\n }\n function syncAddAndCreateActions(){\n // Note: Keeps footer actions scoped to the currently selected Add/Create tab.\n const createActive = isCreateTorrentTabActive();\n $('addBtn')?.classList.toggle('d-none', !!createActive);\n $('clearAddTorrentBtn')?.classList.toggle('d-none', !!createActive);\n $('createTorrentBtn')?.classList.toggle('d-none', !createActive);\n }\n function createTorrentPayload(){\n const fd = new FormData();\n fd.append('source_path', $('createSourcePath')?.value || '');\n fd.append('trackers', $('createTrackers')?.value || '');\n fd.append('comment', $('createComment')?.value || '');\n fd.append('source', $('createSourceName')?.value || '');\n fd.append('piece_size_kib', $('createPieceSize')?.value || '256');\n fd.append('private', $('createPrivate')?.checked ? '1' : '0');\n fd.append('share', $('createShare')?.checked ? '1' : '0');\n fd.append('label', $('createLabel')?.value || '');\n return fd;\n }\n function downloadCreatedTorrent(blob,name){\n const obj = URL.createObjectURL(blob);\n const a = document.createElement('a');\n a.href = obj;\n a.download = name;\n document.body.appendChild(a);\n a.click();\n a.remove();\n setTimeout(()=>URL.revokeObjectURL(obj), 1000);\n }\n async function createTorrentFromModal(){\n const btn = $('createTorrentBtn');\n const info = $('createTorrentInfo');\n buttonBusy(btn, true);\n setBusy(true, 'Creating torrent...');\n if(info) info.textContent = 'Creating .torrent file...';\n try{\n const res = await fetch('/api/torrents/create', {method: 'POST', body: createTorrentPayload()});\n if(!res.ok){\n const j = await res.json().catch(()=>({}));\n throw new Error(j.error || `Create failed (${res.status})`);\n }\n const name = filenameFromResponse(res, 'created.torrent');\n const message = res.headers.get('X-PyTorrent-Create-Message') || 'Torrent created';\n const blob = await res.blob();\n downloadCreatedTorrent(blob, name);\n if(info) info.textContent = message;\n toast(message, 'success');\n }catch(e){\n if(info) info.textContent = e.message;\n toast(e.message, 'danger');\n }finally{\n setBusy(false);\n buttonBusy(btn, false);\n }\n }\n $('addModal')?.addEventListener('shown.bs.modal', syncAddAndCreateActions);\n document.querySelectorAll('#addModal [data-bs-toggle=\"pill\"]').forEach(tab => tab.addEventListener('shown.bs.tab', syncAddAndCreateActions));\n $('createTorrentBtn')?.addEventListener('click', createTorrentFromModal);\n";
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
||||
export const dashboardToolsSource = "function ensureDashboardToolsUI(){\n const host=$('toolRss')?.parentElement || document.querySelector('#toolsModal .modal-body');\n if(!host) return;\n addToolTab('smartviews','fa-layer-group','Smart Views','torrentstats');\n addToolTab('notifications','fa-bell','Notifications','appstatus');\n const stats=$('toolTorrentStats');\n if(stats && !$('healthDashboardManager')){\n const section=document.createElement('div');\n section.className='surface-section mt-3';\n section.innerHTML='<div class=\"section-title\"><i class=\"fa-solid fa-heart-pulse\"></i> Torrent health</div><div class=\"tool-note mb-3\">Live health buckets calculated from the current torrent snapshot.</div><div id=\"healthDashboardManager\"></div>';\n stats.appendChild(section);\n section.addEventListener('click',e=>{ const tab=e.target.closest('[data-health-pane]'); if(tab){ const pane=tab.dataset.healthPane; section.querySelectorAll('[data-health-pane]').forEach(x=>x.classList.toggle('active',x.dataset.healthPane===pane)); section.querySelectorAll('[data-health-panel]').forEach(x=>x.classList.toggle('d-none',x.dataset.healthPanel!==pane)); return; } const row=e.target.closest('[data-hash]'); if(!row) return; selectedHash=row.dataset.hash; selected.clear(); selected.add(selectedHash); scheduleRender(true); });\n }\n if(!$('toolSmartviews')){\n const p=document.createElement('div');\n p.id='toolSmartviews';\n p.className='d-none';\n p.innerHTML='<div class=\"surface-section\"><div class=\"section-title\"><i class=\"fa-solid fa-layer-group\"></i> Smart Views</div><div class=\"tool-note mb-3\">One-click filters for common torrent maintenance tasks.</div><div id=\"smartViewsManager\"></div></div>';\n host.appendChild(p);\n p.addEventListener('click',e=>{ const card=e.target.closest('.smart-view-card'); if(!card) return; activeTrackerFilter=''; activeFilter=card.dataset.filter||'all'; mobileActiveFilterKey=activeFilter; saveActiveFilterPreference(); syncFilterButtons(); scheduleRender(true); renderSmartViewsManager(); });\n }\n if(!$('toolNotifications')){\n const p=document.createElement('div');\n p.id='toolNotifications';\n p.className='d-none';\n p.innerHTML='<div class=\"surface-section\"><div class=\"section-title\"><i class=\"fa-solid fa-bell\"></i> Notification center</div><div class=\"tool-note mb-3\">Persistent local history for rTorrent, RSS, automation, disk, queue, planner and port events.</div><div id=\"notificationCenterManager\"></div></div>';\n host.appendChild(p);\n }\n renderHealthDashboard();\n renderSmartViewsManager();\n renderNotificationCenter();\n updateNotificationBadge();\n}\n";
|
||||
@@ -0,0 +1 @@
|
||||
export const diagnosticsDashboardSource = "function diagnosticsSection(title, cards){\n return `<section class=\"diagnostics-section\"><div class=\"section-title\"><i class=\"fa-solid fa-stethoscope\"></i> ${esc(title)}</div><div class=\"diag-grid\">${cards.join('')}</div></section>`;\n}\nasync function loadDiagnosticsPage(){\n const box=$('diagnosticsPageManager');\n if(!box) return;\n box.innerHTML='<span class=\"spinner-border spinner-border-sm\"></span> Loading diagnostics...';\n try{\n const [status,poller,planner,smart]=await Promise.all([\n fetch('/api/app/status?cleanup=1').then(r=>r.json()),\n fetch('/api/poller/settings').then(r=>r.json()).catch(()=>({})),\n fetch('/api/download-planner/preview').then(r=>r.json()).catch(()=>({})),\n fetch('/api/smart-queue?history_limit=100').then(r=>r.json()).catch(()=>({ok:false})),\n ]);\n if(status && status.ok===false) throw new Error(status.error||'Failed to load diagnostics');\n const st=status.status||{}, profile=st.profile||{}, py=st.pytorrent||{}, scgi=st.scgi||{}, cleanup=st.cleanup||{}, db=cleanup.database||{}, pc=st.port_check||{};\n const rt=poller.runtime||{}, ps=poller.settings||{}, pv=planner.preview||{}, smartStats=smart?.ok?buildSmartQueueNerdStats(smart.history||[], Number(smart.history_total||0)):null;\n const profileCards=[diagCard('Active profile', profile.name||profile.id||'-'), diagCard('API response time', `${st.api_ms ?? '-'} ms`), diagCard('Incoming port', pc.port||'-'), diagCard('Port status', portStatusLabel(pc.status), pc.status==='closed'?'diag-error':'')];\n const rtCards=[diagCard('SCGI status', scgi.ok?'OK':'ERROR', scgi.ok?'':'diag-error'), diagCard('SCGI URL', scgi.url||'-'), diagCard('Connect', scgi.connect_ms!=null?`${scgi.connect_ms} ms`:'-'), diagCard('First byte', scgi.first_byte_ms!=null?`${scgi.first_byte_ms} ms`:'-'), diagCard('Total', scgi.total_ms!=null?`${scgi.total_ms} ms`:'-'), diagCard('Request bytes', scgi.request_bytes), diagCard('Response bytes', scgi.response_bytes), diagCard('XML bytes', scgi.xml_bytes), diagCard('rTorrent version', scgi.client_version||'-')];\n const pollerReady=rt.runtime_ready!==false && (Number(rt.tick_count||0)>0 || Number(rt.live_poll_count||0)>0 || Number(rt.list_poll_count||0)>0);\n const pollerWaiting=!pollerReady && rt.runtime_ready===false;\n const pollerCards=[diagCard('Adaptive', (rt.adaptive_enabled ?? ps.adaptive_enabled)===false?'off':'on'), diagCard('Mode', pollerWaiting?'waiting':(rt.adaptive_mode||'-')), diagCard('Effective interval', `${rt.effective_interval_seconds??rt.live_stats_interval_seconds??ps.live_stats_interval_seconds??'-'}s`), diagCard('Minimum interval', `${rt.configured_min_interval_seconds??'-'}s`), diagCard('Tick duration', pollerWaiting?'waiting':`${rt.duration_ms||rt.last_tick_ms||0} ms`), diagCard('Tick gap', pollerWaiting?'waiting':`${rt.last_tick_gap_ms||0} ms`), diagCard('Payload', pollerWaiting?'waiting':fmtBytes(rt.emitted_payload_size||0)), diagCard('rTorrent calls', pollerWaiting?'waiting':(rt.rtorrent_call_count||0)), diagCard('Skipped emissions', rt.skipped_emissions||0), diagCard('Ticks', rt.tick_count||0)];\n const plannerCards=[diagCard('Matched rule', pv.matched_rule||'-'), diagCard('Next change', pv.next_change_at||'-'), diagCard('Planner state', pv.enabled===false?'disabled':'enabled')];\n const databaseCards=[diagCard('DB size', db.size_h||'-'), diagCard('Job logs', cleanup.jobs_clearable ?? '-'), diagCard('Smart Queue logs', cleanup.smart_queue_history_total ?? '-'), diagCard('Automation logs', cleanup.automation_history_total ?? '-')];\n const workerCards=[diagCard('Worker threads', py.worker_threads ?? '-'), diagCard('Jobs total', py.jobs_total ?? '-'), diagCard('Threads', py.threads ?? '-'), diagCard('CPU', `${py.cpu_percent ?? '-'}%`)];\n const smartBlock=`<section class=\"diagnostics-section\"><div class=\"section-title\"><i class=\"fa-solid fa-list-check\"></i> Smart Queue decisions</div>${renderSmartQueueNerdStats(smartStats)}</section>`;\n box.innerHTML=[diagnosticsSection('Profile and port',profileCards), diagnosticsSection('rTorrent connection',rtCards), diagnosticsSection('Adaptive poller',pollerCards), diagnosticsSection('Planner',plannerCards), diagnosticsSection('Database and cleanup',databaseCards), diagnosticsSection('Worker state',workerCards), smartBlock, scgi.error?`<div class=\"alert alert-danger mt-3 mb-0\">${esc(scgi.error)}</div>`:''].join('');\n }catch(e){ box.innerHTML=`<div class=\"alert alert-danger mb-0\">${esc(e.message)}</div>`; }\n}\n";
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
||||
export const footerPreferencesSource = " function applyFooterPreferences(){\n document.querySelectorAll('[data-footer-item]').forEach(el=>{\n const key=el.dataset.footerItem;\n el.classList.toggle('footer-pref-hidden', footerItems[key] === false);\n });\n }\n function renderFooterPreferences(){\n const box=$('footerPreferences');\n if(!box) return;\n box.innerHTML=FOOTER_ITEM_DEFS.map(([key,label])=>`<label class=\"footer-pref-card form-check form-switch ${footerItems[key]===false?'':'active'}\"><input class=\"form-check-input footer-pref-toggle\" type=\"checkbox\" data-footer-key=\"${esc(key)}\" ${footerItems[key]===false?'':'checked'}><span class=\"form-check-label\">${esc(label)}</span></label>`).join('');\n }\n async function saveFooterPreferences(){\n document.querySelectorAll('.footer-pref-toggle').forEach(cb=>{ footerItems[cb.dataset.footerKey] = !!cb.checked; });\n applyFooterPreferences();\n renderFooterPreferences();\n try{ await post('/api/preferences',{footer_items_json:footerItems}); toast('Footer preferences saved','success'); }\n catch(e){ toast(e.message,'danger'); }\n }";
|
||||
@@ -0,0 +1 @@
|
||||
export const footerStatusRefreshSource = " function rtorrentPairText(current, max){\n if(current == null) return '-';\n return max == null ? String(current) : `${current}/${max}`;\n }\n function footerStatusUpdatedText(s={}){\n const value=s.footer_updated_at || s.updated_at;\n if(!value) return '';\n const date=new Date(value);\n return Number.isNaN(date.getTime()) ? '' : ` · last known ${date.toLocaleString()}`;\n }\n function updateRtorrentFooterStats(s={}, cached=false){\n const suffix=cached ? footerStatusUpdatedText(s) : '';\n const sockets=rtorrentPairText(s.open_sockets, s.max_open_sockets);\n if($('statSockets')) $('statSockets').textContent=sockets;\n if($('statusSockets')) $('statusSockets').title=s.open_sockets == null ? `Open sockets unavailable${suffix}` : `Open rTorrent sockets${s.max_open_sockets == null ? '' : ' / max'}: ${sockets}${suffix}`;\n if($('statRtDownloads')) $('statRtDownloads').textContent=rtorrentPairText(s.active_downloads, s.max_downloads_global);\n if($('statusRtDownloads')) $('statusRtDownloads').title=`Active rTorrent downloads / max global downloads${suffix}`;\n if($('statRtUploads')) $('statRtUploads').textContent=rtorrentPairText(s.active_uploads, s.max_uploads_global);\n if($('statusRtUploads')) $('statusRtUploads').title=`Active rTorrent uploads / max global uploads${suffix}`;\n if($('statRtHttp')) $('statRtHttp').textContent=rtorrentPairText(s.open_http, s.max_open_http);\n if($('statusRtHttp')) $('statusRtHttp').title=`Open rTorrent HTTP connections / max HTTP connections${suffix}`;\n if($('statRtFiles')) $('statRtFiles').textContent=rtorrentPairText(s.open_files, s.max_open_files);\n if($('statusRtFiles')) $('statusRtFiles').title=`Open rTorrent files / max open files${suffix}`;\n if($('statRtPort')) $('statRtPort').textContent=(s.listen_port ?? '-') || '-';\n if($('statusRtPort')) $('statusRtPort').title=`rTorrent incoming port${suffix}`;\n if(cached){\n if(s.cpu!==undefined && $('statCpu')) $('statCpu').textContent=s.cpu;\n if(s.ram!==undefined && $('statRam')) $('statRam').textContent=s.ram;\n if(s.version!==undefined && $('statVersion')) $('statVersion').textContent=s.version || '-';\n if(s.down_rate_h!==undefined && $('statDl')) $('statDl').textContent=s.down_rate_h || '0 B/s';\n if(s.up_rate_h!==undefined && $('statUl')) $('statUl').textContent=s.up_rate_h || '0 B/s';\n if(s.down_rate_h!==undefined && $('mobileSpeedDl')) $('mobileSpeedDl').textContent=s.down_rate_h || '0 B/s';\n if(s.up_rate_h!==undefined && $('mobileSpeedUl')) $('mobileSpeedUl').textContent=s.up_rate_h || '0 B/s';\n updateBrowserSpeedTitle(s.down_rate_h, s.up_rate_h);\n }\n }\n function saveFooterStatusCache(s={}){\n const payload={\n open_sockets:s.open_sockets, max_open_sockets:s.max_open_sockets,\n active_downloads:s.active_downloads, max_downloads_global:s.max_downloads_global,\n active_uploads:s.active_uploads, max_uploads_global:s.max_uploads_global,\n open_http:s.open_http, max_open_http:s.max_open_http,\n open_files:s.open_files, max_open_files:s.max_open_files,\n listen_port:s.listen_port,\n cpu:s.cpu, ram:s.ram, version:s.version,\n down_rate_h:s.down_rate_h, up_rate_h:s.up_rate_h,\n footer_updated_at:new Date().toISOString()\n };\n try{ localStorage.setItem(footerStatusStorageKey(), JSON.stringify(payload)); }catch(_){}\n }\n function restoreFooterStatusCache(){\n try{\n const cached=JSON.parse(localStorage.getItem(footerStatusStorageKey())||'null');\n if(cached && typeof cached==='object') updateRtorrentFooterStats(cached, true);\n }catch(_){}\n }\n async function refreshFooterStatusNow(){\n try{\n const res=await fetch('/api/system/status', {cache:'no-store'});\n const j=await res.json();\n const s=j.status||{};\n if(j.ok && s){\n updateRtorrentFooterStats(s, false);\n saveFooterStatusCache(s);\n applyFooterPreferences();\n }\n }catch(_){}\n }\n";
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
||||
export const jobSettingsSource = " async function activeProfileForSettings(){\n const j=await (await fetch('/api/profiles')).json();\n return j.active || (j.profiles||[])[0] || null;\n }\n function fillJobSettings(profile){\n if(!profile) return;\n if($('jobHeavyParallel')) $('jobHeavyParallel').value=profile.max_parallel_jobs||5;\n if($('jobLightParallel')) $('jobLightParallel').value=profile.light_parallel_jobs||4;\n if($('jobLightTimeout')) $('jobLightTimeout').value=profile.light_job_timeout_seconds||300;\n if($('jobHeavyTimeout')) $('jobHeavyTimeout').value=profile.heavy_job_timeout_seconds||7200;\n if($('jobPendingTimeout')) $('jobPendingTimeout').value=profile.pending_job_timeout_seconds||900;\n if($('jobSettingsProfileName')) $('jobSettingsProfileName').textContent=profile.name?`Active profile: ${profile.name}`:'';\n }\n async function loadJobSettings(){\n try{\n const profile=await activeProfileForSettings();\n if(!profile){ if($('jobSettingsProfileName')) $('jobSettingsProfileName').textContent='No active profile.'; return; }\n fillJobSettings(profile);\n }catch(e){ if($('jobSettingsProfileName')) $('jobSettingsProfileName').textContent=e.message; }\n }\n function jobSettingsPayload(profile){\n return {\n name:profile.name,\n scgi_url:profile.scgi_url,\n timeout_seconds:profile.timeout_seconds||5,\n max_parallel_jobs:$('jobHeavyParallel')?.value||5,\n light_parallel_jobs:$('jobLightParallel')?.value||4,\n light_job_timeout_seconds:$('jobLightTimeout')?.value||300,\n heavy_job_timeout_seconds:$('jobHeavyTimeout')?.value||7200,\n pending_job_timeout_seconds:$('jobPendingTimeout')?.value||900,\n is_remote:!!profile.is_remote,\n is_default:!!profile.is_default\n };\n }\n async function saveJobSettings(){\n const btn=$('saveJobSettingsBtn');\n buttonBusy(btn,true);\n try{\n const profile=await activeProfileForSettings();\n if(!profile) throw new Error('No active profile');\n const j=await post(`/api/profiles/${profile.id}`,jobSettingsPayload(profile),'PUT');\n fillJobSettings(j.profile||profile);\n await refreshProfiles();\n toast('Job settings saved','success');\n }catch(e){ toast(e.message,'danger'); }\n finally{ buttonBusy(btn,false); }\n }\n";
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
||||
export const keyboardEventsSource = "document.addEventListener('keydown',e=>{ const tag=(e.target?.tagName||'').toLowerCase(); const editable=tag==='input'||tag==='textarea'||tag==='select'||e.target?.isContentEditable; if(editable){ if(e.key==='Enter' && e.target?.id==='labelInput'){ e.preventDefault(); $('addLabelToSelectionBtn')?.click(); } return; } if((e.ctrlKey||e.metaKey)&&e.key.toLowerCase()==='a'){e.preventDefault();selected.clear();visibleRows.forEach(t=>selected.add(t.hash));scheduleRender(true);} if((e.ctrlKey||e.metaKey)&&e.key.toLowerCase()==='i'){e.preventDefault();visibleRows.forEach(t=>selected.has(t.hash)?selected.delete(t.hash):selected.add(t.hash));scheduleRender(true);} if((e.ctrlKey||e.metaKey)&&e.key.toLowerCase()==='o'){e.preventDefault();new bootstrap.Modal($('addModal')).show();} if((e.ctrlKey||e.metaKey)&&e.key.toLowerCase()==='s'){e.preventDefault();downloadTorrentFiles();return;} if(e.key==='Escape'){selected.clear();scheduleRender(true);} if(e.key==='Delete') new bootstrap.Modal($('removeModal')).show(); if(e.key===' ') {e.preventDefault();runAction('start');} if(e.key.toLowerCase()==='p')runAction('pause'); if(e.key.toLowerCase()==='s' && !(e.ctrlKey||e.metaKey))runAction('stop'); if(e.key.toLowerCase()==='r')runAction('resume'); if(e.key.toLowerCase()==='m')runAction('move'); });\n $('removeModal')?.addEventListener('show.bs.modal',()=>{$('removeCount').textContent=selected.size;$('removeData').checked=true;}); $('confirmRemoveBtn')?.addEventListener('click',async()=>{await runAction('remove',{remove_data:$('removeData').checked});bootstrap.Modal.getInstance($('removeModal'))?.hide();});\n $('addModal')?.addEventListener('show.bs.modal',()=>applyDefaultDownloadPath(true));\n\n $('toolsModal')?.addEventListener('show.bs.modal',()=>applyDefaultDownloadPath(false));\n // Note: Torrent add modal and drag/drop upload handling moved to torrentAdd.js.\n ";
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user