Compare commits
167 Commits
7401feff63
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | |||
| ac5113055d | |||
| d7ac0f18e9 | |||
| af20e55539 | |||
| f4d8611240 | |||
| 6ab330f583 | |||
| 07c23a8d25 | |||
| 559ccd77f3 | |||
| 88ea802192 | |||
| 366b9906bb | |||
| 4cff530b0e | |||
| 0a82211e4c | |||
| 44ebb6afb0 | |||
| 9cf3be8fc6 | |||
| 94f81911a1 |
+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
|
||||
@@ -1 +0,0 @@
|
||||
scripts/INSTALL.md
|
||||
@@ -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
|
||||
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=8091 \
|
||||
RTORRENT_SCGI_PORT=5001 \
|
||||
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
|
||||
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
|
||||
```env
|
||||
PYTORRENT_GEOIP_DB=data/GeoLite2-City.mmdb
|
||||
```
|
||||
|
||||
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.
|
||||
## OpenAPI
|
||||
|
||||
`/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.
|
||||
OpenAPI documentation is available at:
|
||||
|
||||
## Admin CLI
|
||||
```text
|
||||
/docs
|
||||
/api/openapi.json
|
||||
```
|
||||
|
||||
Reset an existing user's password:
|
||||
The API includes profile management, torrent actions, preferences, port checks, system status, planner, poller, RSS, backups and diagnostics endpoints.
|
||||
|
||||
## Configuration reference
|
||||
|
||||
Common environment variables:
|
||||
|
||||
```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
|
||||
```
|
||||
|
||||
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 -m pytorrent.cli reset-password admin new_password
|
||||
python app.py
|
||||
```
|
||||
|
||||
Without the password argument, the CLI asks for it interactively:
|
||||
Run a quick Python compile check:
|
||||
|
||||
```bash
|
||||
python -m pytorrent.cli reset-password admin
|
||||
python -m compileall pytorrent app.py wsgi.py
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
## API authentication tokens
|
||||
|
||||
When `PYTORRENT_AUTH_ENABLE=0`, API endpoints work without authentication.
|
||||
|
||||
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:
|
||||
Download offline frontend assets when needed:
|
||||
|
||||
```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
|
||||
python scripts/download_frontend_libs.py
|
||||
```
|
||||
|
||||
Token permissions follow the owning user's role and rTorrent profile permissions. Revoked tokens stop working immediately.
|
||||
## 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.
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 438 KiB |
+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)}")
|
||||
|
||||
|
||||
|
||||
+9
-11
@@ -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
|
||||
|
||||
+1
-1
@@ -106,7 +106,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
-1
@@ -29,6 +29,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 +86,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")
|
||||
|
||||
+133
-176
@@ -4,12 +4,17 @@ 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,31 +56,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,
|
||||
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,
|
||||
@@ -121,8 +140,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,
|
||||
@@ -130,10 +149,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,
|
||||
@@ -169,8 +188,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,
|
||||
@@ -184,8 +202,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,
|
||||
@@ -202,13 +219,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,
|
||||
@@ -218,8 +234,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 (
|
||||
@@ -251,17 +266,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,
|
||||
@@ -277,12 +297,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 (
|
||||
@@ -303,19 +328,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,
|
||||
@@ -326,7 +349,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,
|
||||
@@ -404,14 +427,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);
|
||||
|
||||
@@ -420,6 +442,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,
|
||||
@@ -428,6 +457,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,
|
||||
@@ -457,6 +487,49 @@ CREATE TABLE IF NOT EXISTS tracker_summary_cache (
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_tracker_summary_cache_profile ON tracker_summary_cache(profile_id, updated_epoch);
|
||||
|
||||
|
||||
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,
|
||||
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,
|
||||
@@ -468,128 +541,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 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)",
|
||||
]
|
||||
|
||||
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)",
|
||||
]
|
||||
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")
|
||||
@@ -615,36 +590,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
|
||||
|
||||
@@ -8,7 +8,7 @@ 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 +33,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,149 @@
|
||||
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
|
||||
|
||||
|
||||
MIGRATIONS: tuple[Migration, ...] = (
|
||||
migrate_disk_monitor_preferences_to_profile_scope,
|
||||
migrate_profile_preferences_sidebar_columns,
|
||||
migrate_operation_log_split_retention,
|
||||
)
|
||||
|
||||
|
||||
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
|
||||
+1967
-8
File diff suppressed because it is too large
Load Diff
+55
-23
@@ -18,11 +18,12 @@ 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 ..config import DB_PATH, JOBS_RETENTION_DAYS, SMART_QUEUE_HISTORY_RETENTION_DAYS, WORKERS, PYTORRENT_TMP_DIR
|
||||
from flask import Blueprint, jsonify, request, abort, send_file, redirect, Response, stream_with_context, url_for
|
||||
# Note: url_for is exported through this shared module for API routes that build temporary in-app links.
|
||||
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
|
||||
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 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
|
||||
@@ -248,30 +249,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))
|
||||
@@ -281,20 +289,44 @@ 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)
|
||||
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"),
|
||||
"automation_history_total": _table_count("automation_history"),
|
||||
"planner_history_total": download_planner.history_count(int((preferences.active_profile() or {}).get("id") or 0)) if preferences.active_profile() else 0,
|
||||
"cache": _active_profile_cache_summary(),
|
||||
"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": automation_history_total,
|
||||
"planner_history_total": download_planner.history_count(profile_id) if profile_id else 0,
|
||||
"cache": cache_summary,
|
||||
"poller_runtime": poller_runtime,
|
||||
"retention_days": {
|
||||
"jobs": JOBS_RETENTION_DAYS,
|
||||
"smart_queue_history": SMART_QUEUE_HISTORY_RETENTION_DAYS,
|
||||
"operation_logs": operation_log_retention.get("retention_days", LOG_RETENTION_DAYS),
|
||||
"automation_history": SMART_QUEUE_HISTORY_RETENTION_DAYS,
|
||||
"planner_history": SMART_QUEUE_HISTORY_RETENTION_DAYS,
|
||||
},
|
||||
"operation_log_retention": operation_log_retention,
|
||||
"retention_labels": {
|
||||
"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:
|
||||
|
||||
@@ -10,5 +10,6 @@ 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
|
||||
|
||||
__all__ = ["bp"]
|
||||
|
||||
@@ -2,7 +2,7 @@ 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 +21,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 +42,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():
|
||||
|
||||
@@ -2,6 +2,11 @@ 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
|
||||
@@ -9,12 +14,15 @@ def automations_get():
|
||||
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
|
||||
@@ -22,14 +30,12 @@ def automations_export():
|
||||
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
|
||||
@@ -39,14 +45,13 @@ def automations_import():
|
||||
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
|
||||
@@ -54,13 +59,13 @@ def automations_save():
|
||||
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
|
||||
@@ -68,13 +73,13 @@ def automations_delete(rule_id: int):
|
||||
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
|
||||
@@ -82,8 +87,12 @@ def automations_run_rule(rule_id: int):
|
||||
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
|
||||
|
||||
@@ -95,13 +104,16 @@ def automations_check():
|
||||
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
|
||||
@@ -109,8 +121,8 @@ def automations_history_clear():
|
||||
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
|
||||
|
||||
+75
-14
@@ -1,31 +1,96 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from ._shared import *
|
||||
from ..services import auth
|
||||
|
||||
|
||||
def _active_profile_id() -> int | None:
|
||||
profile = preferences.active_profile()
|
||||
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()
|
||||
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())})
|
||||
# Note: Legacy endpoint now creates a profile backup so non-admin users cannot capture other users' settings.
|
||||
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()
|
||||
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 +101,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()
|
||||
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 +118,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 +125,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
|
||||
|
||||
|
||||
|
||||
+277
-5
@@ -1,10 +1,16 @@
|
||||
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
|
||||
@@ -18,6 +24,141 @@ 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 +174,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 +215,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>"""
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from ._shared import *
|
||||
from ..services import operation_logs
|
||||
|
||||
|
||||
def _active_profile_or_400():
|
||||
profile = preferences.active_profile()
|
||||
if not profile:
|
||||
return None
|
||||
return profile
|
||||
|
||||
|
||||
@bp.get("/operation-logs")
|
||||
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"})
|
||||
data = operation_logs.list_logs(
|
||||
int(profile["id"]),
|
||||
limit=int(request.args.get("limit") or 200),
|
||||
offset=int(request.args.get("offset") or 0),
|
||||
event_type=str(request.args.get("type") or "").strip(),
|
||||
q=str(request.args.get("q") or "").strip(),
|
||||
hide_jobs=str(request.args.get("hide_jobs") or "").lower() in {"1", "true", "yes", "on"},
|
||||
)
|
||||
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
|
||||
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")
|
||||
def operation_logs_clear():
|
||||
profile = _active_profile_or_400()
|
||||
if not profile:
|
||||
return jsonify({"ok": False, "error": "No profile"}), 400
|
||||
event_type = str((request.get_json(silent=True) or {}).get("event_type") or "").strip()
|
||||
return ok({"deleted": operation_logs.clear(int(profile["id"]), event_type=event_type)})
|
||||
|
||||
|
||||
@bp.post("/operation-logs/apply-retention")
|
||||
def operation_logs_apply_retention():
|
||||
profile = _active_profile_or_400()
|
||||
if not profile:
|
||||
return jsonify({"ok": False, "error": "No profile"}), 400
|
||||
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))
|
||||
@@ -2,6 +2,7 @@ from __future__ import annotations
|
||||
|
||||
from ._shared import *
|
||||
from ..services.rtorrent.diagnostics import profile_diagnostics
|
||||
from ..services import auth
|
||||
|
||||
@bp.get("/profiles")
|
||||
def profiles_list():
|
||||
@@ -108,8 +109,19 @@ def prefs_table_columns_recommended():
|
||||
def labels_list():
|
||||
profile = preferences.active_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})
|
||||
|
||||
|
||||
@@ -123,9 +135,15 @@ def labels_save():
|
||||
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()
|
||||
|
||||
|
||||
@@ -134,8 +152,10 @@ def labels_save():
|
||||
def labels_delete(label_id: int):
|
||||
profile = preferences.active_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()
|
||||
|
||||
|
||||
@@ -145,8 +165,17 @@ def ratio_groups_list():
|
||||
profile = preferences.active_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})
|
||||
|
||||
|
||||
@@ -160,14 +189,23 @@ def ratio_groups_save():
|
||||
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()
|
||||
|
||||
|
||||
|
||||
+64
-23
@@ -2,65 +2,109 @@ from __future__ import annotations
|
||||
|
||||
from ._shared import *
|
||||
|
||||
|
||||
def _active_profile_or_400():
|
||||
profile = preferences.active_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 +115,9 @@ def rss_rule_test():
|
||||
return jsonify({"ok": False, "error": str(exc)}), 400
|
||||
|
||||
|
||||
|
||||
@bp.post("/rss/check")
|
||||
def rss_check():
|
||||
profile = preferences.active_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))
|
||||
|
||||
@@ -14,7 +14,7 @@ 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': []})
|
||||
|
||||
@@ -29,7 +29,7 @@ def smart_queue_save():
|
||||
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)})
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from ._shared import *
|
||||
from ..services import operation_logs
|
||||
from ..services.frontend_assets import static_hash
|
||||
|
||||
@bp.get("/system/disk")
|
||||
def system_disk():
|
||||
@@ -45,6 +47,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.
|
||||
@@ -77,6 +86,7 @@ def app_status():
|
||||
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,
|
||||
@@ -94,10 +104,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)
|
||||
@@ -194,20 +205,47 @@ 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 = preferences.active_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()})
|
||||
|
||||
|
||||
|
||||
@bp.post("/cleanup/operation-logs")
|
||||
def cleanup_operation_logs():
|
||||
profile = preferences.active_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.
|
||||
deleted = operation_logs.clear(int(profile["id"]))
|
||||
return ok({"deleted": deleted, "cleanup": cleanup_summary()})
|
||||
|
||||
|
||||
|
||||
@bp.post("/cleanup/planner")
|
||||
def cleanup_planner():
|
||||
profile = preferences.active_profile()
|
||||
@@ -220,37 +258,56 @@ def cleanup_planner():
|
||||
|
||||
@bp.post("/cleanup/automations")
|
||||
def cleanup_automations():
|
||||
profile = preferences.active_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 = preferences.active_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()
|
||||
deleted_planner = download_planner.clear_history(int(active_profile["id"])) if active_profile else 0
|
||||
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
|
||||
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_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, "planner_history": deleted_planner, "automation_history": deleted_auto}, "cleanup": cleanup_summary()})
|
||||
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()})
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from ._shared import *
|
||||
from ..services import torrent_creator
|
||||
from ..services import pdf_preview_links, torrent_creator
|
||||
from ..services.reverse_dns import attach_reverse_dns
|
||||
|
||||
@bp.get("/torrents")
|
||||
def torrents():
|
||||
@@ -97,6 +98,29 @@ def torrent_files(torrent_hash: str):
|
||||
|
||||
|
||||
|
||||
@bp.get("/torrents/<torrent_hash>/files/<int:file_index>/mediainfo")
|
||||
def torrent_file_media_info(torrent_hash: str, file_index: int):
|
||||
profile = preferences.active_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()
|
||||
@@ -132,11 +156,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",
|
||||
}
|
||||
|
||||
@@ -185,6 +210,77 @@ 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 = preferences.active_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 = preferences.active_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 = preferences.active_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 = preferences.active_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()
|
||||
@@ -193,7 +289,10 @@ def torrent_file_download(torrent_hash: str, file_index: int):
|
||||
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():
|
||||
@@ -386,6 +485,10 @@ def torrent_peers(torrent_hash: str):
|
||||
peers = rtorrent.torrent_peers(profile, torrent_hash)
|
||||
for peer in peers:
|
||||
peer.update(lookup_ip(peer.get("ip", "")))
|
||||
prefs = preferences.get_preferences(profile_id=profile.get("id"))
|
||||
if int(prefs.get("reverse_dns_enabled") or 0):
|
||||
# Note: PTR hostnames are attached only when the user enables the lightweight cached resolver.
|
||||
attach_reverse_dns(peers)
|
||||
return ok({"peers": peers})
|
||||
|
||||
|
||||
|
||||
+266
-16
@@ -6,10 +6,20 @@ 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 +31,16 @@ 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.
|
||||
# Note: Planner, poller and operation-log endpoints are profile-scoped and must follow the active profile context.
|
||||
PROFILE_READ_PREFIXES = (
|
||||
"/api/torrents",
|
||||
"/api/torrent-stats",
|
||||
@@ -40,6 +55,9 @@ PROFILE_READ_PREFIXES = (
|
||||
"/api/smart-queue",
|
||||
"/api/traffic/history",
|
||||
"/api/automations",
|
||||
"/api/download-planner",
|
||||
"/api/poller/settings",
|
||||
"/api/operation-logs",
|
||||
)
|
||||
|
||||
|
||||
@@ -47,16 +65,77 @@ 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:
|
||||
# Note: Allows trusted direct-IP access to keep auth enabled for reverse-proxy traffic.
|
||||
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"])
|
||||
# Note: Keep direct-IP access usable after old installs, but never choose an inactive fallback.
|
||||
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():
|
||||
# Note: Background jobs and schedulers do not have Flask request/session state.
|
||||
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 +148,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 +232,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 +294,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 +309,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 +465,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 +492,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 +501,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 +526,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 +556,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 +589,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 +633,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 +679,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
|
||||
|
||||
@@ -3,7 +3,7 @@ from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
import json
|
||||
from ..db import connect, default_user_id, utcnow
|
||||
from . import rtorrent
|
||||
from . import rtorrent, auth
|
||||
from .preferences import active_profile
|
||||
from .workers import enqueue
|
||||
|
||||
@@ -11,16 +11,32 @@ AUTOMATION_JOB_CHUNK_SIZE = 100
|
||||
AUTOMATION_LIGHT_ACTIONS = {'start', 'stop', 'pause', 'resume', 'set_label'}
|
||||
|
||||
|
||||
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 +47,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 +56,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 +65,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 +170,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 +284,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 +344,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 +359,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 +386,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 +404,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 +421,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,28 +428,45 @@ 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 _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:
|
||||
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()
|
||||
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()
|
||||
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)
|
||||
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:
|
||||
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)]
|
||||
@@ -357,26 +479,28 @@ def check(profile: dict | None = None, user_id: int | None = None, force: bool =
|
||||
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.
|
||||
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, user_id)
|
||||
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:
|
||||
# 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}
|
||||
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]})
|
||||
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(?,?,?,?,?,?,?,?)', (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})
|
||||
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}
|
||||
|
||||
+445
-152
@@ -5,15 +5,58 @@ 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 +65,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 +95,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_access_profile(profile_id, user_id):
|
||||
raise PermissionError("No 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 +389,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 +431,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 +475,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 +566,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,130 @@
|
||||
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()
|
||||
@@ -8,7 +8,7 @@ from typing import Any
|
||||
import psutil
|
||||
|
||||
from ..db import connect, default_user_id, utcnow
|
||||
from . import rtorrent
|
||||
from . import auth, rtorrent
|
||||
|
||||
DEFAULTS = {
|
||||
"enabled": False,
|
||||
@@ -140,14 +140,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 +286,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 +301,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]:
|
||||
@@ -443,11 +463,14 @@ 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)}
|
||||
now = time.monotonic()
|
||||
interval = int(settings.get("check_interval_seconds") or 30)
|
||||
if not force and now - _LAST_RUN.get(profile_id, 0) < interval:
|
||||
@@ -497,13 +520,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,
|
||||
|
||||
@@ -13,18 +13,103 @@ FLAG_ICONS_VERSION = "7.2.3"
|
||||
SWAGGER_UI_VERSION = "5"
|
||||
SOCKET_IO_VERSION = "4.7.5"
|
||||
|
||||
BOOTSTRAP_THEMES = (
|
||||
"default",
|
||||
"flatly",
|
||||
"litera",
|
||||
"lumen",
|
||||
"minty",
|
||||
"sketchy",
|
||||
"solar",
|
||||
"spacelab",
|
||||
"united",
|
||||
"zephyr",
|
||||
GOOGLE_FONT_FAMILIES = (
|
||||
"DM Sans",
|
||||
"Figtree",
|
||||
"Geist",
|
||||
"IBM Plex Sans",
|
||||
"Inter",
|
||||
"JetBrains Mono",
|
||||
"Lato",
|
||||
"Manrope",
|
||||
"Montserrat",
|
||||
"Nunito Sans",
|
||||
"Open Sans",
|
||||
"Poppins",
|
||||
"Roboto",
|
||||
"Source Sans 3",
|
||||
)
|
||||
GOOGLE_FONT_WEIGHTS = "400;500;600;700;800"
|
||||
|
||||
|
||||
def google_fonts_css_url() -> str:
|
||||
families = "&".join(
|
||||
f"family={name.replace(' ', '+')}:wght@{GOOGLE_FONT_WEIGHTS}"
|
||||
for name in GOOGLE_FONT_FAMILIES
|
||||
)
|
||||
return f"https://fonts.googleapis.com/css2?{families}&display=swap"
|
||||
|
||||
|
||||
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": {
|
||||
@@ -39,6 +124,10 @@ STATIC_ASSETS = {
|
||||
"local": f"{LIBS_STATIC_DIR}/flag-icons/{FLAG_ICONS_VERSION}/css/flag-icons.min.css",
|
||||
"cdn": f"https://cdn.jsdelivr.net/gh/lipis/flag-icons@{FLAG_ICONS_VERSION}/css/flag-icons.min.css",
|
||||
},
|
||||
"font_css": {
|
||||
"local": f"{LIBS_STATIC_DIR}/fonts/google-fonts.css",
|
||||
"cdn": google_fonts_css_url(),
|
||||
},
|
||||
"socket_io_js": {
|
||||
"local": f"{LIBS_STATIC_DIR}/socket.io/{SOCKET_IO_VERSION}/socket.io.min.js",
|
||||
"cdn": f"https://cdn.socket.io/{SOCKET_IO_VERSION}/socket.io.min.js",
|
||||
@@ -55,16 +144,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:
|
||||
@@ -87,6 +168,7 @@ def missing_offline_paths() -> list[Path]:
|
||||
LIBS_DIR / f"fontawesome/{FONTAWESOME_VERSION}/webfonts",
|
||||
LIBS_DIR / f"flag-icons/{FLAG_ICONS_VERSION}/flags/4x3",
|
||||
LIBS_DIR / f"flag-icons/{FLAG_ICONS_VERSION}/flags/1x1",
|
||||
LIBS_DIR / "fonts/files",
|
||||
]
|
||||
for directory in required_dirs:
|
||||
if not directory.is_dir() or not any(directory.iterdir()):
|
||||
@@ -106,3 +188,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
|
||||
|
||||
@@ -0,0 +1,537 @@
|
||||
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
|
||||
|
||||
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(_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:
|
||||
item = dict(row)
|
||||
try:
|
||||
item["details"] = json.loads(item.get("details_json") or "{}")
|
||||
except Exception:
|
||||
item["details"] = {}
|
||||
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 get_settings(profile_id: int = 0, user_id: int | None = None) -> dict:
|
||||
user_id = _user_id(user_id)
|
||||
profile_id = int(profile_id or 0)
|
||||
with connect() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT * FROM operation_log_settings WHERE profile_id=? ORDER BY updated_at DESC, user_id ASC LIMIT 1",
|
||||
(profile_id,),
|
||||
).fetchone()
|
||||
if not row:
|
||||
data = {"owner_user_id": user_id, "profile_id": profile_id, **DEFAULT_SETTINGS}
|
||||
else:
|
||||
data = {**DEFAULT_SETTINGS, **dict(row)}
|
||||
data["owner_user_id"] = int(data.pop("user_id", user_id) or user_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)
|
||||
now = utcnow()
|
||||
if not auth.can_write_profile(profile_id, user_id):
|
||||
raise PermissionError("No write access to 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,
|
||||
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, 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, event_type_s, str(severity or "info"), source_s, torrent_hash, torrent_name, action, str(message), _details(details), now),
|
||||
)
|
||||
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_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]
|
||||
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"{_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)
|
||||
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"), "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"), "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"), "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)"]
|
||||
params: list[Any] = [int(profile_id or 0)]
|
||||
if event_type:
|
||||
where.append("event_type=?")
|
||||
params.append(event_type)
|
||||
if hide_jobs:
|
||||
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 ? OR details_json LIKE ?)")
|
||||
like = f"%{q}%"
|
||||
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()
|
||||
total = conn.execute(f"SELECT COUNT(*) AS n FROM operation_logs{sql_where}", tuple(params)).fetchone()["n"]
|
||||
return {"logs": [_row_to_public(r) for r in rows], "total": int(total or 0), "limit": limit, "offset": offset}
|
||||
|
||||
|
||||
def stats(profile_id: int) -> dict:
|
||||
profile_id = int(profile_id or 0)
|
||||
with connect() as conn:
|
||||
total = conn.execute("SELECT COUNT(*) AS n FROM operation_logs WHERE profile_id=? OR profile_id IS NULL", (profile_id,)).fetchone()["n"]
|
||||
by_type = conn.execute("SELECT event_type, COUNT(*) AS n FROM operation_logs WHERE profile_id=? OR profile_id IS NULL GROUP BY event_type ORDER BY n DESC LIMIT 12", (profile_id,)).fetchall()
|
||||
by_day = conn.execute("SELECT substr(created_at,1,10) AS bucket, COUNT(*) AS n FROM operation_logs WHERE profile_id=? OR profile_id IS NULL GROUP BY bucket ORDER BY bucket DESC LIMIT 14", (profile_id,)).fetchall()
|
||||
by_month = conn.execute("SELECT substr(created_at,1,7) AS bucket, COUNT(*) AS n FROM operation_logs WHERE profile_id=? OR profile_id IS NULL GROUP BY bucket ORDER BY bucket DESC LIMIT 12", (profile_id,)).fetchall()
|
||||
top_actions = conn.execute("SELECT COALESCE(action, event_type) AS action, COUNT(*) AS n FROM operation_logs WHERE profile_id=? OR profile_id IS NULL GROUP BY COALESCE(action, event_type) ORDER BY n DESC LIMIT 12", (profile_id,)).fetchall()
|
||||
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:
|
||||
return f"Jobs: {_retention_label_for(settings, 'job')} / Operations: {_retention_label_for(settings, 'operation')}"
|
||||
|
||||
|
||||
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)
|
||||
with connect() as conn:
|
||||
cur = conn.execute("DELETE FROM operation_logs WHERE " + " AND ".join(where), tuple(params))
|
||||
return int(cur.rowcount or 0)
|
||||
|
||||
|
||||
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:
|
||||
now = utcnow()
|
||||
owner_id = int(settings.get("owner_user_id") or _user_id(user_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 profile_id=?
|
||||
""",
|
||||
(now, int(deleted or 0), now, profile_id),
|
||||
)
|
||||
if int(cur.rowcount or 0) == 0:
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO operation_log_settings(user_id, profile_id, created_at, updated_at)
|
||||
VALUES(?,?,?,?)
|
||||
ON CONFLICT(user_id, profile_id) DO UPDATE SET updated_at=excluded.updated_at
|
||||
""",
|
||||
(owner_id, profile_id, now, now),
|
||||
)
|
||||
conn.execute(
|
||||
f"UPDATE operation_log_settings SET {category}_last_retention_run_at=?, {category}_last_retention_deleted=?, updated_at=? WHERE profile_id=?",
|
||||
(now, int(deleted or 0), now, profile_id),
|
||||
)
|
||||
|
||||
|
||||
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:
|
||||
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,109 @@
|
||||
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."""
|
||||
# Note: API routes validate the request first, then return an app URL token instead of exposing stable download URLs in the UI.
|
||||
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."""
|
||||
# Note: The public link is temporary and points to an app route, while streaming still reuses the existing file reader.
|
||||
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."""
|
||||
# Note: File downloads use /download/<token> in the UI, but the backend keeps the same rTorrent streaming logic.
|
||||
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."""
|
||||
# Note: Selected indexes are stored with the token so the final /download route does not need an API body.
|
||||
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."""
|
||||
# Note: The token hides the stable export API URL from browser-visible download actions.
|
||||
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."""
|
||||
# Note: Hashes are copied into the token target after the API validates that the request is non-empty.
|
||||
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."""
|
||||
# Note: Expired links are removed on read so stale browser tabs stop resolving automatically.
|
||||
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
|
||||
@@ -11,10 +11,11 @@ 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 +28,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 +67,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 +81,27 @@ 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]
|
||||
# Note: Safe fallback keeps existing functionality, but prevents very aggressive polling from overloading rTorrent or the browser.
|
||||
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:
|
||||
# Note: Existing installs stored profile poller settings in app_settings; migrate lazily on first read.
|
||||
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 +110,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 +118,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 +140,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 +187,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 +238,69 @@ 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()
|
||||
# Note: Live poller diagnostics track only lightweight speed/status refreshes, not the full torrent snapshot loop.
|
||||
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()
|
||||
# Note: List poller diagnostics are separate because this slower loop runs full torrent snapshot reconciliation.
|
||||
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)
|
||||
# Note: Cleanup resets diagnostic counters only; poller timers and saved settings keep running unchanged.
|
||||
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 +310,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 +350,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,6 +362,24 @@ 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)
|
||||
@@ -241,4 +387,26 @@ def mark_tick(state: ProfilePollState, started_at: float, active: bool, ok: bool
|
||||
|
||||
def snapshot(profile_id: int) -> dict:
|
||||
state = state_for(profile_id)
|
||||
return dict(state.stats or {"profile_id": int(profile_id), "tick_count": state.tick_count})
|
||||
data = dict(state.stats or {"profile_id": int(profile_id), "tick_count": state.tick_count})
|
||||
# Note: Snapshot always exposes split-poller counters, even before the first post-cleanup tick rebuilds full stats.
|
||||
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
|
||||
|
||||
@@ -4,27 +4,28 @@ 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",
|
||||
"adwaita-mono": "Adwaita Mono",
|
||||
"system-ui": "System UI / Apple-like",
|
||||
"figtree": "Figtree",
|
||||
"inter": "Inter",
|
||||
"system-ui": "System UI",
|
||||
"geist": "Geist",
|
||||
"manrope": "Manrope",
|
||||
"dm-sans": "DM Sans",
|
||||
"source-sans-3": "Source Sans 3",
|
||||
"open-sans": "Open Sans",
|
||||
"roboto": "Roboto",
|
||||
"lato": "Lato",
|
||||
"nunito-sans": "Nunito Sans",
|
||||
"poppins": "Poppins",
|
||||
"montserrat": "Montserrat",
|
||||
"ibm-plex-sans": "IBM Plex Sans",
|
||||
"jetbrains-mono": "JetBrains Mono",
|
||||
"adwaita-mono": "Adwaita Mono",
|
||||
}
|
||||
|
||||
# Note: Backend owns the recommended torrent table layout so frontend builds do not duplicate presets.
|
||||
@@ -35,16 +36,19 @@ 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": {
|
||||
"seeds:-1": True, "up_rate:-1": True, "down_rate:-1": True, "progress:-1": True,
|
||||
},
|
||||
"mobileSmartFiltersEnabled": False,
|
||||
"widths": {
|
||||
"select": 44, "name": 389, "status": 83, "size": 75, "progress": 177,
|
||||
"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,
|
||||
},
|
||||
}
|
||||
@@ -54,17 +58,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
|
||||
@@ -80,6 +88,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)
|
||||
@@ -114,6 +131,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
|
||||
|
||||
|
||||
@@ -275,17 +296,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:
|
||||
@@ -293,6 +336,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"):
|
||||
@@ -302,48 +347,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", "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):
|
||||
user_id = user_id or auth.current_user_id() or default_user_id()
|
||||
profile_id = _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")
|
||||
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 = {
|
||||
@@ -361,29 +530,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 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)
|
||||
@@ -399,30 +576,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)
|
||||
|
||||
@@ -5,7 +5,7 @@ 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 +67,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 +95,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 +112,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 +134,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:
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import ipaddress
|
||||
import socket
|
||||
import time
|
||||
from concurrent.futures import ThreadPoolExecutor, wait
|
||||
from threading import Lock
|
||||
from typing import Any
|
||||
|
||||
_CACHE_TTL_SECONDS = 24 * 60 * 60
|
||||
_NEGATIVE_TTL_SECONDS = 60 * 60
|
||||
_CACHE_LIMIT = 2048
|
||||
_LOOKUP_LIMIT_PER_REQUEST = 24
|
||||
_LOOKUP_TIMEOUT_SECONDS = 0.8
|
||||
|
||||
_cache: dict[str, tuple[str, float]] = {}
|
||||
_pending: dict[str, Any] = {}
|
||||
_lock = Lock()
|
||||
_executor = ThreadPoolExecutor(max_workers=3, thread_name_prefix="reverse-dns")
|
||||
|
||||
|
||||
def _is_resolvable_ip(value: str) -> bool:
|
||||
try:
|
||||
ipaddress.ip_address(str(value or "").strip())
|
||||
return True
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
|
||||
def _lookup_host(ip: str) -> str:
|
||||
try:
|
||||
host = socket.gethostbyaddr(ip)[0]
|
||||
return str(host or "").rstrip(".")
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
|
||||
def _trim_cache(now: float) -> None:
|
||||
expired = [ip for ip, (_, expires_at) in _cache.items() if expires_at <= now]
|
||||
for ip in expired:
|
||||
_cache.pop(ip, None)
|
||||
if len(_cache) <= _CACHE_LIMIT:
|
||||
return
|
||||
for ip, _ in sorted(_cache.items(), key=lambda item: item[1][1])[: len(_cache) - _CACHE_LIMIT]:
|
||||
_cache.pop(ip, None)
|
||||
|
||||
|
||||
def _store(ip: str, host: str, now: float | None = None) -> None:
|
||||
now = now or time.monotonic()
|
||||
ttl = _CACHE_TTL_SECONDS if host else _NEGATIVE_TTL_SECONDS
|
||||
_cache[ip] = (host, now + ttl)
|
||||
|
||||
|
||||
def attach_reverse_dns(peers: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
"""Attach cached or newly resolved PTR hostnames to peer rows with a small request budget."""
|
||||
now = time.monotonic()
|
||||
missing: list[str] = []
|
||||
with _lock:
|
||||
_trim_cache(now)
|
||||
for peer in peers:
|
||||
ip = str(peer.get("ip") or "").strip()
|
||||
if not ip or not _is_resolvable_ip(ip):
|
||||
peer["host"] = ""
|
||||
continue
|
||||
cached = _cache.get(ip)
|
||||
if cached and cached[1] > now:
|
||||
peer["host"] = cached[0]
|
||||
continue
|
||||
peer["host"] = ""
|
||||
if ip not in _pending and ip not in missing and len(missing) < _LOOKUP_LIMIT_PER_REQUEST:
|
||||
missing.append(ip)
|
||||
for ip in missing:
|
||||
_pending[ip] = _executor.submit(_lookup_host, ip)
|
||||
futures = list(_pending.items())
|
||||
|
||||
if futures:
|
||||
wait([future for _, future in futures], timeout=_LOOKUP_TIMEOUT_SECONDS)
|
||||
|
||||
done_hosts: dict[str, str] = {}
|
||||
with _lock:
|
||||
now = time.monotonic()
|
||||
for ip, future in list(_pending.items()):
|
||||
if not future.done():
|
||||
continue
|
||||
try:
|
||||
host = str(future.result() or "")
|
||||
except Exception:
|
||||
host = ""
|
||||
_store(ip, host, now)
|
||||
done_hosts[ip] = host
|
||||
_pending.pop(ip, None)
|
||||
|
||||
for peer in peers:
|
||||
ip = str(peer.get("ip") or "").strip()
|
||||
if ip in done_hosts:
|
||||
peer["host"] = done_hosts[ip]
|
||||
elif not peer.get("host") and ip in _pending:
|
||||
peer["host_pending"] = True
|
||||
return peers
|
||||
+12
-13
@@ -8,7 +8,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 +122,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 +135,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 +159,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 +199,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:
|
||||
|
||||
@@ -3,45 +3,387 @@ 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 +396,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 +450,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 +473,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 +483,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 +492,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 +503,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 +518,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 +559,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 +574,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,7 @@
|
||||
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 +64,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 +89,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 +111,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,7 @@
|
||||
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=")
|
||||
@@ -58,10 +59,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 +131,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 +542,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 +658,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 +707,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,5 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
|
||||
from .client import *
|
||||
from .files import set_file_priorities
|
||||
from .system import disk_usage_for_default_path
|
||||
@@ -149,7 +151,8 @@ 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"
|
||||
# Note: rTorrent may report state=1 after a recheck even when the download is not really active yet.
|
||||
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.
|
||||
@@ -197,8 +200,8 @@ def apply_post_check_policy(profile: dict, rows: list[dict], previous_rows: dict
|
||||
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)})
|
||||
@@ -213,9 +216,17 @@ TORRENT_FIELDS = [
|
||||
]
|
||||
|
||||
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.custom1=",
|
||||
]
|
||||
|
||||
|
||||
def human_duration(seconds: int) -> str:
|
||||
# Note: Download ETA is derived locally from remaining bytes and current download speed.
|
||||
@@ -244,7 +255,12 @@ def normalize_row(row: list) -> dict:
|
||||
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
|
||||
# Note: Last activity is optional because older rTorrent builds may not expose this timestamp.
|
||||
last_activity = int(row[23] or 0) if len(row) > 23 else 0
|
||||
if not last_activity and (down_rate > 0 or up_rate > 0):
|
||||
# Note: rTorrent builds without d.timestamp.last_active still expose live rates, so active rows get a safe current timestamp.
|
||||
last_activity = int(time.time())
|
||||
completed_at = 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
|
||||
@@ -267,8 +283,10 @@ def normalize_row(row: list) -> dict:
|
||||
complete = int(row[3] or 0)
|
||||
# Note: d.hashing is authoritative; stale "hash check complete" messages must not keep the UI in Checking forever.
|
||||
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 = bool(state) and not bool(is_active) and not is_checking and not post_check
|
||||
# Note: Post-check is an application-level state that separates torrents waiting after a recheck from manually stopped torrents.
|
||||
status = "Checking" if is_checking else "Post-check" if post_check else "Paused" if is_paused 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 {
|
||||
@@ -300,15 +318,76 @@ 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."""
|
||||
# Note: The live poller intentionally reads only volatile fields so the main list poller can run less often.
|
||||
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)
|
||||
labels = str(row[15] or "")
|
||||
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)
|
||||
is_paused = bool(state) and not bool(is_active) 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 "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,
|
||||
"paused": is_paused,
|
||||
"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:
|
||||
@@ -545,12 +624,16 @@ def _download_runtime_state(c: ScgiRtorrentClient, h: str) -> dict:
|
||||
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)
|
||||
post_check = POST_CHECK_DOWNLOAD_LABEL in _label_names(label) and not bool(active)
|
||||
return {
|
||||
'state': state,
|
||||
'open': opened,
|
||||
'active': active,
|
||||
'paused': bool(state and opened and not active),
|
||||
'paused': bool(state and opened and not active and not post_check),
|
||||
'stopped': not bool(state),
|
||||
'post_check': post_check,
|
||||
'label': label,
|
||||
'message': _str_rpc(c, 'd.message', h),
|
||||
}
|
||||
|
||||
@@ -590,10 +673,14 @@ 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'):
|
||||
result.update({'ok': True, 'skipped': 'already_stopped', 'after': before})
|
||||
return result
|
||||
try:
|
||||
# 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')
|
||||
@@ -643,10 +730,13 @@ def start_or_resume_hash(c: ScgiRtorrentClient, torrent_hash: str, prefer_start:
|
||||
result: dict = {'hash': h, 'before': before, 'commands': []}
|
||||
|
||||
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:
|
||||
if before.get('paused') and not prefer_start and not before.get('post_check'):
|
||||
# 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.
|
||||
@@ -654,6 +744,13 @@ def start_or_resume_hash(c: ScgiRtorrentClient, torrent_hash: str, prefer_start:
|
||||
resumed['mode'] = 'resume_paused'
|
||||
return resumed
|
||||
|
||||
if before.get('post_check'):
|
||||
try:
|
||||
# Note: Post-check start first forces a clean stopped state, matching the manual Stop -> Start recovery path.
|
||||
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 +767,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
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -66,6 +66,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 +137,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 +154,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 +201,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 +217,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,89 +240,113 @@ 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)
|
||||
|
||||
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 _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.
|
||||
with connect() as conn:
|
||||
row = conn.execute(
|
||||
'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 '')
|
||||
|
||||
|
||||
def _record_disabled_waiting_once(profile_id: int, user_id: int, details: dict[str, Any] | None = None) -> bool:
|
||||
"""Record one disabled-state history row until Smart Queue runs or changes state again."""
|
||||
# Note: This keeps the UI audit trail useful without creating repeated disabled logs on every poll.
|
||||
if _latest_history_event(profile_id, user_id) in {'disabled_waiting_start', 'auto_stopped_idle'}:
|
||||
return False
|
||||
payload = {
|
||||
'decision': 'Smart Queue disabled, waiting for start',
|
||||
'enabled': False,
|
||||
**(details or {}),
|
||||
}
|
||||
add_history(profile_id, 'disabled_waiting_start', [], [], 0, payload, user_id)
|
||||
return True
|
||||
|
||||
|
||||
def _excluded_hashes(profile_id: int, user_id: int | None = None) -> set[str]:
|
||||
return {r['torrent_hash'] for r in list_exclusions(profile_id)}
|
||||
|
||||
|
||||
|
||||
@@ -357,9 +391,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:
|
||||
@@ -369,7 +402,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):
|
||||
@@ -383,6 +416,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:
|
||||
@@ -783,9 +837,22 @@ def _is_running_download_slot(t: dict[str, Any]) -> bool:
|
||||
return _is_started_download_slot(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
|
||||
@@ -793,13 +860,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))
|
||||
@@ -825,6 +894,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.
|
||||
@@ -864,7 +955,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]:
|
||||
@@ -915,9 +1006,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)
|
||||
@@ -960,13 +1052,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),
|
||||
@@ -985,6 +1101,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),
|
||||
}
|
||||
@@ -997,6 +1114,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),
|
||||
@@ -1013,6 +1134,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),
|
||||
@@ -1052,24 +1174,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.'}
|
||||
@@ -1082,13 +1436,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)
|
||||
@@ -1096,7 +1455,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:
|
||||
@@ -1105,8 +1464,9 @@ def check(profile: dict | None = None, user_id: int | None = None, force: bool =
|
||||
restored = _cleanup_auto_labels(rtorrent.client_for(profile), profile_id, torrents, set(), True)
|
||||
except Exception:
|
||||
restored = []
|
||||
add_history(profile_id, 'skipped_disabled', [], [], 0, {'enabled': False, 'labels_restored': restored}, user_id)
|
||||
return {'ok': True, 'enabled': False, 'paused': [], 'resumed': [], 'stopped': [], 'started': [], 'labels_restored': restored, 'message': 'Smart Queue disabled'}
|
||||
# Note: Disabled checks are frequent poller passes; record only the first waiting-state row.
|
||||
disabled_log_recorded = _record_disabled_waiting_once(profile_id, user_id, {'labels_restored': restored})
|
||||
return {'ok': True, 'enabled': False, 'paused': [], 'resumed': [], 'stopped': [], 'started': [], 'labels_restored': restored, 'disabled_log_recorded': disabled_log_recorded, 'message': 'Smart Queue disabled, waiting for start'}
|
||||
|
||||
torrents = rtorrent.list_torrents(profile)
|
||||
# Note: Stalled labels block automatic starting only; a manually started Stalled item still counts as a running slot.
|
||||
@@ -1175,6 +1535,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.
|
||||
@@ -1182,9 +1545,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:
|
||||
@@ -1209,31 +1572,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))
|
||||
@@ -1256,6 +1620,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] = []
|
||||
@@ -1269,8 +1644,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 ''))):
|
||||
@@ -1333,10 +1718,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,
|
||||
@@ -1352,6 +1739,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),
|
||||
@@ -1384,9 +1773,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),
|
||||
@@ -1415,9 +1806,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),
|
||||
@@ -1435,4 +1828,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)}
|
||||
|
||||
@@ -2,7 +2,9 @@ from __future__ import annotations
|
||||
|
||||
from threading import RLock
|
||||
from time import time
|
||||
from . import rtorrent
|
||||
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 +35,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:
|
||||
@@ -58,6 +96,8 @@ class TorrentCache:
|
||||
self._data[profile_id] = fresh
|
||||
self._errors[profile_id] = ""
|
||||
self._updated_at[profile_id] = time()
|
||||
if old:
|
||||
operation_logs.record_cache_diff(profile_id, added, removed, updated, old)
|
||||
return {"ok": True, "profile_id": profile_id, "added": added, "updated": updated, "removed": removed, "post_check_changes": post_check_changes}
|
||||
except Exception as exc:
|
||||
with self._lock:
|
||||
|
||||
@@ -19,7 +19,7 @@ _ERROR_PATTERNS = (
|
||||
"unreachable",
|
||||
"denied",
|
||||
)
|
||||
_SUMMARY_TYPES = ("all", "downloading", "seeding", "paused", "checking", "error", "stopped")
|
||||
_SUMMARY_TYPES = ("all", "downloading", "seeding", "paused", "checking", "error", "post_check", "stopped")
|
||||
_summary_cache: dict[int, dict] = {}
|
||||
_summary_lock = RLock()
|
||||
|
||||
@@ -55,9 +55,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
|
||||
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ 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
|
||||
@@ -38,13 +39,15 @@ def _emit_profile(socketio, event: str, payload: dict, profile_id: int) -> None:
|
||||
|
||||
def _run_slow_profile_tasks(socketio, profile: dict, profile_id: int) -> None:
|
||||
state = poller_control.state_for(profile_id)
|
||||
# Note: Background checks keep the profile owner so bypass/admin profiles do not enqueue jobs as the fallback user.
|
||||
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)
|
||||
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"):
|
||||
@@ -55,13 +58,14 @@ def _run_slow_profile_tasks(socketio, profile: dict, profile_id: int) -> None:
|
||||
except Exception as exc:
|
||||
_emit_profile(socketio, "smart_queue_update", {"ok": False, "profile_id": profile_id, "error": str(exc)}, profile_id)
|
||||
try:
|
||||
# Note: Automations are profile-scoped; each queued job still runs as the rule owner.
|
||||
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:
|
||||
@@ -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,47 +115,96 @@ 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"):
|
||||
# Note: Missing or unknown hashes mean the next slow list tick must reconcile rows.
|
||||
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")):
|
||||
@@ -182,9 +235,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:
|
||||
@@ -217,7 +272,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()
|
||||
@@ -234,7 +289,7 @@ def register_socketio_handlers(socketio):
|
||||
|
||||
@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()
|
||||
|
||||
@@ -5,10 +5,12 @@ import threading
|
||||
import time
|
||||
import uuid
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from . import rtorrent, auth, disk_guard
|
||||
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 +26,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):
|
||||
@@ -198,6 +205,8 @@ 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),
|
||||
)
|
||||
# Note: Queued jobs are now written to operation logs so work is visible before a worker starts it.
|
||||
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
|
||||
@@ -216,10 +225,84 @@ 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:
|
||||
# Note: Remove-with-data jobs invalidate disk cache before notifying browsers, otherwise /api/system/disk may return stale values.
|
||||
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)
|
||||
# Note: The browser performs the fresh /api/system/disk read so profile-scoped disk monitor preferences stay respected.
|
||||
_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()
|
||||
# Note: Repeated delete jobs share one delayed refresh per profile and delay, preventing timer storms during bulk cleanup.
|
||||
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)
|
||||
# Note: Worker execution uses the job owner instead of Flask session state.
|
||||
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 +351,43 @@ 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, "summary": cached_summary(profile_id, rows, force=True)})
|
||||
else:
|
||||
_emit("rtorrent_error", diff)
|
||||
except Exception as exc:
|
||||
# Note: A failed live refresh must not change the already completed job result.
|
||||
_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():
|
||||
# Note: rTorrent may expose state changes one poll later than the XML-RPC action result.
|
||||
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 +395,8 @@ 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)
|
||||
# Note: Profile lookup failures used to appear only in the job queue; they are now persisted in operation logs too.
|
||||
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"])
|
||||
@@ -300,15 +417,23 @@ def _run(job_id: str):
|
||||
if not _mark_running(job_id, attempts):
|
||||
return
|
||||
event_meta = _job_event_meta(payload)
|
||||
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})
|
||||
# Note: Remove-with-data jobs ask connected browsers to refresh disk usage immediately after filesystem deletion finishes.
|
||||
action_name = str(job["action"] or "")
|
||||
_emit_disk_refresh_requested(int(profile["id"]), action_name, payload, result or {})
|
||||
# Note: Completed jobs must publish a fresh torrent snapshot/patch so removed or moved torrents disappear without a page reload.
|
||||
_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 {}
|
||||
@@ -319,6 +444,11 @@ def _run(job_id: str):
|
||||
return
|
||||
status = "pending" if attempts < max_attempts else "failed"
|
||||
_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":
|
||||
@@ -365,6 +495,8 @@ 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)
|
||||
# Note: Watchdog timeouts are stored in operation logs because no normal worker exception may be raised.
|
||||
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})
|
||||
|
||||
@@ -392,6 +524,8 @@ def _resubmit_interrupted_running_jobs() -> None:
|
||||
("Resuming interrupted job from last checkpoint", utcnow(), row["id"]),
|
||||
)
|
||||
if int(cur.rowcount or 0):
|
||||
# Note: Interrupted jobs returned to the queue are logged so restart recovery is auditable.
|
||||
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"))
|
||||
|
||||
@@ -413,6 +547,8 @@ 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"]))
|
||||
# Note: Stale pending resubmits are logged to explain duplicated queue attempts after watchdog recovery.
|
||||
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"))
|
||||
|
||||
@@ -518,6 +654,8 @@ def cancel_job(job_id: str) -> bool:
|
||||
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
|
||||
|
||||
@@ -554,6 +692,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
|
||||
@@ -564,6 +703,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
-41
@@ -1,52 +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 { bootstrapSource } from './bootstrap.js';
|
||||
|
||||
export const moduleSources = [
|
||||
stateSource,
|
||||
torrentsSource,
|
||||
mobileSource,
|
||||
messagesSource,
|
||||
torrentAddSource,
|
||||
apiSource,
|
||||
createTorrentSource,
|
||||
torrentDetailsSource,
|
||||
modalsSource,
|
||||
rssSource,
|
||||
smartQueueSource,
|
||||
plannerSource,
|
||||
dashboardSource,
|
||||
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,poller]=await Promise.all([\n fetch('/api/app/status').then(r=>r.json()),\n fetch('/api/poller/settings').then(r=>r.json()).catch(()=>({}))\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 rt=poller.runtime||{}, ps=poller.settings||{};\n // Note: App status now keeps only unique operational diagnostics; storage, jobs, planner and queue details stay in their dedicated tools.\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', ps.adaptive_enabled===false?'off':'on'),\n diagCard('Mode', rt.adaptive_mode||'-'),\n diagCard('Live interval', `${rt.live_stats_interval_seconds ?? ps.live_stats_interval_seconds ?? '-'}s`),\n diagCard('List interval', `${rt.torrent_list_interval_seconds ?? ps.torrent_list_interval_seconds ?? '-'}s`),\n diagCard('Last tick', `${rt.duration_ms||rt.last_tick_ms||0} ms`),\n diagCard('Tick gap', `${rt.last_tick_gap_ms||0} ms`),\n diagCard('Payload', fmtBytes(rt.emitted_payload_size||0)),\n diagCard('rTorrent calls', 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";
|
||||
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 pollerCards=[diagCard('Adaptive', ps.adaptive_enabled===false?'off':'on'), diagCard('Mode', rt.adaptive_mode||'-'), diagCard('Effective interval', `${rt.effective_interval_seconds??'-'}s`), diagCard('Minimum interval', `${rt.configured_min_interval_seconds??'-'}s`), diagCard('Tick duration', `${rt.duration_ms||rt.last_tick_ms||0} ms`), diagCard('Tick gap', `${rt.last_tick_gap_ms||0} ms`), diagCard('Payload', fmtBytes(rt.emitted_payload_size||0)), diagCard('rTorrent calls', 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 ";
|
||||
@@ -0,0 +1 @@
|
||||
export const labelSmartEventsSource = "document.addEventListener('click',async e=>{ const btn=e.target.closest('.delete-label'); if(!btn)return; if(!confirm('Delete this label?')) return; setBusy(true); try{ const r=await fetch('/api/labels/'+btn.dataset.id,{method:'DELETE'}); const j=await r.json(); if(!j.ok) throw new Error(j.error||'Delete failed'); await loadLabels(); toast('Label deleted','success'); }catch(err){toast(err.message,'danger');} finally{setBusy(false);} });\n $('bulkClearBtn')?.addEventListener('click',()=>{selected.clear(); selectedHash=null; lastSelectedHash=null; updateBulkBar(); if($('selectAll')) $('selectAll').checked=false; if($('detailPane')) $('detailPane').innerHTML='Select a torrent.'; setupPeersRefresh('general'); scheduleRender(true);});\n $('smartExcludeSelectedBtn')?.addEventListener('click',openSmartQueueExclusionModal);\n $('smartExclusionSearch')?.addEventListener('input',filterSmartQueueExclusionChoices);\n $('smartExclusionOnlySelected')?.addEventListener('change',filterSmartQueueExclusionChoices);\n $('smartExclusionSelectVisibleBtn')?.addEventListener('click',()=>setSmartQueueVisibleExceptions(true));\n $('smartExclusionClearVisibleBtn')?.addEventListener('click',()=>setSmartQueueVisibleExceptions(false));\n $('smartExclusionSaveBtn')?.addEventListener('click',saveSmartQueueExclusionChoices);\n $('smartHistory')?.addEventListener('click',async e=>{\n const clear=e.target.closest('#smartHistoryClear');\n if(clear){\n // Note: Clear history removes only Smart Queue audit rows for the active profile.\n if(!confirm('Clear Smart Queue history?')) return;\n try{ await post('/api/smart-queue/history',{},'DELETE'); smartHistoryExpanded=false; toast('Smart Queue history cleared','success'); await loadSmartQueue(); }catch(err){ toast(err.message,'danger'); }\n return;\n }\n const btn=e.target.closest('#smartHistoryToggle'); if(!btn) return; smartHistoryExpanded=!smartHistoryExpanded; loadSmartQueue();\n });\n\n // Note: Mobile filter changes are handled by setMobileFilterValue in bootstrap.js to avoid duplicate preference writes.\n ";
|
||||
@@ -0,0 +1 @@
|
||||
export const labelToolsSource = " async function loadLabels(){ const j=await (await fetch('/api/labels')).json(); const labels=j.labels||[]; knownLabels=labels; renderLabelFilters(); renderLabelChooser(); if($('labelsManager')) $('labelsManager').innerHTML=labels.length?labels.map(l=>`<div class=\"label-manager-row\"><span class=\"chip\"><i class=\"fa-solid fa-tag\"></i> ${esc(l.name)}</span><small class=\"text-muted\">Owner: ${esc(l.owner_name||'-')}</small><button class=\"btn btn-xs btn-outline-danger delete-label\" data-id=\"${esc(l.id)}\" title=\"Delete label\"><i class=\"fa-solid fa-trash-can\"></i> Remove</button></div>`).join(''):'<div class=\"empty-state\"><i class=\"fa-solid fa-tags\"></i><b>No labels.</b><span>Add first label above.</span></div>'; }\n function renderLabelChooser(){ if($('selectedLabelList')) $('selectedLabelList').innerHTML=[...modalLabels].map(l=>`<button class=\"chip label-selected\" data-label=\"${esc(l)}\" title=\"Remove\"><i class=\"fa-solid fa-tag\"></i> ${esc(l)} <i class=\"fa-solid fa-xmark ms-1\"></i></button>`).join('') || '<span class=\"text-muted small\">No labels selected.</span>'; if($('labelList')) $('labelList').innerHTML=knownLabels.map(l=>`<button class=\"chip label-chip ${modalLabels.has(l.name)?'active':''}\" data-label=\"${esc(l.name)}\" title=\"Owner: ${esc(l.owner_name||'-')}\"><i class=\"fa-solid fa-tag\"></i> ${esc(l.name)}</button>`).join('') || '<span class=\"text-muted small\">No saved labels.</span>'; }\n async function saveKnownLabel(name){ name=String(name||'').trim(); if(!name) return; await post('/api/labels',{name}); await loadLabels(); }\n";
|
||||
File diff suppressed because one or more lines are too long
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 mobileSelectEventsSource = " document.addEventListener('change',e=>{ const sort=e.target.closest('#mobileSortSelect'); if(sort){ setMobileSortValue(sort.value); return; } const sel=e.target.closest('#mobileFilterSelect'); if(!sel) return; setMobileFilterValue(sel.value); });\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 notificationCenterSource = "function notificationItems(){\n try{ return JSON.parse(localStorage.getItem(NOTIFICATION_STORAGE_KEY)||'[]'); }catch(e){ return []; }\n}\nfunction saveNotificationItems(items){ localStorage.setItem(NOTIFICATION_STORAGE_KEY, JSON.stringify(items.slice(0,120))); }\nfunction recordNotification(type, title, message){\n const item={at:new Date().toISOString(), type:String(type||'info'), title:String(title||type||'Notification'), message:String(message||'')};\n const items=[item,...notificationItems()].slice(0,120);\n saveNotificationItems(items);\n renderNotificationCenter();\n updateNotificationBadge();\n}\nfunction notificationIcon(type){\n if(type==='error') return 'fa-triangle-exclamation';\n if(type==='warning') return 'fa-circle-exclamation';\n if(type==='planner') return 'fa-calendar-days';\n if(type==='queue') return 'fa-shuffle';\n return 'fa-circle-info';\n}\nfunction updateNotificationBadge(){\n const btn=document.querySelector('.tool-tab[data-tool=\"notifications\"]');\n if(!btn) return;\n const count=notificationItems().length;\n btn.innerHTML=`<i class=\"fa-solid fa-bell\"></i> Notifications${count?` <span class=\"badge text-bg-danger\">${count}</span>`:''}`;\n}\nfunction renderNotificationCenter(){\n const box=$('notificationCenterManager');\n if(!box) return;\n const items=notificationItems();\n box.innerHTML=`<div class=\"notification-toolbar\"><button id=\"clearNotificationsBtn\" class=\"btn btn-sm btn-outline-danger\" type=\"button\"><i class=\"fa-solid fa-trash\"></i> Clear</button><span>${esc(items.length)} saved event(s)</span></div><div class=\"notification-list\">${items.map(x=>`<article class=\"notification-item notification-${esc(x.type)}\"><i class=\"fa-solid ${notificationIcon(x.type)}\"></i><div><b>${esc(x.title)}</b><span>${esc(x.message)}</span><small>${esc(new Date(x.at).toLocaleString())}</small></div></article>`).join('')||'<span class=\"empty-mini\">No notifications yet.</span>'}</div>`;\n $('clearNotificationsBtn')?.addEventListener('click',()=>{ saveNotificationItems([]); renderNotificationCenter(); updateNotificationBadge(); });\n}\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 peerRefreshSource = " function setupPeersRefresh(tab=activeTab()){ clearInterval(peersRefreshTimer); peersRefreshTimer=null; if($('peersRefreshSelect')) $('peersRefreshSelect').value=String(peersRefreshSeconds||0); if(tab==='peers' && peersRefreshSeconds>0){ peersRefreshTimer=setInterval(()=>{ if(activeTab()==='peers' && selectedHash) loadDetails('peers',{silent:true,backgroundRefresh:true}); }, peersRefreshSeconds*1000); } }\n function refreshPeersOnceForReverseDns(){\n // Note: Enabling reverse DNS immediately refreshes peers; pending hostnames then use their own follow-up loop.\n if(activeTab()==='peers' && selectedHash) loadDetails('peers');\n const modal=$('mobileDetailsModal');\n if(modal?.classList.contains('show') && selectedHash) openMobileDetails(selectedHash);\n }\n function syncMobileMode(){ const auto=window.matchMedia&&window.matchMedia(\"(max-width: 900px)\").matches; document.body.classList.toggle(\"mobile-mode\", auto || document.body.classList.contains(\"mobile-mode-manual\")); scheduleRender(true); }\n\n\n let automationRulesCache=[];\n let automationConditions=[];\n let automationEffects=[];\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 plannerPreviewHistorySource = "";
|
||||
@@ -0,0 +1 @@
|
||||
export const plannerSettingsSource = " function plannerPayload(){ return {enabled:$('plannerEnabled')?.checked,profile_name:$('plannerProfileName')?.value||'night mode',dry_run:$('plannerDryRun')?.checked,night_only_enabled:$('plannerNightOnly')?.checked,night_start:$('plannerNightStart')?.value||'23:00',night_end:$('plannerNightEnd')?.value||'07:00',quiet_hours_enabled:$('plannerQuietEnabled')?.checked,quiet_start:$('plannerQuietStart')?.value||'22:00',quiet_end:$('plannerQuietEnd')?.value||'06:00',weekday_down:Number($('plannerWeekdayDown')?.value||0),weekday_up:Number($('plannerWeekdayUp')?.value||0),weekend_down:Number($('plannerWeekendDown')?.value||0),weekend_up:Number($('plannerWeekendUp')?.value||0),hourly_schedule_enabled:$('plannerHourlyEnabled')?.checked,hourly_schedule:plannerHourlyPayload(),auto_pause_cpu_enabled:$('plannerCpuEnabled')?.checked,auto_pause_cpu_percent:Number($('plannerCpuPercent')?.value||90),auto_pause_disk_enabled:$('plannerDiskEnabled')?.checked,auto_pause_disk_percent:Number($('plannerDiskPercent')?.value||95),network_protection_enabled:$('plannerNetworkEnabled')?.checked,network_max_down:Number($('plannerNetworkDown')?.value||0),network_max_up:Number($('plannerNetworkUp')?.value||0),load_protection_enabled:$('plannerLoadEnabled')?.checked,load_cpu_percent:Number($('plannerLoadCpu')?.value||95),auto_resume:$('plannerAutoResume')?.checked,auto_resume_grace_seconds:Number($('plannerResumeGrace')?.value||0)}; }\n function plannerOnOff(value){ return value ? 'on' : 'off'; }\n function plannerSummaryValue(label, value){\n return `<span class=\"planner-diagnostic-item\"><b>${esc(label)}:</b> ${esc(value)}</span>`;\n }\n\n // Note: Current Settings intentionally reuses the Poller Diagnostics row structure for matching radius, spacing and typography.\n function updatePlannerCurrentSummary(state={}){\n const box=$('plannerCurrentSummary');\n if(!box) return;\n const enabled=$('plannerEnabled')?.checked ?? !!state.enabled;\n const dryRun=$('plannerDryRun')?.checked;\n const nightStart=$('plannerNightStart')?.value || state.night_start || '--:--';\n const nightEnd=$('plannerNightEnd')?.value || state.night_end || '--:--';\n const quietStart=$('plannerQuietStart')?.value || state.quiet_start || '--:--';\n const quietEnd=$('plannerQuietEnd')?.value || state.quiet_end || '--:--';\n const items=[\n plannerSummaryValue('Status', `${enabled ? 'on' : 'off'}${dryRun ? ' / dry-run' : ''}`),\n plannerSummaryValue('Profile', $('plannerProfileName')?.value || state.profile_name || '-'),\n plannerSummaryValue('Hourly', plannerOnOff($('plannerHourlyEnabled')?.checked)),\n plannerSummaryValue('Night', `${plannerOnOff($('plannerNightOnly')?.checked)} ${nightStart}-${nightEnd}`),\n plannerSummaryValue('Quiet', `${plannerOnOff($('plannerQuietEnabled')?.checked)} ${quietStart}-${quietEnd}`),\n plannerSummaryValue('Protection', `CPU ${plannerOnOff($('plannerCpuEnabled')?.checked)}, disk ${plannerOnOff($('plannerDiskEnabled')?.checked)}, network ${plannerOnOff($('plannerNetworkEnabled')?.checked)}, load ${plannerOnOff($('plannerLoadEnabled')?.checked)}`),\n ];\n box.innerHTML=`<div><b><i class=\"fa-solid fa-sliders\"></i> Current settings</b><small class=\"planner-diagnostic-line\">${items.join('<i class=\"fa-solid fa-circle fa-2xs diagnostic-separator\" aria-hidden=\"true\"></i>')}</small></div>`;\n }\n\n function updatePlannerFooter(enabled,preview={}){ updatePlannerCurrentSummary(preview); const btn=$('statusPlannerOpen'); if(btn){ btn.classList.toggle('d-none',!enabled); btn.classList.toggle('text-warning',!!preview.manual_override_until); btn.title=enabled?`Planner ${preview.matched_rule||'enabled'}${preview.dry_run?' · dry-run':''}`:'Download planner is disabled.'; const span=btn.querySelector('span'); if(span) span.textContent=preview.dry_run?'Planner dry-run':preview.manual_override_until?'Planner paused':'Planner'; } const badge=$('plannerStatusBadge'); if(badge){ badge.className=`badge ${enabled?'text-bg-success':'text-bg-secondary'}`; badge.textContent=enabled?(preview.dry_run?'dry-run':preview.manual_override_until?'override':'enabled'):'off'; } }\n function plannerDateText(value){ if(!value) return '-'; if(typeof value==='number') return formatDateTime(value); const d=new Date(value); return isNaN(d.getTime())?'-':d.toLocaleString(); }\n";
|
||||
@@ -0,0 +1 @@
|
||||
export const plannerSpeedControlsSource = " const plannerMbpsToBytes=mbps=>mbps?Math.round(Number(mbps)*1000000/8):0;\n const plannerBytesToMbps=bytes=>bytes?Math.round(Number(bytes)*8/1000000):0;\n function plannerLimitText(bytes){ const mbps=plannerBytesToMbps(Number(bytes||0)); return mbps?`${mbps} Mbit/s`:'Unlimited'; }\n function plannerHourLabel(hour){ return `${String(hour).padStart(2,'0')}:00-${String((hour+1)%24).padStart(2,'0')}:00`; }\n function renderPlannerHourlyGrid(){\n const box=$('plannerHourlyGrid'); if(!box) return;\n box.innerHTML=Array.from({length:24},(_,hour)=>`<div class=\"planner-hour-row\" data-hour=\"${hour}\"><span>${plannerHourLabel(hour)}</span><input id=\"plannerHour${hour}Down\" class=\"form-control form-control-sm planner-hour-input\" type=\"number\" min=\"0\" step=\"1024\" placeholder=\"DL B/s\"><input id=\"plannerHour${hour}Up\" class=\"form-control form-control-sm planner-hour-input\" type=\"number\" min=\"0\" step=\"1024\" placeholder=\"UL B/s\"><small id=\"plannerHour${hour}Summary\">Unlimited</small></div>`).join('');\n document.querySelectorAll('.planner-hour-input').forEach(input=>input.addEventListener('input',()=>updatePlannerHourSummary(Number(input.closest('.planner-hour-row')?.dataset.hour||0))));\n }\n function updatePlannerHourSummary(hour){ const down=Number($(`plannerHour${hour}Down`)?.value||0), up=Number($(`plannerHour${hour}Up`)?.value||0); const out=$(`plannerHour${hour}Summary`); if(out) out.textContent=`DL ${plannerLimitText(down)} / UL ${plannerLimitText(up)}`; }\n function fillPlannerHours(mbps){ const bytes=plannerMbpsToBytes(mbps); for(let hour=0;hour<24;hour++){ const d=$(`plannerHour${hour}Down`), u=$(`plannerHour${hour}Up`); if(d)d.value=bytes; if(u)u.value=bytes; updatePlannerHourSummary(hour); } }\n function copyPlannerSpeedToHours(prefix){ const down=Number($(`${prefix}Down`)?.value||0), up=Number($(`${prefix}Up`)?.value||0); for(let hour=0;hour<24;hour++){ const d=$(`plannerHour${hour}Down`), u=$(`plannerHour${hour}Up`); if(d)d.value=down; if(u)u.value=up; updatePlannerHourSummary(hour); } }\n function plannerHourlyPayload(){ return Array.from({length:24},(_,hour)=>({hour,down:Number($(`plannerHour${hour}Down`)?.value||0),up:Number($(`plannerHour${hour}Up`)?.value||0)})); }\n function setPlannerSpeed(prefix,mbps){\n const bytes=plannerMbpsToBytes(mbps);\n ['Down','Up'].forEach(dir=>{ const input=$(`${prefix}${dir}`); if(input) input.value=bytes; });\n updatePlannerSpeedControls(prefix);\n }\n function updatePlannerSpeedControls(prefix){\n const down=Number($(`${prefix}Down`)?.value||0), up=Number($(`${prefix}Up`)?.value||0);\n [['Down',down],['Up',up]].forEach(([dir,value])=>{ const slider=$(`${prefix}${dir}Slider`), out=$(`${prefix}${dir}Mbps`); const mbps=plannerBytesToMbps(value); if(slider){ if(mbps>Number(slider.max||0)) slider.max=String(mbps); slider.value=String(mbps); } if(out) out.textContent=plannerLimitText(value); });\n const sum=$(`${prefix}Summary`); if(sum) sum.textContent=`DL ${plannerLimitText(down)} / UL ${plannerLimitText(up)}`;\n }\n function setupPlannerSpeedControls(){\n document.querySelectorAll('.planner-speed-preset').forEach(btn=>btn.addEventListener('click',()=>setPlannerSpeed(btn.dataset.prefix,Number(btn.dataset.mbps||0))));\n document.querySelectorAll('.planner-mbps-slider').forEach(slider=>slider.addEventListener('input',()=>{ const target=$(slider.dataset.target); if(target) target.value=plannerMbpsToBytes(Number(slider.value||0)); const prefix=(slider.dataset.target||'').replace(/(Down|Up)$/,''); updatePlannerSpeedControls(prefix); }));\n document.querySelectorAll('.planner-byte-input').forEach(input=>input.addEventListener('input',()=>updatePlannerSpeedControls(input.id.replace(/(Down|Up)$/,''))));\n }\n";
|
||||
File diff suppressed because one or more lines are too long
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 portCheckActionsSource = " async function loadPortCheck(force=false){ try{ const res=force?await post('/api/port-check',{}):await (await fetch('/api/port-check')).json(); if(!res.ok) throw new Error(res.error||'Port check failed'); renderPortCheck(res.port_check||{}); }catch(e){ renderPortCheck({status:'error',enabled:portCheckEnabled,error:e.message}); } }";
|
||||
@@ -0,0 +1 @@
|
||||
export const preferenceEventsSource = "$('appStatusRefreshBtn')?.addEventListener('click',loadAppStatus); $('portCheckEnabled')?.addEventListener('change',savePortCheckPref); $('portCheckNowBtn')?.addEventListener('click',()=>loadPortCheck(true)); $('bootstrapThemeSelect')?.addEventListener('change',saveAppearancePreferences); $('fontFamilySelect')?.addEventListener('change',saveAppearancePreferences); $('interfaceScaleRange')?.addEventListener('input',e=>applyInterfaceScale(e.target.value)); $('interfaceScaleRange')?.addEventListener('change',saveAppearancePreferences); $('torrentListFontSizeRange')?.addEventListener('input',e=>applyTorrentListFontSize(e.target.value)); $('torrentListFontSizeRange')?.addEventListener('change',saveAppearancePreferences); $('compactTorrentListEnabled')?.addEventListener('change',saveAppearancePreferences); $('resetViewPreferencesBtn')?.addEventListener('click',resetViewPreferences); $('titleSpeedEnabled')?.addEventListener('change',saveTitleSpeedPreference); $('trackerFaviconsEnabled')?.addEventListener('change',saveTrackerFaviconsPreference); $('reverseDnsEnabled')?.addEventListener('change',saveReverseDnsPreference); $('automationToastsEnabled')?.addEventListener('change',saveNotificationPrefs); $('smartQueueToastsEnabled')?.addEventListener('change',saveNotificationPrefs); $('saveEasterEggPrefsBtn')?.addEventListener('click',saveEasterEggPrefs); $('easterEggEnabled')?.addEventListener('change',saveEasterEggPrefs); document.querySelectorAll('.disk-monitor-mode').forEach(input=>input.addEventListener('change',async e=>{ diskMonitorMode=e.target.value||'default'; if(diskMonitorMode==='selected' && !diskMonitorSelectedPath && diskMonitorPaths.length) diskMonitorSelectedPath=diskMonitorPaths[0]; renderDiskMonitorPaths(); await saveDiskMonitorPrefs(); })); $('diskMonitorSelectedPath')?.addEventListener('change',async e=>{ diskMonitorSelectedPath=e.target.value||''; if(diskMonitorSelectedPath) diskMonitorMode='selected'; renderDiskMonitorPaths(); await saveDiskMonitorPrefs(); }); $('addDiskPathBtn')?.addEventListener('click',async()=>{ const p=($('diskMonitorPathInput')?.value||'').trim(); if(!p) return; if(!diskMonitorPaths.includes(p)) diskMonitorPaths.push(p); if(!diskMonitorSelectedPath) diskMonitorSelectedPath=p; if(diskMonitorMode==='default') diskMonitorMode='selected'; if($('diskMonitorPathInput')) $('diskMonitorPathInput').value=''; renderDiskMonitorPaths(); await saveDiskMonitorPrefs(); }); $('diskMonitorPaths')?.addEventListener('click',async e=>{ const use=e.target.closest('.disk-path-select'); if(use){ diskMonitorSelectedPath=use.dataset.path||''; diskMonitorMode='selected'; renderDiskMonitorPaths(); await saveDiskMonitorPrefs(); return; } const btn=e.target.closest('.disk-path-remove'); if(!btn) return; diskMonitorPaths=diskMonitorPaths.filter(p=>p!==btn.dataset.path); if(diskMonitorSelectedPath===btn.dataset.path) diskMonitorSelectedPath=diskMonitorPaths[0]||''; if(diskMonitorMode==='selected' && !diskMonitorSelectedPath) diskMonitorMode='default'; renderDiskMonitorPaths(); await saveDiskMonitorPrefs(); }); $('saveFooterPrefsBtn')?.addEventListener('click',saveFooterPreferences);\n ";
|
||||
@@ -0,0 +1 @@
|
||||
export const preferencesToolsSource = " async function loadPreferences(){\n try{\n const j=await (await fetch(`/api/preferences?_=${Date.now()}`, {cache:'no-store'})).json();\n const prefs=j.preferences||{};\n portCheckEnabled=!!Number(prefs.port_check_enabled ?? portCheckEnabled);\n reverseDnsEnabled=!!Number(prefs.reverse_dns_enabled ?? (reverseDnsEnabled?1:0));\n if($('reverseDnsEnabled')) $('reverseDnsEnabled').checked=reverseDnsEnabled;\n automationToastsEnabled=Number(prefs.automation_toasts_enabled ?? (automationToastsEnabled?1:0))!==0;\n smartQueueToastsEnabled=Number(prefs.smart_queue_toasts_enabled ?? (smartQueueToastsEnabled?1:0))!==0;\n easterEggEnabled=Number(prefs.easter_egg_enabled ?? (easterEggEnabled?1:0))!==0;\n easterEggLoadingImageUrl=String(prefs.easter_egg_loading_image_url ?? easterEggLoadingImageUrl ?? '').trim();\n easterEggClickImageUrl=String(prefs.easter_egg_click_image_url ?? easterEggClickImageUrl ?? '').trim();\n diskMonitorMode=prefs.disk_monitor_mode||diskMonitorMode;\n diskMonitorSelectedPath=prefs.disk_monitor_selected_path||'';\n diskMonitorOwnerLabel=String(prefs.disk_monitor_owner_label||'').trim();\n try{ diskMonitorPaths=JSON.parse(prefs.disk_monitor_paths_json||'[]'); }catch(_){ diskMonitorPaths=[]; }\n bootstrapTheme=prefs.bootstrap_theme||bootstrapTheme;\n fontFamily=prefs.font_family||fontFamily;\n interfaceScale=Number(prefs.interface_scale||interfaceScale||100);\n torrentListFontSize=clampTorrentListFontSize(prefs.torrent_list_font_size||torrentListFontSize||13);\n compactTorrentListEnabled=Number(prefs.compact_torrent_list_enabled ?? (compactTorrentListEnabled?1:0))!==0;\n try{ footerItems={...DEFAULT_FOOTER_ITEMS,...JSON.parse(prefs.footer_items_json||'{}')}; }catch(_){ footerItems={...DEFAULT_FOOTER_ITEMS}; }\n }catch(e){ console.warn('Preference load failed', e); }\n if($('portCheckEnabled')) $('portCheckEnabled').checked=portCheckEnabled; if($('automationToastsEnabled')) $('automationToastsEnabled').checked=automationToastsEnabled; if($('smartQueueToastsEnabled')) $('smartQueueToastsEnabled').checked=smartQueueToastsEnabled; if($('easterEggEnabled')) $('easterEggEnabled').checked=easterEggEnabled; if($('easterEggLoadingImageUrl')) $('easterEggLoadingImageUrl').value=easterEggLoadingImageUrl; if($('easterEggClickImageUrl')) $('easterEggClickImageUrl').value=easterEggClickImageUrl; if($('diskMonitorMode')) $('diskMonitorMode').value=diskMonitorMode; if($('diskMonitorSelectedPath')) $('diskMonitorSelectedPath').value=diskMonitorSelectedPath; renderDiskMonitorPaths(); applyInitialLoaderEasterEgg(); scheduleRender(true); applyBootstrapTheme(bootstrapTheme); applyFontFamily(fontFamily); applyInterfaceScale(interfaceScale); applyTorrentListFontSize(torrentListFontSize); applyCompactTorrentList(compactTorrentListEnabled); renderFooterPreferences(); applyFooterPreferences(); await loadPortCheck(false); }";
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
||||
export const profileFormSource = " function profileFormPayload(){ return {id:$('profileId')?.value||null,name:$('profileName')?.value||'',scgi_url:$('profileUrl')?.value||'',timeout_seconds:$('profileTimeout')?.value||5,max_parallel_jobs:$('profileParallel')?.value||5,light_parallel_jobs:$('jobLightParallel')?.value||4,light_job_timeout_seconds:$('jobLightTimeout')?.value||300,heavy_job_timeout_seconds:$('jobHeavyTimeout')?.value||7200,pending_job_timeout_seconds:$('jobPendingTimeout')?.value||900,is_remote:$('profileRemote')?.checked}; }\n function renderProfileDiagnostics(d={}){ const box=$('profileDiagnosticsResult'); if(!box) return; const status=profileDiagnosticStatusLabel(d.status || (d.ok?'normal':'error')); const cls=profileDiagnosticStatusClass(status); const paths=d.base_paths||{}; const wp=d.write_permissions||{}; const disk=d.free_disk||{}; const firstDisk=Object.values(disk)[0]||{}; const cards=[['Status',`<span class=\"badge text-bg-${cls}\">${esc(status)}</span>`],['rTorrent',esc(d.version||'-')],['Library',esc(d.library_version||'-')],['Response',d.response_time_ms!=null?`${esc(d.response_time_ms)} ms`:'-'],['Slow threshold',d.slow_threshold_ms!=null?`${esc(d.slow_threshold_ms)} ms`:'-'],['Default path',esc(paths.default_directory||'-')],['CWD',esc(paths.cwd||'-')],['Write',esc(Object.values(wp)[0]||'-')],['Free disk',esc(firstDisk.free_h||firstDisk.error||'-')]]; box.classList.remove('text-muted'); box.innerHTML=`<div class=\"profile-diagnostics-grid\">${cards.map(([k,v])=>`<div class=\"profile-diagnostics-card\"><small>${esc(k)}</small><b>${v}</b></div>`).join('')}</div>${d.error?`<div class=\"alert alert-danger mt-2 mb-0\">${esc(d.error)}</div>`:''}`; }\n async function testProfilePayload(payload=null){ const p=payload||profileFormPayload(); const res=await post('/api/profiles/test', p); renderProfileDiagnostics(res.diagnostics||{}); return res.diagnostics||{}; }\n\n function resetProfileForm(){ if($('profileId')) $('profileId').value=''; if($('profileName')) $('profileName').value=''; if($('profileUrl')) $('profileUrl').value=''; if($('profileTimeout')) $('profileTimeout').value='5'; if($('profileParallel')) $('profileParallel').value='5'; if($('profileRemote')) $('profileRemote').checked=false; if($('profileFormTitle')) $('profileFormTitle').textContent='Add profile'; if($('saveProfileBtn')) $('saveProfileBtn').innerHTML='<i class=\"fa-solid fa-plus\"></i> Add profile'; $('cancelProfileEditBtn')?.classList.add('d-none'); }\n function editProfileForm(profile){ if(!profile) return; if($('profileId')) $('profileId').value=profile.id; if($('profileName')) $('profileName').value=profile.name||''; if($('profileUrl')) $('profileUrl').value=profile.scgi_url||''; if($('profileTimeout')) $('profileTimeout').value=profile.timeout_seconds||5; if($('profileParallel')) $('profileParallel').value=profile.max_parallel_jobs||5; if($('profileRemote')) $('profileRemote').checked=!!profile.is_remote; fillJobSettings(profile); if($('profileFormTitle')) $('profileFormTitle').textContent='Edit rTorrent profile'; if($('saveProfileBtn')) $('saveProfileBtn').innerHTML='<i class=\"fa-solid fa-floppy-disk\"></i> Save profile'; $('cancelProfileEditBtn')?.classList.remove('d-none'); $('profileName')?.focus(); }\n";
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user